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 52ce3066..9a2fb068 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.10.1.1", + "control_name": "Policy on the use of cryptographic controls", + "description": "SQL Server minimum TLS version applies cryptographic controls to data in transit. A policy on the use of cryptographic controls for protection of information should be developed and implemented.", + "mapping_type": "supporting", + "evidence_type": "automated_configuration_scan", + "primary_source": "ISO/IEC 27001:2013:2013, Annex A control A.10.1.1", + "rationale": "ISO/IEC 27001:2013 Annex A control A.10.1.1 ('Policy on the use of cryptographic controls') 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.9.4.2", "control_name": "Secure log-on procedures", 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/rules/az_db_008.py b/scanner/rules/az_db_008.py new file mode 100644 index 00000000..30e55c37 --- /dev/null +++ b/scanner/rules/az_db_008.py @@ -0,0 +1,61 @@ +"""AZ-DB-008: Azure SQL Server does not enforce a minimum TLS version of 1.2.""" + +from typing import Any, Dict, List + +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.10.1.1", "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"} + + +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.""" + 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", "") + tls_version = getattr(server, "minimal_tls_version", None) + + if tls_version not in _COMPLIANT_VERSIONS: + 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, + "location": getattr(server, "location", ""), + "minimal_tls_version": tls_version or "not set", + }, + } + ) + + return findings diff --git a/tests/test_rules_database.py b/tests/test_rules_database.py index e2fd01b4..4dc9de0d 100644 --- a/tests/test_rules_database.py +++ b/tests/test_rules_database.py @@ -6,6 +6,7 @@ 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 tests.helpers.mock_azure import make_resource try: @@ -255,3 +256,61 @@ 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"