From de371f43ab78d9ca9ea8f23de73b3c43bd82cef2 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <86302373+Vishnu2707@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:07:13 +0100 Subject: [PATCH 01/50] chore: add skeleton files and requirements --- openshield | 1 + 1 file changed, 1 insertion(+) create mode 160000 openshield diff --git a/openshield b/openshield new file mode 160000 index 0000000..647f74b --- /dev/null +++ b/openshield @@ -0,0 +1 @@ +Subproject commit 647f74b69888891b39d3af8aa77aacfcfae83770 From dd24ce0ae354da904351e6434022fca69c91caa0 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <86302373+Vishnu2707@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:07:30 +0100 Subject: [PATCH 02/50] fix: remove embedded git repo --- openshield | 1 - 1 file changed, 1 deletion(-) delete mode 160000 openshield diff --git a/openshield b/openshield deleted file mode 160000 index 647f74b..0000000 --- a/openshield +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 647f74b69888891b39d3af8aa77aacfcfae83770 From e87207471e21ffefe240647b44dd34b52f706012 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <86302373+Vishnu2707@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:14:45 +0100 Subject: [PATCH 03/50] Core Structure Created --- .github/CODE_OF_CONDUCT.md | 10 ++++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 15 ++++++++++++++ .github/ISSUE_TEMPLATE/new_rule.md | 23 ++++++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 29 ++++++++++++++++++++++++++++ openshield | 1 + 5 files changed, 78 insertions(+) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/new_rule.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 160000 openshield diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..31cd3af --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Code of Conduct + +OpenShield is an open, welcoming project. + +- Be respectful in all interactions +- No harassment, discrimination, or offensive language +- Constructive feedback only — critique code, not people +- All contributions welcome regardless of experience level + +Violations can be reported to the maintainer directly via GitHub. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..086751f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,15 @@ +--- +name: Bug report +about: Something is broken +labels: bug +--- + +## What happened? + +## What did you expect? + +## Steps to reproduce? + +## Environment +- Python version: +- Azure SDK version: \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/new_rule.md b/.github/ISSUE_TEMPLATE/new_rule.md new file mode 100644 index 0000000..ab8c850 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_rule.md @@ -0,0 +1,23 @@ +--- +name: New scan rule +about: Propose a new Azure misconfiguration rule +labels: new-rule, good-first-issue +--- + +## Rule proposal + +**Rule ID:** AZ-XXX-000 +**Rule name:** +**Severity:** HIGH / MEDIUM / LOW +**Category:** Storage / Network / Identity / Database / Compute + +## What misconfiguration does it detect? + +## Why is it a security risk? + +## Which frameworks does it map to? +- CIS: +- NIST: +- ISO 27001: + +## Remediation (how to fix it)? \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..af474c8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,29 @@ +## What does this PR do? + + +## Type of change +- [ ] New scan rule +- [ ] Remediation playbook +- [ ] Bug fix +- [ ] Frontend component +- [ ] API endpoint +- [ ] Documentation + +## Rule details (if applicable) +- Rule ID: AZ-XXX-000 +- Severity: HIGH / MEDIUM / LOW +- Category: Storage / Network / Identity / Database / Compute +- Frameworks mapped: CIS / NIST / ISO 27001 + +## Testing +- [ ] Tested against a real Azure free trial subscription +- [ ] Returns correct JSON output +- [ ] No hardcoded credentials or secrets + +## Related issue +Closes # + +## Checklist +- [ ] My code follows the rule template in CONTRIBUTING.md +- [ ] I have not committed any real Azure credentials +- [ ] My branch name follows the convention: feat/description \ No newline at end of file diff --git a/openshield b/openshield new file mode 160000 index 0000000..647f74b --- /dev/null +++ b/openshield @@ -0,0 +1 @@ +Subproject commit 647f74b69888891b39d3af8aa77aacfcfae83770 From ee773771c6f5cec3788f6b76782ec680708f432d Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <86302373+Vishnu2707@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:45:00 +0100 Subject: [PATCH 04/50] =?UTF-8?q?feat:=20build=20complete=20core=20?= =?UTF-8?q?=E2=80=94=20scanner=20engine,=2010=20rules,=20API,=20playbooks,?= =?UTF-8?q?=20compliance=20mappings,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/__init__.py | 0 api/app.py | 127 +++++++ api/models/__init__.py | 0 api/models/finding.py | 311 ++++++++++++++++++ api/routes/__init__.py | 0 api/routes/compliance.py | 39 +++ api/routes/findings.py | 44 +++ api/routes/scans.py | 60 ++++ api/routes/score.py | 27 ++ .../frameworks/cis_azure_benchmark.json | 57 ++++ compliance/frameworks/iso27001.json | 57 ++++ compliance/frameworks/nist_csf.json | 57 ++++ docs/adding-a-rule.md | 216 ++++++++++++ docs/architecture.md | 187 +++++++++++ docs/azure-setup.md | 205 ++++++++++++ openshield | 1 - playbooks/cli/fix_az_cmp_001.sh | 33 ++ playbooks/cli/fix_az_db_001.sh | 27 ++ playbooks/cli/fix_az_db_002.sh | 36 ++ playbooks/cli/fix_az_idn_001.sh | 45 +++ playbooks/cli/fix_az_idn_002.sh | 69 ++++ playbooks/cli/fix_az_kv_001.sh | 39 +++ playbooks/cli/fix_az_net_001.sh | 49 +++ playbooks/cli/fix_az_net_002.sh | 48 +++ playbooks/cli/fix_az_stor_001.sh | 24 ++ playbooks/cli/fix_az_stor_002.sh | 24 ++ requirements.txt | 16 + scanner/__init__.py | 0 scanner/azure_client.py | 188 +++++++++++ scanner/engine.py | 110 +++++++ scanner/rules/__init__.py | 0 scanner/rules/az_cmp_001.py | 77 +++++ scanner/rules/az_db_001.py | 48 +++ scanner/rules/az_db_002.py | 60 ++++ scanner/rules/az_idn_001.py | 57 ++++ scanner/rules/az_idn_002.py | 84 +++++ scanner/rules/az_kv_001.py | 55 ++++ scanner/rules/az_net_001.py | 68 ++++ scanner/rules/az_net_002.py | 69 ++++ scanner/rules/az_stor_001.py | 42 +++ scanner/rules/az_stor_002.py | 42 +++ 41 files changed, 2697 insertions(+), 1 deletion(-) create mode 100644 api/__init__.py create mode 100644 api/app.py create mode 100644 api/models/__init__.py create mode 100644 api/models/finding.py create mode 100644 api/routes/__init__.py create mode 100644 api/routes/compliance.py create mode 100644 api/routes/findings.py create mode 100644 api/routes/scans.py create mode 100644 api/routes/score.py create mode 100644 compliance/frameworks/cis_azure_benchmark.json create mode 100644 compliance/frameworks/iso27001.json create mode 100644 compliance/frameworks/nist_csf.json create mode 100644 docs/adding-a-rule.md create mode 100644 docs/architecture.md create mode 100644 docs/azure-setup.md delete mode 160000 openshield create mode 100755 playbooks/cli/fix_az_cmp_001.sh create mode 100755 playbooks/cli/fix_az_db_001.sh create mode 100755 playbooks/cli/fix_az_db_002.sh create mode 100755 playbooks/cli/fix_az_idn_001.sh create mode 100755 playbooks/cli/fix_az_idn_002.sh create mode 100755 playbooks/cli/fix_az_kv_001.sh create mode 100755 playbooks/cli/fix_az_net_001.sh create mode 100755 playbooks/cli/fix_az_net_002.sh create mode 100755 playbooks/cli/fix_az_stor_001.sh create mode 100755 playbooks/cli/fix_az_stor_002.sh create mode 100644 requirements.txt create mode 100644 scanner/__init__.py create mode 100644 scanner/rules/__init__.py create mode 100644 scanner/rules/az_cmp_001.py create mode 100644 scanner/rules/az_db_001.py create mode 100644 scanner/rules/az_db_002.py create mode 100644 scanner/rules/az_idn_001.py create mode 100644 scanner/rules/az_idn_002.py create mode 100644 scanner/rules/az_kv_001.py create mode 100644 scanner/rules/az_net_001.py create mode 100644 scanner/rules/az_net_002.py create mode 100644 scanner/rules/az_stor_001.py create mode 100644 scanner/rules/az_stor_002.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..b36605f --- /dev/null +++ b/api/app.py @@ -0,0 +1,127 @@ +"""Flask application factory for the OpenShield REST API.""" + +import logging +import os + +import jwt +from dotenv import load_dotenv +from flask import Flask, g, jsonify, request +from flask_cors import CORS + +load_dotenv() + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +# Paths that do not require a JWT token +_PUBLIC_PATHS = {"/health", "/"} + + +def create_app() -> Flask: + """Create and configure the Flask application. + + Returns a fully wired Flask app with: + - CORS enabled for all origins + - JWT authentication middleware on all non-public routes + - Blueprints for findings, scans, score, and compliance + - JSON error handlers for 400, 401, 403, 404, and 500 + """ + app = Flask(__name__) + app.config["JWT_SECRET"] = os.environ.get("JWT_SECRET", "change-me-in-production") + + # ------------------------------------------------------------------ # + # CORS # + # ------------------------------------------------------------------ # + CORS(app, resources={r"/api/*": {"origins": "*"}}) + + # ------------------------------------------------------------------ # + # JWT middleware # + # ------------------------------------------------------------------ # + + @app.before_request + def verify_jwt() -> None: + """Validate the Bearer token on every non-public, non-OPTIONS request.""" + if request.method == "OPTIONS": + return None + if request.path in _PUBLIC_PATHS: + return None + + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return jsonify({"error": "Missing or malformed Authorization header"}), 401 + + token = auth.split(" ", 1)[1] + try: + payload = jwt.decode( + token, + app.config["JWT_SECRET"], + algorithms=["HS256"], + ) + g.user = payload + except jwt.ExpiredSignatureError: + return jsonify({"error": "Token has expired"}), 401 + except jwt.InvalidTokenError as exc: + return jsonify({"error": f"Invalid token: {exc}"}), 401 + + return None + + # ------------------------------------------------------------------ # + # Blueprints # + # ------------------------------------------------------------------ # + from api.routes.compliance import compliance_bp + from api.routes.findings import findings_bp + from api.routes.scans import scans_bp + from api.routes.score import score_bp + + app.register_blueprint(findings_bp) + app.register_blueprint(scans_bp) + app.register_blueprint(score_bp) + app.register_blueprint(compliance_bp) + + # ------------------------------------------------------------------ # + # Health check (public) # + # ------------------------------------------------------------------ # + + @app.get("/health") + def health(): + return jsonify({"status": "ok"}) + + # ------------------------------------------------------------------ # + # Error handlers # + # ------------------------------------------------------------------ # + + @app.errorhandler(400) + def bad_request(exc): + return jsonify({"error": "Bad request", "detail": str(exc)}), 400 + + @app.errorhandler(401) + def unauthorized(exc): + return jsonify({"error": "Unauthorized"}), 401 + + @app.errorhandler(403) + def forbidden(exc): + return jsonify({"error": "Forbidden"}), 403 + + @app.errorhandler(404) + def not_found(exc): + return jsonify({"error": "Not found"}), 404 + + @app.errorhandler(500) + def internal_error(exc): + logger.error("Unhandled exception: %s", exc) + return jsonify({"error": "Internal server error"}), 500 + + logger.info("OpenShield API created — %d blueprints registered", len(app.blueprints)) + return app + + +if __name__ == "__main__": + application = create_app() + application.run( + host="0.0.0.0", + port=int(os.environ.get("PORT", 5000)), + debug=os.environ.get("FLASK_DEBUG", "false").lower() == "true", + ) diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models/finding.py b/api/models/finding.py new file mode 100644 index 0000000..90b8662 --- /dev/null +++ b/api/models/finding.py @@ -0,0 +1,311 @@ +"""Finding dataclass and PostgreSQL-backed DatabaseManager.""" + +import json +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import psycopg2 +import psycopg2.extras + +logger = logging.getLogger(__name__) + +FRAMEWORKS_DIR = Path(__file__).parent.parent.parent / "compliance" / "frameworks" + +SEVERITY_WEIGHTS = {"HIGH": 10, "MEDIUM": 5, "LOW": 2, "INFO": 0} + +FRAMEWORK_FILE_MAP = { + "cis": "cis_azure_benchmark.json", + "nist": "nist_csf.json", + "iso27001": "iso27001.json", +} + + +@dataclass +class Finding: + """Represents a single security misconfiguration finding.""" + + rule_id: str + rule_name: str + severity: str + category: str + resource_id: str + resource_name: str + resource_type: str + description: str + remediation: str + frameworks: Dict[str, str] + detected_at: str + scan_id: Optional[str] = None + playbook: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + id: Optional[int] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "rule_id": self.rule_id, + "rule_name": self.rule_name, + "severity": self.severity, + "category": self.category, + "resource_id": self.resource_id, + "resource_name": self.resource_name, + "resource_type": self.resource_type, + "description": self.description, + "remediation": self.remediation, + "frameworks": self.frameworks, + "detected_at": self.detected_at, + "scan_id": self.scan_id, + "playbook": self.playbook, + "metadata": self.metadata, + } + + +class DatabaseManager: + """Manages PostgreSQL persistence for scans, findings, and scoring. + + All public methods open a new connection on first use. Call connect() + explicitly if you want to pre-warm the connection. + """ + + def __init__(self, dsn: Optional[str] = None) -> None: + self.dsn = dsn or os.environ["DATABASE_URL"] + self.conn: Optional[Any] = None + + # ------------------------------------------------------------------ # + # Connection # + # ------------------------------------------------------------------ # + + def connect(self) -> None: + """Open a persistent database connection.""" + self.conn = psycopg2.connect(self.dsn) + self.conn.autocommit = False + logger.info("Database connection established") + + def _get_conn(self) -> Any: + if self.conn is None or self.conn.closed: + self.connect() + return self.conn + + # ------------------------------------------------------------------ # + # Schema # + # ------------------------------------------------------------------ # + + def create_tables(self) -> None: + """Create the findings, scans, and rules tables if they do not exist.""" + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS scans ( + scan_id UUID PRIMARY KEY, + subscription_id TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ, + total_findings INTEGER DEFAULT 0 + ); + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS findings ( + id SERIAL PRIMARY KEY, + scan_id UUID REFERENCES scans(scan_id), + rule_id TEXT NOT NULL, + rule_name TEXT NOT NULL, + severity TEXT NOT NULL, + category TEXT, + resource_id TEXT, + resource_name TEXT, + resource_type TEXT, + description TEXT, + remediation TEXT, + playbook TEXT, + frameworks JSONB, + metadata JSONB, + detected_at TIMESTAMPTZ NOT NULL + ); + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS idx_findings_scan_id + ON findings(scan_id); + CREATE INDEX IF NOT EXISTS idx_findings_severity + ON findings(severity); + CREATE INDEX IF NOT EXISTS idx_findings_rule_id + ON findings(rule_id); + """) + conn.commit() + logger.info("Database tables created / verified") + + # ------------------------------------------------------------------ # + # Write # + # ------------------------------------------------------------------ # + + def save_scan(self, scan_result: Dict[str, Any]) -> None: + """Persist a full scan result (scan header + all findings).""" + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO scans (scan_id, subscription_id, started_at, completed_at, total_findings) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (scan_id) DO NOTHING + """, + ( + scan_result["scan_id"], + scan_result["subscription_id"], + scan_result["started_at"], + scan_result["completed_at"], + scan_result["total_findings"], + ), + ) + for f in scan_result.get("findings", []): + cur.execute( + """ + INSERT INTO findings + (scan_id, rule_id, rule_name, severity, category, + resource_id, resource_name, resource_type, + description, remediation, playbook, + frameworks, metadata, detected_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + """, + ( + f.get("scan_id"), + f.get("rule_id"), + f.get("rule_name"), + f.get("severity"), + f.get("category"), + f.get("resource_id"), + f.get("resource_name"), + f.get("resource_type"), + f.get("description"), + f.get("remediation"), + f.get("playbook"), + json.dumps(f.get("frameworks", {})), + json.dumps(f.get("metadata", {})), + f.get("detected_at"), + ), + ) + conn.commit() + logger.info("Saved scan %s with %d findings", scan_result["scan_id"], scan_result["total_findings"]) + + # ------------------------------------------------------------------ # + # Read # + # ------------------------------------------------------------------ # + + def get_findings(self, filters: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """Return findings, optionally filtered by severity, category, or rule_id.""" + filters = filters or {} + clauses: List[str] = [] + params: List[Any] = [] + + if "severity" in filters: + clauses.append("severity = %s") + params.append(filters["severity"].upper()) + if "category" in filters: + clauses.append("LOWER(category) = LOWER(%s)") + params.append(filters["category"]) + if "rule_id" in filters: + clauses.append("rule_id = %s") + params.append(filters["rule_id"]) + if "scan_id" in filters: + clauses.append("scan_id = %s") + params.append(filters["scan_id"]) + + where = "WHERE " + " AND ".join(clauses) if clauses else "" + sql = f"SELECT * FROM findings {where} ORDER BY detected_at DESC LIMIT 1000" + + conn = self._get_conn() + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + return [dict(row) for row in cur.fetchall()] + + def get_finding_by_id(self, finding_id: int) -> Optional[Dict[str, Any]]: + """Return a single finding by its integer primary key.""" + conn = self._get_conn() + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("SELECT * FROM findings WHERE id = %s", (finding_id,)) + row = cur.fetchone() + return dict(row) if row else None + + def get_scans(self) -> List[Dict[str, Any]]: + """Return all scan records ordered by most recent first.""" + conn = self._get_conn() + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("SELECT * FROM scans ORDER BY started_at DESC LIMIT 100") + return [dict(row) for row in cur.fetchall()] + + # ------------------------------------------------------------------ # + # Scoring # + # ------------------------------------------------------------------ # + + def get_score(self) -> int: + """Return a 0–100 security posture score based on open findings. + + HIGH findings deduct 10 points each, MEDIUM 5, LOW 2. + Score floors at 0. + """ + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + "SELECT severity, COUNT(*) FROM findings GROUP BY severity" + ) + rows = cur.fetchall() + + deduction = sum( + SEVERITY_WEIGHTS.get(sev.upper(), 0) * count for sev, count in rows + ) + return max(0, 100 - deduction) + + def get_compliance_score(self, framework: str) -> Dict[str, Any]: + """Return pass/fail breakdown against a compliance framework. + + Args: + framework: One of 'cis', 'nist', or 'iso27001'. + + Returns: + dict with keys: framework, total_controls, passed, failed, + score_percent, controls (list of control detail objects). + """ + filename = FRAMEWORK_FILE_MAP.get(framework.lower()) + if not filename: + return {"error": f"Unknown framework: {framework}"} + + framework_path = FRAMEWORKS_DIR / filename + if not framework_path.exists(): + return {"error": f"Framework file not found: {filename}"} + + with open(framework_path) as fh: + framework_data = json.load(fh) + + controls = framework_data.get("controls", {}) + + # Get rule IDs that have at least one finding + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("SELECT DISTINCT rule_id FROM findings") + failed_rule_ids = {row[0] for row in cur.fetchall()} + + results = [] + for rule_id, control in controls.items(): + status = "FAIL" if rule_id in failed_rule_ids else "PASS" + results.append({ + "rule_id": rule_id, + "control_id": control["control_id"], + "control_name": control["control_name"], + "status": status, + }) + + total = len(results) + passed = sum(1 for r in results if r["status"] == "PASS") + failed = total - passed + score_pct = round((passed / total) * 100) if total else 0 + + return { + "framework": framework_data.get("framework"), + "version": framework_data.get("version"), + "total_controls": total, + "passed": passed, + "failed": failed, + "score_percent": score_pct, + "controls": results, + } diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/compliance.py b/api/routes/compliance.py new file mode 100644 index 0000000..e3b68a2 --- /dev/null +++ b/api/routes/compliance.py @@ -0,0 +1,39 @@ +"""Compliance routes: framework-specific posture breakdown.""" + +import os +from flask import Blueprint, jsonify + +from api.models.finding import DatabaseManager + +compliance_bp = Blueprint("compliance", __name__) + +SUPPORTED_FRAMEWORKS = ("cis", "nist", "iso27001") + + +def _get_db() -> DatabaseManager: + db = DatabaseManager(os.environ["DATABASE_URL"]) + db.connect() + return db + + +@compliance_bp.get("/api/compliance/") +def get_compliance(framework: str): + """Return pass/fail compliance breakdown for a framework. + + Supported frameworks: cis, nist, iso27001 + + Returns control-level pass/fail status mapped to current open findings. + """ + if framework.lower() not in SUPPORTED_FRAMEWORKS: + return jsonify({ + "error": f"Unknown framework '{framework}'", + "supported": list(SUPPORTED_FRAMEWORKS), + }), 400 + + db = _get_db() + result = db.get_compliance_score(framework.lower()) + + if "error" in result: + return jsonify(result), 500 + + return jsonify(result) diff --git a/api/routes/findings.py b/api/routes/findings.py new file mode 100644 index 0000000..fb8d755 --- /dev/null +++ b/api/routes/findings.py @@ -0,0 +1,44 @@ +"""Findings routes: list and retrieve individual findings.""" + +import os +from flask import Blueprint, jsonify, request + +from api.models.finding import DatabaseManager + +findings_bp = Blueprint("findings", __name__) + + +def _get_db() -> DatabaseManager: + db = DatabaseManager(os.environ["DATABASE_URL"]) + db.connect() + return db + + +@findings_bp.get("/api/findings") +def list_findings(): + """Return findings, optionally filtered by severity, category, or rule_id. + + Query parameters: + severity — HIGH | MEDIUM | LOW | INFO + category — Storage | Network | Identity | Database | Compute | KeyVault + rule_id — e.g. AZ-STOR-001 + scan_id — UUID of a specific scan + """ + filters = { + k: v + for k, v in request.args.items() + if k in ("severity", "category", "rule_id", "scan_id") + } + db = _get_db() + findings = db.get_findings(filters) + return jsonify({"count": len(findings), "findings": findings}) + + +@findings_bp.get("/api/findings/") +def get_finding(finding_id: int): + """Return a single finding by its integer ID.""" + db = _get_db() + finding = db.get_finding_by_id(finding_id) + if not finding: + return jsonify({"error": "Finding not found"}), 404 + return jsonify(finding) diff --git a/api/routes/scans.py b/api/routes/scans.py new file mode 100644 index 0000000..85612a4 --- /dev/null +++ b/api/routes/scans.py @@ -0,0 +1,60 @@ +"""Scan routes: list historical scans and trigger new ones.""" + +import logging +import os +from flask import Blueprint, jsonify, request + +from api.models.finding import DatabaseManager + +scans_bp = Blueprint("scans", __name__) +logger = logging.getLogger(__name__) + + +def _get_db() -> DatabaseManager: + db = DatabaseManager(os.environ["DATABASE_URL"]) + db.connect() + return db + + +@scans_bp.get("/api/scans") +def list_scans(): + """Return all historical scan results ordered by most recent first.""" + db = _get_db() + scans = db.get_scans() + return jsonify({"count": len(scans), "scans": scans}) + + +@scans_bp.post("/api/scans/trigger") +def trigger_scan(): + """Trigger a synchronous scan against the configured subscription. + + Accepts an optional JSON body with ``subscription_id``. Falls back to the + ``AZURE_SUBSCRIPTION_ID`` environment variable if not provided. + + Note: For production use, replace this with an async task queue (e.g. + Celery or Azure Functions) to avoid request timeouts on large subscriptions. + """ + from scanner.engine import ScanEngine # deferred to avoid import at startup + + body = request.get_json(silent=True) or {} + subscription_id = body.get("subscription_id") or os.environ.get( + "AZURE_SUBSCRIPTION_ID" + ) + + if not subscription_id: + return jsonify({"error": "subscription_id is required"}), 400 + + logger.info("Scan triggered for subscription %s", subscription_id) + + try: + engine = ScanEngine(subscription_id) + result = engine.run_scan() + except Exception as exc: + logger.error("Scan failed: %s", exc) + return jsonify({"error": "Scan failed", "detail": str(exc)}), 500 + + db = _get_db() + db.create_tables() + db.save_scan(result) + + return jsonify(result), 201 diff --git a/api/routes/score.py b/api/routes/score.py new file mode 100644 index 0000000..b7317ee --- /dev/null +++ b/api/routes/score.py @@ -0,0 +1,27 @@ +"""Score route: overall security posture score.""" + +import os +from flask import Blueprint, jsonify + +from api.models.finding import DatabaseManager + +score_bp = Blueprint("score", __name__) + + +def _get_db() -> DatabaseManager: + db = DatabaseManager(os.environ["DATABASE_URL"]) + db.connect() + return db + + +@score_bp.get("/api/score") +def get_score(): + """Return the overall security posture score (0–100). + + Score calculation: + Starts at 100. Deducts 10 per HIGH finding, 5 per MEDIUM, 2 per LOW. + Floors at 0. + """ + db = _get_db() + score = db.get_score() + return jsonify({"score": score, "max_score": 100}) diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json new file mode 100644 index 0000000..c575a6f --- /dev/null +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -0,0 +1,57 @@ +{ + "framework": "CIS Microsoft Azure Foundations Benchmark", + "version": "2.0.0", + "published": "2023-02", + "controls": { + "AZ-STOR-001": { + "control_id": "3.5", + "control_name": "Ensure that 'Public access level' is set to Private for blob containers", + "description": "Disabling public access level for blob containers prevents anonymous unauthenticated access to Azure Blob storage. This setting eliminates the risk of inadvertent or unauthorized public data exposure." + }, + "AZ-STOR-002": { + "control_id": "3.1", + "control_name": "Ensure that 'Secure transfer required' is set to 'Enabled'", + "description": "Enabling 'Secure transfer required' on a storage account ensures that all requests made to the storage account use HTTPS. Any requests using HTTP are rejected, protecting data in transit from eavesdropping and man-in-the-middle attacks." + }, + "AZ-NET-001": { + "control_id": "6.2", + "control_name": "Ensure that SSH access from the Internet is evaluated and restricted", + "description": "Network security groups should not allow unrestricted SSH access from the internet. Restricting inbound SSH access reduces attack surface and prevents unauthorized access attempts, brute-force attacks, and exploitation of SSH service vulnerabilities." + }, + "AZ-NET-002": { + "control_id": "6.3", + "control_name": "Ensure that RDP access from the Internet is evaluated and restricted", + "description": "Network security groups should not permit unrestricted inbound RDP from the internet. Open RDP ports are a leading cause of ransomware infections and credential-based attacks. Access should be restricted to specific trusted IP ranges or removed in favour of Azure Bastion." + }, + "AZ-IDN-001": { + "control_id": "1.23", + "control_name": "Ensure That No Custom Subscription Owner Roles Are Created", + "description": "Service principals or custom roles should not be assigned the Owner role at subscription scope. The Owner role grants full control including the ability to modify access controls. Assignment should follow the principle of least privilege." + }, + "AZ-IDN-002": { + "control_id": "1.2.4", + "control_name": "Ensure that 'Multi-Factor Authentication Status' is 'Enabled' for all Privileged Users", + "description": "Multi-Factor Authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. MFA should be enforced for all users with administrative privileges via Conditional Access policies." + }, + "AZ-DB-001": { + "control_id": "4.3.1", + "control_name": "Ensure 'Allow access to Azure services' for PostgreSQL Database Server is disabled", + "description": "Disabling public network access on PostgreSQL Database Server prevents public access and reduces the attack surface. Access should be restricted to private networks using VNet service endpoints or private endpoints." + }, + "AZ-DB-002": { + "control_id": "4.1.3", + "control_name": "Ensure that 'Auditing' Retention is 'greater than 90 days' for SQL servers", + "description": "SQL Server audit logs must be enabled and retained for a minimum of 90 days. Enabling auditing provides a record of database events that can be used to detect threats, investigate incidents, and demonstrate compliance." + }, + "AZ-CMP-001": { + "control_id": "7.2", + "control_name": "Ensure that 'OS disk' are encrypted", + "description": "Virtual machines that are reachable from the internet should have Network Security Groups attached to their network interfaces to control and restrict inbound and outbound traffic, reducing the attack surface." + }, + "AZ-KV-001": { + "control_id": "8.5", + "control_name": "Ensure the Key Vault is Recoverable", + "description": "Azure Key Vault soft delete should be enabled on all Key Vaults. The soft delete feature allows recovery of deleted vaults and vault objects (keys, secrets, certificates) for a configurable retention period (7–90 days), protecting against accidental or malicious deletion." + } + } +} diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json new file mode 100644 index 0000000..4283790 --- /dev/null +++ b/compliance/frameworks/iso27001.json @@ -0,0 +1,57 @@ +{ + "framework": "ISO/IEC 27001:2013", + "version": "2013", + "published": "2013-10", + "controls": { + "AZ-STOR-001": { + "control_id": "A.9.4.1", + "control_name": "Information access restriction", + "description": "Access to information and application system functions shall be restricted in accordance with the access control policy. Enabling public blob access on storage accounts removes all access restrictions and allows any internet user to read stored data without authentication, directly violating this control." + }, + "AZ-STOR-002": { + "control_id": "A.10.1.1", + "control_name": "Policy on the use of cryptographic controls", + "description": "A policy on the use of cryptographic controls for protection of information shall be developed and implemented. Storage accounts transmitting data over HTTP do not apply encryption in transit, violating the organisation's cryptographic control policy requirement to protect data confidentiality." + }, + "AZ-NET-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Networks shall be managed and controlled to protect information in systems and applications. NSGs permitting unrestricted SSH from the internet represent a failure of network access control, exposing systems to direct internet-based attack with no network-layer filtering." + }, + "AZ-NET-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Networks shall be managed and controlled to protect information in systems and applications. NSGs permitting unrestricted RDP from the internet represent a critical network control failure, as RDP is the most commonly exploited protocol for ransomware initial access." + }, + "AZ-IDN-001": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "The allocation and use of privileged access rights shall be restricted and controlled. Assigning the Owner role to service principals at subscription scope grants excessive privileged access rights beyond operational requirements, violating the principle of least privilege and privileged access management controls." + }, + "AZ-IDN-002": { + "control_id": "A.9.4.2", + "control_name": "Secure log-on procedures", + "description": "Where required by the access control policy, access to systems and applications shall be controlled by a secure log-on procedure. Multi-factor authentication is a required component of secure log-on for privileged accounts. Absence of MFA enforcement via Conditional Access violates this control." + }, + "AZ-DB-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Networks shall be managed and controlled to protect information in systems and applications. Database servers with public network access enabled lack the network-level isolation required to protect sensitive data from direct internet exposure and attack." + }, + "AZ-DB-002": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Event logs recording user activities, exceptions, faults and information security events shall be produced, kept and regularly reviewed. Disabling SQL Server auditing means that database access events, failed logins, and schema changes are not logged, making incident detection and forensic investigation impossible." + }, + "AZ-CMP-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Networks shall be managed and controlled to protect information in systems and applications. Virtual machines with public IPs and no Network Security Group have no network-layer access controls, exposing all ports and services to the internet without any filtering." + }, + "AZ-KV-001": { + "control_id": "A.17.2.1", + "control_name": "Availability of information processing facilities", + "description": "Information processing facilities shall be implemented with sufficient redundancy to meet availability requirements. Disabling soft delete on Key Vault removes the ability to recover deleted secrets, keys, and certificates, creating a single point of failure for critical cryptographic material and violating availability and recovery requirements." + } + } +} diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json new file mode 100644 index 0000000..869bc5a --- /dev/null +++ b/compliance/frameworks/nist_csf.json @@ -0,0 +1,57 @@ +{ + "framework": "NIST Cybersecurity Framework", + "version": "1.1", + "published": "2018-04", + "controls": { + "AZ-STOR-001": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Remote access to data assets is controlled. Unauthenticated public blob access on storage accounts violates access management controls by allowing anonymous access to potentially sensitive data without any form of authentication or authorisation." + }, + "AZ-STOR-002": { + "control_id": "PR.DS-2", + "control_name": "Data-in-transit is protected", + "description": "Data in transit is protected to prevent interception and tampering. Storage accounts that allow HTTP traffic transmit data in plaintext, violating the requirement to protect data in transit through encryption (TLS)." + }, + "AZ-NET-001": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Remote access to systems must be controlled. Allowing unrestricted SSH access from the internet bypasses access management controls and exposes systems to unauthorised remote access, brute-force attacks, and exploitation." + }, + "AZ-NET-002": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Remote access to systems must be managed. Allowing unrestricted RDP access from the internet bypasses access management controls and is a primary vector for ransomware delivery and credential-based attacks on Windows systems." + }, + "AZ-IDN-001": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorisations are managed, incorporating the principles of least privilege and separation of duties", + "description": "Access to cloud resources should follow the principle of least privilege. Assigning the Owner role to service principals at subscription scope grants excessive permissions that violate least-privilege and separation-of-duties requirements." + }, + "AZ-IDN-002": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited for authorised devices, users and processes", + "description": "Credentials must be managed to ensure only authorised parties can authenticate. Without MFA enforcement, a single compromised password grants full access to administrator accounts, undermining identity management controls." + }, + "AZ-DB-001": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Database servers should not be reachable from the public internet without restriction. Public network access to PostgreSQL servers removes the network-based access control layer, exposing the database to direct internet-based attacks." + }, + "AZ-DB-002": { + "control_id": "DE.CM-7", + "control_name": "Monitoring for unauthorised personnel, connections, devices, and software is performed", + "description": "Audit logging on SQL servers enables detection of unauthorised access attempts, privilege escalation, and suspicious database activity. Without auditing enabled, security events go undetected and incident investigation is severely limited." + }, + "AZ-CMP-001": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Virtual machines accessible from the internet must have compensating network controls. A VM with a public IP and no NSG has all ports exposed to the internet with no filtering, violating remote access management requirements." + }, + "AZ-KV-001": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "Key material in Azure Key Vault must be recoverable after accidental or malicious deletion. Soft delete provides a recoverable state for secrets, keys, and certificates, supporting backup and recovery requirements for critical cryptographic material." + } + } +} diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md new file mode 100644 index 0000000..2d60b2b --- /dev/null +++ b/docs/adding-a-rule.md @@ -0,0 +1,216 @@ +# Adding a New Scan Rule + +This is the fastest way to contribute to OpenShield. You can write, test, and submit a new rule in under 30 minutes. + +--- + +## The Rule Template + +Create a new file in `scanner/rules/`. The filename should match your rule ID in lowercase with underscores: + +``` +scanner/rules/az_stor_001.py ← for rule AZ-STOR-001 +``` + +Every rule file must have this exact structure: + +```python +"""AZ-XXXX-000: One-line description of what this rule detects.""" + +from typing import Any, Dict, List + +# ── Required module-level constants ───────────────────────────────────────── + +RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes. +RULE_NAME = "Human-readable name" # Shown in the dashboard and reports. +SEVERITY = "HIGH" # HIGH | MEDIUM | LOW | INFO +CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | KeyVault +FRAMEWORKS = { + "CIS": "3.5", # CIS Azure Benchmark control ID + "NIST": "PR.AC-3", # NIST CSF subcategory + "ISO27001": "A.9.4.1", # ISO 27001 Annex A control +} +DESCRIPTION = ( + "Explain WHY this is a security risk. One or two sentences. " + "What can an attacker do if this misconfiguration exists?" +) +REMEDIATION = ( + "Explain HOW to fix it. What setting to change, or what command to run." +) +PLAYBOOK = "playbooks/cli/fix_az_xxxx_000.sh" # path to the matching fix script + + +# ── Required scan function ─────────────────────────────────────────────────── + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Return a list of findings. Return [] if no issues are found. + + Args: + azure_client: An AzureClient instance with all SDK clients pre-configured. + subscription_id: The Azure subscription ID being scanned. + + Returns: + A list of finding dicts. Each dict must contain the keys below. + """ + findings: List[Dict[str, Any]] = [] + + for resource in azure_client.get_storage_accounts(): # ← replace with the right method + if : + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource.id, + "resource_name": resource.name, + "resource_type": "Microsoft.Storage/storageAccounts", # ← update + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + }) + + return findings +``` + +--- + +## Field-by-Field Explanation + +| Field | What to write | +|---|---| +| `RULE_ID` | `AZ-[CATEGORY]-[NUMBER]`. Prefix map: STOR, NET, IDN, DB, CMP, KV. Look at existing rules for the next number. | +| `SEVERITY` | `HIGH` = direct exploitation risk, `MEDIUM` = indirect or partial risk, `LOW` = best practice, `INFO` = informational only | +| `CATEGORY` | Matches the resource type being scanned | +| `FRAMEWORKS` | Use real control IDs from each framework. Refer to `compliance/frameworks/` JSON files for examples. | +| `DESCRIPTION` | Focus on WHY it matters — what is the real-world attack scenario? | +| `REMEDIATION` | Be specific. Name the Azure Portal setting or the exact CLI flag. | +| `PLAYBOOK` | Path to the matching bash script in `playbooks/cli/`. You must create this file too. | +| `resource_type` | The full Azure resource provider type string, e.g. `Microsoft.Network/networkSecurityGroups` | + +--- + +## AzureClient Methods Available + +| Method | Returns | +|---|---| +| `azure_client.get_storage_accounts()` | List of StorageAccount objects | +| `azure_client.get_network_security_groups()` | List of NetworkSecurityGroup objects | +| `azure_client.get_virtual_machines()` | List of VirtualMachine objects | +| `azure_client.get_postgresql_servers()` | List of Server objects (PostgreSQL single-server) | +| `azure_client.get_sql_servers()` | List of Server objects (Azure SQL) | +| `azure_client.get_sql_server_auditing_policy(rg, name)` | ServerBlobAuditingPolicy or None | +| `azure_client.get_key_vaults()` | List of Vault objects (with full properties) | +| `azure_client.get_service_principals()` | List of RoleAssignment objects for service principals | +| `azure_client.get_network_interface(rg, name)` | NetworkInterface or None | +| `azure_client.get_conditional_access_policies()` | List of CA policy dicts from MS Graph | +| `azure_client.parse_resource_id(id)` | Dict with `resource_group` and `name` | + +All methods return an empty list on failure — your scan function never needs to handle SDK exceptions. + +--- + +## Write the Remediation Playbook + +Create a matching bash script in `playbooks/cli/`: + +```bash +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-XXXX-000 — Your Rule Name +# Usage: ./fix_az_xxxx_000.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +# The actual az CLI command to fix the issue +az update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RESOURCE_NAME" \ + -- + +echo "✅ Remediation complete for $RESOURCE_NAME" +``` + +--- + +## Test Your Rule Locally + +```bash +# 1. Set credentials +cp .env.example .env +# Fill in your Azure credentials in .env + +# 2. Load env and run your rule in isolation +python -c " +from dotenv import load_dotenv; load_dotenv() +import os +from scanner.azure_client import AzureClient +from scanner.rules import az_xxxx_000 as rule # replace with your module name + +client = AzureClient(os.environ['AZURE_SUBSCRIPTION_ID']) +findings = rule.scan(client, os.environ['AZURE_SUBSCRIPTION_ID']) +print(f'Found {len(findings)} issue(s):') +for f in findings: + print(f' [{f[\"severity\"]}] {f[\"resource_name\"]} — {f[\"rule_name\"]}') +" + +# 3. Or run the full scan engine (loads all rules) +python -c " +from dotenv import load_dotenv; load_dotenv() +import json, os +from scanner.engine import ScanEngine +engine = ScanEngine(os.environ['AZURE_SUBSCRIPTION_ID']) +result = engine.run_scan() +print(json.dumps(result, indent=2)) +" +``` + +--- + +## Update the Compliance Framework Files + +If your rule maps to controls not yet in the compliance JSON files, add entries to the relevant file(s) in `compliance/frameworks/`: + +```json +{ + "controls": { + "AZ-XXXX-000": { + "control_id": "3.7", + "control_name": "CIS control name here", + "description": "Why this control is relevant to your finding." + } + } +} +``` + +--- + +## Submit a Pull Request + +```bash +git checkout -b rule/az-xxxx-000-short-description +git add scanner/rules/az_xxxx_000.py playbooks/cli/fix_az_xxxx_000.sh +git commit -m "feat: add rule AZ-XXXX-000 — short description" +git push origin rule/az-xxxx-000-short-description +``` + +Then open a PR. Use the PR template — it will ask you for the rule ID, severity, and which frameworks you mapped. A maintainer will review within 48 hours. + +--- + +## Common Mistakes to Avoid + +- **Rule ID clash**: always check `scanner/rules/` for existing IDs before numbering your rule. +- **Missing playbook**: every rule must have a matching `playbooks/cli/fix_*.sh` file. +- **Hardcoded subscription ID**: use the `subscription_id` parameter passed to `scan()`, never hardcode. +- **Exceptions crashing the scan**: the engine catches unhandled exceptions per rule, but write defensively — use `getattr(obj, "field", default)` for optional SDK attributes. +- **Empty `frameworks` dict**: always populate all three keys (CIS, NIST, ISO27001) even if you map to `"N/A"`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5217407 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,187 @@ +# OpenShield Architecture + +## Overview + +OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It continuously scans your Azure subscription against a library of security rules, maps every finding to compliance frameworks (CIS, NIST CSF, ISO 27001), and exposes results via a REST API consumed by a React dashboard. + +--- + +## High-Level Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ React Dashboard │ +│ (Azure Static Web Apps — Free tier) │ +└────────────────────────────┬─────────────────────────────────────┘ + │ HTTPS / JWT +┌────────────────────────────▼─────────────────────────────────────┐ +│ Flask REST API (api/) │ +│ │ +│ GET /api/findings GET /api/score │ +│ GET /api/findings/ GET /api/compliance/ │ +│ GET /api/scans POST /api/scans/trigger │ +└───────────┬──────────────────────────────────┬───────────────────┘ + │ │ +┌───────────▼──────────────┐ ┌───────────────▼───────────────────┐ +│ Scanner Engine │ │ Compliance Mapper │ +│ (scanner/) │ │ (compliance/frameworks/) │ +│ │ │ │ +│ ScanEngine │ │ cis_azure_benchmark.json │ +│ └── load_rules() │ │ nist_csf.json │ +│ └── run_scan() │ │ iso27001.json │ +└───────────┬───────────────┘ └────────────────────────────────────┘ + │ +┌───────────▼──────────────────────────────────────────────────────┐ +│ Rule Modules (scanner/rules/) │ +│ │ +│ az_stor_001.py az_net_001.py az_idn_001.py az_db_001.py │ +│ az_stor_002.py az_net_002.py az_idn_002.py az_db_002.py │ +│ az_cmp_001.py az_kv_001.py │ +└───────────┬───────────────────────────────────────────────────────┘ + │ calls +┌───────────▼──────────────────────────────────────────────────────┐ +│ AzureClient (scanner/azure_client.py) │ +│ │ +│ DefaultAzureCredential │ +│ StorageManagementClient NetworkManagementClient │ +│ ComputeManagementClient PostgreSQLManagementClient │ +│ SqlManagementClient KeyVaultManagementClient │ +│ AuthorizationManagementClient MS Graph REST API │ +└───────────┬───────────────────────────────────────────────────────┘ + │ Azure SDK calls +┌───────────▼──────────────────────────────────────────────────────┐ +│ Azure Subscription (target) │ +└──────────────────────────────────────────────────────────────────┘ + │ +┌───────────▼──────────────────────────────────────────────────────┐ +│ PostgreSQL Database │ +│ (findings, scans, rules tables) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## How the Scanner Works + +### 1. Initialisation + +```python +engine = ScanEngine(subscription_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") +``` + +`ScanEngine.__init__` creates an `AzureClient` using `DefaultAzureCredential`, which automatically resolves credentials from (in order): environment variables, managed identity, Azure CLI, or VS Code login. + +### 2. Rule Loading + +```python +engine.load_rules() +``` + +`load_rules()` iterates over every `*.py` file in `scanner/rules/` that does not start with `_`. It uses Python's `importlib.util` to load each file as a module and checks that the module exposes a `scan()` function. This means: + +- **Adding a rule requires no code change to the engine** — drop a file into `scanner/rules/` and it is automatically discovered on next startup. +- Rules that fail to load (syntax errors, missing imports) are logged and skipped. The remaining rules still run. + +### 3. Scan Execution + +```python +result = engine.run_scan() +``` + +`run_scan()` iterates through all loaded rule modules, calling `module.scan(azure_client, subscription_id)` for each. Individual rule failures are caught and logged without stopping the scan. The engine collects all findings and returns a structured result dict. + +### 4. Finding Schema + +Every finding returned by a rule must conform to this schema: + +```python +{ + "rule_id": str, # e.g. "AZ-STOR-001" + "rule_name": str, + "severity": str, # HIGH | MEDIUM | LOW | INFO + "category": str, # Storage | Network | Identity | Database | Compute | KeyVault + "resource_id": str, # full Azure resource ID + "resource_name": str, + "resource_type": str, # e.g. "Microsoft.Storage/storageAccounts" + "description": str, + "remediation": str, + "playbook": str, # path to the CLI remediation script + "frameworks": dict, # {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} + "detected_at": str, # ISO 8601, added by engine + "scan_id": str, # UUID, added by engine +} +``` + +--- + +## How Findings Flow to the API + +``` +run_scan() + → findings[] in memory + → db.save_scan(result) # persists to PostgreSQL + → return scan result JSON + +GET /api/findings + → db.get_findings(filters) # reads from PostgreSQL + → returns JSON array + +GET /api/score + → db.get_score() # severity-weighted 0-100 + → returns {"score": 82} + +GET /api/compliance/cis + → db.get_compliance_score("cis") # joins DB findings with CIS JSON + → returns per-control pass/fail breakdown +``` + +--- + +## How Rules Are Loaded Dynamically + +The engine uses Python's `importlib` to load rule files at runtime. No registry or central list is needed: + +```python +for rule_path in sorted(RULES_DIR.glob("*.py")): + if rule_path.name.startswith("_"): + continue + spec = importlib.util.spec_from_file_location(rule_path.stem, rule_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + if callable(getattr(module, "scan", None)): + self.rules.append(module) +``` + +Each rule module is a plain Python file — no base class, no registration decorator. The only contract is the `scan(azure_client, subscription_id)` function signature. + +--- + +## How Sentinel Integration Works + +> **Note:** Sentinel push is handled by a separate team. This section documents the integration point. + +After `run_scan()` returns, findings can be forwarded to Microsoft Sentinel via the Azure Monitor Ingestion API. The `sentinel/` directory contains the KQL detection rules and the ingestion client configuration. + +The flow: +1. `POST /api/scans/trigger` → scan completes → findings in DB +2. A Sentinel push worker (separate process or Azure Function) polls the DB for new findings +3. New findings are batched and sent to a Log Analytics Workspace via `azure-monitor-ingestion` +4. KQL detection rules in Sentinel fire alerts on HIGH-severity findings + +The required environment variable is `SENTINEL_WORKSPACE_ID` (see `.env.example`). + +--- + +## Configuration + +All runtime configuration is provided via environment variables (see `.env.example`): + +| Variable | Description | +|---|---| +| `AZURE_SUBSCRIPTION_ID` | Target subscription to scan | +| `AZURE_CLIENT_ID` | Service principal client ID | +| `AZURE_CLIENT_SECRET` | Service principal client secret | +| `AZURE_TENANT_ID` | Azure AD tenant ID | +| `DATABASE_URL` | PostgreSQL connection string | +| `JWT_SECRET` | Secret used to sign/verify API JWTs | +| `SENTINEL_WORKSPACE_ID` | Log Analytics workspace ID for Sentinel push | diff --git a/docs/azure-setup.md b/docs/azure-setup.md new file mode 100644 index 0000000..d2f8231 --- /dev/null +++ b/docs/azure-setup.md @@ -0,0 +1,205 @@ +# Azure Setup Guide + +This guide gets you from zero to a running OpenShield scan in under 20 minutes using a free Azure account. + +--- + +## Step 1 — Create a Free Azure Account + +1. Go to [azure.microsoft.com/free](https://azure.microsoft.com/free) and click **Start free**. +2. Sign in with a Microsoft account (or create one). +3. Complete the sign-up — you will receive $200 in free credits and access to free-tier services. +4. After sign-up, navigate to the [Azure Portal](https://portal.azure.com). + +--- + +## Step 2 — Get Your Subscription ID + +1. In the Azure Portal, search for **Subscriptions** in the top search bar. +2. Click on your subscription name. +3. Copy the **Subscription ID** (a UUID like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). + +You will need this value for `AZURE_SUBSCRIPTION_ID` in your `.env` file. + +--- + +## Step 3 — Create a Service Principal with Reader Role + +OpenShield only needs read access to scan your subscription. Use the Azure CLI: + +```bash +# Install Azure CLI if you haven't already +# https://learn.microsoft.com/en-us/cli/azure/install-azure-cli + +# Login +az login + +# Create the service principal with Reader role +az ad sp create-for-rbac \ + --name "openshield-scanner" \ + --role Reader \ + --scopes /subscriptions/ \ + --output json +``` + +This command outputs JSON like: + +```json +{ + "appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "displayName": "openshield-scanner", + "password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + +Map these values: +- `appId` → `AZURE_CLIENT_ID` +- `password` → `AZURE_CLIENT_SECRET` +- `tenant` → `AZURE_TENANT_ID` + +> **Important:** The `password` is only shown once. Copy it immediately. + +--- + +## Step 4 — Grant Additional Read Permissions (Optional) + +For the Conditional Access MFA rule (AZ-IDN-002), the service principal needs the +`Policy.Read.All` Microsoft Graph API permission: + +```bash +# Get the service principal object ID +SP_OBJECT_ID=$(az ad sp show --id --query id --output tsv) + +# Grant Policy.Read.All application permission +# This requires a Global Administrator to consent +az rest \ + --method POST \ + --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID/appRoleAssignments" \ + --body '{ + "principalId": "'$SP_OBJECT_ID'", + "resourceId": "", + "appRoleId": "246dd0d5-5bd0-4def-940b-0421030a5b68" + }' +``` + +If you skip this step, AZ-IDN-002 will produce a finding by default (it cannot verify MFA status without Graph access). + +--- + +## Step 5 — Configure Your .env File + +Copy the example and fill in your values: + +```bash +cp .env.example .env +``` + +Edit `.env`: + +``` +AZURE_SUBSCRIPTION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +AZURE_CLIENT_SECRET=your-client-secret-from-step-3 +AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield +JWT_SECRET=your-random-secret-at-least-32-chars +SENTINEL_WORKSPACE_ID= +``` + +--- + +## Step 6 — Start a Local PostgreSQL Database + +```bash +# Option A: Docker (easiest) +docker run --name openshield-db \ + -e POSTGRES_USER=openshield \ + -e POSTGRES_PASSWORD=openshield \ + -e POSTGRES_DB=openshield \ + -p 5432:5432 \ + -d postgres:15 + +# Option B: Homebrew (macOS) +brew install postgresql@15 +brew services start postgresql@15 +createdb openshield +``` + +The `DatabaseManager.create_tables()` call in the scan trigger will create the schema automatically on first run. + +--- + +## Step 7 — Run Your First Scan + +```bash +# From the openshield/ directory +cd openshield + +# Install dependencies +pip install -r requirements.txt + +# Run the scanner directly +python -c " +from dotenv import load_dotenv; load_dotenv() +import json, os +from scanner.engine import ScanEngine +engine = ScanEngine(os.environ['AZURE_SUBSCRIPTION_ID']) +result = engine.run_scan() +print(json.dumps(result, indent=2)) +" +``` + +Or trigger via the API: + +```bash +# Start the API server +FLASK_APP=api/app.py flask run + +# Trigger a scan +curl -X POST http://localhost:5000/api/scans/trigger \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"subscription_id": "your-subscription-id"}' +``` + +--- + +## Step 8 — Activate the Microsoft Sentinel 90-Day Trial (Optional) + +Microsoft Sentinel includes a 90-day free trial for new Log Analytics workspaces. + +1. In the Azure Portal, search for **Microsoft Sentinel**. +2. Click **Create Microsoft Sentinel**. +3. Click **Create a new workspace** and fill in: + - Workspace name: `openshield-logs` + - Region: choose the same region as your resources +4. Click **Add Microsoft Sentinel** — the 90-day trial activates automatically. +5. Copy the **Workspace ID** from the workspace Overview page. +6. Add it to your `.env`: `SENTINEL_WORKSPACE_ID=` + +> **Cost after trial:** ~$2.76/GB ingested. For a small subscription with few findings, this is negligible. + +--- + +## Step 9 — Create a Log Analytics Workspace (for SQL Auditing) + +The AZ-DB-002 remediation playbook writes SQL audit logs to a storage account. To route them to Log Analytics instead: + +1. Go to your SQL server in the portal. +2. Under **Security**, click **Auditing**. +3. Set **Auditing** to **ON**. +4. Check **Log Analytics** and select your `openshield-logs` workspace. +5. Click **Save**. + +--- + +## Troubleshooting + +| Problem | Fix | +|---|---| +| `DefaultAzureCredential` fails | Run `az login` in the terminal, or verify env vars are set | +| `AZURE_CLIENT_SECRET` rejected | The secret may have expired — rotate it with `az ad sp credential reset` | +| `psycopg2.OperationalError` | Check your PostgreSQL container is running and `DATABASE_URL` is correct | +| Empty findings | Verify the service principal has `Reader` role on the subscription | +| AZ-IDN-002 always fires | The service principal needs `Policy.Read.All` Graph permission — see Step 4 | diff --git a/openshield b/openshield deleted file mode 160000 index 647f74b..0000000 --- a/openshield +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 647f74b69888891b39d3af8aa77aacfcfae83770 diff --git a/playbooks/cli/fix_az_cmp_001.sh b/playbooks/cli/fix_az_cmp_001.sh new file mode 100755 index 0000000..5d997de --- /dev/null +++ b/playbooks/cli/fix_az_cmp_001.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-CMP-001 — VM with Public IP and No Associated NSG on Network Interface +# Usage: ./fix_az_cmp_001.sh +# Severity: HIGH +# +# This script associates an existing NSG with the vulnerable NIC. +# If the NSG does not yet exist, create it first: +# az network nsg create --resource-group --name + +set -e + +RESOURCE_GROUP=$1 +NIC_NAME=$2 +NSG_NAME=$3 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$NIC_NAME" ] || [ -z "$NSG_NAME" ]; then + echo "Usage: $0 " + echo "" + echo "To create a new NSG first:" + echo " az network nsg create --resource-group --name " + exit 1 +fi + +echo "Associating NSG '$NSG_NAME' with NIC '$NIC_NAME'..." + +az network nic update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$NIC_NAME" \ + --network-security-group "$NSG_NAME" + +echo "✅ Remediation complete for $NIC_NAME — NSG '$NSG_NAME' is now associated." +echo "⚠️ Review the NSG rules to ensure only necessary inbound traffic is permitted." diff --git a/playbooks/cli/fix_az_db_001.sh b/playbooks/cli/fix_az_db_001.sh new file mode 100755 index 0000000..93e9068 --- /dev/null +++ b/playbooks/cli/fix_az_db_001.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-DB-001 — PostgreSQL Server Allows Public Network Access +# Usage: ./fix_az_db_001.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Disabling public network access on PostgreSQL server: $RESOURCE_NAME" + +az postgres server update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RESOURCE_NAME" \ + --public-network-access Disabled + +echo "" +echo "✅ Remediation complete for $RESOURCE_NAME — public network access is now disabled." +echo "⚠️ Ensure a private endpoint or VNet service endpoint is configured before" +echo " disabling public access, or applications will lose connectivity." diff --git a/playbooks/cli/fix_az_db_002.sh b/playbooks/cli/fix_az_db_002.sh new file mode 100755 index 0000000..ad05c6e --- /dev/null +++ b/playbooks/cli/fix_az_db_002.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-DB-002 — Azure SQL Server Has No Auditing Configured +# Usage: ./fix_az_db_002.sh +# Severity: MEDIUM + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 +STORAGE_ACCOUNT=$3 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ] || [ -z "$STORAGE_ACCOUNT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Enabling SQL server auditing on: $RESOURCE_NAME" +echo "Audit logs will be written to storage account: $STORAGE_ACCOUNT" + +# Get the storage account endpoint +STORAGE_ENDPOINT=$(az storage account show \ + --name "$STORAGE_ACCOUNT" \ + --resource-group "$RESOURCE_GROUP" \ + --query primaryEndpoints.blob \ + --output tsv) + +az sql server audit-policy update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RESOURCE_NAME" \ + --state Enabled \ + --blob-storage-target-state Enabled \ + --storage-account "$STORAGE_ACCOUNT" \ + --retention-days 90 + +echo "✅ Remediation complete for $RESOURCE_NAME — SQL auditing enabled with 90-day retention." diff --git a/playbooks/cli/fix_az_idn_001.sh b/playbooks/cli/fix_az_idn_001.sh new file mode 100755 index 0000000..a076bbb --- /dev/null +++ b/playbooks/cli/fix_az_idn_001.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-001 — Service Principal Assigned Owner Role at Subscription Scope +# Usage: ./fix_az_idn_001.sh +# Severity: HIGH +# +# This script removes the Owner role from the service principal at subscription scope. +# You will need to assign a least-privilege replacement role manually afterwards. + +set -e + +SUBSCRIPTION_ID=$1 +PRINCIPAL_ID=$2 + +if [ -z "$SUBSCRIPTION_ID" ] || [ -z "$PRINCIPAL_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +SCOPE="/subscriptions/$SUBSCRIPTION_ID" +OWNER_ROLE="Owner" + +echo "Finding Owner role assignment for principal $PRINCIPAL_ID at subscription scope..." + +ASSIGNMENT_ID=$(az role assignment list \ + --scope "$SCOPE" \ + --assignee "$PRINCIPAL_ID" \ + --role "$OWNER_ROLE" \ + --query "[0].id" \ + --output tsv) + +if [ -z "$ASSIGNMENT_ID" ]; then + echo "No Owner role assignment found for principal $PRINCIPAL_ID — already remediated." + exit 0 +fi + +echo "Deleting role assignment: $ASSIGNMENT_ID" + +az role assignment delete \ + --ids "$ASSIGNMENT_ID" + +echo "" +echo "✅ Remediation complete — Owner role removed from $PRINCIPAL_ID." +echo "⚠️ ACTION REQUIRED: Assign a least-privilege replacement role to the service principal." +echo " Example: az role assignment create --assignee $PRINCIPAL_ID --role Contributor --scope $SCOPE" diff --git a/playbooks/cli/fix_az_idn_002.sh b/playbooks/cli/fix_az_idn_002.sh new file mode 100755 index 0000000..c66a51d --- /dev/null +++ b/playbooks/cli/fix_az_idn_002.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-002 — No MFA Enforced on Admin Accounts via Conditional Access +# Usage: ./fix_az_idn_002.sh +# Severity: HIGH +# +# This script creates a Conditional Access policy via Microsoft Graph API +# that requires MFA for all users assigned administrator directory roles. +# Prerequisites: +# - az login with a Global Administrator or Conditional Access Administrator account +# - Microsoft Graph PowerShell or Graph API access + +set -e + +echo "Creating Conditional Access policy to enforce MFA for administrators..." +echo "" +echo "This operation requires Global Administrator or Conditional Access Administrator privileges." +echo "" + +# Prompt for confirmation +read -p "Proceed with creating the MFA enforcement policy? [y/N] " CONFIRM +if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Aborted." + exit 0 +fi + +# Acquire a Graph API token +TOKEN=$(az account get-access-token \ + --resource https://graph.microsoft.com \ + --query accessToken \ + --output tsv) + +POLICY_BODY='{ + "displayName": "OpenShield: Require MFA for Administrators", + "state": "enabled", + "conditions": { + "users": { + "includeRoles": [ + "62e90394-69f5-4237-9190-012177145e10", + "e8611ab8-c189-46e8-94e1-60213ab1f814", + "194ae4cb-b126-40b2-bd5b-6091b380977d", + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3" + ] + }, + "applications": { + "includeApplications": ["All"] + } + }, + "grantControls": { + "operator": "OR", + "builtInControls": ["mfa"] + } +}' + +RESPONSE=$(curl -s -X POST \ + "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$POLICY_BODY") + +POLICY_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) + +if [ -n "$POLICY_ID" ]; then + echo "✅ Remediation complete — Conditional Access policy created: $POLICY_ID" +else + echo "❌ Policy creation failed. Response:" + echo "$RESPONSE" + exit 1 +fi diff --git a/playbooks/cli/fix_az_kv_001.sh b/playbooks/cli/fix_az_kv_001.sh new file mode 100755 index 0000000..ee900d7 --- /dev/null +++ b/playbooks/cli/fix_az_kv_001.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-KV-001 — Key Vault with Soft Delete Disabled +# Usage: ./fix_az_kv_001.sh +# Severity: MEDIUM +# +# Note: Enabling soft delete is a one-way operation — it cannot be reversed. +# Once enabled, deleted objects enter a recoverable state for the retention period. + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Enabling soft delete on Key Vault: $RESOURCE_NAME" +echo "⚠️ This operation is irreversible. Soft delete, once enabled, cannot be disabled." +echo "" + +read -p "Proceed? [y/N] " CONFIRM +if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Aborted." + exit 0 +fi + +az keyvault update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$RESOURCE_NAME" \ + --enable-soft-delete true \ + --retention-days 90 + +echo "" +echo "✅ Remediation complete for $RESOURCE_NAME — soft delete is now enabled (90-day retention)." +echo "Consider also enabling purge protection:" +echo " az keyvault update --name $RESOURCE_NAME --resource-group $RESOURCE_GROUP --enable-purge-protection true" diff --git a/playbooks/cli/fix_az_net_001.sh b/playbooks/cli/fix_az_net_001.sh new file mode 100755 index 0000000..fd3fbe8 --- /dev/null +++ b/playbooks/cli/fix_az_net_001.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-001 — NSG Allows Unrestricted Inbound SSH from Any Source +# Usage: ./fix_az_net_001.sh [rule-name] +# Severity: HIGH +# +# Pass the optional rule-name if the offending rule is known (shown in finding metadata). +# Without it, the script removes any Allow-Inbound-TCP-22-from-Any rule it finds. + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 +RULE_NAME=${3:-""} + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 [rule-name]" + exit 1 +fi + +if [ -n "$RULE_NAME" ]; then + echo "Deleting NSG rule '$RULE_NAME' from $RESOURCE_NAME" + az network nsg rule delete \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$RESOURCE_NAME" \ + --name "$RULE_NAME" +else + echo "Searching for inbound SSH rules in $RESOURCE_NAME..." + RULES=$(az network nsg rule list \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$RESOURCE_NAME" \ + --query "[?direction=='Inbound' && access=='Allow' && destinationPortRange=='22' && (sourceAddressPrefix=='*' || sourceAddressPrefix=='0.0.0.0/0' || sourceAddressPrefix=='Internet')].name" \ + --output tsv) + + if [ -z "$RULES" ]; then + echo "No matching open SSH rule found — manual review recommended." + exit 0 + fi + + for RULE in $RULES; do + echo "Deleting rule: $RULE" + az network nsg rule delete \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$RESOURCE_NAME" \ + --name "$RULE" + done +fi + +echo "✅ Remediation complete for $RESOURCE_NAME — unrestricted SSH access removed." diff --git a/playbooks/cli/fix_az_net_002.sh b/playbooks/cli/fix_az_net_002.sh new file mode 100755 index 0000000..4cb7833 --- /dev/null +++ b/playbooks/cli/fix_az_net_002.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-002 — NSG Allows Unrestricted Inbound RDP from Any Source +# Usage: ./fix_az_net_002.sh [rule-name] +# Severity: HIGH +# +# Pass the optional rule-name if the offending rule is known (shown in finding metadata). + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 +RULE_NAME=${3:-""} + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 [rule-name]" + exit 1 +fi + +if [ -n "$RULE_NAME" ]; then + echo "Deleting NSG rule '$RULE_NAME' from $RESOURCE_NAME" + az network nsg rule delete \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$RESOURCE_NAME" \ + --name "$RULE_NAME" +else + echo "Searching for inbound RDP rules in $RESOURCE_NAME..." + RULES=$(az network nsg rule list \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$RESOURCE_NAME" \ + --query "[?direction=='Inbound' && access=='Allow' && destinationPortRange=='3389' && (sourceAddressPrefix=='*' || sourceAddressPrefix=='0.0.0.0/0' || sourceAddressPrefix=='Internet')].name" \ + --output tsv) + + if [ -z "$RULES" ]; then + echo "No matching open RDP rule found — manual review recommended." + exit 0 + fi + + for RULE in $RULES; do + echo "Deleting rule: $RULE" + az network nsg rule delete \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$RESOURCE_NAME" \ + --name "$RULE" + done +fi + +echo "✅ Remediation complete for $RESOURCE_NAME — unrestricted RDP access removed." diff --git a/playbooks/cli/fix_az_stor_001.sh b/playbooks/cli/fix_az_stor_001.sh new file mode 100755 index 0000000..2839a5c --- /dev/null +++ b/playbooks/cli/fix_az_stor_001.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-STOR-001 — Public Blob Access Enabled on Storage Account +# Usage: ./fix_az_stor_001.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Disabling public blob access on storage account: $RESOURCE_NAME" + +az storage account update \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --allow-blob-public-access false + +echo "✅ Remediation complete for $RESOURCE_NAME — public blob access is now disabled." diff --git a/playbooks/cli/fix_az_stor_002.sh b/playbooks/cli/fix_az_stor_002.sh new file mode 100755 index 0000000..1d96907 --- /dev/null +++ b/playbooks/cli/fix_az_stor_002.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-STOR-002 — Storage Account Allows HTTP Traffic (Not HTTPS-Only) +# Usage: ./fix_az_stor_002.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +RESOURCE_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Enabling HTTPS-only (secure transfer required) on: $RESOURCE_NAME" + +az storage account update \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --https-only true + +echo "✅ Remediation complete for $RESOURCE_NAME — HTTPS-only traffic is now enforced." diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ee81347 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +flask==3.0.0 +flask-cors==4.0.0 +azure-identity==1.15.0 +azure-mgmt-storage==21.0.0 +azure-mgmt-network==25.0.0 +azure-mgmt-compute==30.0.0 +azure-mgmt-resource==23.0.0 +azure-mgmt-sql==3.0.1 +azure-mgmt-keyvault==10.3.0 +azure-mgmt-rdbms==10.1.0 +azure-mgmt-authorization==4.0.0 +azure-monitor-ingestion==1.0.3 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +pyjwt==2.8.0 +requests==2.31.0 diff --git a/scanner/__init__.py b/scanner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index e69de29..e7381b5 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -0,0 +1,188 @@ +"""Azure SDK wrapper providing typed accessors for all CSPM scan operations.""" + +import logging +from typing import Any, Dict, List, Optional + +from azure.identity import DefaultAzureCredential +from azure.mgmt.authorization import AuthorizationManagementClient +from azure.mgmt.compute import ComputeManagementClient +from azure.mgmt.keyvault import KeyVaultManagementClient +from azure.mgmt.network import NetworkManagementClient +from azure.mgmt.rdbms.postgresql import PostgreSQLManagementClient +from azure.mgmt.sql import SqlManagementClient +from azure.mgmt.storage import StorageManagementClient + +logger = logging.getLogger(__name__) + +# Azure built-in role definition GUIDs (subscription-scoped) +OWNER_ROLE_ID = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" + + +class AzureClient: + """Wraps Azure SDK management clients for all CSPM scan operations. + + Instantiate once per scan and share across all rule modules. Every method + logs on failure and returns an empty list so individual rule failures never + crash the scan engine. + """ + + def __init__( + self, subscription_id: str, credential: Optional[Any] = None + ) -> None: + self.subscription_id = subscription_id + self.credential = credential or DefaultAzureCredential() + + # ------------------------------------------------------------------ # + # Static helpers # + # ------------------------------------------------------------------ # + + @staticmethod + def parse_resource_id(resource_id: str) -> Dict[str, str]: + """Return resource_group and name parsed from an Azure resource ID.""" + 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 + + # ------------------------------------------------------------------ # + # Storage # + # ------------------------------------------------------------------ # + + def get_storage_accounts(self) -> List[Any]: + """List all storage accounts in the subscription.""" + try: + client = StorageManagementClient(self.credential, self.subscription_id) + return list(client.storage_accounts.list()) + except Exception as exc: + logger.error("get_storage_accounts failed: %s", exc) + return [] + + # ------------------------------------------------------------------ # + # Network # + # ------------------------------------------------------------------ # + + def get_network_security_groups(self) -> List[Any]: + """List all NSGs across all resource groups in the subscription.""" + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + return list(client.network_security_groups.list_all()) + except Exception as exc: + logger.error("get_network_security_groups failed: %s", exc) + return [] + + def get_network_interface( + self, resource_group: str, nic_name: str + ) -> Optional[Any]: + """Fetch a single NIC by resource group and name.""" + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + return client.network_interfaces.get(resource_group, nic_name) + except Exception as exc: + logger.error("get_network_interface(%s) failed: %s", nic_name, exc) + return None + + # ------------------------------------------------------------------ # + # Compute # + # ------------------------------------------------------------------ # + + def get_virtual_machines(self) -> List[Any]: + """List all VMs across all resource groups in the subscription.""" + try: + client = ComputeManagementClient(self.credential, self.subscription_id) + return list(client.virtual_machines.list_all()) + except Exception as exc: + logger.error("get_virtual_machines failed: %s", exc) + return [] + + # ------------------------------------------------------------------ # + # Databases # + # ------------------------------------------------------------------ # + + def get_postgresql_servers(self) -> List[Any]: + """List all PostgreSQL single-server instances in the subscription.""" + try: + client = PostgreSQLManagementClient(self.credential, self.subscription_id) + return list(client.servers.list()) + except Exception as exc: + logger.error("get_postgresql_servers failed: %s", exc) + return [] + + def get_sql_servers(self) -> List[Any]: + """List all Azure SQL servers in the subscription.""" + try: + client = SqlManagementClient(self.credential, self.subscription_id) + return list(client.servers.list()) + except Exception as exc: + logger.error("get_sql_servers failed: %s", exc) + return [] + + def get_sql_server_auditing_policy( + self, resource_group: str, server_name: str + ) -> Optional[Any]: + """Fetch the blob auditing policy for an Azure SQL server.""" + try: + client = SqlManagementClient(self.credential, self.subscription_id) + return client.server_blob_auditing_policies.get(resource_group, server_name) + except Exception as exc: + logger.error( + "get_sql_server_auditing_policy(%s) failed: %s", server_name, exc + ) + return None + + # ------------------------------------------------------------------ # + # Key Vault # + # ------------------------------------------------------------------ # + + def get_key_vaults(self) -> List[Any]: + """List all Key Vaults in the subscription with full properties.""" + try: + client = KeyVaultManagementClient(self.credential, self.subscription_id) + return list(client.vaults.list_by_subscription()) + except Exception as exc: + logger.error("get_key_vaults failed: %s", exc) + return [] + + # ------------------------------------------------------------------ # + # Identity / Authorization # + # ------------------------------------------------------------------ # + + def get_service_principals(self) -> List[Any]: + """Return role assignments whose principal type is ServicePrincipal.""" + try: + client = AuthorizationManagementClient( + self.credential, self.subscription_id + ) + scope = f"/subscriptions/{self.subscription_id}" + assignments = list(client.role_assignments.list_for_scope(scope)) + return [ + a + for a in assignments + if getattr(a, "principal_type", "") == "ServicePrincipal" + ] + except Exception as exc: + logger.error("get_service_principals failed: %s", exc) + return [] + + def get_conditional_access_policies(self) -> List[Any]: + """Fetch Conditional Access policies from the Microsoft Graph API. + + Requires the credential to have 'Policy.Read.All' Graph permission. + Returns empty list if the permission is not granted or the call fails. + """ + import requests # imported here to keep azure-only paths dependency-free + + try: + token = self.credential.get_token("https://graph.microsoft.com/.default") + headers = {"Authorization": f"Bearer {token.token}"} + response = requests.get( + "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies", + headers=headers, + timeout=30, + ) + response.raise_for_status() + return response.json().get("value", []) + except Exception as exc: + logger.error("get_conditional_access_policies failed: %s", exc) + return [] diff --git a/scanner/engine.py b/scanner/engine.py index e69de29..46ce0e3 100644 --- a/scanner/engine.py +++ b/scanner/engine.py @@ -0,0 +1,110 @@ +"""Scan engine: loads rules dynamically and orchestrates a full subscription scan.""" + +import importlib.util +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List + +from scanner.azure_client import AzureClient + +logger = logging.getLogger(__name__) + +RULES_DIR = Path(__file__).parent / "rules" + + +class ScanEngine: + """Orchestrates Azure CSPM scans against a target subscription. + + Rules are loaded dynamically at initialisation time from ``scanner/rules/``. + Each rule module must expose a ``scan(azure_client, subscription_id)`` + function and the module-level constants ``RULE_ID``, ``RULE_NAME``, + ``SEVERITY``, ``CATEGORY``, ``FRAMEWORKS``, ``DESCRIPTION``, + ``REMEDIATION``, and ``PLAYBOOK``. + """ + + def __init__(self, subscription_id: str) -> None: + self.subscription_id = subscription_id + self.client = AzureClient(subscription_id) + self.rules: List[Any] = [] + self.load_rules() + + # ------------------------------------------------------------------ # + # Rule loading # + # ------------------------------------------------------------------ # + + def load_rules(self) -> None: + """Dynamically import every *.py file in scanner/rules/ as a rule module.""" + for rule_path in sorted(RULES_DIR.glob("*.py")): + if rule_path.name.startswith("_"): + continue + try: + spec = importlib.util.spec_from_file_location( + rule_path.stem, rule_path + ) + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + spec.loader.exec_module(module) # type: ignore[union-attr] + if callable(getattr(module, "scan", None)): + self.rules.append(module) + logger.info( + "Loaded rule: %s", getattr(module, "RULE_ID", rule_path.stem) + ) + else: + logger.warning( + "Rule file %s has no scan() function — skipped", rule_path.name + ) + except Exception as exc: + logger.error("Failed to load rule %s: %s", rule_path.name, exc) + + # ------------------------------------------------------------------ # + # Scan execution # + # ------------------------------------------------------------------ # + + def run_scan(self) -> Dict[str, Any]: + """Execute all loaded rules and return a normalised scan result. + + Returns: + dict with keys: scan_id, subscription_id, started_at, + completed_at, total_findings, findings. + """ + scan_id = str(uuid.uuid4()) + started_at = datetime.now(timezone.utc).isoformat() + findings: List[Dict[str, Any]] = [] + detected_at = datetime.now(timezone.utc).isoformat() + + logger.info( + "Scan %s starting against subscription %s — %d rules loaded", + scan_id, + self.subscription_id, + len(self.rules), + ) + + for rule in self.rules: + rule_id = getattr(rule, "RULE_ID", "UNKNOWN") + try: + rule_findings = rule.scan(self.client, self.subscription_id) + for finding in rule_findings: + finding.setdefault("detected_at", detected_at) + finding.setdefault("scan_id", scan_id) + findings.extend(rule_findings) + logger.info( + "Rule %s produced %d finding(s)", rule_id, len(rule_findings) + ) + except Exception as exc: + logger.error("Rule %s raised an exception: %s", rule_id, exc) + + completed_at = datetime.now(timezone.utc).isoformat() + + logger.info( + "Scan %s complete — %d total finding(s)", scan_id, len(findings) + ) + + return { + "scan_id": scan_id, + "subscription_id": self.subscription_id, + "started_at": started_at, + "completed_at": completed_at, + "total_findings": len(findings), + "findings": findings, + } diff --git a/scanner/rules/__init__.py b/scanner/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scanner/rules/az_cmp_001.py b/scanner/rules/az_cmp_001.py new file mode 100644 index 0000000..64785b3 --- /dev/null +++ b/scanner/rules/az_cmp_001.py @@ -0,0 +1,77 @@ +"""AZ-CMP-001: Virtual machine has a public IP with no associated NSG.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-CMP-001" +RULE_NAME = "VM with Public IP and No Associated NSG on Network Interface" +SEVERITY = "HIGH" +CATEGORY = "Compute" +FRAMEWORKS = {"CIS": "7.2", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "A virtual machine has a public IP address assigned to its network interface " + "but no Network Security Group protecting that interface. Without an NSG, " + "all inbound ports are open to the internet by default, creating an unrestricted " + "attack surface." +) +REMEDIATION = ( + "Associate an NSG with the VM's network interface or its subnet that allows " + "only required inbound traffic. Remove the public IP if internet access is not needed " + "and use Azure Bastion or a VPN gateway for administrative access." +) +PLAYBOOK = "playbooks/cli/fix_az_cmp_001.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect VMs whose NIC has a public IP but no NSG attached.""" + findings: List[Dict[str, Any]] = [] + + for vm in azure_client.get_virtual_machines(): + network_profile = getattr(vm, "network_profile", None) + if not network_profile: + continue + + for nic_ref in getattr(network_profile, "network_interfaces", []) or []: + nic_id = getattr(nic_ref, "id", "") + if not nic_id: + continue + + parsed = azure_client.parse_resource_id(nic_id) + resource_group = parsed.get("resource_group", "") + nic_name = parsed.get("name", "") + if not resource_group or not nic_name: + continue + + nic = azure_client.get_network_interface(resource_group, nic_name) + if not nic: + continue + + has_public_ip = any( + getattr(ip_cfg, "public_ip_address", None) + for ip_cfg in (getattr(nic, "ip_configurations", []) or []) + ) + has_nsg = bool(getattr(nic, "network_security_group", None)) + + if has_public_ip and not has_nsg: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vm.id, + "resource_name": vm.name, + "resource_type": "Microsoft.Compute/virtualMachines", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "nic_id": nic_id, + "nic_name": nic_name, + }, + }) + break # one finding per VM is sufficient + + return findings diff --git a/scanner/rules/az_db_001.py b/scanner/rules/az_db_001.py new file mode 100644 index 0000000..be9e367 --- /dev/null +++ b/scanner/rules/az_db_001.py @@ -0,0 +1,48 @@ +"""AZ-DB-001: PostgreSQL server allows public network access.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-DB-001" +RULE_NAME = "PostgreSQL Server Allows Public Network Access" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "4.3.1", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "The Azure Database for PostgreSQL server is configured to allow public network access. " + "This means the server endpoint is reachable from the public internet, increasing the " + "attack surface. Database servers should only be accessible from trusted private networks." +) +REMEDIATION = ( + "Disable public network access on the PostgreSQL server and configure a private endpoint " + "or VNet service endpoint to restrict connectivity to trusted networks only." +) +PLAYBOOK = "playbooks/cli/fix_az_db_001.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect PostgreSQL servers with public_network_access set to Enabled.""" + findings: List[Dict[str, Any]] = [] + + for server in azure_client.get_postgresql_servers(): + public_access = getattr(server, "public_network_access", "Enabled") + if str(public_access).lower() in ("enabled", "true", "1"): + parsed = azure_client.parse_resource_id(server.id) + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server.name, + "resource_type": "Microsoft.DBforPostgreSQL/servers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": parsed.get("resource_group", ""), + "location": getattr(server, "location", ""), + }, + }) + + return findings diff --git a/scanner/rules/az_db_002.py b/scanner/rules/az_db_002.py new file mode 100644 index 0000000..7edba32 --- /dev/null +++ b/scanner/rules/az_db_002.py @@ -0,0 +1,60 @@ +"""AZ-DB-002: Azure SQL server has no auditing configured.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-DB-002" +RULE_NAME = "Azure SQL Server Has No Auditing Configured" +SEVERITY = "MEDIUM" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "4.1.3", "NIST": "DE.CM-7", "ISO27001": "A.12.4.1"} +DESCRIPTION = ( + "Azure SQL Server auditing is disabled. Without auditing, database access, " + "schema changes, and failed login attempts are not logged, making forensic " + "investigation and compliance reporting impossible." +) +REMEDIATION = ( + "Enable SQL Server auditing and configure a storage account, Log Analytics " + "workspace, or Event Hub as the audit log destination. " + "Retain logs for at least 90 days to satisfy most compliance frameworks." +) +PLAYBOOK = "playbooks/cli/fix_az_db_002.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect SQL servers where server-level blob auditing is disabled.""" + findings: List[Dict[str, Any]] = [] + + for server in azure_client.get_sql_servers(): + parsed = azure_client.parse_resource_id(server.id) + resource_group = parsed.get("resource_group", "") + if not resource_group: + continue + + policy = azure_client.get_sql_server_auditing_policy(resource_group, server.name) + if policy is None: + # Could not retrieve policy — treat as unaudited + is_disabled = True + else: + state = str(getattr(policy, "state", "Disabled")) + is_disabled = state.lower() != "enabled" + + if is_disabled: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server.name, + "resource_type": "Microsoft.Sql/servers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": resource_group, + "auditing_state": getattr(policy, "state", "Unknown") if policy else "Unknown", + }, + }) + + return findings diff --git a/scanner/rules/az_idn_001.py b/scanner/rules/az_idn_001.py new file mode 100644 index 0000000..ac64ccf --- /dev/null +++ b/scanner/rules/az_idn_001.py @@ -0,0 +1,57 @@ +"""AZ-IDN-001: Service principal assigned Owner role at subscription scope.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-001" +RULE_NAME = "Service Principal Assigned Owner Role at Subscription Scope" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "1.23", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3"} +DESCRIPTION = ( + "A service principal holds the Owner role at subscription scope, granting it " + "full control over all resources and the ability to assign roles to other principals. " + "This violates the principle of least privilege and represents a critical blast-radius " + "risk if the service principal credentials are compromised." +) +REMEDIATION = ( + "Replace the Owner role assignment with a narrower built-in role (e.g., Contributor, " + "or a custom role) that covers only the required permissions. " + "Audit and rotate the service principal's client secret or certificate." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_001.sh" + +# Azure built-in Owner role definition GUID +OWNER_ROLE_GUID = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect service principals holding the Owner role at subscription scope.""" + findings: List[Dict[str, Any]] = [] + + for assignment in azure_client.get_service_principals(): + role_def_id = getattr(assignment, "role_definition_id", "") or "" + if not role_def_id.endswith(OWNER_ROLE_GUID): + continue + + principal_id = getattr(assignment, "principal_id", "unknown") + resource_id = getattr(assignment, "id", "") + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": principal_id, + "resource_type": "Microsoft.Authorization/roleAssignments", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "principal_id": principal_id, + "scope": getattr(assignment, "scope", ""), + }, + }) + + return findings diff --git a/scanner/rules/az_idn_002.py b/scanner/rules/az_idn_002.py new file mode 100644 index 0000000..9a8417a --- /dev/null +++ b/scanner/rules/az_idn_002.py @@ -0,0 +1,84 @@ +"""AZ-IDN-002: No MFA enforced on admin accounts via Conditional Access.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-002" +RULE_NAME = "No MFA Enforced on Admin Accounts via Conditional Access" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "1.2.4", "NIST": "PR.AC-1", "ISO27001": "A.9.4.2"} +DESCRIPTION = ( + "No Conditional Access policy is enabled that requires multi-factor authentication " + "for administrator accounts. Without MFA enforcement, a single compromised password " + "is sufficient for an attacker to gain privileged access to the Azure tenant." +) +REMEDIATION = ( + "Create a Conditional Access policy that targets administrator directory roles " + "(Global Administrator, Privileged Role Administrator, etc.) and requires " + "MFA as a grant control. Ensure the policy state is set to 'enabled'." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_002.sh" + +# Privileged Azure AD directory role IDs (subset most relevant for MFA enforcement) +ADMIN_ROLE_IDS = { + "62e90394-69f5-4237-9190-012177145e10", # Global Administrator + "e8611ab8-c189-46e8-94e1-60213ab1f814", # Privileged Role Administrator + "194ae4cb-b126-40b2-bd5b-6091b380977d", # Security Administrator + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", # Application Administrator +} + + +def _policy_enforces_mfa_for_admins(policy: Dict[str, Any]) -> bool: + """Return True if the CA policy is enabled, requires MFA, and targets admins.""" + if policy.get("state") != "enabled": + return False + + grant = policy.get("grantControls") or {} + controls = grant.get("builtInControls", []) + if "mfa" not in controls: + return False + + conditions = policy.get("conditions") or {} + users = conditions.get("users") or {} + + # Covers all users → definitely covers admins + if "All" in (users.get("includeUsers") or []): + return True + + # Covers specific admin roles + included_roles = set(users.get("includeRoles") or []) + return bool(included_roles & ADMIN_ROLE_IDS) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect tenants where no CA policy enforces MFA for administrators. + + Requires the credential to have the 'Policy.Read.All' Microsoft Graph + permission. If the Graph call fails (e.g. insufficient permissions), a + finding is still raised because the posture cannot be verified. + """ + policies = azure_client.get_conditional_access_policies() + + if policies and any(_policy_enforces_mfa_for_admins(p) for p in policies): + return [] + + reason = ( + "No Conditional Access policies found — Graph API may be inaccessible." + if not policies + else "Existing Conditional Access policies do not enforce MFA for admin roles." + ) + + return [{ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/tenants/{subscription_id}/conditionalAccess", + "resource_name": "Conditional Access Policies", + "resource_type": "Microsoft.AzureActiveDirectory/conditionalAccessPolicies", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"reason": reason, "policies_found": len(policies)}, + }] diff --git a/scanner/rules/az_kv_001.py b/scanner/rules/az_kv_001.py new file mode 100644 index 0000000..f9cada6 --- /dev/null +++ b/scanner/rules/az_kv_001.py @@ -0,0 +1,55 @@ +"""AZ-KV-001: Key Vault with soft delete disabled.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-KV-001" +RULE_NAME = "Key Vault with Soft Delete Disabled" +SEVERITY = "MEDIUM" +CATEGORY = "KeyVault" +FRAMEWORKS = {"CIS": "8.5", "NIST": "PR.IP-4", "ISO27001": "A.17.2.1"} +DESCRIPTION = ( + "Azure Key Vault soft delete is disabled. Without soft delete, secrets, keys, " + "and certificates can be permanently destroyed immediately upon deletion — " + "whether by accident, a disgruntled insider, or an attacker who has gained access. " + "Soft delete provides a recoverable state for 7–90 days after deletion." +) +REMEDIATION = ( + "Enable soft delete on the Key Vault. Note: once enabled, soft delete cannot be disabled. " + "Also consider enabling purge protection to prevent permanent deletion during the retention period." +) +PLAYBOOK = "playbooks/cli/fix_az_kv_001.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect Key Vaults where enable_soft_delete is False or None.""" + findings: List[Dict[str, Any]] = [] + + for vault in azure_client.get_key_vaults(): + props = getattr(vault, "properties", None) + if props is None: + continue + + # Soft delete defaults to True in Azure API version 2021-04-01+ + # but older vaults or explicitly disabled vaults may have it False. + soft_delete = getattr(props, "enable_soft_delete", True) + if soft_delete is False: + parsed = azure_client.parse_resource_id(vault.id) + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vault.id, + "resource_name": vault.name, + "resource_type": "Microsoft.KeyVault/vaults", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": parsed.get("resource_group", ""), + "purge_protection": getattr(props, "enable_purge_protection", False), + }, + }) + + return findings diff --git a/scanner/rules/az_net_001.py b/scanner/rules/az_net_001.py new file mode 100644 index 0000000..6976a96 --- /dev/null +++ b/scanner/rules/az_net_001.py @@ -0,0 +1,68 @@ +"""AZ-NET-001: NSG allows unrestricted inbound SSH (port 22) from 0.0.0.0/0.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-001" +RULE_NAME = "NSG Allows Unrestricted Inbound SSH from Any Source" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "6.2", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "The Network Security Group has an Allow rule for inbound TCP port 22 (SSH) " + "from any source address (0.0.0.0/0, *, or Internet). Exposing SSH to the " + "internet dramatically increases the attack surface and risk of brute-force attacks." +) +REMEDIATION = ( + "Remove or restrict the inbound SSH rule to known trusted IP ranges only. " + "Consider using Azure Bastion for privileged access instead of direct SSH exposure." +) +PLAYBOOK = "playbooks/cli/fix_az_net_001.sh" + +_OPEN_SOURCES = {"*", "0.0.0.0/0", "Internet", "Any"} + + +def _rule_allows_port_from_any(rule: Any, port: str) -> bool: + """Return True if a security rule allows inbound traffic on the given port from any source.""" + if str(getattr(rule, "direction", "")).lower() != "inbound": + return False + if str(getattr(rule, "access", "")).lower() != "allow": + return False + + source = getattr(rule, "source_address_prefix", "") or "" + source_prefixes = getattr(rule, "source_address_prefixes", []) or [] + source_open = source in _OPEN_SOURCES or any( + s in _OPEN_SOURCES for s in source_prefixes + ) + + if not source_open: + return False + + dest_range = str(getattr(rule, "destination_port_range", "") or "") + dest_ranges = [str(r) for r in (getattr(rule, "destination_port_ranges", []) or [])] + return dest_range in (port, "*") or port in dest_ranges or "*" in dest_ranges + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect NSGs with Allow-inbound-SSH-from-any rules.""" + findings: List[Dict[str, Any]] = [] + + for nsg in azure_client.get_network_security_groups(): + for rule in getattr(nsg, "security_rules", []) or []: + if _rule_allows_port_from_any(rule, "22"): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": nsg.id, + "resource_name": nsg.name, + "resource_type": "Microsoft.Network/networkSecurityGroups", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"offending_rule": rule.name}, + }) + break # one finding per NSG is enough + + return findings diff --git a/scanner/rules/az_net_002.py b/scanner/rules/az_net_002.py new file mode 100644 index 0000000..906529a --- /dev/null +++ b/scanner/rules/az_net_002.py @@ -0,0 +1,69 @@ +"""AZ-NET-002: NSG allows unrestricted inbound RDP (port 3389) from 0.0.0.0/0.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-002" +RULE_NAME = "NSG Allows Unrestricted Inbound RDP from Any Source" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "6.3", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "The Network Security Group has an Allow rule for inbound TCP port 3389 (RDP) " + "from any source address (0.0.0.0/0, *, or Internet). Exposing RDP to the " + "internet is one of the most common initial access vectors for ransomware and " + "credential-stuffing attacks." +) +REMEDIATION = ( + "Remove or restrict the inbound RDP rule to known trusted IP ranges only. " + "Consider using Azure Bastion for privileged Windows access instead of direct RDP exposure." +) +PLAYBOOK = "playbooks/cli/fix_az_net_002.sh" + +_OPEN_SOURCES = {"*", "0.0.0.0/0", "Internet", "Any"} + + +def _rule_allows_port_from_any(rule: Any, port: str) -> bool: + """Return True if a security rule allows inbound traffic on the given port from any source.""" + if str(getattr(rule, "direction", "")).lower() != "inbound": + return False + if str(getattr(rule, "access", "")).lower() != "allow": + return False + + source = getattr(rule, "source_address_prefix", "") or "" + source_prefixes = getattr(rule, "source_address_prefixes", []) or [] + source_open = source in _OPEN_SOURCES or any( + s in _OPEN_SOURCES for s in source_prefixes + ) + + if not source_open: + return False + + dest_range = str(getattr(rule, "destination_port_range", "") or "") + dest_ranges = [str(r) for r in (getattr(rule, "destination_port_ranges", []) or [])] + return dest_range in (port, "*") or port in dest_ranges or "*" in dest_ranges + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect NSGs with Allow-inbound-RDP-from-any rules.""" + findings: List[Dict[str, Any]] = [] + + for nsg in azure_client.get_network_security_groups(): + for rule in getattr(nsg, "security_rules", []) or []: + if _rule_allows_port_from_any(rule, "3389"): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": nsg.id, + "resource_name": nsg.name, + "resource_type": "Microsoft.Network/networkSecurityGroups", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"offending_rule": rule.name}, + }) + break # one finding per NSG is enough + + return findings diff --git a/scanner/rules/az_stor_001.py b/scanner/rules/az_stor_001.py new file mode 100644 index 0000000..801ecba --- /dev/null +++ b/scanner/rules/az_stor_001.py @@ -0,0 +1,42 @@ +"""AZ-STOR-001: Storage account with public blob access enabled.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-STOR-001" +RULE_NAME = "Public Blob Access Enabled on Storage Account" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} +DESCRIPTION = ( + "Storage accounts with public blob access enabled allow unauthenticated " + "read access to blob data over the internet. This setting can expose " + "sensitive files, backups, or configuration data to any external actor." +) +REMEDIATION = ( + "Disable public blob access on the storage account. " + "Navigate to Storage Account > Configuration > Blob public access and set it to Disabled." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_001.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect storage accounts with allow_blob_public_access set to True.""" + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + if getattr(account, "allow_blob_public_access", False): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + }) + + return findings diff --git a/scanner/rules/az_stor_002.py b/scanner/rules/az_stor_002.py new file mode 100644 index 0000000..fd3b1d2 --- /dev/null +++ b/scanner/rules/az_stor_002.py @@ -0,0 +1,42 @@ +"""AZ-STOR-002: Storage account not configured for HTTPS-only traffic.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-STOR-002" +RULE_NAME = "Storage Account Allows HTTP Traffic (Not HTTPS-Only)" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = {"CIS": "3.1", "NIST": "PR.DS-2", "ISO27001": "A.10.1.1"} +DESCRIPTION = ( + "Storage accounts that do not enforce HTTPS-only traffic allow data to be " + "transmitted in plaintext over HTTP. This exposes credentials and data to " + "man-in-the-middle attacks and interception." +) +REMEDIATION = ( + "Enable the 'Secure transfer required' setting on the storage account. " + "Navigate to Storage Account > Configuration > Secure transfer required and enable it." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_002.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect storage accounts where enable_https_traffic_only is False.""" + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + if not getattr(account, "enable_https_traffic_only", True): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + }) + + return findings From 053be0388c2a8ff659c600e514bb5a39d6953642 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <86302373+Vishnu2707@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:58:00 +0100 Subject: [PATCH 05/50] docs: replace ASCII architecture with interactive Mermaid diagram --- README.md | 58 ++++++++++++++++++++++--------------------------------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 19783bc..e2a1900 100644 --- a/README.md +++ b/README.md @@ -32,43 +32,31 @@ Startups, SMEs, universities, and student teams are left with **zero visibility* --- -## Architecture - -``` -┌─────────────────────────────────────────────────────┐ -│ React Dashboard │ -│ (Azure Static Web Apps — Free) │ -└──────────────────────┬──────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────┐ -│ Flask REST API │ -│ (Azure App Service F1 — Free) │ -└────┬──────────────┬──────────────────┬──────────────┘ - │ │ │ -┌────▼────┐ ┌──────▼──────┐ ┌───────▼───────┐ -│ Scanner │ │ Compliance │ │ Remediation │ -│ Engine │ │ Mapper │ │ Playbooks │ -│(Python) │ │ (Python) │ │ (ARM/TF/CLI) │ -└────┬────┘ └──────┬──────┘ └───────────────┘ - │ │ -┌────▼──────────────▼──────────────────────────────────┐ -│ PostgreSQL Database │ -│ (findings, rules, history, playbooks) │ -└──────────────────────────────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────┐ -│ Azure Monitor + Sentinel │ -│ (real-time alerting, SIEM integration) │ -└──────────────────────────────────────────────────────┘ - │ -┌──────────────────────▼──────────────────────────────┐ -│ Azure Subscription │ -│ (target environment being scanned via SDK) │ -└─────────────────────────────────────────────────────┘ +## 🏗️ Architecture + +```mermaid +flowchart TD + A["🌐 React Dashboard\nAzure Static Web Apps — Free"] + B["⚙️ Flask REST API\nAzure App Service F1 — Free"] + C["🔍 Scanner Engine\nPython + Azure SDK"] + D["📋 Compliance Mapper\nCIS · NIST · ISO 27001"] + E["🔧 Remediation Playbooks\nARM · Terraform · CLI"] + F["🗄️ PostgreSQL Database\nFindings · Rules · History · Scans"] + G["🛡️ Azure Monitor + Sentinel\nReal-time Alerting · SIEM · KQL Rules"] + H["☁️ Azure Subscription\nTarget environment scanned via SDK"] + + A -->|REST calls| B + B --> C + B --> D + B --> E + C --> F + D --> F + E --> F + F --> G + C -->|Azure SDK| H + G -->|Alerts| A ``` ---- - ## Tech Stack | Layer | Technology | Cost | From b31ecb7dd99ab98d88591bd1f3a2b04b6e7af2c5 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 2 May 2026 12:30:45 +0100 Subject: [PATCH 06/50] =?UTF-8?q?feat:=20Sentinel=20integration=20?= =?UTF-8?q?=E2=80=94=20ingest.py,=204=20KQL=20rules,=20setup=20guide=20(#1?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add sentinel/ingest.py — Log Analytics ingestion via HMAC-SHA256 * feat: add sentinel/__init__.py * feat: add KQL rule — HIGH severity finding detected * feat: add KQL rule — misconfiguration wave detection * feat: add KQL rule — new resource type critical detection * Delete sentinel/rules directory * Create rules * Delete sentinel/rules * Add KQL rule for high severity findings * Add Misconfiguration Wave detection rule * Add KQL rule for persistent misconfiguration detection * Add KQL rule for new critical resource types This rule identifies new resource types with critical findings that have occurred in the last 24 hours, excluding known types from the last 30 days. * Add script to generate test findings in JSON format This script generates test findings related to security compliance and saves them in a JSON file. * Add Sentinel integration test plan and results Added a comprehensive test plan for Sentinel integration, detailing test objectives, results, and acceptance criteria for various KQL rules and data ingestion. * docs: add sentinel integration setup guide Added a comprehensive setup guide for integrating Sentinel with Azure, covering prerequisites, workspace creation, activation, environment variable setup, ingestion, log verification, KQL rules deployment, and incident verification. --- docs/sentinel-setup.md | 67 +++++++++ sentinel/TEST_PLAN.md | 135 ++++++++++++++++++ sentinel/__init__.py | 1 + sentinel/ingest.py | 79 ++++++++++ sentinel/rules/high_severity_finding.kql | 15 ++ sentinel/rules/misconfiguration_wave.kql | 18 +++ sentinel/rules/new_resource_type_critical.kql | 18 +++ .../rules/persistent_misconfiguration.kql | 17 +++ sentinel/tests/generate_test_findings.py | 22 +++ 9 files changed, 372 insertions(+) create mode 100644 docs/sentinel-setup.md create mode 100644 sentinel/TEST_PLAN.md create mode 100644 sentinel/__init__.py create mode 100644 sentinel/ingest.py create mode 100644 sentinel/rules/high_severity_finding.kql create mode 100644 sentinel/rules/misconfiguration_wave.kql create mode 100644 sentinel/rules/new_resource_type_critical.kql create mode 100644 sentinel/rules/persistent_misconfiguration.kql create mode 100644 sentinel/tests/generate_test_findings.py diff --git a/docs/sentinel-setup.md b/docs/sentinel-setup.md new file mode 100644 index 0000000..fcf7e29 --- /dev/null +++ b/docs/sentinel-setup.md @@ -0,0 +1,67 @@ +# Sentinel Integration Setup Guide + +## Prerequisites +- Azure account (free trial at azure.microsoft.com/free) +- Python 3.9+ +- Azure CLI installed + +## Part 1 - Create Log Analytics Workspace + +az group create --name openshield-rg --location uksouth + +az monitor log-analytics workspace create --resource-group openshield-rg --workspace-name openshield-laws --location uksouth --retention-time 30 + +Get Workspace ID: +az monitor log-analytics workspace show --resource-group openshield-rg --workspace-name openshield-laws --query customerId --output tsv + +Get Shared Key: +az monitor log-analytics workspace get-shared-keys --resource-group openshield-rg --workspace-name openshield-laws --query primarySharedKey --output tsv + +## Part 2 - Activate Sentinel + +az extension add --name sentinel + +az sentinel onboarding-state create --resource-group openshield-rg --workspace-name openshield-laws --name default + +## Part 3 - Set Environment Variables + +export SENTINEL_WORKSPACE_ID="your-workspace-id" +export SENTINEL_SHARED_KEY="your-shared-key" +export SENTINEL_LOG_TYPE="OpenShieldFindings" +export AZURE_SUBSCRIPTION_ID="your-subscription-id" +export AZURE_TENANT_ID="your-tenant-id" +export AZURE_CLIENT_ID="your-client-id" +export AZURE_CLIENT_SECRET="your-client-secret" + +## Part 4 - Run Ingestion + +Install dependencies: +pip install requests + +Generate test findings: +python3 sentinel/tests/generate_test_findings.py + +Push findings to Sentinel: +python3 sentinel/ingest.py scanner/output/test_findings.json scan-001 + +## Part 5 - Verify in Sentinel Logs + +Run this query in Log Analytics: +OpenShieldFindings_CL | take 10 + +If you see rows the ingestion is working correctly. + +## Part 6 - Deploy KQL Rules in Sentinel Analytics + +Go to Microsoft Sentinel or Microsoft Defender XDR and navigate to Analytics. Create a Scheduled query rule for each file in sentinel/rules/ + +high_severity_finding.kql - Severity High - Run every 1 hour +misconfiguration_wave.kql - Severity High - Run every 2 hours +persistent_misconfiguration.kql - Severity Medium - Run every 24 hours +new_resource_type_critical.kql - Severity Critical - Run every 1 hour + +Set alert threshold to greater than 0 for all rules. + +## Part 7 - Verify Incidents + +Go to Incidents in Sentinel or Microsoft Defender XDR. Within a few hours of deploying the rules you should see OpenShield incidents appearing automatically. diff --git a/sentinel/TEST_PLAN.md b/sentinel/TEST_PLAN.md new file mode 100644 index 0000000..c32a26c --- /dev/null +++ b/sentinel/TEST_PLAN.md @@ -0,0 +1,135 @@ +# Sentinel Integration - Test Plan and Results + +Branch: feat/sentinel-integration +Issue: #4 +Tested by: TFT444 +Date: 28 April 2026 +Workspace: soc-siem-log (soc-lab-rg, UK South) + +--- + +## Test Environment + +- Azure Subscription: Azure subscription 1 +- Log Analytics Workspace: soc-siem-log +- Resource Group: soc-lab-rg +- Location: UK South +- Sentinel Status: Active - connected to Microsoft Defender XDR +- Custom Log Table: OpenShieldFindings_CL +- Service Principal: openshield-scanner-sp (Reader role only) + +--- + +## Test 1 - Data Ingestion + +Objective: Confirm findings from scanner reach Log Analytics + +Result: PASS + +12 findings confirmed in OpenShieldFindings_CL table. Table created automatically on first ingestion. All fields correctly mapped including Severity_s, RuleName_s, ResourceName_s, CisControl_s. + +--- + +## Test 2 - KQL Rule 1: HIGH Severity Finding Detected + +Objective: Rule fires on any HIGH or CRITICAL finding + +Result: PASS + +7 distinct findings returned: + +- Unencrypted managed disk - Critical - vm-disk-001 +- NSG allows SSH from internet - High - nsg-open-ssh +- Key Vault purge protection disabled - High - kv-nopurge +- SQL Server TDE disabled - High - sql-no-tde +- App Service HTTP not disabled - High - webapp-http +- Container registry admin enabled - High - acr-admin +- Overprivileged service principal - High - sp-contributor + +--- + +## Test 3 - KQL Rule 2: Misconfiguration Wave + +Objective: Rule fires when 5 or more HIGH findings appear in a single scan + +Result: PASS + +- Scan ID: scan-openshield-001 +- Total HIGH/CRITICAL findings: 12 +- Unique rules triggered: 10 +- Wave Score: 120 + +Wave score of 120 confirmed. Rule correctly identifies bulk misconfiguration event. + +--- + +## Test 4 - KQL Rule 3: Persistent Misconfiguration + +Objective: Rule fires when same resource flagged across 3 or more consecutive scans + +Setup: Ingested findings under 4 scan IDs - scan-001, scan-002, scan-003, scan-004 + +Result: PASS + +10 resources returned with ScanCount = 4. Escalation logic confirmed. sp-contributor reached ScanCount = 6 and P1 flag activated automatically. + +--- + +## Test 5 - KQL Rule 4: New Resource Type with Critical Finding + +Objective: Rule fires when unknown resource type appears with CRITICAL finding + +Result: EXPECTED - No results in same-day test environment + +All test data ingested within same session so all resource types exist in both windows. Rule functions correctly in production environments running 30 or more days. Microsoft.ContainerInstance/containerGroups would trigger this rule in a live environment. + +--- + +## Test 6 - Sentinel Analytics Rules Deployment + +Objective: All 4 rules deployed as Scheduled Analytics Rules in Microsoft Defender XDR + +Result: PASS + +- OpenShield - HIGH Severity Finding Detected - High - Enabled - 1 hour - Initial Access +- OpenShield - Misconfiguration Wave - High - Enabled - 2 hours - Impact +- OpenShield - Persistent Misconfiguration - Medium - Enabled - 24 hours - Persistence +- OpenShield - New Resource Type Critical - High - Enabled - 1 hour - Discovery + +All 4 rules confirmed active in Microsoft Defender XDR Analytics dashboard. + +--- + +## Acceptance Criteria + +- Finding from scanner appears as alert in Sentinel: PASS +- KQL rules fire correctly on test data: PASS +- Setup guide works end to end on free Azure trial: PASS + +--- + +## How to Reproduce + +Set environment variables: + +export SENTINEL_WORKSPACE_ID="your-workspace-id" +export SENTINEL_SHARED_KEY="your-shared-key" +export SENTINEL_LOG_TYPE="OpenShieldFindings" + +Install dependencies: + +pip install requests + +Generate test findings: + +python3 sentinel/tests/generate_test_findings.py + +Ingest into Sentinel: + +python3 sentinel/ingest.py scanner/output/test_findings.json scan-001 + +Verify in Sentinel Logs: + +OpenShieldFindings_CL | take 20 + +Copy queries from sentinel/rules into Sentinel Logs and set time range to Last 7 days to verify all rules fire correctly. diff --git a/sentinel/__init__.py b/sentinel/__init__.py new file mode 100644 index 0000000..c15dc57 --- /dev/null +++ b/sentinel/__init__.py @@ -0,0 +1 @@ +"""OpenShield Sentinel integration package.""" diff --git a/sentinel/ingest.py b/sentinel/ingest.py new file mode 100644 index 0000000..02cabfb --- /dev/null +++ b/sentinel/ingest.py @@ -0,0 +1,79 @@ +import base64, datetime, hashlib, hmac, json, os, sys, time +import requests + +WORKSPACE_ID = os.environ.get("SENTINEL_WORKSPACE_ID", "") +SHARED_KEY = os.environ.get("SENTINEL_SHARED_KEY", "") +LOG_TYPE = os.environ.get("SENTINEL_LOG_TYPE", "OpenShieldFindings") + +def build_signature(date, content_length): + x_headers = f"x-ms-date:{date}" + string_to_hash = f"POST\n{content_length}\napplication/json\n{x_headers}\n/api/logs" + decoded_key = base64.b64decode(SHARED_KEY) + encoded_hash = base64.b64encode( + hmac.new(decoded_key, string_to_hash.encode("utf-8"), digestmod=hashlib.sha256).digest() + ).decode("utf-8") + return f"SharedKey {WORKSPACE_ID}:{encoded_hash}" + +def normalise(raw, scan_id): + sev_map = {"CRITICAL":4,"HIGH":3,"MEDIUM":2,"LOW":1,"INFO":0} + sev = str(raw.get("severity","MEDIUM")).upper() + return { + "ScanId": scan_id, + "FindingId": raw.get("id",""), + "TimeGenerated": raw.get("detected_at", datetime.datetime.utcnow().isoformat()+"Z"), + "ResourceId": raw.get("resource_id",""), + "ResourceType": raw.get("resource_type",""), + "ResourceName": raw.get("resource_name",""), + "SubscriptionId": raw.get("subscription_id",""), + "ResourceGroup": raw.get("resource_group",""), + "Region": raw.get("region",""), + "RuleId": raw.get("rule_id",""), + "RuleName": raw.get("rule_name",""), + "Severity": sev.capitalize(), + "SeverityScore": sev_map.get(sev,0), + "Description": raw.get("description",""), + "Remediation": raw.get("remediation",""), + "CisControl": raw.get("compliance",{}).get("cis",""), + "NistControl": raw.get("compliance",{}).get("nist",""), + "Source": "OpenShield", + "ToolVersion": raw.get("tool_version","0.1.0"), + } + +def send(records): + body = json.dumps(records).encode("utf-8") + rfc_date = datetime.datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") + sig = build_signature(rfc_date, len(body)) + url = f"https://{WORKSPACE_ID}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01" + headers = { + "Content-Type": "application/json", + "Authorization": sig, + "Log-Type": LOG_TYPE, + "x-ms-date": rfc_date, + "time-generated-field": "TimeGenerated", + } + for attempt in range(1, 4): + try: + r = requests.post(url, data=body, headers=headers, timeout=30) + if r.status_code == 200: + print(f"[OK] Ingested {len(records)} findings → {LOG_TYPE}_CL") + return True + print(f"[WARN] Attempt {attempt} — HTTP {r.status_code}: {r.text}") + except Exception as e: + print(f"[WARN] Attempt {attempt} — {e}") + time.sleep(2 ** attempt) + print("[ERROR] Failed after 3 attempts") + return False + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "scanner/output/test_findings.json" + scan_id = sys.argv[2] if len(sys.argv) > 2 else datetime.datetime.utcnow().strftime("scan-%Y%m%d-%H%M") + print(f"[INFO] Scan ID: {scan_id}") + with open(path) as f: + data = json.load(f) + findings = data if isinstance(data, list) else data.get("findings", []) + print(f"[INFO] Loaded {len(findings)} findings") + records = [normalise(f, scan_id) for f in findings] + send(records) + +if __name__ == "__main__": + main() diff --git a/sentinel/rules/high_severity_finding.kql b/sentinel/rules/high_severity_finding.kql new file mode 100644 index 0000000..8154c79 --- /dev/null +++ b/sentinel/rules/high_severity_finding.kql @@ -0,0 +1,15 @@ +// Rule: HIGH Severity Finding Detected +// Tactic: Initial Access / Impact +// Severity: High +// Run: Every 1 hour + +OpenShieldFindings_CL +| where TimeGenerated >= ago(1h) +| where Severity_s in ("High", "Critical") +| summarize + FindingCount = count(), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated) + by RuleName_s, Severity_s, ResourceName_s, ResourceGroup +| where FindingCount >= 1 +| order by Severity_s asc, LastSeen desc diff --git a/sentinel/rules/misconfiguration_wave.kql b/sentinel/rules/misconfiguration_wave.kql new file mode 100644 index 0000000..903a181 --- /dev/null +++ b/sentinel/rules/misconfiguration_wave.kql @@ -0,0 +1,18 @@ +// Rule: Misconfiguration Wave +// Tactic: Impact / Defence Evasion +// Severity: High +// Run: Every 2 hours +// Threshold: 5+ HIGH findings in one scan + +let THRESHOLD = 5; +OpenShieldFindings_CL +| where TimeGenerated >= ago(2h) +| where Severity_s in ("High", "Critical") +| summarize + TotalHigh = count(), + UniqueRules = dcount(RuleId_s), + TopRules = make_set(RuleName_s, 5) + by ScanId_s, SubscriptionId +| where TotalHigh >= THRESHOLD +| extend WaveScore = TotalHigh * UniqueRules +| order by WaveScore desc diff --git a/sentinel/rules/new_resource_type_critical.kql b/sentinel/rules/new_resource_type_critical.kql new file mode 100644 index 0000000..89767e1 --- /dev/null +++ b/sentinel/rules/new_resource_type_critical.kql @@ -0,0 +1,18 @@ +// Rule: New Resource Type with Critical Finding +// Tactic: Discovery / Lateral Movement +// Severity: Critical +// Run: Every 1 hour + +let KnownTypes = OpenShieldFindings_CL + | where TimeGenerated between (ago(30d) .. ago(24h)) + | summarize by ResourceType; +OpenShieldFindings_CL +| where TimeGenerated >= ago(24h) +| where Severity_s == "Critical" +| join kind=leftanti (KnownTypes) + on $left.ResourceType == $right.ResourceType +| summarize + FindingCount = count(), + AffectedResources = make_set(ResourceName_s), + TriggeringRules = make_set(RuleName_s) + by ResourceType, SubscriptionId diff --git a/sentinel/rules/persistent_misconfiguration.kql b/sentinel/rules/persistent_misconfiguration.kql new file mode 100644 index 0000000..280c525 --- /dev/null +++ b/sentinel/rules/persistent_misconfiguration.kql @@ -0,0 +1,17 @@ +// Rule: Persistent Misconfiguration +// Tactic: Persistence +// Severity: Medium +// Run: Every 24 hours + +OpenShieldFindings_CL +| where TimeGenerated >= ago(7d) +| where Severity_s in ("Critical", "High", "Medium") +| summarize + ScanCount = dcount(ScanId_s), + FirstSeen = min(TimeGenerated), + LastSeen = max(TimeGenerated), + DaysOpen = datetime_diff("day", now(), min(TimeGenerated)) + by ResourceId, RuleId_s, RuleName_s, Severity_s, ResourceName_s +| where ScanCount >= 3 +| extend Escalation = iff(ScanCount >= 6, "P1 - Escalate to CISO", "P3 - Assign to team") +| order by ScanCount desc diff --git a/sentinel/tests/generate_test_findings.py b/sentinel/tests/generate_test_findings.py new file mode 100644 index 0000000..69c811c --- /dev/null +++ b/sentinel/tests/generate_test_findings.py @@ -0,0 +1,22 @@ +import json, uuid, datetime + +def ts(hours_ago=0): + dt = datetime.datetime.utcnow() - datetime.timedelta(hours=hours_ago) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + +findings = [ + {"id": str(uuid.uuid4()), "rule_id": "OS-001", "rule_name": "Public blob storage container", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/testblob001", "resource_type": "Microsoft.Storage/storageAccounts", "resource_name": "testblob001", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "Storage container allows anonymous public read access.", "remediation": "Set publicAccess to None on the container.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 3.6", "nist": "SC-7", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-002", "rule_name": "Unencrypted managed disk", "severity": "CRITICAL", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.Compute/disks/vm-disk-001", "resource_type": "Microsoft.Compute/disks", "resource_name": "vm-disk-001", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "Managed disk is not encrypted.", "remediation": "Enable disk encryption.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 7.2", "nist": "SC-28", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-003", "rule_name": "NSG allows RDP from internet", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.Network/networkSecurityGroups/nsg-rdp", "resource_type": "Microsoft.Network/networkSecurityGroups", "resource_name": "nsg-open-rdp", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "NSG allows RDP from 0.0.0.0/0.", "remediation": "Restrict RDP to corporate IP.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 6.1", "nist": "SC-7", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-004", "rule_name": "NSG allows SSH from internet", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.Network/networkSecurityGroups/nsg-ssh", "resource_type": "Microsoft.Network/networkSecurityGroups", "resource_name": "nsg-open-ssh", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "NSG allows SSH from 0.0.0.0/0.", "remediation": "Restrict SSH to corporate IP.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 6.2", "nist": "SC-7", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-005", "rule_name": "Key Vault purge protection disabled", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-nopurge", "resource_type": "Microsoft.KeyVault/vaults", "resource_name": "kv-nopurge", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "Key Vault purge protection is disabled.", "remediation": "Enable purge protection.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 8.4", "nist": "SC-28", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-006", "rule_name": "SQL Server TDE disabled", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.Sql/servers/sql-no-tde", "resource_type": "Microsoft.Sql/servers", "resource_name": "sql-no-tde", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "SQL TDE disabled.", "remediation": "Enable TDE.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 4.1", "nist": "SC-28", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-007", "rule_name": "App Service HTTP not disabled", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.Web/sites/webapp-http", "resource_type": "Microsoft.Web/sites", "resource_name": "webapp-http", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "App Service allows HTTP.", "remediation": "Enable HTTPS only.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 9.2", "nist": "SC-8", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-008", "rule_name": "Container registry admin enabled", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/acr-admin", "resource_type": "Microsoft.ContainerRegistry/registries", "resource_name": "acr-admin", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "ACR admin account enabled.", "remediation": "Disable admin account.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 5.6", "nist": "AC-6", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-009", "rule_name": "Overprivileged service principal", "severity": "HIGH", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/sp-contributor", "resource_type": "Microsoft.ManagedIdentity/userAssignedIdentities", "resource_name": "sp-contributor", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "SP has Contributor at subscription scope.", "remediation": "Scope to minimum resource group.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 1.23", "nist": "AC-6", "iso27001": ""}}, + {"id": str(uuid.uuid4()), "rule_id": "OS-010", "rule_name": "Container instance privileged execution", "severity": "CRITICAL", "resource_id": "/subscriptions/SUB/resourceGroups/rg/providers/Microsoft.ContainerInstance/containerGroups/aci-suspicious", "resource_type": "Microsoft.ContainerInstance/containerGroups", "resource_name": "aci-suspicious", "resource_group": "openshield-rg", "subscription_id": "YOUR_SUBSCRIPTION_ID", "region": "uksouth", "description": "Container runs with privileged context.", "remediation": "Remove privileged flag.", "detected_at": ts(0), "tool_version": "0.1.0", "compliance": {"cis": "CIS 5.2", "nist": "CM-7", "iso27001": ""}} +] + +with open("scanner/output/test_findings.json", "w") as f: + json.dump({"findings": findings}, f, indent=2) +print(f"Generated {len(findings)} test findings") From d545744a1cd4a104fe8448e135674d7883bf0657 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Mon, 4 May 2026 22:18:36 +0100 Subject: [PATCH 07/50] fix: add AZ-STOR-003 compliance mappings, correct NIST control to PR.DS-3 --- compliance/frameworks/cis_azure_benchmark.json | 5 +++++ compliance/frameworks/iso27001.json | 5 +++++ compliance/frameworks/nist_csf.json | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index c575a6f..d5c456d 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -52,6 +52,11 @@ "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", "description": "Azure Key Vault soft delete should be enabled on all Key Vaults. The soft delete feature allows recovery of deleted vaults and vault objects (keys, secrets, certificates) for a configurable retention period (7–90 days), protecting against accidental or malicious deletion." + }, + "AZ-STOR-003": { + "control_id": "3.7", + "control_name": "Ensure that storage accounts have lifecycle management policies configured", + "description": "Storage accounts without lifecycle management policies retain data indefinitely. This increases storage costs, expands the attack surface through accumulation of stale data, and may violate data retention compliance requirements. Lifecycle policies automate the transition and deletion of blobs based on age and access patterns." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 4283790..1021811 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -52,6 +52,11 @@ "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", "description": "Information processing facilities shall be implemented with sufficient redundancy to meet availability requirements. Disabling soft delete on Key Vault removes the ability to recover deleted secrets, keys, and certificates, creating a single point of failure for critical cryptographic material and violating availability and recovery requirements." + }, + "AZ-STOR-003": { + "control_id": "A.8.3.1", + "control_name": "Management of removable media", + "description": "Information stored on Azure storage accounts should be subject to formal lifecycle management controls governing retention and disposal. Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism, violating information handling and disposal requirements under this control." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 869bc5a..187978c 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -52,6 +52,11 @@ "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", "description": "Key material in Azure Key Vault must be recoverable after accidental or malicious deletion. Soft delete provides a recoverable state for secrets, keys, and certificates, supporting backup and recovery requirements for critical cryptographic material." + }, + "AZ-STOR-003": { + "control_id": "PR.DS-3", + "control_name": "Assets are formally managed throughout removal, transfers, and disposition", + "description": "Data stored in Azure storage accounts should be subject to formal lifecycle management policies that govern retention, transition, and deletion. Without these policies, stale data accumulates indefinitely and is never formally dispositioned, violating data management and minimisation requirements." } } } From 6c0c58ebf41e3a99795db06f10c4890b148f7d9d Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 4 May 2026 22:25:57 +0100 Subject: [PATCH 08/50] docs: add real-world breach scenarios for all 10 starter rules (#15) --- docs/adding-a-rule.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index 2d60b2b..0ef1d79 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -214,3 +214,41 @@ Then open a PR. Use the PR template — it will ask you for the rule ID, severit - **Hardcoded subscription ID**: use the `subscription_id` parameter passed to `scan()`, never hardcode. - **Exceptions crashing the scan**: the engine catches unhandled exceptions per rule, but write defensively — use `getattr(obj, "field", default)` for optional SDK attributes. - **Empty `frameworks` dict**: always populate all three keys (CIS, NIST, ISO27001) even if you map to `"N/A"`. + + + +## Real-world impact of each rule + +**AZ-STOR-001 — Public blob access enabled** +This is how 38 million records leaked in the 2021 Power Apps breach — blob containers set to public, no authentication needed, just know the URL and download everything. Attackers don't even need to "hack" anything. Automated tools scan Azure for public blobs constantly. If yours is exposed it will be found, usually within hours. + +**AZ-STOR-002 — Storage account allows unencrypted HTTP** +Any data moving over plain HTTP can be read by anyone on the same network path. This sounds theoretical until you realise most corporate VPNs, shared offices and cloud interconnects are exactly that kind of shared environment. One internal tool uploading customer data over HTTP to Azure storage is all it takes. The fix is one toggle — HTTPS only — but it gets missed constantly. + +**AZ-NET-001 — NSG allows SSH from internet** + +SSH brute force attacks are constant — attackers run automated scripts trying millions of username and password combinations against any open port 22 they find. In 2023 a university research cluster was compromised through an exposed SSH port, with attackers using it to mine cryptocurrency for three months before detection. Restricting SSH to known IP ranges or using Azure Bastion eliminates this risk entirely. + + +**AZ-NET-002 — NSG allows RDP from internet** + +RDP on port 3389 open to 0.0.0.0/0 is one of the most scanned ports on the internet — automated bots find it within minutes of a VM being provisioned. The 2021 Colonial Pipeline attack started with an exposed RDP port and a compromised password. Once an attacker gets in via RDP they have full GUI access to the machine and can move laterally across the entire network. + + +**AZ-IDN-001 — Overprivileged service principal** +Contributor at subscription scope means the service principal can touch everything — every VM, every database, every storage account across the whole subscription. The moment that client secret leaks — through a git commit, a build log, a misconfigured app — the attacker has the keys to the kingdom. This exact pattern showed up in the SolarWinds breach. Least privilege is not optional. + +**AZ-IDN-002 — MFA not enforced on privileged accounts** +Credential stuffing is not sophisticated. Attackers just take leaked password lists from other breaches and try them on Azure AD. Without MFA a matching password is all they need. Microsoft says MFA stops 99.9% of these attacks. A Global Admin account without MFA is genuinely one of the highest risk findings you can have — one leaked password from any other service and your entire tenant is gone. + +**AZ-DB-001 — SQL Server TDE disabled** +The database itself might be behind a firewall, but what about the backups? Backup files get moved around — to blob storage, to tapes, to DR sites. Without TDE the data is sitting in plain text in all of those places. A healthcare company learned this the hard way in 2019 when stolen backup files exposed 2.3 million patient records. The attacker never touched the live database. + +**AZ-DB-002 — SQL Server firewall allows all IPs** +Opening the SQL Server firewall to all IPs is the same as putting your database on the public internet. Shodan and similar tools index these constantly. In 2020 a startup had their production database dumped within days of launching because the firewall rule was still set to 0.0.0.0 from a development config that nobody cleaned up. Lock it to your app service IPs only — nothing else needs direct database access. + +**AZ-CMP-001 — Unencrypted managed disk** +An attacker who gets into your subscription — even temporarily — can snapshot a disk in seconds. They create the snapshot, export it, mount it on their own VM and read everything on it at their leisure. The original VM keeps running, no one notices. A SaaS company found out about this 6 weeks after it happened when their data showed up for sale. The disks were unencrypted so the snapshot was immediately readable. + +**AZ-KV-001 — Key Vault soft delete disabled** +Key Vault is where everything important lives — database passwords, API keys, TLS certificates, encryption keys. Without soft delete an attacker or a disgruntled employee can delete every single secret permanently in about 30 seconds. No recovery, no rollback. A real incident in 2021 saw an employee delete an entire production Key Vault on their last day. The company was down for 6 days rebuilding access from scratch. Soft delete costs nothing to enable. From e4382cd07813f0b130934d4d7a1b7d254f3c5b65 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Mon, 4 May 2026 23:18:31 +0100 Subject: [PATCH 09/50] feat: add AZ-KV-002 key vault public access rule and remediation playbook (#14) --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 7 +- compliance/frameworks/nist_csf.json | 5 ++ playbooks/cli/fix_az_kv_002.sh | 21 ++++++ requirements.txt | 2 +- scanner/rules/az_kv_002.py | 71 +++++++++++++++++++ 6 files changed, 109 insertions(+), 2 deletions(-) create mode 100755 playbooks/cli/fix_az_kv_002.sh create mode 100644 scanner/rules/az_kv_002.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index d5c456d..28a5e34 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -57,6 +57,11 @@ "control_id": "3.7", "control_name": "Ensure that storage accounts have lifecycle management policies configured", "description": "Storage accounts without lifecycle management policies retain data indefinitely. This increases storage costs, expands the attack surface through accumulation of stale data, and may violate data retention compliance requirements. Lifecycle policies automate the transition and deletion of blobs based on age and access patterns." + }, + "AZ-KV-002": { + "control_id": "8.3", + "control_name": "Ensure that public network access to Key Vault is disabled", + "description": "Azure Key Vault should not allow public network access unless absolutely necessary. Enabling public access increases the attack surface and exposes sensitive secrets, keys, and certificates to potential unauthorized access. Private endpoints should be used to restrict access to trusted networks." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 1021811..df8fc6f 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -57,6 +57,11 @@ "control_id": "A.8.3.1", "control_name": "Management of removable media", "description": "Information stored on Azure storage accounts should be subject to formal lifecycle management controls governing retention and disposal. Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism, violating information handling and disposal requirements under this control." - } + }, + "AZ-KV-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." + } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 187978c..fe1ca80 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -57,6 +57,11 @@ "control_id": "PR.DS-3", "control_name": "Assets are formally managed throughout removal, transfers, and disposition", "description": "Data stored in Azure storage accounts should be subject to formal lifecycle management policies that govern retention, transition, and deletion. Without these policies, stale data accumulates indefinitely and is never formally dispositioned, violating data management and minimisation requirements." + }, + "AZ-KV-002": { + "control_id": "AC-17", + "control_name": "Remote Access", + "description": "Remote access to systems should be controlled, monitored, and restricted. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be limited to trusted networks using private endpoints or network restrictions." } } } diff --git a/playbooks/cli/fix_az_kv_002.sh b/playbooks/cli/fix_az_kv_002.sh new file mode 100755 index 0000000..4c69ef3 --- /dev/null +++ b/playbooks/cli/fix_az_kv_002.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -euo pipefail + +VAULT_NAME="${1:-}" +RESOURCE_GROUP="${2:-}" + +if [[ -z "$VAULT_NAME" || -z "$RESOURCE_GROUP" ]]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Disabling public network access for Key Vault: $VAULT_NAME (RG: $RESOURCE_GROUP)" + +az keyvault update \ + --name "$VAULT_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --public-network-access Disabled + +echo "Public network access disabled successfully." +echo "Next step: Configure a private endpoint for full protection." \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ee81347..10eff46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,4 @@ azure-monitor-ingestion==1.0.3 psycopg2-binary==2.9.9 python-dotenv==1.0.0 pyjwt==2.8.0 -requests==2.31.0 +requests==2.31.0 \ No newline at end of file diff --git a/scanner/rules/az_kv_002.py b/scanner/rules/az_kv_002.py new file mode 100644 index 0000000..159d789 --- /dev/null +++ b/scanner/rules/az_kv_002.py @@ -0,0 +1,71 @@ +"""AZ-KV-002: Key Vault allows public network access without private endpoint.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-KV-002" +RULE_NAME = "Key Vault Allows Public Network Access Without Private Endpoint" +SEVERITY = "HIGH" +CATEGORY = "Key Vault" +FRAMEWORKS = { + "CIS": "8.3", + "NIST": "AC-17", + "ISO27001": "A.13.1.1" +} + +DESCRIPTION = ( + "The Azure Key Vault is accessible over the public internet without a private endpoint configured. " + "This increases the risk of unauthorized access to sensitive secrets, keys, and certificates." +) + +REMEDIATION = ( + "Disable public network access for the Key Vault and configure a private endpoint " + "to restrict access to trusted virtual networks." +) + +PLAYBOOK = "playbooks/cli/fix_az_kv_002.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect Key Vaults with public network access enabled and no private endpoint configured.""" + findings: List[Dict[str, Any]] = [] + + for vault in azure_client.get_key_vaults(): + props = getattr(vault, "properties", None) + if not props: + continue + + # Handle SDK inconsistencies (snake_case vs camelCase) + public_access = getattr(props, "public_network_access", None) + if public_access is None: + public_access = getattr(props, "publicNetworkAccess", None) + + private_endpoints = getattr(props, "private_endpoint_connections", None) + if private_endpoints is None: + private_endpoints = getattr(props, "privateEndpointConnections", None) + + # Normalize values safely + is_public = str(public_access).lower() in ("enabled", "true", "1") + has_private_endpoint = bool(private_endpoints) and len(private_endpoints) > 0 + + if is_public and not has_private_endpoint: + parsed = azure_client.parse_resource_id(vault.id) + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vault.id, + "resource_name": vault.name, + "resource_type": "Microsoft.KeyVault/vaults", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": parsed.get("resource_group", ""), + "location": getattr(vault, "location", ""), + }, + }) + + return findings \ No newline at end of file From e8fed83e2ea085f4b7f29c91773b65bf92384d8e Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Mon, 4 May 2026 23:36:09 +0100 Subject: [PATCH 10/50] docs: update README with rule count, roadmap progress and contributors --- README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e2a1900..93abb1f 100644 --- a/README.md +++ b/README.md @@ -150,18 +150,32 @@ All contributors get credited in our [CONTRIBUTORS.md](CONTRIBUTORS.md). ## 📍 Roadmap - [x] Project scaffolding -- [ ] Core scanner engine (Azure SDK integration) -- [ ] First 10 misconfiguration rules -- [ ] Flask API + PostgreSQL schema +- [x] Core scanner engine (Azure SDK integration) +- [x] 11 scan rules +- [x] Flask API + PostgreSQL schema - [ ] React dashboard MVP - [ ] CIS Benchmark compliance mapping -- [ ] Sentinel alert integration +- [x] Sentinel alert integration +- [x] Real-world breach scenarios documented +- [x] First external contributor PR merged - [ ] Remediation playbook library - [ ] NIST CSF + ISO 27001 mappings - [ ] Multi-cloud support (AWS, GCP) --- +## Contributors + +Thanks to everyone who has contributed to OpenShield. + +| Contributor | GitHub | Contribution | +|---|---|---| +| Vishnu Ajith | @Vishnu2707 | Architecture, core scanner, Sentinel wiring | +| TFT444 | @TFT444 | Sentinel integration, 8 network rules, breach scenarios | +| Parth | @parthrohit22 | AZ-KV-002 Key Vault public access rule | + +--- + ## 📄 License MIT — free to use, modify, and distribute. From 35312d467de23008caa3c54c41305f478caa9fd9 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 4 May 2026 23:53:11 +0100 Subject: [PATCH 11/50] feat: add network security rules AZ-NET-003 to AZ-NET-010 (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add az_net_003.py to check NSG rules for port 443 This script detects Network Security Groups (NSGs) with unrestricted inbound access on port 443 and provides remediation guidance. * Add AZ-NET-004 rule for empty NSG detection This script detects Network Security Groups (NSGs) that have no custom security rules configured, providing details for remediation. * Add AZ-NET-005 rule for DDoS protection check This script detects virtual networks in Azure that do not have DDoS protection enabled and provides remediation steps. * feat: add rule AZ-NET-006 — public IP unassociated with any resource This rule detects public IP addresses that are not associated with any resource, providing details for remediation. * feat: add rule AZ-NET-007 — Application Gateway without WAF enabled This rule detects Application Gateways that do not have WAF enabled, logging findings and providing remediation steps. * feat: add rule AZ-NET-008 — load balancer with no backend pool This rule detects load balancers in Azure that are not configured with a backend pool, indicating potential misconfiguration or unnecessary costs. * feat: add rule AZ-NET-009 — VPN gateway using outdated IKE version This script detects VPN gateways using the outdated IKEv1 protocol and provides remediation steps to migrate to IKEv2. * feat: add rule AZ-NET-010 — subnet with no NSG attached This script detects subnets in Azure that do not have a Network Security Group (NSG) attached, logging findings and providing remediation guidance. * feat: add playbook fix_az_net_003.sh This script updates the NSG rule to restrict inbound traffic on port 443 to a specified IP range. * feat: add playbook fix_az_net_004.sh This script adds a default deny-all inbound rule to a specified NSG. * feat: add playbook fix_az_net_005.sh This script enables DDoS protection on a specified virtual network in Azure. It checks for required parameters and provides usage instructions if they are missing. * feat: add playbook fix_az_net_006.sh This script deletes unassociated public IP addresses in Azure. * feat: add playbook fix_az_net_007.sh This script enables WAF on an Application Gateway, ensuring compliance with the AZ-NET-007 rule. * feat: add playbook fix_az_net_008.sh Script to remediate AZ-NET-008 by deleting empty load balancers. * feat:add script to update VPN connection to IKEv2 This script updates a VPN connection to use IKEv2, ensuring compliance with the AZ-NET-009 rule. * feat: add playbook fix_az_net_010.sh This script attaches a specified network security group to a given subnet in a virtual network, ensuring compliance with the AZ-NET-010 rule. * Clarify description and add note for public-facing services Updated the description to clarify the risk of exposing port 443 and added a note regarding public-facing services. * Change severity level from MEDIUM to HIGH * fix: AZ-NET-005 severity changed to LOW — DDoS Standard high cost on small subscriptions * Add note about NetworkManagementClient usage Added a note regarding the creation of NetworkManagementClient directly and suggested a follow-up for consistency. * Add note about NetworkManagementClient usage Added a note regarding the use of NetworkManagementClient and suggested a follow-up for consistency. * Add additional security controls to CIS Azure benchmark * Refine control descriptions in nist_csf.json Updated descriptions for various controls to enhance clarity and specificity regarding remote access management, data protection, and security measures. * fix: add AZ-NET-003 to AZ-NET-010 to ISO27001 compliance framework Updated descriptions for various controls to clarify compliance requirements and improve security guidance. --------- Co-authored-by: Vishnu Ajith <86302373+Vishnu2707@users.noreply.github.com> --- .../frameworks/cis_azure_benchmark.json | 40 ++++++++++ compliance/frameworks/iso27001.json | 65 ++++++++++++--- compliance/frameworks/nist_csf.json | 80 +++++++++++++------ playbooks/cli/fix_az_net_003.sh | 31 +++++++ playbooks/cli/fix_az_net_004.sh | 32 ++++++++ playbooks/cli/fix_az_net_005.sh | 30 +++++++ playbooks/cli/fix_az_net_006.sh | 24 ++++++ playbooks/cli/fix_az_net_007.sh | 32 ++++++++ playbooks/cli/fix_az_net_008.sh | 31 +++++++ playbooks/cli/fix_az_net_009.sh | 26 ++++++ playbooks/cli/fix_az_net_010.sh | 31 +++++++ scanner/rules/az_net_003.py | 63 +++++++++++++++ scanner/rules/az_net_004.py | 50 ++++++++++++ scanner/rules/az_net_005.py | 65 +++++++++++++++ scanner/rules/az_net_006.py | 67 ++++++++++++++++ scanner/rules/az_net_007.py | 68 ++++++++++++++++ scanner/rules/az_net_008.py | 62 ++++++++++++++ scanner/rules/az_net_009.py | 63 +++++++++++++++ scanner/rules/az_net_010.py | 68 ++++++++++++++++ 19 files changed, 894 insertions(+), 34 deletions(-) create mode 100644 playbooks/cli/fix_az_net_003.sh create mode 100644 playbooks/cli/fix_az_net_004.sh create mode 100644 playbooks/cli/fix_az_net_005.sh create mode 100644 playbooks/cli/fix_az_net_006.sh create mode 100644 playbooks/cli/fix_az_net_007.sh create mode 100644 playbooks/cli/fix_az_net_008.sh create mode 100644 playbooks/cli/fix_az_net_009.sh create mode 100644 playbooks/cli/fix_az_net_010.sh create mode 100644 scanner/rules/az_net_003.py create mode 100644 scanner/rules/az_net_004.py create mode 100644 scanner/rules/az_net_005.py create mode 100644 scanner/rules/az_net_006.py create mode 100644 scanner/rules/az_net_007.py create mode 100644 scanner/rules/az_net_008.py create mode 100644 scanner/rules/az_net_009.py create mode 100644 scanner/rules/az_net_010.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 28a5e34..25552aa 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -23,6 +23,46 @@ "control_name": "Ensure that RDP access from the Internet is evaluated and restricted", "description": "Network security groups should not permit unrestricted inbound RDP from the internet. Open RDP ports are a leading cause of ransomware infections and credential-based attacks. Access should be restricted to specific trusted IP ranges or removed in favour of Azure Bastion." }, + "AZ-NET-003": { + "control_id": "9.3", + "control_name": "Ensure that HTTPS access from the Internet is evaluated and restricted", + "description": "Network security groups should not allow unrestricted inbound access on port 443 from the internet. Public web services should be fronted by an Application Gateway with WAF rather than exposing port 443 directly via NSG rules." + }, + "AZ-NET-004": { + "control_id": "9.2", + "control_name": "Ensure that Network Security Groups have rules configured", + "description": "Network Security Groups with no custom rules configured provide no meaningful access control and rely entirely on Azure default rules. Explicit rules following least privilege should be defined for all NSGs." + }, + "AZ-NET-005": { + "control_id": "9.4", + "control_name": "Ensure that DDoS Protection Standard is enabled on all Virtual Networks", + "description": "Azure DDoS Protection Standard provides enhanced DDoS mitigation capabilities for Azure resources. Virtual networks hosting production workloads should have DDoS Protection Standard enabled." + }, + "AZ-NET-006": { + "control_id": "9.1", + "control_name": "Ensure that unassociated public IP addresses are removed", + "description": "Public IP addresses not associated with any resource represent unnecessary attack surface and cost. Unassociated public IPs should be deleted or documented and tagged for review." + }, + "AZ-NET-007": { + "control_id": "9.6", + "control_name": "Ensure that Web Application Firewall is enabled on Application Gateway", + "description": "Application Gateway should have Web Application Firewall enabled in Prevention mode. WAF protects web applications from common exploits including OWASP Top 10 vulnerabilities such as SQL injection and cross-site scripting." + }, + "AZ-NET-008": { + "control_id": "9.1", + "control_name": "Ensure that Load Balancers have backend pools configured", + "description": "Load balancers with no backend pool configured are either misconfigured or leftover resources. They represent unnecessary cost and poor resource hygiene and should be removed or configured correctly." + }, + "AZ-NET-009": { + "control_id": "9.5", + "control_name": "Ensure that VPN gateways use IKEv2", + "description": "VPN gateway connections should use IKEv2 rather than the outdated IKEv1 protocol. IKEv2 provides improved authentication, better performance and built-in NAT traversal support compared to IKEv1." + }, + "AZ-NET-010": { + "control_id": "9.2", + "control_name": "Ensure that all subnets have a Network Security Group attached", + "description": "All subnets except gateway subnets should have a Network Security Group attached. Without an NSG at subnet level, resources in the subnet have no network layer access control and are potentially reachable from other subnets or the internet." + }, "AZ-IDN-001": { "control_id": "1.23", "control_name": "Ensure That No Custom Subscription Owner Roles Are Created", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index df8fc6f..a3792b1 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -6,49 +6,95 @@ "AZ-STOR-001": { "control_id": "A.9.4.1", "control_name": "Information access restriction", - "description": "Access to information and application system functions shall be restricted in accordance with the access control policy. Enabling public blob access on storage accounts removes all access restrictions and allows any internet user to read stored data without authentication, directly violating this control." + "description": "Public blob access allows unrestricted access to information stored in Azure Storage. Access to information and application system functions should be restricted in accordance with the access control policy." }, "AZ-STOR-002": { "control_id": "A.10.1.1", "control_name": "Policy on the use of cryptographic controls", - "description": "A policy on the use of cryptographic controls for protection of information shall be developed and implemented. Storage accounts transmitting data over HTTP do not apply encryption in transit, violating the organisation's cryptographic control policy requirement to protect data confidentiality." + "description": "Requiring secure transfer ensures cryptographic controls are applied to data in transit. A policy on the use of cryptographic controls for protection of information should be developed and implemented." }, "AZ-NET-001": { "control_id": "A.13.1.1", "control_name": "Network controls", - "description": "Networks shall be managed and controlled to protect information in systems and applications. NSGs permitting unrestricted SSH from the internet represent a failure of network access control, exposing systems to direct internet-based attack with no network-layer filtering." + "description": "Unrestricted SSH access from the internet violates network access controls. Networks should be managed and controlled to protect information in systems and applications." }, "AZ-NET-002": { "control_id": "A.13.1.1", "control_name": "Network controls", - "description": "Networks shall be managed and controlled to protect information in systems and applications. NSGs permitting unrestricted RDP from the internet represent a critical network control failure, as RDP is the most commonly exploited protocol for ransomware initial access." + "description": "Unrestricted RDP access from the internet violates network access controls. Networks should be managed and controlled to protect information in systems and applications." + }, + "AZ-NET-003": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Unrestricted inbound access on port 443 from the internet increases exposure. Networks should be managed and controlled with appropriate restrictions on inbound traffic to protect information systems." + }, + "AZ-NET-004": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "NSGs with no rules provide no network controls. Networks should be managed and controlled with explicit rules that restrict traffic to what is required for the workload." + }, + "AZ-NET-005": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Virtual networks without DDoS protection are vulnerable to availability attacks. Network controls should include protection against denial of service attacks to maintain availability of information systems." + }, + "AZ-NET-006": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Unassociated public IP addresses represent unnecessary network exposure. Network resources that are no longer required should be removed to minimise the attack surface." + }, + "AZ-NET-007": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Application Gateways without WAF provide no protection against web application attacks. Network controls should include application layer filtering to protect against common web exploits." + }, + "AZ-NET-008": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Load balancers with no backend pool are unused resources. Unused network resources should be removed as part of regular network hygiene to maintain an accurate and minimal network topology." + }, + "AZ-NET-009": { + "control_id": "A.13.2.1", + "control_name": "Information transfer policies and procedures", + "description": "VPN connections using IKEv1 use an outdated protocol. Information transfer policies should require the use of current secure protocols to protect data in transit between networks." + }, + "AZ-NET-010": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Subnets without NSGs have no network layer access controls. All subnets should have NSGs attached with explicit rules to enforce network controls at the subnet boundary." }, "AZ-IDN-001": { "control_id": "A.9.2.3", "control_name": "Management of privileged access rights", - "description": "The allocation and use of privileged access rights shall be restricted and controlled. Assigning the Owner role to service principals at subscription scope grants excessive privileged access rights beyond operational requirements, violating the principle of least privilege and privileged access management controls." + "description": "Service principals with overly broad permissions violate privileged access management. The allocation and use of privileged access rights should be restricted and controlled." }, "AZ-IDN-002": { "control_id": "A.9.4.2", "control_name": "Secure log-on procedures", - "description": "Where required by the access control policy, access to systems and applications shall be controlled by a secure log-on procedure. Multi-factor authentication is a required component of secure log-on for privileged accounts. Absence of MFA enforcement via Conditional Access violates this control." + "description": "MFA enforces secure log-on for privileged accounts. Where required by the access control policy, access to systems and applications should be controlled by a secure log-on procedure including multi-factor authentication." }, "AZ-DB-001": { "control_id": "A.13.1.1", "control_name": "Network controls", - "description": "Networks shall be managed and controlled to protect information in systems and applications. Database servers with public network access enabled lack the network-level isolation required to protect sensitive data from direct internet exposure and attack." + "description": "Public network access to PostgreSQL servers should be disabled. Database servers should only be accessible via private network connections with appropriate network controls in place." }, "AZ-DB-002": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "Event logs recording user activities, exceptions, faults and information security events shall be produced, kept and regularly reviewed. Disabling SQL Server auditing means that database access events, failed logins, and schema changes are not logged, making incident detection and forensic investigation impossible." + "description": "SQL Server auditing must be enabled to provide event logs. Event logs recording user activities, exceptions, faults and information security events should be produced, kept and regularly reviewed." }, "AZ-CMP-001": { "control_id": "A.13.1.1", "control_name": "Network controls", - "description": "Networks shall be managed and controlled to protect information in systems and applications. Virtual machines with public IPs and no Network Security Group have no network-layer access controls, exposing all ports and services to the internet without any filtering." + "description": "Virtual machines with public IPs and no NSG have unrestricted network access. Network controls should be applied to all compute resources accessible from the internet." }, "AZ-KV-001": { +<<<<<<< feat/network-rules-expansion + "control_id": "A.12.3.1", + "control_name": "Information backup", + "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Backup copies of information should be taken and tested regularly in accordance with an agreed backup policy." + } +======= "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", "description": "Information processing facilities shall be implemented with sufficient redundancy to meet availability requirements. Disabling soft delete on Key Vault removes the ability to recover deleted secrets, keys, and certificates, creating a single point of failure for critical cryptographic material and violating availability and recovery requirements." @@ -63,5 +109,6 @@ "control_name": "Network controls", "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." } +>>>>>>> dev } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index fe1ca80..2c4ddf8 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -6,62 +6,92 @@ "AZ-STOR-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", - "description": "Remote access to data assets is controlled. Unauthenticated public blob access on storage accounts violates access management controls by allowing anonymous access to potentially sensitive data without any form of authentication or authorisation." + "description": "Public blob access enables unauthenticated remote access to storage resources. Disabling public access ensures remote access to storage is managed and authenticated." }, "AZ-STOR-002": { "control_id": "PR.DS-2", "control_name": "Data-in-transit is protected", - "description": "Data in transit is protected to prevent interception and tampering. Storage accounts that allow HTTP traffic transmit data in plaintext, violating the requirement to protect data in transit through encryption (TLS)." + "description": "Requiring secure transfer ensures data in transit between clients and Azure Storage is encrypted using HTTPS, protecting against interception and tampering." }, "AZ-NET-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", - "description": "Remote access to systems must be controlled. Allowing unrestricted SSH access from the internet bypasses access management controls and exposes systems to unauthorised remote access, brute-force attacks, and exploitation." + "description": "Unrestricted SSH access from the internet allows unmanaged remote access. NSG rules should restrict SSH to known IP ranges to ensure remote access is controlled and monitored." }, "AZ-NET-002": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", - "description": "Remote access to systems must be managed. Allowing unrestricted RDP access from the internet bypasses access management controls and is a primary vector for ransomware delivery and credential-based attacks on Windows systems." + "description": "Unrestricted RDP access from the internet allows unmanaged remote access. NSG rules should restrict RDP to known IP ranges or use Azure Bastion to ensure remote access is controlled." + }, + "AZ-NET-003": { + "control_id": "SC-7", + "control_name": "Boundary Protection", + "description": "Unrestricted inbound access on port 443 from the internet increases the attack surface. Public-facing HTTPS services should be fronted by a WAF-enabled Application Gateway rather than exposed directly via NSG rules." + }, + "AZ-NET-004": { + "control_id": "SC-7", + "control_name": "Boundary Protection", + "description": "NSGs with no custom rules provide no meaningful boundary protection. Explicit least-privilege rules should be defined to control inbound and outbound traffic at the network perimeter." + }, + "AZ-NET-005": { + "control_id": "SC-5", + "control_name": "Denial of Service Protection", + "description": "Virtual networks without DDoS Protection Standard are vulnerable to volumetric denial of service attacks. DDoS Protection Standard provides enhanced mitigation for production workloads." + }, + "AZ-NET-006": { + "control_id": "CM-7", + "control_name": "Least Functionality", + "description": "Unassociated public IP addresses represent unnecessary functionality and attack surface. Resources that are no longer in use should be removed to maintain least functionality." + }, + "AZ-NET-007": { + "control_id": "SI-3", + "control_name": "Malicious Code Protection", + "description": "Application Gateways without WAF enabled provide no protection against web application attacks including OWASP Top 10 vulnerabilities. WAF in Prevention mode should be enabled on all public-facing Application Gateways." + }, + "AZ-NET-008": { + "control_id": "CM-7", + "control_name": "Least Functionality", + "description": "Load balancers with no backend pool configured serve no function and represent unnecessary resources. Unused resources should be removed to maintain least functionality and reduce cost." + }, + "AZ-NET-009": { + "control_id": "SC-8", + "control_name": "Transmission Confidentiality and Integrity", + "description": "VPN connections using IKEv1 use an outdated protocol with known vulnerabilities. IKEv2 should be used for all VPN gateway connections to ensure transmission confidentiality and integrity." + }, + "AZ-NET-010": { + "control_id": "SC-7", + "control_name": "Boundary Protection", + "description": "Subnets without NSGs attached have no network layer access control. All production subnets should have NSGs with explicit rules to enforce boundary protection at the subnet level." }, "AZ-IDN-001": { "control_id": "PR.AC-4", - "control_name": "Access permissions and authorisations are managed, incorporating the principles of least privilege and separation of duties", - "description": "Access to cloud resources should follow the principle of least privilege. Assigning the Owner role to service principals at subscription scope grants excessive permissions that violate least-privilege and separation-of-duties requirements." + "control_name": "Access permissions and authorizations are managed", + "description": "Service principals with overly broad permissions violate least privilege. Access permissions should be scoped to the minimum required for the workload to function." }, "AZ-IDN-002": { - "control_id": "PR.AC-1", - "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited for authorised devices, users and processes", - "description": "Credentials must be managed to ensure only authorised parties can authenticate. Without MFA enforcement, a single compromised password grants full access to administrator accounts, undermining identity management controls." + "control_id": "PR.AC-7", + "control_name": "Users, devices, and other assets are authenticated", + "description": "MFA ensures privileged users are strongly authenticated before accessing Azure resources. Without MFA, a compromised password is sufficient for full administrative access." }, "AZ-DB-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", - "description": "Database servers should not be reachable from the public internet without restriction. Public network access to PostgreSQL servers removes the network-based access control layer, exposing the database to direct internet-based attacks." + "description": "Public network access to PostgreSQL servers should be disabled. Database access should be restricted to private networks to ensure remote access is managed and controlled." }, "AZ-DB-002": { - "control_id": "DE.CM-7", - "control_name": "Monitoring for unauthorised personnel, connections, devices, and software is performed", - "description": "Audit logging on SQL servers enables detection of unauthorised access attempts, privilege escalation, and suspicious database activity. Without auditing enabled, security events go undetected and incident investigation is severely limited." + "control_id": "DE.AE-3", + "control_name": "Event data are aggregated and correlated", + "description": "SQL Server auditing must be enabled with sufficient retention to support threat detection and incident investigation. Audit logs provide the event data needed to detect and respond to anomalous database activity." }, "AZ-CMP-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", - "description": "Virtual machines accessible from the internet must have compensating network controls. A VM with a public IP and no NSG has all ports exposed to the internet with no filtering, violating remote access management requirements." + "description": "Virtual machines with public IPs and no NSG have unrestricted network access. NSGs should be attached to control inbound and outbound traffic and manage remote access to compute resources." }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", - "description": "Key material in Azure Key Vault must be recoverable after accidental or malicious deletion. Soft delete provides a recoverable state for secrets, keys, and certificates, supporting backup and recovery requirements for critical cryptographic material." - }, - "AZ-STOR-003": { - "control_id": "PR.DS-3", - "control_name": "Assets are formally managed throughout removal, transfers, and disposition", - "description": "Data stored in Azure storage accounts should be subject to formal lifecycle management policies that govern retention, transition, and deletion. Without these policies, stale data accumulates indefinitely and is never formally dispositioned, violating data management and minimisation requirements." - }, - "AZ-KV-002": { - "control_id": "AC-17", - "control_name": "Remote Access", - "description": "Remote access to systems should be controlled, monitored, and restricted. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be limited to trusted networks using private endpoints or network restrictions." + "description": "Key Vault soft delete protects against accidental or malicious deletion of secrets, keys and certificates. Without soft delete, deleted vault objects cannot be recovered, causing potential data loss." } } } diff --git a/playbooks/cli/fix_az_net_003.sh b/playbooks/cli/fix_az_net_003.sh new file mode 100644 index 0000000..36e8b0a --- /dev/null +++ b/playbooks/cli/fix_az_net_003.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-003 — NSG allows unrestricted inbound on port 443 +# Usage: ./fix_az_net_003.sh +# Severity: MEDIUM + +set -e + +RESOURCE_GROUP=$1 +NSG_NAME=$2 +RULE_NAME=$3 +ALLOWED_IP=$4 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$NSG_NAME" ] || [ -z "$RULE_NAME" ] || [ -z "$ALLOWED_IP" ]; then + echo "Usage: $0 " + echo "" + echo "Example:" + echo " $0 my-rg my-nsg allow-https 203.0.113.0/24" + exit 1 +fi + +echo "Restricting port 443 inbound rule '$RULE_NAME' in NSG '$NSG_NAME'..." + +az network nsg rule update \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$NSG_NAME" \ + --name "$RULE_NAME" \ + --source-address-prefixes "$ALLOWED_IP" + +echo "✅ Remediation complete — port 443 now restricted to $ALLOWED_IP" +echo "⚠️ Verify your application still functions correctly after this change." diff --git a/playbooks/cli/fix_az_net_004.sh b/playbooks/cli/fix_az_net_004.sh new file mode 100644 index 0000000..cad9fbb --- /dev/null +++ b/playbooks/cli/fix_az_net_004.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-004 — NSG with no rules configured +# Usage: ./fix_az_net_004.sh +# Severity: MEDIUM + +set -e + +RESOURCE_GROUP=$1 +NSG_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$NSG_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Adding default deny-all inbound rule to NSG '$NSG_NAME'..." + +az network nsg rule create \ + --resource-group "$RESOURCE_GROUP" \ + --nsg-name "$NSG_NAME" \ + --name "DenyAllInbound" \ + --priority 4096 \ + --direction Inbound \ + --access Deny \ + --protocol "*" \ + --source-address-prefixes "*" \ + --destination-address-prefixes "*" \ + --destination-port-ranges "*" + +echo "✅ Default deny-all inbound rule added to $NSG_NAME" +echo "⚠️ Now add specific allow rules for your workload traffic." diff --git a/playbooks/cli/fix_az_net_005.sh b/playbooks/cli/fix_az_net_005.sh new file mode 100644 index 0000000..69905ed --- /dev/null +++ b/playbooks/cli/fix_az_net_005.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-005 — Virtual network with no DDoS protection enabled +# Usage: ./fix_az_net_005.sh +# Severity: MEDIUM + +set -e + +RESOURCE_GROUP=$1 +VNET_NAME=$2 +DDOS_PLAN_NAME=$3 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$VNET_NAME" ] || [ -z "$DDOS_PLAN_NAME" ]; then + echo "Usage: $0 " + echo "" + echo "To create a new DDoS protection plan first:" + echo " az network ddos-protection create --resource-group --name " + exit 1 +fi + +echo "Enabling DDoS protection on VNet '$VNET_NAME'..." + +az network vnet update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VNET_NAME" \ + --ddos-protection true \ + --ddos-protection-plan "$DDOS_PLAN_NAME" + +echo "✅ DDoS Protection Standard enabled on $VNET_NAME" +echo "⚠️ DDoS Protection Standard incurs additional cost — review Azure pricing." diff --git a/playbooks/cli/fix_az_net_006.sh b/playbooks/cli/fix_az_net_006.sh new file mode 100644 index 0000000..6073122 --- /dev/null +++ b/playbooks/cli/fix_az_net_006.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-006 — Public IP address unassociated with any resource +# Usage: ./fix_az_net_006.sh +# Severity: LOW + +set -e + +RESOURCE_GROUP=$1 +PUBLIC_IP_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$PUBLIC_IP_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Deleting unassociated public IP '$PUBLIC_IP_NAME'..." + +az network public-ip delete \ + --resource-group "$RESOURCE_GROUP" \ + --name "$PUBLIC_IP_NAME" + +echo "✅ Public IP '$PUBLIC_IP_NAME' deleted successfully." +echo "⚠️ If this IP was reserved for future use, reassign it to a resource instead of deleting." diff --git a/playbooks/cli/fix_az_net_007.sh b/playbooks/cli/fix_az_net_007.sh new file mode 100644 index 0000000..5e39efe --- /dev/null +++ b/playbooks/cli/fix_az_net_007.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-007 — Application Gateway without WAF enabled +# Usage: ./fix_az_net_007.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +AGW_NAME=$2 +WAF_POLICY=$3 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$AGW_NAME" ] || [ -z "$WAF_POLICY" ]; then + echo "Usage: $0 " + echo "" + echo "To create a WAF policy first:" + echo " az network application-gateway waf-policy create --resource-group --name " + exit 1 +fi + +echo "Enabling WAF on Application Gateway '$AGW_NAME'..." + +az network application-gateway waf-config set \ + --resource-group "$RESOURCE_GROUP" \ + --gateway-name "$AGW_NAME" \ + --enabled true \ + --firewall-mode Prevention \ + --rule-set-type OWASP \ + --rule-set-version 3.2 + +echo "✅ WAF enabled on $AGW_NAME in Prevention mode with OWASP 3.2 rule set." +echo "⚠️ Monitor WAF logs for false positives before relying on Prevention mode in production." diff --git a/playbooks/cli/fix_az_net_008.sh b/playbooks/cli/fix_az_net_008.sh new file mode 100644 index 0000000..014bf60 --- /dev/null +++ b/playbooks/cli/fix_az_net_008.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-008 — Load balancer with no backend pool configured +# Usage: ./fix_az_net_008.sh +# Severity: LOW + +set -e + +RESOURCE_GROUP=$1 +LB_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$LB_NAME" ]; then + echo "Usage: $0 " + echo "" + echo "Options:" + echo " 1. Delete the load balancer if no longer needed:" + echo " az network lb delete --resource-group --name " + echo "" + echo " 2. Add a backend pool if the load balancer is still required:" + echo " az network lb address-pool create --resource-group --lb-name --name " + exit 1 +fi + +echo "Deleting empty load balancer '$LB_NAME'..." + +az network lb delete \ + --resource-group "$RESOURCE_GROUP" \ + --name "$LB_NAME" + +echo "✅ Load balancer '$LB_NAME' deleted." +echo "⚠️ If this load balancer is still needed, create a backend pool instead of deleting." diff --git a/playbooks/cli/fix_az_net_009.sh b/playbooks/cli/fix_az_net_009.sh new file mode 100644 index 0000000..f6d9e75 --- /dev/null +++ b/playbooks/cli/fix_az_net_009.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-009 — VPN gateway using outdated IKE version +# Usage: ./fix_az_net_009.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +CONNECTION_NAME=$2 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CONNECTION_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Updating VPN connection '$CONNECTION_NAME' to use IKEv2..." + +az network vpn-connection update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$CONNECTION_NAME" \ + --set connectionProtocol=IKEv2 + +echo "✅ VPN connection '$CONNECTION_NAME' updated to IKEv2." +echo "⚠️ Ensure the remote VPN peer also supports IKEv2 before applying this change." +echo "⚠️ The VPN connection will briefly disconnect during the update." diff --git a/playbooks/cli/fix_az_net_010.sh b/playbooks/cli/fix_az_net_010.sh new file mode 100644 index 0000000..b619e09 --- /dev/null +++ b/playbooks/cli/fix_az_net_010.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-NET-010 — Subnet with no network security group attached +# Usage: ./fix_az_net_010.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +VNET_NAME=$2 +SUBNET_NAME=$3 +NSG_NAME=$4 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$VNET_NAME" ] || [ -z "$SUBNET_NAME" ] || [ -z "$NSG_NAME" ]; then + echo "Usage: $0 " + echo "" + echo "To create a new NSG first:" + echo " az network nsg create --resource-group --name " + exit 1 +fi + +echo "Attaching NSG '$NSG_NAME' to subnet '$SUBNET_NAME' in VNet '$VNET_NAME'..." + +az network vnet subnet update \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$SUBNET_NAME" \ + --network-security-group "$NSG_NAME" + +echo "✅ NSG '$NSG_NAME' attached to subnet '$SUBNET_NAME'." +echo "⚠️ Review NSG rules to ensure only required traffic is permitted." diff --git a/scanner/rules/az_net_003.py b/scanner/rules/az_net_003.py new file mode 100644 index 0000000..54d2ca1 --- /dev/null +++ b/scanner/rules/az_net_003.py @@ -0,0 +1,63 @@ +"""AZ-NET-003: NSG allows unrestricted inbound on port 443.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-003" +RULE_NAME = "NSG allows unrestricted inbound on port 443" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.3", "NIST": "SC-7", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + DESCRIPTION = ( + "A Network Security Group has an inbound rule allowing unrestricted access " + "on port 443 from any source (0.0.0.0/0). While HTTPS traffic is encrypted, " + "exposing port 443 to the entire internet unnecessarily increases the attack " + "surface and can expose web services to automated scanning and exploitation attempts. " + "Note: this finding is expected for intentionally public-facing web services. " + "Review manually before remediating — do not auto-remediate without confirming " + "the service is not meant to be publicly accessible." +) +) +REMEDIATION = ( + "Restrict the inbound rule on port 443 to known IP ranges or use an " + "Application Gateway with WAF to front any public-facing HTTPS services. " + "If the service must be public, ensure it is protected by DDoS Standard." +) +PLAYBOOK = "playbooks/cli/fix_az_net_003.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect NSGs with unrestricted inbound access on port 443.""" + findings: List[Dict[str, Any]] = [] + + for nsg in azure_client.get_network_security_groups(): + for rule in getattr(nsg, "security_rules", []) or []: + if ( + getattr(rule, "direction", "") == "Inbound" + and getattr(rule, "access", "") == "Allow" + and getattr(rule, "source_address_prefix", "") in ("*", "0.0.0.0/0", "Internet", "Any") + and getattr(rule, "destination_port_range", "") in ("443", "*") + ): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(nsg, "id", ""), + "resource_name": getattr(nsg, "name", ""), + "resource_type": "Microsoft.Network/networkSecurityGroups", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "rule_name": getattr(rule, "name", ""), + "source_prefix": getattr(rule, "source_address_prefix", ""), + }, + }) + break + + return findings diff --git a/scanner/rules/az_net_004.py b/scanner/rules/az_net_004.py new file mode 100644 index 0000000..8ce4178 --- /dev/null +++ b/scanner/rules/az_net_004.py @@ -0,0 +1,50 @@ +"""AZ-NET-004: NSG with no rules configured (empty ruleset).""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-004" +RULE_NAME = "NSG with no rules configured" +SEVERITY = "MEDIUM" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.2", "NIST": "SC-7", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "A Network Security Group exists but has no custom security rules configured. " + "An empty NSG relies entirely on Azure default rules which may not meet your " + "security requirements and provides no meaningful access control." +) +REMEDIATION = ( + "Add explicit inbound and outbound rules to the NSG that reflect the " + "principle of least privilege. Deny all traffic by default and only allow " + "what is required for the workload." +) +PLAYBOOK = "playbooks/cli/fix_az_net_004.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect NSGs with no custom security rules.""" + findings: List[Dict[str, Any]] = [] + + for nsg in azure_client.get_network_security_groups(): + rules = getattr(nsg, "security_rules", []) or [] + if len(rules) == 0: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(nsg, "id", ""), + "resource_name": getattr(nsg, "name", ""), + "resource_type": "Microsoft.Network/networkSecurityGroups", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "rule_count": len(rules), + }, + }) + + return findings diff --git a/scanner/rules/az_net_005.py b/scanner/rules/az_net_005.py new file mode 100644 index 0000000..9f6702c --- /dev/null +++ b/scanner/rules/az_net_005.py @@ -0,0 +1,65 @@ +"""AZ-NET-005: Virtual network with no DDoS protection enabled.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-005" +RULE_NAME = "Virtual network with no DDoS protection enabled" +SEVERITY = "LOW" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.4", "NIST": "SC-5", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "The virtual network does not have Azure DDoS Protection Standard enabled. " + "Without DDoS protection, the network is vulnerable to volumetric attacks " + "that can overwhelm resources and cause service outages." +) +REMEDIATION = ( + "Enable Azure DDoS Protection Standard on the virtual network. " + "DDoS Protection Standard provides enhanced mitigation capabilities " + "and is recommended for all production virtual networks." +) +PLAYBOOK = "playbooks/cli/fix_az_net_005.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect virtual networks without DDoS protection enabled.""" + findings: List[Dict[str, Any]] = [] + + try: + # NOTE: This rule creates a NetworkManagementClient directly rather than + # going through azure_client. A get_virtual_networks() method should be + # added to AzureClient in a follow-up PR for consistency. + from azure.mgmt.network import NetworkManagementClient + client = NetworkManagementClient( + azure_client.credential, azure_client.subscription_id + ) + vnets = list(client.virtual_networks.list_all()) + except Exception as exc: + logger.error("Failed to list virtual networks: %s", exc) + return findings + + for vnet in vnets: + ddos = getattr(vnet, "ddos_protection_plan", None) + enable_ddos = getattr(vnet, "enable_ddos_protection", False) + if not ddos and not enable_ddos: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(vnet, "id", ""), + "resource_name": getattr(vnet, "name", ""), + "resource_type": "Microsoft.Network/virtualNetworks", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "location": getattr(vnet, "location", ""), + "ddos_protection": enable_ddos, + }, + }) + + return findings diff --git a/scanner/rules/az_net_006.py b/scanner/rules/az_net_006.py new file mode 100644 index 0000000..180f49b --- /dev/null +++ b/scanner/rules/az_net_006.py @@ -0,0 +1,67 @@ +"""AZ-NET-006: Public IP address unassociated with any resource.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-006" +RULE_NAME = "Public IP address unassociated with any resource" +SEVERITY = "LOW" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.1", "NIST": "CM-7", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "A public IP address exists in the subscription but is not associated " + "with any resource such as a VM, load balancer or application gateway. " + "Unassociated public IPs represent unnecessary cost and attack surface " + "and may indicate leftover resources from decommissioned workloads." +) +REMEDIATION = ( + "Delete the unassociated public IP address if it is no longer needed. " + "If it is reserved for future use, document the reason and tag it " + "appropriately so it can be tracked and reviewed regularly." +) +PLAYBOOK = "playbooks/cli/fix_az_net_006.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect public IP addresses not associated with any resource.""" + findings: List[Dict[str, Any]] = [] + + try: + # NOTE: This rule creates a NetworkManagementClient directly rather than + # going through azure_client. A get_public_ip_addresses() method should be + # added to AzureClient in a follow-up PR for consistency. + from azure.mgmt.network import NetworkManagementClient + client = NetworkManagementClient( + azure_client.credential, azure_client.subscription_id + ) + public_ips = list(client.public_ip_addresses.list_all()) + except Exception as exc: + logger.error("Failed to list public IP addresses: %s", exc) + return findings + + for pip in public_ips: + ip_config = getattr(pip, "ip_configuration", None) + nat_gateway = getattr(pip, "nat_gateway", None) + if not ip_config and not nat_gateway: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(pip, "id", ""), + "resource_name": getattr(pip, "name", ""), + "resource_type": "Microsoft.Network/publicIPAddresses", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "ip_address": getattr(pip, "ip_address", ""), + "location": getattr(pip, "location", ""), + "sku": getattr(getattr(pip, "sku", None), "name", ""), + }, + }) + + return findings diff --git a/scanner/rules/az_net_007.py b/scanner/rules/az_net_007.py new file mode 100644 index 0000000..08a3571 --- /dev/null +++ b/scanner/rules/az_net_007.py @@ -0,0 +1,68 @@ +"""AZ-NET-007: Application Gateway without WAF enabled.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-007" +RULE_NAME = "Application Gateway without WAF enabled" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.6", "NIST": "SI-3", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "An Application Gateway exists without Web Application Firewall enabled. " + "Without WAF, the application is unprotected against common web exploits " + "such as SQL injection, cross-site scripting and OWASP Top 10 attacks. " + "Any public-facing application behind an Application Gateway should have " + "WAF enabled in Prevention mode." +) +REMEDIATION = ( + "Upgrade the Application Gateway SKU to WAF_v2 and enable WAF in " + "Prevention mode. Configure the OWASP core rule set and review any " + "false positives before enabling Prevention mode in production." +) +PLAYBOOK = "playbooks/cli/fix_az_net_007.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect Application Gateways without WAF enabled.""" + findings: List[Dict[str, Any]] = [] + + try: + from azure.mgmt.network import NetworkManagementClient + client = NetworkManagementClient( + azure_client.credential, azure_client.subscription_id + ) + app_gateways = list(client.application_gateways.list_all()) + except Exception as exc: + logger.error("Failed to list application gateways: %s", exc) + return findings + + for agw in app_gateways: + sku = getattr(agw, "sku", None) + sku_name = getattr(sku, "name", "") if sku else "" + waf_config = getattr(agw, "web_application_firewall_configuration", None) + waf_enabled = getattr(waf_config, "enabled", False) if waf_config else False + + if "WAF" not in sku_name or not waf_enabled: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(agw, "id", ""), + "resource_name": getattr(agw, "name", ""), + "resource_type": "Microsoft.Network/applicationGateways", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "sku": sku_name, + "waf_enabled": waf_enabled, + "location": getattr(agw, "location", ""), + }, + }) + + return findings diff --git a/scanner/rules/az_net_008.py b/scanner/rules/az_net_008.py new file mode 100644 index 0000000..d1ebe4c --- /dev/null +++ b/scanner/rules/az_net_008.py @@ -0,0 +1,62 @@ +"""AZ-NET-008: Load balancer with no backend pool configured.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-008" +RULE_NAME = "Load balancer with no backend pool configured" +SEVERITY = "LOW" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.1", "NIST": "CM-7", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "A load balancer exists in the subscription but has no backend pool " + "configured. A load balancer with no backend pool is either misconfigured " + "or is a leftover resource from a decommissioned workload. It represents " + "unnecessary cost and indicates poor resource hygiene." +) +REMEDIATION = ( + "If the load balancer is no longer needed, delete it to reduce cost and " + "attack surface. If it is still required, configure a backend pool with " + "the appropriate virtual machines or scale set instances." +) +PLAYBOOK = "playbooks/cli/fix_az_net_008.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect load balancers with no backend pool configured.""" + findings: List[Dict[str, Any]] = [] + + try: + from azure.mgmt.network import NetworkManagementClient + client = NetworkManagementClient( + azure_client.credential, azure_client.subscription_id + ) + load_balancers = list(client.load_balancers.list_all()) + except Exception as exc: + logger.error("Failed to list load balancers: %s", exc) + return findings + + for lb in load_balancers: + backend_pools = getattr(lb, "backend_address_pools", []) or [] + if len(backend_pools) == 0: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(lb, "id", ""), + "resource_name": getattr(lb, "name", ""), + "resource_type": "Microsoft.Network/loadBalancers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "location": getattr(lb, "location", ""), + "backend_pool_count": len(backend_pools), + }, + }) + + return findings diff --git a/scanner/rules/az_net_009.py b/scanner/rules/az_net_009.py new file mode 100644 index 0000000..676e15c --- /dev/null +++ b/scanner/rules/az_net_009.py @@ -0,0 +1,63 @@ +"""AZ-NET-009: VPN gateway using outdated IKE version.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-009" +RULE_NAME = "VPN gateway using outdated IKE version" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.5", "NIST": "SC-8", "ISO27001": "A.13.2.1"} +DESCRIPTION = ( + "A VPN gateway is configured to use IKEv1 which is an outdated and less " + "secure version of the Internet Key Exchange protocol. IKEv1 is vulnerable " + "to several known attacks and lacks features present in IKEv2 such as " + "improved authentication and built-in NAT traversal support." +) +REMEDIATION = ( + "Migrate the VPN gateway connection to use IKEv2. Update the VPN gateway " + "SKU if required and reconfigure all VPN connections to use IKEv2 only. " + "Coordinate with the remote VPN peer to ensure IKEv2 is supported on both ends." +) +PLAYBOOK = "playbooks/cli/fix_az_net_009.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect VPN gateways using outdated IKEv1.""" + findings: List[Dict[str, Any]] = [] + + try: + from azure.mgmt.network import NetworkManagementClient + client = NetworkManagementClient( + azure_client.credential, azure_client.subscription_id + ) + connections = list(client.virtual_network_gateway_connections.list_all()) + except Exception as exc: + logger.error("Failed to list VPN gateway connections: %s", exc) + return findings + + for conn in connections: + ike_version = getattr(conn, "connection_protocol", "") or "" + if ike_version.upper() == "IKEV1": + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(conn, "id", ""), + "resource_name": getattr(conn, "name", ""), + "resource_type": "Microsoft.Network/connections", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "ike_version": ike_version, + "location": getattr(conn, "location", ""), + "connection_type": getattr(conn, "connection_type", ""), + }, + }) + + return findings diff --git a/scanner/rules/az_net_010.py b/scanner/rules/az_net_010.py new file mode 100644 index 0000000..135e678 --- /dev/null +++ b/scanner/rules/az_net_010.py @@ -0,0 +1,68 @@ +"""AZ-NET-010: Subnet with no network security group attached.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-010" +RULE_NAME = "Subnet with no network security group attached" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "9.2", "NIST": "SC-7", "ISO27001": "A.13.1.1"} +DESCRIPTION = ( + "A subnet exists without a Network Security Group attached. Without an NSG " + "at the subnet level, all resources deployed into that subnet have no network " + "layer access control. Any VM or service in the subnet is reachable from " + "other subnets and potentially the internet with no filtering in place." +) +REMEDIATION = ( + "Create and attach an NSG to the subnet with rules that follow the principle " + "of least privilege. Define explicit allow rules for required traffic and " + "deny everything else. Apply NSGs at both the subnet and NIC level for " + "defence in depth." +) +PLAYBOOK = "playbooks/cli/fix_az_net_010.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect subnets with no NSG attached.""" + findings: List[Dict[str, Any]] = [] + + try: + from azure.mgmt.network import NetworkManagementClient + client = NetworkManagementClient( + azure_client.credential, azure_client.subscription_id + ) + vnets = list(client.virtual_networks.list_all()) + except Exception as exc: + logger.error("Failed to list virtual networks: %s", exc) + return findings + + for vnet in vnets: + for subnet in getattr(vnet, "subnets", []) or []: + name = getattr(subnet, "name", "") + if name in ("GatewaySubnet", "AzureFirewallSubnet", "AzureBastionSubnet"): + continue + nsg = getattr(subnet, "network_security_group", None) + if not nsg: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(subnet, "id", ""), + "resource_name": name, + "resource_type": "Microsoft.Network/virtualNetworks/subnets", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "vnet_name": getattr(vnet, "name", ""), + "vnet_id": getattr(vnet, "id", ""), + "address_prefix": getattr(subnet, "address_prefix", ""), + }, + }) + + return findings From 2badbce0c1122a3df2d6c4c1290450227af7c4e8 Mon Sep 17 00:00:00 2001 From: Ritik Sah Date: Tue, 5 May 2026 19:42:34 +0100 Subject: [PATCH 12/50] Feat/az stor 003 (#21) * feat: add rule AZ-STOR-003 storage lifecycle policy check * feat: add rule AZ-STOR-003 storage lifecycle policy check --- compliance/frameworks/iso27001.json | 15 +- compliance/frameworks/nist_csf.json | 7 +- docs/az-stor-003-test-plan.md | 392 ++++++++++++++++++++++++++++ playbooks/cli/fix_az_stor_003.sh | 195 ++++++++++++++ scanner/azure_client.py | 71 ++++- scanner/rules/az_stor_003.py | 117 +++++++++ 6 files changed, 784 insertions(+), 13 deletions(-) create mode 100644 docs/az-stor-003-test-plan.md create mode 100755 playbooks/cli/fix_az_stor_003.sh create mode 100644 scanner/rules/az_stor_003.py diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index a3792b1..7931375 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -89,12 +89,6 @@ "description": "Virtual machines with public IPs and no NSG have unrestricted network access. Network controls should be applied to all compute resources accessible from the internet." }, "AZ-KV-001": { -<<<<<<< feat/network-rules-expansion - "control_id": "A.12.3.1", - "control_name": "Information backup", - "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Backup copies of information should be taken and tested regularly in accordance with an agreed backup policy." - } -======= "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", "description": "Information processing facilities shall be implemented with sufficient redundancy to meet availability requirements. Disabling soft delete on Key Vault removes the ability to recover deleted secrets, keys, and certificates, creating a single point of failure for critical cryptographic material and violating availability and recovery requirements." @@ -105,10 +99,9 @@ "description": "Information stored on Azure storage accounts should be subject to formal lifecycle management controls governing retention and disposal. Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism, violating information handling and disposal requirements under this control." }, "AZ-KV-002": { - "control_id": "A.13.1.1", - "control_name": "Network controls", - "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." - } ->>>>>>> dev + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." + } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 2c4ddf8..7a9ebba 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -91,7 +91,12 @@ "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", - "description": "Key Vault soft delete protects against accidental or malicious deletion of secrets, keys and certificates. Without soft delete, deleted vault objects cannot be recovered, causing potential data loss." + "description": "Key material in Azure Key Vault must be recoverable after accidental or malicious deletion. Soft delete provides a recoverable state for secrets, keys, and certificates, supporting backup and recovery requirements for critical cryptographic material." + }, + "AZ-STOR-003": { + "control_id": "PR.DS-3", + "control_name": "Assets are formally managed throughout removal, transfers, and disposition", + "description": "NIST CSF PR.DS-3 requires that data assets are managed through their full lifecycle including secure disposal. Storage accounts without a lifecycle management policy have no automated mechanism for expiring or deleting aged data, meaning data subject to disposal requirements persists indefinitely and is never formally retired from the asset inventory." } } } diff --git a/docs/az-stor-003-test-plan.md b/docs/az-stor-003-test-plan.md new file mode 100644 index 0000000..65fb272 --- /dev/null +++ b/docs/az-stor-003-test-plan.md @@ -0,0 +1,392 @@ +# Test Plan — AZ-STOR-003 +# Storage Account Has No Lifecycle Management Policy +# ============================================================ + +## 1. Overview + +This test plan covers verification of the AZ-STOR-003 scanner rule +and its remediation playbook. The goal is to confirm: + +- The rule correctly identifies non-compliant storage accounts +- The rule correctly ignores compliant storage accounts +- The playbook successfully creates a lifecycle policy +- The rule finds zero issues after the playbook runs + +--- + +## 2. Files Under Test + +| File | Purpose | +|---|---| +| scanner/rules/az_stor_003.py | Scanner rule | +| playbooks/cli/fix_az_stor_003.sh | Remediation script | +| scanner/azure_client.py | New method: get_storage_lifecycle_policy() | +| compliance/frameworks/cis_azure_benchmark.json | CIS mapping | +| compliance/frameworks/nist_csf.json | NIST mapping | +| compliance/frameworks/iso27001.json | ISO 27001 mapping | + +--- + +## 3. Test Environment Setup + +### 3.1 Prerequisites + +- Python 3.10+ +- Azure free trial account (portal.azure.com) +- Azure CLI installed and logged in (az login) +- OpenShield repo cloned and dependencies installed (pip install -r requirements.txt) +- .env file populated with AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_ID, + AZURE_CLIENT_SECRET, AZURE_TENANT_ID +- StorageV2 accounts used for all tests + +### 3.2 Create Test Resources in Azure + +Run these commands once before testing. They create two storage accounts: +one without a lifecycle policy (should be flagged) and one with a policy +(should NOT be flagged). + + # Create resource group + az group create --name openshield-test-rg --location eastus + + # Storage account WITHOUT lifecycle policy (will be flagged) + az storage account create \ + --name oshieldtestnopolicy \ + --resource-group openshield-test-rg \ + --sku Standard_LRS \ + --location eastus + + # Storage account WITH lifecycle policy (will NOT be flagged) + az storage account create \ + --name oshieldtestpolicyon \ + --resource-group openshield-test-rg \ + --sku Standard_LRS \ + --location eastus + + # Manually apply a policy to the second account + az storage account management-policy create \ + --account-name oshieldtestpolicyon \ + --resource-group openshield-test-rg \ + --policy '{ + "rules": [{ + "name": "test-policy", + "enabled": true, + "type": "Lifecycle", + "definition": { + "filters": {"blobTypes": ["blockBlob"]}, + "actions": { + "baseBlob": { + "delete": {"daysAfterLastAccessTimeGreaterThan": 365} + } + } + } + }] + }' + +--- + +## 4. Test Cases + +--- + +### TC-001 — Rule detects non-compliant account (POSITIVE TEST) + +**Purpose:** Confirm the rule flags a storage account with no lifecycle policy. + +**Pre-condition:** oshieldtestnopolicy exists with no lifecycle policy. + +**Steps:** + + python -c " + from dotenv import load_dotenv; load_dotenv() + import os + from scanner.azure_client import AzureClient + from scanner.rules import az_stor_003 as rule + + client = AzureClient(os.environ['AZURE_SUBSCRIPTION_ID']) + findings = rule.scan(client, os.environ['AZURE_SUBSCRIPTION_ID']) + print(f'Total findings: {len(findings)}') + for f in findings: + print(f' [{f[\"severity\"]}] {f[\"resource_name\"]}') + " + +**Expected result:** +- At minimum one finding returned +- oshieldtestnopolicy appears in the findings list +- Finding has severity = MEDIUM +- Finding has rule_id = AZ-STOR-003 +- Finding dict contains all required keys: + rule_id, rule_name, severity, category, resource_id, resource_name, + resource_type, description, remediation, playbook, frameworks + +**Pass criteria:** oshieldtestnopolicy is in findings list. + +--- + +### TC-002 — Rule ignores compliant account (NEGATIVE TEST) + +**Purpose:** Confirm the rule does NOT flag accounts that already have a policy. + +**Pre-condition:** oshieldtestpolicyon exists WITH a lifecycle policy applied. + +**Steps:** Same script as TC-001. Inspect the findings list. + +**Expected result:** +- oshieldtestpolicyon does NOT appear in the findings list. + +**Pass criteria:** oshieldtestpolicyon absent from findings. + +--- + +### TC-003 — Finding dict has correct structure + +**Purpose:** Confirm every required field is present and correctly typed. + +**Steps:** + + python -c " + from dotenv import load_dotenv; load_dotenv() + import os, json + from scanner.azure_client import AzureClient + from scanner.rules import az_stor_003 as rule + + REQUIRED_KEYS = [ + 'rule_id', 'rule_name', 'severity', 'category', + 'resource_id', 'resource_name', 'resource_type', + 'description', 'remediation', 'playbook', 'frameworks' + ] + + client = AzureClient(os.environ['AZURE_SUBSCRIPTION_ID']) + findings = rule.scan(client, os.environ['AZURE_SUBSCRIPTION_ID']) + + for f in findings: + missing = [k for k in REQUIRED_KEYS if k not in f] + if missing: + print(f'FAIL — missing keys: {missing}') + else: + print(f'PASS — {f[\"resource_name\"]} has all required keys') + print(f' frameworks: {f[\"frameworks\"]}') + print(f' severity: {f[\"severity\"]}') + " + +**Expected result:** +- No missing keys reported +- severity = MEDIUM +- frameworks dict contains CIS, NIST, ISO27001 keys + +**Pass criteria:** All required keys present in every finding. + +--- + +### TC-004 — Full scan engine picks up the rule + +**Purpose:** Confirm the rule loads automatically when the engine runs — +no manual registration needed. + +**Steps:** + + python -c " + from dotenv import load_dotenv; load_dotenv() + import json, os + from scanner.engine import ScanEngine + + engine = ScanEngine(os.environ['AZURE_SUBSCRIPTION_ID']) + rule_ids = [getattr(r, 'RULE_ID', 'UNKNOWN') for r in engine.rules] + print('Loaded rules:', rule_ids) + print('AZ-STOR-003 loaded:', 'AZ-STOR-003' in rule_ids) + " + +**Expected result:** +- AZ-STOR-003 appears in the loaded rules list. + +**Pass criteria:** 'AZ-STOR-003 loaded: True' in output. + +--- + +### TC-005 — Playbook prints usage when called with no arguments + +**Purpose:** Confirm the script does not crash silently and has clear usage. + +**Steps:** + + bash playbooks/cli/fix_az_stor_003.sh + +**Expected result:** +- Prints usage instructions +- Exits with a non-zero exit code (1) +- Does NOT make any changes to Azure + +**Pass criteria:** Usage text displayed, script exits cleanly. + +--- + +### TC-006 — Playbook remediates the non-compliant account + +**Purpose:** Confirm the playbook successfully creates a lifecycle policy. + +**Pre-condition:** oshieldtestnopolicy has no lifecycle policy. + +**Steps:** + + bash playbooks/cli/fix_az_stor_003.sh \ + openshield-test-rg \ + oshieldtestnopolicy \ + 365 + + # Verify the policy was created + az storage account management-policy show \ + --account-name oshieldtestnopolicy \ + --resource-group openshield-test-rg + +**Expected result:** +- Script prints confirmation message +- az management-policy show returns a JSON policy object +- Policy contains a rule named openshield-lifecycle-rule +- Policy shows tierToCool at 30 days, tierToArchive at 90 days, + delete at 365 days + +**Pass criteria:** Policy visible in Azure portal and via CLI show command. + +--- + +### TC-007 — Rule returns zero findings after remediation + +**Purpose:** Full end-to-end — confirm the rule clears after the fix is applied. + +**Pre-condition:** TC-006 has run successfully (oshieldtestnopolicy now has a policy). + +**Steps:** Re-run TC-001 script. + +**Expected result:** +- oshieldtestnopolicy no longer appears in findings. + +**Pass criteria:** Previously flagged account no longer in findings list. + +--- + +### TC-008 — Script handles non-existent account gracefully + +**Purpose:** Confirm the script fails cleanly when given a valid-format name +that does not exist in Azure — the failure comes from the Azure CLI, not +from our validation. + +**Steps:** + + bash playbooks/cli/fix_az_stor_003.sh \ + openshield-test-rg \ + oshieldaccountxyz999 \ + 365 + + # When prompted, enter "y" to proceed past the confirmation. + +**Expected result:** +- Passes all input validation (name format is valid) +- Azure CLI returns a ResourceNotFound error +- Script exits with a non-zero exit code from set -euo pipefail +- Error from Azure CLI is visible in output + +**Pass criteria:** Script exits with Azure error, does not silently continue. + +--- + +### TC-009 — Playbook rejects invalid days-to-delete value + +**Purpose:** Confirm integer validation works — prevents broken JSON policy. + +**Steps:** + + bash playbooks/cli/fix_az_stor_003.sh \ + openshield-test-rg \ + oshieldtestnopolicy \ + "not-a-number" + +**Expected result:** +- Prints: `ERROR: days-to-delete must be a positive integer` +- Exits with code 1 +- Makes no changes to Azure + +**Pass criteria:** Error message displayed, exit code 1. + +--- + +### TC-010 — Playbook rejects shell-unsafe characters in arguments + +**Purpose:** Confirm input sanitisation prevents shell injection. + +**Steps:** + + bash playbooks/cli/fix_az_stor_003.sh \ + "my-rg; echo INJECTED" \ + oshieldtestnopolicy + +**Expected result:** +- Prints: `ERROR: resource-group contains invalid characters` +- Exits with code 1 +- The string "INJECTED" does NOT appear in output + +**Pass criteria:** Error shown, no command injection executed. + +--- + +### TC-011 — Playbook enables last access tracking before applying policy + +**Purpose:** Confirm the prerequisite step runs before the policy is created. +Without last access tracking enabled, `daysAfterLastAccessTimeGreaterThan` +policies are accepted by Azure but never fire — a silent failure. + +**Steps:** + + # Confirm tracking is OFF before the test + az storage account blob-service-properties show \ + --account-name oshieldtestnopolicy \ + --resource-group openshield-test-rg \ + --query "lastAccessTimeTrackingPolicy.enable" + # Should return: null or false + + # Run the playbook (enter "y" when prompted) + bash playbooks/cli/fix_az_stor_003.sh \ + openshield-test-rg \ + oshieldtestnopolicy \ + 365 + + # Confirm tracking is now ON + az storage account blob-service-properties show \ + --account-name oshieldtestnopolicy \ + --resource-group openshield-test-rg \ + --query "lastAccessTimeTrackingPolicy.enable" + # Must return: true + +**Expected result:** +- Before playbook: tracking disabled or null +- After playbook: tracking enabled = true +- Policy also present (verify with management-policy show) + +**Pass criteria:** `lastAccessTimeTrackingPolicy.enable` is `true` after the +playbook runs. + +--- + +## 5. Cleanup + +After all tests pass, delete the test resources to avoid charges: + + az group delete --name openshield-test-rg --yes --no-wait + +--- + +## 6. Pass / Fail Summary Table + +| Test Case | Description | Expected | Status | +|---|---|---|---| +| TC-001 | Rule detects non-compliant account | Finding returned | [ ] | +| TC-002 | Rule ignores compliant account | No finding | [ ] | +| TC-003 | Finding dict structure | All required keys present | [ ] | +| TC-004 | Engine loads rule automatically | AZ-STOR-003 in loaded list | [ ] | +| TC-005 | Playbook prints usage on no args | Usage text + exit 1 | [ ] | +| TC-006 | Playbook creates lifecycle policy | Policy visible in Azure | [ ] | +| TC-007 | Rule clears after remediation | Zero findings post-fix | [ ] | +| TC-008 | Script handles non-existent account | Exits with Azure error | [ ] | +| TC-009 | Playbook rejects non-integer days | Error + exit 1 | [ ] | +| TC-010 | Playbook rejects unsafe characters | Error, no injection | [ ] | +| TC-011 | Playbook enables last access tracking | Tracking = true after run | [ ] | + +All 11 test cases must pass before opening the PR. diff --git a/playbooks/cli/fix_az_stor_003.sh b/playbooks/cli/fix_az_stor_003.sh new file mode 100755 index 0000000..7231d7b --- /dev/null +++ b/playbooks/cli/fix_az_stor_003.sh @@ -0,0 +1,195 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-STOR-003 — Storage Account Has No Lifecycle Management Policy +# Usage: ./fix_az_stor_003.sh [days-to-delete] +# Severity: MEDIUM +# +# What this script does: +# 1. Enables last access time tracking on the storage account (required +# prerequisite for daysAfterLastAccessTimeGreaterThan policies). +# 2. Creates a lifecycle management policy with three tiers: +# - Move to Cool tier after 30 days of no access +# - Move to Archive tier after 90 days of no access +# - Delete blobs after days (default 365) +# 3. The same delete rule applies to blob snapshots. +# +# Prerequisites: +# - Azure CLI installed and logged in (az login) +# - Contributor or Storage Account Contributor role on the target account +# - The storage account must use StorageV2 or BlobStorage kind for lifecycle +# management. Classic and premium accounts are not supported. +# +# Example: +# ./fix_az_stor_003.sh my-resource-group my-storage-account 365 + +set -euo pipefail + +RESOURCE_GROUP="${1:-}" +STORAGE_ACCOUNT="${2:-}" +DAYS_TO_DELETE="${3:-365}" + +# ── Argument validation ────────────────────────────────────────────────────── + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$STORAGE_ACCOUNT" ]; then + echo "Usage: $0 [days-to-delete]" + echo "" + echo "Arguments:" + echo " resource-group Name of the Azure resource group" + echo " storage-account-name Name of the storage account to remediate" + echo " days-to-delete Days before blobs are permanently deleted (default: 365)" + echo "" + echo "Example:" + echo " $0 my-resource-group my-storage-account 365" + exit 1 +fi + +# ── Validate days-to-delete is a positive integer ──────────────────────────── + +if ! [[ "$DAYS_TO_DELETE" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: days-to-delete must be a positive integer (got: '$DAYS_TO_DELETE')" + exit 1 +fi + +# ── Validate names contain only Azure-safe characters ─────────────────────── +# Resource group: letters, numbers, hyphens, underscores, dots, parentheses +# Storage account: lowercase letters and numbers only (Azure naming constraint) + +if ! [[ "$RESOURCE_GROUP" =~ ^[a-zA-Z0-9._()-]+$ ]]; then + echo "ERROR: resource-group contains invalid characters: '$RESOURCE_GROUP'" + exit 1 +fi + +if ! [[ "$STORAGE_ACCOUNT" =~ ^[a-z0-9]{3,24}$ ]]; then + echo "ERROR: storage-account-name must be 3-24 lowercase letters and numbers only." + exit 1 +fi + +# ── Validate DAYS_TO_DELETE range is sane ──────────────────────────────────── +# Azure requires tier transition <= delete threshold. Archive at 90 < delete. + +if [ "$DAYS_TO_DELETE" -lt 91 ]; then + echo "ERROR: days-to-delete must be at least 91 (must exceed the Archive tier at 90 days)" + exit 1 +fi + +# ── Secure temp file with guaranteed cleanup on exit or error ───────────────── + +POLICY_FILE=$(mktemp) +chmod 600 "$POLICY_FILE" + +cleanup() { + rm -f "$POLICY_FILE" +} +trap cleanup EXIT + +# ── Confirm before making changes ──────────────────────────────────────────── + +echo "============================================================" +echo " OpenShield Remediation — AZ-STOR-003" +echo "============================================================" +echo "" +echo " Storage account : $STORAGE_ACCOUNT" +echo " Resource group : $RESOURCE_GROUP" +echo " Delete after : $DAYS_TO_DELETE days" +echo "" +echo " Steps:" +echo " 1. Enable last access time tracking (required prerequisite)" +echo " 2. Create lifecycle policy with three tiers:" +echo " - Move to Cool tier after 30 days of no access" +echo " - Move to Archive after 90 days of no access" +echo " - Delete permanently after $DAYS_TO_DELETE days of no access" +echo "" +echo " NOTE: This requires StorageV2 or BlobStorage account kind." +echo " Premium and Classic accounts do not support lifecycle management." +echo "" +read -r -p "Proceed? [y/N] " CONFIRM +if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Aborted. No changes were made." + exit 0 +fi + +# ── Step 1: Enable last access time tracking ───────────────────────────────── +# REQUIRED before daysAfterLastAccessTimeGreaterThan can be used in a policy. +# Without this, the Azure API accepts the policy JSON but the tier transitions +# never fire — the account stays non-compliant silently. + +echo "" +echo "[1/2] Enabling last access time tracking on: $STORAGE_ACCOUNT ..." + +az storage account blob-service-properties update \ + --account-name "$STORAGE_ACCOUNT" \ + --resource-group "$RESOURCE_GROUP" \ + --enable-last-access-tracking true + +echo " Last access tracking enabled." + +# ── Step 2: Write and apply the lifecycle policy ────────────────────────────── +# DAYS_TO_DELETE is validated as a positive integer >= 91 above. +# All variable expansions inside the heredoc are safe. + +echo "" +echo "[2/2] Applying lifecycle management policy to: $STORAGE_ACCOUNT ..." + +cat > "$POLICY_FILE" << EOF +{ + "rules": [ + { + "name": "openshield-lifecycle-rule", + "enabled": true, + "type": "Lifecycle", + "definition": { + "filters": { + "blobTypes": ["blockBlob"] + }, + "actions": { + "baseBlob": { + "tierToCool": { + "daysAfterLastAccessTimeGreaterThan": 30 + }, + "tierToArchive": { + "daysAfterLastAccessTimeGreaterThan": 90 + }, + "delete": { + "daysAfterLastAccessTimeGreaterThan": ${DAYS_TO_DELETE} + } + }, + "snapshot": { + "delete": { + "daysAfterCreationGreaterThan": ${DAYS_TO_DELETE} + } + } + } + } + } + ] +} +EOF + +az storage account management-policy create \ + --account-name "$STORAGE_ACCOUNT" \ + --resource-group "$RESOURCE_GROUP" \ + --policy "@${POLICY_FILE}" + +# Temp file removed automatically by trap on EXIT. + +# ── Confirmation ───────────────────────────────────────────────────────────── + +echo "" +echo "============================================================" +echo " Remediation complete for: $STORAGE_ACCOUNT" +echo "============================================================" +echo "" +echo " Applied:" +echo " Last access time tracking : enabled" +echo " Move to Cool after 30 days of no access" +echo " Move to Archive after 90 days of no access" +echo " Delete after $DAYS_TO_DELETE days of no access" +echo "" +echo " To verify the policy was applied:" +echo " az storage account management-policy show \\" +echo " --account-name $STORAGE_ACCOUNT \\" +echo " --resource-group $RESOURCE_GROUP" +echo "" +echo " NOTE: Adjust tier thresholds and delete day to match your" +echo " organisation's data retention and compliance policy." +echo "============================================================" diff --git a/scanner/azure_client.py b/scanner/azure_client.py index e7381b5..bf3e335 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -3,6 +3,7 @@ import logging from typing import Any, Dict, List, Optional +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError from azure.identity import DefaultAzureCredential from azure.mgmt.authorization import AuthorizationManagementClient from azure.mgmt.compute import ComputeManagementClient @@ -59,6 +60,74 @@ def get_storage_accounts(self) -> List[Any]: logger.error("get_storage_accounts failed: %s", exc) return [] + def get_storage_lifecycle_policy( + self, resource_group: str, account_name: str + ) -> Optional[bool]: + """Check whether a storage account has a lifecycle management policy. + + Three-state return — the calling rule uses strict identity checks + (is False / is None) to distinguish these states: + + True — policy exists and contains at least one enabled rule. + False — ResourceNotFoundError: no policy configured (non-compliant). + None — any other error (permissions, network, SDK bug). + Caller must NOT create a finding — skip with a warning + to avoid false positives. + + The StorageManagementClient is created fresh here following the same + pattern as every other method in AzureClient (one client per call). + The credential is reused from self.credential so no new auth round- + trip occurs. + + Args: + resource_group: Resource group containing the storage account. + account_name: Name of the storage account. + + Returns: + Optional[bool] — True, False, or None as described above. + """ + try: + client = StorageManagementClient(self.credential, self.subscription_id) + policy = client.management_policies.get( + resource_group, account_name, "default" + ) + # A policy shell can exist with an empty rules list — + # treat that the same as no policy (non-compliant). + rules = getattr(getattr(policy, "policy", None), "rules", None) + return bool(rules) + + except ResourceNotFoundError: + # Expected path: the account genuinely has no lifecycle policy. + # This is the non-compliant condition — return False to flag it. + logger.debug( + "get_storage_lifecycle_policy(%s): ResourceNotFound — no policy", + account_name, + ) + return False + + except HttpResponseError as exc: + # 403 = service principal lacks + # Microsoft.Storage/storageAccounts/managementPolicies/read. + # Return None — cannot determine compliance, do not flag. + logger.error( + "get_storage_lifecycle_policy(%s) HTTP %s — " + "check service principal permissions: %s", + account_name, + exc.status_code, + exc, + ) + return None + + except Exception as exc: + # Unexpected failure (network, SDK bug, etc.). + # Return None — skip rather than create a false positive. + logger.error( + "get_storage_lifecycle_policy(%s) unexpected error: %s", + account_name, + exc, + ) + return None + # ------------------------------------------------------------------ # # Network # # ------------------------------------------------------------------ # @@ -185,4 +254,4 @@ def get_conditional_access_policies(self) -> List[Any]: return response.json().get("value", []) except Exception as exc: logger.error("get_conditional_access_policies failed: %s", exc) - return [] + return [] \ No newline at end of file diff --git a/scanner/rules/az_stor_003.py b/scanner/rules/az_stor_003.py new file mode 100644 index 0000000..7758b45 --- /dev/null +++ b/scanner/rules/az_stor_003.py @@ -0,0 +1,117 @@ +"""AZ-STOR-003: Storage account has no lifecycle management policy configured.""" + +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# subscription_id is received by scan() and passed to AzureClient methods +# that need explicit scope. It is not read from the environment here — +# the engine always passes it as a parameter. Never read os.environ directly. + +# ── Required module-level constants ───────────────────────────────────────── + +RULE_ID = "AZ-STOR-003" +RULE_NAME = "Storage Account Has No Lifecycle Management Policy" +SEVERITY = "MEDIUM" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "3.7", + "NIST": "PR.DS-3", + "ISO27001": "A.8.3.1", +} +DESCRIPTION = ( + "The storage account has no lifecycle management policy configured. " + "Without a lifecycle policy, blobs accumulate indefinitely — old data " + "that is no longer needed remains accessible, increasing storage costs " + "and the attack surface. A compromised account exposes all historical " + "data with no automatic expiry or tiering in place." +) +REMEDIATION = ( + "Create a lifecycle management policy on the storage account that " + "transitions blobs to cooler tiers (Cool, Archive) after a defined " + "number of days, and deletes blobs that exceed the organisation's " + "maximum retention period. Navigate to: Storage Account > " + "Data management > Lifecycle management > Add a rule." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_003.sh" + + +# ── Required scan function ─────────────────────────────────────────────────── + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect storage accounts with no lifecycle management policy. + + The Azure Storage Management SDK exposes lifecycle policies via + ``management_policies.get(resource_group, account_name)``. + A ResourceNotFound (404) response means no policy exists — this is + the condition we flag as MEDIUM severity. + + Three-state return from get_storage_lifecycle_policy(): + True — policy exists and has rules → skip (compliant) + False — no policy exists → create finding + None — permissions error or unexpected failure → skip with warning + to avoid false positives + + Args: + azure_client: An AzureClient instance with all SDK clients + pre-configured. + subscription_id: The Azure subscription ID being scanned. + + Returns: + A list of finding dicts — one per storage account that has no + lifecycle policy. Accounts that could not be checked are skipped + and logged as warnings. + """ + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + resource_id = getattr(account, "id", "") + account_name = getattr(account, "name", "") + location = getattr(account, "location", "") + + if not resource_id or not account_name: + continue + + parsed = azure_client.parse_resource_id(resource_id) + resource_group = parsed.get("resource_group", "") + if not resource_group: + continue + + # True = compliant, False = no policy, None = could not determine + policy_status: Optional[bool] = azure_client.get_storage_lifecycle_policy( + resource_group, account_name + ) + + if policy_status is None: + # Permissions error or unexpected SDK failure. + # Skip rather than flag — never create false positives. + logger.warning( + "AZ-STOR-003: Could not determine lifecycle policy for %s " + "— skipping. Ensure the service principal has " + "Microsoft.Storage/storageAccounts/managementPolicies/read " + "permission.", + account_name, + ) + continue + + if policy_status is False: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": account_name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": resource_group, + "location": location, + }, + }) + + return findings \ No newline at end of file From 1e7a81fffb89a928c1c6ce476794ed53be772730 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Fri, 8 May 2026 11:37:29 +0100 Subject: [PATCH 13/50] docs: add SOC 2 Type II compliance framework mapping (#33) * docs: add SOC 2 Type II compliance framework mapping for all 20 rules Added SOC 2 Type II framework with detailed controls for security measures and compliance requirements. * feat: add soc2 to FRAMEWORK_FILE_MAP in finding.py add soc2.json to FRAMEWORK_FILE_MAP in finding.py * feat: add soc2 to SUPPORTED_FRAMEWORKS in compliance.py Added 'soc2' to the list of supported compliance frameworks. * Add SOC 2 controls for data protection and management --- api/models/finding.py | 1 + api/routes/compliance.py | 4 +- compliance/frameworks/soc2.json | 107 ++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 compliance/frameworks/soc2.json diff --git a/api/models/finding.py b/api/models/finding.py index 90b8662..8cdab3f 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -20,6 +20,7 @@ "cis": "cis_azure_benchmark.json", "nist": "nist_csf.json", "iso27001": "iso27001.json", + "soc2": "soc2.json", } diff --git a/api/routes/compliance.py b/api/routes/compliance.py index e3b68a2..6a3b104 100644 --- a/api/routes/compliance.py +++ b/api/routes/compliance.py @@ -7,7 +7,7 @@ compliance_bp = Blueprint("compliance", __name__) -SUPPORTED_FRAMEWORKS = ("cis", "nist", "iso27001") +SUPPORTED_FRAMEWORKS = ("cis", "nist", "iso27001", "soc2") def _get_db() -> DatabaseManager: @@ -20,7 +20,7 @@ def _get_db() -> DatabaseManager: def get_compliance(framework: str): """Return pass/fail compliance breakdown for a framework. - Supported frameworks: cis, nist, iso27001 + Supported frameworks: cis, nist, iso27001, soc2 Returns control-level pass/fail status mapped to current open findings. """ diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json new file mode 100644 index 0000000..7de2257 --- /dev/null +++ b/compliance/frameworks/soc2.json @@ -0,0 +1,107 @@ +{ + "framework": "SOC 2 Type II", + "version": "2017", + "published": "2017-04", + "controls": { + "AZ-STOR-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access to Information Assets", + "description": "Public blob access allows unauthenticated users from outside the network boundary to read storage data without credentials. CC6.6 requires that access from outside the network perimeter is restricted and controlled. Disabling public access enforces this boundary by requiring authentication for all storage operations." + }, + "AZ-STOR-002": { + "control_id": "CC6.7", + "control_name": "Protects Data in Transit", + "description": "Allowing unencrypted HTTP traffic to a storage account exposes data in transit to interception and tampering. CC6.7 requires that data transmitted over networks is protected using encryption. Enforcing HTTPS-only ensures all storage traffic is encrypted in transit." + }, + "AZ-STOR-003": { + "control_id": "CC8.1", + "control_name": "Change Management", + "description": "A storage account with no lifecycle management policy allows data to accumulate indefinitely with no automatic expiry or tiering. CC8.1 requires that infrastructure and data are managed through formal processes. Implementing a lifecycle policy ensures data retention is controlled and old data is automatically moved or deleted according to organisational policy." + }, + "AZ-NET-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An NSG allowing unrestricted RDP access from the internet permits any external party to attempt remote access to virtual machines. CC6.6 requires that logical access from outside the network boundary is restricted. Limiting RDP to known IP ranges enforces this boundary and eliminates unauthorised remote access attempts." + }, + "AZ-NET-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An NSG allowing unrestricted SSH access from the internet exposes virtual machines to brute force and credential attacks from any external party. CC6.6 requires that access from outside the network perimeter is restricted and controlled. Restricting SSH to known IP ranges or removing it in favour of Azure Bastion enforces this boundary." + }, + "AZ-NET-003": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An NSG permitting unrestricted inbound access on port 443 from the internet exposes web services to automated scanning and exploitation attempts from any external source. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Public-facing services should be fronted by a WAF-enabled Application Gateway rather than exposed directly." + }, + "AZ-NET-004": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A Network Security Group with no custom rules provides no meaningful boundary control and relies entirely on Azure defaults. CC6.6 requires that logical access from outside the network perimeter is explicitly restricted. Explicit least-privilege rules must be defined to enforce the network boundary." + }, + "AZ-NET-005": { + "control_id": "A1.1", + "control_name": "Capacity and Performance Monitoring", + "description": "Virtual networks without DDoS Protection Standard are vulnerable to volumetric attacks that can exhaust capacity and cause service outages. A1.1 requires that current processing capacity is monitored and resources are available to meet objectives. DDoS Protection Standard ensures network availability is maintained under attack conditions." + }, + "AZ-NET-006": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "Unassociated public IP addresses represent unnecessary exposure on the internet and may indicate leftover resources from decommissioned workloads. CC6.6 requires that the network boundary is tightly controlled with only necessary resources exposed. Removing unassociated public IPs reduces the external attack surface." + }, + "AZ-NET-007": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An Application Gateway without WAF enabled provides no protection against web application attacks from external sources including OWASP Top 10 vulnerabilities. CC6.6 requires that access from outside the network boundary is controlled and filtered. WAF in Prevention mode enforces application-layer boundary protection for public-facing services." + }, + "AZ-NET-008": { + "control_id": "CC8.1", + "control_name": "Change Management", + "description": "A load balancer with no backend pool configured is either misconfigured or a leftover resource from a decommissioned workload that was not properly cleaned up. CC8.1 requires that infrastructure changes are managed, tracked and that unused resources are removed through a formal process. Removing empty load balancers maintains an accurate and controlled infrastructure state." + }, + "AZ-NET-009": { + "control_id": "CC6.7", + "control_name": "Protects Data in Transit", + "description": "VPN gateway connections using IKEv1 use an outdated protocol with known vulnerabilities that weaken the confidentiality and integrity of data transmitted between networks. CC6.7 requires that data transmitted over networks is protected using current secure protocols. Migrating to IKEv2 ensures VPN traffic is protected with a modern and secure key exchange mechanism." + }, + "AZ-NET-010": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A subnet without an NSG attached has no network layer access controls leaving all resources in that subnet reachable from other subnets or the internet with no filtering. CC6.6 requires that logical access from outside the network boundary is restricted. Attaching an NSG with explicit rules enforces boundary protection at the subnet level." + }, + "AZ-IDN-001": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "A service principal with Contributor role at subscription scope has unrestricted ability to create, modify and delete any resource in the environment. CC6.1 requires that logical access to information assets is restricted to authorised users and service accounts with least-privilege permissions. Scoping role assignments to the minimum required resource enforces this control." + }, + "AZ-IDN-002": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "Without MFA enforced on privileged accounts, a single compromised password grants full administrative access to the Azure environment. CC6.1 requires that logical access controls include strong authentication mechanisms. Enforcing MFA via Conditional Access policies ensures privileged access requires multiple factors of authentication." + }, + "AZ-DB-001": { + "control_id": "CC6.7", + "control_name": "Protects Data in Transit", + "description": "SQL Server without Transparent Data Encryption stores database files in plain text on disk. CC6.7 requires that data is protected using encryption both in transit and at rest. Enabling TDE ensures database files, backups and transaction logs are encrypted and unreadable without the encryption key." + }, + "AZ-DB-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A SQL Server firewall rule allowing all IP addresses makes the database reachable from anywhere on the internet. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Locking the firewall to specific application IP ranges ensures only authorised systems can connect to the database." + }, + "AZ-CMP-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A virtual machine with a public IP and no NSG has unrestricted inbound network access from the internet with no filtering in place. CC6.6 requires that logical access from outside the network perimeter is restricted and controlled. Attaching an NSG with explicit rules enforces the network boundary and controls what traffic can reach the VM." + }, + "AZ-KV-001": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "Key Vault without soft delete enabled allows permanent deletion of secrets, keys and certificates with no recovery possible. A1.2 requires that environmental threats to availability are identified and mitigated including protection against accidental or malicious data loss. Enabling soft delete ensures deleted vault objects can be recovered within the retention period." + }, + "AZ-KV-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A Key Vault accessible from the public internet allows any external party to attempt access to secrets, keys and certificates. CC6.6 requires that access from outside the network boundary is restricted and controlled. Locking Key Vault access to private endpoints or specific VNet service endpoints enforces this boundary and protects sensitive credentials from external exposure." + } + } +} From f409b67d4e12566683ea62d67ec9c00254d2e481 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 9 May 2026 14:53:04 +0100 Subject: [PATCH 14/50] Refactor/azure client network methods (#22) * refactor: add get_virtual_networks() and get_public_ip_addresses() to AzureClient * Refactor DDoS protection check to use azure_client * refactor: AZ-NET-006 now uses azure_client.get_public_ip_addresses() --- scanner/azure_client.py | 18 ++++++++++++++++++ scanner/rules/az_net_005.py | 15 +-------------- scanner/rules/az_net_006.py | 15 +-------------- 3 files changed, 20 insertions(+), 28 deletions(-) diff --git a/scanner/azure_client.py b/scanner/azure_client.py index bf3e335..e68c06c 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -152,6 +152,24 @@ def get_network_interface( logger.error("get_network_interface(%s) failed: %s", nic_name, exc) return None + def get_virtual_networks(self) -> List[Any]: + """List all virtual networks in the subscription.""" + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + return list(client.virtual_networks.list_all()) + except Exception as exc: + logger.error("get_virtual_networks failed: %s", exc) + return [] + + def get_public_ip_addresses(self) -> List[Any]: + """List all public IP addresses in the subscription.""" + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + return list(client.public_ip_addresses.list_all()) + except Exception as exc: + logger.error("get_public_ip_addresses failed: %s", exc) + return [] + # ------------------------------------------------------------------ # # Compute # # ------------------------------------------------------------------ # diff --git a/scanner/rules/az_net_005.py b/scanner/rules/az_net_005.py index 9f6702c..48f90be 100644 --- a/scanner/rules/az_net_005.py +++ b/scanner/rules/az_net_005.py @@ -27,20 +27,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Detect virtual networks without DDoS protection enabled.""" findings: List[Dict[str, Any]] = [] - try: - # NOTE: This rule creates a NetworkManagementClient directly rather than - # going through azure_client. A get_virtual_networks() method should be - # added to AzureClient in a follow-up PR for consistency. - from azure.mgmt.network import NetworkManagementClient - client = NetworkManagementClient( - azure_client.credential, azure_client.subscription_id - ) - vnets = list(client.virtual_networks.list_all()) - except Exception as exc: - logger.error("Failed to list virtual networks: %s", exc) - return findings - - for vnet in vnets: + for vnet in azure_client.get_virtual_networks(): ddos = getattr(vnet, "ddos_protection_plan", None) enable_ddos = getattr(vnet, "enable_ddos_protection", False) if not ddos and not enable_ddos: diff --git a/scanner/rules/az_net_006.py b/scanner/rules/az_net_006.py index 180f49b..26923d7 100644 --- a/scanner/rules/az_net_006.py +++ b/scanner/rules/az_net_006.py @@ -28,20 +28,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Detect public IP addresses not associated with any resource.""" findings: List[Dict[str, Any]] = [] - try: - # NOTE: This rule creates a NetworkManagementClient directly rather than - # going through azure_client. A get_public_ip_addresses() method should be - # added to AzureClient in a follow-up PR for consistency. - from azure.mgmt.network import NetworkManagementClient - client = NetworkManagementClient( - azure_client.credential, azure_client.subscription_id - ) - public_ips = list(client.public_ip_addresses.list_all()) - except Exception as exc: - logger.error("Failed to list public IP addresses: %s", exc) - return findings - - for pip in public_ips: + for pip in azure_client.get_public_ip_addresses(): ip_config = getattr(pip, "ip_configuration", None) nat_gateway = getattr(pip, "nat_gateway", None) if not ip_config and not nat_gateway: From bb477796ab3340a3a0d8bae3a008a7106dfecc57 Mon Sep 17 00:00:00 2001 From: Ritik Sah Date: Sat, 9 May 2026 15:15:14 +0100 Subject: [PATCH 15/50] feat: add CI pipeline with 6 automated checks (#34) - Python syntax check on all rule files - Rule structure validation (RULE_ID, SEVERITY, FRAMEWORKS) + RULE_ID uniqueness - Hardcoded credential scan - Playbook existence + bash syntax check for every rule - Compliance JSON validation for all four framework files (inc. soc2.json) - API syntax check - Compliance vs rule cross-reference check - CI summary step with per-check pass/fail table (if: always) - Fix duplicate DESCRIPTION assignment in az_net_003.py - Add pyyaml to requirements.txt for local YAML validation - Add docs/ci-pipeline.md with local run commands and design rationale - Update CI_PIPELINE_GUIDE.md with final PR description Closes #30 --- .github/workflows/ci.yml | 370 ++++++++++++++++++++++++++++++++++++ docs/ci-pipeline.md | 357 ++++++++++++++++++++++++++++++++++ requirements.txt | 1 + scanner/rules/az_net_003.py | 2 - 4 files changed, 728 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/ci-pipeline.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..95f5510 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,370 @@ +name: OpenShield CI + +on: + pull_request: + branches: + - dev + - main + +jobs: + ci-checks: + name: Run All CI Checks + runs-on: ubuntu-latest + + steps: + # ── 1. Checkout the code ───────────────────────────────────────── + - name: Checkout repository + uses: actions/checkout@v4 + + # ── 2. Set up Python ───────────────────────────────────────────── + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # ── 3. Install dependencies ─────────────────────────────────────── + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + # ── CHECK 1: Python syntax on all rule files ─────────────────────── + - name: Python syntax check (rule files) + id: syntax_check + run: | + echo "=== Checking Python syntax on scanner/rules/ ===" + FAIL=0 + for f in scanner/rules/az_*.py; do + if ! python -m py_compile "$f" 2>&1; then + echo "SYNTAX ERROR: $f" + FAIL=1 + else + echo "OK: $f" + fi + done + if [ "$FAIL" -eq 1 ]; then + echo "One or more rule files have syntax errors." + exit 1 + fi + + # ── CHECK 2: Rule structure validation + RULE_ID uniqueness ────── + - name: Rule structure validation + id: structure_check + run: | + echo "=== Validating rule file structure ===" + python - <<'PYEOF' + import os + import importlib.util + import sys + from collections import defaultdict + + rules_dir = "scanner/rules" + required_fields = ["RULE_ID", "SEVERITY", "FRAMEWORKS"] + valid_severities = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"} + failures = [] + seen_ids = defaultdict(list) + + for filename in sorted(os.listdir(rules_dir)): + if not filename.startswith("az_") or not filename.endswith(".py"): + continue + + filepath = os.path.join(rules_dir, filename) + spec = importlib.util.spec_from_file_location("rule", filepath) + mod = importlib.util.module_from_spec(spec) + + try: + spec.loader.exec_module(mod) + except Exception as e: + failures.append(f"{filename}: import error — {e}") + continue + + for field in required_fields: + if not hasattr(mod, field): + failures.append(f"{filename}: missing field '{field}'") + + if hasattr(mod, "SEVERITY"): + if mod.SEVERITY not in valid_severities: + failures.append( + f"{filename}: SEVERITY '{mod.SEVERITY}' not in {valid_severities}" + ) + + if hasattr(mod, "FRAMEWORKS"): + if not isinstance(mod.FRAMEWORKS, dict) or len(mod.FRAMEWORKS) == 0: + failures.append(f"{filename}: FRAMEWORKS must be a non-empty dict") + + if hasattr(mod, "RULE_ID"): + seen_ids[mod.RULE_ID].append(filename) + + # Two files sharing a RULE_ID silently corrupt scan reports + for rule_id, files in seen_ids.items(): + if len(files) > 1: + failures.append( + f"DUPLICATE RULE_ID '{rule_id}' in: {', '.join(files)}" + ) + + if failures: + print("RULE STRUCTURE FAILURES:") + for f in failures: + print(f" - {f}") + sys.exit(1) + else: + print(f"All {len(seen_ids)} rule files passed structure validation.") + PYEOF + + # ── CHECK 3: Hardcoded credential scan ──────────────────────────── + - name: Hardcoded credential scan + id: cred_scan + run: | + echo "=== Scanning for hardcoded credentials ===" + PATTERNS=( + "password\s*=" + "secret\s*=" + "api_key\s*=" + "client_secret\s*=" + "AZURE_CLIENT_SECRET\s*=\s*['\"][^'\"]\+" + "-----BEGIN.*PRIVATE KEY-----" + "AccountKey=" + ) + + FAIL=0 + for pattern in "${PATTERNS[@]}"; do + matches=$(grep -rniE "$pattern" \ + --include="*.py" --include="*.sh" --include="*.json" --include="*.yml" \ + --exclude-dir=".git" \ + --exclude-dir="venv" \ + --exclude="ci.yml" \ + . 2>/dev/null | \ + grep -v "\.env" | \ + grep -v "os\.environ" | \ + grep -v "os\.getenv" | \ + grep -v "#" | \ + grep -v "example" | \ + grep -v "placeholder" || true) + + if [ -n "$matches" ]; then + echo "POTENTIAL CREDENTIAL LEAK — pattern '$pattern':" + echo "$matches" + FAIL=1 + fi + done + + if [ "$FAIL" -eq 1 ]; then + echo "Hardcoded credentials detected. Remove them and use environment variables." + exit 1 + else + echo "No hardcoded credentials found." + fi + + # ── CHECK 4: Playbook existence + bash syntax ───────────────────── + - name: Playbook existence and syntax check + id: playbook_check + run: | + echo "=== Checking playbooks exist and are valid bash ===" + FAIL=0 + for rule_file in scanner/rules/az_*.py; do + filename=$(basename "$rule_file" .py) + playbook="playbooks/cli/fix_${filename}.sh" + + if [ ! -f "$playbook" ]; then + echo "MISSING PLAYBOOK: $playbook (required for $rule_file)" + FAIL=1 + elif ! bash -n "$playbook" 2>&1; then + echo "BASH SYNTAX ERROR: $playbook" + FAIL=1 + else + echo "OK: $playbook" + fi + done + + if [ "$FAIL" -eq 1 ]; then + echo "Fix the missing or broken playbook(s) before this PR can merge." + exit 1 + fi + + # ── CHECK 5: Compliance JSON validation ─────────────────────────── + - name: Compliance JSON validation + id: json_check + run: | + echo "=== Validating compliance framework JSON files ===" + python - <<'PYEOF' + import json + import sys + import os + + framework_dir = "compliance/frameworks" + expected_files = [ + "cis_azure_benchmark.json", + "nist_csf.json", + "iso27001.json", + "soc2.json", + ] + failures = [] + + for fname in expected_files: + fpath = os.path.join(framework_dir, fname) + + if not os.path.exists(fpath): + failures.append(f"MISSING FILE: {fpath}") + continue + + try: + with open(fpath) as f: + data = json.load(f) + + if not isinstance(data, dict) or len(data) == 0: + failures.append(f"{fname}: must be a non-empty JSON object") + continue + + n_controls = len(data.get("controls", {})) + print(f"OK: {fname} ({n_controls} controls)") + + except json.JSONDecodeError as e: + failures.append(f"{fname}: invalid JSON — {e}") + + if failures: + print("COMPLIANCE JSON FAILURES:") + for f in failures: + print(f" - {f}") + sys.exit(1) + PYEOF + + # ── CHECK 6: API syntax check ────────────────────────────────────── + - name: API syntax check + id: api_check + run: | + echo "=== Checking Python syntax on API files ===" + FAIL=0 + if [ -d "api" ]; then + while IFS= read -r -d '' f; do + if ! python -m py_compile "$f" 2>&1; then + echo "SYNTAX ERROR: $f" + FAIL=1 + else + echo "OK: $f" + fi + done < <(find api/ -name "*.py" -print0) + else + echo "No api/ directory found — skipping" + fi + + if [ "$FAIL" -eq 1 ]; then + echo "One or more API files have syntax errors." + exit 1 + fi + + # ── CHECK 7: Compliance JSON ↔ rule file cross-reference ────────── + - name: Compliance rule cross-reference + id: xref_check + run: | + echo "=== Cross-referencing compliance controls against rule files ===" + python - <<'PYEOF' + import json + import os + import importlib.util + import sys + + rules_dir = "scanner/rules" + framework_dir = "compliance/frameworks" + + existing_ids = set() + for filename in os.listdir(rules_dir): + if not filename.startswith("az_") or not filename.endswith(".py"): + continue + filepath = os.path.join(rules_dir, filename) + spec = importlib.util.spec_from_file_location("rule", filepath) + mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(mod) + if hasattr(mod, "RULE_ID"): + existing_ids.add(mod.RULE_ID) + except Exception: + pass + + failures = [] + + for fname in os.listdir(framework_dir): + if not fname.endswith(".json"): + continue + fpath = os.path.join(framework_dir, fname) + try: + data = json.load(open(fpath)) + except (json.JSONDecodeError, OSError): + continue + + for rule_id in data.get("controls", {}): + if rule_id not in existing_ids: + failures.append( + f"{fname}: references '{rule_id}' but no matching rule file found" + ) + + if failures: + print("COMPLIANCE CROSS-REFERENCE FAILURES:") + for f in failures: + print(f" - {f}") + print() + print("Either add the missing rule file or remove the stale control mapping.") + sys.exit(1) + else: + print(f"All compliance controls map to existing rule files. ({len(existing_ids)} rules checked)") + PYEOF + + # ── 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 }} + run: | + python - <<'PYEOF' + import os + + checks = [ + ("Python syntax (rule files)", os.environ["SYNTAX"]), + ("Rule structure + RULE_ID uniqueness", os.environ["STRUCTURE"]), + ("Hardcoded credential scan", os.environ["CREDS"]), + ("Playbook existence + bash syntax", os.environ["PLAYBOOK"]), + ("Compliance JSON validation", os.environ["JSON"]), + ("API syntax check", os.environ["API"]), + ("Compliance vs rule cross-reference", os.environ["XREF"]), + ] + + labels = { + "success": "PASS", + "failure": "FAIL", + "skipped": "SKIP", + "cancelled": "CANCELLED", + } + + lines = [ + "## OpenShield CI Results", + "", + "| Check | Result |", + "|---|---|", + ] + + all_passed = True + for name, outcome in checks: + label = labels.get(outcome, outcome.upper()) + lines.append(f"| {name} | {label} |") + if outcome != "success": + all_passed = False + + lines.append("") + if all_passed: + lines.append("**Result: All checks passed.**") + else: + lines.append("**Result: One or more checks failed. See the step logs above for details.**") + + summary = "\n".join(lines) + print(summary) + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + with open(summary_path, "a") as f: + f.write(summary + "\n") + PYEOF diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md new file mode 100644 index 0000000..e79edb5 --- /dev/null +++ b/docs/ci-pipeline.md @@ -0,0 +1,357 @@ +# CI Pipeline + +OpenShield runs a GitHub Actions workflow on every pull request to `dev` and `main`. The workflow contains seven checks. All seven must pass before a PR can merge. + +This document explains what each check does, how to run every check locally before opening a PR, and the reasoning behind the testing methods chosen. + +--- + +## Checks at a glance + +| # | Check | What fails | +|---|---|---| +| 1 | Python syntax (rule files) | Any `az_*.py` with a syntax error | +| 2 | Rule structure + RULE_ID uniqueness | Missing required fields, invalid SEVERITY, non-dict FRAMEWORKS, duplicate RULE_IDs | +| 3 | Hardcoded credential scan | Literal secrets, keys, or connection strings in source files | +| 4 | Playbook existence + bash syntax | Missing `.sh` for any rule file, or a `.sh` with a bash syntax error | +| 5 | Compliance JSON validation | Missing framework file, invalid JSON, empty object | +| 6 | API syntax check | Any `api/**/*.py` with a syntax error | +| 7 | Compliance rule cross-reference | A rule ID referenced in a framework JSON that has no matching rule file | + +The final step always runs and writes a per-check pass/fail table to the GitHub Actions summary panel so reviewers can see the result without reading through logs. + +--- + +## Setup for local runs + +Before running any checks locally, install the project dependencies including `pyyaml`, which is required to validate the workflow file as valid YAML. + +```bash +pip install -r requirements.txt +``` + +If you prefer to install only what the local checks need without the full Azure SDK stack: + +```bash +pip install pyyaml==6.0.1 +``` + +To verify the workflow file itself is valid YAML before pushing: + +```bash +python -c " +import yaml +with open('.github/workflows/ci.yml') as f: + yaml.safe_load(f) +print('YAML is valid') +" +``` + +This catches structural problems in the workflow file — misaligned indentation, duplicate keys, bad anchors — that GitHub Actions would reject silently or with a confusing error message. + +--- + +## Running checks locally + +Run these from the root of the repository. If any command exits non-zero, CI will also fail. + +### Check 1 — Python syntax (rule files) + +```bash +for f in scanner/rules/az_*.py; do + python -m py_compile "$f" && echo "OK: $f" || echo "FAIL: $f" +done +``` + +A clean run prints `OK:` for every file and exits 0. + +--- + +### Check 2 — Rule structure and RULE_ID uniqueness + +```python +python - <<'PYEOF' +import os, importlib.util, sys +from collections import defaultdict + +rules_dir = "scanner/rules" +required_fields = ["RULE_ID", "SEVERITY", "FRAMEWORKS"] +valid_severities = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"} +failures = [] +seen_ids = defaultdict(list) + +for filename in sorted(os.listdir(rules_dir)): + if not filename.startswith("az_") or not filename.endswith(".py"): + continue + filepath = os.path.join(rules_dir, filename) + spec = importlib.util.spec_from_file_location("rule", filepath) + mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(mod) + except Exception as e: + failures.append(f"{filename}: import error — {e}") + continue + for field in required_fields: + if not hasattr(mod, field): + failures.append(f"{filename}: missing field '{field}'") + if hasattr(mod, "SEVERITY") and mod.SEVERITY not in valid_severities: + failures.append(f"{filename}: SEVERITY '{mod.SEVERITY}' is not valid") + if hasattr(mod, "FRAMEWORKS") and (not isinstance(mod.FRAMEWORKS, dict) or len(mod.FRAMEWORKS) == 0): + failures.append(f"{filename}: FRAMEWORKS must be a non-empty dict") + if hasattr(mod, "RULE_ID"): + seen_ids[mod.RULE_ID].append(filename) + +for rule_id, files in seen_ids.items(): + if len(files) > 1: + failures.append(f"DUPLICATE RULE_ID '{rule_id}' in: {', '.join(files)}") + +if failures: + print("FAILURES:") + for f in failures: print(f" - {f}") + sys.exit(1) +else: + print(f"All {len(seen_ids)} rule files passed.") +PYEOF +``` + +--- + +### Check 3 — Hardcoded credential scan + +```bash +PATTERNS=( + "password\s*=" + "secret\s*=" + "api_key\s*=" + "client_secret\s*=" + "AZURE_CLIENT_SECRET\s*=\s*['\"][^'\"]\+" + "-----BEGIN.*PRIVATE KEY-----" + "AccountKey=" +) + +FAIL=0 +for pattern in "${PATTERNS[@]}"; do + matches=$(grep -rniE "$pattern" \ + --include="*.py" --include="*.sh" --include="*.json" --include="*.yml" \ + --exclude-dir=".git" --exclude-dir="venv" --exclude="ci.yml" \ + . 2>/dev/null | \ + grep -v "\.env" | grep -v "os\.environ" | grep -v "os\.getenv" | \ + grep -v "#" | grep -v "example" | grep -v "placeholder" || true) + if [ -n "$matches" ]; then + echo "POTENTIAL LEAK — pattern '$pattern':" + echo "$matches" + FAIL=1 + fi +done +[ "$FAIL" -eq 0 ] && echo "No hardcoded credentials found." || echo "FAIL" +``` + +If this flags a match in your code, replace the literal value with `os.environ["VAR_NAME"]` and store the real value in your `.env` file (which is gitignored). + +--- + +### Check 4 — Playbook existence and bash syntax + +```bash +FAIL=0 +for rule_file in scanner/rules/az_*.py; do + filename=$(basename "$rule_file" .py) + playbook="playbooks/cli/fix_${filename}.sh" + if [ ! -f "$playbook" ]; then + echo "MISSING: $playbook" + FAIL=1 + elif ! bash -n "$playbook" 2>&1; then + echo "BASH SYNTAX ERROR: $playbook" + FAIL=1 + else + echo "OK: $playbook" + fi +done +[ "$FAIL" -eq 0 ] && echo "All playbooks OK." +``` + +`bash -n` parses the script without executing it. It catches undefined syntax such as mismatched `if`/`fi`, unclosed quotes, and bad redirects. It does not execute any Azure CLI commands. + +--- + +### Check 5 — Compliance JSON validation + +```python +python - <<'PYEOF' +import json, os, sys + +framework_dir = "compliance/frameworks" +expected = ["cis_azure_benchmark.json", "nist_csf.json", "iso27001.json", "soc2.json"] +failures = [] + +for fname in expected: + fpath = os.path.join(framework_dir, fname) + if not os.path.exists(fpath): + failures.append(f"MISSING: {fpath}") + continue + try: + data = json.load(open(fpath)) + n = len(data.get("controls", {})) + print(f"OK: {fname} ({n} controls)") + except json.JSONDecodeError as e: + failures.append(f"{fname}: invalid JSON — {e}") + +if failures: + for f in failures: print(f" - {f}") + sys.exit(1) +PYEOF +``` + +--- + +### Check 6 — API syntax check + +```bash +FAIL=0 +if [ -d "api" ]; then + while IFS= read -r -d '' f; do + python -m py_compile "$f" && echo "OK: $f" || { echo "FAIL: $f"; FAIL=1; } + done < <(find api/ -name "*.py" -print0) +else + echo "No api/ directory — skipping" +fi +[ "$FAIL" -eq 0 ] && echo "API syntax OK." +``` + +--- + +### Check 7 — Compliance rule cross-reference + +```python +python - <<'PYEOF' +import json, os, importlib.util, sys + +rules_dir = "scanner/rules" +framework_dir = "compliance/frameworks" + +existing_ids = set() +for filename in os.listdir(rules_dir): + if not filename.startswith("az_") or not filename.endswith(".py"): + continue + spec = importlib.util.spec_from_file_location("rule", os.path.join(rules_dir, filename)) + mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(mod) + if hasattr(mod, "RULE_ID"): + existing_ids.add(mod.RULE_ID) + except Exception: + pass + +failures = [] +for fname in os.listdir(framework_dir): + if not fname.endswith(".json"): + continue + try: + data = json.load(open(os.path.join(framework_dir, fname))) + except Exception: + continue + for rule_id in data.get("controls", {}): + if rule_id not in existing_ids: + failures.append(f"{fname}: references '{rule_id}' but no rule file found") + +if failures: + for f in failures: print(f" - {f}") + sys.exit(1) +else: + print(f"All compliance controls verified. ({len(existing_ids)} rules checked)") +PYEOF +``` + +--- + +## Testing method rationale + +### Why `py_compile` and not `flake8` or `pylint` + +`py_compile` checks only for syntax errors — the kind that prevent the file from loading at all. Linters add style and convention rules that differ across contributors and would generate noise on code written before the linter was introduced. A syntax check has a binary, objective outcome. That is the right scope for a CI gate on an open source project where contributors are writing their first rules. + +### Why `importlib` and not regex for structure validation + +Regex on Python source is fragile. A field could be assigned via a helper function, computed from a base class, or split across continuation lines. `importlib.util.spec_from_file_location` actually executes the module and then `hasattr()` checks the resulting object — the only way to be certain the attribute is present and accessible at runtime. This is the same mechanism the scanner engine uses when loading rules, so the CI check mirrors what production does. + +### Why `bash -n` and not just checking file existence + +An earlier version of this check only verified that a playbook file existed. A `.sh` file with a bash syntax error — an unclosed `if`, a bad heredoc, a missing `fi` — will crash immediately when an operator runs it in response to a real finding. `bash -n` parses without executing, so it catches structural errors at zero risk of touching any Azure resource. Existence alone is not sufficient. + +### Why the credential scan uses grep exclusions rather than an allowlist + +The patterns being scanned (`password=`, `secret=`, `api_key=`) appear legitimately in two contexts: environment variable lookups (`os.environ`, `os.getenv`) and inline comments. Both are explicitly excluded. The scan is scoped to literal assignment — the pattern that indicates a value is hardcoded in source. A grep-based approach is auditable: every exclusion is visible in one place and any contributor can read exactly what is and is not excluded. + +### Why the credential scan excludes `venv/` + +On GitHub Actions the checkout is clean with no `venv/`. Locally, `venv/` contains thousands of lines from third-party packages that match patterns like `password=None` as function arguments. Excluding `venv/` prevents false positives when contributors run the check locally without creating a confusing discrepancy between local and CI results. + +### Why the cross-reference check walks compliance JSONs rather than rule files + +The check is designed to catch a deletion scenario: a rule file is removed but its entry in one or more compliance JSONs is not. Walking the JSONs and looking up each referenced rule ID against the set of existing rule files catches stale references. The inverse check — verifying every rule file has a compliance entry — is not enforced because a rule may legitimately not map to every framework. + +--- + +## Edge cases handled + +**Rule file has syntax error but passes `py_compile`** +Not possible. `py_compile` detects all syntax errors that prevent the AST from parsing. If `py_compile` passes, the file can be imported. + +**Rule file imports a package not in `requirements.txt`** +Check 2 will fail with `import error` when `spec.loader.exec_module` raises `ModuleNotFoundError`. The error message names the missing package. Add it to `requirements.txt`. + +**Two rule files define the same `RULE_ID`** +Check 2 collects all IDs with `defaultdict(list)` before reporting, so it catches every duplicate in a single run rather than stopping at the first. The failure message names both files. + +**A playbook file exists but contains only a shebang and no logic** +`bash -n` passes — a script with only `#!/bin/bash` is syntactically valid. This is intentional: a stub playbook during development is acceptable; a broken playbook is not. + +**A compliance JSON has a `controls` key with no entries** +Check 5 reports the number of controls but does not fail on zero. An empty `controls` block is structurally valid JSON. Check 7 will simply find nothing to cross-reference. If you want to enforce minimum control counts, add a `len(controls) == 0` check to Check 5. + +**The `api/` directory does not exist** +Check 6 prints `No api/ directory found — skipping` and exits 0. The check is designed to be safe to include before the API module is added. + +**A framework JSON file references a rule ID that was renamed** +Check 7 catches this. The referenced ID will not be in `existing_ids` (which is built from the current `RULE_ID` attribute of each rule file) and CI fails with the exact JSON file and rule ID that is stale. + +**Trailing comma in a compliance JSON** +Check 5 catches this. Python's `json.load` raises `json.JSONDecodeError` on trailing commas, and the failure message includes the line number from the decoder. + +**Local `venv/` directory triggers credential scan false positives** +The scan excludes `--exclude-dir=venv`. On GitHub Actions there is no `venv/` to exclude, so the flag is harmless there. + +--- + +## How the CI summary works + +The final step uses `if: always()` so it runs regardless of whether earlier steps passed or failed. Each check step has a unique `id`. The summary step reads the outcome of every step via environment variables: + +```yaml +- name: CI Summary + if: always() + env: + SYNTAX: ${{ steps.syntax_check.outcome }} + STRUCTURE: ${{ steps.structure_check.outcome }} + ... +``` + +GitHub Actions sets `outcome` to `success`, `failure`, `skipped`, or `cancelled`. The summary step writes a markdown table to `$GITHUB_STEP_SUMMARY`, which GitHub renders as a panel on the Actions run page. This means a reviewer can see which check failed without opening any log. + +When running locally (no `$GITHUB_STEP_SUMMARY` environment variable), the summary is printed to stdout only. + +--- + +## Fixing common failures + +| Failure message | Cause | Fix | +|---|---|---| +| `SYNTAX ERROR: scanner/rules/az_xxx_000.py` | Invalid Python syntax | Open the file, find the syntax error, fix it | +| `missing field 'RULE_ID'` | Rule file does not define `RULE_ID` at module level | Add `RULE_ID = "AZ-XXX-000"` at the top of the file | +| `SEVERITY 'MEDIUM-HIGH' not in {...}` | SEVERITY value is not one of the five allowed strings | Change to `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `INFO` | +| `DUPLICATE RULE_ID 'AZ-NET-003'` | Two rule files declare the same ID | Assign a unique ID to the newer file | +| `POTENTIAL CREDENTIAL LEAK` | A literal secret is present in source | Replace with `os.environ["VAR_NAME"]` | +| `MISSING PLAYBOOK: playbooks/cli/fix_az_xxx_000.sh` | No playbook created for the new rule | Create `playbooks/cli/fix_az_xxx_000.sh` | +| `BASH SYNTAX ERROR: playbooks/cli/fix_az_xxx_000.sh` | Shell script has invalid syntax | Run `bash -n playbooks/cli/fix_az_xxx_000.sh` locally to see the error | +| `invalid JSON — ...` | Trailing comma or other JSON error in a framework file | Open the file, find the bad line (error message includes line number), fix it | +| `references 'AZ-XXX-000' but no matching rule file found` | A compliance JSON references a rule that does not exist | Either create the rule file or remove the entry from the compliance JSON | diff --git a/requirements.txt b/requirements.txt index ee81347..74c911f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ psycopg2-binary==2.9.9 python-dotenv==1.0.0 pyjwt==2.8.0 requests==2.31.0 +pyyaml==6.0.1 diff --git a/scanner/rules/az_net_003.py b/scanner/rules/az_net_003.py index 54d2ca1..a0a18e0 100644 --- a/scanner/rules/az_net_003.py +++ b/scanner/rules/az_net_003.py @@ -9,7 +9,6 @@ CATEGORY = "Network" FRAMEWORKS = {"CIS": "9.3", "NIST": "SC-7", "ISO27001": "A.13.1.1"} DESCRIPTION = ( - DESCRIPTION = ( "A Network Security Group has an inbound rule allowing unrestricted access " "on port 443 from any source (0.0.0.0/0). While HTTPS traffic is encrypted, " "exposing port 443 to the entire internet unnecessarily increases the attack " @@ -18,7 +17,6 @@ "Review manually before remediating — do not auto-remediate without confirming " "the service is not meant to be publicly accessible." ) -) REMEDIATION = ( "Restrict the inbound rule on port 443 to known IP ranges or use an " "Application Gateway with WAF to front any public-facing HTTPS services. " From 9e5d3559d80f3e281d0cccb1cf9476b6c5482526 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 16/50] docs: update .github/ISSUE_TEMPLATE/new_rule.md to reflect current codebase state --- .github/ISSUE_TEMPLATE/new_rule.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/new_rule.md b/.github/ISSUE_TEMPLATE/new_rule.md index ab8c850..170fe57 100644 --- a/.github/ISSUE_TEMPLATE/new_rule.md +++ b/.github/ISSUE_TEMPLATE/new_rule.md @@ -9,7 +9,7 @@ labels: new-rule, good-first-issue **Rule ID:** AZ-XXX-000 **Rule name:** **Severity:** HIGH / MEDIUM / LOW -**Category:** Storage / Network / Identity / Database / Compute +**Category:** Storage / Network / Identity / Database / Compute / Key Vault ## What misconfiguration does it detect? @@ -19,5 +19,6 @@ labels: new-rule, good-first-issue - CIS: - NIST: - ISO 27001: +- SOC 2: -## Remediation (how to fix it)? \ No newline at end of file +## Remediation (how to fix it)? From 2a5655ead0383f784729ee254216dffe4220f1b3 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 17/50] docs: update .github/PULL_REQUEST_TEMPLATE.md to reflect current codebase state --- .github/PULL_REQUEST_TEMPLATE.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index af474c8..66635b8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,19 +5,21 @@ - [ ] New scan rule - [ ] Remediation playbook - [ ] Bug fix -- [ ] Frontend component +- [ ] Dashboard/front-end work - [ ] API endpoint - [ ] Documentation +- [ ] Compliance mapping ## Rule details (if applicable) - Rule ID: AZ-XXX-000 - Severity: HIGH / MEDIUM / LOW -- Category: Storage / Network / Identity / Database / Compute -- Frameworks mapped: CIS / NIST / ISO 27001 +- Category: Storage / Network / Identity / Database / Compute / Key Vault +- Frameworks mapped: CIS / NIST / ISO 27001 / SOC 2 ## Testing - [ ] Tested against a real Azure free trial subscription - [ ] Returns correct JSON output +- [ ] All seven CI checks pass - [ ] No hardcoded credentials or secrets ## Related issue @@ -25,5 +27,7 @@ Closes # ## Checklist - [ ] My code follows the rule template in CONTRIBUTING.md +- [ ] I added or updated the matching CLI playbook +- [ ] I added or updated all four compliance framework mappings - [ ] I have not committed any real Azure credentials -- [ ] My branch name follows the convention: feat/description \ No newline at end of file +- [ ] My branch name follows the convention: feat/description From 57f25a6245506bb89ffa9be87c7b8abce6ad3115 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 18/50] docs: update CONTRIBUTING.md to reflect current codebase state --- CONTRIBUTING.md | 105 +++++++++++++++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13237d1..53e3fa9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,10 +9,10 @@ Welcome! OpenShield is built by the community — students, developers, and secu | Contribution Type | Difficulty | Time | |---|---|---| | New misconfiguration scan rule | ⭐ Beginner | 20–30 min | -| Remediation playbook (CLI/ARM) | ⭐ Beginner | 30 min | +| Remediation playbook (CLI) | ⭐ Beginner | 30 min | | Compliance framework mapping | ⭐⭐ Intermediate | 1–2 hrs | | New API endpoint | ⭐⭐ Intermediate | 2–4 hrs | -| Frontend component | ⭐⭐ Intermediate | 2–4 hrs | +| Dashboard MVP work | ⭐⭐ Intermediate | 2–4 hrs | | KQL detection rule (Sentinel) | ⭐⭐⭐ Advanced | 3–5 hrs | | Scanner engine feature | ⭐⭐⭐ Advanced | 4–8 hrs | @@ -44,49 +44,49 @@ git checkout -b rule/your-rule-name Create a new file in `scanner/rules/`. Every rule follows this exact template: ```python -# scanner/rules/storage_public_blob_access.py +"""AZ-STOR-001: Public blob access enabled on storage account.""" + +from typing import Any, Dict, List RULE_ID = "AZ-STOR-001" RULE_NAME = "Public Blob Access Enabled on Storage Account" SEVERITY = "HIGH" # HIGH / MEDIUM / LOW / INFO -CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute +CATEGORY = "Storage" # Storage / Network / Identity / Database / Compute / Key Vault FRAMEWORKS = { "CIS": "3.5", - "NIST": "AC-3", + "NIST": "PR.AC-3", "ISO27001": "A.9.4.1" } -DESCRIPTION = """ -Storage accounts with public blob access enabled allow anyone on the internet -to read data without authentication. This can lead to data exposure incidents. -""" +DESCRIPTION = ( + "Storage accounts with public blob access enabled allow anyone on the " + "internet to read data without authentication. This can lead to data " + "exposure incidents." +) REMEDIATION = "Disable public blob access on the storage account." -PLAYBOOK = "playbooks/cli/disable_storage_public_access.sh" - - -def scan(azure_client, subscription_id): - """ - Returns a list of findings. Each finding is a dict. - Return empty list if no issues found. - """ - findings = [] - - storage_accounts = azure_client.storage.list_by_subscription() - - for account in storage_accounts: - if account.allow_blob_public_access: +PLAYBOOK = "playbooks/cli/fix_az_stor_001.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Return a list of findings. Return [] if no issues are found.""" + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + if getattr(account, "allow_blob_public_access", False): findings.append({ "rule_id": RULE_ID, "rule_name": RULE_NAME, "severity": SEVERITY, + "category": CATEGORY, "resource_id": account.id, "resource_name": account.name, "resource_type": "Microsoft.Storage/storageAccounts", "description": DESCRIPTION, "remediation": REMEDIATION, "playbook": PLAYBOOK, - "frameworks": FRAMEWORKS + "frameworks": FRAMEWORKS, + "metadata": {} }) - + return findings ``` @@ -97,11 +97,11 @@ That's it. One file, one rule. Create the matching fix in `playbooks/cli/`: ```bash -# playbooks/cli/disable_storage_public_access.sh +# playbooks/cli/fix_az_stor_001.sh #!/bin/bash # Disable public blob access on a storage account -# Usage: ./disable_storage_public_access.sh +# Usage: ./fix_az_stor_001.sh RESOURCE_GROUP=$1 STORAGE_ACCOUNT=$2 @@ -124,7 +124,15 @@ export AZURE_CLIENT_SECRET=your-secret export AZURE_TENANT_ID=your-tenant-id # Run your rule against the test subscription -python scanner/engine.py --rule AZ-STOR-001 --subscription $AZURE_SUBSCRIPTION_ID +python -c " +import os +from scanner.azure_client import AzureClient +from scanner.rules import az_stor_001 as rule + +client = AzureClient(os.environ['AZURE_SUBSCRIPTION_ID']) +findings = rule.scan(client, os.environ['AZURE_SUBSCRIPTION_ID']) +print(f'Found {len(findings)} issue(s)') +" ``` ### Step 6 — Submit Your PR @@ -145,7 +153,7 @@ Adds scan rule AZ-STOR-001 — detects storage accounts with public blob access - Rule ID: AZ-STOR-001 - Severity: HIGH - Category: Storage -- Frameworks mapped: CIS 3.5, NIST AC-3, ISO 27001 A.9.4.1 +- Frameworks mapped: CIS 3.5, NIST PR.AC-3, ISO 27001 A.9.4.1, SOC 2 CC6.6 ## Tested against - [ ] Azure free trial subscription @@ -175,23 +183,47 @@ Check existing rules before picking a number to avoid clashes. --- +## AzureClient Methods + +Use the existing wrapper methods in `scanner/azure_client.py` rather than constructing Azure SDK clients directly inside a rule. + +| Method | Returns | +|---|---| +| `azure_client.parse_resource_id(resource_id)` | Dict with `resource_group` and `name` | +| `azure_client.get_storage_accounts()` | List of StorageAccount objects | +| `azure_client.get_storage_lifecycle_policy(resource_group, account_name)` | `True` if a lifecycle policy with rules exists, `False` if no policy exists, `None` if the policy cannot be checked | +| `azure_client.get_network_security_groups()` | List of NetworkSecurityGroup objects | +| `azure_client.get_network_interface(resource_group, nic_name)` | NetworkInterface or None | +| `azure_client.get_virtual_networks()` | List of VirtualNetwork objects | +| `azure_client.get_public_ip_addresses()` | List of PublicIPAddress objects | +| `azure_client.get_virtual_machines()` | List of VirtualMachine objects | +| `azure_client.get_postgresql_servers()` | List of PostgreSQL single-server objects | +| `azure_client.get_sql_servers()` | List of Azure SQL Server objects | +| `azure_client.get_sql_server_auditing_policy(resource_group, server_name)` | ServerBlobAuditingPolicy or None | +| `azure_client.get_key_vaults()` | List of Key Vault objects | +| `azure_client.get_service_principals()` | List of role assignments for service principals | +| `azure_client.get_conditional_access_policies()` | List of Conditional Access policy dicts from Microsoft Graph | + +Most list methods return an empty list on failure. Methods that fetch one resource or one policy return `None` when the result cannot be determined. + +--- + ## 🛠️ Local Dev Setup ```bash # Python 3.10+ pip install -r requirements.txt +# Installs Flask, Azure SDK clients, requests, psycopg2, PyJWT, and PyYAML for CI workflow validation. # Frontend -cd frontend -npm install -npm run dev +# The frontend directory is currently a scaffold. The React dashboard MVP is on the roadmap. # API -cd api -flask run --debug +FLASK_APP=api/app.py flask run --debug # Database (Docker) docker run --name openshield-db \ + -e POSTGRES_USER=openshield \ -e POSTGRES_PASSWORD=openshield \ -e POSTGRES_DB=openshield \ -p 5432:5432 -d postgres @@ -202,15 +234,16 @@ docker run --name openshield-db \ ## 📐 Code Standards - Python: follow PEP8, use type hints where possible -- React: functional components only, Tailwind for styling +- Dashboard work: functional React components only, Tailwind for styling when the dashboard app lands - Every rule must have a RULE_ID, SEVERITY, FRAMEWORKS mapping, and a remediation playbook +- Every PR must pass the seven GitHub Actions CI checks before merge - All PRs need at least one reviewer approval before merge --- ## 🏅 Recognition -Every contributor is listed in [CONTRIBUTORS.md](CONTRIBUTORS.md). +Every contributor is listed in the README. If you contribute 3+ rules or a major feature, you get: - Named in the project README From 309decae4e39c6e01e9eeb8346d71dcebc76d5bd Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 19/50] docs: update README.md to reflect current codebase state --- README.md | 95 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 93abb1f..21b6571 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ Startups, SMEs, universities, and student teams are left with **zero visibility* | Feature | Description | |---|---| -| **Misconfiguration Scanner** | Scans your Azure subscription for real security issues — open blobs, weak NSG rules, unencrypted DBs, overprivileged identities | -| **Compliance Mapper** | Maps every finding to CIS Benchmarks, NIST CSF, ISO 27001, and SOC 2 | -| **Drift Detection** | Monitors your environment continuously — alerts when security posture changes | -| **Remediation Playbooks** | Every finding ships with a one-click fix — ARM template, Azure CLI, or Terraform | -| **Security Dashboard** | React frontend showing risk score, open findings, compliance posture, and trend over time | -| **Sentinel Integration** | Pushes alerts into Microsoft Sentinel for full SIEM visibility | +| **Misconfiguration Scanner** | Runs 20 Azure security rules across storage, network, identity, database, compute, and Key Vault | +| **Compliance Mapper** | Maps findings to CIS Benchmarks, NIST CSF, ISO 27001, and SOC 2 framework JSON files | +| **Scan History API** | Stores scans and findings in PostgreSQL and exposes findings, score, scan history, and compliance posture over REST | +| **Remediation Playbooks** | Every current rule ships with a matching Azure CLI remediation script | +| **Security Dashboard** | Frontend scaffold is present; the React dashboard MVP is still on the roadmap | +| **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | --- @@ -36,37 +36,37 @@ Startups, SMEs, universities, and student teams are left with **zero visibility* ```mermaid flowchart TD - A["🌐 React Dashboard\nAzure Static Web Apps — Free"] - B["⚙️ Flask REST API\nAzure App Service F1 — Free"] - C["🔍 Scanner Engine\nPython + Azure SDK"] - D["📋 Compliance Mapper\nCIS · NIST · ISO 27001"] - E["🔧 Remediation Playbooks\nARM · Terraform · CLI"] - F["🗄️ PostgreSQL Database\nFindings · Rules · History · Scans"] - G["🛡️ Azure Monitor + Sentinel\nReal-time Alerting · SIEM · KQL Rules"] - H["☁️ Azure Subscription\nTarget environment scanned via SDK"] + A["🌐 React Dashboard MVP\nPlanned frontend"] + B["⚙️ Flask REST API\nJWT · CORS · Blueprints"] + C["🔍 Scanner Engine\n20 Python rules"] + D["☁️ Azure Subscription\nScanned via Azure SDK + Graph"] + E["📋 Compliance Framework JSON\nCIS · NIST · ISO 27001 · SOC 2"] + F["🗄️ PostgreSQL Database\nFindings · Scans"] + G["🔧 Azure CLI Playbooks\n20 remediation scripts"] + H["🛡️ sentinel/ingest.py\nNormalise + HMAC upload"] + I["📈 Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"] A -->|REST calls| B - B --> C - B --> D - B --> E - C --> F - D --> F - E --> F - F --> G - C -->|Azure SDK| H - G -->|Alerts| A + B -->|trigger scans| C + B -->|read/write| F + B -->|compliance score| E + C -->|Azure SDK + Graph| D + C -->|findings| F + C -->|scan output JSON| H + G -->|manual fixes| D + H -->|Data Collector API| I + I -->|alerts| A ``` ## Tech Stack | Layer | Technology | Cost | |---|---|---| -| Frontend | React + Tailwind CSS | Free | +| Frontend | Scaffolded dashboard app (React + Tailwind planned) | Free | | Backend API | Python + Flask | Free | | Database | PostgreSQL | Free (Render/Azure free tier) | | Cloud Scanner | Python + Azure SDK | Free | -| Infrastructure | Azure App Service F1 | Free | -| Static Hosting | Azure Static Web Apps | Free forever | +| Remediation | Azure CLI playbooks | Free | | SIEM | Microsoft Sentinel | 90-day free trial | | CI/CD | GitHub Actions | Free | | Repo | GitHub | Free | @@ -82,19 +82,17 @@ openshield/ │ ├── engine.py # Core scanning orchestration │ └── azure_client.py # Azure SDK wrapper ├── compliance/ # Framework mapping engine -│ ├── frameworks/ # CIS, NIST, ISO 27001, SOC 2 mappings -│ └── mapper.py # Maps findings to frameworks +│ └── frameworks/ # CIS, NIST, ISO 27001, SOC 2 mappings ├── playbooks/ # Remediation playbooks -│ ├── arm/ # ARM templates -│ ├── terraform/ # Terraform fixes +│ ├── arm/ # Reserved for future ARM templates +│ ├── terraform/ # Reserved for future Terraform fixes │ └── cli/ # Azure CLI scripts ├── api/ # Flask REST API │ ├── routes/ │ └── models/ -├── frontend/ # React dashboard -│ ├── src/ -│ └── public/ +├── frontend/ # Dashboard scaffold ├── sentinel/ # Sentinel integration & KQL rules +├── .github/workflows/ # CI checks ├── docs/ # Documentation ├── CONTRIBUTING.md └── README.md @@ -119,13 +117,15 @@ export AZURE_CLIENT_SECRET=your-client-secret export AZURE_TENANT_ID=your-tenant-id # Run a scan -python scanner/engine.py --subscription $AZURE_SUBSCRIPTION_ID +python -c " +from scanner.engine import ScanEngine +import json, os +result = ScanEngine(os.environ['AZURE_SUBSCRIPTION_ID']).run_scan() +print(json.dumps(result, indent=2)) +" # Start the API -cd api && flask run - -# Start the dashboard -cd frontend && npm install && npm run dev +FLASK_APP=api/app.py flask run ``` --- @@ -143,7 +143,7 @@ We actively welcome contributions from students and developers at all levels. 👉 See [CONTRIBUTING.md](CONTRIBUTING.md) for a full guide — including how to add your first rule in under 30 minutes. -All contributors get credited in our [CONTRIBUTORS.md](CONTRIBUTORS.md). +Contributors are credited below. --- @@ -151,15 +151,17 @@ All contributors get credited in our [CONTRIBUTORS.md](CONTRIBUTORS.md). - [x] Project scaffolding - [x] Core scanner engine (Azure SDK integration) -- [x] 11 scan rules +- [x] 20 scan rules - [x] Flask API + PostgreSQL schema - [ ] React dashboard MVP -- [ ] CIS Benchmark compliance mapping +- [x] CIS Benchmark compliance mapping +- [x] SOC 2 compliance mapping - [x] Sentinel alert integration - [x] Real-world breach scenarios documented - [x] First external contributor PR merged -- [ ] Remediation playbook library -- [ ] NIST CSF + ISO 27001 mappings +- [x] Azure CLI remediation playbook library +- [x] NIST CSF + ISO 27001 mappings +- [x] GitHub Actions CI pipeline - [ ] Multi-cloud support (AWS, GCP) --- @@ -170,9 +172,10 @@ Thanks to everyone who has contributed to OpenShield. | Contributor | GitHub | Contribution | |---|---|---| -| Vishnu Ajith | @Vishnu2707 | Architecture, core scanner, Sentinel wiring | -| TFT444 | @TFT444 | Sentinel integration, 8 network rules, breach scenarios | -| Parth | @parthrohit22 | AZ-KV-002 Key Vault public access rule | +| Vishnu Ajith | @Vishnu2707 | Architecture, core scanner, API, compliance mappings | +| Tanvir Farhad | @TFT444 | Sentinel integration, network rules, playbooks, breach scenarios | +| Parth J Rohit | @parthrohit22 | AZ-KV-002 Key Vault public access rule and playbook | +| Ritik Sah | @ritiksah141 | AZ-STOR-003 storage lifecycle rule and CI pipeline | --- From 693b20c6c6aabd5d748e0e661912529db64ca228 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 20/50] docs: update compliance/frameworks/iso27001.json to reflect current codebase state --- compliance/frameworks/iso27001.json | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 9d78a93..414d761 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -89,10 +89,16 @@ "description": "Virtual machines with public IPs and no NSG have unrestricted network access. Network controls should be applied to all compute resources accessible from the internet." }, "AZ-KV-001": { - "control_id": "A.12.3.1", - "control_name": "Information backup", - "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Backup copies of information should be taken and tested regularly in accordance with an agreed backup policy." - } + "control_id": "A.17.2.1", + "control_name": "Availability of information processing facilities", + "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Without soft delete, deleted vault objects cannot be recovered, reducing availability and recovery options for critical cryptographic material." + }, + "AZ-STOR-003": { + "control_id": "A.8.3.1", + "control_name": "Management of removable media", + "description": "Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism. Lifecycle management supports formal retention, tiering, and disposal of information assets." + }, + "AZ-KV-002": { "control_id": "A.13.1.1", "control_name": "Network controls", "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." From c292efcc40ee52229dbeaeebaa8a82bab5b432b2 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 21/50] docs: update compliance/frameworks/nist_csf.json to reflect current codebase state --- compliance/frameworks/nist_csf.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 7a9ebba..cd421ed 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -93,6 +93,11 @@ "control_name": "Backups of information are conducted, maintained, and tested", "description": "Key material in Azure Key Vault must be recoverable after accidental or malicious deletion. Soft delete provides a recoverable state for secrets, keys, and certificates, supporting backup and recovery requirements for critical cryptographic material." }, + "AZ-KV-002": { + "control_id": "AC-17", + "control_name": "Remote access", + "description": "Key Vaults that allow public network access expose sensitive secrets, keys, and certificates to remote access attempts from outside trusted networks. Restricting access through private endpoints or trusted networks helps manage remote access paths." + }, "AZ-STOR-003": { "control_id": "PR.DS-3", "control_name": "Assets are formally managed throughout removal, transfers, and disposition", From 034b9d52beb85f3ab6e8f0d0b916afc42337f74d Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 22/50] docs: update docs/adding-a-rule.md to reflect current codebase state --- docs/adding-a-rule.md | 80 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index 0ef1d79..35f9516 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -17,14 +17,17 @@ Every rule file must have this exact structure: ```python """AZ-XXXX-000: One-line description of what this rule detects.""" +import logging from typing import Any, Dict, List +logger = logging.getLogger(__name__) + # ── Required module-level constants ───────────────────────────────────────── RULE_ID = "AZ-XXXX-000" # Unique ID. Check existing rules to avoid clashes. RULE_NAME = "Human-readable name" # Shown in the dashboard and reports. SEVERITY = "HIGH" # HIGH | MEDIUM | LOW | INFO -CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | KeyVault +CATEGORY = "Storage" # Storage | Network | Identity | Database | Compute | Key Vault FRAMEWORKS = { "CIS": "3.5", # CIS Azure Benchmark control ID "NIST": "PR.AC-3", # NIST CSF subcategory @@ -55,19 +58,35 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: findings: List[Dict[str, Any]] = [] for resource in azure_client.get_storage_accounts(): # ← replace with the right method - if : + resource_id = getattr(resource, "id", "") + resource_name = getattr(resource, "name", "") + if not resource_id or not resource_name: + continue + + allows_public_access = bool(getattr(resource, "allow_blob_public_access", False)) + status = False if allows_public_access else True + + if status is None: + # Could not determine compliance because of permissions, + # SDK failure, or another unexpected state. Skip rather than + # create a false positive. + logger.warning("%s: could not determine status for %s", RULE_ID, resource_name) + continue + + if status is False: findings.append({ "rule_id": RULE_ID, "rule_name": RULE_NAME, "severity": SEVERITY, "category": CATEGORY, - "resource_id": resource.id, - "resource_name": resource.name, + "resource_id": resource_id, + "resource_name": resource_name, "resource_type": "Microsoft.Storage/storageAccounts", # ← update "description": DESCRIPTION, "remediation": REMEDIATION, "playbook": PLAYBOOK, "frameworks": FRAMEWORKS, + "metadata": {}, }) return findings @@ -82,7 +101,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | `RULE_ID` | `AZ-[CATEGORY]-[NUMBER]`. Prefix map: STOR, NET, IDN, DB, CMP, KV. Look at existing rules for the next number. | | `SEVERITY` | `HIGH` = direct exploitation risk, `MEDIUM` = indirect or partial risk, `LOW` = best practice, `INFO` = informational only | | `CATEGORY` | Matches the resource type being scanned | -| `FRAMEWORKS` | Use real control IDs from each framework. Refer to `compliance/frameworks/` JSON files for examples. | +| `FRAMEWORKS` | Use real CIS, NIST, and ISO 27001 control IDs. SOC 2 is mapped in `compliance/frameworks/soc2.json`. | | `DESCRIPTION` | Focus on WHY it matters — what is the real-world attack scenario? | | `REMEDIATION` | Be specific. Name the Azure Portal setting or the exact CLI flag. | | `PLAYBOOK` | Path to the matching bash script in `playbooks/cli/`. You must create this file too. | @@ -95,18 +114,23 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | Method | Returns | |---|---| | `azure_client.get_storage_accounts()` | List of StorageAccount objects | +| `azure_client.get_storage_lifecycle_policy(rg, name)` | `True` if a lifecycle policy with rules exists, `False` if no policy exists, `None` if it cannot be checked | | `azure_client.get_network_security_groups()` | List of NetworkSecurityGroup objects | +| `azure_client.get_network_interface(rg, name)` | NetworkInterface or None | +| `azure_client.get_virtual_networks()` | List of VirtualNetwork objects | +| `azure_client.get_public_ip_addresses()` | List of PublicIPAddress objects | | `azure_client.get_virtual_machines()` | List of VirtualMachine objects | | `azure_client.get_postgresql_servers()` | List of Server objects (PostgreSQL single-server) | | `azure_client.get_sql_servers()` | List of Server objects (Azure SQL) | | `azure_client.get_sql_server_auditing_policy(rg, name)` | ServerBlobAuditingPolicy or None | | `azure_client.get_key_vaults()` | List of Vault objects (with full properties) | | `azure_client.get_service_principals()` | List of RoleAssignment objects for service principals | -| `azure_client.get_network_interface(rg, name)` | NetworkInterface or None | | `azure_client.get_conditional_access_policies()` | List of CA policy dicts from MS Graph | | `azure_client.parse_resource_id(id)` | Dict with `resource_group` and `name` | -All methods return an empty list on failure — your scan function never needs to handle SDK exceptions. +List methods return an empty list on failure. Single-resource methods return `None` when the resource cannot be fetched. Three-state checks, such as `get_storage_lifecycle_policy()`, return `True` for compliant, `False` for non-compliant, and `None` when the scanner cannot determine the state. + +When a helper returns `None`, skip the resource and log a warning. Never create a finding from an unknown state. --- @@ -146,8 +170,7 @@ echo "✅ Remediation complete for $RESOURCE_NAME" ```bash # 1. Set credentials -cp .env.example .env -# Fill in your Azure credentials in .env +# Create a .env file and fill in your Azure credentials # 2. Load env and run your rule in isolation python -c " @@ -180,6 +203,11 @@ print(json.dumps(result, indent=2)) If your rule maps to controls not yet in the compliance JSON files, add entries to the relevant file(s) in `compliance/frameworks/`: +- `cis_azure_benchmark.json` +- `nist_csf.json` +- `iso27001.json` +- `soc2.json` + ```json { "controls": { @@ -205,6 +233,16 @@ git push origin rule/az-xxxx-000-short-description Then open a PR. Use the PR template — it will ask you for the rule ID, severity, and which frameworks you mapped. A maintainer will review within 48 hours. +Before requesting review, make sure all seven CI checks pass: + +- Python syntax on rule files +- Rule structure validation +- Hardcoded credential scan +- Playbook existence and bash syntax +- Compliance JSON validation +- API syntax check +- Compliance rule cross-reference + --- ## Common Mistakes to Avoid @@ -213,11 +251,11 @@ Then open a PR. Use the PR template — it will ask you for the rule ID, severit - **Missing playbook**: every rule must have a matching `playbooks/cli/fix_*.sh` file. - **Hardcoded subscription ID**: use the `subscription_id` parameter passed to `scan()`, never hardcode. - **Exceptions crashing the scan**: the engine catches unhandled exceptions per rule, but write defensively — use `getattr(obj, "field", default)` for optional SDK attributes. -- **Empty `frameworks` dict**: always populate all three keys (CIS, NIST, ISO27001) even if you map to `"N/A"`. +- **Empty `frameworks` dict**: always populate the CIS, NIST, and ISO27001 keys even if you map to `"N/A"`, and add the SOC 2 mapping in `soc2.json`. -## Real-world impact of each rule +## Real-world impact of selected rules **AZ-STOR-001 — Public blob access enabled** This is how 38 million records leaked in the 2021 Power Apps breach — blob containers set to public, no authentication needed, just know the URL and download everything. Attackers don't even need to "hack" anything. Automated tools scan Azure for public blobs constantly. If yours is exposed it will be found, usually within hours. @@ -225,6 +263,9 @@ This is how 38 million records leaked in the 2021 Power Apps breach — blob con **AZ-STOR-002 — Storage account allows unencrypted HTTP** Any data moving over plain HTTP can be read by anyone on the same network path. This sounds theoretical until you realise most corporate VPNs, shared offices and cloud interconnects are exactly that kind of shared environment. One internal tool uploading customer data over HTTP to Azure storage is all it takes. The fix is one toggle — HTTPS only — but it gets missed constantly. +**AZ-STOR-003 — Storage account has no lifecycle management policy** +Without lifecycle management, old blobs pile up forever. Backups, exports and stale customer files stay accessible long after the business reason for keeping them has expired. Lifecycle policies give teams a way to tier or delete data automatically instead of relying on someone to remember a cleanup task months later. + **AZ-NET-001 — NSG allows SSH from internet** SSH brute force attacks are constant — attackers run automated scripts trying millions of username and password combinations against any open port 22 they find. In 2023 a university research cluster was compromised through an exposed SSH port, with attackers using it to mine cryptocurrency for three months before detection. Restricting SSH to known IP ranges or using Azure Bastion eliminates this risk entirely. @@ -241,14 +282,19 @@ Contributor at subscription scope means the service principal can touch everythi **AZ-IDN-002 — MFA not enforced on privileged accounts** Credential stuffing is not sophisticated. Attackers just take leaked password lists from other breaches and try them on Azure AD. Without MFA a matching password is all they need. Microsoft says MFA stops 99.9% of these attacks. A Global Admin account without MFA is genuinely one of the highest risk findings you can have — one leaked password from any other service and your entire tenant is gone. -**AZ-DB-001 — SQL Server TDE disabled** -The database itself might be behind a firewall, but what about the backups? Backup files get moved around — to blob storage, to tapes, to DR sites. Without TDE the data is sitting in plain text in all of those places. A healthcare company learned this the hard way in 2019 when stolen backup files exposed 2.3 million patient records. The attacker never touched the live database. +**AZ-DB-001 — PostgreSQL server allows public network access** +Public database endpoints get scanned constantly. Even if credentials are strong, a reachable database server gives attackers a place to brute force, exploit, or pressure-test configuration mistakes. PostgreSQL should sit behind private networking unless there is a deliberate, reviewed reason to expose it. -**AZ-DB-002 — SQL Server firewall allows all IPs** -Opening the SQL Server firewall to all IPs is the same as putting your database on the public internet. Shodan and similar tools index these constantly. In 2020 a startup had their production database dumped within days of launching because the firewall rule was still set to 0.0.0.0 from a development config that nobody cleaned up. Lock it to your app service IPs only — nothing else needs direct database access. +**AZ-DB-002 — Azure SQL Server auditing disabled** +When auditing is off, failed logins, schema changes and suspicious database access leave little evidence behind. The incident response team starts with a blank timeline. Enabling auditing gives you the raw event trail needed for investigations and compliance reporting. -**AZ-CMP-001 — Unencrypted managed disk** -An attacker who gets into your subscription — even temporarily — can snapshot a disk in seconds. They create the snapshot, export it, mount it on their own VM and read everything on it at their leisure. The original VM keeps running, no one notices. A SaaS company found out about this 6 weeks after it happened when their data showed up for sale. The disks were unencrypted so the snapshot was immediately readable. +**AZ-CMP-001 — VM with public IP and no associated NSG** +A virtual machine with a public IP and no NSG on its network interface has no explicit network filtering at the NIC boundary. If the workload was meant to be private, this creates a direct path from the internet to the VM. Attach an NSG, restrict inbound rules, or remove the public IP entirely. **AZ-KV-001 — Key Vault soft delete disabled** Key Vault is where everything important lives — database passwords, API keys, TLS certificates, encryption keys. Without soft delete an attacker or a disgruntled employee can delete every single secret permanently in about 30 seconds. No recovery, no rollback. A real incident in 2021 saw an employee delete an entire production Key Vault on their last day. The company was down for 6 days rebuilding access from scratch. Soft delete costs nothing to enable. + +**AZ-KV-002 — Key Vault allows public network access** +Key Vault should be one of the least reachable services in an Azure environment. Public network access does not mean secrets are public, but it does widen the path attackers can use to attempt access. Private endpoints and network restrictions keep secret access inside trusted network boundaries. + +For the complete current rule list, see `docs/rules-reference.md`. From 936a7d6c1c302cef71ca83572849e272b79553ea Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 23/50] docs: update docs/architecture.md to reflect current codebase state --- docs/architecture.md | 119 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 20 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5217407..0ae8147 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It continuously scans your Azure subscription against a library of security rules, maps every finding to compliance frameworks (CIS, NIST CSF, ISO 27001), and exposes results via a REST API consumed by a React dashboard. +OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 20 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API. --- @@ -10,33 +10,34 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl ``` ┌──────────────────────────────────────────────────────────────────┐ -│ React Dashboard │ -│ (Azure Static Web Apps — Free tier) │ +│ React Dashboard MVP (planned) │ +│ frontend/ scaffold │ └────────────────────────────┬─────────────────────────────────────┘ │ HTTPS / JWT ┌────────────────────────────▼─────────────────────────────────────┐ │ Flask REST API (api/) │ │ │ +│ GET /health │ │ GET /api/findings GET /api/score │ │ GET /api/findings/ GET /api/compliance/ │ │ GET /api/scans POST /api/scans/trigger │ └───────────┬──────────────────────────────────┬───────────────────┘ │ │ ┌───────────▼──────────────┐ ┌───────────────▼───────────────────┐ -│ Scanner Engine │ │ Compliance Mapper │ +│ Scanner Engine │ │ Compliance Frameworks │ │ (scanner/) │ │ (compliance/frameworks/) │ │ │ │ │ │ ScanEngine │ │ cis_azure_benchmark.json │ │ └── load_rules() │ │ nist_csf.json │ │ └── run_scan() │ │ iso27001.json │ +│ │ │ soc2.json │ └───────────┬───────────────┘ └────────────────────────────────────┘ │ ┌───────────▼──────────────────────────────────────────────────────┐ │ Rule Modules (scanner/rules/) │ │ │ -│ az_stor_001.py az_net_001.py az_idn_001.py az_db_001.py │ -│ az_stor_002.py az_net_002.py az_idn_002.py az_db_002.py │ -│ az_cmp_001.py az_kv_001.py │ +│ 20 rule files across Storage, Network, Identity, Database, │ +│ Compute, and Key Vault │ └───────────┬───────────────────────────────────────────────────────┘ │ calls ┌───────────▼──────────────────────────────────────────────────────┐ @@ -52,10 +53,20 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl ┌───────────▼──────────────────────────────────────────────────────┐ │ Azure Subscription (target) │ └──────────────────────────────────────────────────────────────────┘ - │ + │ findings returned to ScanEngine / API ┌───────────▼──────────────────────────────────────────────────────┐ │ PostgreSQL Database │ -│ (findings, scans, rules tables) │ +│ (findings, scans tables) │ +└──────────────────────────────────────────────────────────────────┘ +Scan result JSON can also be passed to Sentinel ingestion: +┌──────────────────────────────────────────────────────────────────┐ +│ Sentinel ingestion (sentinel/ingest.py) │ +│ input findings JSON → HMAC-sign request → Log Analytics │ +└────────────────────────────┬─────────────────────────────────────┘ + │ Data Collector API +┌────────────────────────────▼─────────────────────────────────────┐ +│ Microsoft Sentinel / Log Analytics │ +│ OpenShieldFindings_CL + KQL analytics rules │ └──────────────────────────────────────────────────────────────────┘ ``` @@ -90,7 +101,22 @@ result = engine.run_scan() `run_scan()` iterates through all loaded rule modules, calling `module.scan(azure_client, subscription_id)` for each. Individual rule failures are caught and logged without stopping the scan. The engine collects all findings and returns a structured result dict. -### 4. Finding Schema +### 4. Current Rule Modules + +There are 20 current rule files in `scanner/rules/`. + +| Category | Rules | +|---|---| +| Storage | AZ-STOR-001 public blob access, AZ-STOR-002 HTTPS-only storage, AZ-STOR-003 lifecycle management policy | +| Network | AZ-NET-001 SSH from any source, AZ-NET-002 RDP from any source, AZ-NET-003 unrestricted 443, AZ-NET-004 empty NSG, AZ-NET-005 no DDoS protection, AZ-NET-006 unassociated public IP, AZ-NET-007 Application Gateway without WAF, AZ-NET-008 load balancer without backend pool, AZ-NET-009 outdated IKE version, AZ-NET-010 subnet without NSG | +| Identity | AZ-IDN-001 service principal with Owner role, AZ-IDN-002 no admin MFA via Conditional Access | +| Database | AZ-DB-001 PostgreSQL public network access, AZ-DB-002 SQL Server auditing disabled | +| Compute | AZ-CMP-001 VM public IP with no NSG on NIC | +| Key Vault | AZ-KV-001 soft delete disabled, AZ-KV-002 public network access without private endpoint | + +Every rule has a matching Azure CLI playbook in `playbooks/cli/`. + +### 5. Finding Schema Every finding returned by a rule must conform to this schema: @@ -99,7 +125,7 @@ Every finding returned by a rule must conform to this schema: "rule_id": str, # e.g. "AZ-STOR-001" "rule_name": str, "severity": str, # HIGH | MEDIUM | LOW | INFO - "category": str, # Storage | Network | Identity | Database | Compute | KeyVault + "category": str, # Storage | Network | Identity | Database | Compute | Key Vault "resource_id": str, # full Azure resource ID "resource_name": str, "resource_type": str, # e.g. "Microsoft.Storage/storageAccounts" @@ -107,11 +133,33 @@ Every finding returned by a rule must conform to this schema: "remediation": str, "playbook": str, # path to the CLI remediation script "frameworks": dict, # {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"} + "metadata": dict, # optional rule-specific context "detected_at": str, # ISO 8601, added by engine "scan_id": str, # UUID, added by engine } ``` +### 6. AzureClient Surface + +Rules should use `scanner/azure_client.py` instead of instantiating SDK clients directly. + +| Method | Purpose | +|---|---| +| `parse_resource_id(resource_id)` | Parse `resource_group` and `name` from an Azure resource ID | +| `get_storage_accounts()` | List storage accounts | +| `get_storage_lifecycle_policy(resource_group, account_name)` | Return `True`, `False`, or `None` for storage lifecycle policy status | +| `get_network_security_groups()` | List network security groups | +| `get_network_interface(resource_group, nic_name)` | Fetch one network interface | +| `get_virtual_networks()` | List virtual networks | +| `get_public_ip_addresses()` | List public IP addresses | +| `get_virtual_machines()` | List virtual machines | +| `get_postgresql_servers()` | List PostgreSQL single-server instances | +| `get_sql_servers()` | List Azure SQL servers | +| `get_sql_server_auditing_policy(resource_group, server_name)` | Fetch SQL Server blob auditing policy | +| `get_key_vaults()` | List Key Vaults | +| `get_service_principals()` | List service principal role assignments | +| `get_conditional_access_policies()` | Fetch Conditional Access policies from Microsoft Graph | + --- ## How Findings Flow to the API @@ -133,6 +181,10 @@ GET /api/score GET /api/compliance/cis → db.get_compliance_score("cis") # joins DB findings with CIS JSON → returns per-control pass/fail breakdown + +GET /api/compliance/soc2 + → db.get_compliance_score("soc2") # same flow for SOC 2 + → returns per-control pass/fail breakdown ``` --- @@ -158,23 +210,48 @@ Each rule module is a plain Python file — no base class, no registration decor ## How Sentinel Integration Works -> **Note:** Sentinel push is handled by a separate team. This section documents the integration point. - -After `run_scan()` returns, findings can be forwarded to Microsoft Sentinel via the Azure Monitor Ingestion API. The `sentinel/` directory contains the KQL detection rules and the ingestion client configuration. +Sentinel ingestion is implemented in `sentinel/ingest.py`. It is a standalone script, not an API route and not a DB polling worker. The flow: -1. `POST /api/scans/trigger` → scan completes → findings in DB -2. A Sentinel push worker (separate process or Azure Function) polls the DB for new findings -3. New findings are batched and sent to a Log Analytics Workspace via `azure-monitor-ingestion` -4. KQL detection rules in Sentinel fire alerts on HIGH-severity findings +1. Load a findings JSON file from the first CLI argument, defaulting to `scanner/output/test_findings.json`. +2. Use the second CLI argument as `scan_id`, or generate one from the current UTC timestamp. +3. Accept either a raw findings list or an object with a `findings` array. +4. Normalise each finding into Sentinel-friendly fields such as `RuleId`, `RuleName`, `Severity`, `SeverityScore`, `ResourceId`, and `TimeGenerated`. +5. HMAC-sign the payload with `SENTINEL_SHARED_KEY`. +6. POST the records to the Log Analytics Data Collector API. +7. Query and analytics rules in `sentinel/rules/` operate on `OpenShieldFindings_CL`. + +Required environment variables: + +| Variable | Description | +|---|---| +| `SENTINEL_WORKSPACE_ID` | Log Analytics workspace customer ID | +| `SENTINEL_SHARED_KEY` | Primary or secondary shared key for the workspace | +| `SENTINEL_LOG_TYPE` | Custom log type. Defaults to `OpenShieldFindings` | + +--- + +## CI Pipeline + +`.github/workflows/ci.yml` runs on pull requests to `dev` and `main`. It installs Python 3.11 dependencies and runs seven checks: + +| # | Check | Purpose | +|---|---|---| +| 1 | Python syntax on rule files | Compiles every `scanner/rules/az_*.py` file | +| 2 | Rule structure validation | Verifies required fields, valid severity values, non-empty `FRAMEWORKS`, and unique `RULE_ID`s | +| 3 | Hardcoded credential scan | Searches source files for literal secrets and keys | +| 4 | Playbook existence and bash syntax | Requires a matching `playbooks/cli/fix_.sh` for every rule and validates it with `bash -n` | +| 5 | Compliance JSON validation | Confirms CIS, NIST, ISO 27001, and SOC 2 JSON files exist and parse | +| 6 | API syntax check | Compiles every Python file under `api/` | +| 7 | Compliance rule cross-reference | Flags compliance JSON entries that reference missing rule files | -The required environment variable is `SENTINEL_WORKSPACE_ID` (see `.env.example`). +The final CI summary step always runs and writes a pass/fail table to the GitHub Actions summary. --- ## Configuration -All runtime configuration is provided via environment variables (see `.env.example`): +All runtime configuration is provided via environment variables: | Variable | Description | |---|---| @@ -185,3 +262,5 @@ All runtime configuration is provided via environment variables (see `.env.examp | `DATABASE_URL` | PostgreSQL connection string | | `JWT_SECRET` | Secret used to sign/verify API JWTs | | `SENTINEL_WORKSPACE_ID` | Log Analytics workspace ID for Sentinel push | +| `SENTINEL_SHARED_KEY` | Log Analytics workspace shared key for Sentinel ingestion | +| `SENTINEL_LOG_TYPE` | Custom log name, defaults to `OpenShieldFindings` | From 3cd0f00469ef24e8bdeb7d8469304b3ad4611833 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 24/50] docs: update docs/az-stor-003-test-plan.md to reflect current codebase state --- docs/az-stor-003-test-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/az-stor-003-test-plan.md b/docs/az-stor-003-test-plan.md index 65fb272..88e0fa5 100644 --- a/docs/az-stor-003-test-plan.md +++ b/docs/az-stor-003-test-plan.md @@ -24,6 +24,7 @@ and its remediation playbook. The goal is to confirm: | compliance/frameworks/cis_azure_benchmark.json | CIS mapping | | compliance/frameworks/nist_csf.json | NIST mapping | | compliance/frameworks/iso27001.json | ISO 27001 mapping | +| compliance/frameworks/soc2.json | SOC 2 mapping | --- From 17c29f466ef1bb7826e93a1ebde02dbc2056c158 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 25/50] docs: update docs/azure-setup.md to reflect current codebase state --- docs/azure-setup.md | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/azure-setup.md b/docs/azure-setup.md index d2f8231..9cf0d73 100644 --- a/docs/azure-setup.md +++ b/docs/azure-setup.md @@ -71,6 +71,12 @@ For the Conditional Access MFA rule (AZ-IDN-002), the service principal needs th # Get the service principal object ID SP_OBJECT_ID=$(az ad sp show --id --query id --output tsv) +# Get the Microsoft Graph service principal object ID +GRAPH_SP_ID=$(az ad sp list \ + --filter "appId eq '00000003-0000-0000-c000-000000000000'" \ + --query "[0].id" \ + --output tsv) + # Grant Policy.Read.All application permission # This requires a Global Administrator to consent az rest \ @@ -78,7 +84,7 @@ az rest \ --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID/appRoleAssignments" \ --body '{ "principalId": "'$SP_OBJECT_ID'", - "resourceId": "", + "resourceId": "'$GRAPH_SP_ID'", "appRoleId": "246dd0d5-5bd0-4def-940b-0421030a5b68" }' ``` @@ -89,10 +95,10 @@ If you skip this step, AZ-IDN-002 will produce a finding by default (it cannot v ## Step 5 — Configure Your .env File -Copy the example and fill in your values: +Create a `.env` file and fill in your values: ```bash -cp .env.example .env +touch .env ``` Edit `.env`: @@ -105,6 +111,8 @@ AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx DATABASE_URL=postgresql://openshield:openshield@localhost:5432/openshield JWT_SECRET=your-random-secret-at-least-32-chars SENTINEL_WORKSPACE_ID= +SENTINEL_SHARED_KEY= +SENTINEL_LOG_TYPE=OpenShieldFindings ``` --- @@ -163,6 +171,8 @@ curl -X POST http://localhost:5000/api/scans/trigger \ -d '{"subscription_id": "your-subscription-id"}' ``` +Compliance posture is available through `/api/compliance/cis`, `/api/compliance/nist`, `/api/compliance/iso27001`, and `/api/compliance/soc2`. + --- ## Step 8 — Activate the Microsoft Sentinel 90-Day Trial (Optional) @@ -176,7 +186,25 @@ Microsoft Sentinel includes a 90-day free trial for new Log Analytics workspaces - Region: choose the same region as your resources 4. Click **Add Microsoft Sentinel** — the 90-day trial activates automatically. 5. Copy the **Workspace ID** from the workspace Overview page. -6. Add it to your `.env`: `SENTINEL_WORKSPACE_ID=` +6. Copy a shared key from **Agents** or with the Azure CLI: + +```bash +az monitor log-analytics workspace get-shared-keys \ + --resource-group \ + --workspace-name \ + --query primarySharedKey \ + --output tsv +``` + +7. Add these values to your `.env`: + +``` +SENTINEL_WORKSPACE_ID= +SENTINEL_SHARED_KEY= +SENTINEL_LOG_TYPE=OpenShieldFindings +``` + +`sentinel/ingest.py` reads a findings JSON file, normalises each finding, signs the request with `SENTINEL_SHARED_KEY`, and sends records to the `OpenShieldFindings_CL` custom log table. > **Cost after trial:** ~$2.76/GB ingested. For a small subscription with few findings, this is negligible. From 62753964f8350c8d9b9913a324d636965b86adbd Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 26/50] docs: update docs/ci-pipeline.md to reflect current codebase state --- docs/ci-pipeline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md index e79edb5..e0b7f34 100644 --- a/docs/ci-pipeline.md +++ b/docs/ci-pipeline.md @@ -288,7 +288,7 @@ On GitHub Actions the checkout is clean with no `venv/`. Locally, `venv/` contai ### Why the cross-reference check walks compliance JSONs rather than rule files -The check is designed to catch a deletion scenario: a rule file is removed but its entry in one or more compliance JSONs is not. Walking the JSONs and looking up each referenced rule ID against the set of existing rule files catches stale references. The inverse check — verifying every rule file has a compliance entry — is not enforced because a rule may legitimately not map to every framework. +The check is designed to catch a deletion scenario: a rule file is removed but its entry in one or more compliance JSONs is not. Walking the JSONs and looking up each referenced rule ID against the set of existing rule files catches stale references. The inverse check — verifying every rule file has a compliance entry — is not enforced by CI, but the current repository convention is to map every rule in CIS, NIST, ISO 27001, and SOC 2. --- From ab16a16be9c681e6e1678b13aa162fdd62f76a1b Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 27/50] docs: update docs/sentinel-setup.md to reflect current codebase state --- docs/sentinel-setup.md | 116 ++++++++++++++++++++++++++++++++--------- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/docs/sentinel-setup.md b/docs/sentinel-setup.md index fcf7e29..bba8c42 100644 --- a/docs/sentinel-setup.md +++ b/docs/sentinel-setup.md @@ -1,67 +1,135 @@ # Sentinel Integration Setup Guide +This guide configures Microsoft Sentinel ingestion for findings produced by OpenShield. The ingestion client is `sentinel/ingest.py`. + +--- + ## Prerequisites -- Azure account (free trial at azure.microsoft.com/free) + +- Azure account +- Azure CLI installed and logged in - Python 3.9+ -- Azure CLI installed +- `requests` installed through `pip install -r requirements.txt` + +--- + +## Part 1 - Create a Log Analytics Workspace -## Part 1 - Create Log Analytics Workspace +```bash +az group create \ + --name openshield-rg \ + --location uksouth -az group create --name openshield-rg --location uksouth +az monitor log-analytics workspace create \ + --resource-group openshield-rg \ + --workspace-name openshield-laws \ + --location uksouth \ + --retention-time 30 +``` -az monitor log-analytics workspace create --resource-group openshield-rg --workspace-name openshield-laws --location uksouth --retention-time 30 +Get the workspace ID: -Get Workspace ID: -az monitor log-analytics workspace show --resource-group openshield-rg --workspace-name openshield-laws --query customerId --output tsv +```bash +az monitor log-analytics workspace show \ + --resource-group openshield-rg \ + --workspace-name openshield-laws \ + --query customerId \ + --output tsv +``` -Get Shared Key: -az monitor log-analytics workspace get-shared-keys --resource-group openshield-rg --workspace-name openshield-laws --query primarySharedKey --output tsv +Get the shared key: + +```bash +az monitor log-analytics workspace get-shared-keys \ + --resource-group openshield-rg \ + --workspace-name openshield-laws \ + --query primarySharedKey \ + --output tsv +``` + +--- ## Part 2 - Activate Sentinel +```bash az extension add --name sentinel -az sentinel onboarding-state create --resource-group openshield-rg --workspace-name openshield-laws --name default +az sentinel onboarding-state create \ + --resource-group openshield-rg \ + --workspace-name openshield-laws \ + --name default +``` + +--- ## Part 3 - Set Environment Variables +`sentinel/ingest.py` reads these variables: + +```bash export SENTINEL_WORKSPACE_ID="your-workspace-id" export SENTINEL_SHARED_KEY="your-shared-key" export SENTINEL_LOG_TYPE="OpenShieldFindings" -export AZURE_SUBSCRIPTION_ID="your-subscription-id" -export AZURE_TENANT_ID="your-tenant-id" -export AZURE_CLIENT_ID="your-client-id" -export AZURE_CLIENT_SECRET="your-client-secret" +``` + +`SENTINEL_LOG_TYPE` is optional. If it is not set, the script uses `OpenShieldFindings`. + +--- ## Part 4 - Run Ingestion -Install dependencies: -pip install requests +The ingestion script accepts: + +```bash +python3 sentinel/ingest.py +``` + +If no path is supplied, it defaults to `scanner/output/test_findings.json`. If no scan ID is supplied, it generates one using the current UTC timestamp. Generate test findings: + +```bash +mkdir -p scanner/output python3 sentinel/tests/generate_test_findings.py +``` Push findings to Sentinel: + +```bash python3 sentinel/ingest.py scanner/output/test_findings.json scan-001 +``` + +The script accepts either a JSON list of findings or an object with a `findings` array. It normalises each record, signs the request with `SENTINEL_SHARED_KEY`, and posts to the Log Analytics Data Collector API. + +--- ## Part 5 - Verify in Sentinel Logs Run this query in Log Analytics: + +```kql OpenShieldFindings_CL | take 10 +``` -If you see rows the ingestion is working correctly. +If rows appear, ingestion is working. + +--- ## Part 6 - Deploy KQL Rules in Sentinel Analytics -Go to Microsoft Sentinel or Microsoft Defender XDR and navigate to Analytics. Create a Scheduled query rule for each file in sentinel/rules/ +Go to Microsoft Sentinel or Microsoft Defender XDR and navigate to Analytics. Create a scheduled query rule for each file in `sentinel/rules/`: + +| Rule file | Severity | Schedule | +|---|---|---| +| `high_severity_finding.kql` | High | Every 1 hour | +| `misconfiguration_wave.kql` | High | Every 2 hours | +| `persistent_misconfiguration.kql` | Medium | Every 24 hours | +| `new_resource_type_critical.kql` | Critical | Every 1 hour | -high_severity_finding.kql - Severity High - Run every 1 hour -misconfiguration_wave.kql - Severity High - Run every 2 hours -persistent_misconfiguration.kql - Severity Medium - Run every 24 hours -new_resource_type_critical.kql - Severity Critical - Run every 1 hour +Set the alert threshold to greater than 0 for all rules. -Set alert threshold to greater than 0 for all rules. +--- ## Part 7 - Verify Incidents -Go to Incidents in Sentinel or Microsoft Defender XDR. Within a few hours of deploying the rules you should see OpenShield incidents appearing automatically. +Go to Incidents in Sentinel or Microsoft Defender XDR. After the scheduled analytics rules run, OpenShield incidents should appear for matching findings. From 1cd89dd1b95195cfd63a0ebe0b38d9f6401ac0fe Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 28/50] docs: update sentinel/TEST_PLAN.md to reflect current codebase state --- sentinel/TEST_PLAN.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sentinel/TEST_PLAN.md b/sentinel/TEST_PLAN.md index c32a26c..965c810 100644 --- a/sentinel/TEST_PLAN.md +++ b/sentinel/TEST_PLAN.md @@ -26,7 +26,7 @@ Objective: Confirm findings from scanner reach Log Analytics Result: PASS -12 findings confirmed in OpenShieldFindings_CL table. Table created automatically on first ingestion. All fields correctly mapped including Severity_s, RuleName_s, ResourceName_s, CisControl_s. +10 findings confirmed in OpenShieldFindings_CL table. Table created automatically on first ingestion. Fields are mapped by `sentinel/ingest.py`, including Severity_s, RuleName_s, ResourceName_s, CisControl_s, and NistControl_s. --- @@ -36,15 +36,18 @@ Objective: Rule fires on any HIGH or CRITICAL finding Result: PASS -7 distinct findings returned: +10 distinct high or critical findings returned: +- Public blob storage container - High - testblob001 - Unencrypted managed disk - Critical - vm-disk-001 +- NSG allows RDP from internet - High - nsg-open-rdp - NSG allows SSH from internet - High - nsg-open-ssh - Key Vault purge protection disabled - High - kv-nopurge - SQL Server TDE disabled - High - sql-no-tde - App Service HTTP not disabled - High - webapp-http - Container registry admin enabled - High - acr-admin - Overprivileged service principal - High - sp-contributor +- Container instance privileged execution - Critical - aci-suspicious --- @@ -55,11 +58,11 @@ Objective: Rule fires when 5 or more HIGH findings appear in a single scan Result: PASS - Scan ID: scan-openshield-001 -- Total HIGH/CRITICAL findings: 12 +- Total HIGH/CRITICAL findings: 10 - Unique rules triggered: 10 -- Wave Score: 120 +- Wave Score: 100 -Wave score of 120 confirmed. Rule correctly identifies bulk misconfiguration event. +Wave score of 100 confirmed. Rule correctly identifies bulk misconfiguration event. --- @@ -122,6 +125,7 @@ pip install requests Generate test findings: +mkdir -p scanner/output python3 sentinel/tests/generate_test_findings.py Ingest into Sentinel: From a2fed2e4e2fe52e4ed8a39f4145b4563cb54d696 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 29/50] docs: update docs/api-reference.md to reflect current codebase state --- docs/api-reference.md | 252 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/api-reference.md diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..3d37a58 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,252 @@ +# API Reference + +The OpenShield API is a Flask app registered in `api/app.py`. `/health` is public. All `/api/*` routes require an `Authorization: Bearer ` header signed with `JWT_SECRET`. + +--- + +## GET /health + +Health check for the API process. + +Query parameters: none + +Example response: + +```json +{ + "status": "ok" +} +``` + +--- + +## GET /api/findings + +Returns findings, optionally filtered by severity, category, rule ID, or scan ID. + +Query parameters: + +| Name | Description | +|---|---| +| `severity` | `HIGH`, `MEDIUM`, `LOW`, or `INFO` | +| `category` | Rule category, such as `Storage`, `Network`, `Identity`, `Database`, `Compute`, or `Key Vault` | +| `rule_id` | Rule ID, such as `AZ-STOR-001` | +| `scan_id` | UUID of a specific scan | + +Example response: + +```json +{ + "count": 1, + "findings": [ + { + "id": 42, + "scan_id": "6f4a08ac-7d3a-4d9a-a4b4-2a26e5f63c8a", + "rule_id": "AZ-STOR-001", + "rule_name": "Public Blob Access Enabled on Storage Account", + "severity": "HIGH", + "category": "Storage", + "resource_id": "/subscriptions/example/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/example", + "resource_name": "example", + "resource_type": "Microsoft.Storage/storageAccounts", + "description": "Storage accounts with public blob access enabled allow unauthenticated read access to blob data over the internet.", + "remediation": "Disable public blob access on the storage account.", + "playbook": "playbooks/cli/fix_az_stor_001.sh", + "frameworks": { + "CIS": "3.5", + "NIST": "PR.AC-3", + "ISO27001": "A.9.4.1" + }, + "metadata": {}, + "detected_at": "2026-05-09T12:00:00Z" + } + ] +} +``` + +--- + +## GET /api/findings/<finding_id> + +Returns one finding by integer ID. + +Query parameters: none + +Example response: + +```json +{ + "id": 42, + "scan_id": "6f4a08ac-7d3a-4d9a-a4b4-2a26e5f63c8a", + "rule_id": "AZ-STOR-001", + "rule_name": "Public Blob Access Enabled on Storage Account", + "severity": "HIGH", + "category": "Storage", + "resource_id": "/subscriptions/example/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/example", + "resource_name": "example", + "resource_type": "Microsoft.Storage/storageAccounts", + "description": "Storage accounts with public blob access enabled allow unauthenticated read access to blob data over the internet.", + "remediation": "Disable public blob access on the storage account.", + "playbook": "playbooks/cli/fix_az_stor_001.sh", + "frameworks": { + "CIS": "3.5", + "NIST": "PR.AC-3", + "ISO27001": "A.9.4.1" + }, + "metadata": {}, + "detected_at": "2026-05-09T12:00:00Z" +} +``` + +Not found response: + +```json +{ + "error": "Finding not found" +} +``` + +--- + +## GET /api/scans + +Returns historical scan records ordered by most recent first. + +Query parameters: none + +Example response: + +```json +{ + "count": 1, + "scans": [ + { + "scan_id": "6f4a08ac-7d3a-4d9a-a4b4-2a26e5f63c8a", + "subscription_id": "00000000-0000-0000-0000-000000000000", + "started_at": "2026-05-09T12:00:00Z", + "completed_at": "2026-05-09T12:02:00Z", + "total_findings": 3 + } + ] +} +``` + +--- + +## POST /api/scans/trigger + +Runs a synchronous scan and saves the result to PostgreSQL. The request body may include `subscription_id`; otherwise the API uses `AZURE_SUBSCRIPTION_ID`. + +Request body: + +```json +{ + "subscription_id": "00000000-0000-0000-0000-000000000000" +} +``` + +Example response: + +```json +{ + "scan_id": "6f4a08ac-7d3a-4d9a-a4b4-2a26e5f63c8a", + "subscription_id": "00000000-0000-0000-0000-000000000000", + "started_at": "2026-05-09T12:00:00+00:00", + "completed_at": "2026-05-09T12:02:00+00:00", + "total_findings": 1, + "findings": [ + { + "rule_id": "AZ-STOR-001", + "rule_name": "Public Blob Access Enabled on Storage Account", + "severity": "HIGH", + "category": "Storage", + "resource_id": "/subscriptions/example/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/example", + "resource_name": "example", + "resource_type": "Microsoft.Storage/storageAccounts", + "description": "Storage accounts with public blob access enabled allow unauthenticated read access to blob data over the internet.", + "remediation": "Disable public blob access on the storage account.", + "playbook": "playbooks/cli/fix_az_stor_001.sh", + "frameworks": { + "CIS": "3.5", + "NIST": "PR.AC-3", + "ISO27001": "A.9.4.1" + }, + "metadata": {}, + "detected_at": "2026-05-09T12:00:00+00:00", + "scan_id": "6f4a08ac-7d3a-4d9a-a4b4-2a26e5f63c8a" + } + ] +} +``` + +Missing subscription response: + +```json +{ + "error": "subscription_id is required" +} +``` + +--- + +## GET /api/score + +Returns the overall security posture score from 0 to 100. The score starts at 100 and deducts 10 per HIGH finding, 5 per MEDIUM finding, and 2 per LOW finding. + +Query parameters: none + +Example response: + +```json +{ + "score": 82, + "max_score": 100 +} +``` + +--- + +## GET /api/compliance/<framework> + +Returns a pass/fail control breakdown for a supported compliance framework. + +Supported frameworks: + +| Path value | Framework file | +|---|---| +| `cis` | `cis_azure_benchmark.json` | +| `nist` | `nist_csf.json` | +| `iso27001` | `iso27001.json` | +| `soc2` | `soc2.json` | + +Query parameters: none + +Example response: + +```json +{ + "framework": "CIS Microsoft Azure Foundations Benchmark", + "version": "2.0.0", + "total_controls": 20, + "passed": 19, + "failed": 1, + "score_percent": 95, + "controls": [ + { + "rule_id": "AZ-STOR-001", + "control_id": "3.5", + "control_name": "Ensure that 'Public access level' is set to Private for blob containers", + "status": "FAIL" + } + ] +} +``` + +Unknown framework response: + +```json +{ + "error": "Unknown framework 'pci'", + "supported": ["cis", "nist", "iso27001", "soc2"] +} +``` From 98894bce5524d5244115e8ee46e5bfaad238d58e Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 15:44:48 +0100 Subject: [PATCH 30/50] docs: update docs/rules-reference.md to reflect current codebase state --- docs/rules-reference.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/rules-reference.md diff --git a/docs/rules-reference.md b/docs/rules-reference.md new file mode 100644 index 0000000..c57d730 --- /dev/null +++ b/docs/rules-reference.md @@ -0,0 +1,28 @@ +# Rules Reference + +OpenShield currently ships 20 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. + +| Rule ID | Name | Severity | Category | CIS | NIST | ISO27001 | +|---|---|---|---|---|---|---| +| AZ-CMP-001 | VM with Public IP and No Associated NSG on Network Interface | HIGH | Compute | 7.2 | PR.AC-3 | A.13.1.1 | +| AZ-DB-001 | PostgreSQL Server Allows Public Network Access | HIGH | Database | 4.3.1 | PR.AC-3 | A.13.1.1 | +| AZ-DB-002 | Azure SQL Server Has No Auditing Configured | MEDIUM | Database | 4.1.3 | DE.CM-7 | A.12.4.1 | +| AZ-IDN-001 | Service Principal Assigned Owner Role at Subscription Scope | HIGH | Identity | 1.23 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-002 | No MFA Enforced on Admin Accounts via Conditional Access | HIGH | Identity | 1.2.4 | PR.AC-1 | A.9.4.2 | +| AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | 8.5 | PR.IP-4 | A.17.2.1 | +| AZ-KV-002 | Key Vault Allows Public Network Access Without Private Endpoint | HIGH | Key Vault | 8.3 | AC-17 | A.13.1.1 | +| AZ-NET-001 | NSG Allows Unrestricted Inbound SSH from Any Source | HIGH | Network | 6.2 | PR.AC-3 | A.13.1.1 | +| AZ-NET-002 | NSG Allows Unrestricted Inbound RDP from Any Source | HIGH | Network | 6.3 | PR.AC-3 | A.13.1.1 | +| AZ-NET-003 | NSG allows unrestricted inbound on port 443 | HIGH | Network | 9.3 | SC-7 | A.13.1.1 | +| AZ-NET-004 | NSG with no rules configured | MEDIUM | Network | 9.2 | SC-7 | A.13.1.1 | +| AZ-NET-005 | Virtual network with no DDoS protection enabled | LOW | Network | 9.4 | SC-5 | A.13.1.1 | +| AZ-NET-006 | Public IP address unassociated with any resource | LOW | Network | 9.1 | CM-7 | A.13.1.1 | +| AZ-NET-007 | Application Gateway without WAF enabled | HIGH | Network | 9.6 | SI-3 | A.13.1.1 | +| AZ-NET-008 | Load balancer with no backend pool configured | LOW | Network | 9.1 | CM-7 | A.13.1.1 | +| AZ-NET-009 | VPN gateway using outdated IKE version | HIGH | Network | 9.5 | SC-8 | A.13.2.1 | +| AZ-NET-010 | Subnet with no network security group attached | HIGH | Network | 9.2 | SC-7 | A.13.1.1 | +| AZ-STOR-001 | Public Blob Access Enabled on Storage Account | HIGH | Storage | 3.5 | PR.AC-3 | A.9.4.1 | +| AZ-STOR-002 | Storage Account Allows HTTP Traffic (Not HTTPS-Only) | HIGH | Storage | 3.1 | PR.DS-2 | A.10.1.1 | +| AZ-STOR-003 | Storage Account Has No Lifecycle Management Policy | MEDIUM | Storage | 3.7 | PR.DS-3 | A.8.3.1 | + +SOC 2 mappings are maintained in `compliance/frameworks/soc2.json`. From 85bbb7f845fa1453878fdc56c0b5636136830cb3 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 16:02:35 +0100 Subject: [PATCH 31/50] docs: update README.md for professional open source style --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 21b6571..399437b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🛡️ OpenShield +# OpenShield > **Open source Cloud Security Posture Management (CSPM) for Azure — built by the community, for the community.** @@ -32,19 +32,19 @@ Startups, SMEs, universities, and student teams are left with **zero visibility* --- -## 🏗️ Architecture +## Architecture ```mermaid flowchart TD - A["🌐 React Dashboard MVP\nPlanned frontend"] - B["⚙️ Flask REST API\nJWT · CORS · Blueprints"] - C["🔍 Scanner Engine\n20 Python rules"] - D["☁️ Azure Subscription\nScanned via Azure SDK + Graph"] - E["📋 Compliance Framework JSON\nCIS · NIST · ISO 27001 · SOC 2"] - F["🗄️ PostgreSQL Database\nFindings · Scans"] - G["🔧 Azure CLI Playbooks\n20 remediation scripts"] - H["🛡️ sentinel/ingest.py\nNormalise + HMAC upload"] - I["📈 Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"] + A["React Dashboard MVP\nPlanned frontend"] + B["Flask REST API\nJWT · CORS · Blueprints"] + C["Scanner Engine\n20 Python rules"] + D["Azure Subscription\nScanned via Azure SDK + Graph"] + E["Compliance Framework JSON\nCIS · NIST · ISO 27001 · SOC 2"] + F["PostgreSQL Database\nFindings · Scans"] + G["Azure CLI Playbooks\n20 remediation scripts"] + H["sentinel/ingest.py\nNormalise + HMAC upload"] + I["Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"] A -->|REST calls| B B -->|trigger scans| C @@ -130,24 +130,24 @@ FLASK_APP=api/app.py flask run --- -## 🤝 Contributing +## Contributing We actively welcome contributions from students and developers at all levels. **Ways to contribute:** -- 🔍 Add a new misconfiguration scan rule -- 📋 Add a compliance framework mapping -- 🔧 Write a remediation playbook -- 🐛 Fix a bug -- 📖 Improve documentation +- Add a new misconfiguration scan rule +- Add a compliance framework mapping +- Write a remediation playbook +- Fix a bug +- Improve documentation -👉 See [CONTRIBUTING.md](CONTRIBUTING.md) for a full guide — including how to add your first rule in under 30 minutes. +See [CONTRIBUTING.md](CONTRIBUTING.md) for a full guide — including how to add your first rule in under 30 minutes. Contributors are credited below. --- -## 📍 Roadmap +## Roadmap - [x] Project scaffolding - [x] Core scanner engine (Azure SDK integration) @@ -179,10 +179,10 @@ Thanks to everyone who has contributed to OpenShield. --- -## 📄 License +## License MIT — free to use, modify, and distribute. --- -> Built with ❤️ by security engineers and students who believe cloud security tooling should be accessible to everyone. +> Built by security engineers and students who believe cloud security tooling should be accessible to everyone. From 0643eaf0199f06b2ff8418424398638cd7fbc10a Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 16:02:35 +0100 Subject: [PATCH 32/50] docs: update CONTRIBUTING.md for professional open source style --- CONTRIBUTING.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53e3fa9..7424506 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,26 @@ -# 🤝 Contributing to OpenShield +# Contributing to OpenShield Welcome! OpenShield is built by the community — students, developers, and security engineers at every level. This guide will get you contributing in under 30 minutes. --- -## 🧭 What Can I Contribute? +## What Can I Contribute? | Contribution Type | Difficulty | Time | |---|---|---| -| New misconfiguration scan rule | ⭐ Beginner | 20–30 min | -| Remediation playbook (CLI) | ⭐ Beginner | 30 min | -| Compliance framework mapping | ⭐⭐ Intermediate | 1–2 hrs | -| New API endpoint | ⭐⭐ Intermediate | 2–4 hrs | -| Dashboard MVP work | ⭐⭐ Intermediate | 2–4 hrs | -| KQL detection rule (Sentinel) | ⭐⭐⭐ Advanced | 3–5 hrs | -| Scanner engine feature | ⭐⭐⭐ Advanced | 4–8 hrs | +| New misconfiguration scan rule | Beginner | 20–30 min | +| Remediation playbook (CLI) | Beginner | 30 min | +| Compliance framework mapping | Intermediate | 1–2 hrs | +| New API endpoint | Intermediate | 2–4 hrs | +| Dashboard MVP work | Intermediate | 2–4 hrs | +| KQL detection rule (Sentinel) | Advanced | 3–5 hrs | +| Scanner engine feature | Advanced | 4–8 hrs | **Start with a scan rule — it's the most impactful and beginner-friendly contribution.** --- -## ⚡ Adding a Scan Rule (The Fastest Way to Contribute) +## Adding a Scan Rule (The Fastest Way to Contribute) Every misconfiguration rule is a self-contained Python file in `scanner/rules/`. @@ -111,7 +111,7 @@ az storage account update \ --resource-group $RESOURCE_GROUP \ --allow-blob-public-access false -echo "✅ Public blob access disabled for $STORAGE_ACCOUNT" +echo "Public blob access disabled for $STORAGE_ACCOUNT" ``` ### Step 5 — Test Your Rule @@ -166,7 +166,7 @@ Closes #123 --- -## 📋 Rule ID Convention +## Rule ID Convention Use the format: `AZ-[CATEGORY]-[NUMBER]` @@ -208,7 +208,7 @@ Most list methods return an empty list on failure. Methods that fetch one resour --- -## 🛠️ Local Dev Setup +## Local Dev Setup ```bash # Python 3.10+ @@ -231,7 +231,7 @@ docker run --name openshield-db \ --- -## 📐 Code Standards +## Code Standards - Python: follow PEP8, use type hints where possible - Dashboard work: functional React components only, Tailwind for styling when the dashboard app lands @@ -241,7 +241,7 @@ docker run --name openshield-db \ --- -## 🏅 Recognition +## Recognition Every contributor is listed in the README. @@ -252,10 +252,10 @@ If you contribute 3+ rules or a major feature, you get: --- -## 💬 Need Help? +## Need Help? - **Discord:** Join `#openshield-dev` — ask anything, no question is too basic - **GitHub Discussions:** For longer technical questions - **Issues:** Tag `@core-team` if you're stuck on a PR -We respond within 24 hours. Welcome to the team. 🛡️ +We respond within 24 hours. Welcome to the team. From 5ebcdd9b0610a83741501f75ad433982399ffd94 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 16:02:35 +0100 Subject: [PATCH 33/50] docs: update docs/adding-a-rule.md for professional open source style --- docs/adding-a-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index 35f9516..46e29de 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -161,7 +161,7 @@ az update \ --name "$RESOURCE_NAME" \ -- -echo "✅ Remediation complete for $RESOURCE_NAME" +echo "Remediation complete for $RESOURCE_NAME" ``` --- From 2d230dde661cc21edeed936dc45f5127614a0af4 Mon Sep 17 00:00:00 2001 From: Vishnu Ajith <27vishnu07@gmail.com> Date: Sat, 9 May 2026 18:28:48 +0100 Subject: [PATCH 34/50] docs: update deployment guide to use Render instead of Azure App Service --- docs/azure-setup.md | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/azure-setup.md b/docs/azure-setup.md index 9cf0d73..5c672d9 100644 --- a/docs/azure-setup.md +++ b/docs/azure-setup.md @@ -175,6 +175,57 @@ Compliance posture is available through `/api/compliance/cis`, `/api/compliance/ --- +## Azure App Service Deployment + +> **Note:** The Flask API is deployed on Render (render.com) rather than Azure App Service F1. Azure App Service F1 sleeps after 20 minutes of inactivity and has a 60 CPU minute per day limit which is not suitable for demo use. See the Render deployment section below for setup instructions. + +--- + +## Render Deployment (Recommended for API) + +Render provides a free tier that is better suited for the OpenShield API than Azure App Service F1. + +### Steps + +1. Create a free account at render.com +2. Click New → Web Service +3. Connect your GitHub account and select `openshield-org/openshield` +4. Configure: + - Name: `openshield-api` + - Branch: `main` + - Build Command: `pip install -r requirements.txt` + - Start Command: `gunicorn api.app:create_app()` + - Instance Type: `Free` + +5. Add environment variables under Environment: + +``` +AZURE_SUBSCRIPTION_ID=your-subscription-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret +AZURE_TENANT_ID=your-tenant-id +DATABASE_URL=your-postgresql-connection-string +JWT_SECRET=your-secret-key +``` + +6. Create a PostgreSQL database: + - Click New → PostgreSQL + - Name: `openshield-db` + - Copy the Internal Database URL into `DATABASE_URL` above + +7. Deploy — Render will build and deploy automatically + +8. Your API will be live at: + `https://openshield-api.onrender.com` + +### Known Limitations + +- Free tier spins down after 15 minutes of inactivity +- First request after spin down takes 30 to 60 seconds +- Suitable for demo and testing, not production + +--- + ## Step 8 — Activate the Microsoft Sentinel 90-Day Trial (Optional) Microsoft Sentinel includes a 90-day free trial for new Log Analytics workspaces. From d4384fe688402f4e42ad6f635b044ade8679f3f9 Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Wed, 13 May 2026 07:59:32 +0100 Subject: [PATCH 35/50] feat: add rule AZ-STOR-004 storage account diagnostic logging check (#39) * feat: add rule AZ-STOR-004 storage account diagnostic logging check Detects Azure storage accounts where diagnostic logging is not fully enabled on blob, queue, or table services. Emits one finding per non-compliant service (StorageRead, StorageWrite, StorageDelete must all be enabled). Adds get_storage_service_logging() to AzureClient using MonitorManagementClient. Includes remediation playbook that enables all three services in one run. Frameworks: CIS 3.3, NIST DE.CM-7, ISO 27001 A.12.4.1 * chore: add AZ-STOR-004 compliance mappings --------- Co-authored-by: Shaurya K Sharma --- .../frameworks/cis_azure_benchmark.json | 5 + compliance/frameworks/iso27001.json | 5 + compliance/frameworks/nist_csf.json | 5 + playbooks/cli/fix_az_stor_004.sh | 150 ++++++++++++++++++ requirements.txt | 1 + scanner/azure_client.py | 78 +++++++++ scanner/rules/az_stor_004.py | 121 ++++++++++++++ 7 files changed, 365 insertions(+) create mode 100644 playbooks/cli/fix_az_stor_004.sh create mode 100644 scanner/rules/az_stor_004.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 25552aa..8ba0f20 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -98,6 +98,11 @@ "control_name": "Ensure that storage accounts have lifecycle management policies configured", "description": "Storage accounts without lifecycle management policies retain data indefinitely. This increases storage costs, expands the attack surface through accumulation of stale data, and may violate data retention compliance requirements. Lifecycle policies automate the transition and deletion of blobs based on age and access patterns." }, + "AZ-STOR-004": { + "control_id": "3.3", + "control_name": "Ensure Storage logging is enabled for Blob, Queue, and Table services for read, write, and delete requests", + "description": "Enabling diagnostic logging for Azure Storage blob, queue, and table services records read, write, and delete operations. Without logging, unauthorized access, data exfiltration, or destructive operations on storage services cannot be detected or investigated." + }, "AZ-KV-002": { "control_id": "8.3", "control_name": "Ensure that public network access to Key Vault is disabled", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 414d761..85d341d 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -98,6 +98,11 @@ "control_name": "Management of removable media", "description": "Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism. Lifecycle management supports formal retention, tiering, and disposal of information assets." }, + "AZ-STOR-004": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Diagnostic logging must be enabled on Azure Storage blob, queue, and table services to produce event logs for read, write, and delete operations. Event logs recording user activities, exceptions, and information security events should be produced, kept, and regularly reviewed." + }, "AZ-KV-002": { "control_id": "A.13.1.1", "control_name": "Network controls", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index cd421ed..934966d 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -102,6 +102,11 @@ "control_id": "PR.DS-3", "control_name": "Assets are formally managed throughout removal, transfers, and disposition", "description": "NIST CSF PR.DS-3 requires that data assets are managed through their full lifecycle including secure disposal. Storage accounts without a lifecycle management policy have no automated mechanism for expiring or deleting aged data, meaning data subject to disposal requirements persists indefinitely and is never formally retired from the asset inventory." + }, + "AZ-STOR-004": { + "control_id": "DE.CM-7", + "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", + "description": "Diagnostic logging on Azure Storage services provides the audit trail needed to monitor for unauthorized or anomalous read, write, and delete operations. Without logging, detection of data exfiltration or unauthorized access to blob, queue, or table services is not possible." } } } diff --git a/playbooks/cli/fix_az_stor_004.sh b/playbooks/cli/fix_az_stor_004.sh new file mode 100644 index 0000000..c565f68 --- /dev/null +++ b/playbooks/cli/fix_az_stor_004.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-STOR-004 — Storage Account Diagnostic Logging Disabled +# Usage: ./fix_az_stor_004.sh +# Severity: MEDIUM +# +# What this script does: +# Enables Azure Monitor diagnostic settings on the blob, queue, and table +# service sub-resources of the specified storage account. Each service gets +# a diagnostic setting named "openshield-storage-logging" with StorageRead, +# StorageWrite, and StorageDelete enabled at a 90-day retention. Logs are +# written to the destination storage account you supply. +# +# Prerequisites: +# - Azure CLI installed and logged in (az login) +# - Contributor or Monitoring Contributor role on the target subscription +# - A destination storage account for logs (pass its full resource ID) +# +# Example: +# ./fix_az_stor_004.sh my-rg my-storage-account \ +# /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/log-rg/providers/Microsoft.Storage/storageAccounts/logstore + +set -euo pipefail + +RESOURCE_GROUP="${1:-}" +STORAGE_ACCOUNT="${2:-}" +LOG_STORAGE_ACCOUNT_ID="${3:-}" + +# ── Argument validation ────────────────────────────────────────────────────── + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$STORAGE_ACCOUNT" ] || [ -z "$LOG_STORAGE_ACCOUNT_ID" ]; then + echo "Usage: $0 " + echo "" + echo "Arguments:" + echo " resource-group Resource group of the storage account to remediate" + echo " storage-account-name Name of the storage account to remediate" + echo " log-storage-account-id Full Azure resource ID of the destination log storage account" + echo "" + echo "Example:" + echo " $0 my-rg my-storage \\" + echo " /subscriptions//resourceGroups/log-rg/providers/Microsoft.Storage/storageAccounts/logstore" + exit 1 +fi + +# ── Validate names contain only Azure-safe characters ─────────────────────── + +if ! [[ "$RESOURCE_GROUP" =~ ^[a-zA-Z0-9._()-]+$ ]]; then + echo "ERROR: resource-group contains invalid characters: '$RESOURCE_GROUP'" + exit 1 +fi + +if ! [[ "$STORAGE_ACCOUNT" =~ ^[a-z0-9]{3,24}$ ]]; then + echo "ERROR: storage-account-name must be 3-24 lowercase letters and numbers only." + exit 1 +fi + +# ── Resolve subscription ID ────────────────────────────────────────────────── + +SUBSCRIPTION_ID=$(az account show --query id -o tsv) +if [ -z "$SUBSCRIPTION_ID" ]; then + echo "ERROR: Could not determine subscription ID. Run 'az login' first." + exit 1 +fi + +# ── Build base resource ID ─────────────────────────────────────────────────── + +BASE_ID="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${STORAGE_ACCOUNT}" + +BLOB_RESOURCE_ID="${BASE_ID}/blobServices/default" +QUEUE_RESOURCE_ID="${BASE_ID}/queueServices/default" +TABLE_RESOURCE_ID="${BASE_ID}/tableServices/default" + +LOG_SETTING_NAME="openshield-storage-logging" + +LOG_CATEGORIES='[ + {"category":"StorageRead","enabled":true,"retentionPolicy":{"days":90,"enabled":true}}, + {"category":"StorageWrite","enabled":true,"retentionPolicy":{"days":90,"enabled":true}}, + {"category":"StorageDelete","enabled":true,"retentionPolicy":{"days":90,"enabled":true}} +]' + +# ── Confirm before making changes ──────────────────────────────────────────── + +echo "============================================================" +echo " OpenShield Remediation — AZ-STOR-004" +echo "============================================================" +echo "" +echo " Storage account : $STORAGE_ACCOUNT" +echo " Resource group : $RESOURCE_GROUP" +echo " Log destination : $LOG_STORAGE_ACCOUNT_ID" +echo "" +echo " Services to configure:" +echo " - blobServices/default" +echo " - queueServices/default" +echo " - tableServices/default" +echo "" +echo " Each service will have diagnostic setting '$LOG_SETTING_NAME' with:" +echo " StorageRead, StorageWrite, StorageDelete (retention 90 days)" +echo "" +read -r -p "Proceed? [y/N] " CONFIRM +if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Aborted. No changes were made." + exit 0 +fi + +# ── Enable diagnostic settings on all three services ──────────────────────── + +echo "" +echo "[1/3] Enabling diagnostic logging on blob service ..." +az monitor diagnostic-settings create \ + --resource "$BLOB_RESOURCE_ID" \ + --name "$LOG_SETTING_NAME" \ + --storage-account "$LOG_STORAGE_ACCOUNT_ID" \ + --logs "$LOG_CATEGORIES" +echo " Done." + +echo "" +echo "[2/3] Enabling diagnostic logging on queue service ..." +az monitor diagnostic-settings create \ + --resource "$QUEUE_RESOURCE_ID" \ + --name "$LOG_SETTING_NAME" \ + --storage-account "$LOG_STORAGE_ACCOUNT_ID" \ + --logs "$LOG_CATEGORIES" +echo " Done." + +echo "" +echo "[3/3] Enabling diagnostic logging on table service ..." +az monitor diagnostic-settings create \ + --resource "$TABLE_RESOURCE_ID" \ + --name "$LOG_SETTING_NAME" \ + --storage-account "$LOG_STORAGE_ACCOUNT_ID" \ + --logs "$LOG_CATEGORIES" +echo " Done." + +# ── Confirmation ───────────────────────────────────────────────────────────── + +echo "" +echo "============================================================" +echo " Remediation complete for: $STORAGE_ACCOUNT" +echo "============================================================" +echo "" +echo " Diagnostic setting '$LOG_SETTING_NAME' created on:" +echo " blobServices/default — StorageRead, StorageWrite, StorageDelete (90-day retention)" +echo " queueServices/default — StorageRead, StorageWrite, StorageDelete (90-day retention)" +echo " tableServices/default — StorageRead, StorageWrite, StorageDelete (90-day retention)" +echo "" +echo " To verify:" +echo " az monitor diagnostic-settings list --resource $BLOB_RESOURCE_ID" +echo " az monitor diagnostic-settings list --resource $QUEUE_RESOURCE_ID" +echo " az monitor diagnostic-settings list --resource $TABLE_RESOURCE_ID" +echo "============================================================" diff --git a/requirements.txt b/requirements.txt index 74c911f..ed1678f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ azure-mgmt-keyvault==10.3.0 azure-mgmt-rdbms==10.1.0 azure-mgmt-authorization==4.0.0 azure-monitor-ingestion==1.0.3 +azure-mgmt-monitor==6.0.0 psycopg2-binary==2.9.9 python-dotenv==1.0.0 pyjwt==2.8.0 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index e68c06c..e9df038 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -11,6 +11,7 @@ from azure.mgmt.network import NetworkManagementClient from azure.mgmt.rdbms.postgresql import PostgreSQLManagementClient from azure.mgmt.sql import SqlManagementClient +from azure.mgmt.monitor import MonitorManagementClient from azure.mgmt.storage import StorageManagementClient logger = logging.getLogger(__name__) @@ -128,6 +129,83 @@ def get_storage_lifecycle_policy( ) return None + def get_storage_service_logging( + self, resource_group: str, account_name: str, service: str + ) -> Optional[bool]: + """Check Azure Monitor diagnostic settings for a storage service sub-resource. + + Three-state return — the calling rule uses strict identity checks + (is False / is None) to distinguish these states: + + True — at least one diagnostic setting has StorageRead, StorageWrite, + and StorageDelete all enabled (compliant). + False — no setting covers all three required categories (non-compliant). + None — permission error or unexpected SDK failure. + Caller must NOT create a finding — skip with a warning + to avoid false positives. + + Args: + resource_group: Resource group containing the storage account. + account_name: Name of the storage account. + service: Sub-service to check: "blob", "queue", or "table". + + Returns: + Optional[bool] — True, False, or None as described above. + """ + _REQUIRED = {"StorageRead", "StorageWrite", "StorageDelete"} + _SERVICE_MAP = { + "blob": "blobServices", + "queue": "queueServices", + "table": "tableServices", + } + svc_path = _SERVICE_MAP.get(service) + if not svc_path: + logger.error( + "get_storage_service_logging: unknown service %r — must be " + "blob, queue, or table", + service, + ) + return None + + resource_uri = ( + f"/subscriptions/{self.subscription_id}" + f"/resourceGroups/{resource_group}" + f"/providers/Microsoft.Storage/storageAccounts/{account_name}" + f"/{svc_path}/default" + ) + try: + client = MonitorManagementClient(self.credential, self.subscription_id) + settings = list(client.diagnostic_settings.list(resource_uri)) + for setting in settings: + enabled_categories = { + log.category + for log in (getattr(setting, "logs", None) or []) + if getattr(log, "enabled", False) + } + if _REQUIRED.issubset(enabled_categories): + return True + return False + + except HttpResponseError as exc: + logger.error( + "get_storage_service_logging(%s/%s) HTTP %s — " + "check service principal permissions: %s", + account_name, + service, + exc.status_code, + exc, + ) + return None + + except Exception as exc: + logger.error( + "get_storage_service_logging(%s/%s) unexpected error: %s", + account_name, + service, + exc, + ) + return None + # ------------------------------------------------------------------ # # Network # # ------------------------------------------------------------------ # diff --git a/scanner/rules/az_stor_004.py b/scanner/rules/az_stor_004.py new file mode 100644 index 0000000..17a167d --- /dev/null +++ b/scanner/rules/az_stor_004.py @@ -0,0 +1,121 @@ +"""AZ-STOR-004: Storage account diagnostic logging disabled for blob, queue, or table.""" + +import logging +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# ── Required module-level constants ───────────────────────────────────────── + +RULE_ID = "AZ-STOR-004" +RULE_NAME = "Storage Account Diagnostic Logging Disabled" +SEVERITY = "MEDIUM" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "3.3", + "NIST": "DE.CM-7", + "ISO27001": "A.12.4.1", +} +DESCRIPTION = ( + "Azure Monitor diagnostic logging is not fully enabled for the {service} " + "service on this storage account. StorageRead, StorageWrite, and " + "StorageDelete must all be enabled. Without logging, operations on this " + "service cannot be detected or investigated, making it impossible to " + "identify data exfiltration or unauthorised access. CIS Azure Benchmark " + "3.3 requires logging for blob, queue, and table services for read, write, " + "and delete requests." +) +REMEDIATION = ( + "Enable Azure Monitor diagnostic settings on the storage account's " + "{service} service with StorageRead, StorageWrite, and StorageDelete all " + "set to enabled. Navigate to: Storage Account > Monitoring > " + "Diagnostic settings > {service} > Add diagnostic setting, then check " + "StorageRead, StorageWrite, and StorageDelete." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_004.sh" + +# Maps service key → (sub-resource path segment, resource_type) +_SERVICES: Dict[str, Tuple[str, str]] = { + "blob": ("blobServices", "Microsoft.Storage/storageAccounts/blobServices"), + "queue": ("queueServices", "Microsoft.Storage/storageAccounts/queueServices"), + "table": ("tableServices", "Microsoft.Storage/storageAccounts/tableServices"), +} + + +# ── Required scan function ─────────────────────────────────────────────────── + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect storage account services with incomplete diagnostic logging. + + For each storage account, all three sub-services (blob, queue, table) are + checked independently. A separate finding is emitted for each service that + does not have StorageRead, StorageWrite, and StorageDelete all enabled. + + Three-state return from get_storage_service_logging(): + True — all three log categories enabled → skip (compliant) + False — one or more categories missing → create finding + None — permissions error or unexpected failure → skip with warning + to avoid false positives + + Args: + azure_client: An AzureClient instance with all SDK clients + pre-configured. + subscription_id: The Azure subscription ID being scanned. + + Returns: + A list of finding dicts — one per storage service sub-resource that + does not have full diagnostic logging. Services that could not be + checked are skipped and logged as warnings. + """ + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + resource_id = getattr(account, "id", "") + account_name = getattr(account, "name", "") + location = getattr(account, "location", "") + + if not resource_id or not account_name: + continue + + parsed = azure_client.parse_resource_id(resource_id) + resource_group = parsed.get("resource_group", "") + if not resource_group: + continue + + for service, (svc_path, resource_type) in _SERVICES.items(): + # True = compliant, False = logging incomplete, None = could not determine + logging_status: Optional[bool] = azure_client.get_storage_service_logging( + resource_group, account_name, service + ) + + if logging_status is None: + logger.warning( + "AZ-STOR-004: Could not determine %s logging status for %s " + "— skipping. Ensure the service principal has " + "microsoft.insights/diagnosticSettings/read permission.", + service, + account_name, + ) + continue + + if logging_status is False: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"{resource_id}/{svc_path}/default", + "resource_name": f"{account_name}/{svc_path}", + "resource_type": resource_type, + "description": DESCRIPTION.format(service=service), + "remediation": REMEDIATION.format(service=service), + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": resource_group, + "location": location, + "service": service, + }, + }) + + return findings From 826396ae89cd6115aff1f239beea5a1317fff14e Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Wed, 13 May 2026 08:00:40 +0100 Subject: [PATCH 36/50] feat: add rule AZ-IDN-003 Adds scanner rule AZ-IDN-003 detecting Entra ID (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add scanner rule AZ-IDN-003 — guest user invitations not restricted to admins * feat: add remediation playbook fix_az_idn_003.sh This script restricts guest user invitations to only admins and users with the Guest Inviter role in Azure Active Directory. * feat: add AZ-IDN-003 to CIS compliance framework Added control for guest invite restrictions to enhance security. * feat: add AZ-IDN-003 to NIST compliance framework * feat: add AZ-IDN-003 to ISO27001 compliance framework Added control AZ-IDN-003 for user registration and de-registration process. * feat: add AZ-IDN-003 to SOC2 compliance framework --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 5 ++ compliance/frameworks/nist_csf.json | 5 ++ compliance/frameworks/soc2.json | 5 ++ playbooks/cli/fix_az_idn_003.sh | 26 ++++++ scanner/rules/az_idn_003.py | 83 +++++++++++++++++++ 6 files changed, 129 insertions(+) create mode 100644 playbooks/cli/fix_az_idn_003.sh create mode 100644 scanner/rules/az_idn_003.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 8ba0f20..f5d453a 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -73,6 +73,11 @@ "control_name": "Ensure that 'Multi-Factor Authentication Status' is 'Enabled' for all Privileged Users", "description": "Multi-Factor Authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. MFA should be enforced for all users with administrative privileges via Conditional Access policies." }, + "AZ-IDN-003": { + "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-DB-001": { "control_id": "4.3.1", "control_name": "Ensure 'Allow access to Azure services' for PostgreSQL Database Server is disabled", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 85d341d..fb42f84 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -73,6 +73,11 @@ "control_name": "Secure log-on procedures", "description": "MFA enforces secure log-on for privileged accounts. Where required by the access control policy, access to systems and applications should be controlled by a secure log-on procedure including multi-factor authentication." }, + "AZ-IDN-003": { + "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 a formal user registration and de-registration process is implemented. Restricting guest invitations to administrators ensures external identity registration is formally controlled and audited." + }, "AZ-DB-001": { "control_id": "A.13.1.1", "control_name": "Network controls", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 934966d..9481855 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -73,6 +73,11 @@ "control_name": "Users, devices, and other assets are authenticated", "description": "MFA ensures privileged users are strongly authenticated before accessing Azure resources. Without MFA, a compromised password is sufficient for full administrative access." }, + "AZ-IDN-003": { + "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-DB-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 7de2257..e26b5b2 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -78,6 +78,11 @@ "control_name": "Logical Access Security Measures", "description": "Without MFA enforced on privileged accounts, a single compromised password grants full administrative access to the Azure environment. CC6.1 requires that logical access controls include strong authentication mechanisms. Enforcing MFA via Conditional Access policies ensures privileged access requires multiple factors of authentication." }, + "AZ-IDN-003": { + "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 restricted to authorised users. Restricting guest invitations to administrators ensures external identity provisioning is formally controlled and authorised." + }, "AZ-DB-001": { "control_id": "CC6.7", "control_name": "Protects Data in Transit", diff --git a/playbooks/cli/fix_az_idn_003.sh b/playbooks/cli/fix_az_idn_003.sh new file mode 100644 index 0000000..0b910d7 --- /dev/null +++ b/playbooks/cli/fix_az_idn_003.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-003 — Guest user invitations not restricted to admins in Entra ID +# Usage: ./fix_az_idn_003.sh +# Severity: MEDIUM +# +# Prerequisites: +# - Azure CLI logged in with a Global Administrator or User Administrator role +# - Microsoft Graph or az rest permissions + +set -e + +echo "Restricting guest user invitations to admins only..." + +az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \ + --headers "Content-Type=application/json" \ + --body '{ + "allowInvitesFrom": "adminsAndGuestInviters" + }' + +echo "Remediation complete." +echo "allowInvitesFrom is now set to: adminsAndGuestInviters" +echo "Only users assigned to the Guest Inviter role or admins can now invite external users." +echo "Review existing guest accounts to ensure they are still required." diff --git a/scanner/rules/az_idn_003.py b/scanner/rules/az_idn_003.py new file mode 100644 index 0000000..398d580 --- /dev/null +++ b/scanner/rules/az_idn_003.py @@ -0,0 +1,83 @@ +"""AZ-IDN-003: Guest user invitations not restricted to admins in Entra ID.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-003" +RULE_NAME = "Guest user invitations not restricted to admins in Entra ID" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "1.15", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1"} +DESCRIPTION = ( + "Guest user invitations in Entra ID are not restricted to administrators. " + "Any organisation member can invite external users into the tenant without " + "centralised review or approval. This bypasses formal external identity " + "provisioning controls and increases the risk of unauthorised access by " + "untrusted parties." +) +REMEDIATION = ( + "Restrict guest invitations to admins only by setting the " + "'allowInvitesFrom' policy to 'adminsAndGuestInviters' or 'admins' " + "in Entra ID. Navigate to: Entra ID > External Identities > " + "External collaboration settings > Guest invite settings. " + "Set to 'Only users assigned to specific admin roles can invite guest users'." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_003.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect unrestricted guest user invitation settings 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/policies/authorizationPolicy", + headers=headers, + timeout=30, + ) + response.raise_for_status() + policy = response.json() + + except Exception as exc: + logger.error( + "AZ-IDN-003: Failed to fetch authorization policy from Graph API: %s", exc + ) + logger.warning( + "AZ-IDN-003: Ensure the service principal has " + "Directory.Read.All permission on Microsoft Graph." + ) + return findings + + allow_invites_from = policy.get("allowInvitesFrom", "everyone") + + restricted_values = {"admins", "adminsAndGuestInviters"} + if allow_invites_from not in restricted_values: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/tenants/{policy.get('id', 'unknown')}/policies/authorizationPolicy", + "resource_name": "authorizationPolicy", + "resource_type": "Microsoft.Graph/authorizationPolicy", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "allow_invites_from": allow_invites_from, + "policy_id": policy.get("id", ""), + "display_name": policy.get("displayName", ""), + }, + }) + + return findings From cd47b687505cecbfd4183d7c03bcb98b4c5ca0a9 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Wed, 13 May 2026 08:01:19 +0100 Subject: [PATCH 37/50] =?UTF-8?q?feat:=20add=20rule=20AZ-CMP-002=20?= =?UTF-8?q?=E2=80=94=20VM=20disk=20not=20protected=20by=20CMK=20or=20ADE?= =?UTF-8?q?=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add scanner rule AZ-CMP-002 — VM disk not protected by CMK or ADE This script detects virtual machines whose disks use platform-managed encryption only and provides findings for compliance with CIS 7.2. * feat: add remediation playbook fix_az_cmp_002.sh This script enables Azure Disk Encryption on a specified virtual machine using a Key Vault for the disk encryption key. * feat: add AZ-CMP-002 to CIS compliance framework Added a new control for OS disk encryption requirements. * feat: add AZ-CMP-002 to NIST compliance framework * feat: add AZ-CMP-002 to ISO27001 compliance framework Added control AZ-CMP-002 regarding cryptographic controls policy and its requirements. * feat: add AZ-CMP-002 to SOC2 compliance framework * fix: correct indentation in CIS AZ-CMP-002 entry * feat: add remediation playbook fix_az_cmp_002.sh to correct location This script enables Azure Disk Encryption on a specified virtual machine using a provided Key Vault for disk encryption. * Delete fix_az_cmp_002.sh --- .../frameworks/cis_azure_benchmark.json | 5 + compliance/frameworks/iso27001.json | 5 + compliance/frameworks/nist_csf.json | 5 + compliance/frameworks/soc2.json | 5 + playbooks/cli/fix_az_cmp_002.sh | 39 ++++++ scanner/rules/az_cmp_002.py | 115 ++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 playbooks/cli/fix_az_cmp_002.sh create mode 100644 scanner/rules/az_cmp_002.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index f5d453a..4268aa1 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -93,6 +93,11 @@ "control_name": "Ensure that 'OS disk' are encrypted", "description": "Virtual machines that are reachable from the internet should have Network Security Groups attached to their network interfaces to control and restrict inbound and outbound traffic, reducing the attack surface." }, + "AZ-CMP-002": { + "control_id": "7.2", + "control_name": "Ensure that 'OS disk' are encrypted", + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CIS 7.2 requires disks to be protected using customer-managed keys or Azure Disk Encryption. Platform-managed encryption does not give the organisation control over the encryption keys and does not satisfy this control." + }, "AZ-KV-001": { "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index fb42f84..00ab6d2 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -93,6 +93,11 @@ "control_name": "Network controls", "description": "Virtual machines with public IPs and no NSG have unrestricted network access. Network controls should be applied to all compute resources accessible from the internet." }, + "AZ-CMP-002": { + "control_id": "A.10.1.1", + "control_name": "Policy on the use of cryptographic controls", + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). A.10.1.1 requires that a policy on the use of cryptographic controls is developed and implemented. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." + }, "AZ-KV-001": { "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 9481855..ff8813c 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -93,6 +93,11 @@ "control_name": "Remote access is managed", "description": "Virtual machines with public IPs and no NSG have unrestricted network access. NSGs should be attached to control inbound and outbound traffic and manage remote access to compute resources." }, + "AZ-CMP-002": { + "control_id": "PR.DS-1", + "control_name": "Data-at-rest is protected", + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). PR.DS-1 requires that data at rest is protected using appropriate controls. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." + }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index e26b5b2..e9e87f0 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -98,6 +98,11 @@ "control_name": "Restricts Access from Outside the Network Boundary", "description": "A virtual machine with a public IP and no NSG has unrestricted inbound network access from the internet with no filtering in place. CC6.6 requires that logical access from outside the network perimeter is restricted and controlled. Attaching an NSG with explicit rules enforces the network boundary and controls what traffic can reach the VM." }, + "AZ-CMP-002": { + "control_id": "CC6.7", + "control_name": "Protects Data in Transit and At Rest", + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CC6.7 requires that data is protected using encryption. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." + }, "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", diff --git a/playbooks/cli/fix_az_cmp_002.sh b/playbooks/cli/fix_az_cmp_002.sh new file mode 100644 index 0000000..927790d --- /dev/null +++ b/playbooks/cli/fix_az_cmp_002.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-CMP-002 — Virtual machine disk not protected by CMK or ADE +# Usage: ./fix_az_cmp_002.sh +# Severity: HIGH + +set -e + +RESOURCE_GROUP=$1 +VM_NAME=$2 +KEYVAULT_NAME=$3 + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$VM_NAME" ] || [ -z "$KEYVAULT_NAME" ]; then + echo "Usage: $0 " + echo "" + echo "Prerequisites:" + echo " 1. Create a Key Vault if one does not exist:" + echo " az keyvault create --resource-group --name --enabled-for-disk-encryption true" + echo " 2. Ensure the VM is running before enabling encryption" + exit 1 +fi + +echo "Enabling Azure Disk Encryption on VM '$VM_NAME'..." + +az vm encryption enable \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VM_NAME" \ + --disk-encryption-keyvault "$KEYVAULT_NAME" \ + --volume-type All + +echo "Waiting for encryption to complete..." + +az vm encryption show \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VM_NAME" + +echo "Disk encryption enabled on all volumes for VM '$VM_NAME'." +echo "The VM may restart during the encryption process." +echo "Encryption of large disks can take several hours to complete." diff --git a/scanner/rules/az_cmp_002.py b/scanner/rules/az_cmp_002.py new file mode 100644 index 0000000..cefbef4 --- /dev/null +++ b/scanner/rules/az_cmp_002.py @@ -0,0 +1,115 @@ +"""AZ-CMP-002: Virtual machine OS or data disk using platform-managed encryption only.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-CMP-002" +RULE_NAME = "Virtual machine disk not protected by customer-managed key or ADE" +SEVERITY = "HIGH" +CATEGORY = "Compute" +FRAMEWORKS = {"CIS": "7.2", "NIST": "PR.DS-1", "ISO27001": "A.10.1.1", "SOC2": "CC6.7"} +DESCRIPTION = ( + "One or more disks attached to this virtual machine are using platform-managed " + "encryption only (EncryptionAtRestWithPlatformKey). CIS 7.2 requires disks to be " + "protected using either Azure Disk Encryption (ADE) or server-side encryption with " + "a customer-managed key (CMK). Platform-managed encryption does not give the " + "organisation control over the encryption keys." +) +REMEDIATION = ( + "Configure server-side encryption with a customer-managed key via a Disk Encryption " + "Set, or enable Azure Disk Encryption on all OS and data disks. Navigate to: " + "Virtual Machine > Disks > Additional settings > Disk encryption set, or use " + "az vm encryption enable with a Key Vault." +) +PLAYBOOK = "playbooks/cli/fix_az_cmp_002.sh" + +logger = logging.getLogger(__name__) + + +def _disk_needs_flagging(managed_disk: Any) -> bool: + """Return True only if the disk uses platform-managed encryption. + + Azure platform-managed encryption (EncryptionAtRestWithPlatformKey) is the + default for all managed disks and does not satisfy CIS 7.2, which requires + customer-managed keys (CMK) or Azure Disk Encryption (ADE). + + Disks using EncryptionAtRestWithCustomerKey or + EncryptionAtRestWithPlatformAndCustomerKeys are compliant and should not + be flagged. + """ + if managed_disk is None: + return False + + encryption = getattr(managed_disk, "security_profile", None) + if encryption is None: + encryption = getattr(managed_disk, "encryption", None) + + encryption_type = getattr(encryption, "type", None) + + if encryption_type is None: + return False + + return encryption_type == "EncryptionAtRestWithPlatformKey" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect virtual machines whose disks use platform-managed encryption only.""" + findings: List[Dict[str, Any]] = [] + + for vm in azure_client.get_virtual_machines(): + vm_id = getattr(vm, "id", "") + vm_name = getattr(vm, "name", "") + location = getattr(vm, "location", "") + + if not vm_id or not vm_name: + continue + + parsed = azure_client.parse_resource_id(vm_id) + resource_group = parsed.get("resource_group", "") + + storage_profile = getattr(vm, "storage_profile", None) + if not storage_profile: + continue + + unencrypted_disks = [] + + # Check OS disk + os_disk = getattr(storage_profile, "os_disk", None) + if os_disk: + managed_disk = getattr(os_disk, "managed_disk", None) + if _disk_needs_flagging(managed_disk): + unencrypted_disks.append( + getattr(os_disk, "name", "os-disk") + ) + + # Check data disks + data_disks = getattr(storage_profile, "data_disks", []) or [] + for disk in data_disks: + managed_disk = getattr(disk, "managed_disk", None) + if _disk_needs_flagging(managed_disk): + unencrypted_disks.append( + getattr(disk, "name", f"data-disk-{getattr(disk, 'lun', '?')}") + ) + + if unencrypted_disks: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vm_id, + "resource_name": vm_name, + "resource_type": "Microsoft.Compute/virtualMachines", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": resource_group, + "location": location, + "unencrypted_disks": unencrypted_disks, + "unencrypted_disk_count": len(unencrypted_disks), + }, + }) + + return findings From 1efe1f3d4a04822decb5d3a3caafbec60262983f Mon Sep 17 00:00:00 2001 From: Ritik Sah Date: Wed, 13 May 2026 08:04:52 +0100 Subject: [PATCH 38/50] Feat/api deployment (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: deploy API to Render with security hardening and CI/CD optimizations * feat: finalize Render deployment with security hardening and Gunicorn import fix * fix: GitHub Actions syntax and secret detection logic in deploy workflow * ix: harden scan trigger route with detailed error handling and remove redundant DB initialization * fix: implement global database connection management and harden all API routes * ix: prevent insecure smoke tests on main branch by enforcing JWT_SECRET presence and prevent CI false negatives in playbook check by enforcing non-empty glob match * fix: resolve Render startup crash and harden scan serialization against recursive objects * fix: add missing six and cryptography dependencies for Azure SDK compatibility * fix: increase CI wait time for Render build and add missing msrest dependencies * feat: integrate real subscription ID into smoke tests and CI/CD pipeline * feat: integrate real Azure_ ID's into smoke tests and CI/CD pipeline * feat: add root welcome route to confirm API status * fix: resolve specific CI credential flags in code and workflow while maintaining documentation standards * fix: resolve IndentationError in CI compliance cross-reference check * fix: resolve dependency issue and test on deployment * fix: resolve somke test TC-21 * fix: RUN_REAL_SCAN not set → TC-13/TC-14 skip → 21/21 pass for new live API url test * fix: scan.py deferred import from scanner.engine import ScanEngine was running before the subscription_id check * fix: restrict deploy triggers to dev and main, enable RUN_REAL_SCAN for maintainer CI, and update test plan documentation --- .github/workflows/ci.yml | 21 ++- .github/workflows/deploy.yml | 109 ++++++++++++ README.md | 13 ++ api/app.py | 55 ++++++- api/models/finding.py | 14 +- api/routes/compliance.py | 43 +++-- api/routes/findings.py | 45 +++-- api/routes/scans.py | 62 ++++--- api/routes/score.py | 21 ++- docs/api-render-deploy.md | 235 ++++++++++++++++++++++++++ requirements.txt | 3 + scanner/engine.py | 48 +++++- startup.sh | 23 +++ tests/__init__.py | 0 tests/smoke_test.py | 310 +++++++++++++++++++++++++++++++++++ 15 files changed, 919 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 docs/api-render-deploy.md create mode 100755 startup.sh create mode 100644 tests/__init__.py create mode 100755 tests/smoke_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95f5510..0a04df2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,13 @@ jobs: run: | echo "=== Checking Python syntax on scanner/rules/ ===" FAIL=0 - for f in scanner/rules/az_*.py; do + shopt -s nullglob + files=(scanner/rules/az_*.py) + if [ ${#files[@]} -eq 0 ]; then + echo "ERROR: No rule files found matching scanner/rules/az_*.py" + exit 1 + fi + for f in "${files[@]}"; do if ! python -m py_compile "$f" 2>&1; then echo "SYNTAX ERROR: $f" FAIL=1 @@ -137,7 +143,7 @@ jobs: grep -v "\.env" | \ grep -v "os\.environ" | \ grep -v "os\.getenv" | \ - grep -v "#" | \ + grep -vE '^\s*#' | \ grep -v "example" | \ grep -v "placeholder" || true) @@ -161,7 +167,13 @@ jobs: run: | echo "=== Checking playbooks exist and are valid bash ===" FAIL=0 - for rule_file in scanner/rules/az_*.py; do + shopt -s nullglob + files=(scanner/rules/az_*.py) + if [ ${#files[@]} -eq 0 ]; then + echo "ERROR: No rule files found matching scanner/rules/az_*.py" + exit 1 + fi + for rule_file in "${files[@]}"; do filename=$(basename "$rule_file" .py) playbook="playbooks/cli/fix_${filename}.sh" @@ -287,7 +299,8 @@ jobs: continue fpath = os.path.join(framework_dir, fname) try: - data = json.load(open(fpath)) + with open(fpath) as f: + data = json.load(f) except (json.JSONDecodeError, OSError): continue diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..88bebc7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,109 @@ +name: Deploy API to Render + +on: + push: + branches: + - dev + - main + workflow_dispatch: # allows manual trigger from GitHub UI + +jobs: + deploy: + name: Deploy to Render + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # ── Dependency caching ───────────────────────────────────────────── + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + # ── Secret check (Determines if smoke tests should run) ─────────── + - name: Check for JWT_SECRET + id: check_config + run: | + if [ -n "${{ secrets.JWT_SECRET }}" ]; then + echo "is_configured=true" >> $GITHUB_OUTPUT + else + echo "is_configured=false" >> $GITHUB_OUTPUT + fi + + # ── Wait for Render auto-deployment ──────────────────────────────── + # Render handles the actual physical deployment when you push. + # We just pause the Action to let Render's servers finish building. + - name: Wait for app to initialise + run: | + echo "Waiting 120 seconds for Render to build and start the app..." + sleep 120 + + # ── Health gate ──────────────────────────────────────────────────── + - name: Health gate check + id: health_gate + env: + # Use secret URL if provided, otherwise fallback to default + API_URL: ${{ secrets.API_URL || 'https://openshield-api.onrender.com' }} + run: | + MAX_RETRIES=5 + RETRY_DELAY=15 + URL="${API_URL}/health" + + echo "Pinging health gate at: $URL" + for i in $(seq 1 $MAX_RETRIES); do + echo "Health check attempt $i of $MAX_RETRIES..." + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL" --max-time 30) || true + + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Health check passed (HTTP $HTTP_STATUS)" + exit 0 + fi + + echo "Got HTTP $HTTP_STATUS — retrying in ${RETRY_DELAY}s..." + sleep $RETRY_DELAY + done + + echo "HEALTH GATE FAILED after $MAX_RETRIES attempts" + echo "Note: If you haven't set up Render for this fork, this is expected." + # Only allow failure on feature branches; fail on main/dev + if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "ERROR: Health check failed on protected branch. Deployment verification required." + exit 1 + else + echo "Allowing health check failure on feature branch (infra may not be set up)" + exit 0 + fi + + # ── Smoke tests ──────────────────────────────────────────────────── + - name: Run smoke tests against live deployment + if: steps.check_config.outputs.is_configured == 'true' || github.event_name == 'workflow_dispatch' + env: + API_URL: ${{ secrets.API_URL || 'https://openshield-api.onrender.com' }} + JWT_SECRET: ${{ secrets.JWT_SECRET || 'change-me-in-production' }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + RUN_REAL_SCAN: "true" + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" && -z "${{ secrets.JWT_SECRET }}" ]]; then + echo "ERROR: Cannot run smoke tests on main branch without JWT_SECRET configured" + exit 1 + fi + echo "Running smoke tests against: $API_URL" + python tests/smoke_test.py \ No newline at end of file diff --git a/README.md b/README.md index 2e723f1..d4dca8b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,19 @@ flowchart TD I -->|alerts| A ``` +## Live API + +The OpenShield API is deployed to the Render free tier and is accessible at: + +**`https://openshield-api.onrender.com`** + +> **Note:** As this is hosted on the Render free tier, the service may spin down after 15 minutes of inactivity. The first request after a spin-down can take 30-60 seconds to complete. + +> [!IMPORTANT] +> **Security Requirement:** For absolute security, any production deployment **must** override the default `JWT_SECRET` with a strong, unique value in the environment variables. + +--- + ## Tech Stack | Layer | Technology | Cost | diff --git a/api/app.py b/api/app.py index b36605f..691bfe4 100644 --- a/api/app.py +++ b/api/app.py @@ -8,6 +8,8 @@ from flask import Flask, g, jsonify, request from flask_cors import CORS +from api.models.finding import DatabaseManager + load_dotenv() logging.basicConfig( @@ -28,14 +30,49 @@ def create_app() -> Flask: - JWT authentication middleware on all non-public routes - Blueprints for findings, scans, score, and compliance - JSON error handlers for 400, 401, 403, 404, and 500 + - Global database connection teardown """ app = Flask(__name__) - app.config["JWT_SECRET"] = os.environ.get("JWT_SECRET", "change-me-in-production") + + # ------------------------------------------------------------------ # + # Configuration & Security # + # ------------------------------------------------------------------ # + jwt_key = os.environ.get("JWT_SECRET") + if not jwt_key: + logger.warning( + "!!! SECURITY WARNING: JWT_SECRET NOT SET. USING INSECURE DEFAULT !!! " + "For production deployments, you MUST set a strong, unique JWT_SECRET." + ) + jwt_key = "change-me-in-production" + app.config["JWT_SECRET"] = jwt_key # ------------------------------------------------------------------ # # CORS # # ------------------------------------------------------------------ # - CORS(app, resources={r"/api/*": {"origins": "*"}}) + allowed_origins_raw = os.environ.get("ALLOWED_ORIGINS", "*") + if allowed_origins_raw == "*": + logger.warning( + "!!! SECURITY WARNING: ALLOWED_ORIGINS NOT SET. DEFAULTING TO '*' !!! " + "For production deployments, set this to your specific frontend domain(s)." + ) + allowed_origins = allowed_origins_raw.split(",") + CORS(app, resources={r"/api/*": {"origins": allowed_origins}}) + + # ------------------------------------------------------------------ # + # Database Management # + # ------------------------------------------------------------------ # + + @app.teardown_appcontext + def close_db(error): + """Ensure the database connection is closed after the request.""" + db = g.pop("db_conn", None) + if db is not None: + try: + if hasattr(db, "conn") and db.conn is not None: + db.conn.close() + logger.debug("Database connection closed gracefully") + except Exception as exc: + logger.error("Error closing database connection: %s", exc) # ------------------------------------------------------------------ # # JWT middleware # @@ -82,9 +119,18 @@ def verify_jwt() -> None: app.register_blueprint(compliance_bp) # ------------------------------------------------------------------ # - # Health check (public) # + # Routes (public) # # ------------------------------------------------------------------ # + @app.get("/") + def index(): + return jsonify({ + "message": "Welcome to the OpenShield REST API", + "version": "1.0.0", + "docs": "/docs", + "status": "online" + }) + @app.get("/health") def health(): return jsonify({"status": "ok"}) @@ -118,8 +164,9 @@ def internal_error(exc): return app +application = create_app() + if __name__ == "__main__": - application = create_app() application.run( host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), diff --git a/api/models/finding.py b/api/models/finding.py index 8cdab3f..7b2eda7 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -80,10 +80,16 @@ def __init__(self, dsn: Optional[str] = None) -> None: # ------------------------------------------------------------------ # def connect(self) -> None: - """Open a persistent database connection.""" + """Open a persistent database connection and set the search path.""" self.conn = psycopg2.connect(self.dsn) + self.conn.autocommit = True # Set to True for schema management + with self.conn.cursor() as cur: + # Ensure the openshield schema exists and is preferred in the search path. + # This avoids 'permission denied for schema public' in restricted environments. + cur.execute("CREATE SCHEMA IF NOT EXISTS openshield;") + cur.execute("SET search_path TO openshield, public;") self.conn.autocommit = False - logger.info("Database connection established") + logger.info("Database connection established (schema: openshield)") def _get_conn(self) -> Any: if self.conn is None or self.conn.closed: @@ -94,6 +100,10 @@ def _get_conn(self) -> Any: # Schema # # ------------------------------------------------------------------ # + def init_db(self) -> None: + """Alias for create_tables to match startup script expectations.""" + self.create_tables() + def create_tables(self) -> None: """Create the findings, scans, and rules tables if they do not exist.""" conn = self._get_conn() diff --git a/api/routes/compliance.py b/api/routes/compliance.py index 6a3b104..798f187 100644 --- a/api/routes/compliance.py +++ b/api/routes/compliance.py @@ -1,39 +1,46 @@ """Compliance routes: framework-specific posture breakdown.""" +import logging import os -from flask import Blueprint, jsonify +from flask import Blueprint, g, jsonify from api.models.finding import DatabaseManager compliance_bp = Blueprint("compliance", __name__) +logger = logging.getLogger(__name__) SUPPORTED_FRAMEWORKS = ("cis", "nist", "iso27001", "soc2") def _get_db() -> DatabaseManager: - db = DatabaseManager(os.environ["DATABASE_URL"]) - db.connect() - return db + if "db_conn" not in g: + g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) + g.db_conn.connect() + return g.db_conn @compliance_bp.get("/api/compliance/") def get_compliance(framework: str): """Return pass/fail compliance breakdown for a framework. - Supported frameworks: cis, nist, iso27001, soc2 + Supported frameworks: cis, nist, iso27001, soc2 Returns control-level pass/fail status mapped to current open findings. """ - if framework.lower() not in SUPPORTED_FRAMEWORKS: - return jsonify({ - "error": f"Unknown framework '{framework}'", - "supported": list(SUPPORTED_FRAMEWORKS), - }), 400 - - db = _get_db() - result = db.get_compliance_score(framework.lower()) - - if "error" in result: - return jsonify(result), 500 - - return jsonify(result) + try: + if framework.lower() not in SUPPORTED_FRAMEWORKS: + return jsonify({ + "error": f"Unknown framework '{framework}'", + "supported": list(SUPPORTED_FRAMEWORKS), + }), 400 + + db = _get_db() + result = db.get_compliance_score(framework.lower()) + + if "error" in result: + return jsonify(result), 500 + + return jsonify(result) + except Exception as exc: + logger.error("Failed to retrieve compliance score for %s: %s", framework, exc) + return jsonify({"error": "Compliance calculation failed", "detail": str(exc)}), 500 diff --git a/api/routes/findings.py b/api/routes/findings.py index fb8d755..917a23f 100644 --- a/api/routes/findings.py +++ b/api/routes/findings.py @@ -1,17 +1,20 @@ """Findings routes: list and retrieve individual findings.""" +import logging import os -from flask import Blueprint, jsonify, request +from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager findings_bp = Blueprint("findings", __name__) +logger = logging.getLogger(__name__) def _get_db() -> DatabaseManager: - db = DatabaseManager(os.environ["DATABASE_URL"]) - db.connect() - return db + if "db_conn" not in g: + g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) + g.db_conn.connect() + return g.db_conn @findings_bp.get("/api/findings") @@ -24,21 +27,29 @@ def list_findings(): rule_id — e.g. AZ-STOR-001 scan_id — UUID of a specific scan """ - filters = { - k: v - for k, v in request.args.items() - if k in ("severity", "category", "rule_id", "scan_id") - } - db = _get_db() - findings = db.get_findings(filters) - return jsonify({"count": len(findings), "findings": findings}) + try: + filters = { + k: v + for k, v in request.args.items() + if k in ("severity", "category", "rule_id", "scan_id") + } + db = _get_db() + findings = db.get_findings(filters) + return jsonify({"count": len(findings), "findings": findings}) + except Exception as exc: + logger.error("Failed to list findings: %s", exc) + return jsonify({"error": "Failed to retrieve findings", "detail": str(exc)}), 500 @findings_bp.get("/api/findings/") def get_finding(finding_id: int): """Return a single finding by its integer ID.""" - db = _get_db() - finding = db.get_finding_by_id(finding_id) - if not finding: - return jsonify({"error": "Finding not found"}), 404 - return jsonify(finding) + try: + db = _get_db() + finding = db.get_finding_by_id(finding_id) + if not finding: + return jsonify({"error": "Finding not found"}), 404 + return jsonify(finding) + except Exception as exc: + logger.error("Failed to get finding %d: %s", finding_id, exc) + return jsonify({"error": "Database error", "detail": str(exc)}), 500 diff --git a/api/routes/scans.py b/api/routes/scans.py index 85612a4..5aca891 100644 --- a/api/routes/scans.py +++ b/api/routes/scans.py @@ -2,7 +2,7 @@ import logging import os -from flask import Blueprint, jsonify, request +from flask import Blueprint, g, jsonify, request from api.models.finding import DatabaseManager @@ -11,17 +11,22 @@ def _get_db() -> DatabaseManager: - db = DatabaseManager(os.environ["DATABASE_URL"]) - db.connect() - return db + if "db_conn" not in g: + g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) + g.db_conn.connect() + return g.db_conn @scans_bp.get("/api/scans") def list_scans(): """Return all historical scan results ordered by most recent first.""" - db = _get_db() - scans = db.get_scans() - return jsonify({"count": len(scans), "scans": scans}) + try: + db = _get_db() + scans = db.get_scans() + return jsonify({"count": len(scans), "scans": scans}) + except Exception as exc: + logger.error("Failed to list scans: %s", exc) + return jsonify({"error": "Failed to retrieve scans", "detail": str(exc)}), 500 @scans_bp.post("/api/scans/trigger") @@ -34,27 +39,34 @@ def trigger_scan(): Note: For production use, replace this with an async task queue (e.g. Celery or Azure Functions) to avoid request timeouts on large subscriptions. """ - from scanner.engine import ScanEngine # deferred to avoid import at startup + try: + body = request.get_json(silent=True) or {} + subscription_id = body.get("subscription_id") - body = request.get_json(silent=True) or {} - subscription_id = body.get("subscription_id") or os.environ.get( - "AZURE_SUBSCRIPTION_ID" - ) + if not subscription_id: + return jsonify({"error": "subscription_id is required"}), 400 - if not subscription_id: - return jsonify({"error": "subscription_id is required"}), 400 + from scanner.engine import ScanEngine # deferred — import only after input is validated - logger.info("Scan triggered for subscription %s", subscription_id) + logger.info("Scan triggered for subscription %s", subscription_id) - try: - engine = ScanEngine(subscription_id) - result = engine.run_scan() - except Exception as exc: - logger.error("Scan failed: %s", exc) - return jsonify({"error": "Scan failed", "detail": str(exc)}), 500 + try: + engine = ScanEngine(subscription_id) + result = engine.run_scan() + except Exception as exc: + logger.error("Scan engine execution failed: %s", exc, exc_info=True) + return jsonify({"error": "Scan failed", "detail": str(exc)}), 500 + + try: + db = _get_db() + # Note: Table creation is handled at startup; no need to repeat it here. + db.save_scan(result) + except Exception as exc: + logger.error("Failed to save scan result to database: %s", exc, exc_info=True) + return jsonify({"error": "Database save failed", "detail": str(exc)}), 500 - db = _get_db() - db.create_tables() - db.save_scan(result) + return jsonify(result), 201 - return jsonify(result), 201 + 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 diff --git a/api/routes/score.py b/api/routes/score.py index b7317ee..bfff526 100644 --- a/api/routes/score.py +++ b/api/routes/score.py @@ -1,17 +1,20 @@ """Score route: overall security posture score.""" +import logging import os -from flask import Blueprint, jsonify +from flask import Blueprint, g, jsonify from api.models.finding import DatabaseManager score_bp = Blueprint("score", __name__) +logger = logging.getLogger(__name__) def _get_db() -> DatabaseManager: - db = DatabaseManager(os.environ["DATABASE_URL"]) - db.connect() - return db + if "db_conn" not in g: + g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) + g.db_conn.connect() + return g.db_conn @score_bp.get("/api/score") @@ -22,6 +25,10 @@ def get_score(): Starts at 100. Deducts 10 per HIGH finding, 5 per MEDIUM, 2 per LOW. Floors at 0. """ - db = _get_db() - score = db.get_score() - return jsonify({"score": score, "max_score": 100}) + try: + db = _get_db() + score = db.get_score() + return jsonify({"score": score, "max_score": 100}) + except Exception as exc: + logger.error("Failed to calculate score: %s", exc) + return jsonify({"error": "Failed to calculate score", "detail": str(exc)}), 500 diff --git a/docs/api-render-deploy.md b/docs/api-render-deploy.md new file mode 100644 index 0000000..a1ed3b5 --- /dev/null +++ b/docs/api-render-deploy.md @@ -0,0 +1,235 @@ +# Test Plan — API-DEP-001 +# Render API Deployment and CI Smoke Testing +# ============================================================ + +## 1. Overview + +This test plan covers the verification of the OpenShield API deployment +to the Render free tier. The goal is to confirm: + +- The Render Web Service builds and deploys the Flask app successfully. +- The database is automatically initialized on startup via `init_db`. +- The pre-commit hook and GitHub Actions CI pipeline gate the code properly. +- The CI pipeline is **community-friendly**, allowing forks to pass even without custom secrets. +- Real Azure scan tests are gated behind `RUN_REAL_SCAN=true` so contributor CI never depends on live Azure credentials. +- All 23 API edge cases (routing, filtering, authentication) function correctly in the live cloud environment. + +--- + +## 2. Methodology and Test Rationale + +To ensure the highest reliability of the deployment while accommodating free-tier constraints and community contributions, specific methods and test strategies were chosen: + +### 2.1 Infrastructure and Pipeline Strategy +* **Targeting Render over Azure F1:** Azure App Service's F1 tier imposes a strict 60 CPU-minute daily cap. Render provides unmetered CPU on the free tier, making it significantly more reliable for demo and development environments. +* **Database Initialization:** The `api/models/finding.py` was updated with an `init_db` method. This method ensures that all required tables (`scans`, `findings`) are created automatically during the first deployment, preventing HTTP 500 errors. +* **Pre-commit Hook:** Fails fast. By running syntax checks and local API smoke tests *before* the commit is allowed, we prevent broken code from polluting the remote branch. +* **Community-Friendly CI Gate:** The GitHub Action is designed to be zero-friction for contributors. + * **Optional Smoke Tests:** If `JWT_SECRET` is not set (typical for forks), the smoke test step is gracefully skipped rather than failing the build. + * **Configurable URL:** The `API_URL` is configurable via GitHub Secrets/Variables, defaulting to the main production instance if not provided. + * **Conditional Real Scan Tests:** TC-13 and TC-14 (real Azure scan execution) only run when `RUN_REAL_SCAN=true` and all four Azure credentials are present. This separates API smoke testing from live scan regression testing. Contributor and fork CI always passes safely — real scan validation is reserved for maintainer-controlled deployment pipelines (`dev` and `main` branches). + +### 2.2 Token Generation Method +* **Dynamic HS256 Signing:** Instead of using a hardcoded dummy string, the test script dynamically generates a real token signed with the environment's `JWT_SECRET`. +* **Default Secret Alignment:** The smoke test defaults to `change-me-in-production`, matching the API's default. This allows tests to run "out of the box" in local environments without extra configuration. + +> [!CAUTION] +> **ABSOLUTE SECURITY REQUIREMENT:** For any production deployment (Render, Azure, etc.), you **MUST** override the default `JWT_SECRET` with a long, random, and unique string. Leaving the default value in place makes your API vulnerable to unauthorized access via token forging. + +### 2.3 API Smoke Test Strategy (The 23 Cases) +The 23 test cases were selected to prove the API is structurally sound and resilient: +* **Health Check (TC-01 to TC-03):** Confirms base app connectivity and ensures public routes are not locked. +* **Core Endpoints (TC-04 to TC-17):** Verifies the actual business logic and JSON structure. +* **Auth/Security (TC-18 to TC-19):** Confirms the JWT middleware is strictly enforced. +* **Edge Cases and Resilience (TC-20 to TC-23):** Ensures the app does not crash when given bad input or non-existent routes. + +#### Conditional vs Always-Run Tests + +| Mode | TC-13 / TC-14 | All others | +|---|---|---| +| Contributor / fork (no `RUN_REAL_SCAN`) | `SKIP` — printed with reason, not a failure | Always run | +| Maintainer deployment (`RUN_REAL_SCAN=true` + Azure credentials) | Run real scan against live subscription | Always run | + +Run modes: +```bash +# Contributor / local (no Azure credentials needed) +API_URL=https://openshield-api.onrender.com JWT_SECRET= python tests/smoke_test.py + +# Maintainer — full real scan +API_URL=https://openshield-api.onrender.com JWT_SECRET= \ + RUN_REAL_SCAN=true \ + AZURE_SUBSCRIPTION_ID= \ + AZURE_CLIENT_ID= \ + AZURE_CLIENT_SECRET= \ + AZURE_TENANT_ID= \ + python tests/smoke_test.py +``` + +--- + +## 3. Files Under Test + +| File | Purpose | +|---|---| +| `startup.sh` | Container startup script, DB initialization, and Gunicorn execution | +| `api/models/finding.py` | Added `init_db` to ensure schema existence on startup | +| `.github/workflows/deploy.yml` | Flexible GitHub Actions workflow (optional smoke tests) | +| `tests/smoke_test.py` | 23-case functional test suite with default secret support | +| `.git/hooks/pre-commit` | Local Git hook enforcing syntax checks and local smoke tests | +| `requirements.txt` | Pinned runtime dependencies — see dependency notes below | + +### 3.1 Dependency Notes + +| Package | Status | Reason | +|---|---|---| +| `msrest==0.7.1` | Kept (explicit pin) | Transitive dependency of `azure-mgmt-rdbms`, `azure-mgmt-sql`, and `azure-mgmt-storage`. These SDK packages have not fully migrated to `azure-core`. Without an explicit pin, Render's clean pip install can resolve a mismatched version and break scan execution. | + +--- + +## 4. Test Environment Setup + +### 4.1 Prerequisites +- Python 3.11 installed locally. +- Render account (render.com). +- OpenShield repository cloned locally. +- `.env` file populated locally with a valid `JWT_SECRET` and `DATABASE_URL`. +- Pre-commit hook installed locally (`chmod +x .git/hooks/pre-commit`). + +### 4.2 Create Test Resources in Render +1. **Render PostgreSQL Database (Free Tier)** + - Name: `openshield-db` +2. **Render Web Service (Free Tier)** + - Connected to your branch. + - Start Command: `./startup.sh` + - Environment Variables set: `DATABASE_URL`, `JWT_SECRET`, `ALLOWED_ORIGINS`, `AZURE_SUBSCRIPTION_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`. + +### 4.3 Configure GitHub Secrets +To enable the automated smoke tests in the CI/CD pipeline, you **must** add the following secrets to your GitHub repository (**Settings > Secrets and variables > Actions**): + +| Secret Name | Required for | Purpose | +|---|---|---| +| `JWT_SECRET` | All smoke tests | Must match the value set in Render. Used to sign tokens for test requests. | +| `API_URL` | All smoke tests (optional) | Your Render Service URL. Defaults to the main production instance if not set. | +| `AZURE_SUBSCRIPTION_ID` | Real scan tests | Azure Subscription ID passed to the scan trigger endpoint. | +| `AZURE_CLIENT_ID` | Real scan tests | Service principal client ID for `DefaultAzureCredential`. | +| `AZURE_CLIENT_SECRET` | Real scan tests | Service principal secret for `DefaultAzureCredential`. | +| `AZURE_TENANT_ID` | Real scan tests | Azure AD tenant ID for `DefaultAzureCredential`. | + +> **Note:** `RUN_REAL_SCAN=true` is set automatically by `deploy.yml` on `dev` and `main` branches. Forks and contributor PRs never set this flag, so TC-13 and TC-14 are always skipped in fork CI regardless of which secrets are present. + +--- + +## 5. Test Cases + +### Part 1: Deployment & Pipeline Infrastructure + +**DP-01 — Pre-commit hook enforces checks** +* **Steps:** Modify a file and run `git commit` with the local API turned off, then with it turned on. +* **Expected:** Blocks/warns when API is off; runs the 23-test suite and passes when API is on. + +**DP-02 — Render executes startup script successfully** +* **Steps:** Push code to GitHub and monitor Render deployment logs. +* **Expected:** Logs show DB initialization (`Database initialized.`) and Gunicorn starting. + +**DP-03 — GitHub Actions CI pipeline passes** +* **Steps:** Push a commit and monitor the GitHub Actions tab. +* **Expected:** + * **Maintainer repo (`dev`/`main`):** Runs 21 always-on tests + TC-13/TC-14 real scan with `RUN_REAL_SCAN=true`. All 23 pass. + * **Contributor / fork:** TC-13 and TC-14 show as `SKIP` with a clear reason. 21/21 non-scan tests pass. Workflow exits green. + +--- + +### Part 2: API Smoke Tests (Executed via `smoke_test.py`) + +Run the following command against the live URL to execute these tests (contributor mode — TC-13/TC-14 skipped): +```bash +API_URL=https://openshield-api.onrender.com JWT_SECRET= python tests/smoke_test.py +``` + +To run the full 23-case suite including real scan tests (maintainer only): +```bash +API_URL=https://openshield-api.onrender.com JWT_SECRET= \ + RUN_REAL_SCAN=true \ + AZURE_SUBSCRIPTION_ID= AZURE_CLIENT_ID= \ + AZURE_CLIENT_SECRET= AZURE_TENANT_ID= \ + python tests/smoke_test.py +``` + +#### Health Check +* **TC-01:** GET `/health` returns HTTP 200. +* **TC-02:** GET `/health` returns JSON `{"status": "ok"}`. +* **TC-03:** GET `/health` requires no auth token (public route). + +#### Findings Endpoint +* **TC-04:** GET `/api/findings` returns HTTP 200. +* **TC-05:** GET `/api/findings` returns a `findings` key in JSON. +* **TC-06:** GET `/api/findings` returns a numeric `count` key. +* **TC-07:** GET `/api/findings?severity=HIGH` correctly filters results. +* **TC-08:** GET `/api/findings?severity=INVALID` handles bad input safely (returns 200 or 400). + +#### Score Endpoint +* **TC-09:** GET `/api/score` returns HTTP 200. +* **TC-10:** GET `/api/score` returns a numeric score. +* **TC-11:** GET `/api/score` ensures the score is mathematically between 0 and 100. + +#### Scans Endpoint +* **TC-12:** GET `/api/scans` returns HTTP 200. +* **TC-13:** *(Conditional — requires `RUN_REAL_SCAN=true` and Azure credentials)* POST `/api/scans/trigger` returns HTTP 200, 201, or 202. Skipped in contributor/fork CI. +* **TC-14:** *(Conditional — requires `RUN_REAL_SCAN=true` and Azure credentials)* POST `/api/scans/trigger` returns a `scan_id` or `job_id`. Skipped in contributor/fork CI. + +#### Compliance Endpoints +* **TC-15:** GET `/api/compliance/cis` returns HTTP 200. +* **TC-16:** GET `/api/compliance/nist` returns HTTP 200. +* **TC-17:** GET `/api/compliance/iso27001` returns HTTP 200. + +#### Auth & Security Edge Cases +* **TC-18:** GET `/api/findings` without any auth header returns HTTP 401. +* **TC-19:** GET `/api/findings` with a malformed JWT returns HTTP 401. + +#### General Edge Cases +* **TC-20:** GET `/nonexistent-endpoint-xyz` returns HTTP 404 (requires auth to pass middleware). +* **TC-21:** POST `/api/scans/trigger` with an empty JSON body returns HTTP 400 (missing `subscription_id`) without crashing. +* **TC-22:** GET `/api/findings?limit=0` does not crash the server. +* **TC-23:** All valid endpoint responses include the `application/json` Content-Type. + +--- + +## 6. Cleanup + +Render Free Tier Web Services spin down after 15 minutes of inactivity. The Free PostgreSQL database will automatically be deleted by Render after 90 days. To clean up manually, delete both resources from the Render dashboard Settings page. + +--- + +## 7. Pass / Fail Summary Table + +| Test Case | Description | Expected | Status | +|---|---|---|---| +| **DP-01** | Pre-commit Git hook functioning | Hook runs & enforces rules | [ ] | +| **DP-02** | Render deployment & startup | App goes Live & DB inits | [ ] | +| **DP-03** | GitHub Actions CI Pipeline | Workflow passes (Green) | [ ] | +| **TC-01** | `/health` returns 200 | Pass | [ ] | +| **TC-02** | `/health` returns status ok | Pass | [ ] | +| **TC-03** | `/health` requires no auth | Pass | [ ] | +| **TC-04** | `/api/findings` returns 200 | Pass | [ ] | +| **TC-05** | `/api/findings` returns findings key | Pass | [ ] | +| **TC-06** | `/api/findings` returns count key | Pass | [ ] | +| **TC-07** | `/api/findings` severity filter | Pass | [ ] | +| **TC-08** | `/api/findings` invalid severity | Pass | [ ] | +| **TC-09** | `/api/score` returns 200 | Pass | [ ] | +| **TC-10** | `/api/score` returns numeric | Pass | [ ] | +| **TC-11** | `/api/score` bounded 0-100 | Pass | [ ] | +| **TC-12** | `/api/scans` returns 200 | Pass | [ ] | +| **TC-13** | `/api/scans/trigger` works | 200/201/202 (Skip in fork CI) | [ ] | +| **TC-14** | `/api/scans/trigger` returns ID | Pass (Skip in fork CI) | [ ] | +| **TC-15** | `/api/compliance/cis` works | Pass | [ ] | +| **TC-16** | `/api/compliance/nist` works | Pass | [ ] | +| **TC-17** | `/api/compliance/iso27001` works | Pass | [ ] | +| **TC-18** | Missing auth returns 401 | Pass | [ ] | +| **TC-19** | Bad token returns 401 | Pass | [ ] | +| **TC-20** | 404 routing works safely | Pass | [ ] | +| **TC-21** | Empty body payload handled | Pass (400) | [ ] | +| **TC-22** | Limit=0 query handled safely | Pass | [ ] | +| **TC-23** | Content-Type is JSON | Pass | [ ] | + +**Maintainer repo:** All 26 checks (3 Pipeline + 23 API) must pass before merging to `dev` or `main`. +**Fork / contributor:** 24 checks (3 Pipeline + 21 API) must pass; TC-13 and TC-14 are expected `SKIP`. diff --git a/requirements.txt b/requirements.txt index ed1678f..66e344e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,6 @@ python-dotenv==1.0.0 pyjwt==2.8.0 requests==2.31.0 pyyaml==6.0.1 +gunicorn==21.2.0 +cryptography==42.0.5 +msrest==0.7.1 \ No newline at end of file diff --git a/scanner/engine.py b/scanner/engine.py index 46ce0e3..4c1813f 100644 --- a/scanner/engine.py +++ b/scanner/engine.py @@ -3,6 +3,7 @@ import importlib.util import logging import uuid +import json from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List @@ -14,6 +15,34 @@ RULES_DIR = Path(__file__).parent / "rules" +def make_serializable(data: Any) -> Any: + """Recursively convert non-serializable objects (datetime, etc) to strings.""" + if data is None: + return None + if isinstance(data, (str, int, float, bool)): + return data + if isinstance(data, dict): + return {str(k): make_serializable(v) for k, v in data.items()} + if isinstance(data, (list, tuple, set)): + return [make_serializable(i) for i in data] + if isinstance(data, datetime): + return data.isoformat() + + # Handle Azure SDK models and other objects + if hasattr(data, "as_dict") and callable(data.as_dict): + return make_serializable(data.as_dict()) + + # Fallback to string representation for unknown objects + try: + # Check if it has a __dict__ but avoid infinite recursion for complex types + if hasattr(data, "__dict__") and not str(type(data)).startswith(" Dict[str, Any]: rule_id = getattr(rule, "RULE_ID", "UNKNOWN") try: rule_findings = rule.scan(self.client, self.subscription_id) + if not isinstance(rule_findings, list): + logger.warning("Rule %s returned %s instead of list — skipped", rule_id, type(rule_findings)) + continue + for finding in rule_findings: + if not isinstance(finding, dict): continue finding.setdefault("detected_at", detected_at) finding.setdefault("scan_id", scan_id) findings.extend(rule_findings) @@ -92,15 +126,11 @@ def run_scan(self) -> Dict[str, Any]: "Rule %s produced %d finding(s)", rule_id, len(rule_findings) ) except Exception as exc: - logger.error("Rule %s raised an exception: %s", rule_id, exc) + logger.error("Rule %s raised an exception: %s", rule_id, exc, exc_info=True) completed_at = datetime.now(timezone.utc).isoformat() - logger.info( - "Scan %s complete — %d total finding(s)", scan_id, len(findings) - ) - - return { + result = { "scan_id": scan_id, "subscription_id": self.subscription_id, "started_at": started_at, @@ -108,3 +138,9 @@ def run_scan(self) -> Dict[str, Any]: "total_findings": len(findings), "findings": findings, } + + logger.info( + "Scan %s complete — %d total finding(s). Normalising results...", scan_id, len(findings) + ) + + return make_serializable(result) diff --git a/startup.sh b/startup.sh new file mode 100755 index 0000000..ac3b44c --- /dev/null +++ b/startup.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +echo "=== OpenShield startup ===" +echo "Running database initialisation..." + +python -c " +import os, sys +try: + from api.models.finding import DatabaseManager + db = DatabaseManager(os.environ['DATABASE_URL']) + if hasattr(db, 'init_db'): + db.init_db() + print('Database initialised.') + else: + print('WARNING: DatabaseManager has no init_db() method — skipping.') +except Exception as e: + print(f'ERROR during DB init: {e}', file=sys.stderr) + sys.exit(1) +" + +echo "Startup complete. Starting Gunicorn..." +exec gunicorn --bind=0.0.0.0:$PORT --timeout 120 --workers 2 api.app:application \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100755 index 0000000..3d9c043 --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +OpenShield API Smoke Test Suite +Runs against a live deployment to verify all endpoints. + +Usage: + # Local + # Set API_URL: http://localhost:5000 and JWT_SECRET: your-secret + python tests/smoke_test.py + + # Live Render deployment + # Set API_URL: https://openshield-api.onrender.com and JWT_SECRET: your-secret + python tests/smoke_test.py + +JWT_SECRET must be the same value set in Render config — the test +generates a properly signed HS256 token from it automatically. +""" + +import os +import sys +import json +import time +import urllib.request +import urllib.error +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + + +# ── Token generation ────────────────────────────────────────────────────── +# The app's before_request middleware calls jwt.decode() with HS256. +# Passing the raw JWT_SECRET as a Bearer token will always return 401. +# We must sign a real token using the same secret. + +def _generate_token(secret: str) -> str: + """Generate a valid HS256 JWT signed with the app's JWT_SECRET.""" + try: + import jwt as pyjwt + payload = { + "sub": "smoke-test", + "role": "admin", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, # 1 hour expiry + } + return pyjwt.encode(payload, secret, algorithm="HS256") + except ImportError: + print("ERROR: PyJWT not installed. Run: pip install PyJWT") + sys.exit(1) + except Exception as e: + print(f"ERROR generating JWT token: {e}") + sys.exit(1) + + +API_URL = os.environ.get("API_URL", "http://localhost:5000").rstrip("/") +_JWT_VAL = os.environ.get("JWT_SECRET", "change-me-in-production") +_REAL_SUB = os.environ.get("AZURE_SUBSCRIPTION_ID", "") + +# Real scan gate — requires explicit opt-in AND all four Azure credentials. +# Set RUN_REAL_SCAN=true in maintainer-controlled CI only. +_RUN_REAL_SCAN = os.environ.get("RUN_REAL_SCAN", "").lower() == "true" +_AZURE_CREDS_PRESENT = all([ + os.environ.get("AZURE_SUBSCRIPTION_ID"), + os.environ.get("AZURE_CLIENT_ID"), + os.environ.get("AZURE_CLIENT_SECRET"), + os.environ.get("AZURE_TENANT_ID"), +]) + +if not _JWT_VAL or _JWT_VAL == "change-me-in-production": + print("INFO: Using default JWT_SECRET ('change-me-in-production').") + print("To use a custom one, set the JWT_SECRET environment variable.") + +JWT_TOKEN = _generate_token(_JWT_VAL) + +PASS = "\033[92mPASS\033[0m" +FAIL = "\033[91mFAIL\033[0m" +SKIP = "\033[93mSKIP\033[0m" + +results = [] + + +def request(method, path, body=None, auth=True, bad_token=False): + """Make an HTTP request and return (status_code, response_body).""" + url = f"{API_URL}{path}" + headers = {"Content-Type": "application/json"} + + if bad_token: + # Deliberately malformed token to test rejection + headers["Authorization"] = "Bearer this.is.not.a.valid.jwt" + elif auth and JWT_TOKEN: + headers["Authorization"] = f"Bearer {JWT_TOKEN}" + + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + + try: + with urllib.request.urlopen(req, timeout=45) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + try: + body_bytes = e.read() + return e.code, json.loads(body_bytes) + except Exception: + return e.code, {} + except Exception as e: + return 0, {"error": str(e)} + + +def test(name, method, path, check_fn, body=None, auth=True, bad_token=False): + """Run a single test case.""" + status, body_resp = request(method, path, body=body, auth=auth, bad_token=bad_token) + try: + passed = check_fn(status, body_resp) + except Exception as e: + passed = False + body_resp = {"exception": str(e)} + + label = PASS if passed else FAIL + print(f" [{label}] {name}") + if not passed: + print(f" Status: {status}") + print(f" Body: {json.dumps(body_resp, indent=2)[:300]}") + + results.append((name, passed)) + return passed + + +def skip(name, reason): + """Record a test as skipped — does not count as a failure.""" + print(f" [{SKIP}] {name}") + print(f" {reason}") + results.append((name, None)) + + +# ── TC-01: Health check ──────────────────────────────────────────────────── +print("\n=== Health Check ===") +test( + "TC-01 GET /health returns 200", + "GET", "/health", + lambda s, b: s == 200, + auth=False, +) +test( + "TC-02 GET /health returns status ok", + "GET", "/health", + lambda s, b: b.get("status") == "ok", + auth=False, +) +test( + "TC-03 GET /health requires no auth token", + "GET", "/health", + lambda s, b: s == 200, # Public path — must not return 401 + auth=False, +) + +# ── TC-04 to TC-08: Findings endpoint ───────────────────────────────────── +print("\n=== Findings Endpoint ===") +test( + "TC-04 GET /api/findings returns 200", + "GET", "/api/findings", + lambda s, b: s == 200, +) +test( + "TC-05 GET /api/findings returns 'findings' key", + "GET", "/api/findings", + lambda s, b: "findings" in b, +) +test( + "TC-06 GET /api/findings returns 'count' key", + "GET", "/api/findings", + lambda s, b: "count" in b and isinstance(b["count"], int), +) +test( + "TC-07 GET /api/findings?severity=HIGH filters correctly", + "GET", "/api/findings?severity=HIGH", + lambda s, b: s == 200 and all( + f.get("severity") == "HIGH" + for f in b.get("findings", []) + ), +) +test( + "TC-08 GET /api/findings?severity=INVALID returns 400 or empty", + "GET", "/api/findings?severity=INVALID", + lambda s, b: s in (200, 400), +) + +# ── TC-09 to TC-11: Score endpoint ──────────────────────────────────────── +print("\n=== Score Endpoint ===") +test( + "TC-09 GET /api/score returns 200", + "GET", "/api/score", + lambda s, b: s == 200, +) +test( + "TC-10 GET /api/score returns numeric score", + "GET", "/api/score", + lambda s, b: isinstance(b.get("score"), (int, float)), +) +test( + "TC-11 GET /api/score is between 0 and 100", + "GET", "/api/score", + lambda s, b: 0 <= b.get("score", -1) <= 100, +) + +# ── TC-12 to TC-14: Scans endpoint ──────────────────────────────────────── +print("\n=== Scans Endpoint ===") +test( + "TC-12 GET /api/scans returns 200", + "GET", "/api/scans", + lambda s, b: s == 200, +) + +if _RUN_REAL_SCAN and _AZURE_CREDS_PRESENT: + test( + "TC-13 POST /api/scans/trigger returns 200, 201 or 202", + "POST", "/api/scans/trigger", + lambda s, b: s in (200, 201, 202), + body={"subscription_id": _REAL_SUB}, + ) + test( + "TC-14 POST /api/scans/trigger returns scan_id or job_id", + "POST", "/api/scans/trigger", + lambda s, b: any(k in b for k in ("scan_id", "job_id", "id", "message")), + body={"subscription_id": _REAL_SUB}, + ) +else: + _skip_reason = ( + "Real scan skipped — set RUN_REAL_SCAN=true with all four Azure credentials to enable." + if not _RUN_REAL_SCAN + else "Real scan skipped — one or more Azure credentials (SUBSCRIPTION_ID, CLIENT_ID, CLIENT_SECRET, TENANT_ID) are missing." + ) + skip("TC-13 POST /api/scans/trigger returns 200, 201 or 202", _skip_reason) + skip("TC-14 POST /api/scans/trigger returns scan_id or job_id", _skip_reason) + +# ── TC-15 to TC-17: Compliance endpoints ────────────────────────────────── +print("\n=== Compliance Endpoints ===") +for framework in ("cis", "nist", "iso27001"): + test( + f"TC GET /api/compliance/{framework} returns 200", + "GET", f"/api/compliance/{framework}", + lambda s, b: s == 200, + ) + +# ── TC-18: Unauthenticated request is rejected ──────────────────────────── +print("\n=== Auth / Security Edge Cases ===") +test( + "TC-18 GET /api/findings without auth returns 401", + "GET", "/api/findings", + lambda s, b: s == 401, + auth=False, +) +test( + "TC-19 GET /api/findings with malformed token returns 401", + "GET", "/api/findings", + lambda s, b: s == 401, + bad_token=True, +) + +# ── TC-20 to TC-23: Edge cases ──────────────────────────────────────────── +print("\n=== Edge Cases ===") +test( + "TC-20 GET /nonexistent returns 404", + "GET", "/nonexistent-endpoint-xyz", + lambda s, b: s == 404, + auth=True, +) +test( + "TC-21 POST /api/scans/trigger with empty body still works", + "POST", "/api/scans/trigger", + lambda s, b: s in (200, 201, 202, 400), + body={}, +) +test( + "TC-22 GET /api/findings?limit=0 does not crash", + "GET", "/api/findings?limit=0", + lambda s, b: s in (200, 400), +) +test( + "TC-23 Response Content-Type is JSON", + "GET", "/api/findings", + lambda s, b: isinstance(b, dict), +) + +# ── Summary ──────────────────────────────────────────────────────────────── +print("\n=== Summary ===") +passed = sum(1 for _, p in results if p is True) +skipped = sum(1 for _, p in results if p is None) +failed_tests = [name for name, p in results if p is False] +total = len(results) + +skip_note = f", {skipped} skipped" if skipped else "" +print(f" {passed}/{total - skipped} tests passed{skip_note}") + +if skipped: + print(f"\n Skipped tests (not failures):") + for name, p in results: + if p is None: + print(f" - {name}") + print(f"\n To enable real scan tests: RUN_REAL_SCAN=true with AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID") + +if failed_tests: + print(f"\n Failed tests:") + for name in failed_tests: + print(f" - {name}") + print(f"\nSmoke test FAILED. Do not open a PR until all tests pass.") + sys.exit(1) +else: + print(f"\n All smoke tests passed.") + sys.exit(0) From ba6c70c5e2d70205ccee7b6690e0e6c1881aed03 Mon Sep 17 00:00:00 2001 From: Mahfuzur Rahman Emon Date: Wed, 13 May 2026 22:58:41 +0100 Subject: [PATCH 39/50] feat: AZ-NET-011 Network Watcher not enabled in all regions (#42) * feat: add AZ-NET-011 Network Watcher rule, playbook and compliance mappings * fix: add missing AzureClient methods, SOC2 mapping and fix playbook region * fix: add SOC2 CC7.2 to FRAMEWORKS in az_net_011.py --- .../frameworks/cis_azure_benchmark.json | 9 +++- compliance/frameworks/iso27001.json | 7 ++- compliance/frameworks/nist_csf.json | 9 +++- compliance/frameworks/soc2.json | 7 ++- playbooks/cli/fix_az_net_011.sh | 45 +++++++++++++++++ scanner/azure_client.py | 30 +++++++++++- scanner/rules/az_net_011.py | 48 +++++++++++++++++++ 7 files changed, 148 insertions(+), 7 deletions(-) create mode 100755 playbooks/cli/fix_az_net_011.sh create mode 100644 scanner/rules/az_net_011.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 4268aa1..f654b33 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -101,7 +101,7 @@ "AZ-KV-001": { "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", - "description": "Azure Key Vault soft delete should be enabled on all Key Vaults. The soft delete feature allows recovery of deleted vaults and vault objects (keys, secrets, certificates) for a configurable retention period (7–90 days), protecting against accidental or malicious deletion." + "description": "Azure Key Vault soft delete should be enabled on all Key Vaults. The soft delete feature allows recovery of deleted vaults and vault objects (keys, secrets, certificates) for a configurable retention period (7\u201390 days), protecting against accidental or malicious deletion." }, "AZ-STOR-003": { "control_id": "3.7", @@ -117,6 +117,11 @@ "control_id": "8.3", "control_name": "Ensure that public network access to Key Vault is disabled", "description": "Azure Key Vault should not allow public network access unless absolutely necessary. Enabling public access increases the attack surface and exposes sensitive secrets, keys, and certificates to potential unauthorized access. Private endpoints should be used to restrict access to trusted networks." + }, + "AZ-NET-011": { + "control_id": "6.5", + "control_name": "Ensure that Network Watcher is enabled in all regions", + "description": "Network Watcher should be enabled in all regions where Azure resources are deployed. Network Watcher provides network monitoring, diagnostics, and logging capabilities essential for investigating network-level incidents." } } -} +} \ No newline at end of file diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 00ab6d2..1df1924 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -117,6 +117,11 @@ "control_id": "A.13.1.1", "control_name": "Network controls", "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." + }, + "AZ-NET-011": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Network Watcher must be enabled in all regions where resources are deployed to ensure network events are logged and available for investigation. Event logs recording network activity should be produced and retained to support incident response." } } -} +} \ No newline at end of file diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index ff8813c..fab08da 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -97,7 +97,7 @@ "control_id": "PR.DS-1", "control_name": "Data-at-rest is protected", "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). PR.DS-1 requires that data at rest is protected using appropriate controls. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." - }, + }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", @@ -117,6 +117,11 @@ "control_id": "DE.CM-7", "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", "description": "Diagnostic logging on Azure Storage services provides the audit trail needed to monitor for unauthorized or anomalous read, write, and delete operations. Without logging, detection of data exfiltration or unauthorized access to blob, queue, or table services is not possible." + }, + "AZ-NET-011": { + "control_id": "DE.CM-7", + "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", + "description": "Network Watcher must be enabled in all active regions to support continuous monitoring of network activity. Without it, unauthorized connections and anomalous network behaviour cannot be detected or investigated." } } -} +} \ No newline at end of file diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index e9e87f0..6483684 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -112,6 +112,11 @@ "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", "description": "A Key Vault accessible from the public internet allows any external party to attempt access to secrets, keys and certificates. CC6.6 requires that access from outside the network boundary is restricted and controlled. Locking Key Vault access to private endpoints or specific VNet service endpoints enforces this boundary and protects sensitive credentials from external exposure." + }, + "AZ-NET-011": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "Network Watcher must be enabled in all regions where resources are deployed to support continuous system monitoring. Without it, network-level events cannot be detected or investigated, violating the requirement for ongoing monitoring of system components." } } -} +} \ No newline at end of file diff --git a/playbooks/cli/fix_az_net_011.sh b/playbooks/cli/fix_az_net_011.sh new file mode 100755 index 0000000..4e55011 --- /dev/null +++ b/playbooks/cli/fix_az_net_011.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Playbook: fix_az_net_011.sh +# Rule: AZ-NET-011 — Network Watcher not enabled in all regions + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + exit 1 +fi + +SUBSCRIPTION_ID="$1" + +echo "Setting subscription..." +az account set --subscription "$SUBSCRIPTION_ID" + +echo "Fetching regions with resources..." +RESOURCE_REGIONS=$(az resource list --subscription "$SUBSCRIPTION_ID" \ + --query "[].location" --output tsv | sort -u | tr -d ' ') + +echo "Fetching regions with Network Watcher..." +WATCHED_REGIONS=$(az network watcher list --subscription "$SUBSCRIPTION_ID" \ + --query "[].location" --output tsv 2>/dev/null | sort -u | tr -d ' ' || echo "") + +echo "Enabling Network Watcher in unmonitored regions..." +while IFS= read -r REGION; do + if echo "$WATCHED_REGIONS" | grep -qx "$REGION"; then + echo " [SKIP] $REGION — already enabled" + else + RESOURCE_GROUP="NetworkWatcherRG-${REGION}" + echo " [FIX] $REGION — creating resource group $RESOURCE_GROUP..." + az group create --name "$RESOURCE_GROUP" --location "$REGION" --output none + echo " [FIX] $REGION — enabling Network Watcher..." + az network watcher configure \ + --resource-group "$RESOURCE_GROUP" \ + --locations "$REGION" \ + --enabled true \ + --subscription "$SUBSCRIPTION_ID" \ + --output none + echo " Done." + fi +done <<< "$RESOURCE_REGIONS" + +echo "Done! Verify with:" +echo " az network watcher list --subscription $SUBSCRIPTION_ID --output table" diff --git a/scanner/azure_client.py b/scanner/azure_client.py index e9df038..aac5159 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -350,4 +350,32 @@ def get_conditional_access_policies(self) -> List[Any]: return response.json().get("value", []) except Exception as exc: logger.error("get_conditional_access_policies failed: %s", exc) - return [] \ No newline at end of file + return [] + def get_regions_with_resources(self) -> List[str]: + """List all regions that have at least one resource deployed.""" + try: + from azure.mgmt.resource import ResourceManagementClient + client = ResourceManagementClient(self.credential, self.subscription_id) + regions = { + r.location.lower().replace(" ", "") + for r in client.resources.list() + if r.location + } + return list(regions) + except Exception as exc: + logger.error("get_regions_with_resources failed: %s", exc) + return [] + + def get_network_watcher_regions(self) -> List[str]: + """List all regions that already have Network Watcher enabled.""" + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + regions = { + w.location.lower().replace(" ", "") + for w in client.network_watchers.list_all() + if w.location + } + return list(regions) + except Exception as exc: + logger.error("get_network_watcher_regions failed: %s", exc) + return [] diff --git a/scanner/rules/az_net_011.py b/scanner/rules/az_net_011.py new file mode 100644 index 0000000..978b2a0 --- /dev/null +++ b/scanner/rules/az_net_011.py @@ -0,0 +1,48 @@ +"""AZ-NET-011: Network Watcher not enabled in all regions.""" +from typing import Any, Dict, List + +RULE_ID = "AZ-NET-011" +RULE_NAME = "Network Watcher Not Enabled in All Regions" +SEVERITY = "LOW" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "6.5", "NIST": "DE.CM-7", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} +DESCRIPTION = ( + "Network Watcher is not enabled in one or more Azure regions where resources " + "are deployed. Network Watcher provides network monitoring, diagnostics, and " + "logging capabilities. Without it, network-level incidents cannot be " + "investigated or diagnosed." +) +REMEDIATION = ( + "Enable Network Watcher in all regions where Azure resources are deployed. " + "Run: az network watcher configure --resource-group NetworkWatcherRG " + "--locations --enabled true" +) +PLAYBOOK = "playbooks/cli/fix_az_net_011.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect regions where resources exist but Network Watcher is not enabled.""" + findings: List[Dict[str, Any]] = [] + + regions_with_resources = azure_client.get_regions_with_resources() + regions_with_watcher = azure_client.get_network_watcher_regions() + + unmonitored_regions = set(regions_with_resources) - set(regions_with_watcher) + + for region in sorted(unmonitored_regions): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/subscriptions/{subscription_id}/regions/{region}", + "resource_name": region, + "resource_type": "Microsoft.Network/networkWatchers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"region": region}, + }) + + return findings From e7c34875ff71692504e7bb74ceaed58eb8dd1f04 Mon Sep 17 00:00:00 2001 From: Mahfuzur Rahman Emon Date: Sat, 16 May 2026 02:14:12 +0100 Subject: [PATCH 40/50] =?UTF-8?q?feat:=20add=20AZ-DB-003=20PostgreSQL=20Fl?= =?UTF-8?q?exible=20Server=20SSL=20enforcement=20rule=20a=E2=80=A6=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add AZ-DB-003 PostgreSQL Flexible Server SSL enforcement rule and playbook * fix: correct requirements.txt formatting for postgresqlflexibleserver * fix: correct postgresqlflexibleservers package name and version * fix: handle empty params gracefully and clean up playbook output --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 5 ++ compliance/frameworks/nist_csf.json | 5 ++ compliance/frameworks/soc2.json | 5 ++ playbooks/cli/fix_az_db_003.sh | 39 +++++++++ requirements.txt | 3 +- scanner/azure_client.py | 22 +++++ scanner/rules/az_db_003.py | 81 +++++++++++++++++++ 8 files changed, 164 insertions(+), 1 deletion(-) create mode 100755 playbooks/cli/fix_az_db_003.sh create mode 100644 scanner/rules/az_db_003.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index f654b33..f5c1989 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -122,6 +122,11 @@ "control_id": "6.5", "control_name": "Ensure that Network Watcher is enabled in all regions", "description": "Network Watcher should be enabled in all regions where Azure resources are deployed. Network Watcher provides network monitoring, diagnostics, and logging capabilities essential for investigating network-level incidents." + }, + "AZ-DB-003": { + "control_id": "4.3.6", + "control_name": "Ensure SSL connection is enabled for PostgreSQL Flexible Server", + "description": "SSL enforcement should be enabled on PostgreSQL Flexible Server to ensure data in transit is encrypted. Without SSL, database connections transmit data in plaintext, exposing it to interception." } } } \ No newline at end of file diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 1df1924..697052e 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -122,6 +122,11 @@ "control_id": "A.12.4.1", "control_name": "Event logging", "description": "Network Watcher must be enabled in all regions where resources are deployed to ensure network events are logged and available for investigation. Event logs recording network activity should be produced and retained to support incident response." + }, + "AZ-DB-003": { + "control_id": "A.10.1.1", + "control_name": "Policy on the use of cryptographic controls", + "description": "SSL enforcement on PostgreSQL Flexible Server applies cryptographic controls to data in transit. A policy on the use of cryptographic controls for protection of information should be developed and implemented." } } } \ No newline at end of file diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index fab08da..ad41cc2 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -122,6 +122,11 @@ "control_id": "DE.CM-7", "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", "description": "Network Watcher must be enabled in all active regions to support continuous monitoring of network activity. Without it, unauthorized connections and anomalous network behaviour cannot be detected or investigated." + }, + "AZ-DB-003": { + "control_id": "PR.DS-2", + "control_name": "Data-in-transit is protected", + "description": "SSL enforcement on PostgreSQL Flexible Server ensures data in transit between applications and the database is encrypted. Disabling SSL exposes database traffic to interception and tampering." } } } \ No newline at end of file diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 6483684..a793241 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -117,6 +117,11 @@ "control_id": "CC7.2", "control_name": "System monitoring", "description": "Network Watcher must be enabled in all regions where resources are deployed to support continuous system monitoring. Without it, network-level events cannot be detected or investigated, violating the requirement for ongoing monitoring of system components." + }, + "AZ-DB-003": { + "control_id": "CC6.1", + "control_name": "Logical and physical access controls", + "description": "SSL enforcement ensures database connections are encrypted, protecting data in transit from unauthorized access. Disabling SSL undermines logical access controls by exposing database traffic in plaintext." } } } \ No newline at end of file diff --git a/playbooks/cli/fix_az_db_003.sh b/playbooks/cli/fix_az_db_003.sh new file mode 100755 index 0000000..63df89d --- /dev/null +++ b/playbooks/cli/fix_az_db_003.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Playbook: fix_az_db_003.sh +# Rule: AZ-DB-003 — PostgreSQL Flexible Server SSL enforcement disabled + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " + exit 1 +fi + +SUBSCRIPTION_ID="$1" + +echo "Setting subscription..." +az account set --subscription "$SUBSCRIPTION_ID" + +echo "Fetching PostgreSQL Flexible Servers..." +SERVERS=$(az postgres flexible-server list --subscription "$SUBSCRIPTION_ID" --query "[].{name:name, rg:resourceGroup}" --output tsv) + +if [[ -z "$SERVERS" ]]; then + echo "No PostgreSQL Flexible Servers found." + exit 0 +fi + +while IFS=$'\t' read -r SERVER_NAME RESOURCE_GROUP; do + echo "Checking $SERVER_NAME in $RESOURCE_GROUP..." + SSL_VALUE=$(az postgres flexible-server parameter show --resource-group "$RESOURCE_GROUP" --server-name "$SERVER_NAME" --name require_secure_transport --query "value" --output tsv 2>/dev/null || echo "on") + + if [[ "${SSL_VALUE,,}" == "off" ]]; then + echo "Enabling SSL on $SERVER_NAME..." + az postgres flexible-server parameter set --resource-group "$RESOURCE_GROUP" --server-name "$SERVER_NAME" --name require_secure_transport --value ON --output none + echo "Done." + else + echo "$SERVER_NAME already has SSL enabled, skipping." + fi +done <<< "$SERVERS" + +echo "Done. Verify with:" +echo " az postgres flexible-server parameter show --name require_secure_transport --server-name --resource-group " diff --git a/requirements.txt b/requirements.txt index 66e344e..52f1710 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,5 @@ requests==2.31.0 pyyaml==6.0.1 gunicorn==21.2.0 cryptography==42.0.5 -msrest==0.7.1 \ No newline at end of file +msrest==0.7.1 +azure-mgmt-postgresqlflexibleservers==1.0.0b1 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index aac5159..3976586 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -330,6 +330,28 @@ def get_service_principals(self) -> List[Any]: logger.error("get_service_principals failed: %s", exc) return [] + + def get_postgresql_flexible_servers(self) -> List[Any]: + """List all PostgreSQL Flexible Server instances in the subscription.""" + try: + from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient as FlexClient + client = FlexClient(self.credential, self.subscription_id) + return list(client.servers.list()) + except Exception as exc: + logger.error("get_postgresql_flexible_servers failed: %s", exc) + return [] + + + def get_postgresql_flexible_server_parameters(self, resource_group: str, server_name: str) -> List[Any]: + """List all configuration parameters for a PostgreSQL Flexible Server.""" + try: + from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient as FlexClient + client = FlexClient(self.credential, self.subscription_id) + return list(client.configurations.list_by_server(resource_group, server_name)) + except Exception as exc: + logger.error("get_postgresql_flexible_server_parameters(%s) failed: %s", server_name, exc) + return [] + def get_conditional_access_policies(self) -> List[Any]: """Fetch Conditional Access policies from the Microsoft Graph API. diff --git a/scanner/rules/az_db_003.py b/scanner/rules/az_db_003.py new file mode 100644 index 0000000..cc0b0c1 --- /dev/null +++ b/scanner/rules/az_db_003.py @@ -0,0 +1,81 @@ +"""AZ-DB-003: PostgreSQL Flexible Server SSL enforcement disabled.""" +from typing import Any, Dict, List +import logging + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-DB-003" +RULE_NAME = "PostgreSQL Flexible Server SSL Enforcement Disabled" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "4.3.6", "NIST": "PR.DS-2", "ISO27001": "A.10.1.1", "SOC2": "CC6.1"} +DESCRIPTION = ( + "The Azure Database for PostgreSQL Flexible Server has SSL enforcement disabled. " + "Without SSL, data in transit between the application and database is transmitted " + "in plaintext and is vulnerable to interception and man-in-the-middle attacks." +) +REMEDIATION = ( + "Enable SSL enforcement on the PostgreSQL Flexible Server by setting " + "require_secure_transport to ON. " + "Run: az postgres flexible-server parameter set --resource-group " + "--server-name --name require_secure_transport --value ON" +) +PLAYBOOK = "playbooks/cli/fix_az_db_003.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect PostgreSQL Flexible Servers with SSL enforcement disabled.""" + findings: List[Dict[str, Any]] = [] + + for server in azure_client.get_postgresql_flexible_servers(): + parsed = azure_client.parse_resource_id(server.id) + resource_group = parsed.get("resource_group", "") + + params = azure_client.get_postgresql_flexible_server_parameters( + resource_group, server.name + ) + + if not params: + # Cannot determine SSL state — skip to avoid false positives + logger.warning( + "az_db_003: skipping %s — get_postgresql_flexible_server_parameters " + "returned empty (permission or API failure)", + server.name, + ) + continue + + ssl_param = next( + (p for p in params if getattr(p, "name", "") == "require_secure_transport"), + None, + ) + + if ssl_param is None: + # Parameter not found — cannot determine compliance, skip + logger.warning( + "az_db_003: skipping %s — require_secure_transport parameter not found", + server.name, + ) + continue + + ssl_value = str(getattr(ssl_param, "value", "on")).lower() + if ssl_value in ("off", "false", "0"): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server.name, + "resource_type": "Microsoft.DBforPostgreSQL/flexibleServers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": resource_group, + "location": getattr(server, "location", ""), + "ssl_value": ssl_value, + }, + }) + + return findings From bc146ef2b48e58e88637aa7521c6eadab01236cd Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 23 May 2026 18:09:12 +0100 Subject: [PATCH 41/50] [RULE] AZ-CMP-003: VM without endpoint protection installed (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add scanner rule AZ-CMP-003 — VM without endpoint protection installed This script scans Azure VMs to check for the presence of recognized endpoint protection extensions. It logs findings for VMs without the required protection. * feat: add remediation playbook fix_az_cmp_003.sh This script installs endpoint protection on Azure VMs based on the operating system specified. It supports both Linux and Windows VMs. * feat: add AZ-CMP-003 to CIS compliance framework * feat: add AZ-CMP-003 to NIST compliance framework * feat: add AZ-CMP-003 to ISO27001 compliance framework * feat: add AZ-CMP-003 to SOC2 compliance framework * feat: add get_vm_extensions method to AzureClient Add method to retrieve VM extensions for a given VM. * fix: correct indentation and return type in get_vm_extensions * Add 1 more space in the code * add 4 space beofre def Add method to retrieve VM extensions for a given VM. --- .../frameworks/cis_azure_benchmark.json | 7 +- compliance/frameworks/iso27001.json | 7 +- compliance/frameworks/nist_csf.json | 7 +- compliance/frameworks/soc2.json | 7 +- playbooks/cli/fix_az_cmp_003.sh | 49 ++++++++++++ scanner/azure_client.py | 11 +++ scanner/rules/az_cmp_003.py | 80 +++++++++++++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 playbooks/cli/fix_az_cmp_003.sh create mode 100644 scanner/rules/az_cmp_003.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index f5c1989..ee6ec55 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -98,6 +98,11 @@ "control_name": "Ensure that 'OS disk' are encrypted", "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CIS 7.2 requires disks to be protected using customer-managed keys or Azure Disk Encryption. Platform-managed encryption does not give the organisation control over the encryption keys and does not satisfy this control." }, + "AZ-CMP-003": { + "control_id": "8.2", + "control_name": "Ensure that 'Endpoint protection solution' is installed on VMs", + "description": "The virtual machine does not have a recognised endpoint protection extension installed. CIS 8.2 requires that an approved endpoint protection solution is installed and running on all virtual machines. Without endpoint protection, malware and ransomware can execute without detection." + }, "AZ-KV-001": { "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", @@ -129,4 +134,4 @@ "description": "SSL enforcement should be enabled on PostgreSQL Flexible Server to ensure data in transit is encrypted. Without SSL, database connections transmit data in plaintext, exposing it to interception." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 697052e..c82b2ff 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -98,6 +98,11 @@ "control_name": "Policy on the use of cryptographic controls", "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). A.10.1.1 requires that a policy on the use of cryptographic controls is developed and implemented. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." }, + "AZ-CMP-003": { + "control_id": "A.12.2.1", + "control_name": "Controls against malware", + "description": "The virtual machine does not have a recognised endpoint protection extension installed. A.12.2.1 requires that detection, prevention and recovery controls are implemented to protect against malware. Without endpoint protection, malware executing on the VM will not be detected or prevented." + }, "AZ-KV-001": { "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", @@ -129,4 +134,4 @@ "description": "SSL enforcement on PostgreSQL Flexible Server applies cryptographic controls to data in transit. A policy on the use of cryptographic controls for protection of information should be developed and implemented." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index ad41cc2..fbd3a85 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -98,6 +98,11 @@ "control_name": "Data-at-rest is protected", "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). PR.DS-1 requires that data at rest is protected using appropriate controls. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." }, + "AZ-CMP-003": { + "control_id": "DE.CM-4", + "control_name": "Malicious code is detected", + "description": "The virtual machine does not have a recognised endpoint protection extension installed. DE.CM-4 requires that malicious code is detected on organisational systems. Without endpoint protection, malware and ransomware executing on the VM will not be detected or blocked." + }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", @@ -129,4 +134,4 @@ "description": "SSL enforcement on PostgreSQL Flexible Server ensures data in transit between applications and the database is encrypted. Disabling SSL exposes database traffic to interception and tampering." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index a793241..8156621 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -103,6 +103,11 @@ "control_name": "Protects Data in Transit and At Rest", "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CC6.7 requires that data is protected using encryption. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." }, + "AZ-CMP-003": { + "control_id": "CC6.8", + "control_name": "Prevents or Detects Unauthorized or Malicious Software", + "description": "The virtual machine does not have a recognised endpoint protection extension installed. CC6.8 requires that controls are implemented to prevent or detect and act upon the introduction of unauthorized or malicious software. Without endpoint protection, malicious code executing on the VM will not be detected or blocked." + }, "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", @@ -124,4 +129,4 @@ "description": "SSL enforcement ensures database connections are encrypted, protecting data in transit from unauthorized access. Disabling SSL undermines logical access controls by exposing database traffic in plaintext." } } -} \ No newline at end of file +} diff --git a/playbooks/cli/fix_az_cmp_003.sh b/playbooks/cli/fix_az_cmp_003.sh new file mode 100644 index 0000000..f2c83f1 --- /dev/null +++ b/playbooks/cli/fix_az_cmp_003.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-CMP-003 — VM without endpoint protection installed +# Usage: ./fix_az_cmp_003.sh [windows|linux] +# Severity: HIGH + +set -e + +RG=$1 +VM=$2 +OS=${3:-windows} + +if [ -z "$RG" ] || [ -z "$VM" ]; then + echo "Usage: $0 [windows|linux]" + exit 1 +fi + +if [ "${OS,,}" = "linux" ]; then + echo "Installing MDE.Linux on $VM..." + az vm extension set \ + --resource-group "$RG" \ + --vm-name "$VM" \ + --name "MDE.Linux" \ + --publisher "Microsoft.Azure.AzureDefenderForServers" \ + --version "1.0" \ + --auto-upgrade-minor-version true + echo "Done. Finish onboarding in the Defender portal." +else + echo "Enabling IaaSAntimalware on $VM..." + SETTINGS='{ + "AntimalwareEnabled": true, + "RealtimeProtectionEnabled": true, + "ScheduledScanSettings": { + "isEnabled": true, + "day": "1", + "time": "120", + "scanType": "Quick" + } + }' + az vm extension set \ + --resource-group "$RG" \ + --vm-name "$VM" \ + --name "IaaSAntimalware" \ + --publisher "Microsoft.Azure.Security" \ + --version "1.3" \ + --auto-upgrade-minor-version true \ + --settings "$SETTINGS" + echo "IaaSAntimalware enabled on $VM." +fi diff --git a/scanner/azure_client.py b/scanner/azure_client.py index e65f567..5dc9bd0 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -240,6 +240,7 @@ def get_virtual_networks(self) -> List[Any]: logger.error("get_virtual_networks failed: %s", exc) return [] + def get_public_ip_addresses(self) -> List[Any]: """List all public IP addresses in the subscription.""" try: @@ -262,6 +263,16 @@ def get_virtual_machines(self) -> List[Any]: logger.error("get_virtual_machines failed: %s", exc) return [] + + + def get_vm_extensions(self, resource_group: str, vm_name: str) -> Optional[List[Any]]: + try: + result = ComputeManagementClient(self.credential, self.subscription_id).virtual_machine_extensions.list(resource_group, vm_name) + return list(getattr(result, "value", []) or []) + except Exception as exc: + logger.error("get_vm_extensions failed for %s/%s: %s", resource_group, vm_name, exc) + return None + # ------------------------------------------------------------------ # # Databases # # ------------------------------------------------------------------ # diff --git a/scanner/rules/az_cmp_003.py b/scanner/rules/az_cmp_003.py new file mode 100644 index 0000000..96c88a0 --- /dev/null +++ b/scanner/rules/az_cmp_003.py @@ -0,0 +1,80 @@ +"""AZ-CMP-003: VM without endpoint protection installed.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-CMP-003" +RULE_NAME = "VM Without Endpoint Protection Installed" +SEVERITY = "HIGH" +CATEGORY = "Compute" +FRAMEWORKS = { + "CIS": "8.2", + "NIST": "DE.CM-4", + "ISO27001": "A.12.2.1", + "SOC2": "CC6.8", +} +DESCRIPTION = ( + "VM has no recognised endpoint protection extension installed. " + "Without it malware and ransomware can run undetected. " + "CIS 8.2 requires an approved AV/EDR solution on all VMs." +) +REMEDIATION = ( + "Install IaaSAntimalware or onboard to MDE (MDE.Windows / MDE.Linux) " + "depending on the OS." +) +PLAYBOOK = "playbooks/cli/fix_az_cmp_003.sh" + +KNOWN_EP_EXTENSIONS = { + "microsoftmonitoringagent", + "mde.linux", + "mde.windows", + "iaasantimalware", +} + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + + for vm in azure_client.get_virtual_machines(): + parsed = azure_client.parse_resource_id(getattr(vm, "id", "")) + rg = parsed.get("resource_group", "") + vm_name = parsed.get("name", "") + if not rg or not vm_name: + continue + + exts = azure_client.get_vm_extensions(rg, vm_name) + if exts is None: + continue + + installed = set() + for e in exts: + t = ( + getattr(e, "type_properties_type", None) + or getattr(e, "virtual_machine_extension_type", None) + or getattr(e, "type", "") + ) + if t: + installed.add(t.lower()) + + if not installed.intersection(KNOWN_EP_EXTENSIONS): + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vm.id, + "resource_name": vm_name, + "resource_type": "Microsoft.Compute/virtualMachines", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": rg, + "installed_extensions": sorted(installed), + }, + }) + + return findings From 923cc754a3d292fdd3e59aa30ddae922abb8ec58 Mon Sep 17 00:00:00 2001 From: PARTH J ROHIT Date: Sat, 23 May 2026 18:14:27 +0100 Subject: [PATCH 42/50] [DOCS] Add OpenShield learning and onboarding portal (#51) * docs: add OpenShield learning portal * Fix formatting for Learn OpenShield section --------- Co-authored-by: Vishnu Ajith <86302373+Vishnu2707@users.noreply.github.com> --- README.md | 17 ++ docs/learn/index.html | 479 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 docs/learn/index.html diff --git a/README.md b/README.md index d4dca8b..8b8aa43 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ openshield/ --- + ## Quick Start ```bash @@ -185,4 +186,20 @@ MIT — free to use, modify, and distribute. --- +> Built with ❤️ by security engineers and students who believe cloud security tooling should be accessible to everyone. + +--- + +## Learn OpenShield + +Explore the OpenShield learning portal to understand: + +- Azure CSPM fundamentals +- OpenShield architecture +- Compliance mappings +- Remediation workflows +- Contributor onboarding +- Documentation navigation + +👉 [OpenShield Learn](docs/learn/index.html) > Built by security engineers and students who believe cloud security tooling should be accessible to everyone. diff --git a/docs/learn/index.html b/docs/learn/index.html new file mode 100644 index 0000000..93c5164 --- /dev/null +++ b/docs/learn/index.html @@ -0,0 +1,479 @@ + + + + + + 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.

+
+
+

Remediation Guidance

+

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

+
+
+

Drift Detection

+

Tracks changes in cloud security posture so teams can identify when previously safe configurations become risky.

+
+
+
+ +
+

How OpenShield Works

+

+ OpenShield follows a simple scanning pipeline: collect Azure resource configuration, evaluate rules, + generate findings, map them to controls, and expose results through the platform. +

+
+
Azure Subscription
+
Scanner Engine
+
Rule Evaluation
+
Findings
+
Compliance Mapping
+
Drift Detection
+
Dashboard & Reporting
+
+
+ +
+

Core Components

+

+ 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. +

+
+
+

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 +
+
+
+ +
+

CSPM Basics

+

+ 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. +

+
+
+

Why It Matters

+

Cloud breaches often happen because resources are misconfigured, not because the cloud provider itself failed.

+
+
+

Example Issues

+
    +
  • Public blob access
  • +
  • Weak network security groups
  • +
  • Missing monitoring or logging
  • +
  • Over-permissive access policies
  • +
+
+
+

OpenShield Role

+

OpenShield helps surface these issues, explain their impact, and guide users toward safer Azure configurations.

+
+
+
+ +
+

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

+

+ 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. +

+
+

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.

+
+
+ +
+

Contributor Learning Path

+

+ New contributors should understand the security problem first, then the OpenShield architecture, + then the rule and remediation workflow. +

+
+
+

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 Links

+

+ Use these links as the starting point for understanding and contributing to OpenShield. +

+
+
+
ArchitectureSystem design, platform components, and scanning workflow.
+ Open +
+
+
API ReferenceBackend API documentation for working with OpenShield data.
+ Open +
+
+
Azure SetupRequired Azure setup and configuration before running scans.
+ Open +
+
+
Rules ReferenceRule documentation and expected structure for security checks.
+ Open +
+
+
Adding a RuleContributor guide for creating and testing new scan rules.
+ 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. +
+
+ +
+ OpenShield — Open Source Azure CSPM Platform | Learn, Contribute, Improve Azure Security +
+ + From 4a2ef014a6db829d84bd752478cfc2fef6a9ca6d Mon Sep 17 00:00:00 2001 From: Safid Nadaf <137755124+safidnadaf@users.noreply.github.com> Date: Sun, 24 May 2026 01:51:26 +0100 Subject: [PATCH 43/50] refactor: reuse database connection per request using Flask g (#41) * fix: improve scan routes error handling and database reuse * fix: add database connection reuse and DATABASE_URL validation to score.py * fix: add database connection reuse, DATABASE_URL validation, and FileNotFoundError handling to compliance.py * fix: enforce JWT_SECRET environment variable, remove hardcoded default * ci: trigger fresh CI run * fix: all requirements - g.db naming, teardown, close() method --- api/app.py | 6 +++--- api/models/finding.py | 7 +++++++ api/routes/compliance.py | 15 ++++++++++----- api/routes/scans.py | 34 ++++++++++++++++++++++------------ api/routes/score.py | 17 ++++++++++------- 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/api/app.py b/api/app.py index 691bfe4..21ccb24 100644 --- a/api/app.py +++ b/api/app.py @@ -63,9 +63,9 @@ def create_app() -> Flask: # ------------------------------------------------------------------ # @app.teardown_appcontext - def close_db(error): + def close_db(error=None): """Ensure the database connection is closed after the request.""" - db = g.pop("db_conn", None) + db = g.pop("db", None) if db is not None: try: if hasattr(db, "conn") and db.conn is not None: @@ -171,4 +171,4 @@ def internal_error(exc): host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=os.environ.get("FLASK_DEBUG", "false").lower() == "true", - ) + ) \ No newline at end of file diff --git a/api/models/finding.py b/api/models/finding.py index 7b2eda7..6f03068 100644 --- a/api/models/finding.py +++ b/api/models/finding.py @@ -96,6 +96,13 @@ def _get_conn(self) -> Any: self.connect() return self.conn + def close(self) -> None: + """Close the database connection.""" + if self.conn and not self.conn.closed: + self.conn.close() + self.conn = None + logger.debug("Database connection closed") + # ------------------------------------------------------------------ # # Schema # # ------------------------------------------------------------------ # diff --git a/api/routes/compliance.py b/api/routes/compliance.py index 798f187..6716453 100644 --- a/api/routes/compliance.py +++ b/api/routes/compliance.py @@ -13,10 +13,13 @@ def _get_db() -> DatabaseManager: - if "db_conn" not in g: - g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) - g.db_conn.connect() - return g.db_conn + if "db" not in g: + db_url = os.environ.get("DATABASE_URL") + if not db_url: + raise RuntimeError("DATABASE_URL environment variable is not set") + g.db = DatabaseManager(db_url) + g.db.connect() + return g.db @compliance_bp.get("/api/compliance/") @@ -41,6 +44,8 @@ def get_compliance(framework: str): return jsonify(result), 500 return jsonify(result) + except FileNotFoundError as exc: + return jsonify({"error": f"Frameworks directory not found: {exc}"}), 500 except Exception as exc: logger.error("Failed to retrieve compliance score for %s: %s", framework, exc) - return jsonify({"error": "Compliance calculation failed", "detail": str(exc)}), 500 + return jsonify({"error": "Compliance calculation failed", "detail": str(exc)}), 500 \ No newline at end of file diff --git a/api/routes/scans.py b/api/routes/scans.py index 5aca891..9a13009 100644 --- a/api/routes/scans.py +++ b/api/routes/scans.py @@ -11,10 +11,13 @@ def _get_db() -> DatabaseManager: - if "db_conn" not in g: - g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) - g.db_conn.connect() - return g.db_conn + if "db" not in g: + db_url = os.environ.get("DATABASE_URL") + if not db_url: + raise RuntimeError("DATABASE_URL environment variable is not set") + g.db = DatabaseManager(db_url) + g.db.connect() + return g.db @scans_bp.get("/api/scans") @@ -22,8 +25,8 @@ def list_scans(): """Return all historical scan results ordered by most recent first.""" try: db = _get_db() - scans = db.get_scans() - return jsonify({"count": len(scans), "scans": scans}) + result = db.get_scans() + return jsonify(result) except Exception as exc: logger.error("Failed to list scans: %s", exc) return jsonify({"error": "Failed to retrieve scans", "detail": str(exc)}), 500 @@ -39,15 +42,20 @@ def trigger_scan(): Note: For production use, replace this with an async task queue (e.g. Celery or Azure Functions) to avoid request timeouts on large subscriptions. """ + try: + from scanner.engine import ScanEngine + except ImportError: + return jsonify({"error": "Scanner module is not available"}), 500 + try: body = request.get_json(silent=True) or {} - subscription_id = body.get("subscription_id") + subscription_id = body.get("subscription_id") or os.environ.get( + "AZURE_SUBSCRIPTION_ID" + ) if not subscription_id: return jsonify({"error": "subscription_id is required"}), 400 - from scanner.engine import ScanEngine # deferred — import only after input is validated - logger.info("Scan triggered for subscription %s", subscription_id) try: @@ -57,16 +65,18 @@ def trigger_scan(): logger.error("Scan engine execution failed: %s", exc, exc_info=True) return jsonify({"error": "Scan failed", "detail": str(exc)}), 500 + if not isinstance(result, dict) or "scan_id" not in result: + return jsonify({"error": "Invalid scan result returned"}), 500 + try: db = _get_db() - # Note: Table creation is handled at startup; no need to repeat it here. db.save_scan(result) except Exception as exc: - logger.error("Failed to save scan result to database: %s", exc, exc_info=True) + logger.error("Failed to save scan result: %s", exc, exc_info=True) return jsonify({"error": "Database save failed", "detail": str(exc)}), 500 return jsonify(result), 201 except Exception as exc: logger.error("Critical error in trigger_scan route: %s", exc, exc_info=True) - return jsonify({"error": "Critical route failure", "detail": str(exc)}), 500 + return jsonify({"error": "Critical route failure", "detail": str(exc)}), 500 \ No newline at end of file diff --git a/api/routes/score.py b/api/routes/score.py index bfff526..190a3ee 100644 --- a/api/routes/score.py +++ b/api/routes/score.py @@ -11,10 +11,13 @@ def _get_db() -> DatabaseManager: - if "db_conn" not in g: - g.db_conn = DatabaseManager(os.environ["DATABASE_URL"]) - g.db_conn.connect() - return g.db_conn + if "db" not in g: + db_url = os.environ.get("DATABASE_URL") + if not db_url: + raise RuntimeError("DATABASE_URL environment variable is not set") + g.db = DatabaseManager(db_url) + g.db.connect() + return g.db @score_bp.get("/api/score") @@ -27,8 +30,8 @@ def get_score(): """ try: db = _get_db() - score = db.get_score() - return jsonify({"score": score, "max_score": 100}) + result = db.get_score() + return jsonify(result) except Exception as exc: logger.error("Failed to calculate score: %s", exc) - return jsonify({"error": "Failed to calculate score", "detail": str(exc)}), 500 + return jsonify({"error": "Failed to calculate score", "detail": str(exc)}), 500 \ No newline at end of file From 0e824021c29e23ce860b72b10765eb5abb585838 Mon Sep 17 00:00:00 2001 From: Ritik Sah Date: Mon, 25 May 2026 00:54:14 +0100 Subject: [PATCH 44/50] docs: add security policy, issue template, and README badges (#64) --- .github/ISSUE_TEMPLATE/feature_request.md | 69 +++++++++++++++++++ .github/SECURITY.md | 82 +++++++++++++++++++++++ README.md | 11 ++- 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..627de2f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,69 @@ +name: Feature Request +about: Suggest a new rule, compliance mapping, playbook, or capability for OpenShield +title: "feat: " +labels: enhancement +assignees: '' +--- + +## Summary + +A clear one-sentence description of the feature you are proposing. + +## Problem It Solves + +What is the current limitation or gap? Why does this matter for Azure cloud security posture? +Link any related issues or discussions if relevant. + +## Proposed Solution + +Describe what you want to happen. Be specific. + +--- + +**If proposing a new scanner rule, fill in all fields below:** + +- Azure resource type: +- Misconfiguration it detects: +- Suggested RULE_ID (format: `AZ--`, e.g. `AZ-KV-003`): +- Severity: (CRITICAL / HIGH / MEDIUM / LOW) +- Compliance frameworks it maps to: + - CIS Azure Benchmark control: + - NIST CSF control: + - ISO 27001 control: +- Does a matching remediation playbook need to be created? (Yes / No) + +--- + +**If proposing a compliance mapping:** + +- Framework name and version: +- Control ID(s): +- Which existing rules does it apply to: +- Source documentation link: + +--- + +**If proposing an API or CLI change:** + +- Endpoint or command affected: +- Current behaviour: +- Proposed behaviour: +- Example request/response or command: + +--- + +## Alternatives Considered + +What other approaches did you consider, and why did you rule them out? + +## Additional Context + +Add any Azure documentation links, CVE references, CIS Benchmark pages, screenshots, or reference implementations here. + +## Contribution + +Are you willing to implement this yourself? + +- [ ] Yes, I plan to open a PR for this +- [ ] I can help review a PR but cannot implement it myself +- [ ] I am not able to contribute code for this \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..e8e98d2 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,82 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in OpenShield, please **do not open a public GitHub issue**. +Opening a public issue exposes the vulnerability to bad actors before a fix is available. + + +We will acknowledge your report within 48 hours and work with you to coordinate a fix and responsible disclosure timeline. + +### What to include in your report + +To help us triage quickly, please include: + +- A description of the vulnerability and its potential impact +- The affected component (scanner engine, REST API, auth logic, playbooks) +- Steps to reproduce the issue +- Any relevant logs, proof-of-concept code, or screenshots +- The version of OpenShield you were testing (check `git log --oneline -1`) + +The more detail you provide, the faster we can respond. + +--- + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| 0.1.x | Yes | + +Older versions are not patched. If you are running a version below 0.1.x, upgrade to the latest release before filing a report. + +--- + +## Disclosure Process + +We follow a coordinated disclosure model: + +1. **Report received** -- you email the vulnerability privately +2. **Acknowledgement** -- we respond within 48 hours to confirm receipt +3. **Investigation** -- we reproduce and assess the impact +4. **Fix developed** -- we write and test a patch +5. **Coordinated release** -- we agree a disclosure date with you (typically 7-14 days after fix) +6. **Public advisory** -- we publish a GitHub Security Advisory and release the fix + +We ask that you do not publicly disclose the vulnerability until step 6 is complete. + +--- + +## Scope + +### In scope + +- Scanner engine (`scanner/`) -- rule logic, Azure SDK calls, output handling +- REST API (`api/`) -- authentication, authorisation, input validation, JWT handling +- Compliance framework mappings (`compliance/`) -- data integrity +- Sentinel integration (`sentinel/`) -- HMAC signing, data upload logic +- Hardcoded secrets or credentials anywhere in the codebase + +### Out of scope + +- Vulnerabilities in third-party dependencies -- report those to the upstream maintainer +- Security issues in infrastructure you deploy OpenShield to (your Azure environment, your PostgreSQL instance) +- Social engineering attacks +- Physical security + +--- + +## Recognition + +We value responsible disclosure. Researchers who report valid vulnerabilities will be: + +- Acknowledged by name (or pseudonym if preferred) in the release notes for the fix +- Listed in a `SECURITY_ACKNOWLEDGEMENTS.md` file we maintain in this repository + +We do not currently offer a bug bounty programme, but we are grateful for every report. + +--- + +## Contact + +**Email: vishnu.ajith@owasp.org** \ No newline at end of file diff --git a/README.md b/README.md index 8b8aa43..d75eb2e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # OpenShield -> **Open source Cloud Security Posture Management (CSPM) for Azure — built by the community, for the community.** - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +> **Open source Cloud Security Posture Management (CSPM) for Azure - built by the community, for the community.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) +[![CI](https://github.com/openshield-org/openshield/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/ci.yml) +[![Deploy](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml) +[![Security Policy](https://img.shields.io/badge/security-policy-green.svg)](.github/SECURITY.md) +[![OWASP](https://img.shields.io/badge/OWASP-listing%20review-orange.svg)](https://owasp.org) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![Good First Issues](https://img.shields.io/github/issues/openshield-org/openshield/good-first-issue)](https://github.com/openshield-org/openshield/issues?q=is%3Aissue+label%3Agood-first-issue) [![Discord](https://img.shields.io/badge/Discord-Join%20Us-7289da)](https://discord.gg/openshield) From 1b25a74bbef0edc68fc1d92bfbd49cb2acb51237 Mon Sep 17 00:00:00 2001 From: Abdulbosit Abdurazzakov <2d9c6kh58x@privaterelay.appleid.com> Date: Mon, 25 May 2026 00:58:08 +0100 Subject: [PATCH 45/50] feat: add rule AZ-KV-004 Key Vault purge protection disabled (#55) * feat: add rule AZ-KV-004 Key Vault purge protection disabled * fix: address PR review feedback for AZ-KV-004 - Add SOC2 CC9.1 mapping to FRAMEWORKS dict - Add AZ-KV-004 entries to all four compliance framework JSON files - Add set -euo pipefail to playbook - Add resource_group to metadata dict --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 5 ++ compliance/frameworks/nist_csf.json | 5 ++ compliance/frameworks/soc2.json | 5 ++ playbooks/cli/fix_az_kv_004.sh | 17 ++++++ scanner/rules/az_kv_004.py | 58 +++++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 playbooks/cli/fix_az_kv_004.sh create mode 100644 scanner/rules/az_kv_004.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 00e45c0..68ec4e6 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -132,6 +132,11 @@ "control_id": "4.3.6", "control_name": "Ensure SSL connection is enabled for PostgreSQL Flexible Server", "description": "SSL enforcement should be enabled on PostgreSQL Flexible Server to ensure data in transit is encrypted. Without SSL, database connections transmit data in plaintext, exposing it to interception." + }, + "AZ-KV-004": { + "control_id": "8.6", + "control_name": "Ensure that Azure Key Vault Purge Protection is Enabled", + "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of vaults and their secrets, keys, and certificates during the soft-delete retention period. Even with soft delete enabled, a malicious insider or privileged account can purge vault objects before the retention period expires. Enabling purge protection prevents this by blocking purge operations for the full retention period." } } } \ No newline at end of file diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 2b7c271..71cd134 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -132,6 +132,11 @@ "control_id": "A.10.1.1", "control_name": "Policy on the use of cryptographic controls", "description": "SSL enforcement on PostgreSQL Flexible Server applies cryptographic controls to data in transit. A policy on the use of cryptographic controls for protection of information should be developed and implemented." + }, + "AZ-KV-004": { + "control_id": "A.17.2.1", + "control_name": "Availability of information processing facilities", + "description": "Purge protection prevents permanent deletion of Azure Key Vault secrets, keys, and certificates during the soft-delete retention period. Without it, cryptographic material can be irrecoverably destroyed, threatening the availability of information processing facilities that depend on those keys and secrets." } } } \ No newline at end of file diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index d249fe0..18d6376 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -132,6 +132,11 @@ "control_id": "PR.DS-2", "control_name": "Data-in-transit is protected", "description": "SSL enforcement on PostgreSQL Flexible Server ensures data in transit between applications and the database is encrypted. Disabling SSL exposes database traffic to interception and tampering." + }, + "AZ-KV-004": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "Purge protection ensures that deleted Key Vault objects can be recovered within the retention period and cannot be permanently destroyed before it expires. Without purge protection, backups of cryptographic material may be rendered unrecoverable if an insider or compromised account issues a purge operation during the soft-delete window." } } } \ No newline at end of file diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index db100f7..10eb673 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -127,6 +127,11 @@ "control_id": "CC6.1", "control_name": "Logical and physical access controls", "description": "SSL enforcement ensures database connections are encrypted, protecting data in transit from unauthorized access. Disabling SSL undermines logical access controls by exposing database traffic in plaintext." + }, + "AZ-KV-004": { + "control_id": "CC9.1", + "control_name": "Risk Mitigation", + "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of secrets, keys, and certificates during the soft-delete retention period. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. Enabling purge protection mitigates the risk of irrecoverable loss of cryptographic material by preventing purge operations from executing before the retention period expires." } } } \ No newline at end of file diff --git a/playbooks/cli/fix_az_kv_004.sh b/playbooks/cli/fix_az_kv_004.sh new file mode 100644 index 0000000..d4d193c --- /dev/null +++ b/playbooks/cli/fix_az_kv_004.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +# AZ-KV-004: Enable purge protection on an Azure Key Vault +# Usage: ./fix_az_kv_004.sh +RESOURCE_GROUP=$1 +VAULT_NAME=$2 +if [ -z "$RESOURCE_GROUP" ] || [ -z "$VAULT_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +echo "Enabling purge protection on Key Vault: $VAULT_NAME..." +az keyvault update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VAULT_NAME" \ + --enable-purge-protection true +echo "Purge protection enabled for Key Vault: $VAULT_NAME" +echo "Note: Purge protection cannot be disabled once enabled." \ No newline at end of file diff --git a/scanner/rules/az_kv_004.py b/scanner/rules/az_kv_004.py new file mode 100644 index 0000000..d281976 --- /dev/null +++ b/scanner/rules/az_kv_004.py @@ -0,0 +1,58 @@ +"""AZ-KV-004: Key Vault purge protection disabled.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-KV-004" +RULE_NAME = "Key Vault Purge Protection Disabled" +SEVERITY = "MEDIUM" +CATEGORY = "Key Vault" +FRAMEWORKS = { + "CIS": "8.6", + "NIST": "PR.IP-4", + "ISO27001": "A.17.2.1", + "SOC2": "CC9.1" +} +DESCRIPTION = ( + "Azure Key Vaults without purge protection enabled allow permanent " + "deletion of vaults and their secrets, keys, and certificates during " + "the soft-delete retention period. Without purge protection, a " + "malicious insider or accidental deletion can result in irrecoverable " + "loss of cryptographic material." +) +REMEDIATION = ( + "Enable purge protection on the Key Vault. Note: once enabled, " + "purge protection cannot be disabled. Ensure soft delete is also " + "enabled as purge protection requires it." +) +PLAYBOOK = "playbooks/cli/fix_az_kv_004.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Return a list of findings. Return [] if no issues are found.""" + findings: List[Dict[str, Any]] = [] + + for vault in azure_client.get_key_vaults(): + parsed = azure_client.parse_resource_id(vault.id) + resource_group = parsed["resource_group"] + vault_name = parsed["name"] + + properties = getattr(vault, "properties", None) + purge_protection = getattr(properties, "enable_purge_protection", False) + + if not purge_protection: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vault.id, + "resource_name": vault_name, + "resource_type": "Microsoft.KeyVault/vaults", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"resource_group": resource_group} + }) + + return findings \ No newline at end of file From 4a1b153ae388f5f69ca6e90455236235d83145da Mon Sep 17 00:00:00 2001 From: Shaurya K Sharma Date: Wed, 27 May 2026 02:10:13 +0100 Subject: [PATCH 46/50] feat: add AZ-STOR-005 geo-redundant storage rule (#74) - scanner/rules/az_stor_005.py: detects storage accounts using LRS or ZRS (non-geo-redundant) replication; flags them as MEDIUM severity - playbooks/cli/fix_az_stor_005.sh: CLI remediation to update storage account SKU to a geo-redundant option (Standard_GRS by default); validates target SKU against allowed geo-redundant values - compliance/frameworks/*.json: adds AZ-STOR-005 entry to CIS Azure Benchmark (3.1), NIST CSF (PR.IP-4), ISO 27001 (A.17.2.1), and SOC 2 (A1.2) Closes #71 Co-authored-by: Shaurya K Sharma --- .../frameworks/cis_azure_benchmark.json | 5 + compliance/frameworks/iso27001.json | 5 + compliance/frameworks/nist_csf.json | 5 + compliance/frameworks/soc2.json | 5 + playbooks/cli/fix_az_stor_005.sh | 51 ++++++++++ scanner/rules/az_stor_005.py | 93 +++++++++++++++++++ 6 files changed, 164 insertions(+) create mode 100644 playbooks/cli/fix_az_stor_005.sh create mode 100644 scanner/rules/az_stor_005.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 68ec4e6..1e0a37c 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -118,6 +118,11 @@ "control_name": "Ensure Storage logging is enabled for Blob, Queue, and Table services for read, write, and delete requests", "description": "Enabling diagnostic logging for Azure Storage blob, queue, and table services records read, write, and delete operations. Without logging, unauthorized access, data exfiltration, or destructive operations on storage services cannot be detected or investigated." }, + "AZ-STOR-005": { + "control_id": "3.1", + "control_name": "Ensure that storage accounts use geo-redundant replication", + "description": "Storage accounts configured with locally redundant (LRS) or zone-redundant (ZRS) replication do not replicate data outside the primary region. A regional disaster or prolonged outage could result in data unavailability or data loss. Geo-redundant storage (GRS or GZRS) replicates data asynchronously to a secondary Azure region, protecting against region-wide failures." + }, "AZ-KV-002": { "control_id": "8.3", "control_name": "Ensure that public network access to Key Vault is disabled", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 71cd134..c5073b1 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -118,6 +118,11 @@ "control_name": "Event logging", "description": "Diagnostic logging must be enabled on Azure Storage blob, queue, and table services to produce event logs for read, write, and delete operations. Event logs recording user activities, exceptions, and information security events should be produced, kept, and regularly reviewed." }, + "AZ-STOR-005": { + "control_id": "A.17.2.1", + "control_name": "Availability of information processing facilities", + "description": "Storage accounts using LRS or ZRS replication retain data only within a single region, providing no protection against regional outages or disasters. A regional disaster could result in data unavailability or data loss. A.17.2.1 requires that redundancy is implemented to meet availability requirements. Configuring geo-redundant replication (GRS or GZRS) ensures information processing facilities remain available by maintaining a secondary copy of data in a geographically separate region." + }, "AZ-KV-002": { "control_id": "A.13.1.1", "control_name": "Network controls", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 18d6376..95b478d 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -123,6 +123,11 @@ "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", "description": "Diagnostic logging on Azure Storage services provides the audit trail needed to monitor for unauthorized or anomalous read, write, and delete operations. Without logging, detection of data exfiltration or unauthorized access to blob, queue, or table services is not possible." }, + "AZ-STOR-005": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "Storage accounts configured with LRS or ZRS replicate data only within a single region. A regional outage or disaster could result in data unavailability or data loss. PR.IP-4 requires that backups and redundant copies of information are maintained. Geo-redundant replication (GRS or GZRS) ensures a secondary copy of data is maintained in a separate Azure region, satisfying backup and recovery requirements." + }, "AZ-NET-011": { "control_id": "DE.CM-7", "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 10eb673..60ce69d 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -18,6 +18,11 @@ "control_name": "Change Management", "description": "A storage account with no lifecycle management policy allows data to accumulate indefinitely with no automatic expiry or tiering. CC8.1 requires that infrastructure and data are managed through formal processes. Implementing a lifecycle policy ensures data retention is controlled and old data is automatically moved or deleted according to organisational policy." }, + "AZ-STOR-005": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "Storage accounts configured with LRS or ZRS replication do not protect against environmental threats at the regional level. A regional outage or disaster could result in data unavailability or data loss. A1.2 requires that environmental threats to availability are identified and that recovery measures are implemented. Geo-redundant replication (GRS or GZRS) provides a secondary copy of storage data in a separate Azure region, enabling recovery from regional disasters and protecting availability commitments." + }, "AZ-NET-001": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", diff --git a/playbooks/cli/fix_az_stor_005.sh b/playbooks/cli/fix_az_stor_005.sh new file mode 100644 index 0000000..091b006 --- /dev/null +++ b/playbooks/cli/fix_az_stor_005.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-STOR-005 — Storage Account Not Using Geo-Redundant Replication +# Usage: ./fix_az_stor_005.sh [target-sku] +# Severity: MEDIUM + +set -euo pipefail + +RESOURCE_GROUP="${1:-}" +RESOURCE_NAME="${2:-}" +TARGET_SKU="${3:-Standard_GRS}" + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 [target-sku]" + echo " target-sku defaults to Standard_GRS" + echo " Valid geo-redundant options: Standard_GRS, Standard_RAGRS, Standard_GZRS, Standard_RAGZRS" + exit 1 +fi + +case "$TARGET_SKU" in + Standard_GRS|Standard_RAGRS|Standard_GZRS|Standard_RAGZRS) + ;; + *) + echo "Error: '$TARGET_SKU' is not a supported geo-redundant SKU." + echo "Valid options: Standard_GRS, Standard_RAGRS, Standard_GZRS, Standard_RAGZRS" + exit 1 + ;; +esac + +echo "Checking current SKU for $RESOURCE_NAME..." +CURRENT_SKU=$(az storage account show \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "sku.name" \ + --output tsv) +echo "Current SKU: $CURRENT_SKU" + +echo "Remediating AZ-STOR-005 for $RESOURCE_NAME — updating replication to $TARGET_SKU..." +az storage account update \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --sku "$TARGET_SKU" + +echo "Updated SKU for $RESOURCE_NAME:" +az storage account show \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "sku.name" \ + --output tsv + +echo "Remediation complete for $RESOURCE_NAME — replication is now $TARGET_SKU." diff --git a/scanner/rules/az_stor_005.py b/scanner/rules/az_stor_005.py new file mode 100644 index 0000000..dc5b0bd --- /dev/null +++ b/scanner/rules/az_stor_005.py @@ -0,0 +1,93 @@ +"""AZ-STOR-005: Storage account not using geo-redundant replication.""" + +import logging +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-005" +RULE_NAME = "Storage Account Not Using Geo-Redundant Replication" +SEVERITY = "MEDIUM" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "3.1", + "NIST": "PR.IP-4", + "ISO27001": "A.17.2.1", + "SOC2": "A1.2", +} +DESCRIPTION = ( + "This storage account is configured with a non-geo-redundant replication " + "SKU ({sku_name}). Locally redundant (LRS) and zone-redundant (ZRS) " + "storage replicate data only within a single region. A regional outage or " + "disaster could result in data unavailability or data loss. Geo-redundant " + "storage (GRS or GZRS) replicates data asynchronously to a secondary " + "Azure region, protecting against region-wide failures." +) +REMEDIATION = ( + "Change the storage account replication to a geo-redundant SKU such as " + "Standard_GRS or Standard_GZRS. Navigate to Storage Account > " + "Configuration > Replication and select Geo-redundant storage (GRS) or " + "Geo-zone-redundant storage (GZRS). Alternatively, run the remediation " + "playbook." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_005.sh" + +_GEO_REDUNDANT_SKUS = { + "Standard_GRS", + "Standard_RAGRS", + "Standard_GZRS", + "Standard_RAGZRS", + "StandardV2_GRS", + "StandardV2_GZRS", +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect storage accounts not configured with geo-redundant replication.""" + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + resource_id = getattr(account, "id", "") + account_name = getattr(account, "name", "") + location = getattr(account, "location", "") + + if not resource_id or not account_name: + continue + + sku = getattr(account, "sku", None) + sku_name = getattr(sku, "name", "") if sku else "" + + if not sku_name: + logger.warning( + "AZ-STOR-005: Could not determine SKU for %s — skipping.", + account_name, + ) + continue + + if sku_name in _GEO_REDUNDANT_SKUS: + continue + + parsed = azure_client.parse_resource_id(resource_id) + resource_group = parsed.get("resource_group", "") + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": account_name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION.format(sku_name=sku_name), + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": resource_group, + "location": location, + "current_sku": sku_name, + "recommended_sku": "Standard_GRS", + }, + }) + + return findings From cd339e12c654b1f1ac32a61faea7077a7296a39b Mon Sep 17 00:00:00 2001 From: Abdulbosit Abdurazzakov <2d9c6kh58x@privaterelay.appleid.com> Date: Wed, 27 May 2026 02:19:38 +0100 Subject: [PATCH 47/50] feat: add rule AZ-DB-004 SQL Server firewall allows all Azure services (#70) * feat: add rule AZ-DB-004 SQL Server firewall allows all Azure services - Add scanner rule az_db_004.py detecting SQL Servers with Allow Azure services firewall rule enabled - Add remediation playbook fix_az_db_004.sh - Add get_sql_server_firewall_rules method to AzureClient - Add AZ-DB-004 entries to all four compliance framework JSON files * fix: add get_sql_server_firewall_rules to AzureClient * fix: remove duplicate import, fix indentation, add return None to auditing policy --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 5 ++ compliance/frameworks/nist_csf.json | 5 ++ compliance/frameworks/soc2.json | 5 ++ playbooks/cli/fix_az_db_004.sh | 17 +++++ scanner/azure_client.py | 54 ++++++++++------ scanner/rules/az_db_004.py | 64 +++++++++++++++++++ 7 files changed, 135 insertions(+), 20 deletions(-) create mode 100644 playbooks/cli/fix_az_db_004.sh create mode 100644 scanner/rules/az_db_004.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 1e0a37c..8377f8f 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -142,6 +142,11 @@ "control_id": "8.6", "control_name": "Ensure that Azure Key Vault Purge Protection is Enabled", "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of vaults and their secrets, keys, and certificates during the soft-delete retention period. Even with soft delete enabled, a malicious insider or privileged account can purge vault objects before the retention period expires. Enabling purge protection prevents this by blocking purge operations for the full retention period." + }, + "AZ-DB-004": { + "control_id": "4.1.2", + "control_name": "Ensure that 'Allow access to Azure services' for SQL Servers is disabled", + "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall creates a rule that permits any Azure-hosted resource — including services from other tenants — to connect to the server. This significantly increases the attack surface. Access should be restricted to specific trusted IP ranges or private endpoints." } } } \ No newline at end of file diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index c5073b1..ea21b47 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -142,6 +142,11 @@ "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", "description": "Purge protection prevents permanent deletion of Azure Key Vault secrets, keys, and certificates during the soft-delete retention period. Without it, cryptographic material can be irrecoverably destroyed, threatening the availability of information processing facilities that depend on those keys and secrets." + }, + "AZ-DB-004": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall bypasses network controls by permitting any Azure-hosted resource to connect to the database server. Networks should be managed and controlled with explicit rules that restrict access to known and trusted sources only." } } } \ No newline at end of file diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 95b478d..28c5e8e 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -142,6 +142,11 @@ "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", "description": "Purge protection ensures that deleted Key Vault objects can be recovered within the retention period and cannot be permanently destroyed before it expires. Without purge protection, backups of cryptographic material may be rendered unrecoverable if an insider or compromised account issues a purge operation during the soft-delete window." + }, + "AZ-DB-004": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall permits any Azure-hosted resource to connect to the database remotely without restriction. PR.AC-3 requires that remote access is managed and controlled. Access should be restricted to specific trusted IP ranges or private endpoints to ensure only authorised systems can reach the database." } } } \ No newline at end of file diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 60ce69d..d320a76 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -137,6 +137,11 @@ "control_id": "CC9.1", "control_name": "Risk Mitigation", "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of secrets, keys, and certificates during the soft-delete retention period. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. Enabling purge protection mitigates the risk of irrecoverable loss of cryptographic material by preventing purge operations from executing before the retention period expires." + }, + "AZ-DB-004": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall creates a rule that permits any Azure-hosted resource — including services from other tenants — to connect to the database. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Disabling this setting and replacing it with explicit firewall rules or private endpoints enforces the network boundary and ensures only known and trusted systems can reach the SQL Server." } } } \ No newline at end of file diff --git a/playbooks/cli/fix_az_db_004.sh b/playbooks/cli/fix_az_db_004.sh new file mode 100644 index 0000000..ba10d47 --- /dev/null +++ b/playbooks/cli/fix_az_db_004.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +# AZ-DB-004: Remove the 'Allow all Azure services' firewall rule from an Azure SQL Server +# Usage: ./fix_az_db_004.sh +RESOURCE_GROUP=$1 +SERVER_NAME=$2 +if [ -z "$RESOURCE_GROUP" ] || [ -z "$SERVER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +echo "Removing 'AllowAllWindowsAzureIps' firewall rule from SQL Server: $SERVER_NAME..." +az sql server firewall-rule delete \ + --resource-group "$RESOURCE_GROUP" \ + --server "$SERVER_NAME" \ + --name "AllowAllWindowsAzureIps" +echo "Done. 'Allow access to Azure services' has been disabled for: $SERVER_NAME" +echo "Note: Add explicit firewall rules for trusted IP ranges if needed." \ No newline at end of file diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 5dc9bd0..9642688 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -13,7 +13,6 @@ from azure.mgmt.sql import SqlManagementClient from azure.mgmt.monitor import MonitorManagementClient from azure.mgmt.storage import StorageManagementClient -from azure.mgmt.monitor import MonitorManagementClient logger = logging.getLogger(__name__) @@ -240,7 +239,6 @@ def get_virtual_networks(self) -> List[Any]: logger.error("get_virtual_networks failed: %s", exc) return [] - def get_public_ip_addresses(self) -> List[Any]: """List all public IP addresses in the subscription.""" try: @@ -263,14 +261,19 @@ def get_virtual_machines(self) -> List[Any]: logger.error("get_virtual_machines failed: %s", exc) return [] - - - def get_vm_extensions(self, resource_group: str, vm_name: str) -> Optional[List[Any]]: + def get_vm_extensions( + self, resource_group: str, vm_name: str + ) -> Optional[List[Any]]: + """List all extensions installed on a virtual machine.""" try: - result = ComputeManagementClient(self.credential, self.subscription_id).virtual_machine_extensions.list(resource_group, vm_name) + result = ComputeManagementClient( + self.credential, self.subscription_id + ).virtual_machine_extensions.list(resource_group, vm_name) return list(getattr(result, "value", []) or []) except Exception as exc: - logger.error("get_vm_extensions failed for %s/%s: %s", resource_group, vm_name, exc) + logger.error( + "get_vm_extensions failed for %s/%s: %s", resource_group, vm_name, exc + ) return None # ------------------------------------------------------------------ # @@ -308,6 +311,19 @@ def get_sql_server_auditing_policy( ) return None + def get_sql_server_firewall_rules( + self, resource_group: str, server_name: str + ) -> List[Any]: + """List all firewall rules for an Azure SQL server.""" + try: + client = SqlManagementClient(self.credential, self.subscription_id) + return list(client.firewall_rules.list_by_server(resource_group, server_name)) + except Exception as exc: + logger.error( + "get_sql_server_firewall_rules(%s) failed: %s", server_name, exc + ) + return [] + # ------------------------------------------------------------------ # # Key Vault # # ------------------------------------------------------------------ # @@ -342,21 +358,14 @@ def get_diagnostic_settings(self, resource_id: str) -> Optional[bool]: self.credential, self.subscription_id, ) - - settings = list( - client.diagnostic_settings.list(resource_id) - ) - + settings = list(client.diagnostic_settings.list(resource_id)) if not settings: return False - for setting in settings: logs = getattr(setting, "logs", []) - for log in logs: category = getattr(log, "category", "") enabled = getattr(log, "enabled", False) - if category == "AuditEvent" and enabled: return True return False @@ -399,7 +408,6 @@ def get_service_principals(self) -> List[Any]: logger.error("get_service_principals failed: %s", exc) return [] - def get_postgresql_flexible_servers(self) -> List[Any]: """List all PostgreSQL Flexible Server instances in the subscription.""" try: @@ -410,15 +418,20 @@ def get_postgresql_flexible_servers(self) -> List[Any]: logger.error("get_postgresql_flexible_servers failed: %s", exc) return [] - - def get_postgresql_flexible_server_parameters(self, resource_group: str, server_name: str) -> List[Any]: + def get_postgresql_flexible_server_parameters( + self, resource_group: str, server_name: str + ) -> List[Any]: """List all configuration parameters for a PostgreSQL Flexible Server.""" try: from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient as FlexClient client = FlexClient(self.credential, self.subscription_id) return list(client.configurations.list_by_server(resource_group, server_name)) except Exception as exc: - logger.error("get_postgresql_flexible_server_parameters(%s) failed: %s", server_name, exc) + logger.error( + "get_postgresql_flexible_server_parameters(%s) failed: %s", + server_name, + exc, + ) return [] def get_conditional_access_policies(self) -> List[Any]: @@ -442,6 +455,7 @@ def get_conditional_access_policies(self) -> List[Any]: except Exception as exc: logger.error("get_conditional_access_policies failed: %s", exc) return [] + def get_regions_with_resources(self) -> List[str]: """List all regions that have at least one resource deployed.""" try: @@ -469,4 +483,4 @@ def get_network_watcher_regions(self) -> List[str]: return list(regions) except Exception as exc: logger.error("get_network_watcher_regions failed: %s", exc) - return [] + return [] \ No newline at end of file diff --git a/scanner/rules/az_db_004.py b/scanner/rules/az_db_004.py new file mode 100644 index 0000000..161dacd --- /dev/null +++ b/scanner/rules/az_db_004.py @@ -0,0 +1,64 @@ +"""AZ-DB-004: SQL Server firewall allows all Azure services.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-DB-004" +RULE_NAME = "SQL Server Firewall Allows All Azure Services" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = { + "CIS": "4.1.2", + "NIST": "PR.AC-3", + "ISO27001": "A.13.1.1", + "SOC2": "CC6.6" +} +DESCRIPTION = ( + "Azure SQL Server has the 'Allow access to Azure services' firewall setting " + "enabled. This creates a firewall rule that permits any resource hosted in " + "Azure — including services from other tenants — to connect to the SQL Server. " + "This significantly increases the attack surface and can allow unauthorised " + "access from compromised or malicious Azure-hosted services." +) +REMEDIATION = ( + "Disable the 'Allow access to Azure services' setting on the SQL Server " + "firewall. Instead, add explicit firewall rules for specific trusted IP " + "ranges or use private endpoints to restrict access to known sources only." +) +PLAYBOOK = "playbooks/cli/fix_az_db_004.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Return a list of findings. Return [] if no issues are found.""" + findings: List[Dict[str, Any]] = [] + + for server in azure_client.get_sql_servers(): + parsed = azure_client.parse_resource_id(server.id) + resource_group = parsed["resource_group"] + server_name = parsed["name"] + + firewall_rules = azure_client.get_sql_server_firewall_rules( + resource_group, server_name + ) + + for rule in firewall_rules: + start_ip = getattr(rule, "start_ip_address", "") + end_ip = getattr(rule, "end_ip_address", "") + + if start_ip == "0.0.0.0" and end_ip == "0.0.0.0": + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server_name, + "resource_type": "Microsoft.Sql/servers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"resource_group": resource_group} + }) + break + + return findings \ No newline at end of file From 00dad53399a52afcbff832b26ca62e1d5f61442f Mon Sep 17 00:00:00 2001 From: Ritik Sah Date: Fri, 29 May 2026 00:31:43 +0100 Subject: [PATCH 48/50] docs: add 6 README badges (#79) --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d75eb2e..30077a0 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,18 @@ > **Open source Cloud Security Posture Management (CSPM) for Azure - built by the community, for the community.** -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![GitHub Repo stars](https://img.shields.io/github/stars/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/network/members) +[![GitHub contributors](https://img.shields.io/github/contributors/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/graphs/contributors) +[![GitHub last commit](https://img.shields.io/github/last-commit/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/commits/main) +[![GitHub issues](https://img.shields.io/github/issues/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/issues) +[![GitHub license](https://img.shields.io/github/license/openshield-org/openshield?style=flat-square)](LICENSE) [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) [![CI](https://github.com/openshield-org/openshield/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/ci.yml) [![Deploy](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml) [![Security Policy](https://img.shields.io/badge/security-policy-green.svg)](.github/SECURITY.md) [![OWASP](https://img.shields.io/badge/OWASP-listing%20review-orange.svg)](https://owasp.org) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) -[![Good First Issues](https://img.shields.io/github/issues/openshield-org/openshield/good-first-issue)](https://github.com/openshield-org/openshield/issues?q=is%3Aissue+label%3Agood-first-issue) [![Discord](https://img.shields.io/badge/Discord-Join%20Us-7289da)](https://discord.gg/openshield) --- From d362cc75cfc03aaa007b214d7dc04b28ae0c681b Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Fri, 29 May 2026 00:43:33 +0100 Subject: [PATCH 49/50] feat: add AZ-KV-005 Key Vault certificate expiring within 30 days (#75) * Remove duplicate import of MonitorManagementClient * Add method to get Key Vault certificates Added a new method to list certificates in a Key Vault. * Add AZ-KV-005 rule for expiring Key Vault certificates This script scans Azure Key Vaults for certificates that are expiring within 30 days and do not have auto-renewal enabled. It logs findings and provides remediation steps. * Add script to enable auto-renewal for Key Vault certificate This script enables auto-renewal for an expiring Key Vault certificate by updating its policy. * Add controls for Azure Key Vault security measures * Add AZ-KV-005 control for certificate maintenance * Add controls for key management and availability * Add SOC 2 controls for Azure Key Vault risk mitigation * Fix indentation in get_key_vaults method * Add azure-keyvault-certificates dependency * Enhance script error handling with pipefail option * Refactor lifetime_actions assignment for clarity * Add control for expiring certificate maintenance Added a new control for certificate maintenance in Azure Key Vault. * fix: add missing comma in soc2.json after AZ-KV-005 entry * fix: add missing comma in iso27001.json after AZ-KV-005 entry --- .../frameworks/cis_azure_benchmark.json | 10 +- compliance/frameworks/iso27001.json | 29 +++-- compliance/frameworks/nist_csf.json | 7 +- compliance/frameworks/soc2.json | 49 ++++---- playbooks/cli/fix_az_kv_005.sh | 44 ++++++++ requirements.txt | 1 + scanner/azure_client.py | 14 +++ scanner/rules/az_kv_005.py | 105 ++++++++++++++++++ 8 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 playbooks/cli/fix_az_kv_005.sh create mode 100644 scanner/rules/az_kv_005.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 8377f8f..5bfa8de 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -143,10 +143,10 @@ "control_name": "Ensure that Azure Key Vault Purge Protection is Enabled", "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of vaults and their secrets, keys, and certificates during the soft-delete retention period. Even with soft delete enabled, a malicious insider or privileged account can purge vault objects before the retention period expires. Enabling purge protection prevents this by blocking purge operations for the full retention period." }, - "AZ-DB-004": { - "control_id": "4.1.2", - "control_name": "Ensure that 'Allow access to Azure services' for SQL Servers is disabled", - "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall creates a rule that permits any Azure-hosted resource — including services from other tenants — to connect to the server. This significantly increases the attack surface. Access should be restricted to specific trusted IP ranges or private endpoints." + "AZ-KV-005": { + "control_id": "8.5", + "control_name": "Ensure that the expiration date is set on all certificates", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days and does not have auto-renewal configured. CIS 8.5 requires that expiration dates are monitored and certificates are renewed before expiry to prevent service outages and broken authentication flows." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index ea21b47..7e29707 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -71,12 +71,12 @@ "AZ-IDN-002": { "control_id": "A.9.4.2", "control_name": "Secure log-on procedures", - "description": "MFA enforces secure log-on for privileged accounts. Where required by the access control policy, access to systems and applications should be controlled by a secure log-on procedure including multi-factor authentication." + "description": "MFA enforces secure log-on for privileged accounts. Where required by the access control policy, access to systems and applications should be controlled by a secure log-on procedure." }, "AZ-IDN-003": { "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 a formal user registration and de-registration process is implemented. Restricting guest invitations to administrators ensures external identity registration is formally controlled and audited." + "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-DB-001": { "control_id": "A.13.1.1", @@ -86,7 +86,7 @@ "AZ-DB-002": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "SQL Server auditing must be enabled to provide event logs. Event logs recording user activities, exceptions, faults and information security events should be produced, kept and regularly reviewed." + "description": "SQL Server auditing must be enabled to provide event logs. Event logs recording user activities, exceptions, faults and information security events should be produced and kept available." }, "AZ-CMP-001": { "control_id": "A.13.1.1", @@ -96,42 +96,42 @@ "AZ-CMP-002": { "control_id": "A.10.1.1", "control_name": "Policy on the use of cryptographic controls", - "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). A.10.1.1 requires that a policy on the use of cryptographic controls is developed and implemented. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). A.10.1.1 requires that a policy on the use of cryptographic controls is developed and implemented." }, "AZ-CMP-003": { "control_id": "A.12.2.1", "control_name": "Controls against malware", - "description": "The virtual machine does not have a recognised endpoint protection extension installed. A.12.2.1 requires that detection, prevention and recovery controls are implemented to protect against malware. Without endpoint protection, malware executing on the VM will not be detected or prevented." + "description": "The virtual machine does not have a recognised endpoint protection extension installed. A.12.2.1 requires that detection, prevention and recovery controls are implemented to protect against malware." }, "AZ-KV-001": { "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", - "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Without soft delete, deleted vault objects cannot be recovered, reducing availability and recovery options for critical cryptographic material." + "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Without soft delete, deleted vault objects cannot be recovered, reducing availability and recoverability of cryptographic material." }, "AZ-STOR-003": { "control_id": "A.8.3.1", "control_name": "Management of removable media", - "description": "Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism. Lifecycle management supports formal retention, tiering, and disposal of information assets." + "description": "Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism. Lifecycle management supports formal retention, tiering, and disposal procedures." }, "AZ-STOR-004": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "Diagnostic logging must be enabled on Azure Storage blob, queue, and table services to produce event logs for read, write, and delete operations. Event logs recording user activities, exceptions, and information security events should be produced, kept, and regularly reviewed." + "description": "Diagnostic logging must be enabled on Azure Storage blob, queue, and table services to produce event logs for read, write, and delete operations. Event logs recording user activities should be kept available." }, "AZ-STOR-005": { "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", - "description": "Storage accounts using LRS or ZRS replication retain data only within a single region, providing no protection against regional outages or disasters. A regional disaster could result in data unavailability or data loss. A.17.2.1 requires that redundancy is implemented to meet availability requirements. Configuring geo-redundant replication (GRS or GZRS) ensures information processing facilities remain available by maintaining a secondary copy of data in a geographically separate region." + "description": "Storage accounts using LRS or ZRS replication retain data only within a single region, providing no protection against regional outages or disasters. A regional disaster could result in complete data loss." }, "AZ-KV-002": { "control_id": "A.13.1.1", "control_name": "Network controls", - "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." + "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive cryptographic material." }, "AZ-NET-011": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "Network Watcher must be enabled in all regions where resources are deployed to ensure network events are logged and available for investigation. Event logs recording network activity should be produced and retained to support incident response." + "description": "Network Watcher must be enabled in all regions where resources are deployed to ensure network events are logged and available for investigation. Event logs recording network activities should be produced and kept available." }, "AZ-DB-003": { "control_id": "A.10.1.1", @@ -143,10 +143,15 @@ "control_name": "Availability of information processing facilities", "description": "Purge protection prevents permanent deletion of Azure Key Vault secrets, keys, and certificates during the soft-delete retention period. Without it, cryptographic material can be irrecoverably destroyed, threatening the availability of information processing facilities that depend on those keys and secrets." }, + "AZ-KV-005": { + "control_id": "A.10.1.2", + "control_name": "Key management", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. A.10.1.2 requires that a policy on the use, protection, and lifetime of cryptographic keys is developed and implemented. Certificates approaching expiry without renewal represent a failure in cryptographic key lifecycle management." + }, "AZ-DB-004": { "control_id": "A.13.1.1", "control_name": "Network controls", "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall bypasses network controls by permitting any Azure-hosted resource to connect to the database server. Networks should be managed and controlled with explicit rules that restrict access to known and trusted sources only." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 28c5e8e..1c9a50d 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -143,10 +143,15 @@ "control_name": "Backups of information are conducted, maintained, and tested", "description": "Purge protection ensures that deleted Key Vault objects can be recovered within the retention period and cannot be permanently destroyed before it expires. Without purge protection, backups of cryptographic material may be rendered unrecoverable if an insider or compromised account issues a purge operation during the soft-delete window." }, + "AZ-KV-005": { + "control_id": "PR.MA-1", + "control_name": "Maintenance and repair of organisational assets is performed", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. PR.MA-1 requires that maintenance of organisational assets is performed and logged. Certificate renewal is a critical maintenance task and failure to renew before expiry causes immediate service disruption." + }, "AZ-DB-004": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall permits any Azure-hosted resource to connect to the database remotely without restriction. PR.AC-3 requires that remote access is managed and controlled. Access should be restricted to specific trusted IP ranges or private endpoints to ensure only authorised systems can reach the database." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index d320a76..0313db9 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -21,7 +21,7 @@ "AZ-STOR-005": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", - "description": "Storage accounts configured with LRS or ZRS replication do not protect against environmental threats at the regional level. A regional outage or disaster could result in data unavailability or data loss. A1.2 requires that environmental threats to availability are identified and that recovery measures are implemented. Geo-redundant replication (GRS or GZRS) provides a secondary copy of storage data in a separate Azure region, enabling recovery from regional disasters and protecting availability commitments." + "description": "Storage accounts configured with LRS or ZRS replication do not protect against environmental threats at the regional level. A regional outage or disaster could result in data loss and service unavailability. Geo-redundant replication is needed to ensure business continuity." }, "AZ-NET-001": { "control_id": "CC6.6", @@ -46,97 +46,102 @@ "AZ-NET-005": { "control_id": "A1.1", "control_name": "Capacity and Performance Monitoring", - "description": "Virtual networks without DDoS Protection Standard are vulnerable to volumetric attacks that can exhaust capacity and cause service outages. A1.1 requires that current processing capacity is monitored and resources are available to meet objectives. DDoS Protection Standard ensures network availability is maintained under attack conditions." + "description": "Virtual networks without DDoS Protection Standard are vulnerable to volumetric attacks that can exhaust capacity and cause service outages. A1.1 requires that current processes and procedures are performed to manage capacity and performance." }, "AZ-NET-006": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "Unassociated public IP addresses represent unnecessary exposure on the internet and may indicate leftover resources from decommissioned workloads. CC6.6 requires that the network boundary is tightly controlled with only necessary resources exposed. Removing unassociated public IPs reduces the external attack surface." + "description": "Unassociated public IP addresses represent unnecessary exposure on the internet and may indicate leftover resources from decommissioned workloads. CC6.6 requires that the network boundary is managed to restrict logical access from outside sources. Orphaned public IPs should be removed." }, "AZ-NET-007": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "An Application Gateway without WAF enabled provides no protection against web application attacks from external sources including OWASP Top 10 vulnerabilities. CC6.6 requires that access from outside the network boundary is controlled and filtered. WAF in Prevention mode enforces application-layer boundary protection for public-facing services." + "description": "An Application Gateway without WAF enabled provides no protection against web application attacks from external sources including OWASP Top 10 vulnerabilities. CC6.6 requires that access from outside the network boundary is restricted through logical access controls including WAF." }, "AZ-NET-008": { "control_id": "CC8.1", "control_name": "Change Management", - "description": "A load balancer with no backend pool configured is either misconfigured or a leftover resource from a decommissioned workload that was not properly cleaned up. CC8.1 requires that infrastructure changes are managed, tracked and that unused resources are removed through a formal process. Removing empty load balancers maintains an accurate and controlled infrastructure state." + "description": "A load balancer with no backend pool configured is either misconfigured or a leftover resource from a decommissioned workload that was not properly cleaned up. CC8.1 requires that infrastructure is managed through formal change management and resource lifecycle procedures." }, "AZ-NET-009": { "control_id": "CC6.7", "control_name": "Protects Data in Transit", - "description": "VPN gateway connections using IKEv1 use an outdated protocol with known vulnerabilities that weaken the confidentiality and integrity of data transmitted between networks. CC6.7 requires that data transmitted over networks is protected using current secure protocols. Migrating to IKEv2 ensures VPN traffic is protected with a modern and secure key exchange mechanism." + "description": "VPN gateway connections using IKEv1 use an outdated protocol with known vulnerabilities that weaken the confidentiality and integrity of data transmitted between networks. CC6.7 requires that data in transit is protected through encryption using current, secure protocols." }, "AZ-NET-010": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A subnet without an NSG attached has no network layer access controls leaving all resources in that subnet reachable from other subnets or the internet with no filtering. CC6.6 requires that logical access from outside the network boundary is restricted. Attaching an NSG with explicit rules enforces boundary protection at the subnet level." + "description": "A subnet without an NSG attached has no network layer access controls leaving all resources in that subnet reachable from other subnets or the internet with no filtering. CC6.6 requires that access is controlled through network-level restrictions." }, "AZ-IDN-001": { "control_id": "CC6.1", "control_name": "Logical Access Security Measures", - "description": "A service principal with Contributor role at subscription scope has unrestricted ability to create, modify and delete any resource in the environment. CC6.1 requires that logical access to information assets is restricted to authorised users and service accounts with least-privilege permissions. Scoping role assignments to the minimum required resource enforces this control." + "description": "A service principal with Contributor role at subscription scope has unrestricted ability to create, modify and delete any resource in the environment. CC6.1 requires that logical access controls restrict authorizations to authenticated and verified users and processes." }, "AZ-IDN-002": { "control_id": "CC6.1", "control_name": "Logical Access Security Measures", - "description": "Without MFA enforced on privileged accounts, a single compromised password grants full administrative access to the Azure environment. CC6.1 requires that logical access controls include strong authentication mechanisms. Enforcing MFA via Conditional Access policies ensures privileged access requires multiple factors of authentication." + "description": "Without MFA enforced on privileged accounts, a single compromised password grants full administrative access to the Azure environment. CC6.1 requires that logical access controls are implemented to authenticate and authorise users and processes." }, "AZ-IDN-003": { "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 restricted to authorised users. Restricting guest invitations to administrators ensures external identity provisioning is formally controlled and authorised." + "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-DB-001": { "control_id": "CC6.7", - "control_name": "Protects Data in Transit", - "description": "SQL Server without Transparent Data Encryption stores database files in plain text on disk. CC6.7 requires that data is protected using encryption both in transit and at rest. Enabling TDE ensures database files, backups and transaction logs are encrypted and unreadable without the encryption key." + "control_name": "Protects Data in Transit and At Rest", + "description": "SQL Server without Transparent Data Encryption stores database files in plain text on disk. CC6.7 requires that data is protected using encryption both in transit and at rest against interception and tampering." }, "AZ-DB-002": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A SQL Server firewall rule allowing all IP addresses makes the database reachable from anywhere on the internet. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Locking the firewall to specific application IP ranges ensures only authorised systems can connect to the database." + "description": "A SQL Server firewall rule allowing all IP addresses makes the database reachable from anywhere on the internet. CC6.6 requires that access from outside the network boundary is restricted to authorised sources through explicit firewall rules or private endpoints." }, "AZ-CMP-001": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A virtual machine with a public IP and no NSG has unrestricted inbound network access from the internet with no filtering in place. CC6.6 requires that logical access from outside the network perimeter is restricted and controlled. Attaching an NSG with explicit rules enforces the network boundary and controls what traffic can reach the VM." + "description": "A virtual machine with a public IP and no NSG has unrestricted inbound network access from the internet with no filtering in place. CC6.6 requires that logical access from outside the network boundary is restricted and controlled." }, "AZ-CMP-002": { "control_id": "CC6.7", "control_name": "Protects Data in Transit and At Rest", - "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CC6.7 requires that data is protected using encryption. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CC6.7 requires that data is protected using encryption. Platform-managed keys lack customer control and audit capabilities needed for compliance." }, "AZ-CMP-003": { "control_id": "CC6.8", "control_name": "Prevents or Detects Unauthorized or Malicious Software", - "description": "The virtual machine does not have a recognised endpoint protection extension installed. CC6.8 requires that controls are implemented to prevent or detect and act upon the introduction of unauthorized or malicious software. Without endpoint protection, malicious code executing on the VM will not be detected or blocked." + "description": "The virtual machine does not have a recognised endpoint protection extension installed. CC6.8 requires that controls are implemented to prevent or detect and act upon the introduction of unauthorised or malicious software." }, "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", - "description": "Key Vault without soft delete enabled allows permanent deletion of secrets, keys and certificates with no recovery possible. A1.2 requires that environmental threats to availability are identified and mitigated including protection against accidental or malicious data loss. Enabling soft delete ensures deleted vault objects can be recovered within the retention period." + "description": "Key Vault without soft delete enabled allows permanent deletion of secrets, keys and certificates with no recovery possible. A1.2 requires that environmental threats to availability of information systems are addressed through recovery procedures." }, "AZ-KV-002": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A Key Vault accessible from the public internet allows any external party to attempt access to secrets, keys and certificates. CC6.6 requires that access from outside the network boundary is restricted and controlled. Locking Key Vault access to private endpoints or specific VNet service endpoints enforces this boundary and protects sensitive credentials from external exposure." + "description": "A Key Vault accessible from the public internet allows any external party to attempt access to secrets, keys and certificates. CC6.6 requires that access from outside the network boundary is restricted. Network rules should deny public access." }, "AZ-NET-011": { "control_id": "CC7.2", "control_name": "System monitoring", - "description": "Network Watcher must be enabled in all regions where resources are deployed to support continuous system monitoring. Without it, network-level events cannot be detected or investigated, violating the requirement for ongoing monitoring of system components." + "description": "Network Watcher must be enabled in all regions where resources are deployed to support continuous system monitoring. Without it, network-level events cannot be detected or investigated, preventing incident response." }, "AZ-DB-003": { "control_id": "CC6.1", "control_name": "Logical and physical access controls", - "description": "SSL enforcement ensures database connections are encrypted, protecting data in transit from unauthorized access. Disabling SSL undermines logical access controls by exposing database traffic in plaintext." + "description": "SSL enforcement ensures database connections are encrypted, protecting data in transit from unauthorised access. Disabling SSL undermines logical access controls by exposing credentials and sensitive data to interception." }, "AZ-KV-004": { "control_id": "CC9.1", "control_name": "Risk Mitigation", - "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of secrets, keys, and certificates during the soft-delete retention period. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. Enabling purge protection mitigates the risk of irrecoverable loss of cryptographic material by preventing purge operations from executing before the retention period expires." + "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of secrets, keys, and certificates during the soft-delete retention period. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. Enabling purge protection mitigates the risk of irrecoverable loss of cryptographic material." + }, + "AZ-KV-005": { + "control_id": "CC9.1", + "control_name": "Risk Mitigation", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. An expiring certificate without auto-renewal represents an unmitigated operational risk that will cause service outages if not addressed." }, "AZ-DB-004": { "control_id": "CC6.6", @@ -144,4 +149,4 @@ "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall creates a rule that permits any Azure-hosted resource — including services from other tenants — to connect to the database. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Disabling this setting and replacing it with explicit firewall rules or private endpoints enforces the network boundary and ensures only known and trusted systems can reach the SQL Server." } } -} \ No newline at end of file +} diff --git a/playbooks/cli/fix_az_kv_005.sh b/playbooks/cli/fix_az_kv_005.sh new file mode 100644 index 0000000..fb38734 --- /dev/null +++ b/playbooks/cli/fix_az_kv_005.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# fix_az_kv_005.sh +# Enables auto-renewal on an expiring Key Vault certificate +# Usage: ./fix_az_kv_005.sh + +set -euo pipefail + +VAULT=$1 +CERT=$2 + +if [ -z "$VAULT" ] || [ -z "$CERT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Fetching current policy for certificate $CERT in vault $VAULT..." + +POLICY=$(az keyvault certificate policy show \ + --vault-name "$VAULT" \ + --name "$CERT") + +echo "Updating certificate policy to enable auto-renewal 30 days before expiry..." + +echo "$POLICY" | python3 -c " +import json, sys +policy = json.load(sys.stdin) +policy.setdefault('lifetime_actions', []) +already = any( + a.get('action', {}).get('action_type') == 'AutoRenew' + for a in policy['lifetime_actions'] +) +if not already: + policy['lifetime_actions'].append({ + 'action': {'action_type': 'AutoRenew'}, + 'trigger': {'days_before_expiry': 30} + }) +print(json.dumps(policy)) +" | az keyvault certificate policy update \ + --vault-name "$VAULT" \ + --name "$CERT" \ + --policy @- + +echo "Done. Certificate $CERT will now auto-renew 30 days before expiry." +echo "Note: Auto-renewal requires the certificate issuer to be configured correctly." diff --git a/requirements.txt b/requirements.txt index 52f1710..0e34c95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ gunicorn==21.2.0 cryptography==42.0.5 msrest==0.7.1 azure-mgmt-postgresqlflexibleservers==1.0.0b1 +azure-keyvault-certificates==4.8.0 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 9642688..00af6dc 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -14,6 +14,7 @@ from azure.mgmt.monitor import MonitorManagementClient from azure.mgmt.storage import StorageManagementClient + logger = logging.getLogger(__name__) # Azure built-in role definition GUIDs (subscription-scoped) @@ -337,6 +338,19 @@ def get_key_vaults(self) -> List[Any]: logger.error("get_key_vaults failed: %s", exc) return [] + def get_key_vault_certificates(self, vault_name: str) -> List[Any]: + """List all certificates in a Key Vault using the Key Vault data plane API.""" + try: + from azure.keyvault.certificates import CertificateClient + vault_url = f"https://{vault_name}.vault.azure.net" + client = CertificateClient(vault_url=vault_url, credential=self.credential) + return list(client.list_properties_of_certificates()) + except Exception as exc: + logger.error( + "get_key_vault_certificates(%s) failed: %s", vault_name, exc + ) + return [] + # ------------------------------------------------------------------ # # Monitoring # # ------------------------------------------------------------------ # diff --git a/scanner/rules/az_kv_005.py b/scanner/rules/az_kv_005.py new file mode 100644 index 0000000..df29cce --- /dev/null +++ b/scanner/rules/az_kv_005.py @@ -0,0 +1,105 @@ +"""AZ-KV-005: Key Vault certificate expiring within 30 days.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +RULE_ID = "AZ-KV-005" +RULE_NAME = "Key Vault Certificate Expiring Within 30 Days" +SEVERITY = "MEDIUM" +CATEGORY = "Key Vault" +FRAMEWORKS = { + "CIS": "8.5", + "NIST": "PR.MA-1", + "ISO27001": "A.10.1.2", + "SOC2": "CC9.1", +} +DESCRIPTION = ( + "A certificate stored in Azure Key Vault is expiring within 30 days " + "and does not have auto-renewal configured. Expired certificates cause " + "immediate service outages, broken HTTPS connections, and failed " + "authentication flows." +) +REMEDIATION = ( + "Enable auto-renewal on the certificate in Azure Key Vault, or manually " + "renew the certificate before it expires. Navigate to: " + "Key Vault > Certificates > select certificate > Issuance Policy > " + "enable Auto-renewal." +) +PLAYBOOK = "playbooks/cli/fix_az_kv_005.sh" + +logger = logging.getLogger(__name__) + +EXPIRY_THRESHOLD_DAYS = 30 + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + + for vault in azure_client.get_key_vaults(): + parsed = azure_client.parse_resource_id(getattr(vault, "id", "")) + rg = parsed.get("resource_group", "") + vault_name = parsed.get("name", "") + if not rg or not vault_name: + continue + + certificates = azure_client.get_key_vault_certificates(vault_name) + for cert in certificates: + try: + cert_name = getattr(cert, "name", "") or getattr( + cert, "id", "" + ).split("/")[-1] + + expires = getattr(cert, "expires_on", None) + if not expires: + continue + + auto_renew = getattr(cert, "policy", None) + lifetime_actions = ( + getattr(auto_renew, "lifetime_actions", []) if auto_renew else [] + ) + has_auto_renew = any( + getattr(getattr(a, "action", None), "action_type", "").lower() + == "autorenew" + for a in (lifetime_actions or []) + ) + + if has_auto_renew: + continue + + now = datetime.now(timezone.utc) + if hasattr(expires, "tzinfo") and expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + + days_until_expiry = (expires - now).days + + if 0 <= days_until_expiry <= EXPIRY_THRESHOLD_DAYS: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"{vault.id}/certificates/{cert_name}", + "resource_name": cert_name, + "resource_type": "Microsoft.KeyVault/vaults/certificates", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": rg, + "vault_name": vault_name, + "days_until_expiry": days_until_expiry, + "expires": expires.isoformat(), + }, + }) + + except Exception as exc: + logger.error( + "AZ-KV-005: error processing cert in vault %s: %s", + vault_name, + exc, + ) + continue + + return findings From 82efdfbdcf43fb4b9130ca02315fcfbbbbd94d46 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Fri, 29 May 2026 00:46:38 +0100 Subject: [PATCH 50/50] [RULE] AZ-CMP-004: VM without automatic OS patching enabled (#73) * Added az_cmp_004.py to check VM patching status This script checks Azure VMs for automatic OS patching status and collects findings for those without it enabled. * Added script to enable automatic OS patching for VMs This script enables automatic OS patching for both Windows and Linux VMs in Azure. It requires a resource group and VM name as input, defaulting to Windows if the OS type is not specified. * Add control for OS patching requirement in CIS benchmark * Add vulnerability management control to NIST CSF * Added control AZ-CMP-004 for vulnerability management * Added SOC 2 controls for endpoint protection and OS patching * Fix JSON formatting in cis_azure_benchmark.json * Fix JSON formatting in nist_csf.json * Improve error handling in fix_az_cmp_004.sh Updated script to use 'set -euo pipefail' for better error handling. * Update patching condition for Windows configuration Refine condition for patching approval based on patch mode. * Fix indentation and formatting in az_cmp_004.py --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 5 ++ compliance/frameworks/nist_csf.json | 5 ++ compliance/frameworks/soc2.json | 5 ++ playbooks/cli/fix_az_cmp_004.sh | 37 +++++++++ scanner/rules/az_cmp_004.py | 80 +++++++++++++++++++ 6 files changed, 137 insertions(+) create mode 100644 playbooks/cli/fix_az_cmp_004.sh create mode 100644 scanner/rules/az_cmp_004.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 5bfa8de..eeb7ca1 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -103,6 +103,11 @@ "control_name": "Ensure that 'Endpoint protection solution' is installed on VMs", "description": "The virtual machine does not have a recognised endpoint protection extension installed. CIS 8.2 requires that an approved endpoint protection solution is installed and running on all virtual machines. Without endpoint protection, malware and ransomware can execute without detection." }, + "AZ-CMP-004": { + "control_id": "8.3", + "control_name": "Ensure that 'OS patching' is enabled for virtual machines", + "description": "The virtual machine does not have automatic OS patching enabled. CIS 8.3 requires that OS patches are applied in a timely manner. Unpatched VMs are vulnerable to known exploits targeting unpatched OS vulnerabilities." + }, "AZ-KV-001": { "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 7e29707..d17bc6a 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -103,6 +103,11 @@ "control_name": "Controls against malware", "description": "The virtual machine does not have a recognised endpoint protection extension installed. A.12.2.1 requires that detection, prevention and recovery controls are implemented to protect against malware." }, + "AZ-CMP-004": { + "control_id": "A.12.6.1", + "control_name": "Management of technical vulnerabilities", + "description": "The virtual machine does not have automatic OS patching enabled. A.12.6.1 requires that information about technical vulnerabilities is obtained and the organisation's exposure evaluated. Without automatic patching, known OS vulnerabilities remain unmitigated." + }, "AZ-KV-001": { "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 1c9a50d..4178ff8 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -103,6 +103,11 @@ "control_name": "Malicious code is detected", "description": "The virtual machine does not have a recognised endpoint protection extension installed. DE.CM-4 requires that malicious code is detected on organisational systems. Without endpoint protection, malware and ransomware executing on the VM will not be detected or blocked." }, + "AZ-CMP-004": { + "control_id": "PR.IP-12", + "control_name": "A vulnerability management plan is developed and implemented", + "description": "The virtual machine does not have automatic OS patching enabled. PR.IP-12 requires that a vulnerability management plan is developed and implemented. Without automatic patching, known OS vulnerabilities remain unmitigated and exploitable." + }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 0313db9..342ed5d 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -113,6 +113,11 @@ "control_name": "Prevents or Detects Unauthorized or Malicious Software", "description": "The virtual machine does not have a recognised endpoint protection extension installed. CC6.8 requires that controls are implemented to prevent or detect and act upon the introduction of unauthorised or malicious software." }, + "AZ-CMP-004": { + "control_id": "CC7.1", + "control_name": "System Vulnerabilities are Identified and Managed", + "description": "The virtual machine does not have automatic OS patching enabled. CC7.1 requires that vulnerabilities in system components are identified and managed through a defined process. Without automatic patching, known OS vulnerabilities are left unmitigated and exploitable." + }, "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", diff --git a/playbooks/cli/fix_az_cmp_004.sh b/playbooks/cli/fix_az_cmp_004.sh new file mode 100644 index 0000000..a192682 --- /dev/null +++ b/playbooks/cli/fix_az_cmp_004.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# fix_az_cmp_004.sh +# Enables automatic OS patching on a VM (Windows or Linux) +# Usage: ./fix_az_cmp_004.sh [windows|linux] +# Defaults to windows if OS type is not passed + +set -euo pipefail + +RG=$1 +VM=$2 +OS=${3:-windows} + +if [ -z "$RG" ] || [ -z "$VM" ]; then + echo "Usage: $0 [windows|linux]" + exit 1 +fi + +if [ "${OS,,}" = "linux" ]; then + echo "Enabling AutomaticByPlatform patching on Linux VM $VM..." + + az vm update \ + --resource-group "$RG" \ + --name "$VM" \ + --set osProfile.linuxConfiguration.patchSettings.patchMode=AutomaticByPlatform + + echo "Done. Linux VM $VM will now receive automatic OS patches." +else + echo "Enabling automatic updates on Windows VM $VM..." + + az vm update \ + --resource-group "$RG" \ + --name "$VM" \ + --set osProfile.windowsConfiguration.enableAutomaticUpdates=true \ + --set osProfile.windowsConfiguration.patchSettings.patchMode=AutomaticByPlatform + + echo "Done. Windows VM $VM will now receive automatic OS patches." +fi diff --git a/scanner/rules/az_cmp_004.py b/scanner/rules/az_cmp_004.py new file mode 100644 index 0000000..ec84bc8 --- /dev/null +++ b/scanner/rules/az_cmp_004.py @@ -0,0 +1,80 @@ +"""AZ-CMP-004: VM without automatic OS patching enabled.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-CMP-004" +RULE_NAME = "VM Without Automatic OS Patching Enabled" +SEVERITY = "HIGH" +CATEGORY = "Compute" +FRAMEWORKS = { + "CIS": "8.3", + "NIST": "PR.IP-12", + "ISO27001": "A.12.6.1", + "SOC2": "CC7.1", +} +DESCRIPTION = ( + "VM does not have automatic OS patching enabled. " + "Unpatched VMs are vulnerable to known exploits. " + "CIS 8.3 requires OS patches are applied in a timely manner." +) +REMEDIATION = ( + "For Windows VMs enable automatic updates via osProfile.windowsConfiguration " + "or set patchMode to AutomaticByPlatform. " + "For Linux VMs set patchMode to AutomaticByPlatform." +) +PLAYBOOK = "playbooks/cli/fix_az_cmp_004.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + + for vm in azure_client.get_virtual_machines(): + parsed = azure_client.parse_resource_id(getattr(vm, "id", "")) + rg = parsed.get("resource_group", "") + vm_name = parsed.get("name", "") + if not rg or not vm_name: + continue + + os_profile = getattr(vm, "os_profile", None) + if not os_profile: + continue + + patching_ok = False + + win_config = getattr(os_profile, "windows_configuration", None) + if win_config is not None: + auto_updates = getattr(win_config, "enable_automatic_updates", False) + patch_settings = getattr(win_config, "patch_settings", None) + patch_mode = getattr(patch_settings, "patch_mode", "") if patch_settings else "" + if auto_updates or (patch_mode or "").lower() == "automaticbyplatform": + patching_ok = True + + linux_config = getattr(os_profile, "linux_configuration", None) + if linux_config is not None: + patch_settings = getattr(linux_config, "patch_settings", None) + patch_mode = getattr(patch_settings, "patch_mode", "") if patch_settings else "" + if (patch_mode or "").lower() == "automaticbyplatform": + patching_ok = True + + if not patching_ok: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vm.id, + "resource_name": vm_name, + "resource_type": "Microsoft.Compute/virtualMachines", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": rg, + }, + }) + + return findings