Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -548,9 +548,27 @@ jobs:
- name: Lint
run: npm run lint

- name: Run aiSettings tests
run: node src/utils/aiApi.test.mjs

- name: Build
run: npm run build

website:
name: Website (script tests)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"

- name: Run website script tests
run: node website/test_toEmbedUrl.mjs

# ── Enforce branch flow: main may only receive PRs from dev ───────────────
# No-op on dev PRs (step skipped -> job succeeds). To allow hotfixes straight
# to main, widen the condition below to also accept a 'hotfix/*' head branch.
Expand All @@ -575,7 +593,7 @@ jobs:
name: CI Summary
runs-on: ubuntu-latest
needs:
[lint, rule-validation, secret-scan, sast-bandit, sca-pip-audit, sbom, container-scan, backend-tests, frontend, enforce-source-branch]
[lint, rule-validation, secret-scan, sast-bandit, sca-pip-audit, sbom, container-scan, backend-tests, frontend, website, enforce-source-branch]
if: always()
steps:
- name: Build summary
Expand All @@ -589,6 +607,7 @@ jobs:
CONTAINER_SCAN: ${{ needs.container-scan.result }}
BACKEND_TESTS: ${{ needs.backend-tests.result }}
FRONTEND: ${{ needs.frontend.result }}
WEBSITE: ${{ needs.website.result }}
ENFORCE_SOURCE: ${{ needs.enforce-source-branch.result }}
run: |
python3 - <<'PYEOF'
Expand All @@ -604,6 +623,7 @@ jobs:
("Container Scan (Trivy, INFRA 1 pending)", os.environ["CONTAINER_SCAN"]),
("Backend Tests (pytest + coverage)", os.environ["BACKEND_TESTS"]),
("Frontend (lint + build)", os.environ["FRONTEND"]),
("Website (script tests)", os.environ["WEBSITE"]),
("Enforce dev to main source", os.environ["ENFORCE_SOURCE"]),
]

Expand Down Expand Up @@ -654,5 +674,6 @@ jobs:
needs.sbom.result != 'success' ||
needs.backend-tests.result != 'success' ||
needs.frontend.result != 'success' ||
needs.website.result != 'success' ||
needs.enforce-source-branch.result != 'success'
run: exit 1
3 changes: 2 additions & 1 deletion api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ def ready():

@app.errorhandler(400)
def bad_request(exc):
return jsonify({"error": "Bad request", "detail": str(exc), "request_id": get_request_id()}), 400
logger.warning("Bad request: %s", exc)
return jsonify({"error": "Bad request", "request_id": get_request_id()}), 400

@app.errorhandler(401)
def unauthorized(exc):
Expand Down
43 changes: 31 additions & 12 deletions api/routes/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,25 @@ def _read_request():
return body, None


_AI_ERROR_MESSAGES = {
503: "AI knowledge base is not available",
400: "Invalid AI request parameters",
502: "AI provider request failed",
}


def _ai_error_response(exc: Exception, status: int, log_context: str):
"""Return a safe, generic error response, logging the real exception server-side.

Never reflect str(exc) back to the client: get_completion() wraps
third-party provider errors (e.g. "Anthropic request failed: {exc}"),
which could carry response bodies or connection details from the
underlying HTTP client that shouldn't reach the caller.
"""
logger.error("%s: %s", log_context, exc)
return jsonify({"error": _AI_ERROR_MESSAGES.get(status, "AI request failed")}), status


@ai_bp.post("/api/ai/insights")
@rate_limit(_AI_RATE_LIMIT)
def insights():
Expand Down Expand Up @@ -223,7 +242,7 @@ def ai_summary():
try:
context, sources = _context_for(findings_text)
except VectorStoreNotBuilt as exc:
return jsonify({"error": str(exc)}), 503
return _ai_error_response(exc, 503, "Vector store unavailable in ai_summary")

prompt = (
"You are a cloud security advisor. Using ONLY the grounded knowledge "
Expand All @@ -234,9 +253,9 @@ def ai_summary():
try:
answer = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model"))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return _ai_error_response(exc, 400, "Invalid request in ai_summary")
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 502
return _ai_error_response(exc, 502, "Provider failure in ai_summary")

return jsonify(
{
Expand All @@ -262,7 +281,7 @@ def ai_prioritise():
try:
context, sources = _context_for(findings_text)
except VectorStoreNotBuilt as exc:
return jsonify({"error": str(exc)}), 503
return _ai_error_response(exc, 503, "Vector store unavailable in ai_prioritise")

prompt = (
"You are a cloud security advisor. Using ONLY the grounded knowledge "
Expand All @@ -275,9 +294,9 @@ def ai_prioritise():
try:
raw = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model"))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return _ai_error_response(exc, 400, "Invalid request in ai_prioritise")
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 502
return _ai_error_response(exc, 502, "Provider failure in ai_prioritise")

try:
prioritised = json.loads(raw)
Expand Down Expand Up @@ -307,7 +326,7 @@ def ai_ask():
try:
context, sources = _context_for(question)
except VectorStoreNotBuilt as exc:
return jsonify({"error": str(exc)}), 503
return _ai_error_response(exc, 503, "Vector store unavailable in ai_ask")

findings = body.get("findings", [])
findings_text = _findings_to_text(findings) if findings else "Not provided."
Expand All @@ -322,9 +341,9 @@ def ai_ask():
try:
answer = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model"))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return _ai_error_response(exc, 400, "Invalid request in ai_ask")
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 502
return _ai_error_response(exc, 502, "Provider failure in ai_ask")

return jsonify(
{
Expand Down Expand Up @@ -352,15 +371,15 @@ def ai_threat_simulation():
try:
context, sources = _context_for(findings_text)
except VectorStoreNotBuilt as exc:
return jsonify({"error": str(exc)}), 503
return _ai_error_response(exc, 503, "Vector store unavailable in ai_threat_simulation")

prompt = _build_threat_simulation_prompt(findings_text, context)
try:
raw = get_completion(body["provider"], body["api_key"], prompt, model=body.get("model"))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return _ai_error_response(exc, 400, "Invalid request in ai_threat_simulation")
except RuntimeError as exc:
return jsonify({"error": str(exc)}), 502
return _ai_error_response(exc, 502, "Provider failure in ai_threat_simulation")

try:
simulation = json.loads(raw)
Expand Down
5 changes: 3 additions & 2 deletions api/routes/compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ def get_compliance(framework: str):

return jsonify(result)
except FileNotFoundError as exc:
return jsonify({"error": f"Frameworks directory not found: {exc}"}), 500
logger.error("Frameworks directory not found: %s", exc)
return jsonify({"error": "Compliance frameworks are not available"}), 500
except Exception as exc:
logger.error("Failed to retrieve compliance score for %s: %s", framework, exc)
return jsonify({"error": "Compliance calculation failed", "detail": str(exc)}), 500
return jsonify({"error": "Compliance calculation failed"}), 500
2 changes: 1 addition & 1 deletion api/routes/drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,4 @@ def _rg(resource_id: str) -> str:

except Exception as exc:
logger.error("Failed to compute drift: %s", exc)
return jsonify({"error": "Failed to retrieve drift", "detail": str(exc)}), 500
return jsonify({"error": "Failed to retrieve drift"}), 500
6 changes: 3 additions & 3 deletions api/routes/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def list_findings():
return jsonify({"count": len(findings), "findings": findings})
except Exception as exc:
logger.error("Failed to list findings: %s", exc)
return jsonify({"error": "Failed to retrieve findings", "detail": str(exc)}), 500
return jsonify({"error": "Failed to retrieve findings"}), 500


@findings_bp.get("/api/findings/<int:finding_id>")
Expand All @@ -57,7 +57,7 @@ def get_finding(finding_id: int):
return jsonify(finding)
except Exception as exc:
logger.error("Failed to get finding %d: %s", finding_id, exc)
return jsonify({"error": "Database error", "detail": str(exc)}), 500
return jsonify({"error": "Database error"}), 500


@findings_bp.get("/api/findings/<int:finding_id>/playbook")
Expand Down Expand Up @@ -126,4 +126,4 @@ def get_playbook(finding_id: int):

except Exception as exc:
logger.error("Failed to get playbook for finding %d: %s", finding_id, exc)
return jsonify({"error": "Failed to retrieve playbook", "detail": str(exc)}), 500
return jsonify({"error": "Failed to retrieve playbook"}), 500
2 changes: 1 addition & 1 deletion api/routes/prioritization.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,4 @@ def get_prioritization():

except Exception as exc:
logger.error("Failed to build prioritization: %s", exc)
return jsonify({"error": "Failed to retrieve prioritization", "detail": str(exc)}), 500
return jsonify({"error": "Failed to retrieve prioritization"}), 500
2 changes: 1 addition & 1 deletion api/routes/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,4 @@ def get_resources():

except Exception as exc:
logger.error("Failed to build resources: %s", exc)
return jsonify({"error": "Failed to retrieve resources", "detail": str(exc)}), 500
return jsonify({"error": "Failed to retrieve resources"}), 500
10 changes: 5 additions & 5 deletions api/routes/scans.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def list_scans():
return jsonify(result)
except Exception as exc:
logger.error("Failed to list scans: %s", exc)
return jsonify({"error": "Failed to retrieve scans", "detail": str(exc)}), 500
return jsonify({"error": "Failed to retrieve scans"}), 500


@scans_bp.get("/api/scans/<scan_id>")
Expand All @@ -46,7 +46,7 @@ def get_scan_status(scan_id):
return jsonify(scan)
except Exception as exc:
logger.error("Failed to get scan status: %s", exc)
return jsonify({"error": "Database error", "detail": str(exc)}), 500
return jsonify({"error": "Database error"}), 500


@scans_bp.post("/api/scans/trigger")
Expand All @@ -73,15 +73,15 @@ def trigger_scan():
db.create_pending_scan(scan_id, subscription_id)
except Exception as exc:
logger.error("Failed to create pending scan: %s", exc, exc_info=True)
return jsonify({"error": "Database error", "detail": str(exc)}), 500
return jsonify({"error": "Database error"}), 500

return jsonify(
{"scan_id": scan_id, "status": "pending", "message": "Scan has been queued and will start shortly."}
), 202

except Exception as exc:
logger.error("Critical error in trigger_scan route: %s", exc, exc_info=True)
return jsonify({"error": "Critical route failure", "detail": str(exc)}), 500
return jsonify({"error": "Critical route failure"}), 500


def _run_enrichment_in_background(scan_id: str, findings: list, db_url: str) -> None:
Expand Down Expand Up @@ -162,4 +162,4 @@ def enrich_scan(scan_id):

except Exception as exc:
logger.error("Failed to start enrichment for scan %s: %s", scan_id, exc)
return jsonify({"error": "Internal server error", "detail": str(exc)}), 500
return jsonify({"error": "Internal server error"}), 500
4 changes: 2 additions & 2 deletions api/routes/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def get_score():
return jsonify(result)
except Exception as exc:
logger.error("Failed to calculate score: %s", exc)
return jsonify({"error": "Failed to calculate score", "detail": str(exc)}), 500
return jsonify({"error": "Failed to calculate score"}), 500


@score_bp.get("/api/score/cve-summary")
Expand All @@ -46,4 +46,4 @@ def get_cve_summary():
return jsonify(result)
except Exception as exc:
logger.error("Failed to fetch CVE summary: %s", exc)
return jsonify({"error": "Failed to fetch CVE summary", "detail": str(exc)}), 500
return jsonify({"error": "Failed to fetch CVE summary"}), 500
22 changes: 15 additions & 7 deletions frontend/src/utils/aiApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,27 @@ const API_BASE = import.meta.env.VITE_API_URL
|| (import.meta.env.DEV ? 'http://localhost:5000' : 'https://openshield-api.onrender.com');
const TIMEOUT = 30000;

// ── Provider settings stored in localStorage ───────────────────────────────
// ── Provider settings ───────────────────────────────────────────────────────
// The API key is the user's own bring-your-own AI provider credential. It is
// kept in memory only for the life of the page — never written to
// localStorage/sessionStorage, which any script running on the page can read
// (XSS-exfiltrable) and which persists indefinitely. The trade-off is that
// the key does not survive a page reload; provider/model (not secret) still
// persist in localStorage as before.
let apiKeyInMemory = '';

export const aiSettings = {
getProvider: () => localStorage.getItem('ai_provider') || 'anthropic',
getApiKey: () => localStorage.getItem('ai_api_key') || '',
getApiKey: () => apiKeyInMemory,
getModel: () => localStorage.getItem('ai_model') || '',
save: ({ provider, apiKey, model }) => {
if (provider) localStorage.setItem('ai_provider', provider);
if (apiKey !== undefined) /* key storage removed */;
if (model !== undefined) localStorage.setItem('ai_model', model || '');
if (provider) localStorage.setItem('ai_provider', provider);
if (apiKey !== undefined) apiKeyInMemory = apiKey;
if (model !== undefined) localStorage.setItem('ai_model', model || '');
},
isConfigured: () => !!localStorage.getItem('ai_api_key'),
isConfigured: () => !!apiKeyInMemory,
clear: () => {
localStorage.removeItem('ai_api_key');
apiKeyInMemory = '';
localStorage.removeItem('ai_provider');
localStorage.removeItem('ai_model');
},
Expand Down
Loading
Loading