From 30e7d749424c825633905ec2cf6a704029b9652d Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Tue, 15 Sep 2026 01:16:53 +0100 Subject: [PATCH 1/3] 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 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..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 931d027a13ae176d6f8b283b4a15abca7225cd53 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Sat, 3 Oct 2026 10:16:30 +0100 Subject: [PATCH 2/3] 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 e63825aa3747fd223e16eea5d6fdef3c4595cea2 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Sun, 4 Oct 2026 11:41:13 +0100 Subject: [PATCH 3/3] 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}"