diff --git a/.github/ISSUE_TEMPLATE/new_rule.md b/.github/ISSUE_TEMPLATE/new_rule.md index 36065743..0ac755d8 100644 --- a/.github/ISSUE_TEMPLATE/new_rule.md +++ b/.github/ISSUE_TEMPLATE/new_rule.md @@ -8,7 +8,7 @@ assignees: '' ## Rule Details - Rule ID: AZ-XXX-000 -- Severity: HIGH / MEDIUM / LOW +- Severity: CRITICAL / HIGH / MEDIUM / LOW / INFO - Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes / PostQuantum - Frameworks: CIS / NIST / ISO 27001 / SOC 2 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2da9e0de..e615bcac 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ ## Rule details (if applicable) - Rule ID: AZ-XXX-000 -- Severity: HIGH / MEDIUM / LOW +- Severity: CRITICAL / HIGH / MEDIUM / LOW / INFO - Category: Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes - Frameworks mapped: CIS / NIST / ISO 27001 / SOC 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fd1fe77..d9c697f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,10 +93,10 @@ jobs: import importlib.util import sys from collections import defaultdict + from openshield.severity import CANONICAL_SEVERITIES rules_dir = "scanner/rules" required_fields = ["RULE_ID", "SEVERITY", "FRAMEWORKS"] - valid_severities = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"} failures = [] seen_ids = defaultdict(list) @@ -127,9 +127,10 @@ jobs: failures.append(f"{filename}: missing field '{field}'") if hasattr(mod, "SEVERITY"): - if mod.SEVERITY not in valid_severities: + if mod.SEVERITY not in CANONICAL_SEVERITIES: failures.append( - f"{filename}: SEVERITY '{mod.SEVERITY}' not in {valid_severities}" + f"{filename}: SEVERITY '{mod.SEVERITY}' not in " + f"{sorted(CANONICAL_SEVERITIES)}" ) if hasattr(mod, "FRAMEWORKS"): @@ -619,6 +620,9 @@ jobs: - name: Run dashboard load-state tests run: node src/hooks/usePageData.test.mjs + - name: Run severity contract tests + run: npm run test:severity + - name: Run accessibility and internationalization checks run: npm run test:a11y && npm run test:i18n diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 702a02a0..76acd585 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -74,8 +74,8 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} run: python scripts/render_deploy_preflight.py - # Creation and polling are separate so both exact deployment IDs are - # retained and independently monitored. POST creation is never retried. + # The API must become live before a new worker is created. API startup + # owns schema migration, and the worker for this SHA may require it. - name: Create API deployment id: create_api env: @@ -85,18 +85,8 @@ jobs: GITHUB_SHA: ${{ github.sha }} run: python scripts/render_deploy.py create - - name: Create worker deployment - id: create_worker - env: - RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} - RENDER_SERVICE_ID: ${{ env.RENDER_WORKER_SERVICE_ID }} - RENDER_SERVICE_NAME: worker - GITHUB_SHA: ${{ github.sha }} - run: python scripts/render_deploy.py create - - name: Wait for API deployment id: wait_api - continue-on-error: true env: RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} RENDER_SERVICE_ID: ${{ env.RENDER_API_SERVICE_ID }} @@ -105,6 +95,15 @@ jobs: GITHUB_SHA: ${{ github.sha }} run: python scripts/render_deploy.py wait + - name: Create worker deployment + id: create_worker + env: + RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + RENDER_SERVICE_ID: ${{ env.RENDER_WORKER_SERVICE_ID }} + RENDER_SERVICE_NAME: worker + GITHUB_SHA: ${{ github.sha }} + run: python scripts/render_deploy.py create + - name: Wait for worker deployment id: wait_worker continue-on-error: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9233250a..a8d7f698 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ from typing import Any, Dict, List RULE_ID = "AZ-STOR-001" RULE_NAME = "Public Blob Access Enabled on Storage Account" -SEVERITY = "HIGH" # HIGH / MEDIUM / LOW / INFO +SEVERITY = "HIGH" # CRITICAL / HIGH / MEDIUM / LOW / INFO CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault / Kubernetes FRAMEWORKS = {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} DESCRIPTION = ( @@ -90,6 +90,8 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: That's it. One file, one rule. +Choose severity using the versioned [finding severity contract](docs/severity-contract.md). Rule files must use a canonical contract value; aliases such as `INFORMATIONAL` are accepted at API boundaries but are not valid rule declarations. + ### Step 4 - Add a Remediation Playbook Create the matching fix in `playbooks/cli/`: diff --git a/alembic/versions/d8e4f6a1b2c3_severity_contract_v1.py b/alembic/versions/d8e4f6a1b2c3_severity_contract_v1.py new file mode 100644 index 00000000..e36c7656 --- /dev/null +++ b/alembic/versions/d8e4f6a1b2c3_severity_contract_v1.py @@ -0,0 +1,100 @@ +"""Enforce severity contract v1 and repair historical scan scores. + +Revision ID: d8e4f6a1b2c3 +Revises: c7a2e9f1b3d4 +Create Date: 2026-08-21 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# Revision identifiers, used by Alembic. +revision: str = "d8e4f6a1b2c3" +down_revision: Union[str, Sequence[str], None] = "c7a2e9f1b3d4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_CONSTRAINT = "ck_findings_severity_v1" + + +def upgrade() -> None: + """Normalize known aliases, reject unknown data, constrain and rescore.""" + op.execute( + """ + DO $$ + DECLARE invalid_values text; + BEGIN + SELECT string_agg(value, ', ' ORDER BY value) + INTO invalid_values + FROM ( + SELECT DISTINCT UPPER(BTRIM(severity)) AS value + FROM findings + WHERE UPPER(BTRIM(severity)) NOT IN + ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO', 'INFORMATIONAL') + ) invalid; + + IF invalid_values IS NOT NULL THEN + RAISE EXCEPTION 'Cannot apply severity contract v1; unsupported values: %', invalid_values; + END IF; + END $$; + """ + ) + op.execute( + """ + UPDATE findings + SET severity = CASE UPPER(BTRIM(severity)) + WHEN 'INFORMATIONAL' THEN 'INFO' + ELSE UPPER(BTRIM(severity)) + END + WHERE severity <> CASE UPPER(BTRIM(severity)) + WHEN 'INFORMATIONAL' THEN 'INFO' + ELSE UPPER(BTRIM(severity)) + END + """ + ) + op.add_column( + "scans", + sa.Column( + "severity_contract_version", + sa.Text(), + nullable=True, + ), + ) + op.execute( + """ + ALTER TABLE findings + ADD CONSTRAINT ck_findings_severity_v1 + CHECK (severity IN ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO')) + NOT VALID + """ + ) + op.execute("ALTER TABLE findings VALIDATE CONSTRAINT ck_findings_severity_v1") + op.execute( + """ + UPDATE scans AS scan + SET score = GREATEST( + 0, + 100 - COALESCE(( + SELECT SUM(CASE finding.severity + WHEN 'CRITICAL' THEN 20 + WHEN 'HIGH' THEN 10 + WHEN 'MEDIUM' THEN 5 + WHEN 'LOW' THEN 2 + WHEN 'INFO' THEN 0 + END) + FROM findings AS finding + WHERE finding.scan_id = scan.scan_id + ), 0) + ), + severity_contract_version = '1.0.0' + WHERE scan.status = 'completed'; + """ + ) + + +def downgrade() -> None: + """Remove the v1 constraint; corrected historical scores remain corrected.""" + op.drop_constraint(_CONSTRAINT, "findings", type_="check") + op.drop_column("scans", "severity_contract_version") diff --git a/api/models/finding.py b/api/models/finding.py index 2b5c1cc6..0f366924 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -12,6 +12,14 @@ import psycopg2.extras import psycopg2.pool +from openshield.severity import ( + CONTRACT_VERSION, + normalize_severity, + score_counts, + score_findings, + severity_rank, +) + logger = logging.getLogger(__name__) FRAMEWORKS_DIR = Path(__file__).parent.parent.parent / "compliance" / "frameworks" @@ -38,8 +46,6 @@ def _get_pool(dsn: str) -> "psycopg2.pool.ThreadedConnectionPool": return pool -SEVERITY_WEIGHTS = {"HIGH": 10, "MEDIUM": 5, "LOW": 2, "INFO": 0} - FRAMEWORK_FILE_MAP = { "cis": "cis_azure_benchmark.json", "nist": "nist_csf.json", @@ -159,75 +165,98 @@ def init_db(self) -> None: def save_scan(self, scan_result: Dict[str, Any]) -> None: """Persist a full scan result (scan header + all findings).""" - conn = self._get_conn() from datetime import datetime, timezone + # Validate and canonicalize the entire batch before issuing SQL. A bad + # severity must never be stored with a zero/default weight. + findings = [] + for raw_finding in scan_result.get("findings", []): + finding = dict(raw_finding) + finding["severity"] = normalize_severity(finding.get("severity")) + findings.append(finding) + + conn = self._get_conn() completed_at = scan_result.get("completed_at") or datetime.now(timezone.utc).isoformat() - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO scans ( - scan_id, subscription_id, started_at, completed_at, - total_findings, score, cve_enrichment_status, status, - attempt_count, error_message - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (scan_id) DO UPDATE SET - completed_at = EXCLUDED.completed_at, - total_findings = EXCLUDED.total_findings, - score = EXCLUDED.score, - status = EXCLUDED.status, - error_message = EXCLUDED.error_message - """, - ( - scan_result["scan_id"], - scan_result["subscription_id"], - scan_result["started_at"], - completed_at, - scan_result.get("total_findings", 0), - scan_result.get("score"), - scan_result.get("cve_enrichment_status", "PENDING"), - scan_result.get("status", "completed"), - scan_result.get("attempt_count", 0), - scan_result.get("error_message"), - ), - ) - for f in scan_result.get("findings", []): + try: + with conn.cursor() as cur: cur.execute( """ - INSERT INTO findings - (scan_id, rule_id, rule_name, severity, category, - resource_id, resource_name, resource_type, - description, remediation, playbook, - frameworks, metadata, cve_references, - cvss_score, exploit_available, detected_at) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + INSERT INTO scans ( + scan_id, subscription_id, started_at, completed_at, + total_findings, score, cve_enrichment_status, status, + attempt_count, error_message, severity_contract_version + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (scan_id) DO UPDATE SET + completed_at = EXCLUDED.completed_at, + total_findings = EXCLUDED.total_findings, + score = EXCLUDED.score, + status = EXCLUDED.status, + error_message = EXCLUDED.error_message, + severity_contract_version = EXCLUDED.severity_contract_version """, ( - f.get("scan_id"), - f.get("rule_id"), - f.get("rule_name"), - f.get("severity"), - f.get("category"), - f.get("resource_id"), - f.get("resource_name"), - f.get("resource_type"), - f.get("description"), - f.get("remediation"), - f.get("playbook"), - json.dumps(f.get("frameworks", {})), - json.dumps(f.get("metadata", {})), - json.dumps(f.get("cve_references", [])), - f.get("cvss_score"), - f.get("exploit_available", False), - f.get("detected_at"), + scan_result["scan_id"], + scan_result["subscription_id"], + scan_result["started_at"], + completed_at, + len(findings), + score_findings(findings), + scan_result.get("cve_enrichment_status", "PENDING"), + scan_result.get("status", "completed"), + scan_result.get("attempt_count", 0), + scan_result.get("error_message"), + CONTRACT_VERSION, ), ) - conn.commit() + # A worker retry replaces the previous result atomically. This + # keeps the scan header, child rows, and recomputed score in + # agreement instead of duplicating findings on every attempt. + cur.execute("DELETE FROM findings WHERE scan_id = %s", (scan_result["scan_id"],)) + for f in findings: + cur.execute( + """ + INSERT INTO findings + (scan_id, rule_id, rule_name, severity, category, + resource_id, resource_name, resource_type, + description, remediation, playbook, + frameworks, metadata, cve_references, + cvss_score, exploit_available, detected_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + """, + ( + # The parent scan owns every child in this batch. + # Never trust a caller-supplied child scan_id. + scan_result["scan_id"], + f.get("rule_id"), + f.get("rule_name"), + f.get("severity"), + f.get("category"), + f.get("resource_id"), + f.get("resource_name"), + f.get("resource_type"), + f.get("description"), + f.get("remediation"), + f.get("playbook"), + json.dumps(f.get("frameworks", {})), + json.dumps(f.get("metadata", {})), + json.dumps(f.get("cve_references", [])), + f.get("cvss_score"), + f.get("exploit_available", False), + f.get("detected_at"), + ), + ) + conn.commit() + except Exception: + # psycopg2 connections remain in an aborted transaction after any + # SQL error. Roll back here so the worker can record failure and + # safely process subsequent scans on the same pooled connection. + conn.rollback() + raise logger.info( "Saved scan %s with %d findings", scan_result["scan_id"], - scan_result["total_findings"], + len(findings), ) # ------------------------------------------------------------------ # @@ -238,7 +267,7 @@ def get_findings(self, filters: Optional[Dict[str, Any]] = None) -> List[Dict[st """Return findings, optionally filtered by severity, category, or rule_id.""" filters = filters or {} severity = filters.get("severity") - severity = severity.upper() if severity is not None else None + severity = normalize_severity(severity) if severity is not None else None category = filters.get("category") rule_id = filters.get("rule_id") scan_id = filters.get("scan_id") @@ -473,7 +502,8 @@ def get_score(self) -> int: Scoped to the most recent scan so historical findings from older scans do not accumulate and drive the score to zero. - HIGH findings deduct 10 points each, MEDIUM 5, LOW 2. Floors at 0. + CRITICAL findings deduct 20 points each, HIGH 10, MEDIUM 5, + LOW 2, and INFO 0. Floors at 0. """ conn = self._get_conn() with conn.cursor() as cur: @@ -489,8 +519,7 @@ def get_score(self) -> int: ) rows = cur.fetchall() - deduction = sum(SEVERITY_WEIGHTS.get(sev.upper(), 0) * count for sev, count in rows) - return max(0, 100 - deduction) + return score_counts({severity: count for severity, count in rows}) def get_cve_summary(self) -> Dict[str, Any]: """Return high-level summary of CVE findings for the dashboard.""" @@ -555,28 +584,52 @@ def get_compliance_score(self, framework: str) -> Dict[str, Any]: controls = framework_data.get("controls", {}) - # Get rule IDs that fired in the latest completed scan only + # Get failure detail from the latest completed scan only. This does + # not change the legacy absence-implies-PASS behavior tracked by #263; + # it prevents the frontend from inventing MEDIUM for failed controls. conn = self._get_conn() with conn.cursor() as cur: cur.execute( """ - SELECT DISTINCT rule_id FROM findings + SELECT rule_id, severity, category, COUNT(*) + FROM findings WHERE scan_id = ( SELECT scan_id FROM scans WHERE status = 'completed' ORDER BY started_at DESC LIMIT 1 ) + GROUP BY rule_id, severity, category """ ) - failed_rule_ids = {row[0] for row in cur.fetchall()} + finding_rows = cur.fetchall() + + failures: Dict[str, Dict[str, Any]] = {} + for rule_id, raw_severity, category, resource_count in finding_rows: + severity = normalize_severity(raw_severity) + current = failures.get(rule_id) + if current is None: + failures[rule_id] = { + "severity": severity, + "category": category, + "resources": resource_count, + } + continue + current["resources"] += resource_count + if severity_rank(severity) > severity_rank(current["severity"]): + current["severity"] = severity + current["category"] = category results = [] for rule_id, control in controls.items(): - status = "FAIL" if rule_id in failed_rule_ids else "PASS" + failure = failures.get(rule_id) + status = "FAIL" if failure else "PASS" results.append( { "rule_id": rule_id, "control_id": control["control_id"], "control_name": control["control_name"], "status": status, + "severity": failure["severity"] if failure else None, + "category": failure["category"] if failure else None, + "resources": failure["resources"] if failure else 0, } ) diff --git a/api/routes/ai.py b/api/routes/ai.py index 7f9673c5..3907957f 100644 --- a/api/routes/ai.py +++ b/api/routes/ai.py @@ -22,26 +22,17 @@ require_json_object, ) from ai.retriever import retrieve, VectorStoreNotBuilt +from openshield.severity import severity_rank as contract_severity_rank ai_bp = Blueprint("ai", __name__) logger = logging.getLogger(__name__) _AI_RATE_LIMIT = 20 # requests per minute per client IP, per endpoint -_SEVERITY_RANK = { - "CRITICAL": 5, - "HIGH": 4, - "MEDIUM": 3, - "LOW": 2, - "INFORMATIONAL": 1, - "INFO": 1, -} - -SEVERITY_ORDER = {"CRITICAL": -1, "HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3, "INFORMATIONAL": 3} - def severity_rank(finding: dict) -> int: - return _SEVERITY_RANK.get(str(finding.get("severity", "")).upper(), 0) + value = finding.get("severity") + return contract_severity_rank(value) if value not in (None, "") else -1 def _build_summary_prompt(findings: list) -> str: @@ -139,7 +130,8 @@ def _build_threat_simulation_prompt(findings_text: str, context: str) -> str: def _findings_to_text(findings): ordered = sorted( findings, - key=lambda f: SEVERITY_ORDER.get(str(f.get("severity", "")).upper(), 4), + key=severity_rank, + reverse=True, ) lines = [] for i, f in enumerate(ordered, 1): diff --git a/api/routes/findings.py b/api/routes/findings.py index 040bc439..4325ba1e 100644 --- a/api/routes/findings.py +++ b/api/routes/findings.py @@ -9,13 +9,12 @@ from api.validation import ( CATEGORIES, RULE_ID_RE, - SEVERITIES, VALIDATION_ERROR_MESSAGE, ValidationError, bounded_string, canonical_choice, - choice, positive_integer, + severity_value, uuid_string, ) @@ -40,7 +39,7 @@ def list_findings(): """Return findings, optionally filtered by severity, category, or rule_id. Query parameters: - severity - HIGH | MEDIUM | LOW | INFO + severity - CRITICAL | HIGH | MEDIUM | LOW | INFO category - Storage | Network | Identity | Database | Compute | KeyVault rule_id - e.g. AZ-STOR-001 scan_id - UUID of a specific scan @@ -56,7 +55,7 @@ def list_findings(): filters = {} if "severity" in request.args: - filters["severity"] = choice(request.args["severity"], "severity", SEVERITIES, case="upper") + filters["severity"] = severity_value(request.args["severity"]) if "category" in request.args: filters["category"] = canonical_choice(request.args["category"], "category", CATEGORIES) if "rule_id" in request.args: diff --git a/api/routes/prioritization.py b/api/routes/prioritization.py index d19fe3d0..e138d9c0 100644 --- a/api/routes/prioritization.py +++ b/api/routes/prioritization.py @@ -4,7 +4,13 @@ import os from flask import Blueprint, g, jsonify -from api.models.finding import DatabaseManager, SEVERITY_WEIGHTS +from api.models.finding import DatabaseManager +from openshield.severity import ( + normalize_severity, + severity_rank, + severity_risk_score, + severity_weight, +) prioritization_bp = Blueprint("prioritization", __name__) logger = logging.getLogger(__name__) @@ -24,19 +30,19 @@ _EFFORT_ETA = {1: "15 mins", 2: "1 hour", 3: "1 day", 4: "1 week"} _EFFORT_LABEL = {1: "LOW", 2: "MEDIUM", 3: "HIGH", 4: "HIGH"} -# 1-10 risk score per severity for the matrix -_RISK_SCORE = {"HIGH": 8, "MEDIUM": 5, "LOW": 2, "INFO": 1} - # Composite score threshold → impact label -def _impact(score: int) -> str: +def _impact(score: int, finding_severity: str) -> str: if score >= 40: - return "CRITICAL" - if score >= 20: - return "HIGH" - if score >= 10: - return "MEDIUM" - return "LOW" + aggregate_impact = "CRITICAL" + elif score >= 20: + aggregate_impact = "HIGH" + elif score >= 10: + aggregate_impact = "MEDIUM" + else: + aggregate_impact = "LOW" + severity = normalize_severity(finding_severity) + return max((aggregate_impact, severity), key=severity_rank) def _get_db() -> DatabaseManager: @@ -86,24 +92,31 @@ def get_prioritization(): ) rules = cur.fetchall() - cur.execute("SELECT COUNT(*) AS total FROM findings WHERE scan_id = %s", (latest_scan_id,)) - total_findings = cur.fetchone()["total"] + cur.execute( + """ + SELECT UPPER(severity) AS severity, COUNT(*) AS count + FROM findings + WHERE scan_id = %s + GROUP BY UPPER(severity) + """, + (latest_scan_id,), + ) + severity_rows = cur.fetchall() matrix = [] rankings = [] - action_items = [] - severity_counts: dict = {} + remediation_by_rule = {} + severity_counts = {normalize_severity(row["severity"]): row["count"] for row in severity_rows} + total_findings = sum(severity_counts.values()) for idx, rule in enumerate(rules): - sev = (rule["severity"] or "LOW").upper() + sev = normalize_severity(rule["severity"]) cat = rule["category"] or "Other" effort = _EFFORT.get(cat, _DEFAULT_EFFORT) - weight = SEVERITY_WEIGHTS.get(sev, 2) + weight = severity_weight(sev) affected = rule["affected_count"] score = weight * affected - risk = _RISK_SCORE.get(sev, 2) - - severity_counts[sev] = severity_counts.get(sev, 0) + affected + risk = severity_risk_score(sev) matrix.append( { @@ -128,33 +141,35 @@ def get_prioritization(): "severity": sev, "category": cat, "effort": effort, - "impact": _impact(score), + "impact": _impact(score, sev), "resource": rule["resource_name"], } ) - - # Top 10 rules → action items - if len(action_items) < 10: - action_items.append( - { - "id": idx + 1, - "action": rule["remediation"] or f"Remediate {rule['rule_name']}", - "impact": _impact(score), - "effort": _EFFORT_LABEL.get(effort, "MEDIUM"), - "eta": _EFFORT_ETA.get(effort, "1 hour"), - "rule_id": rule["rule_id"], - "resource": rule["resource_name"], - } - ) + remediation_by_rule[rule["rule_id"]] = rule["remediation"] # Sort rankings by score desc and re-assign ranks rankings.sort(key=lambda r: r["score"], reverse=True) for i, r in enumerate(rankings): r["rank"] = i + 1 - critical = severity_counts.get("HIGH", 0) + action_items = [ + { + "id": ranking["rank"], + "action": remediation_by_rule[ranking["rule_id"]] or f"Remediate {ranking['name']}", + "impact": ranking["impact"], + "effort": _EFFORT_LABEL.get(ranking["effort"], "MEDIUM"), + "eta": _EFFORT_ETA.get(ranking["effort"], "1 hour"), + "rule_id": ranking["rule_id"], + "resource": ranking["resource"], + } + for ranking in rankings[:10] + ] + + critical = severity_counts.get("CRITICAL", 0) total_hours = sum( - _EFFORT.get(r["category"], _DEFAULT_EFFORT) for r in matrix if r["severity"] in ("HIGH", "MEDIUM") + _EFFORT.get(r["category"], _DEFAULT_EFFORT) + for r in matrix + if r["severity"] in ("CRITICAL", "HIGH", "MEDIUM") ) estimated_time = f"{total_hours} hours" if total_hours < 24 else f"{total_hours // 8} days" diff --git a/api/routes/resources.py b/api/routes/resources.py index 56a0309e..26583b16 100644 --- a/api/routes/resources.py +++ b/api/routes/resources.py @@ -5,6 +5,7 @@ from flask import Blueprint, g, jsonify from api.models.finding import DatabaseManager +from openshield.severity import LEVELS, severity_from_rank, severity_rank_sql resources_bp = Blueprint("resources", __name__) logger = logging.getLogger(__name__) @@ -52,36 +53,33 @@ def get_resources(): } ) + rank_expression = severity_rank_sql("severity") cur.execute( - """ + f""" SELECT resource_id, resource_name, resource_type, category, MIN(detected_at) AS discovered_at, - MAX(CASE severity - WHEN 'HIGH' THEN 3 - WHEN 'MEDIUM' THEN 2 - WHEN 'LOW' THEN 1 - ELSE 0 END) AS risk_rank + MAX({rank_expression}) AS risk_rank FROM findings WHERE scan_id = %s GROUP BY resource_id, resource_name, resource_type, category ORDER BY risk_rank DESC, resource_name - """, + """, # nosec B608 - expression is generated from the repository-owned contract (str(latest_scan["scan_id"]),), ) rows = cur.fetchall() - rank_to_risk = {3: "HIGH", 2: "MEDIUM", 1: "LOW", 0: "NONE"} by_category: dict = {} - by_risk_level: dict = {"HIGH": 0, "MEDIUM": 0, "LOW": 0, "NONE": 0} + by_risk_level: dict = {level.id: 0 for level in LEVELS} + by_risk_level["NONE"] = 0 resources = [] for row in rows: sub_id, rg = _parse_resource_id(row["resource_id"]) - risk = rank_to_risk.get(row["risk_rank"], "NONE") + risk = severity_from_rank(row["risk_rank"]) detected = row["discovered_at"] discovered_at = detected.isoformat() if hasattr(detected, "isoformat") else str(detected) diff --git a/api/routes/score.py b/api/routes/score.py index 22d157da..77481267 100644 --- a/api/routes/score.py +++ b/api/routes/score.py @@ -25,8 +25,8 @@ def get_score(): """Return the overall security posture score (0-100). Score calculation: - Starts at 100. Deducts 10 per HIGH finding, 5 per MEDIUM, 2 per LOW. - Floors at 0. + Starts at 100. Deducts 20 per CRITICAL finding, 10 per HIGH, + 5 per MEDIUM, 2 per LOW, and 0 per INFO. Floors at 0. """ try: db = _get_db() diff --git a/api/validation.py b/api/validation.py index f2f93cf7..cb578b9e 100644 --- a/api/validation.py +++ b/api/validation.py @@ -6,6 +6,8 @@ import uuid from typing import Any, Iterable +from openshield.severity import ACCEPTED_SEVERITIES, SeverityContractError, normalize_severity + class ValidationError(ValueError): """Raised when a client-controlled value violates the public API contract.""" @@ -17,7 +19,7 @@ class ValidationError(ValueError): RULE_ID_RE = re.compile(r"^[A-Z0-9]+(?:-[A-Z0-9]+)*$") MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$") -SEVERITIES = frozenset({"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO", "INFORMATIONAL"}) +SEVERITIES = ACCEPTED_SEVERITIES CATEGORIES = frozenset( { "Backup", @@ -96,6 +98,15 @@ def canonical_choice(value: Any, field: str, allowed: Iterable[str]) -> str: raise ValidationError(f"Unsupported {field}") from exc +def severity_value(value: Any, field: str = "severity") -> str: + """Validate a public severity value and return its canonical ID.""" + bounded_string(value, field, maximum=128) + try: + return normalize_severity(value) + except SeverityContractError as exc: + raise ValidationError(f"Unsupported {field}") from exc + + def uuid_string(value: Any, field: str) -> str: result = bounded_string(value, field, maximum=36) try: @@ -146,5 +157,8 @@ def findings_list(value: Any, *, required: bool = False) -> list[dict[str, Any]] raise ValidationError( f"findings[{index}].{key} must be a string of at most {MAX_FINDING_TEXT_LENGTH} characters" ) - validated.append(finding) + normalized = dict(finding) + if normalized.get("severity") is not None: + normalized["severity"] = severity_value(normalized["severity"], f"findings[{index}].severity") + validated.append(normalized) return validated diff --git a/contracts/severity.v1.json b/contracts/severity.v1.json new file mode 100644 index 00000000..344a14a9 --- /dev/null +++ b/contracts/severity.v1.json @@ -0,0 +1,54 @@ +{ + "contract": "openshield.finding-severity", + "version": "1.0.0", + "aliases": { + "INFORMATIONAL": "INFO" + }, + "levels": [ + { + "id": "CRITICAL", + "rank": 4, + "score_weight": 20, + "risk_score": 10, + "label": "Critical", + "color": "#b91c1c", + "tone": "critical" + }, + { + "id": "HIGH", + "rank": 3, + "score_weight": 10, + "risk_score": 8, + "label": "High", + "color": "#ef4444", + "tone": "danger" + }, + { + "id": "MEDIUM", + "rank": 2, + "score_weight": 5, + "risk_score": 5, + "label": "Medium", + "color": "#f97316", + "tone": "warning" + }, + { + "id": "LOW", + "rank": 1, + "score_weight": 2, + "risk_score": 2, + "label": "Low", + "color": "#10b981", + "tone": "success" + }, + { + "id": "INFO", + "rank": 0, + "score_weight": 0, + "risk_score": 1, + "label": "Info", + "color": "#6b7280", + "tone": "neutral" + } + ] +} diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index dc0ed2fb..b93ab33e 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes. RULE_NAME = "Human-readable name" # Shown in the dashboard and reports. -SEVERITY = "HIGH" # HIGH | MEDIUM | LOW | INFO +SEVERITY = "HIGH" # CRITICAL | HIGH | MEDIUM | LOW | INFO CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault | Kubernetes FRAMEWORKS = { "CIS": "3.5", # CIS Azure Benchmark control ID @@ -100,7 +100,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | Field | What to write | |---|---| | `RULE_ID` | `AZ-[CATEGORY]-[NUMBER]`. Prefix map: STOR, NET, IDN, DB, CMP, KV. Look at existing rules for the next number. | -| `SEVERITY` | `HIGH` = direct exploitation risk, `MEDIUM` = indirect or partial risk, `LOW` = best practice, `INFO` = informational only | +| `SEVERITY` | Use the canonical [finding severity contract](severity-contract.md): `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `INFO` | | `CATEGORY` | Matches the resource type being scanned | | `FRAMEWORKS` | Use real CIS, NIST, and ISO 27001 control IDs. SOC 2 is mapped in `compliance/frameworks/soc2.json`. | | `DESCRIPTION` | Focus on WHY it matters — what is the real-world attack scenario? | diff --git a/docs/api-reference.md b/docs/api-reference.md index 1d5fbbfe..ed431f15 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -57,7 +57,7 @@ Query parameters: | Name | Description | |---|---| -| `severity` | `HIGH`, `MEDIUM`, `LOW`, or `INFO` | +| `severity` | `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `INFO` (`INFORMATIONAL` is normalized to `INFO`) | | `category` | Rule category, such as `Storage`, `Network`, `Identity`, `Database`, `Compute`, or `Key Vault` | | `rule_id` | Rule ID, such as `AZ-STOR-001` | | `scan_id` | UUID of a specific scan | @@ -219,7 +219,7 @@ Missing subscription response: ## GET /api/score -Returns the overall security posture score from 0 to 100. The score starts at 100 and deducts 10 per HIGH finding, 5 per MEDIUM finding, and 2 per LOW finding. +Returns the overall security posture score from 0 to 100. Under [severity contract v1](severity-contract.md), the score starts at 100 and deducts 20 per CRITICAL finding, 10 per HIGH finding, 5 per MEDIUM finding, and 2 per LOW finding. INFO findings deduct zero. Query parameters: none @@ -294,7 +294,7 @@ Example response: "summary": { "total": 12, "by_category": { "Storage": 3, "Network": 4, "Identity": 3, "Database": 2 }, - "by_risk_level": { "HIGH": 4, "MEDIUM": 6, "LOW": 2 }, + "by_risk_level": { "CRITICAL": 1, "HIGH": 3, "MEDIUM": 6, "LOW": 2, "INFO": 0, "NONE": 0 }, "last_scan_at": "2026-06-03T15:12:51Z" }, "resources": [ diff --git a/docs/architecture.md b/docs/architecture.md index cbf8a71e..85dc4ef4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,7 +138,7 @@ Every finding returned by a rule must conform to this schema: { "rule_id": str, # e.g. "AZ-STOR-001" "rule_name": str, - "severity": str, # HIGH | MEDIUM | LOW | INFO + "severity": str, # CRITICAL | HIGH | MEDIUM | LOW | INFO "category": str, # Storage | Network | Identity | Database | Compute | Key Vault "resource_id": str, # full Azure resource ID "resource_name": str, @@ -184,7 +184,8 @@ run_scan() → CVE enrichment via NVD API (cve_references, cvss_score, exploit_available) → db.save_scan(result) # persists to PostgreSQL → scans row: scan_id, subscription_id, started_at, completed_at, - total_findings, score (severity-weighted 0-100) + total_findings, score (severity-weighted 0-100), + severity_contract_version → findings rows: one per finding with full metadata + CVE fields → return scan result JSON @@ -197,7 +198,7 @@ GET /api/findings → returns { count, findings[] } GET /api/score - → db.get_score() # severity-weighted: HIGH -10, MEDIUM -5, LOW -2 + → db.get_score() # contract v1: CRITICAL -20, HIGH -10, MEDIUM -5, LOW -2 → returns plain integer (e.g. 18) GET /api/resources diff --git a/docs/severity-contract.md b/docs/severity-contract.md new file mode 100644 index 00000000..4a4239df --- /dev/null +++ b/docs/severity-contract.md @@ -0,0 +1,31 @@ +# Finding Severity Contract + +OpenShield uses [`contracts/severity.v1.json`](../contracts/severity.v1.json) as the single semantic source for finding severity. Scanner rules, persistence, API validation, scoring, resource risk, prioritization, Sentinel export, frontend filters, charts, and Tailwind colors consume this contract through the Python or JavaScript adapter. The frontend commits a byte-equivalent generated mirror under `frontend/src/generated/` because its Vercel project root is `frontend/`; `npm run test:severity` rejects any drift. + +## Version 1.0.0 + +| Severity | Rank | Posture deduction per finding | Matrix risk | Meaning | +|---|---:|---:|---:|---| +| `CRITICAL` | 4 | 20 | 10 | Immediate exploitation or catastrophic business-impact risk | +| `HIGH` | 3 | 10 | 8 | Direct, material security risk | +| `MEDIUM` | 2 | 5 | 5 | Indirect or partial security risk | +| `LOW` | 1 | 2 | 2 | Security hardening or best-practice gap | +| `INFO` | 0 | 0 | 1 | Informational evidence that does not reduce the posture score | + +`INFORMATIONAL` is accepted only as an input alias and is persisted and returned as `INFO`. Unknown values are rejected. `NONE` is a resource-view sentinel, not a finding severity. Evaluation states such as `PASS`, `UNKNOWN`, or `NOT_APPLICABLE` are also not severities. + +The v1 posture score is `max(0, 100 - sum(finding deductions))`. This model only describes severity arithmetic. Issue #263 tracks evidence completeness; a future coverage-aware score must not present incomplete collection or rule execution as a clean result. + +Prioritization may raise an impact label when many affected resources compound risk, but it must never lower the label below the finding's canonical severity. + +## Changing the contract + +Severity meaning is a public data contract. A change to an ID, alias, rank, weight, risk score, label, tone, or color requires all of the following in one coordinated release: + +1. Add a new immutable contract file (for example, `severity.v2.json`), run `npm run sync:severity` in `frontend/`, and update both adapters. Do not edit v1 semantics in place after release. +2. Add an Alembic migration that inventories existing values, explicitly maps supported legacy values, rejects unknown data, updates the database constraint, and backfills stored scores and the contract version. +3. Update scanner, API, Sentinel, frontend, documentation, and contract tests together. No consumer may maintain a fallback severity order or weight map. +4. Drain scanner workers and validate the migration against a production-sized staging copy before rollout. Document the expected score changes and rollback limitations. Contract provenance is nullable so any legacy worker result that lands during a rollout cannot be mislabeled as v1. +5. Deploy and wait for the migration-owning API before creating the worker deployment. Then verify scanner/database score parity plus every severity-facing API and dashboard view. + +Downgrades cannot truthfully restore scores that were previously calculated with incorrect semantics. Treat a score repair as an auditable data correction and retain the contract version on each scan. diff --git a/docs/validation/FRONTEND_API_TESTING.md b/docs/validation/FRONTEND_API_TESTING.md index 4b3ba19d..09f98b4f 100644 --- a/docs/validation/FRONTEND_API_TESTING.md +++ b/docs/validation/FRONTEND_API_TESTING.md @@ -35,7 +35,7 @@ This guide validates the **frontend/API/database integration** of OpenShield. It | Frontend Page | API Helper Function | Backend Endpoint | Method | Auth Required | Expected Data Source | Current Status | Validation Notes | |---|---|---|---|---|---|---|---| | All (health check) | `api.health()` | `GET /health` | GET | No | None (static response) | Registered in `app.py` | Always returns `{"status":"ok"}` | -| Monitoring | `api.getScore()` | `GET /api/score` | GET | No (public GET) | Computed from findings | Registered (`score_bp`) | Score = 100 - (HIGH*10) - (MEDIUM*5) - (LOW*2) | +| Monitoring | `api.getScore()` | `GET /api/score` | GET | No (public GET) | Computed from findings | Registered (`score_bp`) | Contract v1 score = 100 - (CRITICAL*20) - (HIGH*10) - (MEDIUM*5) - (LOW*2) | | Monitoring | `api.getCVESummary()` | `GET /api/score/cve-summary` | GET | No (public GET) | DB + CVE correlation | Registered (`score_bp`) | Returns null on failure (try/catch) | | Monitoring, Discovery, Scan, AI | `api.getFindings()` | `GET /api/findings` | GET | No (public GET) | Database (findings+rules) | Registered (`findings_bp`) | Supports ?severity, ?category, ?rule_id filters | | DetailedScan | `api.getFinding(id)` | `GET /api/findings/:id` | GET | No (public GET) | Database | Registered (`findings_bp`) | Returns 404 if not found | diff --git a/docs/validation/SCANNER_VALIDATION.md b/docs/validation/SCANNER_VALIDATION.md index 10d93665..e519b699 100644 --- a/docs/validation/SCANNER_VALIDATION.md +++ b/docs/validation/SCANNER_VALIDATION.md @@ -68,7 +68,7 @@ The expected finding fields are: |---|---| | `rule_id` | Stable OpenShield rule ID, for example `AZ-STOR-001` | | `rule_name` | Human-readable rule title | -| `severity` | Severity label such as `HIGH`, `MEDIUM`, `LOW`, or `INFO` | +| `severity` | Canonical severity: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `INFO` | | `category` | Rule category such as `Storage`, `Network`, or `Key Vault` | | `resource_id` | Full Azure resource ID when available | | `resource_name` | Azure resource name | diff --git a/frontend/API_ENDPOINTS.txt b/frontend/API_ENDPOINTS.txt index 79cc1d61..369ac039 100644 --- a/frontend/API_ENDPOINTS.txt +++ b/frontend/API_ENDPOINTS.txt @@ -1,5 +1,6 @@ OPENSHIELD FRONTEND API ENDPOINT REFERENCE Last verified: 2026-08-18 +Finding severity source: contracts/severity.v1.json This file describes the API contract used by the React frontend. The source of truth is the implementation in frontend/src/utils/api.js, @@ -137,7 +138,7 @@ GET /api/findings Supported query parameters (each may appear at most once): - severity HIGH, MEDIUM, LOW, or INFO + severity CRITICAL, HIGH, MEDIUM, LOW, or INFO category A supported rule category rule_id A rule ID such as AZ-STOR-001 scan_id Canonical scan UUID @@ -234,7 +235,7 @@ has findings. The highest finding severity becomes each resource's risk value. "summary": { "total": 1, "by_category": { "Storage": 1 }, - "by_risk_level": { "HIGH": 1, "MEDIUM": 0, "LOW": 0, "NONE": 0 }, + "by_risk_level": { "CRITICAL": 0, "HIGH": 1, "MEDIUM": 0, "LOW": 0, "INFO": 0, "NONE": 0 }, "last_scan_at": "" }, "resources": [ diff --git a/frontend/package.json b/frontend/package.json index 87822b65..2f37c61b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "lint": "eslint . --max-warnings=0", "test:i18n": "node src/i18n/messages.test.mjs", "test:a11y": "node scripts/accessibility-check.mjs", + "sync:severity": "node scripts/sync-severity-contract.mjs", + "test:severity": "node scripts/sync-severity-contract.mjs --check && node --test src/utils/severity.test.mjs", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/scripts/sync-severity-contract.mjs b/frontend/scripts/sync-severity-contract.mjs new file mode 100644 index 00000000..45ab797f --- /dev/null +++ b/frontend/scripts/sync-severity-contract.mjs @@ -0,0 +1,17 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +const canonicalUrl = new URL('../../contracts/severity.v1.json', import.meta.url); +const generatedUrl = new URL('../src/generated/severity.v1.json', import.meta.url); +const canonical = JSON.parse(await readFile(canonicalUrl, 'utf8')); +const expected = `${JSON.stringify(canonical, null, 2)}\n`; + +if (process.argv.includes('--check')) { + const generated = await readFile(generatedUrl, 'utf8'); + if (generated !== expected) { + throw new Error( + 'frontend severity contract is stale; run npm run sync:severity and commit the result', + ); + } +} else { + await writeFile(generatedUrl, expected, 'utf8'); +} diff --git a/frontend/src/components/compliance/ComplianceTable.jsx b/frontend/src/components/compliance/ComplianceTable.jsx index dc04f647..528c8b8e 100644 --- a/frontend/src/components/compliance/ComplianceTable.jsx +++ b/frontend/src/components/compliance/ComplianceTable.jsx @@ -28,7 +28,9 @@ export default function ComplianceTable({ controls }) { {c.id} {c.name} - + + {c.severity ? : } + {c.category}
diff --git a/frontend/src/components/discovery/ResourceFilter.jsx b/frontend/src/components/discovery/ResourceFilter.jsx index acb1651f..6f1c38f0 100644 --- a/frontend/src/components/discovery/ResourceFilter.jsx +++ b/frontend/src/components/discovery/ResourceFilter.jsx @@ -1,18 +1,37 @@ import { FiSearch, FiX, FiLayers, FiList } from 'react-icons/fi'; +import { SEVERITY_DEFINITIONS } from '../../utils/severity'; + +const RISK_TEXT_STYLES = { + critical: 'text-severity-critical', + danger: 'text-severity-high', + warning: 'text-severity-medium', + success: 'text-severity-low', + neutral: 'text-severity-info', +}; + +const RISK_ACTIVE_TONE_STYLES = { + critical: 'bg-severity-critical text-white', + danger: 'bg-severity-high text-white', + warning: 'bg-severity-medium text-white', + success: 'bg-severity-low text-white', + neutral: 'bg-severity-info text-white', +}; const RISK_PILLS = [ { value: 'ACTIVE', label: 'Active Issues', color: 'text-text-primary dark:text-text-dark-primary' }, - { value: 'HIGH', label: 'HIGH', color: 'text-severity-high' }, - { value: 'MEDIUM', label: 'MEDIUM', color: 'text-severity-medium' }, - { value: 'LOW', label: 'LOW', color: 'text-severity-low' }, + ...SEVERITY_DEFINITIONS.map((level) => ({ + value: level.id, + label: level.id, + color: RISK_TEXT_STYLES[level.tone], + })), { value: 'CLEAN', label: 'Clean Only', color: 'text-brand-primary' }, ]; const RISK_ACTIVE_STYLES = { ACTIVE: 'bg-text-primary dark:bg-text-dark-primary text-bg-primary dark:text-bg-dark-primary', - HIGH: 'bg-severity-high text-white', - MEDIUM: 'bg-severity-medium text-white', - LOW: 'bg-severity-low text-white', + ...Object.fromEntries( + SEVERITY_DEFINITIONS.map((level) => [level.id, RISK_ACTIVE_TONE_STYLES[level.tone]]), + ), CLEAN: 'bg-brand-primary text-white', }; diff --git a/frontend/src/components/discovery/ResourceSummary.jsx b/frontend/src/components/discovery/ResourceSummary.jsx index 2c3de8a8..d5ee3735 100644 --- a/frontend/src/components/discovery/ResourceSummary.jsx +++ b/frontend/src/components/discovery/ResourceSummary.jsx @@ -17,6 +17,7 @@ export default function ResourceSummary({ summary, activeCategory, onCategoryCli const stats = [ { label: 'Total Resources', value: summary.total, sub: 'across all categories' }, + { label: 'Critical Risk', value: summary.byRiskLevel?.CRITICAL || 0, sub: 'require immediate action', color: 'text-severity-critical' }, { label: 'High Risk', value: summary.byRiskLevel?.HIGH || 0, sub: 'require immediate action', color: 'text-severity-high' }, { label: 'Medium Risk', value: summary.byRiskLevel?.MEDIUM || 0, sub: 'need attention', color: 'text-severity-medium' }, { label: 'Clean', value: summary.byRiskLevel?.NONE || 0, sub: 'no issues found', color: 'text-brand-primary' }, @@ -24,7 +25,7 @@ export default function ResourceSummary({ summary, activeCategory, onCategoryCli return (
-
+
{stats.map(({ label, value, sub, color }) => (

{label}

diff --git a/frontend/src/components/drift/DriftFilters.jsx b/frontend/src/components/drift/DriftFilters.jsx index 0a53ef00..a3354e59 100644 --- a/frontend/src/components/drift/DriftFilters.jsx +++ b/frontend/src/components/drift/DriftFilters.jsx @@ -1,6 +1,8 @@ +import { SEVERITY_IDS } from '../../utils/severity'; + const TYPES = ['All', 'ADDED', 'REMOVED', 'MODIFIED']; -const SEVERITIES = ['All', 'HIGH', 'MEDIUM', 'LOW']; +const SEVERITIES = ['All', ...SEVERITY_IDS]; export default function DriftFilters({ filters, onChange }) { const set = (key, val) => onChange({ ...filters, [key]: val }); diff --git a/frontend/src/components/monitoring/ResourceGroupChart.jsx b/frontend/src/components/monitoring/ResourceGroupChart.jsx index 4e840d17..879ca933 100644 --- a/frontend/src/components/monitoring/ResourceGroupChart.jsx +++ b/frontend/src/components/monitoring/ResourceGroupChart.jsx @@ -3,7 +3,9 @@ import { Tooltip, Legend, ResponsiveContainer, } from 'recharts'; -const COLORS = { HIGH: '#ef4444', MEDIUM: '#f97316', LOW: '#10b981' }; +import { SEVERITY_DEFINITIONS } from '../../utils/severity'; + +const DISPLAY_LEVELS = SEVERITY_DEFINITIONS.filter((level) => level.score_weight > 0); const CustomTooltip = ({ active, payload, label }) => { if (!active || !payload?.length) return null; @@ -33,9 +35,17 @@ export default function ResourceGroupChart({ data }) { } /> - - - + {DISPLAY_LEVELS.map((level, index) => ( + + ))} ); diff --git a/frontend/src/components/monitoring/StatCards.jsx b/frontend/src/components/monitoring/StatCards.jsx index a1812da9..e33bfaf1 100644 --- a/frontend/src/components/monitoring/StatCards.jsx +++ b/frontend/src/components/monitoring/StatCards.jsx @@ -4,13 +4,14 @@ import Card from '../shared/Card'; export default function StatCards({ stats }) { const cards = [ { label: 'Total Findings', value: stats.totalFindings, Icon: FiLayers, color: 'text-status-info', bg: 'bg-blue-50 dark:bg-blue-900/20' }, - { label: 'Critical Issues', value: stats.criticalIssues, Icon: FiAlertCircle, color: 'text-severity-high', bg: 'bg-red-50 dark:bg-red-900/20' }, + { label: 'Critical Issues', value: stats.criticalIssues, Icon: FiAlertCircle, color: 'text-severity-critical', bg: 'bg-red-100 dark:bg-red-950/40' }, + { label: 'High Risk', value: stats.highRisk, Icon: FiAlertCircle, color: 'text-severity-high', bg: 'bg-red-50 dark:bg-red-900/20' }, { label: 'Medium Risk', value: stats.mediumRisk, Icon: FiAlertTriangle, color: 'text-severity-medium', bg: 'bg-orange-50 dark:bg-orange-900/20' }, { label: 'Low Priority', value: stats.lowPriority, Icon: FiInfo, color: 'text-severity-low', bg: 'bg-green-50 dark:bg-green-900/20' }, ]; return ( -
+
{cards.map(({ label, value, Icon, color, bg }) => (
diff --git a/frontend/src/components/prioritization/PriorityFilters.jsx b/frontend/src/components/prioritization/PriorityFilters.jsx index c564771c..40ca1c3a 100644 --- a/frontend/src/components/prioritization/PriorityFilters.jsx +++ b/frontend/src/components/prioritization/PriorityFilters.jsx @@ -1,6 +1,8 @@ +import { SEVERITY_IDS } from '../../utils/severity'; + const CATEGORIES = ['All', 'Storage', 'Compute', 'Network', 'Identity', 'Database', 'KeyVault']; -const SEVERITIES = ['All', 'HIGH', 'MEDIUM', 'LOW']; +const SEVERITIES = ['All', ...SEVERITY_IDS]; export default function PriorityFilters({ filters, onChange }) { const set = (key, val) => onChange({ ...filters, [key]: val }); diff --git a/frontend/src/components/prioritization/PriorityMatrix.jsx b/frontend/src/components/prioritization/PriorityMatrix.jsx index bcc5ad01..67822d70 100644 --- a/frontend/src/components/prioritization/PriorityMatrix.jsx +++ b/frontend/src/components/prioritization/PriorityMatrix.jsx @@ -3,7 +3,7 @@ import { Tooltip, ResponsiveContainer, ReferenceLine, ReferenceArea, } from 'recharts'; -const SEVERITY_COLORS = { HIGH: '#ef4444', MEDIUM: '#f97316', LOW: '#10b981', INFO: '#6b7280' }; +import { SEVERITY_DEFINITIONS, severityColor } from '../../utils/severity'; const CustomTooltip = ({ active, payload }) => { if (!active || !payload?.length) return null; @@ -68,7 +68,7 @@ export default function PriorityMatrix({ items, selectedId, onSelect }) { const { cx, cy, payload } = props; const isSelected = payload.ruleId === selectedId; const isDimmed = hasSelection && !isSelected; - const color = SEVERITY_COLORS[payload.severity] || '#6b7280'; + const color = severityColor(payload.severity); return ( {isSelected && ( @@ -92,9 +92,9 @@ export default function PriorityMatrix({ items, selectedId, onSelect }) { {/* Legend */}
- {[['HIGH', '#ef4444'], ['MEDIUM', '#f97316'], ['LOW', '#10b981']].map(([s, c]) => ( - - {s} + {SEVERITY_DEFINITIONS.map((level) => ( + + {level.id} ))} diff --git a/frontend/src/components/shared/RiskBadge.jsx b/frontend/src/components/shared/RiskBadge.jsx index 1e831460..ab18635d 100644 --- a/frontend/src/components/shared/RiskBadge.jsx +++ b/frontend/src/components/shared/RiskBadge.jsx @@ -1,15 +1,23 @@ +import { normalizeRisk, severityDefinition } from '../../utils/severity'; + const styles = { - HIGH: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - MEDIUM: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - LOW: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + critical: 'bg-red-200 text-red-900 dark:bg-red-950/50 dark:text-red-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + warning: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + neutral: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', NONE: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400', }; export default function RiskBadge({ risk }) { + const normalized = normalizeRisk(risk || 'NONE'); + const style = normalized === 'NONE' + ? styles.NONE + : styles[severityDefinition(normalized).tone]; return ( - - {risk || 'NONE'} + + {normalized} ); } diff --git a/frontend/src/components/shared/SeverityBadge.jsx b/frontend/src/components/shared/SeverityBadge.jsx index 3b56fbd1..1d9679ad 100644 --- a/frontend/src/components/shared/SeverityBadge.jsx +++ b/frontend/src/components/shared/SeverityBadge.jsx @@ -1,16 +1,19 @@ +import { severityDefinition } from '../../utils/severity'; + const styles = { - HIGH: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - MEDIUM: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - LOW: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', - INFO: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', - NONE: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400', + critical: 'bg-red-200 text-red-900 dark:bg-red-950/50 dark:text-red-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + warning: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + neutral: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400', }; export default function SeverityBadge({ severity }) { + const definition = severityDefinition(severity); return ( - - {severity || 'INFO'} + + {definition.id} ); } diff --git a/frontend/src/generated/severity.v1.json b/frontend/src/generated/severity.v1.json new file mode 100644 index 00000000..344a14a9 --- /dev/null +++ b/frontend/src/generated/severity.v1.json @@ -0,0 +1,54 @@ +{ + "contract": "openshield.finding-severity", + "version": "1.0.0", + "aliases": { + "INFORMATIONAL": "INFO" + }, + "levels": [ + { + "id": "CRITICAL", + "rank": 4, + "score_weight": 20, + "risk_score": 10, + "label": "Critical", + "color": "#b91c1c", + "tone": "critical" + }, + { + "id": "HIGH", + "rank": 3, + "score_weight": 10, + "risk_score": 8, + "label": "High", + "color": "#ef4444", + "tone": "danger" + }, + { + "id": "MEDIUM", + "rank": 2, + "score_weight": 5, + "risk_score": 5, + "label": "Medium", + "color": "#f97316", + "tone": "warning" + }, + { + "id": "LOW", + "rank": 1, + "score_weight": 2, + "risk_score": 2, + "label": "Low", + "color": "#10b981", + "tone": "success" + }, + { + "id": "INFO", + "rank": 0, + "score_weight": 0, + "risk_score": 1, + "label": "Info", + "color": "#6b7280", + "tone": "neutral" + } + ] +} diff --git a/frontend/src/pages/Monitoring.jsx b/frontend/src/pages/Monitoring.jsx index 5db511c3..bb8f4a06 100644 --- a/frontend/src/pages/Monitoring.jsx +++ b/frontend/src/pages/Monitoring.jsx @@ -10,47 +10,13 @@ import Loader, { CardLoader } from '../components/shared/Loader'; import ErrorState from '../components/shared/ErrorState'; import usePageData from '../hooks/usePageData'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; - -function buildRgGroups(findings) { - const groups = {}; - findings.forEach((f) => { - const rg = f.resourceGroup || 'unknown'; - if (!groups[rg]) groups[rg] = { group: rg, HIGH: 0, MEDIUM: 0, LOW: 0 }; - const sev = (f.severity || '').toUpperCase(); - if (sev === 'HIGH' || sev === 'MEDIUM' || sev === 'LOW') groups[rg][sev]++; - }); - return Object.values(groups).sort((a, b) => (b.HIGH + b.MEDIUM + b.LOW) - (a.HIGH + a.MEDIUM + a.LOW)); -} - -function buildCategoryScores(findings) { - const catMap = {}; - findings.forEach((f) => { - const cat = f.category || 'Other'; - if (!catMap[cat]) catMap[cat] = { high: 0, medium: 0, low: 0 }; - const sev = (f.severity || '').toUpperCase(); - if (sev === 'HIGH') catMap[cat].high++; - else if (sev === 'MEDIUM') catMap[cat].medium++; - else if (sev === 'LOW') catMap[cat].low++; - }); - return Object.entries(catMap) - .map(([category, c]) => ({ - category, - score: Math.max(0, 100 - c.high * 10 - c.medium * 5 - c.low * 2), - })) - .sort((a, b) => a.score - b.score); -} - -function buildTrend(scans) { - return scans - .slice(0, 8) - .reverse() - .map((s) => ({ - month: new Date(s.started_at || s.startedAt).toLocaleDateString(undefined, { - month: 'short', day: 'numeric', - }), - score: s.score ?? Math.max(0, 100 - (s.total_findings || 0) * 7), - })); -} +import { + buildCategoryScores, + buildFindingsDistribution, + buildResourceGroupGroups, + buildTrend, + countBySeverity, +} from '../utils/monitoring'; export default function Monitoring() { const loadMonitoring = useCallback(async () => { @@ -60,27 +26,22 @@ export default function Monitoring() { api.getScans(), ]); const scans = scansData.scans || []; - const high = findings.filter((f) => f.severity?.toUpperCase() === 'HIGH').length; - const medium = findings.filter((f) => f.severity?.toUpperCase() === 'MEDIUM').length; - const low = findings.filter((f) => f.severity?.toUpperCase() === 'LOW').length; + const counts = countBySeverity(findings); return { score: scoreData.score ?? scoreData, maxScore: scoreData.max_score ?? 100, stats: { totalFindings: findings.length, - criticalIssues: high, - mediumRisk: medium, - lowPriority: low, + criticalIssues: counts.CRITICAL, + highRisk: counts.HIGH, + mediumRisk: counts.MEDIUM, + lowPriority: counts.LOW, }, - findingsDistribution: [ - { name: 'High', value: high, color: '#ef4444' }, - { name: 'Medium', value: medium, color: '#f97316' }, - { name: 'Low', value: low, color: '#10b981' }, - ], + findingsDistribution: buildFindingsDistribution(counts), categoryScores: buildCategoryScores(findings), trend: buildTrend(scans), - findingsByResourceGroup: buildRgGroups(findings), + findingsByResourceGroup: buildResourceGroupGroups(findings), }; }, []); const { status, data, retry } = usePageData(loadMonitoring); @@ -95,8 +56,8 @@ export default function Monitoring() { if (status === 'loading') return (
-
- {[...Array(4)].map((_, i) => )} +
+ {[...Array(5)].map((_, i) => )}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 4325f33d..af52ad6b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -5,6 +5,8 @@ // The backend always has data — either seeded or from a real scan. // ───────────────────────────────────────────────────────────────────────────── +import { normalizeRisk, normalizeSeverity } from './severity.js'; + const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:5000' : 'https://openshield-api.onrender.com'); @@ -40,7 +42,7 @@ function normalizeFinding(f) { id: f.id, ruleId: f.rule_id || f.ruleId, ruleName: f.rule_name || f.ruleName, - severity: f.severity, + severity: normalizeSeverity(f.severity), category: f.category, resourceName: f.resource_name || f.resourceName, resourceGroup: (f.resource_id || f.resourceId)?.split('/')?.[4] ?? f.resourceGroup ?? '', @@ -70,7 +72,7 @@ function normalizeResource(r) { resourceGroup: r.resource_group || r.resourceGroup, subscription: r.subscription_id || r.subscription, location: r.location, - risk: r.risk_level || r.risk, + risk: normalizeRisk(r.risk_level || r.risk), findingCount: r.finding_count || r.findingCount || 0, discoveredAt: r.discovered_at || r.discoveredAt, config: r.config || {}, @@ -79,11 +81,16 @@ function normalizeResource(r) { function normalizeResourcesResponse(data) { const s = data.summary || {}; + const byRiskLevel = {}; + Object.entries(s.by_risk_level || s.byRiskLevel || {}).forEach(([risk, count]) => { + const canonical = normalizeRisk(risk); + byRiskLevel[canonical] = (byRiskLevel[canonical] || 0) + count; + }); return { summary: { total: s.total, byCategory: s.by_category || s.byCategory || {}, - byRiskLevel: s.by_risk_level || s.byRiskLevel || {}, + byRiskLevel, lastScanAt: s.last_scan_at || s.lastScanAt, }, resources: (data.resources || []).map(normalizeResource), @@ -104,7 +111,7 @@ function normalizePrioritizationResponse(data) { risk: m.risk, effort: m.effort, category: m.category, - severity: m.severity, + severity: normalizeSeverity(m.severity), affectedResources: m.affected_resources || m.affectedResources, resource: m.resource, })), @@ -113,7 +120,7 @@ function normalizePrioritizationResponse(data) { ruleId: r.rule_id || r.ruleId, name: r.name, score: r.score, - severity: r.severity, + severity: normalizeSeverity(r.severity), category: r.category, effort: r.effort, impact: r.impact, @@ -136,7 +143,7 @@ function normalizeDriftEvent(e) { return { id: e.id, type: e.type, - severity: e.severity, + severity: normalizeSeverity(e.severity), ruleId: e.rule_id || e.ruleId, ruleName: e.rule_name || e.ruleName, resourceName: e.resource_name || e.resourceName, @@ -197,7 +204,7 @@ function normalizeComplianceControl(c, frameworkName) { name: c.control_name, status: c.status, ruleId: c.rule_id, - severity: c.severity || 'MEDIUM', + severity: normalizeSeverity(c.severity, { nullable: true }), category: c.category || 'General', resources: c.resources || 0, }; diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 15f425b9..10ec63d1 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -1,16 +1,8 @@ -export const RISK_LEVELS = { - HIGH: 'HIGH', - MEDIUM: 'MEDIUM', - LOW: 'LOW', - NONE: 'NONE', -}; +import { SEVERITY_DEFINITIONS, SEVERITY_IDS } from './severity.js'; -export const SEVERITY_LEVELS = { - HIGH: 'HIGH', - MEDIUM: 'MEDIUM', - LOW: 'LOW', - INFO: 'INFO', -}; +export const SEVERITY_LEVELS = Object.freeze(Object.fromEntries(SEVERITY_IDS.map((id) => [id, id]))); + +export const RISK_LEVELS = Object.freeze({ ...SEVERITY_LEVELS, NONE: 'NONE' }); export const CATEGORIES = [ 'Storage', @@ -30,14 +22,21 @@ export const DRIFT_TYPES = { MODIFIED: 'MODIFIED', }; -export const RISK_COLORS = { - HIGH: 'text-severity-high bg-red-50 dark:bg-red-900/20', - MEDIUM: 'text-severity-medium bg-orange-50 dark:bg-orange-900/20', - LOW: 'text-severity-low bg-green-50 dark:bg-green-900/20', - NONE: 'text-text-secondary bg-bg-secondary dark:bg-bg-dark-tertiary', - INFO: 'text-severity-info bg-gray-50 dark:bg-gray-900/20', +const RISK_TONE_CLASSES = { + critical: 'text-severity-critical bg-red-100 dark:bg-red-950/40', + danger: 'text-severity-high bg-red-50 dark:bg-red-900/20', + warning: 'text-severity-medium bg-orange-50 dark:bg-orange-900/20', + success: 'text-severity-low bg-green-50 dark:bg-green-900/20', + neutral: 'text-severity-info bg-gray-50 dark:bg-gray-900/20', }; +export const RISK_COLORS = Object.freeze({ + ...Object.fromEntries( + SEVERITY_DEFINITIONS.map((level) => [level.id, RISK_TONE_CLASSES[level.tone]]), + ), + NONE: 'text-text-secondary bg-bg-secondary dark:bg-bg-dark-tertiary', +}); + export const NAV_ITEMS = [ { path: '/monitoring', label: 'Monitor', icon: 'FiActivity' }, { path: '/discovery', label: 'Discover', icon: 'FiSearch' }, diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js index a86273dd..67e13b2e 100644 --- a/frontend/src/utils/helpers.js +++ b/frontend/src/utils/helpers.js @@ -1,3 +1,13 @@ +import { normalizeRisk, severityColor, severityDefinition } from './severity.js'; + +const SEVERITY_TONE_CLASSES = { + critical: 'bg-red-200 text-red-900 dark:bg-red-950/50 dark:text-red-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + warning: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + neutral: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', +}; + export function formatDate(isoString) { return new Date(isoString).toLocaleDateString('en-US', { month: 'short', @@ -16,25 +26,12 @@ export function formatDateTime(isoString) { } export function getRiskColor(risk) { - const map = { - HIGH: '#ef4444', - MEDIUM: '#f97316', - LOW: '#10b981', - NONE: '#6b7280', - INFO: '#6b7280', - }; - return map[risk] || '#6b7280'; + const normalized = normalizeRisk(risk); + return normalized === 'NONE' ? '#6b7280' : severityColor(normalized); } export function getSeverityClass(severity) { - const map = { - HIGH: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', - MEDIUM: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', - LOW: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', - INFO: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', - NONE: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400', - }; - return map[severity] || map.INFO; + return SEVERITY_TONE_CLASSES[severityDefinition(severity).tone]; } export function calculatePercentage(value, total) { diff --git a/frontend/src/utils/monitoring.js b/frontend/src/utils/monitoring.js new file mode 100644 index 00000000..9d45c5c7 --- /dev/null +++ b/frontend/src/utils/monitoring.js @@ -0,0 +1,80 @@ +import { + SEVERITY_CONTRACT_VERSION, + SEVERITY_BY_ID, + SEVERITY_DEFINITIONS, + normalizeSeverity, + severityWeight, +} from './severity.js'; + +export function buildTrend(scans) { + return scans + .filter((scan) => ( + String(scan.status || '').toLowerCase() === 'completed' + && Number.isFinite(scan.score) + && (scan.severity_contract_version || scan.severityContractVersion) + === SEVERITY_CONTRACT_VERSION + )) + .slice(0, 8) + .reverse() + .map((scan) => ({ + month: new Date(scan.started_at || scan.startedAt).toLocaleDateString(undefined, { + month: 'short', day: 'numeric', + }), + score: scan.score, + })); +} + +export function countBySeverity(findings) { + const counts = Object.fromEntries(SEVERITY_DEFINITIONS.map((level) => [level.id, 0])); + findings.forEach((finding) => { + counts[normalizeSeverity(finding.severity)] += 1; + }); + return counts; +} + +export function buildResourceGroupGroups(findings) { + const groups = {}; + findings.forEach((finding) => { + const group = finding.resourceGroup || 'unknown'; + if (!groups[group]) { + groups[group] = { + group, + ...Object.fromEntries(SEVERITY_DEFINITIONS.map((level) => [level.id, 0])), + }; + } + groups[group][normalizeSeverity(finding.severity)] += 1; + }); + return Object.values(groups).sort((left, right) => { + const total = (item) => SEVERITY_DEFINITIONS.reduce((sum, level) => sum + item[level.id], 0); + return total(right) - total(left); + }); +} + +export function buildCategoryScores(findings) { + const categories = {}; + findings.forEach((finding) => { + const category = finding.category || 'Other'; + if (!categories[category]) categories[category] = []; + categories[category].push(finding); + }); + return Object.entries(categories) + .map(([category, categoryFindings]) => ({ + category, + score: Math.max( + 0, + 100 - categoryFindings.reduce( + (deduction, finding) => deduction + severityWeight(finding.severity), + 0, + ), + ), + })) + .sort((left, right) => left.score - right.score); +} + +export function buildFindingsDistribution(counts) { + return SEVERITY_DEFINITIONS.map((level) => ({ + name: level.label, + value: counts[level.id], + color: SEVERITY_BY_ID[level.id].color, + })); +} diff --git a/frontend/src/utils/severity.js b/frontend/src/utils/severity.js new file mode 100644 index 00000000..c9bc7db8 --- /dev/null +++ b/frontend/src/utils/severity.js @@ -0,0 +1,105 @@ +import severityContract from '../generated/severity.v1.json' with { type: 'json' }; + +function validateContract(contract) { + const supportedTones = new Set(['critical', 'danger', 'warning', 'success', 'neutral']); + if ( + contract.contract !== 'openshield.finding-severity' + || typeof contract.version !== 'string' + || !/^[1-9][0-9]*\.[0-9]+\.[0-9]+$/.test(contract.version) + ) { + throw new Error('Invalid OpenShield severity contract metadata'); + } + if (!Array.isArray(contract.levels) || contract.levels.length === 0) { + throw new Error('OpenShield severity contract has no levels'); + } + const ids = contract.levels.map((level) => level.id); + const ranks = contract.levels.map((level) => level.rank); + if (ids.some((id) => typeof id !== 'string' || id !== id.trim().toUpperCase())) { + throw new Error('OpenShield severity IDs must be canonical uppercase values'); + } + if (new Set(ids).size !== ids.length || new Set(ranks).size !== ranks.length) { + throw new Error('OpenShield severity IDs and ranks must be unique'); + } + if (contract.levels.some((level) => ( + !Number.isInteger(level.rank) + || !Number.isInteger(level.score_weight) + || !Number.isInteger(level.risk_score) + ))) { + throw new Error('OpenShield severity ranks, weights, and risk scores must be integers'); + } + if (contract.levels.some((level) => level.score_weight < 0 || level.risk_score < 0)) { + throw new Error('OpenShield severity weights and risk scores cannot be negative'); + } + if (contract.levels.some((level) => ( + typeof level.label !== 'string' + || level.label.trim() === '' + || typeof level.color !== 'string' + || !/^#[0-9a-f]{6}$/i.test(level.color) + || !supportedTones.has(level.tone) + ))) { + throw new Error('OpenShield severity labels, colors, and tones must be supported'); + } + const ordered = [...contract.levels].sort((left, right) => left.rank - right.rank); + for (let index = 1; index < ordered.length; index += 1) { + const lower = ordered[index - 1]; + const higher = ordered[index]; + if (lower.score_weight > higher.score_weight || lower.risk_score > higher.risk_score) { + throw new Error('OpenShield severity weights and risk scores must increase with rank'); + } + } + const critical = contract.levels.find((level) => level.id === 'CRITICAL'); + if (!critical || critical.rank !== Math.max(...ranks)) { + throw new Error('CRITICAL must be the highest OpenShield severity'); + } + for (const [source, target] of Object.entries(contract.aliases || {})) { + if (source !== source.trim().toUpperCase() || ids.includes(source) || !ids.includes(target)) { + throw new Error('OpenShield severity aliases must map to canonical IDs'); + } + } +} + +validateContract(severityContract); + +export const SEVERITY_CONTRACT_VERSION = severityContract.version; +export const SEVERITY_DEFINITIONS = Object.freeze( + severityContract.levels.map((level) => Object.freeze({ ...level })), +); +export const SEVERITY_IDS = Object.freeze(SEVERITY_DEFINITIONS.map((level) => level.id)); +export const SEVERITY_BY_ID = Object.freeze( + Object.fromEntries(SEVERITY_DEFINITIONS.map((level) => [level.id, level])), +); +export const SEVERITY_ALIASES = Object.freeze({ ...severityContract.aliases }); + +export function normalizeSeverity(value, { nullable = false } = {}) { + if (value == null && nullable) return null; + if (typeof value !== 'string' || value.trim() === '') { + throw new TypeError('severity must be a non-empty string'); + } + const candidate = value.trim().toUpperCase(); + const canonical = SEVERITY_ALIASES[candidate] || candidate; + if (!SEVERITY_BY_ID[canonical]) { + throw new RangeError(`Unsupported severity: ${value}`); + } + return canonical; +} + +export function severityDefinition(value) { + return SEVERITY_BY_ID[normalizeSeverity(value)]; +} + +export function severityWeight(value) { + return severityDefinition(value).score_weight; +} + +export function severityRank(value) { + return severityDefinition(value).rank; +} + +export function severityColor(value) { + return severityDefinition(value).color; +} + +export function normalizeRisk(value) { + if (typeof value === 'string' && value.trim().toUpperCase() === 'NONE') return 'NONE'; + return normalizeSeverity(value); +} diff --git a/frontend/src/utils/severity.test.mjs b/frontend/src/utils/severity.test.mjs new file mode 100644 index 00000000..5ba8cdc3 --- /dev/null +++ b/frontend/src/utils/severity.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import tailwindConfig from '../../tailwind.config.js'; +import { RISK_COLORS, RISK_LEVELS, SEVERITY_LEVELS } from './constants.js'; +import { getRiskColor, getSeverityClass } from './helpers.js'; +import { + SEVERITY_CONTRACT_VERSION, + SEVERITY_DEFINITIONS, + SEVERITY_IDS, + normalizeRisk, + normalizeSeverity, + severityRank, + severityWeight, +} from './severity.js'; +import { + buildCategoryScores, + buildFindingsDistribution, + buildResourceGroupGroups, + buildTrend, + countBySeverity, +} from './monitoring.js'; + +const findings = [ + { severity: 'critical', category: 'Supply Chain', resourceGroup: 'production' }, + { severity: 'HIGH', category: 'Supply Chain', resourceGroup: 'production' }, + { severity: 'INFORMATIONAL', category: 'Inventory', resourceGroup: 'shared' }, +]; + +test('contract v1 ranks and weights CRITICAL above every other severity', () => { + assert.equal(SEVERITY_CONTRACT_VERSION, '1.0.0'); + assert.deepEqual(SEVERITY_IDS, ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']); + assert.equal(severityRank('CRITICAL'), Math.max(...SEVERITY_DEFINITIONS.map((level) => level.rank))); + assert.equal(severityWeight('CRITICAL'), 20); + assert.equal(normalizeSeverity(' informational '), 'INFO'); + assert.equal(normalizeRisk('none'), 'NONE'); + assert.throws(() => normalizeSeverity('urgent'), RangeError); +}); + +test('frontend options, classes, and Tailwind colors include every contract level', () => { + assert.deepEqual(Object.keys(SEVERITY_LEVELS), SEVERITY_IDS); + assert.deepEqual(Object.keys(RISK_LEVELS), [...SEVERITY_IDS, 'NONE']); + assert.deepEqual(Object.keys(RISK_COLORS), [...SEVERITY_IDS, 'NONE']); + assert.match(getSeverityClass('CRITICAL'), /red-950/); + assert.equal(getRiskColor('CRITICAL'), '#b91c1c'); + assert.equal( + tailwindConfig.theme.extend.colors['severity-critical'], + SEVERITY_DEFINITIONS[0].color, + ); +}); + +test('monitoring counts, charts, and category scores use the same contract', () => { + const counts = countBySeverity(findings); + assert.deepEqual(counts, { CRITICAL: 1, HIGH: 1, MEDIUM: 0, LOW: 0, INFO: 1 }); + + const distribution = buildFindingsDistribution(counts); + assert.deepEqual(distribution.map((entry) => entry.name), [ + 'Critical', 'High', 'Medium', 'Low', 'Info', + ]); + assert.equal(distribution[0].value, 1); + + const groups = buildResourceGroupGroups(findings); + assert.deepEqual(groups[0], { + group: 'production', CRITICAL: 1, HIGH: 1, MEDIUM: 0, LOW: 0, INFO: 0, + }); + + const categoryScores = buildCategoryScores(findings); + assert.deepEqual(categoryScores, [ + { category: 'Supply Chain', score: 70 }, + { category: 'Inventory', score: 100 }, + ]); +}); + +test('monitoring trends never invent scores for incomplete or legacy scans', () => { + const trend = buildTrend([ + { + status: 'completed', score: 80, severity_contract_version: '1.0.0', + started_at: '2026-08-21T00:00:00Z', + }, + { + status: 'failed', score: null, severity_contract_version: '1.0.0', + started_at: '2026-08-20T00:00:00Z', total_findings: 0, + }, + { + status: 'pending', score: null, severity_contract_version: '1.0.0', + started_at: '2026-08-19T00:00:00Z', total_findings: 0, + }, + { + status: 'completed', score: 100, severity_contract_version: null, + started_at: '2026-08-18T00:00:00Z', + }, + ]); + + assert.equal(trend.length, 1); + assert.equal(trend[0].score, 80); +}); + +test('every severity-facing React consumer stays wired to contract-backed helpers', async () => { + const consumers = new Map([ + ['../components/shared/SeverityBadge.jsx', ['severityDefinition']], + ['../components/shared/RiskBadge.jsx', ['normalizeRisk', 'severityDefinition']], + ['../components/prioritization/PriorityFilters.jsx', ['SEVERITY_IDS']], + ['../components/drift/DriftFilters.jsx', ['SEVERITY_IDS']], + ['../components/discovery/ResourceFilter.jsx', ['SEVERITY_DEFINITIONS']], + ['../components/prioritization/PriorityMatrix.jsx', ['SEVERITY_DEFINITIONS', 'severityColor']], + ['../components/compliance/ComplianceTable.jsx', ['SeverityBadge', 'c.severity ?']], + ['../components/monitoring/StatCards.jsx', ['criticalIssues', 'severity-critical']], + ['../components/monitoring/ResourceGroupChart.jsx', ['SEVERITY_DEFINITIONS']], + ['../pages/Monitoring.jsx', ['buildTrend', 'countBySeverity']], + ]); + + for (const [relativePath, requiredTokens] of consumers) { + const source = await readFile(new URL(relativePath, import.meta.url), 'utf8'); + for (const token of requiredTokens) { + assert.ok(source.includes(token), `${relativePath} must use ${token}`); + } + } +}); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index c990ec41..2318f14e 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,3 +1,9 @@ +import severityContract from './src/generated/severity.v1.json' with { type: 'json' }; + +const severityColors = Object.fromEntries( + severityContract.levels.map((level) => [`severity-${level.id.toLowerCase()}`, level.color]), +); + export default { content: ['./index.html', './src/**/*.{js,jsx}'], theme: { @@ -5,10 +11,7 @@ export default { colors: { 'brand-primary': '#10b981', 'brand-secondary': '#059669', - 'severity-high': '#ef4444', - 'severity-medium': '#f97316', - 'severity-low': '#10b981', - 'severity-info': '#6b7280', + ...severityColors, 'bg-primary': '#ffffff', 'bg-secondary': '#f8f9fa', 'bg-tertiary': '#f1f3f5', diff --git a/openshield/__init__.py b/openshield/__init__.py new file mode 100644 index 00000000..88fb8643 --- /dev/null +++ b/openshield/__init__.py @@ -0,0 +1 @@ +"""Shared OpenShield domain contracts.""" diff --git a/openshield/severity.py b/openshield/severity.py new file mode 100644 index 00000000..ba1b3058 --- /dev/null +++ b/openshield/severity.py @@ -0,0 +1,171 @@ +"""Canonical finding-severity contract shared by scanner and API code.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any, Iterable, Mapping + +_CONTRACT_PATH = Path(__file__).resolve().parent.parent / "contracts" / "severity.v1.json" +_SQL_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$") +_HEX_COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$") +_CONTRACT_VERSION = re.compile(r"^[1-9][0-9]*\.[0-9]+\.[0-9]+$") +_SUPPORTED_TONES = frozenset({"critical", "danger", "warning", "success", "neutral"}) + + +class SeverityContractError(ValueError): + """Raised when a value is outside the versioned severity contract.""" + + +@dataclass(frozen=True) +class SeverityLevel: + """One canonical finding severity and its cross-product semantics.""" + + id: str + rank: int + score_weight: int + risk_score: int + label: str + color: str + tone: str + + +def _load_contract() -> tuple[str, tuple[SeverityLevel, ...], Mapping[str, str]]: + with _CONTRACT_PATH.open(encoding="utf-8") as handle: + raw = json.load(handle) + + if raw.get("contract") != "openshield.finding-severity": + raise RuntimeError("Unexpected severity contract identifier") + + version = raw.get("version") + if not isinstance(version, str) or _CONTRACT_VERSION.fullmatch(version) is None: + raise RuntimeError("Severity contract version must use major.minor.patch") + + levels = tuple(SeverityLevel(**item) for item in raw.get("levels", [])) + if not levels: + raise RuntimeError("Severity contract must define at least one level") + + ids = [level.id for level in levels] + ranks = [level.rank for level in levels] + if any( + isinstance(level.rank, bool) + or not isinstance(level.rank, int) + or isinstance(level.score_weight, bool) + or not isinstance(level.score_weight, int) + or isinstance(level.risk_score, bool) + or not isinstance(level.risk_score, int) + for level in levels + ): + raise RuntimeError("Severity ranks, weights, and risk scores must be integers") + if any(not isinstance(level_id, str) or level_id != level_id.strip().upper() for level_id in ids): + raise RuntimeError("Severity IDs must be canonical uppercase values") + if len(ids) != len(set(ids)) or len(ranks) != len(set(ranks)): + raise RuntimeError("Severity IDs and ranks must be unique") + if any(level.score_weight < 0 or level.risk_score < 0 for level in levels): + raise RuntimeError("Severity weights and risk scores cannot be negative") + if any( + not isinstance(level.label, str) + or not level.label.strip() + or not isinstance(level.tone, str) + or level.tone not in _SUPPORTED_TONES + or not isinstance(level.color, str) + or _HEX_COLOR.fullmatch(level.color) is None + for level in levels + ): + raise RuntimeError("Severity labels, tones, and six-digit hex colors are required") + + ordered = sorted(levels, key=lambda level: level.rank) + if any( + lower.score_weight > higher.score_weight or lower.risk_score > higher.risk_score + for lower, higher in zip(ordered, ordered[1:]) + ): + raise RuntimeError("Severity weights and risk scores must increase with rank") + + by_id = {level.id: level for level in levels} + if "CRITICAL" not in by_id or by_id["CRITICAL"].rank != max(ranks): + raise RuntimeError("CRITICAL must be the highest-ranked severity") + + aliases = raw.get("aliases", {}) + if not isinstance(aliases, dict): + raise RuntimeError("Severity aliases must be an object") + normalized_aliases: dict[str, str] = {} + for source, target in aliases.items(): + source_id = str(source).strip().upper() + target_id = str(target).strip().upper() + if ( + source != source_id + or target != target_id + or source_id in by_id + or target_id not in by_id + or source_id in normalized_aliases + ): + raise RuntimeError("Severity aliases must map non-canonical names to canonical IDs") + normalized_aliases[source_id] = target_id + + return version, levels, MappingProxyType(normalized_aliases) + + +CONTRACT_VERSION, LEVELS, ALIASES = _load_contract() +LEVEL_BY_ID: Mapping[str, SeverityLevel] = MappingProxyType({level.id: level for level in LEVELS}) +CANONICAL_SEVERITIES = frozenset(LEVEL_BY_ID) +ACCEPTED_SEVERITIES = frozenset((*CANONICAL_SEVERITIES, *ALIASES)) +SEVERITY_WEIGHTS: Mapping[str, int] = MappingProxyType({level.id: level.score_weight for level in LEVELS}) + + +def normalize_severity(value: Any) -> str: + """Return a canonical severity ID or reject the value explicitly.""" + if not isinstance(value, str) or not value.strip(): + raise SeverityContractError("severity must be a non-empty string") + candidate = value.strip().upper() + candidate = ALIASES.get(candidate, candidate) + if candidate not in LEVEL_BY_ID: + raise SeverityContractError(f"unsupported severity: {value!r}") + return candidate + + +def severity_level(value: Any) -> SeverityLevel: + return LEVEL_BY_ID[normalize_severity(value)] + + +def severity_rank(value: Any) -> int: + return severity_level(value).rank + + +def severity_weight(value: Any) -> int: + return severity_level(value).score_weight + + +def severity_risk_score(value: Any) -> int: + return severity_level(value).risk_score + + +def severity_from_rank(rank: int) -> str: + for level in LEVELS: + if level.rank == rank: + return level.id + raise SeverityContractError(f"unsupported severity rank: {rank!r}") + + +def score_findings(findings: Iterable[Mapping[str, Any]]) -> int: + deduction = sum(severity_weight(finding.get("severity")) for finding in findings) + return max(0, 100 - deduction) + + +def score_counts(counts: Mapping[str, int]) -> int: + deduction = 0 + for severity, count in counts.items(): + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise SeverityContractError("severity counts must be non-negative integers") + deduction += severity_weight(severity) * count + return max(0, 100 - deduction) + + +def severity_rank_sql(column: str) -> str: + """Return a CASE expression derived from the contract for trusted SQL identifiers.""" + if _SQL_IDENTIFIER.fullmatch(column) is None: + raise ValueError("column must be a trusted SQL identifier") + cases = " ".join(f"WHEN '{level.id}' THEN {level.rank}" for level in LEVELS) + return f"CASE UPPER({column}) {cases} ELSE -1 END" diff --git a/scanner/engine.py b/scanner/engine.py index 99dbf008..f7af9aa2 100644 --- a/scanner/engine.py +++ b/scanner/engine.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from api.observability import RULE_ERRORS_TOTAL +from openshield.severity import CONTRACT_VERSION, SeverityContractError, normalize_severity, score_findings from scanner.azure_client import AzureClient logger = logging.getLogger(__name__) @@ -75,10 +76,18 @@ def load_rules(self) -> None: spec.loader.exec_module(module) # type: ignore[union-attr] rule_id = getattr(module, "RULE_ID", None) if callable(getattr(module, "scan", None)) and isinstance(rule_id, str) and rule_id: + declared_severity = getattr(module, "SEVERITY", None) + if normalize_severity(declared_severity) != declared_severity: + raise SeverityContractError(f"rule {rule_id} must declare a canonical severity") self.rules.append(module) logger.info("Loaded rule: %s", rule_id) else: logger.warning("Rule file %s has no scan() function or RULE_ID — skipped", rule_path.name) + except SeverityContractError: + # A rule outside the contract must fail startup rather than be + # silently skipped and later appear as a clean evaluation. + logger.exception("Rule %s has an invalid severity", rule_path.name) + raise except Exception as exc: logger.error("Failed to load rule %s: %s", rule_path.name, exc) @@ -116,22 +125,30 @@ def run_scan(self, scan_id: Optional[str] = None) -> Dict[str, Any]: logger.warning("Rule %s returned %s instead of list — skipped", rule_id, type(rule_findings)) continue - for finding in rule_findings: + validated_findings = [] + for raw_finding in rule_findings: + finding = raw_finding if not isinstance(finding, dict): + logger.warning("Rule %s returned a non-object finding — skipped", rule_id) continue + finding = dict(finding) + finding["severity"] = normalize_severity(finding.get("severity")) finding.setdefault("detected_at", detected_at) finding.setdefault("scan_id", scan_id) - findings.extend(rule_findings) - logger.info("Rule %s produced %d finding(s)", rule_id, len(rule_findings)) + validated_findings.append(finding) + findings.extend(validated_findings) + logger.info("Rule %s produced %d finding(s)", rule_id, len(validated_findings)) + except SeverityContractError: + RULE_ERRORS_TOTAL.labels(rule_id=rule_id).inc() + logger.exception("Rule %s returned an invalid severity", rule_id) + raise except Exception as exc: RULE_ERRORS_TOTAL.labels(rule_id=rule_id).inc() logger.error("Rule %s raised an exception: %s", rule_id, exc, exc_info=True) completed_at = datetime.now(timezone.utc).isoformat() - severity_weights = {"HIGH": 10, "MEDIUM": 5, "LOW": 2} - deduction = sum(severity_weights.get((f.get("severity") or "").upper(), 0) for f in findings) - score = max(0, 100 - deduction) + score = score_findings(findings) result = { "scan_id": scan_id, @@ -142,6 +159,7 @@ def run_scan(self, scan_id: Optional[str] = None) -> Dict[str, Any]: "completed_at": completed_at, "total_findings": len(findings), "score": score, + "severity_contract_version": CONTRACT_VERSION, "findings": findings, } diff --git a/sentinel/ingest.py b/sentinel/ingest.py index 53c115fa..abb87441 100644 --- a/sentinel/ingest.py +++ b/sentinel/ingest.py @@ -11,6 +11,7 @@ import requests from api.validation import ValidationError, bounded_string, uuid_string +from openshield.severity import SeverityContractError, normalize_severity, severity_rank WORKSPACE_ID = os.environ.get("SENTINEL_WORKSPACE_ID", "") SHARED_KEY = os.environ.get("SENTINEL_SHARED_KEY", "") @@ -71,10 +72,13 @@ def normalise(raw, scan_id): if not isinstance(raw, dict): raise ValidationError("each Sentinel finding must be an object") scan_id = bounded_string(scan_id, "scan_id", maximum=128, pattern=re.compile(r"^[A-Za-z0-9._:-]+$")) - sev_map = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "INFO": 0} - sev = _safe_text(raw.get("severity", "MEDIUM"), "severity", maximum=16).upper() - if sev not in sev_map: - raise ValidationError("severity must be CRITICAL, HIGH, MEDIUM, LOW, or INFO") + if raw.get("severity") in (None, ""): + raise ValidationError("severity is required for every Sentinel finding") + raw_severity = _safe_text(raw["severity"], "severity", maximum=16) + try: + sev = normalize_severity(raw_severity) + except SeverityContractError as exc: + raise ValidationError("severity is outside the OpenShield severity contract") from exc compliance = raw.get("compliance", {}) if not isinstance(compliance, dict): raise ValidationError("compliance must be an object") @@ -93,7 +97,7 @@ def normalise(raw, scan_id): "RuleId": _safe_text(raw.get("rule_id", ""), "rule_id", maximum=64), "RuleName": _safe_text(raw.get("rule_name", ""), "rule_name", maximum=512), "Severity": sev.capitalize(), - "SeverityScore": sev_map.get(sev, 0), + "SeverityScore": severity_rank(sev), "Description": _safe_text(raw.get("description", ""), "description"), "Remediation": _safe_text(raw.get("remediation", ""), "remediation"), "CisControl": _safe_text(compliance.get("cis", ""), "compliance.cis", maximum=128), diff --git a/tests/test_clean_scan.py b/tests/test_clean_scan.py index 0df83325..55bbfbf5 100644 --- a/tests/test_clean_scan.py +++ b/tests/test_clean_scan.py @@ -198,3 +198,48 @@ def test_get_compliance_score_remediated_rule_shows_pass(): result = db.get_compliance_score("cis") assert result["controls"][0]["status"] == "PASS" + + +def test_get_compliance_score_reports_worst_critical_failure_without_inventing_pass_severity(): + db = _db() + conn = MagicMock() + cur = _mock_cursor( + [ + ("AZ-STOR-001", "HIGH", "Storage", 1), + ("AZ-STOR-001", "CRITICAL", "Storage", 2), + ] + ) + conn.cursor.return_value = cur + + import io + import json + from pathlib import Path + + fake_framework = json.dumps( + { + "framework": "CIS Azure", + "version": "2.0", + "controls": { + "AZ-STOR-001": {"control_id": "3.1", "control_name": "No public blobs"}, + "AZ-NET-001": {"control_id": "6.1", "control_name": "No unrestricted SSH"}, + }, + } + ) + + with patch.object(db, "_get_conn", return_value=conn): + with patch("builtins.open", return_value=io.StringIO(fake_framework)): + with patch.object(Path, "exists", return_value=True): + result = db.get_compliance_score("cis") + + controls = {control["rule_id"]: control for control in result["controls"]} + assert controls["AZ-STOR-001"] == { + "rule_id": "AZ-STOR-001", + "control_id": "3.1", + "control_name": "No public blobs", + "status": "FAIL", + "severity": "CRITICAL", + "category": "Storage", + "resources": 3, + } + assert controls["AZ-NET-001"]["status"] == "PASS" + assert controls["AZ-NET-001"]["severity"] is None diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py index 974ae1be..e7006d5c 100644 --- a/tests/test_engine_integration.py +++ b/tests/test_engine_integration.py @@ -8,7 +8,10 @@ from pathlib import Path +import pytest + import scanner.engine as engine_mod +from openshield.severity import SeverityContractError, score_findings from scanner.engine import ScanEngine from tests.helpers.mock_azure import MockAzureClient, make_resource @@ -59,7 +62,7 @@ def test_engine_loads_all_57_rules(monkeypatch): def test_engine_discovery_matches_ci_and_ignores_misnamed_modules(monkeypatch, tmp_path): """Only CI-validated az_*.py modules may execute at scan time.""" (tmp_path / "az_valid_001.py").write_text( - 'RULE_ID = "AZ-VALID-001"\n\ndef scan(azure_client, subscription_id):\n return []\n', + 'RULE_ID = "AZ-VALID-001"\nSEVERITY = "LOW"\n\ndef scan(azure_client, subscription_id):\n return []\n', encoding="utf-8", ) (tmp_path / "scratch_test.py").write_text( @@ -78,6 +81,20 @@ def test_engine_discovery_matches_ci_and_ignores_misnamed_modules(monkeypatch, t assert [rule.RULE_ID for rule in eng.rules] == ["AZ-VALID-001"] +@pytest.mark.parametrize("severity", ["INFORMATIONAL", "URGENT"]) +def test_engine_rejects_noncanonical_rule_declarations(monkeypatch, tmp_path, severity): + (tmp_path / "az_invalid_001.py").write_text( + f'RULE_ID = "AZ-INVALID-001"\nSEVERITY = "{severity}"\n\n' + "def scan(azure_client, subscription_id):\n return []\n", + encoding="utf-8", + ) + monkeypatch.setattr(engine_mod, "RULES_DIR", Path(tmp_path)) + _patch_engine_client(monkeypatch, _offline_mock()) + + with pytest.raises(SeverityContractError): + ScanEngine(_SUB) + + def test_engine_run_scan_result_is_self_consistent(monkeypatch): """total_findings, the findings list, and score must be mutually consistent.""" client = _offline_mock() @@ -105,9 +122,7 @@ def test_engine_run_scan_result_is_self_consistent(monkeypatch): assert result["total_findings"] > 0 # the public storage account triggers findings # Score is 100 minus severity-weighted deductions, floored at 0. - weights = {"HIGH": 10, "MEDIUM": 5, "LOW": 2} - expected_deduction = sum(weights.get((f.get("severity") or "").upper(), 0) for f in result["findings"]) - assert result["score"] == max(0, 100 - expected_deduction) + assert result["score"] == score_findings(result["findings"]) assert 0 <= result["score"] <= 100 # Every finding is tagged with the scan_id and a detected_at timestamp. diff --git a/tests/test_error_exposure.py b/tests/test_error_exposure.py index 2610ffdc..f098ccdb 100644 --- a/tests/test_error_exposure.py +++ b/tests/test_error_exposure.py @@ -92,7 +92,7 @@ def test_resources_error_does_not_leak_exception(client, auth_headers): def test_prioritization_error_does_not_leak_exception(client, auth_headers): - with patch.object(prioritization_route, "_get_db", return_value=_raising_db("get_findings")): + with patch.object(prioritization_route, "_get_db", return_value=_raising_db("_get_conn")): resp = client.get("/api/prioritization", headers=auth_headers) _assert_no_leak(resp, 500) diff --git a/tests/test_input_validation.py b/tests/test_input_validation.py index 4536302a..86931256 100644 --- a/tests/test_input_validation.py +++ b/tests/test_input_validation.py @@ -118,6 +118,7 @@ def test_compliance_framework_rejects_untrusted_value_without_reflection(client, {"provider": "groq", "api_key": "x" * (MAX_API_KEY_LENGTH + 1), "findings": [{}]}, {"provider": "groq", "api_key": "secret", "model": "../model", "findings": [{}]}, {"provider": "groq", "api_key": "secret", "findings": ["not-an-object"]}, + {"provider": "groq", "api_key": "secret", "findings": [{"severity": "urgent"}]}, {"provider": "groq", "api_key": "secret", "findings": [{}] * (MAX_FINDINGS + 1)}, { "provider": "groq", @@ -148,3 +149,17 @@ def test_safe_request_id_is_preserved(client): def test_oversized_authorization_header_is_rejected(client): response = client.get("/api/findings", headers={"Authorization": "Bearer " + ("x" * 9000)}) assert response.status_code == 401 + + +@pytest.mark.parametrize( + ("supplied", "expected"), + [("critical", "CRITICAL"), ("INFORMATIONAL", "INFO")], +) +def test_finding_severity_filters_are_canonicalized(client, auth_headers, supplied, expected): + db = MagicMock() + db.get_findings.return_value = [] + with patch.object(findings_route, "_get_db", return_value=db): + response = client.get(f"/api/findings?severity={supplied}", headers=auth_headers) + + assert response.status_code == 200 + db.get_findings.assert_called_once_with({"severity": expected}) diff --git a/tests/test_prioritization.py b/tests/test_prioritization.py new file mode 100644 index 00000000..350c0fd3 --- /dev/null +++ b/tests/test_prioritization.py @@ -0,0 +1,57 @@ +"""Prioritization API severity-contract regressions.""" + +from unittest.mock import MagicMock, patch + +import api.routes.prioritization as prioritization_route + + +class _PrioritizationCursor: + def __init__(self): + self.query = "" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, query, params=None): + self.query = query + + def fetchone(self): + return {"scan_id": "00000000-0000-0000-0000-000000000000"} + + def fetchall(self): + if "GROUP BY rule_id" in self.query: + return [ + { + "rule_id": "AZ-SC-005", + "rule_name": "Unsigned production image", + "severity": "CRITICAL", + "category": "Supply Chain", + "remediation": "Sign and verify the image.", + "affected_count": 1, + "resource_name": "prod-acr", + } + ] + return [{"severity": "CRITICAL", "count": 3}] + + +def test_critical_prioritization_uses_contract_risk_weight_and_counts(client, auth_headers): + cursor = _PrioritizationCursor() + connection = MagicMock() + connection.cursor.return_value = cursor + db = MagicMock() + db._get_conn.return_value = connection + + with patch.object(prioritization_route, "_get_db", return_value=db): + response = client.get("/api/prioritization", headers=auth_headers) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["matrix"][0]["risk"] == 10 + assert payload["rankings"][0]["score"] == 20 + assert payload["rankings"][0]["impact"] == "CRITICAL" + assert payload["action_items"][0]["impact"] == "CRITICAL" + assert payload["summary"]["criticalFindings"] == 3 + assert payload["summary"]["highRiskFindings"] == 0 diff --git a/tests/test_render_deploy_config.py b/tests/test_render_deploy_config.py index ea34c8ca..425e32c0 100644 --- a/tests/test_render_deploy_config.py +++ b/tests/test_render_deploy_config.py @@ -82,15 +82,15 @@ def test_each_smoke_secret_is_required_when_enabled(secret): render_deploy_preflight.validate(env) -def test_workflow_orders_preflight_dual_deploy_waits_and_gates(): +def test_workflow_orders_api_migration_before_worker_deploy_and_gates(): workflow = yaml.safe_load((ROOT / ".github/workflows/deploy.yml").read_text(encoding="utf-8")) job = workflow["jobs"]["deploy"] steps = job["steps"] names = [step["name"] for step in steps] assert job["environment"] == "${{ inputs.environment }}" assert names.index("Validate deployment preflight") < names.index("Create API deployment") - assert names.index("Create API deployment") < names.index("Create worker deployment") - assert names.index("Create worker deployment") < names.index("Wait for API deployment") + assert names.index("Create API deployment") < names.index("Wait for API deployment") + assert names.index("Wait for API deployment") < names.index("Create worker deployment") assert names.index("Create worker deployment") < names.index("Wait for worker deployment") assert names.index("Require both deployments to be live") < names.index("Health gate check") assert names.index("Health gate check") < names.index("Run smoke tests against live deployment") @@ -99,6 +99,7 @@ def test_workflow_orders_preflight_dual_deploy_waits_and_gates(): api_create = by_name["Create API deployment"] worker_create = by_name["Create worker deployment"] assert api_create["env"]["GITHUB_SHA"] == worker_create["env"]["GITHUB_SHA"] == "${{ github.sha }}" + assert "continue-on-error" not in by_name["Wait for API deployment"] assert by_name["Wait for API deployment"]["env"]["RENDER_DEPLOY_ID"] == "${{ steps.create_api.outputs.deploy_id }}" assert by_name["Wait for worker deployment"]["env"]["RENDER_DEPLOY_ID"] == ( "${{ steps.create_worker.outputs.deploy_id }}" diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 00000000..85ea2897 --- /dev/null +++ b/tests/test_resources.py @@ -0,0 +1,59 @@ +"""Resource API severity-contract regressions.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import api.routes.resources as resources_route + + +class _ResourceCursor: + def __init__(self): + self.query = "" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, query, params=None): + self.query = query + + def fetchone(self): + return { + "scan_id": "00000000-0000-0000-0000-000000000000", + "started_at": datetime(2026, 8, 21, tzinfo=timezone.utc), + } + + def fetchall(self): + return [ + { + "resource_id": ( + "/subscriptions/00000000-0000-0000-0000-000000000001/" + "resourceGroups/rg-prod/providers/Microsoft.KeyVault/vaults/prod-kv" + ), + "resource_name": "prod-kv", + "resource_type": "Microsoft.KeyVault/vaults", + "category": "KeyVault", + "discovered_at": datetime(2026, 8, 21, tzinfo=timezone.utc), + "risk_rank": 4, + } + ] + + +def test_critical_only_resource_is_not_reported_as_none(client, auth_headers): + cursor = _ResourceCursor() + connection = MagicMock() + connection.cursor.return_value = cursor + db = MagicMock() + db._get_conn.return_value = connection + + with patch.object(resources_route, "_get_db", return_value=db): + response = client.get("/api/resources", headers=auth_headers) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["resources"][0]["risk"] == "CRITICAL" + assert payload["summary"]["by_risk_level"]["CRITICAL"] == 1 + assert payload["summary"]["by_risk_level"]["NONE"] == 0 + assert "WHEN 'CRITICAL' THEN 4" in cursor.query diff --git a/tests/test_sentinel_input_validation.py b/tests/test_sentinel_input_validation.py index 367c054a..2157fafe 100644 --- a/tests/test_sentinel_input_validation.py +++ b/tests/test_sentinel_input_validation.py @@ -30,6 +30,12 @@ def test_normalise_rejects_non_object_and_invalid_severity(): normalise({"severity": "urgent"}, "scan-1") +@pytest.mark.parametrize("finding", ({}, {"severity": None}, {"severity": ""})) +def test_normalise_rejects_missing_or_empty_severity(finding): + with pytest.raises(ValidationError, match="severity is required"): + normalise(finding, "scan-1") + + def test_normalise_accepts_bounded_finding(): record = normalise( {"id": 1, "severity": "HIGH", "rule_id": "AZ-NET-001", "compliance": {"cis": "1.1"}}, @@ -39,6 +45,12 @@ def test_normalise_accepts_bounded_finding(): assert record["RuleId"] == "AZ-NET-001" +def test_normalise_canonicalizes_informational_alias(): + record = normalise({"severity": "INFORMATIONAL"}, "scan-1") + assert record["Severity"] == "Info" + assert record["SeverityScore"] == 0 + + def test_main_handles_invalid_config_without_traceback(monkeypatch, capsys): monkeypatch.setattr(ingest, "WORKSPACE_ID", "") monkeypatch.setattr(ingest.sys, "argv", ["ingest.py"]) diff --git a/tests/test_severity_contract.py b/tests/test_severity_contract.py new file mode 100644 index 00000000..097ce33b --- /dev/null +++ b/tests/test_severity_contract.py @@ -0,0 +1,226 @@ +"""Cross-layer regression tests for finding severity contract v1.""" + +import ast +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from api.models.finding import DatabaseManager +from openshield.severity import ( + ACCEPTED_SEVERITIES, + CANONICAL_SEVERITIES, + CONTRACT_VERSION, + LEVELS, + SeverityContractError, + normalize_severity, + score_counts, + score_findings, + severity_rank, + severity_risk_score, + severity_weight, +) +from scanner.engine import ScanEngine + +ROOT = Path(__file__).resolve().parents[1] + + +def _declared_severity(path: Path): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "SEVERITY": + return ast.literal_eval(node.value) + raise AssertionError(f"{path} has no literal SEVERITY declaration") + + +def _db() -> DatabaseManager: + db = DatabaseManager.__new__(DatabaseManager) + db.dsn = "postgresql://mock/mock" + db.conn = None + return db + + +def _cursor(rows=None): + cursor = MagicMock() + cursor.__enter__ = lambda value: value + cursor.__exit__ = MagicMock(return_value=False) + cursor.fetchall.return_value = rows or [] + return cursor + + +def test_contract_v1_is_ordered_and_critical_is_highest(): + assert CONTRACT_VERSION == "1.0.0" + assert {level.id for level in LEVELS} == {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"} + assert severity_rank("CRITICAL") == max(level.rank for level in LEVELS) + assert severity_weight("CRITICAL") == 20 + assert severity_risk_score("CRITICAL") == 10 + assert len({level.rank for level in LEVELS}) == len(LEVELS) + + +def test_aliases_are_canonicalized_and_unknown_values_are_rejected(): + assert "INFORMATIONAL" in ACCEPTED_SEVERITIES + assert "INFORMATIONAL" not in CANONICAL_SEVERITIES + assert normalize_severity(" informational ") == "INFO" + with pytest.raises(SeverityContractError, match="unsupported severity"): + normalize_severity("urgent") + + +def test_critical_findings_reduce_score_and_floor_at_zero(): + assert score_findings([{"severity": "CRITICAL"}]) == 80 + assert score_findings([{"severity": "CRITICAL"}] * 5) == 0 + with pytest.raises(SeverityContractError): + score_findings([{"severity": "unknown"}]) + with pytest.raises(SeverityContractError, match="non-negative integers"): + score_counts({"CRITICAL": -1}) + + +def test_every_rule_declares_a_canonical_severity(): + rule_paths = sorted((ROOT / "scanner" / "rules").glob("az_*.py")) + assert rule_paths + for path in rule_paths: + assert _declared_severity(path) in CANONICAL_SEVERITIES, path.name + + +def test_engine_rejects_invalid_finding_severity(): + engine = ScanEngine.__new__(ScanEngine) + engine.subscription_id = "00000000-0000-0000-0000-000000000000" + engine.client = MagicMock() + engine.rules = [ + SimpleNamespace( + RULE_ID="AZ-TEST-001", + scan=lambda *_: [{"severity": "URGENT", "rule_id": "AZ-TEST-001"}], + ) + ] + + with pytest.raises(SeverityContractError): + engine.run_scan() + + +def test_engine_publishes_only_canonical_object_findings_without_mutating_rule_data(): + raw_finding = {"severity": "critical", "rule_id": "AZ-TEST-001"} + engine = ScanEngine.__new__(ScanEngine) + engine.subscription_id = "00000000-0000-0000-0000-000000000000" + engine.client = MagicMock() + engine.rules = [ + SimpleNamespace( + RULE_ID="AZ-TEST-001", + scan=lambda *_: ["not-an-object", raw_finding], + ) + ] + + result = engine.run_scan() + + assert result["total_findings"] == 1 + assert result["findings"][0]["severity"] == "CRITICAL" + assert result["score"] == 80 + assert result["severity_contract_version"] == CONTRACT_VERSION + assert raw_finding == {"severity": "critical", "rule_id": "AZ-TEST-001"} + + +def test_database_score_uses_the_same_critical_weight_as_engine(): + db = _db() + conn = MagicMock() + conn.cursor.return_value = _cursor([("CRITICAL", 1)]) + with patch.object(db, "_get_conn", return_value=conn): + assert db.get_score() == 80 + + +def test_persistence_rejects_invalid_severity_before_opening_connection(): + db = _db() + result = { + "scan_id": "00000000-0000-0000-0000-000000000000", + "subscription_id": "00000000-0000-0000-0000-000000000001", + "started_at": "2026-08-21T00:00:00+00:00", + "findings": [{"severity": "URGENT"}], + } + with patch.object(db, "_get_conn") as get_conn: + with pytest.raises(SeverityContractError): + db.save_scan(result) + get_conn.assert_not_called() + + +def test_persistence_canonicalizes_alias_and_records_contract_version(): + db = _db() + cursor = _cursor() + conn = MagicMock() + conn.cursor.return_value = cursor + raw_finding = { + # A stale/caller-supplied child ID must never cross-attach a finding. + "scan_id": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "rule_id": "AZ-TEST-001", + "rule_name": "Test finding", + "severity": "INFORMATIONAL", + "category": "Inventory", + "resource_id": "/subscriptions/test/resources/example", + "resource_name": "example", + "resource_type": "Microsoft.Test/resources", + "description": "Test description", + "remediation": "Test remediation", + "playbook": "playbooks/cli/fix_az_test_001.sh", + "frameworks": {}, + "metadata": {}, + "detected_at": "2026-08-21T00:00:00+00:00", + } + result = { + "scan_id": "00000000-0000-0000-0000-000000000000", + "subscription_id": "00000000-0000-0000-0000-000000000001", + "started_at": "2026-08-21T00:00:00+00:00", + "findings": [raw_finding], + } + + with patch.object(db, "_get_conn", return_value=conn): + db.save_scan(result) + + scan_parameters = cursor.execute.call_args_list[0].args[1] + delete_parameters = cursor.execute.call_args_list[1].args[1] + finding_parameters = cursor.execute.call_args_list[2].args[1] + assert scan_parameters[4] == 1 + assert scan_parameters[5] == 100 + assert scan_parameters[10] == CONTRACT_VERSION + assert delete_parameters == (result["scan_id"],) + assert finding_parameters[0] == result["scan_id"] + assert finding_parameters[3] == "INFO" + assert raw_finding["severity"] == "INFORMATIONAL" + conn.commit.assert_called_once_with() + conn.rollback.assert_not_called() + + +def test_persistence_rolls_back_a_failed_atomic_replacement(): + db = _db() + cursor = _cursor() + cursor.execute.side_effect = [None, None, RuntimeError("insert failed")] + conn = MagicMock() + conn.cursor.return_value = cursor + result = { + "scan_id": "00000000-0000-0000-0000-000000000000", + "subscription_id": "00000000-0000-0000-0000-000000000001", + "started_at": "2026-08-21T00:00:00+00:00", + "findings": [{"rule_id": "AZ-TEST-001", "severity": "HIGH"}], + } + + with patch.object(db, "_get_conn", return_value=conn): + with pytest.raises(RuntimeError, match="insert failed"): + db.save_scan(result) + + conn.rollback.assert_called_once_with() + conn.commit.assert_not_called() + + +def test_v1_migration_freezes_the_same_ids_and_weights_as_the_contract(): + migration = (ROOT / "alembic" / "versions" / "d8e4f6a1b2c3_severity_contract_v1.py").read_text(encoding="utf-8") + for level in LEVELS: + assert f"WHEN '{level.id}' THEN {level.score_weight}" in migration + assert f"'{level.id}'" in migration + assert "severity_contract_version" in migration + assert "server_default" not in migration + + +def test_worker_deploy_waits_for_migration_owning_api(): + workflow = (ROOT / ".github" / "workflows" / "deploy.yml").read_text(encoding="utf-8") + wait_api = workflow.index("- name: Wait for API deployment") + create_worker = workflow.index("- name: Create worker deployment") + assert wait_api < create_worker + assert "continue-on-error" not in workflow[wait_api:create_worker]