From 787d3d3ccf46c03889f9ad9bbea6b3dc85bb0c92 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Tue, 15 Sep 2026 01:16:53 +0100 Subject: [PATCH 1/5] feat: add rule AZ-DB-008 SQL Server minimum TLS version below 1.2 Detects Azure SQL Servers whose minimal_tls_version is missing, an explicit "None", or below 1.2 ("1.0"/"1.1"), matching the real azure-mgmt-sql Server model (a plain string, not the TLS1_0-style enum some other Azure services use). No numbered CIS Azure Foundations control exists for this specific setting (verified against the official 2.0.0 control mapping; only the storage-account minimum TLS control, 3.15, is numbered, and it does not apply to Microsoft.Sql/servers), so this follows the repo's existing N/A-* convention like AZ-STOR-006 through AZ-STOR-009. Closes #246 Signed-off-by: Dipesh Ray --- .../frameworks/cis_azure_benchmark.json | 12 ++++ compliance/frameworks/iso27001.json | 12 ++++ compliance/frameworks/nist_csf.json | 12 ++++ compliance/frameworks/soc2.json | 12 ++++ playbooks/cli/fix_az_db_008.sh | 39 ++++++++++++ scanner/rules/az_db_008.py | 61 +++++++++++++++++++ tests/test_rules_database.py | 59 ++++++++++++++++++ 7 files changed, 207 insertions(+) create mode 100644 playbooks/cli/fix_az_db_008.sh create mode 100644 scanner/rules/az_db_008.py 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..b41c09f9 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.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..69bee9d4 --- /dev/null +++ b/playbooks/cli/fix_az_db_008.sh @@ -0,0 +1,39 @@ +#!/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 + +while IFS=$'\t' read -r SERVER_NAME RESOURCE_GROUP; do + echo "Checking $SERVER_NAME in $RESOURCE_GROUP..." + TLS_VERSION=$(az sql server show --name "$SERVER_NAME" --resource-group "$RESOURCE_GROUP" --query "minimalTlsVersion" --output tsv 2>/dev/null || echo "") + + 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" + +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" From 63e84a395c0bbc24bfc13093d3c5f259cb012f9f Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Sat, 3 Oct 2026 10:16:30 +0100 Subject: [PATCH 2/5] fix(playbook): skip servers whose TLS read fails in fix_az_db_008 The previous '|| echo ""' fallback made a failed 'az sql server show' (permissions, transient API error) indistinguishable from a server with no minimum TLS set, so the playbook could update a server without ever confirming its state. A failed read now reports an error, skips that server, and makes the script exit non-zero; only a successful read of an unset/None/below-1.2 value is remediated. Signed-off-by: Dipesh Ray --- playbooks/cli/fix_az_db_008.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/playbooks/cli/fix_az_db_008.sh b/playbooks/cli/fix_az_db_008.sh index 69bee9d4..c3a70e72 100644 --- a/playbooks/cli/fix_az_db_008.sh +++ b/playbooks/cli/fix_az_db_008.sh @@ -22,9 +22,20 @@ if [[ -z "$SERVERS" ]]; then exit 0 fi +READ_FAILURES=0 + while IFS=$'\t' read -r SERVER_NAME RESOURCE_GROUP; do echo "Checking $SERVER_NAME in $RESOURCE_GROUP..." - TLS_VERSION=$(az sql server show --name "$SERVER_NAME" --resource-group "$RESOURCE_GROUP" --query "minimalTlsVersion" --output tsv 2>/dev/null || echo "") + + # 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..." @@ -35,5 +46,10 @@ while IFS=$'\t' read -r SERVER_NAME RESOURCE_GROUP; do 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" From d490db4ca9b6657b34e0c4c03765c1323422ca8c Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Sun, 4 Oct 2026 11:41:13 +0100 Subject: [PATCH 3/5] feat(scanner): record AZ-DB-008 coverage via evaluate() so a failed inventory is UNKNOWN AzureClient.get_sql_servers() returns [] on any API failure as well as for an empty subscription, so scan() alone made an unreadable inventory look like a clean result. Add evaluate() per the #263 contract: PASS/FAIL per server, and UNKNOWN (never a pass) when no servers are returned. scan() is unchanged apart from sharing the finding builder. Signed-off-by: Dipesh Ray --- scanner/rules/az_db_008.py | 98 +++++++++++++++++++++++++++--------- tests/test_rules_database.py | 33 ++++++++++++ 2 files changed, 108 insertions(+), 23 deletions(-) diff --git a/scanner/rules/az_db_008.py b/scanner/rules/az_db_008.py index 30e55c37..772ff6e3 100644 --- a/scanner/rules/az_db_008.py +++ b/scanner/rules/az_db_008.py @@ -2,6 +2,8 @@ from typing import Any, Dict, List +from scanner.evaluation import EvaluationStatus, RuleEvaluation, subscription_scope_id + RULE_ID = "AZ-DB-008" RULE_NAME = "Azure SQL Server Minimum TLS Version Below 1.2" SEVERITY = "HIGH" @@ -27,35 +29,85 @@ _COMPLIANT_VERSIONS = {"1.2", "1.3"} +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": "Microsoft.Sql/servers", + "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.""" 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", - }, - } - ) + findings.append(_finding(azure_client, server, tls_version)) return findings + + +def evaluate(azure_client: Any, subscription_id: str) -> List[RuleEvaluation]: + """Report coverage per server (PASS included), so an inventory that could + not be read is recorded as UNKNOWN instead of reading as a clean scan.""" + servers = azure_client.get_sql_servers() + if not servers: + # AzureClient.get_sql_servers() returns [] both for a subscription with + # no SQL servers and when the list call itself failed (authorization, + # network, throttling), and scan() cannot tell them apart. UNKNOWN is + # the conservative record: it never counts as a pass in compliance + # scoring, whereas an empty findings list would look compliant. + return [ + RuleEvaluation( + rule_id=RULE_ID, + resource_id=subscription_scope_id(subscription_id), + resource_type="Microsoft.Sql/servers", + status=EvaluationStatus.UNKNOWN, + reason_code="INVENTORY_EMPTY_OR_UNAVAILABLE", + reason=( + "No Azure SQL servers were returned; this is indistinguishable from a failed " + "list call (authorization, network or throttling), so coverage is unknown." + ), + ) + ] + + evaluations: List[RuleEvaluation] = [] + for server in servers: + tls_version = getattr(server, "minimal_tls_version", None) + if tls_version in _COMPLIANT_VERSIONS: + evaluations.append( + RuleEvaluation( + rule_id=RULE_ID, + resource_id=server.id, + resource_type="Microsoft.Sql/servers", + status=EvaluationStatus.PASS, + ) + ) + else: + evaluations.append( + RuleEvaluation( + rule_id=RULE_ID, + resource_id=server.id, + resource_type="Microsoft.Sql/servers", + status=EvaluationStatus.FAIL, + finding=_finding(azure_client, server, tls_version), + ) + ) + + return evaluations diff --git a/tests/test_rules_database.py b/tests/test_rules_database.py index 4dc9de0d..7022803c 100644 --- a/tests/test_rules_database.py +++ b/tests/test_rules_database.py @@ -7,6 +7,7 @@ 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: @@ -314,3 +315,35 @@ def test_db_008_missing_attribute_defaults_to_noncompliant(mock_azure, subscript 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_or_empty_inventory_is_unknown_not_clean(mock_azure, subscription_id): + """get_sql_servers() returns [] on an API failure as well as for a genuinely empty + subscription. scan() alone would look like a clean result either way, so evaluate() + must record UNKNOWN (never a PASS) for that case.""" + mock_azure.set_sql_servers([]) + evaluations = az_db_008.evaluate(mock_azure, subscription_id) + assert len(evaluations) == 1 + assert evaluations[0].status == EvaluationStatus.UNKNOWN + assert evaluations[0].reason_code == "INVENTORY_EMPTY_OR_UNAVAILABLE" + assert evaluations[0].resource_id == f"/subscriptions/{subscription_id}" From b76c1a7f58f85fef3aff1cb36235f5846b98c330 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Wed, 7 Oct 2026 12:35:56 +0100 Subject: [PATCH 4/5] fix: map AZ-DB-008 to ISO/IEC 27001:2022 A.8.24 dev moved the ISO mapping pack to the 2022 edition after this branch was opened, so the 2013 control A.10.1.1 is rejected by the 2022 pack tests. Use A.8.24 (Use of cryptography), matching AZ-DB-003 and AZ-STOR-002, in both the pack entry and the rule's own FRAMEWORKS. Signed-off-by: Dipesh Ray --- compliance/frameworks/iso27001.json | 10 +++++----- scanner/rules/az_db_008.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index b41c09f9..00718c50 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -128,13 +128,13 @@ "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.", + "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: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.", + "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 diff --git a/scanner/rules/az_db_008.py b/scanner/rules/az_db_008.py index 772ff6e3..5a21071e 100644 --- a/scanner/rules/az_db_008.py +++ b/scanner/rules/az_db_008.py @@ -8,7 +8,7 @@ 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"} +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, " From 770a5d8b0ba8a04779dc3423ffbca926dd2a79ff Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Sun, 18 Oct 2026 02:17:29 +0100 Subject: [PATCH 5/5] fix(scanner): align AZ-DB-008 with the shared evaluate() contract dev added tests/test_rule_evaluation_contract.py (#381) after this branch was opened. It requires a failed inventory to be ERROR, an empty one to be NOT_APPLICABLE, and a registered PASS/FAIL fixture, so AZ-DB-008 failed three of its checks once synced with dev. Add AzureClient.list_sql_servers(), which returns None when the list call fails (get_sql_servers() stays as the [] wrapper for legacy callers), and use the shared inventory_unavailable / no_resources_found / fail_findings helpers. This replaces the earlier UNKNOWN-on-empty workaround, which only existed because the failure could not be told apart from an empty result. Evidence now records the inspected minimal_tls_version. Signed-off-by: Dipesh Ray --- scanner/azure_client.py | 15 +++++-- scanner/rules/az_db_008.py | 58 +++++++++++--------------- tests/helpers/mock_azure.py | 10 +++-- tests/test_azure_client_management.py | 1 + tests/test_rule_evaluation_contract.py | 13 ++++++ tests/test_rules_database.py | 33 +++++++++++---- 6 files changed, 81 insertions(+), 49 deletions(-) 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 index 5a21071e..8e16d140 100644 --- a/scanner/rules/az_db_008.py +++ b/scanner/rules/az_db_008.py @@ -2,7 +2,13 @@ from typing import Any, Dict, List -from scanner.evaluation import EvaluationStatus, RuleEvaluation, subscription_scope_id +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" @@ -28,6 +34,8 @@ # 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) @@ -38,7 +46,7 @@ def _finding(azure_client: Any, server: Any, tls_version: Any) -> Dict[str, Any] "category": CATEGORY, "resource_id": server.id, "resource_name": server.name, - "resource_type": "Microsoft.Sql/servers", + "resource_type": RESOURCE_TYPE, "description": DESCRIPTION, "remediation": REMEDIATION, "playbook": PLAYBOOK, @@ -53,50 +61,33 @@ def _finding(azure_client: Any, server: Any, tls_version: Any) -> Dict[str, Any] 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(): - tls_version = getattr(server, "minimal_tls_version", None) - if tls_version not in _COMPLIANT_VERSIONS: - findings.append(_finding(azure_client, server, tls_version)) - - return findings + return fail_findings(evaluate(azure_client, subscription_id)) def evaluate(azure_client: Any, subscription_id: str) -> List[RuleEvaluation]: - """Report coverage per server (PASS included), so an inventory that could - not be read is recorded as UNKNOWN instead of reading as a clean scan.""" - servers = azure_client.get_sql_servers() + """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: - # AzureClient.get_sql_servers() returns [] both for a subscription with - # no SQL servers and when the list call itself failed (authorization, - # network, throttling), and scan() cannot tell them apart. UNKNOWN is - # the conservative record: it never counts as a pass in compliance - # scoring, whereas an empty findings list would look compliant. - return [ - RuleEvaluation( - rule_id=RULE_ID, - resource_id=subscription_scope_id(subscription_id), - resource_type="Microsoft.Sql/servers", - status=EvaluationStatus.UNKNOWN, - reason_code="INVENTORY_EMPTY_OR_UNAVAILABLE", - reason=( - "No Azure SQL servers were returned; this is indistinguishable from a failed " - "list call (authorization, network or throttling), so coverage is unknown." - ), - ) - ] + 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="Microsoft.Sql/servers", + resource_type=RESOURCE_TYPE, status=EvaluationStatus.PASS, + evidence=evidence, ) ) else: @@ -104,8 +95,9 @@ def evaluate(azure_client: Any, subscription_id: str) -> List[RuleEvaluation]: RuleEvaluation( rule_id=RULE_ID, resource_id=server.id, - resource_type="Microsoft.Sql/servers", + resource_type=RESOURCE_TYPE, status=EvaluationStatus.FAIL, + evidence=evidence, finding=_finding(azure_client, server, tls_version), ) ) 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 7022803c..4ef38a56 100644 --- a/tests/test_rules_database.py +++ b/tests/test_rules_database.py @@ -337,13 +337,28 @@ def test_db_008_evaluate_unset_tls_is_fail_not_skipped(mock_azure, subscription_ assert [e.status for e in evaluations] == [EvaluationStatus.FAIL] -def test_db_008_failed_or_empty_inventory_is_unknown_not_clean(mock_azure, subscription_id): - """get_sql_servers() returns [] on an API failure as well as for a genuinely empty - subscription. scan() alone would look like a clean result either way, so evaluate() - must record UNKNOWN (never a PASS) for that case.""" +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([]) - evaluations = az_db_008.evaluate(mock_azure, subscription_id) - assert len(evaluations) == 1 - assert evaluations[0].status == EvaluationStatus.UNKNOWN - assert evaluations[0].reason_code == "INVENTORY_EMPTY_OR_UNAVAILABLE" - assert evaluations[0].resource_id == f"/subscriptions/{subscription_id}" + 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"}, + }