diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fa5aad..46f174f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,17 +328,27 @@ jobs: print(f"All compliance controls map to existing rule files. ({len(existing_ids)} rules checked)") PYEOF + # ── CHECK 8: Rule regression tests (MockAzureClient, no Azure creds) ── + - name: Rule regression tests + id: rule_tests + env: + DATABASE_URL: "postgresql://ci:ci@localhost/ci_db" + run: | + echo "=== Running rule regression tests ===" + pytest tests/test_rules_*.py -v --tb=short + # ── Final summary — always runs, shows per-check pass/fail ──────── - name: CI Summary if: always() env: - SYNTAX: ${{ steps.syntax_check.outcome }} - STRUCTURE: ${{ steps.structure_check.outcome }} - CREDS: ${{ steps.cred_scan.outcome }} - PLAYBOOK: ${{ steps.playbook_check.outcome }} - JSON: ${{ steps.json_check.outcome }} - API: ${{ steps.api_check.outcome }} - XREF: ${{ steps.xref_check.outcome }} + SYNTAX: ${{ steps.syntax_check.outcome }} + STRUCTURE: ${{ steps.structure_check.outcome }} + CREDS: ${{ steps.cred_scan.outcome }} + PLAYBOOK: ${{ steps.playbook_check.outcome }} + JSON: ${{ steps.json_check.outcome }} + API: ${{ steps.api_check.outcome }} + XREF: ${{ steps.xref_check.outcome }} + RULE_TESTS: ${{ steps.rule_tests.outcome }} run: | python - <<'PYEOF' import os @@ -351,6 +361,7 @@ jobs: ("Compliance JSON validation", os.environ["JSON"]), ("API syntax check", os.environ["API"]), ("Compliance vs rule cross-reference", os.environ["XREF"]), + ("Rule regression tests", os.environ["RULE_TESTS"]), ] labels = { diff --git a/README.md b/README.md index 2c72564..de0b752 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,16 @@ MIT — free to use, modify, and distribute. ## Learn OpenShield +Learn OpenShield covers: + +- Azure CSPM fundamentals +- OpenShield architecture +- Compliance mappings +- Remediation workflows +- Contributor onboarding +- Documentation navigation + +Live Learning Portal: https://openshieldlearn.netlify.app/learn/ Full documentation, the security rules gallery, blog, and interactive playground are available at the project website: **[openshield-website.vercel.app](https://openshield-website.vercel.app)** diff --git a/api/models/finding.py b/api/models/finding.py index 5c9634b..ea48380 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -136,7 +136,8 @@ def create_tables(self) -> None: started_at TIMESTAMPTZ NOT NULL, completed_at TIMESTAMPTZ, total_findings INTEGER DEFAULT 0, - score INTEGER DEFAULT NULL + score INTEGER DEFAULT NULL, + cve_enrichment_status TEXT DEFAULT 'PENDING' ); """) cur.execute(""" @@ -203,6 +204,10 @@ def run_migrations(self) -> None: ADD COLUMN IF NOT EXISTS cvss_score FLOAT DEFAULT NULL, ADD COLUMN IF NOT EXISTS exploit_available BOOLEAN DEFAULT FALSE """) + cur.execute(""" + ALTER TABLE scans + ADD COLUMN IF NOT EXISTS cve_enrichment_status TEXT DEFAULT 'PENDING' + """) conn.commit() logger.info("CVE migrations applied successfully") except Exception as e: @@ -219,8 +224,8 @@ def save_scan(self, scan_result: Dict[str, Any]) -> None: with conn.cursor() as cur: cur.execute( """ - INSERT INTO scans (scan_id, subscription_id, started_at, completed_at, total_findings, score) - VALUES (%s, %s, %s, %s, %s, %s) + INSERT INTO scans (scan_id, subscription_id, started_at, completed_at, total_findings, score, cve_enrichment_status) + VALUES (%s, %s, %s, %s, %s, %s, %s) ON CONFLICT (scan_id) DO NOTHING """, ( @@ -230,6 +235,7 @@ def save_scan(self, scan_result: Dict[str, Any]) -> None: scan_result["completed_at"], scan_result["total_findings"], scan_result.get("score"), + scan_result.get("cve_enrichment_status", "PENDING"), ), ) for f in scan_result.get("findings", []): @@ -345,6 +351,17 @@ def update_cve_fields(self, findings: List[Dict[str, Any]]) -> None: ) conn.commit() + def update_scan_enrichment_status(self, scan_id: str, status: str) -> None: + """Update the CVE enrichment status for a specific scan.""" + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + "UPDATE scans SET cve_enrichment_status = %s WHERE scan_id = %s", + (status, scan_id), + ) + conn.commit() + logger.info("Updated scan %s enrichment status to %s", scan_id, status) + def get_scans(self) -> List[Dict[str, Any]]: """Return all scan records ordered by most recent first.""" conn = self._get_conn() @@ -387,21 +404,25 @@ def get_cve_summary(self) -> Dict[str, Any]: conn = self._get_conn() with conn.cursor() as cur: cur.execute(""" - SELECT - COUNT(*) as total_findings, - COUNT(CASE WHEN exploit_available = TRUE THEN 1 END) as exploit_count, - MAX(cvss_score) as max_cvss_score, - AVG(cvss_score) as avg_cvss_score, - COUNT(CASE WHEN cvss_score >= 9.0 THEN 1 END) as critical_cve_count - FROM findings - WHERE scan_id = ( + SELECT + s.cve_enrichment_status, + COUNT(f.*) as total_findings, + COUNT(CASE WHEN f.exploit_available = TRUE THEN 1 END) as exploit_count, + MAX(f.cvss_score) as max_cvss_score, + AVG(f.cvss_score) as avg_cvss_score, + COUNT(CASE WHEN f.cvss_score >= 9.0 THEN 1 END) as critical_cve_count + FROM scans s + LEFT JOIN findings f ON s.scan_id = f.scan_id + WHERE s.scan_id = ( SELECT scan_id FROM scans WHERE total_findings > 0 ORDER BY started_at DESC LIMIT 1 ) + GROUP BY s.cve_enrichment_status """) row = cur.fetchone() if not row: return { + "status": "UNKNOWN", "total_findings": 0, "exploit_count": 0, "max_cvss_score": None, @@ -410,11 +431,12 @@ def get_cve_summary(self) -> Dict[str, Any]: } return { - "total_findings": row[0], - "exploit_count": row[1], - "max_cvss_score": row[2], - "avg_cvss_score": round(row[3], 2) if row[3] is not None else None, - "critical_cve_count": row[4], + "status": row[0], + "total_findings": row[1], + "exploit_count": row[2], + "max_cvss_score": row[3], + "avg_cvss_score": round(row[4], 2) if row[4] is not None else None, + "critical_cve_count": row[5], } def get_compliance_score(self, framework: str) -> Dict[str, Any]: diff --git a/api/routes/findings.py b/api/routes/findings.py index 9c9a9e3..054891b 100644 --- a/api/routes/findings.py +++ b/api/routes/findings.py @@ -6,7 +6,8 @@ from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager -from scanner.cve_correlator import enrich_findings + +_PLAYBOOKS_DIR = Path(__file__).parent.parent.parent / "playbooks" / "cli" _PLAYBOOKS_DIR = Path(__file__).parent.parent.parent / "playbooks" / "cli" @@ -39,16 +40,6 @@ def list_findings(): } db = _get_db() findings = db.get_findings(filters) - legacy_findings = [ - f - for f in findings - if f.get("cve_references") is None - and f.get("cvss_score") is None - and f.get("exploit_available") is None - ] - if legacy_findings: - enrich_findings(legacy_findings) - db.update_cve_fields(legacy_findings) return jsonify({"count": len(findings), "findings": findings}) except Exception as exc: logger.error("Failed to list findings: %s", exc) diff --git a/api/routes/scans.py b/api/routes/scans.py index 9a13009..54d5327 100644 --- a/api/routes/scans.py +++ b/api/routes/scans.py @@ -5,6 +5,7 @@ from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager +from scanner.cve_correlator import enrich_findings scans_bp = Blueprint("scans", __name__) logger = logging.getLogger(__name__) @@ -79,4 +80,50 @@ def trigger_scan(): except Exception as exc: logger.error("Critical error in trigger_scan route: %s", exc, exc_info=True) - return jsonify({"error": "Critical route failure", "detail": str(exc)}), 500 \ No newline at end of file + return jsonify({"error": "Critical route failure", "detail": str(exc)}), 500 + + +@scans_bp.post("/api/scans//enrich") +def enrich_scan(scan_id): + """Trigger CVE enrichment for an existing scan.""" + try: + db = _get_db() + + # Check current status to avoid redundant NVD calls + scans = db.get_scans() + current_scan = next((s for s in scans if str(s["scan_id"]) == scan_id), None) + + if not current_scan: + return jsonify({"error": "Scan not found"}), 404 + + status = current_scan.get("cve_enrichment_status") + if status == "COMPLETED": + return jsonify({"message": "Scan already enriched", "scan_id": scan_id}), 200 + if status == "ENRICHING": + return jsonify({"message": "Enrichment already in progress", "scan_id": scan_id}), 202 + + findings = db.get_findings({"scan_id": scan_id}) + if not findings: + return jsonify({"error": "No findings found for this scan"}), 404 + + logger.info("Enriching %d findings for scan %s", len(findings), scan_id) + db.update_scan_enrichment_status(scan_id, "ENRICHING") + + try: + enriched = enrich_findings(findings) + db.update_cve_fields(enriched) + db.update_scan_enrichment_status(scan_id, "COMPLETED") + except Exception as exc: + logger.error("Enrichment failed for scan %s: %s", scan_id, exc) + db.update_scan_enrichment_status(scan_id, "FAILED") + return jsonify({"error": "Enrichment failed", "detail": str(exc)}), 500 + + return jsonify({ + "scan_id": scan_id, + "status": "COMPLETED", + "enriched_count": len(enriched) + }) + + except Exception as exc: + logger.error("Failed to enrich scan %s: %s", scan_id, exc) + return jsonify({"error": "Internal server error", "detail": str(exc)}), 500 \ No newline at end of file diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 91661e8..ef60b90 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -77,6 +77,31 @@ "control_id": "1.15", "control_name": "Ensure that 'Guest invite restrictions' is set to 'Only users assigned to specific admin roles can invite guest users'", "description": "Unrestricted guest user invitation settings allow any member of the organisation to invite external users into the tenant without administrative review. This bypasses centralised approval for external identity provisioning and increases the risk of unauthorised access by untrusted parties." + }, + "AZ-IDN-005": { + "control_id": "1.3", + "control_name": "Ensure guest users are reviewed on a monthly basis", + "description": "Guest accounts assigned to high privilege roles in Entra ID allow external identities to perform administrative actions in the tenant. CIS 1.3 requires that guest users are reviewed and that privileged access is restricted to internal accounts only. Any guest user holding a role such as Global Administrator, Security Administrator, or User Administrator must have that assignment removed immediately." + }, + "AZ-IDN-006": { + "control_id": "1.14", + "control_name": "Ensure that service principal passwords are rotated within 90 days", + "description": "Service principal client secrets older than 90 days or with no expiry date represent a persistent credential risk. CIS 1.14 requires that service principal passwords are rotated at least every 90 days. Secrets that never expire remain valid indefinitely if leaked, giving an attacker permanent access to the application and its Azure permissions." + }, + "AZ-IDN-007": { + "control_id": "1.1", + "control_name": "Ensure that multi-factor authentication is enabled for all privileged users", + "description": "Active users in Entra ID with no MFA methods registered are vulnerable to password-based attacks including spray and phishing. CIS 1.1 requires that MFA is enabled for all users, particularly those with privileged access. Users without MFA registered must be required to enrol before they can access Azure resources." + }, + "AZ-IDN-008": { + "control_id": "1.23", + "control_name": "Ensure that custom subscription roles do not exist", + "description": "Custom RBAC roles with wildcard actions (*) at subscription scope grant Owner-equivalent permissions and violate the principle of least privilege. CIS 1.23 requires that custom subscription roles do not have wildcard permissions. These roles must be replaced with definitions that specify only the exact actions required for the intended use case." + }, + "AZ-IDN-009": { + "control_id": "5.2.1", + "control_name": "Ensure that activity log alert exists for Create Policy Assignment", + "description": "A subscription without an activity log alert for role assignment changes cannot detect privilege escalation in real time. CIS 5.2.1 requires that activity log alerts exist for administrative operations including role assignment writes. Without this alert, an attacker who elevates their own permissions will go undetected until the next manual review." }, "AZ-DB-001": { "control_id": "4.3.1", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index a777e39..cd7790c 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -77,6 +77,31 @@ "control_id": "A.9.2.1", "control_name": "User registration and de-registration", "description": "Unrestricted guest user invitations allow any organisation member to register external identities into the tenant without centralised review or approval. A.9.2.1 requires that users and external parties should be registered before access." + }, + "AZ-IDN-005": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "The allocation and use of privileged access rights must be restricted and controlled. Guest accounts in Entra ID with high privilege roles represent uncontrolled privileged access by external identities. A.9.2.3 requires that the allocation of privileged access rights is controlled through a formal authorisation process and that privileged roles are assigned only to internal accounts with a verified business need." + }, + "AZ-IDN-006": { + "control_id": "A.9.4.3", + "control_name": "Password management system", + "description": "Service principal client secrets with no expiry or older than 90 days violate password management controls. A.9.4.3 requires that password management systems enforce quality and lifecycle requirements including regular rotation. Non-expiring secrets must have an expiry date set and secrets older than 90 days must be rotated immediately." + }, + "AZ-IDN-007": { + "control_id": "A.9.4.2", + "control_name": "Secure log-on procedures", + "description": "Users without MFA registered in Entra ID authenticate with a single factor, which does not meet secure log-on requirements. A.9.4.2 requires that access to systems and applications is controlled by a secure log-on procedure. Multi-factor authentication must be required for all active user accounts to prevent unauthorised access through compromised passwords." + }, + "AZ-IDN-008": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "Custom RBAC roles with wildcard permissions at subscription scope are a form of uncontrolled privileged access that is harder to audit than built-in roles. A.9.2.3 requires that privileged access rights are allocated only through a formal authorisation process and are regularly reviewed. Custom roles with wildcard actions must be narrowed to specific required permissions or removed if unused." + }, + "AZ-IDN-009": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Subscriptions without an activity log alert for role assignment changes fail to generate actionable security events when privileged access is granted. A.12.4.1 requires that event logs recording user activities and security-relevant events are produced and maintained. An activity log alert for Microsoft.Authorization/roleAssignments/write must be configured and routed to a monitored channel." }, "AZ-DB-001": { "control_id": "A.13.1.1", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index c592c21..82cb9ca 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -77,6 +77,31 @@ "control_id": "PR.AC-1", "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", "description": "Unrestricted guest user invitations allow any organisation member to introduce external identities into the tenant without centralised review. PR.AC-1 requires that identities and credentials are managed and verified. Restricting guest invitations to administrators ensures external identity provisioning is controlled and audited." + }, + "AZ-IDN-005": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Guest users with high privilege roles in Entra ID violate the principle of least privilege and separation of duties. PR.AC-4 requires that access permissions and authorisations are managed, incorporating the principles of least privilege and separation of duties. External guest accounts must not hold privileged directory roles." + }, + "AZ-IDN-006": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are managed for authorised devices and users", + "description": "Client secrets on service principals that are older than 90 days or have no expiry violate credential lifecycle management requirements. PR.AC-1 requires that identities and credentials are issued, managed, verified, revoked, and audited for authorised devices, users, and processes. Long-lived secrets must be rotated or replaced with certificate-based or managed identity authentication." + }, + "AZ-IDN-007": { + "control_id": "PR.AC-7", + "control_name": "Users, devices, and other assets are authenticated", + "description": "Active Entra ID users without MFA registered rely solely on a password for authentication, which is insufficient against modern credential attacks. PR.AC-7 requires that users, devices, and other assets are authenticated commensurate with the risk of the transaction. MFA must be enforced for all active user accounts via Conditional Access policy." + }, + "AZ-IDN-008": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Custom RBAC roles containing wildcard action patterns grant unrestricted resource permissions equivalent to the Owner built-in role. PR.AC-4 requires that access permissions and authorisations are managed incorporating the principle of least privilege. Wildcard actions in custom role definitions must be replaced with the minimum specific actions required." + }, + "AZ-IDN-009": { + "control_id": "DE.CM-3", + "control_name": "Personnel activity is monitored to detect potential cybersecurity events", + "description": "The absence of an activity log alert for Microsoft.Authorization/roleAssignments/write means that privilege escalation events in the subscription are not detected in real time. DE.CM-3 requires that personnel activity is monitored to detect potential cybersecurity events. An alert must be configured to notify security personnel whenever a role assignment is created or modified." }, "AZ-DB-001": { "control_id": "PR.AC-3", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 285fb41..d6e1b92 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -92,6 +92,31 @@ "control_id": "CC6.1", "control_name": "Logical Access Security Measures", "description": "Unrestricted guest user invitations allow any organisation member to introduce unreviewed external identities into the tenant. CC6.1 requires that logical access to information assets is controlled and verified through authentication procedures." + }, + "AZ-IDN-005": { + "control_id": "CC6.3", + "control_name": "Role-based access control", + "description": "Guest users assigned high privilege roles in Entra ID give external parties administrative control over the Azure tenant. CC6.3 requires that role-based access controls restrict access to authorised internal users based on their responsibilities. Privileged roles must be removed from all guest accounts." + }, + "AZ-IDN-006": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "Service principal client secrets older than 90 days or with no expiry date represent unmanaged credentials that persist beyond their useful life. CC6.1 requires that logical access controls implement authentication measures to prevent unauthorised access. Stale or non-expiring secrets must be rotated and replaced with time-bound credentials or managed identities." + }, + "AZ-IDN-007": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "Active Entra ID users with no MFA registered can access Azure resources with a single compromised password. CC6.1 requires that logical access controls implement multi-factor authentication to protect against unauthorised access. Conditional Access policies must enforce MFA registration and usage for all active user accounts." + }, + "AZ-IDN-008": { + "control_id": "CC6.3", + "control_name": "Role-based access control", + "description": "Custom RBAC roles with wildcard permissions grant unconstrained access to subscription resources and undermine role-based access controls. CC6.3 requires that role-based access controls restrict access based on defined job responsibilities. Wildcard actions in custom roles must be replaced with explicit, minimal permission sets." + }, + "AZ-IDN-009": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "Without an activity log alert for role assignment changes, privilege escalation events in the subscription are not detected or investigated. CC7.2 requires that the entity monitors system components and the operation of controls to detect anomalies. An alert for Microsoft.Authorization/roleAssignments/write must be created and linked to an active action group." }, "AZ-DB-001": { "control_id": "CC6.7", diff --git a/docs/_redirects b/docs/_redirects new file mode 100644 index 0000000..fe0b625 --- /dev/null +++ b/docs/_redirects @@ -0,0 +1 @@ +/ /learn/ 302 diff --git a/docs/cve_correlation_feature.md b/docs/cve_correlation_feature.md index c1836eb..40052dd 100644 --- a/docs/cve_correlation_feature.md +++ b/docs/cve_correlation_feature.md @@ -18,20 +18,22 @@ The CVE Correlation feature integrates the MITRE National Vulnerability Database | File | Change | Why | |---|---|---| -| scanner/engine.py | Enrichment-at-Source. Integrated enrich_findings directly into the scan lifecycle. | Performance: By enriching during the scan, CVE data is saved once to the database. The frontend does not have to wait for an NVD API call when loading the dashboard. | -| api/models/finding.py | Updated Finding dataclass and added run_migrations and get_cve_summary. | Persistence: Adds cve_references, cvss_score, and exploit_available columns to PostgreSQL. get_cve_summary provides stats for dashboard widgets. | +| scanner/engine.py | Decoupled Scan. Removed synchronous enrichment from the scan lifecycle. | Performance: Azure scans now return immediately without waiting for NVD rate limits (7s per resource type). | +| api/routes/scans.py | New Endpoint. Added `POST /api/scans//enrich`. | Flexibility: CVE enrichment can now be triggered on-demand or by a background job after the scan completes. | +| api/models/finding.py | Updated Scan model and added enrichment status tracking. | Persistence: Adds `cve_enrichment_status` to track `PENDING`, `COMPLETED`, or `FAILED` states. | | api/app.py | Added db.run_migrations call at startup. | Auto-Deployment: Ensures the database schema is updated automatically on any environment where the app is launched. | -| api/routes/score.py | Added GET /api/score/cve-summary endpoint. | Dashboard UI: Provides the frontend with high-level data like Total Known Exploits in a single lightweight request. | -| api/routes/findings.py | Returns findings from the database and enriches only legacy rows missing CVE fields. | Performance: Avoids extra NVD calls on every request while still backfilling older records. | +| api/routes/score.py | Added GET /api/score/cve-summary endpoint. | Dashboard UI: Provides the frontend with high-level data like Total Known Exploits and enrichment status. | +| api/routes/findings.py | Returns findings from the database without JIT enrichment. | Performance: Ensures predictable and fast API responses for findings. | ## Frontend Integration Design -To ensure the frontend dashboard works perfectly, the architecture uses an Enrichment-at-Source model: +To ensure the frontend dashboard works perfectly, the architecture uses a Decoupled Enrichment model: -1. Zero-Latency Dashboard Loads: The scan engine pre-enriches findings. When the frontend calls the API, it receives static data from the database. Legacy rows missing CVE fields are enriched on-demand only once. -2. Dashboard-Ready Summary Endpoint: The /api/score/cve-summary endpoint allows the frontend to fetch high-level statistics (Total Findings, Exploit Count, Max CVSS) in one call instead of processing thousands of records locally. -3. Actionable Risk (CISA KEV): The exploit_available flag uses the CISA Known Exploited Vulnerabilities catalogue, allowing the dashboard to highlight high-priority risks that are being exploited in the wild. -4. Persistent Historical State: Enrichment happens at the time of scan, meaning the dashboard shows the CVE status as it existed on that day. This ensures accurate compliance and historical reporting. +1. Fast Dashboard Loads: The scan engine completes rapidly. The dashboard can check the enrichment status of the latest scan. +2. Manual/Job Enrichment: A "Trigger Enrichment" button or a background task calls `POST /api/scans//enrich` to populate CVE data. +3. Dashboard-Ready Summary Endpoint: The /api/score/cve-summary endpoint includes the `status` field, allowing the UI to show a "Scan Enriched" badge or a "Pending" spinner. +4. Actionable Risk (CISA KEV): The exploit_available flag uses the CISA Known Exploited Vulnerabilities catalogue, allowing the dashboard to highlight high-priority risks that are being exploited in the wild. +5. Persistent Historical State: Enrichment happens at the time of the enrichment call, and the result is persisted. ## Security and Compliance Audit @@ -55,17 +57,9 @@ Response shape (abridged): "rule_id": "AZ-STOR-003", "severity": "HIGH", "resource_id": "/subscriptions/...", - "cve_references": [ - { - "cve_id": "CVE-2023-12345", - "cvss_score": 9.8, - "cvss_severity": "CRITICAL", - "exploit_available": true, - "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2023-12345" - } - ], - "cvss_score": 9.8, - "exploit_available": true + "cve_references": [], + "cvss_score": null, + "exploit_available": false } ] } @@ -73,7 +67,7 @@ Response shape (abridged): Notes: 1. Results are ordered by detected_at descending and capped at 1000. -2. CVE fields are always present. Legacy rows are backfilled on request. +2. CVE fields are present but empty if enrichment has not been triggered. ### GET /api/score/cve-summary @@ -81,6 +75,7 @@ Response shape: ```json { + "status": "COMPLETED", "total_findings": 74, "exploit_count": 5, "max_cvss_score": 9.8, diff --git a/docs/learn/index.html b/docs/learn/index.html index 93c5164..7ca6219 100644 --- a/docs/learn/index.html +++ b/docs/learn/index.html @@ -3,64 +3,199 @@ + OpenShield Learn -
-
Open Source Azure CSPM Platform
-

OpenShield Learn

-

- A practical learning hub for understanding OpenShield, Azure cloud security posture management, - misconfiguration detection, compliance mapping, drift detection, and remediation workflows. -

- -
+ -
-
-

What is OpenShield?

-

- OpenShield is an open-source Azure CSPM platform designed to identify cloud misconfigurations, - map findings to compliance frameworks, monitor posture drift, and provide remediation guidance. It helps users understand - what is insecure, why it matters, and how to fix it. -

-
-
-

Misconfiguration Scanning

-

Checks Azure resources for risky settings that can expose data, weaken access control, or reduce security visibility.

-
-
-

Compliance Mapping

-

Connects security findings to frameworks such as CIS, NIST, and ISO so issues can be understood in a governance context.

+ + +
+
+
+
Open-source Azure CSPM platform
+

Learn Azure security posture with OpenShield.

+

+ OpenShield scans Azure subscriptions for misconfigurations, enriches findings with CVE intelligence, + maps risks to compliance frameworks, stores scan history, exposes a Flask API, and presents results through + a React dashboard with demo and live modes. +

+ -
-

Remediation Guidance

-

Provides practical fix guidance using Azure CLI, ARM templates, Terraform, and validation checks where applicable.

+

Static learning hub. No backend, no login, no fake upload flows.

+
+ + +
+ +
+
39Azure scan rules
+
39CLI remediation playbooks
+
4Compliance frameworks
+
8AI security skills
+
22High-severity checks
+
+
+ +
+
+
+
+

Overview

+

What OpenShield does

+

+ OpenShield is built to help users identify risky Azure configurations, understand the impact, connect findings + to compliance controls, and follow practical remediation guidance. It is not a cloud provider replacement or a SIEM; + it is a focused Azure CSPM platform for posture visibility and learning. +

+ +
+
+

Misconfiguration scanning

+

Dynamic Python rule modules inspect Azure resources through Azure SDK clients and return normalized security findings.

+
ScannerAzure SDKRules
+
+
+

CVE enrichment

+

Findings can be enriched with NVD/CVE context so security issues are easier to prioritize and explain.

+
NVDCVERisk context
+
+
+

Compliance mapping

+

Technical findings are mapped to CIS Azure, NIST CSF, ISO 27001, and SOC 2 for governance-oriented reporting.

+
CISNISTISO 27001SOC 2
+
+
+

Remediation guidance

+

Each rule is paired with a CLI playbook so contributors and users can move from detection to manual remediation.

+
Azure CLIPlaybooksValidation
+
+
-
-

How OpenShield Works

+
+

Architecture

+

Production-shaped, MVP-friendly architecture

- OpenShield follows a simple scanning pipeline: collect Azure resource configuration, evaluate rules, - generate findings, map them to controls, and expose results through the platform. + The platform follows a simple pipeline: Azure credentials are resolved by DefaultAzureCredential, the scan engine loads + rule files from scanner/rules/*.py, findings are enriched and stored, then exposed through the API and dashboard.

-
-
Azure Subscription
-
Scanner Engine
-
Rule Evaluation
-
Findings
-
Compliance Mapping
-
Drift Detection
-
Dashboard & Reporting
+ +
+
Azure SubscriptionResources and configuration
+
Scanner EnginePython rule execution
+
Rule Evaluation39 dynamic checks
+
CVE EnrichmentNVD risk context
+
PostgreSQLFindings and scan history
+
Flask APIJWT-protected REST routes
+
React DashboardDemo and live modes
+
Sentinel / AIKQL, RAG, insights
+
+ +
+

Scanner

Core engine, Azure SDK wrapper, NVD/CVE enrichment, and auto-loaded rule files.

+

API

Flask REST API with JWT authentication, CORS, migrations, scans, findings, score, compliance, and AI routes.

+

Frontend

Vite, React, and Tailwind dashboard covering monitoring, discovery, prioritization, compliance, drift, and AI.

+

AI

RAG knowledge pipeline, ChromaDB vector store builder, retriever, and cloud-security knowledge skills.

+

Sentinel

Optional Log Analytics ingestion plus KQL analytics rules for detection workflows.

+

CI and docs

Checks syntax, secrets, rule structure, playbooks, compliance JSON, API syntax, and cross-references.

-
-

Core Components

+
+

Rule coverage

+

39 Azure security rules

- OpenShield is built with a simple MVP-friendly architecture: Python scanner, Flask API, - PostgreSQL storage, React frontend, compliance mapping, Sentinel integration, and supporting remediation playbooks. + OpenShield currently has 39 dynamic rules. The strongest contributor work improves rule accuracy, reduces false positives, + strengthens validation, or improves remediation quality.

-
-
-

Scanner Engine

-

Python-based scanner that uses Azure SDK clients to inspect Azure resource configuration and evaluate security rules.

- PythonAzure SDK -
-
-

Flask API

-

Backend API layer responsible for exposing scan results, findings, metadata, and platform data to the frontend.

- FlaskREST API -
-
-

PostgreSQL

-

Stores scan findings, rule metadata, compliance mappings, and remediation-related information.

- DatabasePersistence -
-
-

React Dashboard

-

Frontend dashboard for viewing findings, severity, affected resources, and security posture information.

- ReactDashboard -
-
-

Playbooks

-

Remediation documents that explain how to fix detected issues using CLI, ARM templates, Terraform, and validation steps.

- Azure CLIARMTerraform -
-
-

Sentinel

-

Supports security monitoring and SIEM-focused documentation where OpenShield findings connect with detection workflows.

- SIEMDetection -
+ +
+
+

Coverage by category

+
+
Network
14
+
Storage
5
+
Key Vault
5
+
Compute
4
+
Database
4
+
Identity
4
+
PostQuantum
3
+
+
+ +
+

Severity distribution

+

Most checks are high severity. That makes validation important: high-severity false positives damage trust quickly.

+
+
22HIGH
+
13MEDIUM
+
4LOW
+
+

Known cleanup item: keep category names consistent, especially KeyVault vs Key Vault.

+
-
-

CSPM Basics

+
+

Learning roadmap

+

Recommended learning path

- Cloud Security Posture Management focuses on continuously identifying insecure cloud configurations. - In Azure, common examples include public storage exposure, weak network rules, missing logging, - overly permissive identities, and disabled security protections. + Follow this path if you are new to OpenShield or preparing to contribute. Learn the security problem before touching code.

-
-
-

Compliance Mapping

-

- A single security finding can map to multiple compliance controls. OpenShield uses mappings to connect - technical misconfigurations with security frameworks such as CIS Benchmarks, NIST CSF, ISO 27001, and SOC 2. -

-
-

CIS

Maps findings to cloud security benchmarks and configuration recommendations.

-

NIST

Connects findings to broader cybersecurity controls and risk management practices.

-

ISO 27001

Supports governance, information security controls, and audit-oriented reporting context.

-

SOC 2

Connects relevant findings to trust-service control areas such as security, availability, and confidentiality.

+ -
-

Remediation Philosophy

+
+

Contributors

+

Where contributors can help

- Detection alone is not enough. A useful CSPM tool should explain the risk, provide fix guidance, - and help validate whether the issue has actually been resolved. + Good contributions should improve detection accuracy, correctness of findings, remediation quality, documentation clarity, + or system reliability. Cosmetic work is useful only when it supports those goals.

+
-

Detect

Identify insecure Azure configuration accurately with minimal false positives.

-

Explain

Show why the finding matters, what resource is affected, and what the risk is.

-

Fix

Provide Azure CLI, ARM template, or Terraform-based remediation steps that users can apply safely.

-

Validate

Re-run checks or confirm settings to verify the misconfiguration is resolved.

+

Rules

Add or improve Azure checks with accurate metadata, safe SDK usage, realistic test cases, and clear findings.

+

Playbooks

Keep remediation scripts aligned with rules. Every fix should include validation and avoid unsafe blanket changes.

+

Compliance

Improve CIS, NIST, ISO 27001, and SOC 2 mappings. Do not map controls just to inflate coverage.

+

Frontend

Connect live API flows carefully. Do not leave mock-backed UI pretending to be production data.

+

Backend

Implement missing endpoints consistently with JWT auth, error handling, data contracts, and PostgreSQL models.

+

AI and Sentinel

Improve RAG quality, knowledge loading, KQL rules, and ingestion without exposing sensitive findings unnecessarily.

-
-

Contributor Learning Path

+
+

Known gaps

+

Current cleanup items

- New contributors should understand the security problem first, then the OpenShield architecture, - then the rule and remediation workflow. + These are not failures; they are useful follow-up targets. Documenting them prevents contributors from pretending the platform is more complete than it is.

-
-
-

Suggested Path

-
    -
  1. Understand CSPM fundamentals
  2. -
  3. Review the OpenShield architecture
  4. -
  5. Explore existing documentation and rules
  6. -
  7. Understand findings, mappings, and remediation playbooks
  8. -
  9. Add or improve rules and playbooks
  10. -
  11. Test changes against Azure safely
  12. -
-
-
-

Contribution Focus

-

Good contributions improve detection accuracy, remediation quality, documentation clarity, or platform reliability.

-
+ +
+
+

Documentation drift

+
    +
  • Some README/docs references still mention 20 rules while the repo has 39.
  • +
  • Some startup commands assume python, but local environments may only expose python3.
  • +
  • API docs and implementation should stay aligned, especially score response shape.
  • +
+
+
+

Implementation gaps

+
    +
  • Some frontend live pages depend on endpoints that may still be mock-backed.
  • +
  • Examples include resources, drift, prioritization, and finding-specific playbook routes.
  • +
  • Fix syntax issues before claiming AI pipeline readiness.
  • +
+
-
-

Documentation Links

+
+

Documentation

+

Useful repo documents

- Use these links as the starting point for understanding and contributing to OpenShield. + These relative links are intentionally static-hosting friendly when this file is served from the docs learning folder. + Adjust paths if the Learn page is moved.

+
-
ArchitectureSystem design, platform components, and scanning workflow.
- Open +
ArchitectureSystem design, scanner flow, platform components, and storage/API structure.
+ Open
-
API ReferenceBackend API documentation for working with OpenShield data.
- Open +
API ReferenceBackend routes for scans, findings, score, compliance, and AI-related data.
+ Open
-
Azure SetupRequired Azure setup and configuration before running scans.
- Open +
Azure SetupEnvironment variables, Azure credentials, and setup requirements for live scans.
+ Open
-
Rules ReferenceRule documentation and expected structure for security checks.
- Open +
Rules ReferenceRule metadata, categories, severity, expected output, and implementation guidance.
+ Open
-
Adding a RuleContributor guide for creating and testing new scan rules.
- Open +
Adding a RuleContributor workflow for implementing, testing, and documenting a new check.
+ Open +
+
+
CI PipelineLocal and GitHub Actions checks for rules, playbooks, compliance JSON, and API syntax.
+ Open +
+
+
CVE CorrelationNVD enrichment, CVSS scoring, exploit availability, and dashboard-ready CVE fields.
+ Open +
+
+
Sentinel SetupLog Analytics ingestion, OpenShield findings table setup, and KQL analytics rules.
+ Open +
+
+
API Render DeployRender deployment test plan, smoke testing, and production JWT requirements.
+ Open +
+
+
AZ-STOR-003 Test PlanLifecycle management policy rule test setup, execution, remediation, and validation.
+ Open
-
-

Open Source Goals

-

- OpenShield aims to make Azure security posture management easier to understand, easier to test, - and easier to improve through community contribution. -

-
-

Security Research

Encourage practical Azure misconfiguration research and rule development.

-

Education

Help learners understand CSPM, cloud controls, and secure Azure configuration.

-

Community

Build a contributor-friendly platform where improvements are clear and reviewable.

-
-
- -
-

Future Scope

-

- OpenShield can grow over time with richer dashboards, stronger compliance reports, - automated remediation workflows, and eventually broader cloud coverage. -

-
- -
- Note: This page is a static documentation hub. Do not add fake file upload buttons here. - Real uploads require backend storage, authentication, authorization, file validation, and access control. -
+
+ Note: OpenShield Learn is a documentation and learning portal. Features such as authentication, file uploads, scan execution, and data persistence require backend services and are intentionally not implemented in this static site. +
- OpenShield — Open Source Azure CSPM Platform | Learn, Contribute, Improve Azure Security +
+ + diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 3d9b92e..563f2b7 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -5,7 +5,6 @@ import { FiLoader, FiZap, FiCheckCircle, FiAlertCircle, FiClock, } from 'react-icons/fi'; import { api } from '../../utils/api'; -import Logo from '../shared/Logo'; const PAGE_TITLES = { '/monitoring': { title: 'Security Monitoring', subtitle: 'Overall health score and trends' }, diff --git a/playbooks/cli/fix_az_idn_005.sh b/playbooks/cli/fix_az_idn_005.sh new file mode 100644 index 0000000..38dccb1 --- /dev/null +++ b/playbooks/cli/fix_az_idn_005.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Playbook: fix_az_idn_005.sh +# Rule: AZ-IDN-005 — Guest users with high privilege roles in Entra ID + +set -euo pipefail + +echo "========================================" +echo " AZ-IDN-005 Remediation Playbook" +echo " Remove High Privilege Roles from Guest Users" +echo "========================================" +echo "" +echo "Guest accounts must not hold privileged roles in Entra ID." +echo "External identities with admin rights represent an uncontrolled risk." +echo "" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [user_principal_name]" + echo "" + echo "Step 1 — List all guest users with role assignments" + echo " az rest --method GET \\" + echo " --url \"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\\\$expand=principal\" \\" + echo " --query \"value[?principal.userType=='Guest'].{user:principal.userPrincipalName, role:roleDefinitionId}\"" + echo "" + echo "Step 2 — Remove the role assignment" + echo " az rest --method DELETE \\" + echo " --url \"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments/\"" + echo "" + exit 0 +fi + +TENANT_ID="$1" + +echo "Step 1 — Fetching guest role assignments in tenant $TENANT_ID" +az rest \ + --method GET \ + --url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$expand=principal" \ + --query "value[?principal.userType=='Guest'].{user:principal.userPrincipalName, assignmentId:id, roleId:roleDefinitionId}" \ + --output table 2>/dev/null \ + || echo "Run az login --tenant $TENANT_ID first and ensure RoleManagement.Read.Directory permission." + +if [[ $# -ge 2 ]]; then + UPN="$2" + echo "" + echo "Step 2 — Looking up role assignments for $UPN" + az rest \ + --method GET \ + --url "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$filter=principal/userPrincipalName eq '$UPN'" \ + --output json 2>/dev/null \ + || echo "Could not fetch assignments for $UPN" +fi + +echo "" +echo "To remove an assignment:" +echo " az rest --method DELETE \\" +echo " --url \"https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments/\"" +echo "" +echo "Remediation guidance complete." +echo "Re-run the scanner after removing assignments to verify compliance." diff --git a/playbooks/cli/fix_az_idn_006.sh b/playbooks/cli/fix_az_idn_006.sh new file mode 100644 index 0000000..5cc7283 --- /dev/null +++ b/playbooks/cli/fix_az_idn_006.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Playbook: fix_az_idn_006.sh +# Rule: AZ-IDN-006 — Service principal client secret older than 90 days + +set -euo pipefail + +echo "========================================" +echo " AZ-IDN-006 Remediation Playbook" +echo " Rotate Stale Service Principal Client Secrets" +echo "========================================" +echo "" +echo "Client secrets older than 90 days or with no expiry must be rotated." +echo "The safest long-term fix is to migrate to managed identities or certificates." +echo "" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [new_end_date]" + echo " app_id — Application (client) ID from the scanner finding" + echo " new_end_date — Optional expiry date in YYYY-MM-DD format (default: 90 days)" + echo "" + echo "Step 1 — List all application credentials" + echo " az ad app credential list --id --output table" + echo "" + echo "Step 2 — Reset the credential with a 90-day expiry" + echo " az ad app credential reset --id \\" + echo " --end-date \$(date -d '+90 days' +%Y-%m-%d)" + echo "" + echo "Step 3 — Update the consuming service with the new secret" + echo " Store the new secret in Azure Key Vault, not in config files." + echo "" + echo "Step 4 — Consider migrating to managed identity" + echo " az webapp identity assign --name --resource-group " + echo "" + exit 0 +fi + +APP_ID="$1" +END_DATE="${2:-$(date -d '+90 days' +%Y-%m-%d 2>/dev/null || date -v+90d +%Y-%m-%d)}" + +echo "Step 1 — Current credentials for app $APP_ID" +az ad app credential list --id "$APP_ID" --output table \ + || { echo "Could not list credentials. Run az login first."; exit 1; } + +echo "" +echo "Step 2 — Resetting credential with expiry $END_DATE" +echo "WARNING: This will generate a new secret. Update all services using this app." +read -r -p "Continue? (y/N): " confirm +if [[ "${confirm,,}" != "y" ]]; then + echo "Aborted." + exit 0 +fi + +az ad app credential reset --id "$APP_ID" --end-date "$END_DATE" + +echo "" +echo "New secret generated. Store it in Azure Key Vault immediately." +echo "Do not log or commit the secret value." +echo "" +echo "Remediation complete. Re-run the scanner after 24 hours to verify." diff --git a/playbooks/cli/fix_az_idn_007.sh b/playbooks/cli/fix_az_idn_007.sh new file mode 100644 index 0000000..ae4a868 --- /dev/null +++ b/playbooks/cli/fix_az_idn_007.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Playbook: fix_az_idn_007.sh +# Rule: AZ-IDN-007 — Active users with no MFA registered in Entra ID + +set -euo pipefail + +echo "========================================" +echo " AZ-IDN-007 Remediation Playbook" +echo " Enforce MFA Registration for All Users" +echo "========================================" +echo "" +echo "Users without MFA registered must be required to register before" +echo "they can access resources. Use Conditional Access to enforce this." +echo "" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + echo "" + echo "Step 1 — Identify users without MFA via the Graph report" + echo " az rest --method GET \\" + echo " --url \"https://graph.microsoft.com/v1.0/reports/credentialUserRegistrationDetails\" \\" + echo " --query \"value[?isMfaRegistered==\`false\` && isEnabled==\`true\`].userPrincipalName\" \\" + echo " --output tsv" + echo "" + echo "Step 2 — Create a Conditional Access policy requiring MFA" + echo " Navigate to: portal.azure.com" + echo " Go to: Entra ID > Protection > Conditional Access > Policies > New policy" + echo " Name: Require MFA for all users" + echo " Users: All users (exclude break-glass accounts)" + echo " Cloud apps: All cloud apps" + echo " Grant: Require multi-factor authentication" + echo " Enable policy: Report-only first, then On after review" + echo "" + echo "Step 3 — Enable the Authentication methods registration campaign" + echo " Go to: Entra ID > Protection > Authentication methods > Registration campaign" + echo " Enable the campaign to prompt users to register MFA on next sign-in" + echo "" + exit 0 +fi + +TENANT_ID="$1" + +echo "Step 1 — Users without MFA registered in tenant $TENANT_ID" +az rest \ + --method GET \ + --url "https://graph.microsoft.com/v1.0/reports/credentialUserRegistrationDetails?%24top=999" \ + --query "value[?isMfaRegistered==\`false\` && isEnabled==\`true\`].{user:userPrincipalName, mfaCapable:isMfaCapable}" \ + --output table 2>/dev/null \ + || echo "Run az login --tenant $TENANT_ID first and ensure Reports.Read.All permission." + +echo "" +echo "Step 2 — Create Conditional Access policy to require MFA (Portal only)" +echo " See: https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-all-users-mfa" +echo "" +echo "Remediation guidance complete." +echo "Re-run the scanner after the CA policy is in Report-only mode to track progress." diff --git a/playbooks/cli/fix_az_idn_008.sh b/playbooks/cli/fix_az_idn_008.sh new file mode 100644 index 0000000..906b532 --- /dev/null +++ b/playbooks/cli/fix_az_idn_008.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Playbook: fix_az_idn_008.sh +# Rule: AZ-IDN-008 — Custom RBAC role with wildcard permissions at subscription scope + +set -euo pipefail + +echo "========================================" +echo " AZ-IDN-008 Remediation Playbook" +echo " Narrow Custom RBAC Role Wildcard Permissions" +echo "========================================" +echo "" +echo "Custom roles with wildcard actions (*) grant Owner-equivalent permissions." +echo "Replace wildcards with the specific actions the role actually needs." +echo "" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [role_name]" + echo "" + echo "Step 1 — List all custom roles with wildcard actions" + echo " az role definition list --custom-role-only true \\" + echo " --query \"[?contains(permissions[0].actions, '*')].{name:roleName, actions:permissions[0].actions}\" \\" + echo " --output table" + echo "" + echo "Step 2 — Review what the role is actually used for" + echo " az role assignment list --role '' --all --output table" + echo "" + echo "Step 3 — Export the role definition" + echo " az role definition show --name '' > role.json" + echo "" + echo "Step 4 — Edit role.json to replace '*' with specific actions" + echo " Use the Azure built-in roles reference to find minimum required actions." + echo " See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles" + echo "" + echo "Step 5 — Update the role definition" + echo " az role definition update --role-definition role.json" + echo "" + echo "Step 6 — If the role is unused, delete it" + echo " az role definition delete --name ''" + echo "" + exit 0 +fi + +SUBSCRIPTION_ID="$1" + +echo "Step 1 — Custom roles with wildcard actions in subscription $SUBSCRIPTION_ID" +az role definition list \ + --custom-role-only true \ + --scope "/subscriptions/$SUBSCRIPTION_ID" \ + --query "[?contains(permissions[0].actions, '*')].{name:roleName, actions:permissions[0].actions}" \ + --output table \ + || { echo "Could not list role definitions. Run az login first."; exit 1; } + +if [[ $# -ge 2 ]]; then + ROLE_NAME="$2" + echo "" + echo "Step 2 — Assignments using role: $ROLE_NAME" + az role assignment list \ + --role "$ROLE_NAME" \ + --subscription "$SUBSCRIPTION_ID" \ + --all \ + --output table + + echo "" + echo "Step 3 — Exporting role definition to role_${ROLE_NAME// /_}.json" + az role definition show \ + --name "$ROLE_NAME" \ + --subscription "$SUBSCRIPTION_ID" \ + > "role_${ROLE_NAME// /_}.json" + echo "Edit the file, then run: az role definition update --role-definition role_${ROLE_NAME// /_}.json" +fi + +echo "" +echo "Remediation guidance complete." +echo "Re-run the scanner after updating role definitions to verify compliance." diff --git a/playbooks/cli/fix_az_idn_009.sh b/playbooks/cli/fix_az_idn_009.sh new file mode 100644 index 0000000..bd223ab --- /dev/null +++ b/playbooks/cli/fix_az_idn_009.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Playbook: fix_az_idn_009.sh +# Rule: AZ-IDN-009 — No activity log alert for role assignment changes + +set -euo pipefail + +echo "========================================" +echo " AZ-IDN-009 Remediation Playbook" +echo " Create Activity Log Alert for Role Assignment Changes" +echo "========================================" +echo "" +echo "An alert must exist for Microsoft.Authorization/roleAssignments/write" +echo "so that privilege escalation is detected in real time." +echo "" + +if [[ $# -lt 3 ]]; then + echo "Usage: $0 " + echo "" + echo "Step 1 — Confirm an action group exists (or create one)" + echo " az monitor action-group list --output table" + echo " az monitor action-group create \\" + echo " --name 'SecurityAlerts' \\" + echo " --resource-group \\" + echo " --short-name 'SecAlerts' \\" + echo " --email-receiver name='on-call' email='security@example.com'" + echo "" + echo "Step 2 — Create the activity log alert" + echo " az monitor activity-log alert create \\" + echo " --name 'Alert-RoleAssignment-Write' \\" + echo " --resource-group \\" + echo " --scope /subscriptions/ \\" + echo " --condition category=Administrative \\" + echo " operationName=Microsoft.Authorization/roleAssignments/write \\" + echo " --action-group " + echo "" + exit 0 +fi + +SUBSCRIPTION_ID="$1" +RESOURCE_GROUP="$2" +ACTION_GROUP_ID="$3" +ALERT_NAME="Alert-RoleAssignment-Write" + +echo "Step 1 — Checking existing activity log alerts..." +az monitor activity-log alert list \ + --subscription "$SUBSCRIPTION_ID" \ + --output table \ + || { echo "Could not list alerts. Run az login first."; exit 1; } + +echo "" +echo "Step 2 — Creating alert: $ALERT_NAME" +az monitor activity-log alert create \ + --name "$ALERT_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --subscription "$SUBSCRIPTION_ID" \ + --scope "/subscriptions/$SUBSCRIPTION_ID" \ + --condition \ + category=Administrative \ + operationName="Microsoft.Authorization/roleAssignments/write" \ + --action-group "$ACTION_GROUP_ID" \ + --description "Alerts when a role assignment is created or modified in the subscription." + +echo "" +echo "Alert '$ALERT_NAME' created successfully." +echo "Re-run the scanner to verify compliance." diff --git a/requirements.txt b/requirements.txt index fb9aabd..77c5511 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,5 @@ azure-keyvault-keys==4.9.0 chromadb==0.4.24 sentence-transformers==2.7.0 numpy<2.0 +pytest>=7.4.0 +pytest-cov>=4.1.0 diff --git a/scanner/engine.py b/scanner/engine.py index f65a341..99035b2 100644 --- a/scanner/engine.py +++ b/scanner/engine.py @@ -3,13 +3,11 @@ import importlib.util import logging import uuid -import json from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List from scanner.azure_client import AzureClient -from scanner.cve_correlator import enrich_findings logger = logging.getLogger(__name__) @@ -129,9 +127,6 @@ def run_scan(self) -> Dict[str, Any]: except Exception as exc: logger.error("Rule %s raised an exception: %s", rule_id, exc, exc_info=True) - logger.info("Enriching %d findings with CVE data...", len(findings)) - findings = enrich_findings(findings) - completed_at = datetime.now(timezone.utc).isoformat() severity_weights = {"HIGH": 10, "MEDIUM": 5, "LOW": 2} @@ -142,6 +137,7 @@ def run_scan(self) -> Dict[str, Any]: "scan_id": scan_id, "subscription_id": self.subscription_id, "status": "completed", + "cve_enrichment_status": "PENDING", "started_at": started_at, "completed_at": completed_at, "total_findings": len(findings), diff --git a/scanner/rules/az_idn_005.py b/scanner/rules/az_idn_005.py new file mode 100644 index 0000000..8a0e248 --- /dev/null +++ b/scanner/rules/az_idn_005.py @@ -0,0 +1,132 @@ +"""AZ-IDN-005: Guest users with high privilege roles in Entra ID.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-005" +RULE_NAME = "Guest User with High Privilege Role in Entra ID" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "1.3", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"} +DESCRIPTION = ( + "One or more guest user accounts (userType = Guest) have been assigned high " + "privilege roles in Entra ID. Guest accounts originate from outside the " + "organisation and should never hold privileged roles. A compromised guest " + "account with admin rights gives an external attacker full control of the " + "Azure tenant." +) +REMEDIATION = ( + "Remove privileged role assignments from all guest accounts. Navigate to: " + "Entra ID > Roles and administrators > [role name] > Assignments. " + "For each guest account found, click the assignment and select Remove. " + "Consider converting the guest to a member account or using a dedicated " + "internal service account for any legitimate administrative need." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_005.sh" + +logger = logging.getLogger(__name__) + +HIGH_RISK_ROLES = [ + "Global Administrator", + "Privileged Role Administrator", + "User Administrator", + "Security Administrator", + "Exchange Administrator", +] + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect guest users assigned to high privilege roles in Entra ID.""" + findings: List[Dict[str, Any]] = [] + + try: + import requests + + token = azure_client.credential.get_token( + "https://graph.microsoft.com/.default" + ) + headers = {"Authorization": f"Bearer {token.token}"} + + response = requests.get( + "https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions", + headers=headers, + timeout=30, + ) + response.raise_for_status() + role_definitions = { + r["id"]: r["displayName"] + for r in response.json().get("value", []) + if r.get("displayName") in HIGH_RISK_ROLES + } + + if not role_definitions: + return findings + + response = requests.get( + "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments", + headers=headers, + timeout=30, + ) + response.raise_for_status() + assignments = response.json().get("value", []) + + except Exception as exc: + logger.error("AZ-IDN-005: Failed to fetch data from Graph API: %s", exc) + logger.warning( + "AZ-IDN-005: Ensure the service principal has " + "RoleManagement.Read.Directory permission on Microsoft Graph." + ) + return findings + + for assignment in assignments: + role_def_id = assignment.get("roleDefinitionId", "") + if role_def_id not in role_definitions: + continue + + principal_id = assignment.get("principalId", "") + if not principal_id: + continue + + try: + user_resp = requests.get( + f"https://graph.microsoft.com/v1.0/users/{principal_id}" + "?$select=id,displayName,userPrincipalName,userType", + headers=headers, + timeout=30, + ) + if user_resp.status_code != 200: + continue + user = user_resp.json() + except Exception: + continue + + if user.get("userType") != "Guest": + continue + + role_name = role_definitions[role_def_id] + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": ( + f"/users/{principal_id}/roleAssignments/{assignment.get('id', '')}" + ), + "resource_name": user.get( + "displayName", user.get("userPrincipalName", principal_id) + ), + "resource_type": "Microsoft.Graph/users", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "user_id": principal_id, + "user_principal_name": user.get("userPrincipalName", ""), + "user_type": "Guest", + "role_name": role_name, + "role_definition_id": role_def_id, + }, + }) + + return findings diff --git a/scanner/rules/az_idn_006.py b/scanner/rules/az_idn_006.py new file mode 100644 index 0000000..2310cc3 --- /dev/null +++ b/scanner/rules/az_idn_006.py @@ -0,0 +1,145 @@ +"""AZ-IDN-006: Service principal client secret older than 90 days or with no expiry.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-006" +RULE_NAME = "Service Principal Client Secret Older Than 90 Days" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "1.14", "NIST": "PR.AC-1", "ISO27001": "A.9.4.3", "SOC2": "CC6.1"} +DESCRIPTION = ( + "One or more service principal applications have client secrets with a creation " + "date older than 90 days and no expiry date set, or secrets that have already " + "expired but remain present. Long-lived or non-expiring secrets are a major " + "credential hygiene risk. If a secret leaks it remains valid indefinitely, " + "giving an attacker persistent access to the application and its permissions." +) +REMEDIATION = ( + "Rotate all client secrets older than 90 days and set an expiry date of no more " + "than 90 days on new secrets. Run: az ad app credential reset --id " + "--years 0 --end-date . Consider migrating to certificate-based " + "authentication or managed identities to eliminate secret rotation entirely." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_006.sh" + +logger = logging.getLogger(__name__) + +EXPIRY_THRESHOLD_DAYS = 90 + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect service principals with stale or non-expiring client secrets.""" + findings: List[Dict[str, Any]] = [] + + try: + import requests + + token = azure_client.credential.get_token( + "https://graph.microsoft.com/.default" + ) + headers = {"Authorization": f"Bearer {token.token}"} + + next_url = ( + "https://graph.microsoft.com/v1.0/applications" + "?$select=id,displayName,appId,passwordCredentials&$top=100" + ) + applications = [] + while next_url: + response = requests.get(next_url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + applications.extend(data.get("value", [])) + next_url = data.get("@odata.nextLink") + + except Exception as exc: + logger.error( + "AZ-IDN-006: Failed to fetch applications from Graph API: %s", exc + ) + logger.warning( + "AZ-IDN-006: Ensure the service principal has " + "Application.Read.All permission on Microsoft Graph." + ) + return findings + + now = datetime.now(timezone.utc) + + for app in applications: + app_id = app.get("id", "") + app_display_name = app.get("displayName", app.get("appId", app_id)) + + for cred in app.get("passwordCredentials", []): + start_dt_str = cred.get("startDateTime") + end_dt_str = cred.get("endDateTime") + key_id = cred.get("keyId", "") + hint = cred.get("hint", "") + + if not start_dt_str: + continue + + try: + start_dt = datetime.fromisoformat( + start_dt_str.replace("Z", "+00:00") + ) + except ValueError: + continue + + age_days = (now - start_dt).days + no_expiry = end_dt_str is None + already_expired = False + + if end_dt_str: + try: + end_dt = datetime.fromisoformat( + end_dt_str.replace("Z", "+00:00") + ) + already_expired = end_dt < now + except ValueError: + logger.debug( + "AZ-IDN-006: Invalid endDateTime for app_id=%s key_id=%s: %r", + app_id, + key_id, + end_dt_str, + ) + + if not (age_days >= EXPIRY_THRESHOLD_DAYS or no_expiry or already_expired): + continue + + if no_expiry: + reason = "no expiry date set" + elif already_expired: + reason = "secret has expired but is still present" + else: + reason = ( + f"secret is {age_days} days old " + f"(threshold: {EXPIRY_THRESHOLD_DAYS} days)" + ) + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": ( + f"/applications/{app_id}/passwordCredentials/{key_id}" + ), + "resource_name": app_display_name, + "resource_type": "Microsoft.Graph/applications", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "app_id": app_id, + "app_client_id": app.get("appId", ""), + "credential_hint": hint, + "credential_key_id": key_id, + "age_days": age_days, + "no_expiry": no_expiry, + "already_expired": already_expired, + "reason": reason, + }, + }) + + return findings diff --git a/scanner/rules/az_idn_007.py b/scanner/rules/az_idn_007.py new file mode 100644 index 0000000..cc8230a --- /dev/null +++ b/scanner/rules/az_idn_007.py @@ -0,0 +1,97 @@ +"""AZ-IDN-007: Active users in Entra ID with no MFA methods registered.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-007" +RULE_NAME = "Active User with No MFA Registered in Entra ID" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "1.1", "NIST": "PR.AC-7", "ISO27001": "A.9.4.2", "SOC2": "CC6.1"} +DESCRIPTION = ( + "One or more active user accounts in Entra ID have no multi-factor " + "authentication methods registered. Accounts without MFA are vulnerable to " + "password spray, credential stuffing, and phishing attacks. A single " + "compromised password gives an attacker full account access with no additional " + "verification required." +) +REMEDIATION = ( + "Enforce MFA registration for all users via a Conditional Access policy. " + "Navigate to: Entra ID > Protection > Conditional Access > Policies > New policy. " + "Set Users to include all users, grant access requiring multi-factor " + "authentication, and enable the policy. Users without MFA registered will be " + "prompted on next sign-in. Use the Authentication methods registration campaign " + "to drive adoption before the policy enforcement date." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_007.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect active user accounts with no MFA methods registered.""" + findings: List[Dict[str, Any]] = [] + + try: + import requests + + token = azure_client.credential.get_token( + "https://graph.microsoft.com/.default" + ) + headers = {"Authorization": f"Bearer {token.token}"} + + next_url = ( + "https://graph.microsoft.com/v1.0/reports/credentialUserRegistrationDetails" + "?$top=999" + ) + registrations = [] + while next_url: + response = requests.get(next_url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + registrations.extend(data.get("value", [])) + next_url = data.get("@odata.nextLink") + + except Exception as exc: + logger.error( + "AZ-IDN-007: Failed to fetch MFA registration report from Graph API: %s", + exc, + ) + logger.warning( + "AZ-IDN-007: Ensure the service principal has " + "Reports.Read.All permission on Microsoft Graph." + ) + return findings + + for reg in registrations: + if not reg.get("isEnabled", True): + continue + if reg.get("isMfaRegistered", True): + continue + + user_id = reg.get("id", "") + user_principal_name = reg.get("userPrincipalName", "") + user_display_name = reg.get("userDisplayName", user_principal_name) + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/users/{user_id}", + "resource_name": user_display_name, + "resource_type": "Microsoft.Graph/users", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "user_id": user_id, + "user_principal_name": user_principal_name, + "is_mfa_registered": False, + "is_mfa_capable": reg.get("isMfaCapable", False), + "is_sspr_registered": reg.get("isSsprRegistered", False), + }, + }) + + return findings diff --git a/scanner/rules/az_idn_008.py b/scanner/rules/az_idn_008.py new file mode 100644 index 0000000..a7963b2 --- /dev/null +++ b/scanner/rules/az_idn_008.py @@ -0,0 +1,91 @@ +"""AZ-IDN-008: Custom RBAC role with wildcard permissions at subscription scope.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-008" +RULE_NAME = "Custom RBAC Role with Wildcard Permissions at Subscription Scope" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "1.23", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"} +DESCRIPTION = ( + "One or more custom RBAC role definitions contain wildcard actions (*) or " + "overly broad permissions at subscription scope. Custom roles with wildcard " + "permissions are functionally equivalent to the built-in Owner role but less " + "visible and harder to audit. They violate the principle of least privilege and " + "are frequently created as shortcuts that are never cleaned up." +) +REMEDIATION = ( + "Replace wildcard actions with the specific actions required for the role. " + "Review the role with: az role definition show --name ''. " + "Edit the role definition to replace '*' with explicit action strings. " + "Use the Azure built-in roles reference to identify the minimum required actions. " + "If the role is unused, delete it with: az role definition delete --name ''." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_008.sh" + +logger = logging.getLogger(__name__) + +WILDCARD_PATTERNS = ["*", "*/write", "*/delete", "*/action"] + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect custom RBAC roles with wildcard or overly broad permissions.""" + findings: List[Dict[str, Any]] = [] + + try: + from azure.mgmt.authorization import AuthorizationManagementClient + + auth_client = AuthorizationManagementClient( + azure_client.credential, subscription_id + ) + role_definitions = list( + auth_client.role_definitions.list( + scope=f"/subscriptions/{subscription_id}", + filter="type eq 'CustomRole'", + ) + ) + except Exception as exc: + logger.error( + "AZ-IDN-008: Failed to list custom role definitions: %s", exc + ) + return findings + + for role in role_definitions: + role_name = role.role_name or role.name or "Unknown" + role_id = role.id or "" + permissions = role.permissions or [] + + flagged_actions = [] + for perm in permissions: + for action in perm.actions or []: + if any( + action == pattern or action.endswith(pattern) + for pattern in WILDCARD_PATTERNS + ): + flagged_actions.append(action) + + if not flagged_actions: + continue + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": role_id, + "resource_name": role_name, + "resource_type": "Microsoft.Authorization/roleDefinitions", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "role_name": role_name, + "role_id": role_id, + "flagged_actions": flagged_actions, + "assignable_scopes": list(role.assignable_scopes or []), + }, + }) + + return findings diff --git a/scanner/rules/az_idn_009.py b/scanner/rules/az_idn_009.py new file mode 100644 index 0000000..edfd22a --- /dev/null +++ b/scanner/rules/az_idn_009.py @@ -0,0 +1,91 @@ +"""AZ-IDN-009: No activity log alert for role assignment changes in subscription.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-009" +RULE_NAME = "No Activity Log Alert for Role Assignment Changes" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "5.2.1", "NIST": "DE.CM-3", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} +DESCRIPTION = ( + "The subscription has no activity log alert configured for role assignment " + "changes (Microsoft.Authorization/roleAssignments/write). Without alerting on " + "privilege escalation events, an attacker who gains access and elevates their " + "own permissions will go undetected. This is a required detective control under " + "CIS Azure Benchmark 5.2.1 and NIST DE.CM-3." +) +REMEDIATION = ( + "Create an activity log alert for role assignment write events. Run: " + "az monitor activity-log alert create " + "--name 'Alert-RoleAssignment-Write' " + "--resource-group " + "--scope /subscriptions/ " + "--condition category=Administrative " + "operationName=Microsoft.Authorization/roleAssignments/write " + "--action-group . " + "Ensure the action group routes alerts to a monitored channel such as email or " + "a ticketing integration." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_009.sh" + +logger = logging.getLogger(__name__) + +TARGET_OPERATION = "Microsoft.Authorization/roleAssignments/write" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect subscriptions with no activity log alert for role assignment changes.""" + findings: List[Dict[str, Any]] = [] + + try: + from azure.mgmt.monitor import MonitorManagementClient + + monitor_client = MonitorManagementClient( + azure_client.credential, subscription_id + ) + alerts = list(monitor_client.activity_log_alerts.list_by_subscription_id()) + except Exception as exc: + logger.error( + "AZ-IDN-009: Failed to list activity log alerts: %s", exc + ) + return findings + + for alert in alerts: + if not getattr(alert, "enabled", True): + continue + + condition = getattr(alert, "condition", None) + if condition is None: + continue + + all_of = getattr(condition, "all_of", []) or [] + operations = [ + leaf.equals + for leaf in all_of + if getattr(leaf, "field", "") == "operationName" + and getattr(leaf, "equals", "") + ] + + if any(op.lower() == TARGET_OPERATION.lower() for op in operations): + return findings + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/subscriptions/{subscription_id}", + "resource_name": f"subscription/{subscription_id}", + "resource_type": "Microsoft.Insights/activityLogAlerts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "subscription_id": subscription_id, + "missing_operation": TARGET_OPERATION, + }, + }) + + return findings diff --git a/tests/conftest.py b/tests/conftest.py index 6f3accd..4b645b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,16 +5,16 @@ import secrets import time -import jwt import pytest -from api.app import create_app +from tests.helpers.mock_azure import MockAzureClient _TEST_JWT_SECRET = secrets.token_urlsafe(32) @pytest.fixture def app(): + from api.app import create_app application = create_app() application.config["TESTING"] = True application.config["JWT_SECRET"] = _TEST_JWT_SECRET @@ -28,6 +28,7 @@ def client(app): @pytest.fixture def auth_headers(): + import jwt payload = { "sub": "test-user", "role": "admin", @@ -36,3 +37,15 @@ def auth_headers(): } token = jwt.encode(payload, _TEST_JWT_SECRET, algorithm="HS256") return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +@pytest.fixture +def mock_azure() -> MockAzureClient: + """Return a clean MockAzureClient with no resources configured.""" + return MockAzureClient() + + +@pytest.fixture +def subscription_id() -> str: + """Return a fake Azure subscription ID for use in scan() calls.""" + return "00000000-0000-0000-0000-000000000001" diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py new file mode 100644 index 0000000..3c3c134 --- /dev/null +++ b/tests/helpers/mock_azure.py @@ -0,0 +1,98 @@ +"""Shared MockAzureClient for rule regression tests. + +Provides a configurable in-memory replacement for the real AzureClient so that +scanner rule unit tests can run fully offline with no Azure credentials. + +Usage: + from tests.helpers.mock_azure import MockAzureClient, make_resource + + client = MockAzureClient() + client.set_storage_accounts([ + make_resource(id="/sub/rg/sa", name="mystorage", allow_blob_public_access=True) + ]) + findings = az_stor_001.scan(client, "sub-id") +""" + +from types import SimpleNamespace +from typing import Any, Dict, List, Tuple + + +def make_resource(**kwargs: Any) -> SimpleNamespace: + """Build a fake Azure resource object with arbitrary attributes.""" + return SimpleNamespace(**kwargs) + + +class MockAzureClient: + """Drop-in replacement for AzureClient that returns configured fake data.""" + + def __init__(self) -> None: + self._storage_accounts: List[Any] = [] + self._network_security_groups: List[Any] = [] + self._virtual_machines: List[Any] = [] + self._key_vaults: List[Any] = [] + self._sql_servers: List[Any] = [] + self._service_principals: List[Any] = [] + self._sql_firewall_rules: Dict[Tuple[str, str], List[Any]] = {} + + def set_storage_accounts(self, accounts: List[Any]) -> "MockAzureClient": + self._storage_accounts = accounts + return self + + def set_network_security_groups(self, nsgs: List[Any]) -> "MockAzureClient": + self._network_security_groups = nsgs + return self + + def set_virtual_machines(self, vms: List[Any]) -> "MockAzureClient": + self._virtual_machines = vms + return self + + def set_key_vaults(self, vaults: List[Any]) -> "MockAzureClient": + self._key_vaults = vaults + return self + + def set_sql_servers(self, servers: List[Any]) -> "MockAzureClient": + self._sql_servers = servers + return self + + def set_service_principals(self, principals: List[Any]) -> "MockAzureClient": + self._service_principals = principals + return self + + def set_sql_server_firewall_rules( + self, resource_group: str, server_name: str, rules: List[Any] + ) -> "MockAzureClient": + self._sql_firewall_rules[(resource_group, server_name)] = rules + return self + + def get_storage_accounts(self) -> List[Any]: + return self._storage_accounts + + def get_network_security_groups(self) -> List[Any]: + return self._network_security_groups + + def get_virtual_machines(self) -> List[Any]: + return self._virtual_machines + + def get_key_vaults(self) -> List[Any]: + return self._key_vaults + + def get_sql_servers(self) -> List[Any]: + return self._sql_servers + + def get_service_principals(self) -> List[Any]: + return self._service_principals + + def get_sql_server_firewall_rules( + self, resource_group: str, server_name: str + ) -> List[Any]: + return self._sql_firewall_rules.get((resource_group, server_name), []) + + @staticmethod + def parse_resource_id(resource_id: str) -> Dict[str, str]: + """Parse an Azure resource ID into a dict with name and resource_group.""" + parts = resource_id.split("/") + result: Dict[str, str] = {"name": parts[-1] if parts else ""} + for idx, segment in enumerate(parts): + if segment.lower() == "resourcegroups" and idx + 1 < len(parts): + result["resource_group"] = parts[idx + 1] + return result diff --git a/tests/smoke_test.py b/tests/smoke_test.py index fd138ae..f6cc44d 100755 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -296,6 +296,75 @@ def skip(name, reason): lambda s, b: "summary" in b and "events" in b and isinstance(b["events"], list), ) +# Playbook test: fetch a real finding ID first, then probe its playbook. +print("\n=== Playbook Endpoint ===") +_finding_status, _finding_body = request("GET", "/api/findings") +_finding_id = ( + _finding_body.get("findings", [{}])[0].get("id") + if _finding_status == 200 and _finding_body.get("findings") + else None +) +if _finding_id is not None: + test( + f"TC-27 GET /api/findings/{_finding_id}/playbook returns 200", + "GET", f"/api/findings/{_finding_id}/playbook", + lambda s, b: s == 200, + ) + test( + f"TC-28 GET /api/findings/{_finding_id}/playbook returns playbook keys", + "GET", f"/api/findings/{_finding_id}/playbook", + lambda s, b: all(k in b for k in ("portal_steps", "cli_commands", "validation_steps")), + ) +else: + skip("TC-27 GET /api/findings//playbook returns 200", "No findings in DB — seed the database first.") + skip("TC-28 GET /api/findings//playbook returns playbook keys", "No findings in DB — seed the database first.") + +# ── TC-33 to TC-35: CVE Enrichment endpoints ────────────────────────────── +print("\n=== CVE Enrichment Endpoints ===") +_scan_status, _scan_body = request("GET", "/api/scans") +_scan_id = ( + _scan_body[0].get("scan_id") + if _scan_status == 200 and isinstance(_scan_body, list) and _scan_body + else None +) +if _scan_id is not None: + test( + f"TC-33 POST /api/scans/{_scan_id}/enrich returns 200", + "POST", f"/api/scans/{_scan_id}/enrich", + lambda s, b: s == 200, + body={}, + ) + test( + f"TC-34 POST /api/scans/{_scan_id}/enrich returns status COMPLETED", + "POST", f"/api/scans/{_scan_id}/enrich", + lambda s, b: b.get("status") == "COMPLETED", + body={}, + ) +else: + skip("TC-33 POST /api/scans//enrich returns 200", "No scans in DB — trigger a scan first.") + skip("TC-34 POST /api/scans//enrich returns status COMPLETED", "No scans in DB — trigger a scan first.") + +test( + "TC-35 GET /api/score/cve-summary returns status field", + "GET", "/api/score/cve-summary", + lambda s, b: "status" in b, +) +test( + "TC-24 GET /api/prioritization returns matrix and rankings keys", + "GET", "/api/prioritization", + lambda s, b: "matrix" in b and "rankings" in b and isinstance(b["matrix"], list), +) +test( + "TC-25 GET /api/drift returns 200", + "GET", "/api/drift", + lambda s, b: s == 200, +) +test( + "TC-26 GET /api/drift returns summary and events keys", + "GET", "/api/drift", + lambda s, b: "summary" in b and "events" in b and isinstance(b["events"], list), +) + # Playbook test: fetch a real finding ID first, then probe its playbook. print("\n=== Playbook Endpoint ===") _finding_status, _finding_body = request("GET", "/api/findings") @@ -322,13 +391,13 @@ def skip(name, reason): # ── TC-29 to TC-32: General edge cases ──────────────────────────────────── print("\n=== Edge Cases ===") test( - "TC-29 GET /nonexistent returns 404", + "TC-36 GET /nonexistent returns 404", "GET", "/nonexistent-endpoint-xyz", lambda s, b: s == 404, auth=True, ) test( - "TC-30 POST /api/scans/trigger with empty body returns 400 or starts scan", + "TC-37 POST /api/scans/trigger with empty body returns 400 or starts scan", "POST", "/api/scans/trigger", # 400 = missing subscription_id (expected when no AZURE_SUBSCRIPTION_ID env var) # 200/201/202 = scan started (AZURE_SUBSCRIPTION_ID configured on server) @@ -338,12 +407,12 @@ def skip(name, reason): body={}, ) test( - "TC-31 GET /api/findings?limit=0 does not crash", + "TC-38 GET /api/findings?limit=0 does not crash", "GET", "/api/findings?limit=0", lambda s, b: s in (200, 400), ) test( - "TC-32 Response Content-Type is JSON", + "TC-39 Response Content-Type is JSON", "GET", "/api/findings", lambda s, b: isinstance(b, dict), ) diff --git a/tests/test_rules_database.py b/tests/test_rules_database.py new file mode 100644 index 0000000..e6a214f --- /dev/null +++ b/tests/test_rules_database.py @@ -0,0 +1,64 @@ +"""Rule regression tests for AZ-DB-004.""" + +import scanner.rules.az_db_004 as az_db_004 +from tests.helpers.mock_azure import make_resource + +_REQUIRED_FIELDS = { + "rule_id", "rule_name", "severity", "category", + "resource_id", "resource_name", "resource_type", + "description", "remediation", "playbook", "frameworks", "metadata", +} + +_SUB = "00000000-0000-0000-0000-000000000001" +_RG = "rg-test" + + +def _sql_id(name): + return ( + f"/subscriptions/{_SUB}/resourceGroups/{_RG}" + f"/providers/Microsoft.Sql/servers/{name}" + ) + + +def _firewall_rule(name, start_ip, end_ip): + return make_resource( + name=name, + start_ip_address=start_ip, + end_ip_address=end_ip, + ) + + +def test_db_004_compliant_returns_no_findings(mock_azure, subscription_id): + """A SQL Server with no AllowAzureServices rule must produce no findings.""" + server = make_resource(id=_sql_id("sql-restricted"), name="sql-restricted") + rule = _firewall_rule("AllowSpecificIP", "203.0.113.10", "203.0.113.10") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_firewall_rules(_RG, "sql-restricted", [rule]) + findings = az_db_004.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_db_004_noncompliant_returns_one_finding(mock_azure, subscription_id): + """A SQL Server with AllowAllWindowsAzureIps rule must produce exactly one finding.""" + server = make_resource(id=_sql_id("sql-open"), name="sql-open") + allow_azure = _firewall_rule("AllowAllWindowsAzureIps", "0.0.0.0", "0.0.0.0") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_firewall_rules(_RG, "sql-open", [allow_azure]) + findings = az_db_004.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-DB-004" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Database" + assert finding["resource_name"] == "sql-open" + assert finding["metadata"]["resource_group"] == _RG + + +def test_db_004_no_firewall_rules_returns_no_findings(mock_azure, subscription_id): + """A SQL Server with no firewall rules must produce no findings.""" + server = make_resource(id=_sql_id("sql-no-rules"), name="sql-no-rules") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_firewall_rules(_RG, "sql-no-rules", []) + findings = az_db_004.scan(mock_azure, subscription_id) + assert findings == [] diff --git a/tests/test_rules_identity.py b/tests/test_rules_identity.py new file mode 100644 index 0000000..26177bf --- /dev/null +++ b/tests/test_rules_identity.py @@ -0,0 +1,53 @@ +"""Rule regression tests for AZ-IDN-001. + +Each test configures a MockAzureClient with fake role assignment objects and +calls the rule's scan() function directly. No network calls are made. +""" + +import scanner.rules.az_idn_001 as az_idn_001 +from tests.helpers.mock_azure import make_resource + +_REQUIRED_FIELDS = { + "rule_id", "rule_name", "severity", "category", + "resource_id", "resource_name", "resource_type", + "description", "remediation", "playbook", "frameworks", "metadata", +} + +_SUB = "00000000-0000-0000-0000-000000000001" +_OWNER_ROLE_GUID = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" +_CONTRIBUTOR_ROLE_GUID = "b24988ac-6180-42a0-ab88-20f7382dd24c" +_ROLE_DEF_BASE = ( + f"/subscriptions/{_SUB}/providers/Microsoft.Authorization/roleDefinitions" +) + + +def _assignment(role_guid, principal_id, assign_id): + return make_resource( + id=f"/subscriptions/{_SUB}/providers/Microsoft.Authorization/roleAssignments/{assign_id}", + role_definition_id=f"{_ROLE_DEF_BASE}/{role_guid}", + principal_id=principal_id, + scope=f"/subscriptions/{_SUB}", + ) + + +def test_idn_001_compliant_returns_no_findings(mock_azure, subscription_id): + """A service principal with a non-Owner role must produce no findings.""" + assignment = _assignment(_CONTRIBUTOR_ROLE_GUID, "sp-contributor-abc123", "assign-001") + mock_azure.set_service_principals([assignment]) + findings = az_idn_001.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_idn_001_noncompliant_returns_one_finding(mock_azure, subscription_id): + """A service principal holding the Owner role must produce exactly one finding.""" + assignment = _assignment(_OWNER_ROLE_GUID, "sp-owner-def456", "assign-002") + mock_azure.set_service_principals([assignment]) + findings = az_idn_001.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-IDN-001" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Identity" + assert finding["resource_name"] == "sp-owner-def456" + assert finding["metadata"]["principal_id"] == "sp-owner-def456" diff --git a/tests/test_rules_keyvault.py b/tests/test_rules_keyvault.py new file mode 100644 index 0000000..2615afa --- /dev/null +++ b/tests/test_rules_keyvault.py @@ -0,0 +1,66 @@ +"""Rule regression tests for AZ-KV-002. + +Each test configures a MockAzureClient with a fake Key Vault object and calls +the rule's scan() function directly. No network calls are made. +""" + +import scanner.rules.az_kv_002 as az_kv_002 +from tests.helpers.mock_azure import make_resource + +_REQUIRED_FIELDS = { + "rule_id", "rule_name", "severity", "category", + "resource_id", "resource_name", "resource_type", + "description", "remediation", "playbook", "frameworks", "metadata", +} + +_SUB = "00000000-0000-0000-0000-000000000001" +_RG = "rg-test" + + +def _kv_id(name): + return ( + f"/subscriptions/{_SUB}/resourceGroups/{_RG}" + f"/providers/Microsoft.KeyVault/vaults/{name}" + ) + + +def _vault(name, public_access, private_endpoints): + props = make_resource( + public_network_access=public_access, + private_endpoint_connections=private_endpoints, + ) + return make_resource( + id=_kv_id(name), + name=name, + location="eastus", + properties=props, + ) + + +def test_kv_002_compliant_public_access_disabled_returns_no_findings(mock_azure, subscription_id): + """A Key Vault with public access disabled must produce no findings.""" + mock_azure.set_key_vaults([_vault("kv-private", "Disabled", [])]) + findings = az_kv_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_kv_002_compliant_private_endpoint_present_returns_no_findings(mock_azure, subscription_id): + """A Key Vault with a private endpoint must produce no findings.""" + endpoint = make_resource(id="pe-connection-001", name="pe-kv-secure") + mock_azure.set_key_vaults([_vault("kv-with-pe", "Enabled", [endpoint])]) + findings = az_kv_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_kv_002_noncompliant_returns_one_finding(mock_azure, subscription_id): + """A Key Vault with public access enabled and no private endpoint must produce one finding.""" + mock_azure.set_key_vaults([_vault("kv-public", "Enabled", [])]) + findings = az_kv_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-KV-002" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Key Vault" + assert finding["resource_name"] == "kv-public" + assert finding["metadata"]["resource_group"] == _RG diff --git a/tests/test_rules_network.py b/tests/test_rules_network.py new file mode 100644 index 0000000..8215c4d --- /dev/null +++ b/tests/test_rules_network.py @@ -0,0 +1,105 @@ +"""Rule regression tests for AZ-NET-001 and AZ-NET-002.""" + +import scanner.rules.az_net_001 as az_net_001 +import scanner.rules.az_net_002 as az_net_002 +from tests.helpers.mock_azure import make_resource + +_REQUIRED_FIELDS = { + "rule_id", "rule_name", "severity", "category", + "resource_id", "resource_name", "resource_type", + "description", "remediation", "playbook", "frameworks", "metadata", +} + +_SUB = "00000000-0000-0000-0000-000000000001" +_RG = "rg-test" + + +def _nsg_id(name): + return ( + f"/subscriptions/{_SUB}/resourceGroups/{_RG}" + f"/providers/Microsoft.Network/networkSecurityGroups/{name}" + ) + + +def _allow_rule(name, port, source="10.0.0.0/24"): + return make_resource( + name=name, + direction="Inbound", + access="Allow", + source_address_prefix=source, + source_address_prefixes=[], + destination_port_range=port, + destination_port_ranges=[], + ) + + +def _open_allow_rule(name, port): + return make_resource( + name=name, + direction="Inbound", + access="Allow", + source_address_prefix="0.0.0.0/0", + source_address_prefixes=[], + destination_port_range=port, + destination_port_ranges=[], + ) + + +def test_net_001_compliant_returns_no_findings(mock_azure, subscription_id): + """An NSG restricting SSH to a trusted IP range must produce no findings.""" + nsg = make_resource( + id=_nsg_id("nsg-ssh-restricted"), + name="nsg-ssh-restricted", + security_rules=[_allow_rule("AllowSSHFromTrusted", "22", "10.0.0.0/24")], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_001.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_net_001_noncompliant_returns_one_finding(mock_azure, subscription_id): + """An NSG with Allow-inbound-SSH-from-any must produce exactly one finding.""" + nsg = make_resource( + id=_nsg_id("nsg-ssh-open"), + name="nsg-ssh-open", + security_rules=[_open_allow_rule("AllowSSHFromInternet", "22")], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_001.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-NET-001" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Network" + assert finding["resource_name"] == "nsg-ssh-open" + + +def test_net_002_compliant_returns_no_findings(mock_azure, subscription_id): + """An NSG restricting RDP to a trusted IP range must produce no findings.""" + nsg = make_resource( + id=_nsg_id("nsg-rdp-restricted"), + name="nsg-rdp-restricted", + security_rules=[_allow_rule("AllowRDPFromTrusted", "3389", "192.168.1.0/24")], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_net_002_noncompliant_returns_one_finding(mock_azure, subscription_id): + """An NSG with Allow-inbound-RDP-from-any must produce exactly one finding.""" + nsg = make_resource( + id=_nsg_id("nsg-rdp-open"), + name="nsg-rdp-open", + security_rules=[_open_allow_rule("AllowRDPFromInternet", "3389")], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-NET-002" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Network" + assert finding["resource_name"] == "nsg-rdp-open" diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py new file mode 100644 index 0000000..4257102 --- /dev/null +++ b/tests/test_rules_storage.py @@ -0,0 +1,85 @@ +"""Rule regression tests for AZ-STOR-001 and AZ-STOR-002. + +Each test configures a MockAzureClient with a single fake storage account +and calls the rule's scan() function directly. No network calls are made. +""" + +import scanner.rules.az_stor_001 as az_stor_001 +import scanner.rules.az_stor_002 as az_stor_002 +from tests.helpers.mock_azure import make_resource + +_REQUIRED_FIELDS = { + "rule_id", "rule_name", "severity", "category", + "resource_id", "resource_name", "resource_type", + "description", "remediation", "playbook", "frameworks", +} + +_SUB = "00000000-0000-0000-0000-000000000001" +_RG = "rg-test" + + +def _storage_id(name): + return ( + f"/subscriptions/{_SUB}/resourceGroups/{_RG}" + f"/providers/Microsoft.Storage/storageAccounts/{name}" + ) + + +def test_stor_001_compliant_returns_no_findings(mock_azure, subscription_id): + """A storage account with public blob access disabled must produce no findings.""" + account = make_resource( + id=_storage_id("compliant-storage"), + name="compliant-storage", + allow_blob_public_access=False, + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_001.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_stor_001_noncompliant_returns_one_finding(mock_azure, subscription_id): + """A storage account with public blob access enabled must produce exactly one finding.""" + account = make_resource( + id=_storage_id("public-storage"), + name="public-storage", + allow_blob_public_access=True, + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_001.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-STOR-001" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Storage" + assert finding["resource_name"] == "public-storage" + + +def test_stor_002_compliant_returns_no_findings(mock_azure, subscription_id): + """A storage account with HTTPS-only enabled must produce no findings.""" + account = make_resource( + id=_storage_id("https-only-storage"), + name="https-only-storage", + enable_https_traffic_only=True, + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_stor_002_noncompliant_returns_one_finding(mock_azure, subscription_id): + """A storage account that allows HTTP traffic must produce exactly one finding.""" + account = make_resource( + id=_storage_id("http-allowed-storage"), + name="http-allowed-storage", + enable_https_traffic_only=False, + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-STOR-002" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Storage" + assert finding["resource_name"] == "http-allowed-storage"