diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index f3181a4e..c3f838d4 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -127,6 +127,18 @@ "review_status": "pending_review", "review_date": null }, + "AZ-DB-008": { + "control_id": "N/A-DB-008", + "control_name": "SQL Server Minimum TLS Version Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation.", + "mapping_type": "not_applicable", + "evidence_type": "not_applicable", + "primary_source": "CIS Microsoft Azure Foundations Benchmark v2.0.0, control N/A-DB-008", + "rationale": "CIS Microsoft Azure Foundations Benchmark v2.0.0 has no numbered control this rule maps to (N/A-DB-008: SQL Server Minimum TLS Version Below 1.2 is a service-specific Azure hardening check; the only numbered minimum-TLS recommendation, 3.15, applies to storage accounts and not to Microsoft.Sql/servers) - reporting it as direct CIS evidence would overstate this framework's coverage, so it is marked not applicable pending review.", + "owner": null, + "review_status": "pending_review", + "review_date": null + }, "AZ-COSMOS-001": { "control_id": "N/A-COSMOS-001", "control_name": "Cosmos DB Local Authentication Enabled", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 5ccaf205..00718c50 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -127,6 +127,18 @@ "review_status": "pending_review", "review_date": null }, + "AZ-DB-008": { + "control_id": "A.8.24", + "control_name": "Use of cryptography", + "description": "Enforcing a minimum TLS version of 1.2 on the SQL server applies cryptographic controls to data in transit. A policy on the use of cryptography for protection of information should be defined and implemented.", + "mapping_type": "supporting", + "evidence_type": "automated_configuration_scan", + "primary_source": "ISO/IEC 27001:2022, Annex A control A.8.24", + "rationale": "ISO/IEC 27001:2022 Annex A control A.8.24 ('Use of cryptography') requires a documented ISMS control, not solely a technical configuration state. OpenShield rule AZ-DB-008 evaluates one Azure technical setting that provides supporting automated evidence toward this control; full conformance also requires the organizational policy and process elements ISO 27001 mandates.", + "owner": null, + "review_status": "pending_review", + "review_date": null + }, "AZ-COSMOS-001": { "control_id": "A.8.5", "control_name": "Secure authentication", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 742110fd..e767a88c 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -127,6 +127,18 @@ "review_status": "pending_review", "review_date": null }, + "AZ-DB-008": { + "control_id": "PR.DS-2", + "control_name": "Data-in-transit is protected", + "description": "Enforcing a minimum TLS version of 1.2 on the SQL server ensures data in transit between clients and the database is protected using current, secure protocols.", + "mapping_type": "supporting", + "evidence_type": "automated_configuration_scan", + "primary_source": "NIST Cybersecurity Framework 1.1, subcategory PR.DS-2", + "rationale": "NIST CSF 1.1 subcategory PR.DS-2 ('Data-in-transit is protected') describes a broader security outcome that requires organizational process in addition to technical configuration. OpenShield rule AZ-DB-008 evaluates one Azure technical control that provides supporting automated evidence toward this outcome; it does not by itself fully satisfy the subcategory.", + "owner": null, + "review_status": "pending_review", + "review_date": null + }, "AZ-COSMOS-001": { "control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index cdfdb858..c2952962 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -127,6 +127,18 @@ "review_status": "pending_review", "review_date": null }, + "AZ-DB-008": { + "control_id": "CC6.7", + "control_name": "Protects Data in Transit", + "description": "The SQL server does not enforce a minimum TLS version of 1.2. CC6.7 requires that data transmitted over networks is protected using encryption. Enforcing TLS 1.2 minimum ensures secure protocols are used for all connections.", + "mapping_type": "supporting", + "evidence_type": "automated_configuration_scan", + "primary_source": "SOC 2 Type II (2017 Trust Services Criteria), criterion CC6.7", + "rationale": "AICPA SOC 2 (2017 Trust Services Criteria) criterion CC6.7 ('Protects Data in Transit') is evaluated by an independent auditor across technical, procedural and organizational evidence. OpenShield rule AZ-DB-008 evaluates one Azure technical control that provides supporting automated evidence toward this criterion; it is not a substitute for an auditor's evaluation.", + "owner": null, + "review_status": "pending_review", + "review_date": null + }, "AZ-COSMOS-001": { "control_id": "CC6.3", "control_name": "Logical access security", diff --git a/playbooks/cli/fix_az_db_008.sh b/playbooks/cli/fix_az_db_008.sh new file mode 100644 index 00000000..c3a70e72 --- /dev/null +++ b/playbooks/cli/fix_az_db_008.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Playbook: fix_az_db_008.sh +# Rule: AZ-DB-008 — Azure SQL Server minimum TLS version below 1.2 + +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 Azure SQL Servers..." +SERVERS=$(az sql server list --subscription "$SUBSCRIPTION_ID" --query "[].{name:name, rg:resourceGroup}" --output tsv) + +if [[ -z "$SERVERS" ]]; then + echo "No Azure SQL Servers found." + exit 0 +fi + +READ_FAILURES=0 + +while IFS=$'\t' read -r SERVER_NAME RESOURCE_GROUP; do + echo "Checking $SERVER_NAME in $RESOURCE_GROUP..." + + # A failed read (permissions, transient API error) says nothing about the + # server's TLS setting, so it must never fall through to the update below. + # Only a successful read returning an unset/"None"/below-1.2 value is + # treated as non-compliant. + if ! TLS_VERSION=$(az sql server show --name "$SERVER_NAME" --resource-group "$RESOURCE_GROUP" --query "minimalTlsVersion" --output tsv 2>/dev/null); then + echo "ERROR: could not read minimalTlsVersion for $SERVER_NAME, skipping (no change made)." >&2 + READ_FAILURES=$((READ_FAILURES + 1)) + continue + fi + + if [[ "$TLS_VERSION" != "1.2" && "$TLS_VERSION" != "1.3" ]]; then + echo "Setting minimum TLS version to 1.2 on $SERVER_NAME..." + az sql server update --name "$SERVER_NAME" --resource-group "$RESOURCE_GROUP" --minimal-tls-version 1.2 --output none + echo "Done." + else + echo "$SERVER_NAME already enforces TLS $TLS_VERSION, skipping." + fi +done <<< "$SERVERS" + +if [[ "$READ_FAILURES" -gt 0 ]]; then + echo "$READ_FAILURES server(s) could not be read and were left unchanged. Re-run after fixing access." >&2 + exit 1 +fi + +echo "Done. Verify with:" +echo " az sql server show --name --resource-group --query minimalTlsVersion" diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 315ac571..2b87e7ef 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -1074,14 +1074,21 @@ def get_postgresql_servers(self) -> List[Any]: logger.error("get_postgresql_servers failed: %s", exc) return [] - def get_sql_servers(self) -> List[Any]: - """List all Azure SQL servers in the subscription.""" + def list_sql_servers(self) -> Optional[List[Any]]: + """List all Azure SQL servers in the subscription. + + Returns ``None`` when the list call fails; see list_storage_accounts(). + """ 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 [] + logger.error("list_sql_servers failed: %s", exc) + return None + + def get_sql_servers(self) -> List[Any]: + """List all Azure SQL servers, or ``[]`` on failure (legacy scan() callers).""" + return self.list_sql_servers() or [] 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.""" diff --git a/scanner/rules/az_db_008.py b/scanner/rules/az_db_008.py new file mode 100644 index 00000000..8e16d140 --- /dev/null +++ b/scanner/rules/az_db_008.py @@ -0,0 +1,105 @@ +"""AZ-DB-008: Azure SQL Server does not enforce a minimum TLS version of 1.2.""" + +from typing import Any, Dict, List + +from scanner.evaluation import ( + EvaluationStatus, + RuleEvaluation, + fail_findings, + inventory_unavailable, + no_resources_found, +) + +RULE_ID = "AZ-DB-008" +RULE_NAME = "Azure SQL Server Minimum TLS Version Below 1.2" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "N/A-DB-008", "NIST": "PR.DS-2", "ISO27001": "A.8.24", "SOC2": "CC6.7"} +DESCRIPTION = ( + "The Azure SQL Server does not enforce a minimum TLS version of 1.2 or higher. " + "Connections are still able to negotiate the deprecated TLS 1.0 or 1.1 protocols, " + "or no minimum is set at all, leaving data in transit exposed to known protocol " + "downgrade and interception weaknesses in those older versions." +) +REMEDIATION = ( + "Set the server's minimum TLS version to 1.2. " + "Run: az sql server update --name --resource-group " + "--minimal-tls-version 1.2" +) +PLAYBOOK = "playbooks/cli/fix_az_db_008.sh" + +# The Server model's minimal_tls_version is a free string, not an enum, and the +# real Azure API allows "None" (no minimum enforced) alongside "1.0"/"1.1"/"1.2"/ +# "1.3" -- a missing attribute (None) must be treated the same as an explicit +# below-1.2 value, not skipped, since both mean TLS 1.2 is not being enforced. +_COMPLIANT_VERSIONS = {"1.2", "1.3"} + +RESOURCE_TYPE = "Microsoft.Sql/servers" + + +def _finding(azure_client: Any, server: Any, tls_version: Any) -> Dict[str, Any]: + parsed = azure_client.parse_resource_id(server.id) + return { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server.name, + "resource_type": RESOURCE_TYPE, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": parsed.get("resource_group", ""), + "location": getattr(server, "location", ""), + "minimal_tls_version": tls_version or "not set", + }, + } + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect Azure SQL Servers whose minimum TLS version is below 1.2 or unset.""" + return fail_findings(evaluate(azure_client, subscription_id)) + + +def evaluate(azure_client: Any, subscription_id: str) -> List[RuleEvaluation]: + """Report a status for every SQL server inspected, PASS included. + + A failed list call is ERROR and an empty one NOT_APPLICABLE, so an unreadable + inventory never reads as a clean scan. + """ + servers = azure_client.list_sql_servers() + if servers is None: + return [inventory_unavailable(RULE_ID, RESOURCE_TYPE, subscription_id)] + if not servers: + return [no_resources_found(RULE_ID, RESOURCE_TYPE, subscription_id)] + + evaluations: List[RuleEvaluation] = [] + for server in servers: + tls_version = getattr(server, "minimal_tls_version", None) + evidence = {"minimal_tls_version": tls_version} + if tls_version in _COMPLIANT_VERSIONS: + evaluations.append( + RuleEvaluation( + rule_id=RULE_ID, + resource_id=server.id, + resource_type=RESOURCE_TYPE, + status=EvaluationStatus.PASS, + evidence=evidence, + ) + ) + else: + evaluations.append( + RuleEvaluation( + rule_id=RULE_ID, + resource_id=server.id, + resource_type=RESOURCE_TYPE, + status=EvaluationStatus.FAIL, + evidence=evidence, + finding=_finding(azure_client, server, tls_version), + ) + ) + + return evaluations diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 69c6c95e..b2a0e874 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -52,7 +52,7 @@ def __init__(self) -> None: self._virtual_machines: List[Any] = [] self._virtual_machine_scale_sets: List[Any] = [] self._key_vaults: Optional[List[Any]] = [] - self._sql_servers: List[Any] = [] + self._sql_servers: Optional[List[Any]] = [] self._service_principals: List[Any] = [] self._sql_firewall_rules: Dict[Tuple[str, str], List[Any]] = {} # --- Additional state added for full rule-coverage tests ---------- # @@ -222,7 +222,8 @@ def set_key_vaults(self, vaults: Optional[List[Any]]) -> "MockAzureClient": self._key_vaults = vaults return self - def set_sql_servers(self, servers: List[Any]) -> "MockAzureClient": + def set_sql_servers(self, servers: Optional[List[Any]]) -> "MockAzureClient": + """``None`` simulates a failed list call, as AzureClient.list_sql_servers() reports it.""" self._sql_servers = servers return self @@ -257,9 +258,12 @@ def list_key_vaults(self) -> Optional[List[Any]]: def get_key_vaults(self) -> List[Any]: return self._key_vaults or [] - def get_sql_servers(self) -> List[Any]: + def list_sql_servers(self) -> Optional[List[Any]]: return self._sql_servers + def get_sql_servers(self) -> List[Any]: + return self._sql_servers or [] + def get_service_principals(self) -> List[Any]: return self._service_principals diff --git a/tests/test_azure_client_management.py b/tests/test_azure_client_management.py index 0f3301c0..87c51f9f 100644 --- a/tests/test_azure_client_management.py +++ b/tests/test_azure_client_management.py @@ -62,6 +62,7 @@ def test_list_wrappers_return_results_and_fail_closed(client, method, patch_targ [ ("list_storage_accounts", "scanner.azure_client.StorageManagementClient", "storage_accounts.list"), ("list_key_vaults", "scanner.azure_client.KeyVaultManagementClient", "vaults.list_by_subscription"), + ("list_sql_servers", "scanner.azure_client.SqlManagementClient", "servers.list"), ], ) def test_fallible_list_wrappers_distinguish_empty_from_failure(client, method, patch_target, operation): diff --git a/tests/test_rule_evaluation_contract.py b/tests/test_rule_evaluation_contract.py index 48dbe7cd..801f0261 100644 --- a/tests/test_rule_evaluation_contract.py +++ b/tests/test_rule_evaluation_contract.py @@ -68,7 +68,20 @@ def vault(name: str, **props: Any) -> Any: ) +def _db_008_fixture() -> MockAzureClient: + def server(name: str, tls: str) -> Any: + return make_resource( + id=f"/subscriptions/{_SUB}/resourceGroups/rg/providers/Microsoft.Sql/servers/{name}", + name=name, + location="eastus", + minimal_tls_version=tls, + ) + + return MockAzureClient().set_sql_servers([server("sql-tls12", "1.2"), server("sql-tls10", "1.0")]) + + FIXTURES: Dict[str, Callable[[], MockAzureClient]] = { + "AZ-DB-008": _db_008_fixture, "AZ-KV-006": _kv_006_fixture, } diff --git a/tests/test_rules_database.py b/tests/test_rules_database.py index e2fd01b4..4ef38a56 100644 --- a/tests/test_rules_database.py +++ b/tests/test_rules_database.py @@ -6,6 +6,8 @@ import scanner.rules.az_db_002 as az_db_002 import scanner.rules.az_db_003 as az_db_003 import scanner.rules.az_db_004 as az_db_004 +import scanner.rules.az_db_008 as az_db_008 +from scanner.evaluation import EvaluationStatus from tests.helpers.mock_azure import make_resource try: @@ -255,3 +257,108 @@ def test_db_003_noncompliant_returns_one_finding(mock_azure, subscription_id): assert findings[0]["rule_id"] == "AZ-DB-003" assert findings[0]["severity"] == "HIGH" assert findings[0]["resource_name"] == "pgflex-nossl" + + +# ── AZ-DB-008: SQL server minimum TLS version below 1.2 ──────────────────── + + +def test_db_008_compliant_tls_1_2_returns_no_findings(mock_azure, subscription_id): + server = make_resource(id=_sql_id("sql-tls12"), name="sql-tls12", minimal_tls_version="1.2") + mock_azure.set_sql_servers([server]) + assert az_db_008.scan(mock_azure, subscription_id) == [] + + +def test_db_008_compliant_tls_1_3_returns_no_findings(mock_azure, subscription_id): + server = make_resource(id=_sql_id("sql-tls13"), name="sql-tls13", minimal_tls_version="1.3") + mock_azure.set_sql_servers([server]) + assert az_db_008.scan(mock_azure, subscription_id) == [] + + +def test_db_008_noncompliant_tls_1_0_returns_one_finding(mock_azure, subscription_id): + server = make_resource(id=_sql_id("sql-tls10"), name="sql-tls10", minimal_tls_version="1.0") + mock_azure.set_sql_servers([server]) + findings = az_db_008.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-DB-008" + assert finding["severity"] == "HIGH" + assert finding["category"] == "Database" + assert finding["resource_name"] == "sql-tls10" + assert finding["resource_type"] == "Microsoft.Sql/servers" + assert finding["metadata"]["resource_group"] == _RG + assert finding["metadata"]["minimal_tls_version"] == "1.0" + + +def test_db_008_noncompliant_tls_1_1_returns_one_finding(mock_azure, subscription_id): + server = make_resource(id=_sql_id("sql-tls11"), name="sql-tls11", minimal_tls_version="1.1") + mock_azure.set_sql_servers([server]) + findings = az_db_008.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-DB-008" + + +def test_db_008_noncompliant_explicit_none_returns_one_finding(mock_azure, subscription_id): + """The real Azure API allows an explicit 'None' string meaning no minimum is enforced, + distinct from the attribute simply being unset.""" + server = make_resource(id=_sql_id("sql-tls-none"), name="sql-tls-none", minimal_tls_version="None") + mock_azure.set_sql_servers([server]) + findings = az_db_008.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["minimal_tls_version"] == "None" + + +def test_db_008_missing_attribute_defaults_to_noncompliant(mock_azure, subscription_id): + """A server with no minimal_tls_version attribute at all must not be silently skipped.""" + server = make_resource(id=_sql_id("sql-tls-unset"), name="sql-tls-unset") + mock_azure.set_sql_servers([server]) + findings = az_db_008.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["minimal_tls_version"] == "not set" + + +def test_db_008_evaluate_pass_and_fail_per_server(mock_azure, subscription_id): + good = make_resource(id=_sql_id("sql-good"), name="sql-good", minimal_tls_version="1.2") + bad = make_resource(id=_sql_id("sql-bad"), name="sql-bad", minimal_tls_version="1.0") + mock_azure.set_sql_servers([good, bad]) + evaluations = az_db_008.evaluate(mock_azure, subscription_id) + statuses = {e.resource_id: e.status for e in evaluations} + assert statuses[_sql_id("sql-good")] == EvaluationStatus.PASS + assert statuses[_sql_id("sql-bad")] == EvaluationStatus.FAIL + failed = next(e for e in evaluations if e.status == EvaluationStatus.FAIL) + assert failed.finding["rule_id"] == "AZ-DB-008" + assert failed.finding["metadata"]["minimal_tls_version"] == "1.0" + + +def test_db_008_evaluate_unset_tls_is_fail_not_skipped(mock_azure, subscription_id): + server = make_resource(id=_sql_id("sql-unset"), name="sql-unset") + mock_azure.set_sql_servers([server]) + evaluations = az_db_008.evaluate(mock_azure, subscription_id) + assert [e.status for e in evaluations] == [EvaluationStatus.FAIL] + + +def test_db_008_failed_inventory_is_error_not_a_clean_scan(mock_azure, subscription_id): + """list_sql_servers() returns None when the list call fails; that must never read as + compliant, so evaluate() reports ERROR and scan() alone cannot be mistaken for a pass.""" + mock_azure.set_sql_servers(None) + result = az_db_008.evaluate(mock_azure, subscription_id) + assert [(e.status, e.reason_code) for e in result] == [(EvaluationStatus.ERROR, "INVENTORY_UNAVAILABLE")] + assert result[0].resource_id == f"/subscriptions/{subscription_id}" + assert az_db_008.scan(mock_azure, subscription_id) == [] + + +def test_db_008_empty_inventory_is_not_applicable(mock_azure, subscription_id): + mock_azure.set_sql_servers([]) + result = az_db_008.evaluate(mock_azure, subscription_id) + assert [(e.status, e.reason_code) for e in result] == [(EvaluationStatus.NOT_APPLICABLE, "NO_RESOURCES_FOUND")] + + +def test_db_008_evaluate_records_the_inspected_value_as_evidence(mock_azure, subscription_id): + good = make_resource(id=_sql_id("sql-good"), name="sql-good", minimal_tls_version="1.3") + bad = make_resource(id=_sql_id("sql-bad"), name="sql-bad", minimal_tls_version="None") + mock_azure.set_sql_servers([good, bad]) + evidence = {e.resource_id: e.evidence for e in az_db_008.evaluate(mock_azure, subscription_id)} + assert evidence == { + _sql_id("sql-good"): {"minimal_tls_version": "1.3"}, + _sql_id("sql-bad"): {"minimal_tls_version": "None"}, + }