From 56ab538e812a0b4d94315bcb0d02013456ea5612 Mon Sep 17 00:00:00 2001 From: ritiksah141 Date: Mon, 10 Aug 2026 23:49:23 +0100 Subject: [PATCH 1/3] feat(network): add data link assurance (#241) Signed-off-by: ritiksah141 --- CHANGELOG.md | 1 + api/routes/assurance.py | 14 ++ api/services/assurance_catalog.py | 73 ++++++++ api/services/data_link_assurance.py | 153 ++++++++++++++++ api/services/physical_assurance.py | 65 +------ compliance/assurance/data_link_layer.json | 45 +++++ .../frameworks/cis_azure_benchmark.json | 10 + compliance/frameworks/iso27001.json | 10 + compliance/frameworks/nist_csf.json | 10 + compliance/frameworks/soc2.json | 10 + docs/data-link-layer-assurance.md | 17 ++ playbooks/cli/fix_az_net_016.sh | 31 ++++ playbooks/cli/fix_az_net_017.sh | 27 +++ scanner/azure_client.py | 14 ++ scanner/rules/_data_link_common.py | 38 ++++ scanner/rules/az_net_016.py | 48 +++++ scanner/rules/az_net_017.py | 58 ++++++ tests/helpers/mock_azure.py | 9 + tests/test_data_link_assurance.py | 173 ++++++++++++++++++ 19 files changed, 750 insertions(+), 56 deletions(-) create mode 100644 api/services/assurance_catalog.py create mode 100644 api/services/data_link_assurance.py create mode 100644 compliance/assurance/data_link_layer.json create mode 100644 docs/data-link-layer-assurance.md create mode 100755 playbooks/cli/fix_az_net_016.sh create mode 100755 playbooks/cli/fix_az_net_017.sh create mode 100644 scanner/rules/_data_link_common.py create mode 100644 scanner/rules/az_net_016.py create mode 100644 scanner/rules/az_net_017.py create mode 100644 tests/test_data_link_assurance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3e1d4..4d3bd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Azure Data Link Layer Assurance API with LLC and MAC coverage plus ExpressRoute Direct MACsec checks - Azure public-cloud Physical Layer Assurance API with complete OSI and IEEE PHY domain, sublayer, and provider-evidence coverage - Semgrep SAST integrated into GitHub Actions CI as an open-source, account-free complement to CodeQL - OpenSSF Best Practices Passing Badge achieved with 100% of applicable Passing-level criteria completed diff --git a/api/routes/assurance.py b/api/routes/assurance.py index 285c7c1..5fe326c 100644 --- a/api/routes/assurance.py +++ b/api/routes/assurance.py @@ -5,6 +5,7 @@ from flask import Blueprint, jsonify from api.services.physical_assurance import CatalogValidationError, get_physical_assurance_report +from api.services.data_link_assurance import get_data_link_assurance_report assurance_bp = Blueprint("assurance", __name__) @@ -22,3 +23,16 @@ def get_physical_layer_assurance(): except Exception as exc: logger.error("Failed to build physical assurance report: %s", exc) return jsonify({"error": "Physical assurance report generation failed"}), 500 + + +@assurance_bp.get("/api/assurance/data-link-layer") +def get_data_link_layer_assurance(): + """Return Azure public-cloud OSI Layer 2 responsibility and evidence coverage.""" + try: + return jsonify(get_data_link_assurance_report()) + except CatalogValidationError as exc: + logger.error("Data Link assurance catalog validation failed: %s", exc) + return jsonify({"error": "Data Link assurance catalog is unavailable"}), 500 + except Exception as exc: + logger.error("Failed to build Data Link assurance report: %s", exc) + return jsonify({"error": "Data Link assurance report generation failed"}), 500 diff --git a/api/services/assurance_catalog.py b/api/services/assurance_catalog.py new file mode 100644 index 0000000..723a42d --- /dev/null +++ b/api/services/assurance_catalog.py @@ -0,0 +1,73 @@ +"""Shared validation primitives for closed assurance catalogs.""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from urllib.parse import urlparse + + +class CatalogValidationError(ValueError): + """Raised when a bundled assurance catalog is incomplete or unsafe.""" + + +def require_string(item: dict[str, Any], field: str, context: str) -> str: + """Return a required non-empty string field.""" + value = item.get(field) + if not isinstance(value, str) or not value.strip(): + raise CatalogValidationError(f"{context}: {field} must be a non-empty string") + return value + + +def require_unique(items: Any, name: str) -> dict[str, dict[str, Any]]: + """Index a required object list by unique string ID.""" + if not isinstance(items, list): + raise CatalogValidationError(f"{name} must be a list") + indexed: dict[str, dict[str, Any]] = {} + for item in items: + if not isinstance(item, dict): + raise CatalogValidationError(f"{name}: every entry must be an object") + item_id = require_string(item, "id", name) + if item_id in indexed: + raise CatalogValidationError(f"{name}: duplicate id {item_id}") + indexed[item_id] = item + return indexed + + +def require_reference_list(item: dict[str, Any], field: str, allowed_ids: set[str], context: str) -> list[str]: + """Validate a non-empty list of unique cross-references.""" + values = item.get(field) + if not isinstance(values, list) or not values: + raise CatalogValidationError(f"{context}: {field} must be a non-empty list") + if any(not isinstance(value, str) for value in values): + raise CatalogValidationError(f"{context}: {field} must contain only strings") + if len(values) != len(set(values)): + raise CatalogValidationError(f"{context}: {field} contains duplicate references") + unknown = set(values) - allowed_ids + if unknown: + raise CatalogValidationError(f"{context}: {field} contains unknown ids {sorted(unknown)}") + return values + + +def parse_iso_date(value: Any, context: str) -> date: + """Parse a required ISO-8601 calendar date.""" + if not isinstance(value, str): + raise CatalogValidationError(f"{context}: date must be an ISO-8601 string") + try: + return date.fromisoformat(value) + except ValueError as exc: + raise CatalogValidationError(f"{context}: invalid ISO-8601 date {value!r}") from exc + + +def validate_evidence_source(source: dict[str, Any], source_id: str, allowed_hosts: set[str]) -> tuple[date, date]: + """Validate common evidence metadata and return its review dates.""" + require_string(source, "title", source_id) + url = require_string(source, "url", source_id) + parsed_url = urlparse(url) + if parsed_url.scheme != "https" or parsed_url.hostname not in allowed_hosts: + raise CatalogValidationError(f"{source_id}: evidence URL must use HTTPS on an allowed host") + reviewed_at = parse_iso_date(source.get("reviewed_at"), f"{source_id}.reviewed_at") + review_due_at = parse_iso_date(source.get("review_due_at"), f"{source_id}.review_due_at") + if review_due_at <= reviewed_at: + raise CatalogValidationError(f"{source_id}: review_due_at must be after reviewed_at") + return reviewed_at, review_due_at diff --git a/api/services/data_link_assurance.py b/api/services/data_link_assurance.py new file mode 100644 index 0000000..e7072e5 --- /dev/null +++ b/api/services/data_link_assurance.py @@ -0,0 +1,153 @@ +"""Load, validate, and report Azure Data Link assurance coverage.""" + +from __future__ import annotations + +import copy +import json +from datetime import date +from pathlib import Path +from typing import Any + +from api.services.assurance_catalog import ( + CatalogValidationError, + require_reference_list, + require_string, + require_unique, + validate_evidence_source, +) + +CATALOG_PATH = Path(__file__).resolve().parents[2] / "compliance" / "assurance" / "data_link_layer.json" +EXPECTED_DOMAIN_IDS = {f"DL-{number:02d}" for number in range(1, 20)} +EXPECTED_SUBLAYER_IDS = {"LLC", "MAC"} +ALLOWED_RESPONSIBILITIES = {"Microsoft", "Customer", "Shared"} +ALLOWED_APPLICABILITY = {"APPLICABLE", "NOT_APPLICABLE", "UNSUPPORTED"} +ALLOWED_VERIFICATION = { + "PROVIDER_ATTESTED", + "PLATFORM_ENFORCED", + "AUTOMATICALLY_CHECKED", + "MANUALLY_VERIFIABLE", + "UNSUPPORTED", + "NOT_APPLICABLE", +} + + +def validate_catalog(catalog: dict[str, Any]) -> None: + """Fail closed when any Layer 2 domain, sublayer, decision, or cross-reference is missing.""" + if not isinstance(catalog, dict): + raise CatalogValidationError("Catalog root must be an object") + layer = catalog.get("layer") + if not isinstance(layer, dict) or layer.get("number") != 2 or layer.get("name") != "Data Link": + raise CatalogValidationError("Catalog must describe OSI Layer 2 Data Link") + require_string(catalog, "catalog_version", "catalog") + scope = catalog.get("scope") + if not isinstance(scope, dict): + raise CatalogValidationError("scope must be an object") + require_string(scope, "statement", "scope") + require_string(scope, "responsibility_boundary", "scope") + + domains = require_unique(catalog.get("domains"), "domains") + sublayers = require_unique(catalog.get("sublayers"), "sublayers") + evidence = require_unique(catalog.get("evidence_sources"), "evidence_sources") + automated = require_unique(catalog.get("automated_controls"), "automated_controls") + if set(domains) != EXPECTED_DOMAIN_IDS: + raise CatalogValidationError("domains must contain the complete DL-01 through DL-19 set") + if set(sublayers) != EXPECTED_SUBLAYER_IDS: + raise CatalogValidationError("sublayers must contain LLC and MAC") + + for sublayer_id, sublayer in sublayers.items(): + require_string(sublayer, "name", sublayer_id) + require_string(sublayer, "description", sublayer_id) + for evidence_id, source in evidence.items(): + validate_evidence_source(source, evidence_id, {"learn.microsoft.com"}) + + referenced_sublayers: set[str] = set() + referenced_evidence: set[str] = set() + referenced_automated: set[str] = set() + for domain_id, domain in domains.items(): + require_string(domain, "name", domain_id) + require_string(domain, "observability_method", domain_id) + responsibility = require_string(domain, "responsibility_owner", domain_id) + applicability = require_string(domain, "azure_applicability", domain_id) + verification = require_string(domain, "verification", domain_id) + if responsibility not in ALLOWED_RESPONSIBILITIES: + raise CatalogValidationError(f"{domain_id}: invalid responsibility_owner") + if applicability not in ALLOWED_APPLICABILITY: + raise CatalogValidationError(f"{domain_id}: invalid azure_applicability") + if verification not in ALLOWED_VERIFICATION: + raise CatalogValidationError(f"{domain_id}: invalid verification") + referenced_sublayers.update(require_reference_list(domain, "sublayer_ids", set(sublayers), domain_id)) + referenced_evidence.update(require_reference_list(domain, "evidence_source_ids", set(evidence), domain_id)) + control_ids = domain.get("automated_control_ids", []) + if verification == "AUTOMATICALLY_CHECKED": + referenced_automated.update( + require_reference_list(domain, "automated_control_ids", set(automated), domain_id) + ) + elif control_ids: + raise CatalogValidationError(f"{domain_id}: only automatically checked domains may reference rules") + if referenced_sublayers != set(sublayers): + raise CatalogValidationError("LLC and MAC must both be covered") + if referenced_evidence != set(evidence): + raise CatalogValidationError("every evidence source must be referenced") + if referenced_automated != set(automated): + raise CatalogValidationError("every automated control must be cross-referenced") + + for control_id, control in automated.items(): + require_string(control, "name", control_id) + require_string(control, "playbook", control_id) + require_reference_list(control, "domain_ids", set(domains), control_id) + frameworks = control.get("frameworks") + if not isinstance(frameworks, dict) or set(frameworks) != {"CIS", "NIST", "ISO27001", "SOC2"}: + raise CatalogValidationError(f"{control_id}: all required framework mappings must be present") + + +def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: + """Load and validate the bundled catalog.""" + try: + with path.open(encoding="utf-8") as catalog_file: + catalog = json.load(catalog_file) + except (OSError, json.JSONDecodeError) as exc: + raise CatalogValidationError(f"Unable to load Data Link assurance catalog: {exc}") from exc + validate_catalog(catalog) + return catalog + + +def build_report(catalog: dict[str, Any], as_of: date | None = None) -> dict[str, Any]: + """Build static responsibility coverage with independent evidence freshness.""" + validate_catalog(catalog) + report = copy.deepcopy(catalog) + as_of = as_of or date.today() + evidence = {item["id"]: item for item in report["evidence_sources"]} + current = sum(date.fromisoformat(item["review_due_at"]) >= as_of for item in evidence.values()) + for domain in report["domains"]: + domain["evidence"] = [evidence[source_id] for source_id in domain["evidence_source_ids"]] + report["catalog_coverage"] = { + "domains_covered": len(report["domains"]), + "domains_total": 19, + "sublayers_covered": 2, + "sublayers_total": 2, + "percent": 100, + } + report["evidence_freshness"] = { + "current_sources": current, + "total_sources": len(evidence), + "percent": round(current / len(evidence) * 100) if evidence else 0, + "assessed_as_of": as_of.isoformat(), + } + report["provider_assurance_state"] = "DOCUMENTED" + report["platform_enforcement_state"] = "DOCUMENTED_NOT_LIVE_INSPECTED" + report["automated_control_applicability"] = { + "state": "REQUIRES_SUBSCRIPTION_INVENTORY", + "without_expressroute_direct": "NOT_APPLICABLE", + "api_or_permission_failure": "INDETERMINATE", + } + report["limitations"] = [ + "This report is not a live inspection of Microsoft switches, forwarding tables, VLANs, or fabric internals.", + "Provider-owned assurance domains do not create findings or change the tenant security score.", + "Only ExpressRoute Direct management-plane configuration is automatically checked.", + ] + return report + + +def get_data_link_assurance_report(as_of: date | None = None) -> dict[str, Any]: + """Return the bundled Data Link assurance report.""" + return build_report(load_catalog(), as_of=as_of) diff --git a/api/services/physical_assurance.py b/api/services/physical_assurance.py index 4b6a680..b86d662 100644 --- a/api/services/physical_assurance.py +++ b/api/services/physical_assurance.py @@ -7,7 +7,14 @@ from datetime import date from pathlib import Path from typing import Any -from urllib.parse import urlparse + +from api.services.assurance_catalog import ( + CatalogValidationError, + require_reference_list as _require_reference_list, + require_string as _require_string, + require_unique as _require_unique, + validate_evidence_source, +) CATALOG_PATH = Path(__file__).resolve().parents[2] / "compliance" / "assurance" / "physical_layer.json" @@ -32,52 +39,6 @@ ALLOWED_EVIDENCE_HOSTS = {"learn.microsoft.com"} -class CatalogValidationError(ValueError): - """Raised when the bundled assurance catalog is incomplete or unsafe.""" - - -def _require_string(item: dict[str, Any], field: str, context: str) -> str: - value = item.get(field) - if not isinstance(value, str) or not value.strip(): - raise CatalogValidationError(f"{context}: {field} must be a non-empty string") - return value - - -def _require_unique(items: list[dict[str, Any]], name: str) -> dict[str, dict[str, Any]]: - indexed: dict[str, dict[str, Any]] = {} - for item in items: - if not isinstance(item, dict): - raise CatalogValidationError(f"{name}: every entry must be an object") - item_id = _require_string(item, "id", name) - if item_id in indexed: - raise CatalogValidationError(f"{name}: duplicate id {item_id}") - indexed[item_id] = item - return indexed - - -def _require_reference_list(item: dict[str, Any], field: str, allowed_ids: set[str], context: str) -> list[str]: - values = item.get(field) - if not isinstance(values, list) or not values: - raise CatalogValidationError(f"{context}: {field} must be a non-empty list") - if any(not isinstance(value, str) for value in values): - raise CatalogValidationError(f"{context}: {field} must contain only strings") - if len(values) != len(set(values)): - raise CatalogValidationError(f"{context}: {field} contains duplicate references") - unknown = set(values) - allowed_ids - if unknown: - raise CatalogValidationError(f"{context}: {field} contains unknown ids {sorted(unknown)}") - return values - - -def _parse_date(value: Any, context: str) -> date: - if not isinstance(value, str): - raise CatalogValidationError(f"{context}: date must be an ISO-8601 string") - try: - return date.fromisoformat(value) - except ValueError as exc: - raise CatalogValidationError(f"{context}: invalid ISO-8601 date {value!r}") from exc - - def load_catalog(path: Path = CATALOG_PATH) -> dict[str, Any]: """Load and validate a physical assurance catalog from disk.""" try: @@ -153,16 +114,8 @@ def validate_catalog(catalog: dict[str, Any]) -> None: _require_reference_list(sublayer, "domain_ids", domain_ids, sublayer_id) for evidence_id, evidence in evidence_index.items(): - _require_string(evidence, "title", evidence_id) _require_string(evidence, "evidence_type", evidence_id) - url = _require_string(evidence, "url", evidence_id) - parsed_url = urlparse(url) - if parsed_url.scheme != "https" or parsed_url.hostname not in ALLOWED_EVIDENCE_HOSTS: - raise CatalogValidationError(f"{evidence_id}: evidence URL must use HTTPS on an allowed host") - reviewed_at = _parse_date(evidence.get("reviewed_at"), f"{evidence_id}.reviewed_at") - review_due_at = _parse_date(evidence.get("review_due_at"), f"{evidence_id}.review_due_at") - if review_due_at <= reviewed_at: - raise CatalogValidationError(f"{evidence_id}: review_due_at must be after reviewed_at") + validate_evidence_source(evidence, evidence_id, ALLOWED_EVIDENCE_HOSTS) microsoft_controls: set[str] = set() iso_controls: set[str] = set() diff --git a/compliance/assurance/data_link_layer.json b/compliance/assurance/data_link_layer.json new file mode 100644 index 0000000..ad5c114 --- /dev/null +++ b/compliance/assurance/data_link_layer.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "catalog_version": "2026.08", + "layer": {"number": 2, "name": "Data Link", "model": "OSI", "assessment_type": "mixed_assurance"}, + "scope": { + "environment": "azure_public_cloud", + "statement": "Azure tenants cannot inspect Microsoft fabric switching or forwarding internals. ExpressRoute Direct is the customer-visible Ethernet boundary with authoritative management-plane configuration.", + "responsibility_boundary": "Microsoft owns the Azure fabric. Customers own their ExpressRoute Direct port configuration and connected devices." + }, + "sublayers": [ + {"id": "LLC", "name": "Logical Link Control", "description": "Link service adaptation, flow and error semantics, and protocol multiplexing."}, + {"id": "MAC", "name": "Media Access Control", "description": "Framing, addressing, medium access, switching, VLAN, QoS, and link security."} + ], + "evidence_sources": [ + {"id": "AZ-L2-BOUNDARY", "title": "Azure virtual network overview", "url": "https://learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-overview", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"}, + {"id": "AZ-ERD-OVERVIEW", "title": "About ExpressRoute Direct", "url": "https://learn.microsoft.com/en-us/azure/expressroute/expressroute-erdirect-about", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"}, + {"id": "AZ-ERD-MACSEC", "title": "Configure MACsec for ExpressRoute Direct ports", "url": "https://learn.microsoft.com/en-us/azure/expressroute/expressroute-howto-macsec", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"}, + {"id": "AZ-ERD-API", "title": "Express Route Ports REST API", "url": "https://learn.microsoft.com/en-us/rest/api/expressroute/express-route-ports", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"} + ], + "automated_controls": [ + {"id": "AZ-NET-016", "name": "ExpressRoute Direct link uses MACsec", "domain_ids": ["DL-15"], "frameworks": {"CIS": "TBD-NET-016", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_net_016.sh"}, + {"id": "AZ-NET-017", "name": "High-speed ExpressRoute Direct link uses XPN MACsec", "domain_ids": ["DL-07", "DL-15"], "frameworks": {"CIS": "TBD-NET-017", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_net_017.sh"} + ], + "domains": [ + {"id":"DL-01","name":"Frame construction and delimiting","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-02","name":"Source and destination MAC addressing","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"provider documentation and ExpressRoute configuration","evidence_source_ids":["AZ-L2-BOUNDARY","AZ-ERD-OVERVIEW"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-03","name":"Media access control","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-04","name":"Frame check sequence and error detection","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"platform behavior and provider documentation","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"PLATFORM_ENFORCED"}, + {"id":"DL-05","name":"Link-level flow control","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-06","name":"Link establishment and teardown","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute administrative link state","evidence_source_ids":["AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-07","name":"MTU, frame size, and jumbo-frame behavior","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute configuration and documentation","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-08","name":"VLAN tagging, 802.1Q, and QinQ encapsulation","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute encapsulation configuration","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-09","name":"Switching, bridging, filtering, and forwarding tables","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider assurance only","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-10","name":"Loop prevention and spanning-tree behavior","sublayer_ids":["MAC"],"azure_applicability":"NOT_APPLICABLE","responsibility_owner":"Microsoft","observability_method":"not exposed to Azure tenants","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"NOT_APPLICABLE"}, + {"id":"DL-11","name":"Link aggregation and LACP","sublayer_ids":["MAC"],"azure_applicability":"UNSUPPORTED","responsibility_owner":"Shared","observability_method":"connected-device review","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"UNSUPPORTED"}, + {"id":"DL-12","name":"Neighbor and link discovery such as LLDP","sublayer_ids":["LLC","MAC"],"azure_applicability":"UNSUPPORTED","responsibility_owner":"Shared","observability_method":"connected-device review","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"UNSUPPORTED"}, + {"id":"DL-13","name":"ARP and neighbor-discovery boundary protection","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"provider assurance and connected-device review","evidence_source_ids":["AZ-L2-BOUNDARY","AZ-ERD-OVERVIEW"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-14","name":"Broadcast and multicast handling","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PLATFORM_ENFORCED"}, + {"id":"DL-15","name":"MACsec and port-access security","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Customer","observability_method":"ExpressRoute Direct management API","evidence_source_ids":["AZ-ERD-MACSEC","AZ-ERD-API"],"verification":"AUTOMATICALLY_CHECKED","automated_control_ids":["AZ-NET-016","AZ-NET-017"]}, + {"id":"DL-16","name":"Layer 2 quality of service and priority handling","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"PROVIDER_ATTESTED"}, + {"id":"DL-17","name":"Operations, administration, monitoring, and packet visibility","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"Azure metrics and connected-device telemetry","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, + {"id":"DL-18","name":"Virtual switching, SR-IOV, and overlay adaptation","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"platform documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PLATFORM_ENFORCED"}, + {"id":"DL-19","name":"Link redundancy and failover","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"ExpressRoute Direct link inventory and connected-device review","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"} + ] +} diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index d6a5ec4..f8a71a9 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -332,6 +332,16 @@ "control_id": "TBD-SC-008", "control_name": "Pipeline Service Connection Uses Password Instead of Federated Credential placeholder", "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping." + }, + "AZ-NET-016": { + "control_id": "TBD-NET-016", + "control_name": "ExpressRoute Direct MACsec placeholder", + "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping." + }, + "AZ-NET-017": { + "control_id": "TBD-NET-017", + "control_name": "ExpressRoute Direct XPN MACsec placeholder", + "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 9cfb9fa..5ffd504 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -332,6 +332,16 @@ "control_id": "A.9.4.3", "control_name": "Password management system", "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." + }, + "AZ-NET-016": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "MACsec protects traffic on the customer-visible ExpressRoute Direct Ethernet boundary." + }, + "AZ-NET-017": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "XPN MACsec provides appropriate packet-number capacity for high-speed ExpressRoute Direct links." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 20a6806..34aeb9d 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -332,6 +332,16 @@ "control_id": "PR.AC-1", "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." + }, + "AZ-NET-016": { + "control_id": "PR.DS-2", + "control_name": "Data in transit is protected", + "description": "MACsec protects traffic on the customer-visible ExpressRoute Direct Ethernet boundary." + }, + "AZ-NET-017": { + "control_id": "PR.DS-2", + "control_name": "Data in transit is protected", + "description": "XPN MACsec avoids packet-number exhaustion risk on high-speed ExpressRoute Direct links." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 2e8af6f..631baca 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -332,6 +332,16 @@ "control_id": "CC6.1", "control_name": "Logical Access Security Measures", "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." + }, + "AZ-NET-016": { + "control_id": "CC6.7", + "control_name": "Restricts Transmission and Movement of Information", + "description": "MACsec protects traffic crossing the customer-visible ExpressRoute Direct Ethernet boundary." + }, + "AZ-NET-017": { + "control_id": "CC6.7", + "control_name": "Restricts Transmission and Movement of Information", + "description": "XPN MACsec provides suitable packet-number capacity for high-speed protected links." } } } diff --git a/docs/data-link-layer-assurance.md b/docs/data-link-layer-assurance.md new file mode 100644 index 0000000..5d004d5 --- /dev/null +++ b/docs/data-link-layer-assurance.md @@ -0,0 +1,17 @@ +# Azure Data Link Layer Assurance + +OpenShield treats Azure OSI Layer 2 as a shared responsibility boundary. Microsoft owns the virtual switching fabric, forwarding tables, broadcast behavior, and tenant-isolation internals. Azure customers cannot inspect those systems, so OpenShield records provider assurance and platform enforcement without creating findings or changing the tenant security score. + +ExpressRoute Direct is different because Azure exposes customer-controlled Ethernet link configuration through the management API. OpenShield checks enabled links for MACsec and checks ports of 40 Gbps or greater for an XPN MACsec cipher. A subscription with no ExpressRoute Direct ports is not applicable. An Azure API or permission failure is indeterminate and never creates a finding. + +## Coverage + +The closed catalog covers both IEEE 802 Data Link sublayers, LLC and MAC, and all 19 functional domains required by issue #241. Each domain records Azure applicability, responsibility, observability, evidence, and one of the supported verification states. + +`GET /api/assurance/data-link-layer` requires JWT authentication. It returns the layer and scope, responsibility boundary, domain and sublayer coverage, provider and platform states, automated-control applicability, evidence review dates, source links, and explicit limitations. Catalog coverage and evidence freshness are separate measurements. + +The endpoint does not claim live access to Microsoft switches, VLANs, forwarding tables, or fabric internals. It requires no paid OpenShield service or external runtime API. + +## Secret handling + +The checks test only whether a MACsec configuration exists and which cipher is selected. They never read, store, log, or return CAK or CKN secret values. Findings contain only the port identity, link name, bandwidth, and non-secret cipher name. diff --git a/playbooks/cli/fix_az_net_016.sh b/playbooks/cli/fix_az_net_016.sh new file mode 100755 index 0000000..5beb09c --- /dev/null +++ b/playbooks/cli/fix_az_net_016.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +RESOURCE_GROUP=${1:-} +PORT_NAME=${2:-} +LINK_NAME=${3:-} +CAK_SECRET_ID=${4:-} +CKN_SECRET_ID=${5:-} + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$PORT_NAME" ] || [ -z "$LINK_NAME" ] || [ -z "$CAK_SECRET_ID" ] || [ -z "$CKN_SECRET_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Review the ExpressRoute Direct peer configuration before continuing." +echo "This change can interrupt connectivity if both ends are not updated in the same maintenance window." +read -r -p "Type APPLY to enable XPN MACsec: " CONFIRMATION +if [ "$CONFIRMATION" != "APPLY" ]; then + echo "No change made." + exit 0 +fi + +az network express-route port link update \ + --resource-group "$RESOURCE_GROUP" \ + --port-name "$PORT_NAME" \ + --name "$LINK_NAME" \ + --macsec-cipher GcmAesXpn256 \ + --macsec-cak-secret-identifier "$CAK_SECRET_ID" \ + --macsec-ckn-secret-identifier "$CKN_SECRET_ID" + +echo "MACsec update submitted. Verify both links and traffic before closing the maintenance window." diff --git a/playbooks/cli/fix_az_net_017.sh b/playbooks/cli/fix_az_net_017.sh new file mode 100755 index 0000000..1bc3769 --- /dev/null +++ b/playbooks/cli/fix_az_net_017.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +RESOURCE_GROUP=${1:-} +PORT_NAME=${2:-} +LINK_NAME=${3:-} + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$PORT_NAME" ] || [ -z "$LINK_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Confirm the connected router supports GcmAesXpn256 and arrange a maintenance window." +echo "Key references remain unchanged. This script never reads or prints CAK or CKN secret values." +read -r -p "Type APPLY to change the configured cipher: " CONFIRMATION +if [ "$CONFIRMATION" != "APPLY" ]; then + echo "No change made." + exit 0 +fi + +az network express-route port link update \ + --resource-group "$RESOURCE_GROUP" \ + --port-name "$PORT_NAME" \ + --name "$LINK_NAME" \ + --macsec-cipher GcmAesXpn256 + +echo "Cipher update submitted. Verify link counters and traffic before closing the maintenance window." diff --git a/scanner/azure_client.py b/scanner/azure_client.py index bb6f598..6505612 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -243,6 +243,20 @@ def get_network_security_groups(self) -> List[Any]: logger.error("get_network_security_groups failed: %s", exc) return [] + def get_express_route_ports(self) -> Optional[List[Any]]: + """List ExpressRoute Direct ports without collapsing API failures. + + An empty list means the subscription has no ExpressRoute Direct ports. + ``None`` means Azure could not be queried, so callers must preserve an + indeterminate result and avoid creating findings. + """ + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + return list(client.express_route_ports.list()) + except Exception as exc: + logger.error("get_express_route_ports failed: %s", exc) + return None + def get_network_interface(self, resource_group: str, nic_name: str) -> Optional[Any]: """Fetch a single NIC by resource group and name.""" try: diff --git a/scanner/rules/_data_link_common.py b/scanner/rules/_data_link_common.py new file mode 100644 index 0000000..96b17d2 --- /dev/null +++ b/scanner/rules/_data_link_common.py @@ -0,0 +1,38 @@ +"""Secret-safe helpers for ExpressRoute Direct Data Link checks.""" + +from typing import Any, Iterator, Tuple + + +def value(item: Any, field: str, default: Any = None) -> Any: + """Read an SDK model or test dictionary field.""" + if isinstance(item, dict): + return item.get(field, default) + return getattr(item, field, default) + + +def enabled_links(port: Any) -> Iterator[Tuple[Any, Any]]: + """Yield enabled links with their parent port.""" + for link in value(port, "links", []) or []: + state = str(getattr(value(link, "admin_state", ""), "value", value(link, "admin_state", ""))) + if state.lower() == "enabled": + yield port, link + + +def has_macsec(link: Any) -> bool: + """Return whether a link has a MACsec configuration without reading secrets.""" + return value(link, "macsec_config") is not None + + +def cipher_name(link: Any) -> str: + """Return only the non-secret MACsec cipher identifier.""" + config = value(link, "macsec_config") + cipher = value(config, "cipher", "") if config is not None else "" + return str(getattr(cipher, "value", cipher)) + + +def resource_identity(port: Any, link: Any) -> tuple[str, str, str]: + """Build non-secret resource identity fields for a finding.""" + port_id = str(value(port, "id", "")) + port_name = str(value(port, "name", "ExpressRoute Direct port")) + link_name = str(value(link, "name", value(link, "interface_name", "link"))) + return port_id, port_name, link_name diff --git a/scanner/rules/az_net_016.py b/scanner/rules/az_net_016.py new file mode 100644 index 0000000..667323f --- /dev/null +++ b/scanner/rules/az_net_016.py @@ -0,0 +1,48 @@ +"""AZ-NET-016: Enabled ExpressRoute Direct link has no MACsec configuration.""" + +from typing import Any, Dict, List + +from scanner.rules._data_link_common import enabled_links, has_macsec, resource_identity + +RULE_ID = "AZ-NET-016" +RULE_NAME = "ExpressRoute Direct Link Does Not Use MACsec" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "TBD-NET-016", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"} +DESCRIPTION = "An enabled ExpressRoute Direct Ethernet link does not have a MACsec configuration." +REMEDIATION = ( + "Plan a maintenance window, store CAK and CKN values in Azure Key Vault, then enable MACsec on both " + "ends of the ExpressRoute Direct link. Confirm connectivity before retiring the previous configuration." +) +PLAYBOOK = "playbooks/cli/fix_az_net_016.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Report enabled links without MACsec and preserve API failures as indeterminate.""" + ports = azure_client.get_express_route_ports() + if ports is None: + return [] + + findings: List[Dict[str, Any]] = [] + for port in ports: + for _, link in enabled_links(port): + if has_macsec(link): + continue + resource_id, resource_name, link_name = resource_identity(port, link) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": resource_name, + "resource_type": "Microsoft.Network/expressRoutePorts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"link_name": link_name}, + } + ) + return findings diff --git a/scanner/rules/az_net_017.py b/scanner/rules/az_net_017.py new file mode 100644 index 0000000..5f7ca70 --- /dev/null +++ b/scanner/rules/az_net_017.py @@ -0,0 +1,58 @@ +"""AZ-NET-017: High-speed ExpressRoute Direct link uses a non-XPN MACsec cipher.""" + +from typing import Any, Dict, List + +from scanner.rules._data_link_common import cipher_name, enabled_links, has_macsec, resource_identity, value + +RULE_ID = "AZ-NET-017" +RULE_NAME = "High-Speed ExpressRoute Direct Link Uses Non-XPN MACsec" +SEVERITY = "MEDIUM" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "TBD-NET-017", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"} +DESCRIPTION = "An enabled ExpressRoute Direct port of 40 Gbps or greater uses a MACsec cipher without XPN." +REMEDIATION = ( + "Confirm both peer devices support an XPN cipher, schedule a maintenance window, update the MACsec " + "cipher on both ends, and verify link counters and traffic before completing the change." +) +PLAYBOOK = "playbooks/cli/fix_az_net_017.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Report high-speed enabled links whose configured MACsec cipher is not XPN.""" + ports = azure_client.get_express_route_ports() + if ports is None: + return [] + + findings: List[Dict[str, Any]] = [] + for port in ports: + bandwidth = int(value(port, "bandwidth_in_gbps", 0) or 0) + if bandwidth < 40: + continue + for _, link in enabled_links(port): + if not has_macsec(link): + continue + cipher = cipher_name(link) + if "xpn" in cipher.lower(): + continue + resource_id, resource_name, link_name = resource_identity(port, link) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": resource_name, + "resource_type": "Microsoft.Network/expressRoutePorts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "bandwidth_in_gbps": bandwidth, + "cipher": cipher or "unreported", + "link_name": link_name, + }, + } + ) + return findings diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 4091a28..f89908d 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -48,6 +48,7 @@ class MockAzureClient: def __init__(self) -> None: self._storage_accounts: List[Any] = [] self._network_security_groups: List[Any] = [] + self._express_route_ports: Optional[List[Any]] = [] self._virtual_machines: List[Any] = [] self._key_vaults: List[Any] = [] self._sql_servers: List[Any] = [] @@ -99,6 +100,14 @@ def set_storage_accounts(self, accounts: List[Any]) -> "MockAzureClient": self._storage_accounts = accounts return self + def set_express_route_ports(self, ports: Optional[List[Any]]) -> "MockAzureClient": + """Configure ExpressRoute Direct inventory; ``None`` represents an API failure.""" + self._express_route_ports = ports + return self + + def get_express_route_ports(self) -> Optional[List[Any]]: + return self._express_route_ports + def set_managed_clusters(self, clusters: Optional[List[Any]]) -> "MockAzureClient": """Configure AKS inventory; ``None`` represents an API failure.""" self._managed_clusters = clusters diff --git a/tests/test_data_link_assurance.py b/tests/test_data_link_assurance.py new file mode 100644 index 0000000..fe22560 --- /dev/null +++ b/tests/test_data_link_assurance.py @@ -0,0 +1,173 @@ +"""Completeness, API, and ExpressRoute MACsec regression tests.""" + +import copy +import json +from datetime import date +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from api.services.data_link_assurance import ( + EXPECTED_DOMAIN_IDS, + EXPECTED_SUBLAYER_IDS, + build_report, + load_catalog, + validate_catalog, +) +from api.services.physical_assurance import CatalogValidationError +from scanner.rules import az_net_016, az_net_017 + + +def _link( + *, + state: str = "Enabled", + cipher: str | None = "GcmAesXpn256", + cak: str = "cak-sensitive-value", + ckn: str = "ckn-sensitive-value", +) -> SimpleNamespace: + config = None + if cipher is not None: + config = SimpleNamespace(cipher=cipher, cak_secret_identifier=cak, ckn_secret_identifier=ckn) + return SimpleNamespace(name="link1", admin_state=state, macsec_config=config) + + +def _port(link: SimpleNamespace, bandwidth: int = 100) -> SimpleNamespace: + return SimpleNamespace( + id="/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/expressRoutePorts/erd1", + name="erd1", + bandwidth_in_gbps=bandwidth, + links=[link], + ) + + +def test_catalog_covers_both_sublayers_and_all_domains(): + catalog = load_catalog() + assert {item["id"] for item in catalog["domains"]} == EXPECTED_DOMAIN_IDS + assert {item["id"] for item in catalog["sublayers"]} == EXPECTED_SUBLAYER_IDS + assert all(domain["responsibility_owner"] for domain in catalog["domains"]) + assert all(domain["azure_applicability"] for domain in catalog["domains"]) + assert all(domain["observability_method"] for domain in catalog["domains"]) + assert all(domain["evidence_source_ids"] for domain in catalog["domains"]) + + +@pytest.mark.parametrize( + ("mutation", "error_match"), + [ + (lambda catalog: catalog["domains"].pop(), "complete DL-01 through DL-19"), + (lambda catalog: catalog["sublayers"].pop(), "LLC and MAC"), + (lambda catalog: catalog["domains"][0].pop("responsibility_owner"), "responsibility_owner"), + (lambda catalog: catalog["domains"][0].pop("azure_applicability"), "azure_applicability"), + (lambda catalog: catalog["domains"][0].pop("evidence_source_ids"), "evidence_source_ids"), + (lambda catalog: catalog["domains"][14].pop("automated_control_ids"), "automated_control_ids"), + ], +) +def test_catalog_fails_closed_on_missing_required_content(mutation, error_match): + catalog = copy.deepcopy(load_catalog()) + mutation(catalog) + with pytest.raises(CatalogValidationError, match=error_match): + validate_catalog(catalog) + + +def test_report_separates_catalog_coverage_from_evidence_freshness(): + report = build_report(load_catalog(), as_of=date(2028, 1, 1)) + assert report["catalog_coverage"] == { + "domains_covered": 19, + "domains_total": 19, + "percent": 100, + "sublayers_covered": 2, + "sublayers_total": 2, + } + assert report["evidence_freshness"]["percent"] == 0 + assert report["provider_assurance_state"] == "DOCUMENTED" + assert report["platform_enforcement_state"] == "DOCUMENTED_NOT_LIVE_INSPECTED" + + +def test_data_link_endpoint_requires_authentication(client): + assert client.get("/api/assurance/data-link-layer").status_code == 401 + + +def test_data_link_endpoint_returns_complete_report(client, auth_headers): + response = client.get("/api/assurance/data-link-layer", headers=auth_headers) + assert response.status_code == 200 + payload = response.get_json() + assert payload["layer"]["number"] == 2 + assert payload["layer"]["name"] == "Data Link" + assert payload["catalog_coverage"]["percent"] == 100 + assert len(payload["domains"]) == 19 + assert {item["id"] for item in payload["sublayers"]} == {"LLC", "MAC"} + assert payload["automated_control_applicability"]["api_or_permission_failure"] == "INDETERMINATE" + + +def test_data_link_endpoint_hides_catalog_errors(client, auth_headers): + with patch( + "api.routes.assurance.get_data_link_assurance_report", + side_effect=CatalogValidationError("sensitive path"), + ): + response = client.get("/api/assurance/data-link-layer", headers=auth_headers) + assert response.status_code == 500 + assert response.get_json() == {"error": "Data Link assurance catalog is unavailable"} + assert "sensitive path" not in response.get_data(as_text=True) + + +@pytest.mark.parametrize("inventory", [[], None]) +def test_empty_or_failed_inventory_creates_no_findings(mock_azure, subscription_id, inventory): + mock_azure.set_express_route_ports(inventory) + assert az_net_016.scan(mock_azure, subscription_id) == [] + assert az_net_017.scan(mock_azure, subscription_id) == [] + + +def test_enabled_link_without_macsec_creates_only_absence_finding(mock_azure, subscription_id): + mock_azure.set_express_route_ports([_port(_link(cipher=None))]) + findings = az_net_016.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-NET-016" + assert az_net_017.scan(mock_azure, subscription_id) == [] + + +def test_high_speed_non_xpn_cipher_creates_finding(mock_azure, subscription_id): + mock_azure.set_express_route_ports([_port(_link(cipher="GcmAes256"), bandwidth=100)]) + findings = az_net_017.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["cipher"] == "GcmAes256" + + +@pytest.mark.parametrize( + ("bandwidth", "cipher"), + [(10, "GcmAes256"), (40, "GcmAesXpn128"), (100, "GcmAesXpn256")], +) +def test_low_speed_or_xpn_links_do_not_create_cipher_findings(mock_azure, subscription_id, bandwidth, cipher): + mock_azure.set_express_route_ports([_port(_link(cipher=cipher), bandwidth=bandwidth)]) + assert az_net_017.scan(mock_azure, subscription_id) == [] + + +def test_disabled_links_are_not_customer_actionable(mock_azure, subscription_id): + mock_azure.set_express_route_ports([_port(_link(state="Disabled", cipher=None))]) + assert az_net_016.scan(mock_azure, subscription_id) == [] + assert az_net_017.scan(mock_azure, subscription_id) == [] + + +def test_findings_never_expose_cak_or_ckn(mock_azure, subscription_id): + mock_azure.set_express_route_ports([_port(_link(cipher="GcmAes256"))]) + serialized = json.dumps(az_net_017.scan(mock_azure, subscription_id)) + assert "cak-sensitive-value" not in serialized + assert "ckn-sensitive-value" not in serialized + assert "secret_identifier" not in serialized + + +def test_express_route_inventory_uses_azure_client_abstraction(): + from scanner.azure_client import AzureClient + + sdk_client = MagicMock() + sdk_client.express_route_ports.list.return_value = [SimpleNamespace(name="erd1")] + with patch("scanner.azure_client.NetworkManagementClient", return_value=sdk_client): + client = AzureClient("sub-1", credential=MagicMock()) + assert [port.name for port in client.get_express_route_ports()] == ["erd1"] + + +def test_express_route_inventory_preserves_api_failure(): + from scanner.azure_client import AzureClient + + with patch("scanner.azure_client.NetworkManagementClient", side_effect=PermissionError("denied")): + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_express_route_ports() is None From 8cfd2446e1ea1f8b409141a520003c95a7829017 Mon Sep 17 00:00:00 2001 From: ritiksah141 Date: Tue, 11 Aug 2026 00:09:27 +0100 Subject: [PATCH 2/3] fix(network): use Data Link rule identifiers (#241) Signed-off-by: ritiksah141 --- compliance/assurance/data_link_layer.json | 6 ++--- .../frameworks/cis_azure_benchmark.json | 8 +++--- compliance/frameworks/iso27001.json | 4 +-- compliance/frameworks/nist_csf.json | 4 +-- compliance/frameworks/soc2.json | 4 +-- docs/data-link-layer-assurance.md | 2 ++ .../{fix_az_net_016.sh => fix_az_dl_001.sh} | 0 .../{fix_az_net_017.sh => fix_az_dl_002.sh} | 0 scanner/rules/{az_net_016.py => az_dl_001.py} | 10 +++---- scanner/rules/{az_net_017.py => az_dl_002.py} | 10 +++---- tests/test_data_link_assurance.py | 27 +++++++++++-------- 11 files changed, 41 insertions(+), 34 deletions(-) rename playbooks/cli/{fix_az_net_016.sh => fix_az_dl_001.sh} (100%) rename playbooks/cli/{fix_az_net_017.sh => fix_az_dl_002.sh} (100%) rename scanner/rules/{az_net_016.py => az_dl_001.py} (86%) rename scanner/rules/{az_net_017.py => az_dl_002.py} (88%) diff --git a/compliance/assurance/data_link_layer.json b/compliance/assurance/data_link_layer.json index ad5c114..db9ee02 100644 --- a/compliance/assurance/data_link_layer.json +++ b/compliance/assurance/data_link_layer.json @@ -18,8 +18,8 @@ {"id": "AZ-ERD-API", "title": "Express Route Ports REST API", "url": "https://learn.microsoft.com/en-us/rest/api/expressroute/express-route-ports", "reviewed_at": "2026-08-10", "review_due_at": "2027-08-10"} ], "automated_controls": [ - {"id": "AZ-NET-016", "name": "ExpressRoute Direct link uses MACsec", "domain_ids": ["DL-15"], "frameworks": {"CIS": "TBD-NET-016", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_net_016.sh"}, - {"id": "AZ-NET-017", "name": "High-speed ExpressRoute Direct link uses XPN MACsec", "domain_ids": ["DL-07", "DL-15"], "frameworks": {"CIS": "TBD-NET-017", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_net_017.sh"} + {"id": "AZ-DL-001", "name": "ExpressRoute Direct link uses MACsec", "domain_ids": ["DL-15"], "frameworks": {"CIS": "TBD-DL-001", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_dl_001.sh"}, + {"id": "AZ-DL-002", "name": "High-speed ExpressRoute Direct link uses XPN MACsec", "domain_ids": ["DL-07", "DL-15"], "frameworks": {"CIS": "TBD-DL-002", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"}, "playbook": "playbooks/cli/fix_az_dl_002.sh"} ], "domains": [ {"id":"DL-01","name":"Frame construction and delimiting","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PROVIDER_ATTESTED"}, @@ -36,7 +36,7 @@ {"id":"DL-12","name":"Neighbor and link discovery such as LLDP","sublayer_ids":["LLC","MAC"],"azure_applicability":"UNSUPPORTED","responsibility_owner":"Shared","observability_method":"connected-device review","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"UNSUPPORTED"}, {"id":"DL-13","name":"ARP and neighbor-discovery boundary protection","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"provider assurance and connected-device review","evidence_source_ids":["AZ-L2-BOUNDARY","AZ-ERD-OVERVIEW"],"verification":"MANUALLY_VERIFIABLE"}, {"id":"DL-14","name":"Broadcast and multicast handling","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PLATFORM_ENFORCED"}, - {"id":"DL-15","name":"MACsec and port-access security","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Customer","observability_method":"ExpressRoute Direct management API","evidence_source_ids":["AZ-ERD-MACSEC","AZ-ERD-API"],"verification":"AUTOMATICALLY_CHECKED","automated_control_ids":["AZ-NET-016","AZ-NET-017"]}, + {"id":"DL-15","name":"MACsec and port-access security","sublayer_ids":["MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Customer","observability_method":"ExpressRoute Direct management API","evidence_source_ids":["AZ-ERD-MACSEC","AZ-ERD-API"],"verification":"AUTOMATICALLY_CHECKED","automated_control_ids":["AZ-DL-001","AZ-DL-002"]}, {"id":"DL-16","name":"Layer 2 quality of service and priority handling","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"provider documentation","evidence_source_ids":["AZ-ERD-OVERVIEW"],"verification":"PROVIDER_ATTESTED"}, {"id":"DL-17","name":"Operations, administration, monitoring, and packet visibility","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Shared","observability_method":"Azure metrics and connected-device telemetry","evidence_source_ids":["AZ-ERD-OVERVIEW","AZ-ERD-API"],"verification":"MANUALLY_VERIFIABLE"}, {"id":"DL-18","name":"Virtual switching, SR-IOV, and overlay adaptation","sublayer_ids":["LLC","MAC"],"azure_applicability":"APPLICABLE","responsibility_owner":"Microsoft","observability_method":"platform documentation","evidence_source_ids":["AZ-L2-BOUNDARY"],"verification":"PLATFORM_ENFORCED"}, diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index f8a71a9..cc7428e 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -333,13 +333,13 @@ "control_name": "Pipeline Service Connection Uses Password Instead of Federated Credential placeholder", "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping." }, - "AZ-NET-016": { - "control_id": "TBD-NET-016", + "AZ-DL-001": { + "control_id": "TBD-DL-001", "control_name": "ExpressRoute Direct MACsec placeholder", "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping." }, - "AZ-NET-017": { - "control_id": "TBD-NET-017", + "AZ-DL-002": { + "control_id": "TBD-DL-002", "control_name": "ExpressRoute Direct XPN MACsec placeholder", "description": "Numbered placeholder pending maintainer approval of a direct CIS mapping." } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 5ffd504..c33865c 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -333,12 +333,12 @@ "control_name": "Password management system", "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." }, - "AZ-NET-016": { + "AZ-DL-001": { "control_id": "A.13.1.1", "control_name": "Network controls", "description": "MACsec protects traffic on the customer-visible ExpressRoute Direct Ethernet boundary." }, - "AZ-NET-017": { + "AZ-DL-002": { "control_id": "A.13.1.1", "control_name": "Network controls", "description": "XPN MACsec provides appropriate packet-number capacity for high-speed ExpressRoute Direct links." diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 34aeb9d..148a30f 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -333,12 +333,12 @@ "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." }, - "AZ-NET-016": { + "AZ-DL-001": { "control_id": "PR.DS-2", "control_name": "Data in transit is protected", "description": "MACsec protects traffic on the customer-visible ExpressRoute Direct Ethernet boundary." }, - "AZ-NET-017": { + "AZ-DL-002": { "control_id": "PR.DS-2", "control_name": "Data in transit is protected", "description": "XPN MACsec avoids packet-number exhaustion risk on high-speed ExpressRoute Direct links." diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 631baca..c4a625d 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -333,12 +333,12 @@ "control_name": "Logical Access Security Measures", "description": "A pipeline service connection authenticates with a stored service principal secret instead of a federated credential, leaving a static credential to rotate and potentially leak." }, - "AZ-NET-016": { + "AZ-DL-001": { "control_id": "CC6.7", "control_name": "Restricts Transmission and Movement of Information", "description": "MACsec protects traffic crossing the customer-visible ExpressRoute Direct Ethernet boundary." }, - "AZ-NET-017": { + "AZ-DL-002": { "control_id": "CC6.7", "control_name": "Restricts Transmission and Movement of Information", "description": "XPN MACsec provides suitable packet-number capacity for high-speed protected links." diff --git a/docs/data-link-layer-assurance.md b/docs/data-link-layer-assurance.md index 5d004d5..562c7f2 100644 --- a/docs/data-link-layer-assurance.md +++ b/docs/data-link-layer-assurance.md @@ -4,6 +4,8 @@ OpenShield treats Azure OSI Layer 2 as a shared responsibility boundary. Microso ExpressRoute Direct is different because Azure exposes customer-controlled Ethernet link configuration through the management API. OpenShield checks enabled links for MACsec and checks ports of 40 Gbps or greater for an XPN MACsec cipher. A subscription with no ExpressRoute Direct ports is not applicable. An Azure API or permission failure is indeterminate and never creates a finding. +The customer-actionable checks use the dedicated Data Link identifiers `AZ-DL-001` and `AZ-DL-002`. The `AZ-DL` namespace distinguishes these Layer 2 controls from the mixed-layer rules historically stored under `AZ-NET`. + ## Coverage The closed catalog covers both IEEE 802 Data Link sublayers, LLC and MAC, and all 19 functional domains required by issue #241. Each domain records Azure applicability, responsibility, observability, evidence, and one of the supported verification states. diff --git a/playbooks/cli/fix_az_net_016.sh b/playbooks/cli/fix_az_dl_001.sh similarity index 100% rename from playbooks/cli/fix_az_net_016.sh rename to playbooks/cli/fix_az_dl_001.sh diff --git a/playbooks/cli/fix_az_net_017.sh b/playbooks/cli/fix_az_dl_002.sh similarity index 100% rename from playbooks/cli/fix_az_net_017.sh rename to playbooks/cli/fix_az_dl_002.sh diff --git a/scanner/rules/az_net_016.py b/scanner/rules/az_dl_001.py similarity index 86% rename from scanner/rules/az_net_016.py rename to scanner/rules/az_dl_001.py index 667323f..5fb6a5c 100644 --- a/scanner/rules/az_net_016.py +++ b/scanner/rules/az_dl_001.py @@ -1,20 +1,20 @@ -"""AZ-NET-016: Enabled ExpressRoute Direct link has no MACsec configuration.""" +"""AZ-DL-001: Enabled ExpressRoute Direct link has no MACsec configuration.""" from typing import Any, Dict, List from scanner.rules._data_link_common import enabled_links, has_macsec, resource_identity -RULE_ID = "AZ-NET-016" +RULE_ID = "AZ-DL-001" RULE_NAME = "ExpressRoute Direct Link Does Not Use MACsec" SEVERITY = "HIGH" -CATEGORY = "Network" -FRAMEWORKS = {"CIS": "TBD-NET-016", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"} +CATEGORY = "Data Link" +FRAMEWORKS = {"CIS": "TBD-DL-001", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"} DESCRIPTION = "An enabled ExpressRoute Direct Ethernet link does not have a MACsec configuration." REMEDIATION = ( "Plan a maintenance window, store CAK and CKN values in Azure Key Vault, then enable MACsec on both " "ends of the ExpressRoute Direct link. Confirm connectivity before retiring the previous configuration." ) -PLAYBOOK = "playbooks/cli/fix_az_net_016.sh" +PLAYBOOK = "playbooks/cli/fix_az_dl_001.sh" def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: diff --git a/scanner/rules/az_net_017.py b/scanner/rules/az_dl_002.py similarity index 88% rename from scanner/rules/az_net_017.py rename to scanner/rules/az_dl_002.py index 5f7ca70..2d7dff7 100644 --- a/scanner/rules/az_net_017.py +++ b/scanner/rules/az_dl_002.py @@ -1,20 +1,20 @@ -"""AZ-NET-017: High-speed ExpressRoute Direct link uses a non-XPN MACsec cipher.""" +"""AZ-DL-002: High-speed ExpressRoute Direct link uses a non-XPN MACsec cipher.""" from typing import Any, Dict, List from scanner.rules._data_link_common import cipher_name, enabled_links, has_macsec, resource_identity, value -RULE_ID = "AZ-NET-017" +RULE_ID = "AZ-DL-002" RULE_NAME = "High-Speed ExpressRoute Direct Link Uses Non-XPN MACsec" SEVERITY = "MEDIUM" -CATEGORY = "Network" -FRAMEWORKS = {"CIS": "TBD-NET-017", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"} +CATEGORY = "Data Link" +FRAMEWORKS = {"CIS": "TBD-DL-002", "NIST": "PR.DS-2", "ISO27001": "A.13.1.1", "SOC2": "CC6.7"} DESCRIPTION = "An enabled ExpressRoute Direct port of 40 Gbps or greater uses a MACsec cipher without XPN." REMEDIATION = ( "Confirm both peer devices support an XPN cipher, schedule a maintenance window, update the MACsec " "cipher on both ends, and verify link counters and traffic before completing the change." ) -PLAYBOOK = "playbooks/cli/fix_az_net_017.sh" +PLAYBOOK = "playbooks/cli/fix_az_dl_002.sh" def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: diff --git a/tests/test_data_link_assurance.py b/tests/test_data_link_assurance.py index fe22560..0db0917 100644 --- a/tests/test_data_link_assurance.py +++ b/tests/test_data_link_assurance.py @@ -16,7 +16,7 @@ validate_catalog, ) from api.services.physical_assurance import CatalogValidationError -from scanner.rules import az_net_016, az_net_017 +from scanner.rules import az_dl_001, az_dl_002 def _link( @@ -113,22 +113,27 @@ def test_data_link_endpoint_hides_catalog_errors(client, auth_headers): @pytest.mark.parametrize("inventory", [[], None]) def test_empty_or_failed_inventory_creates_no_findings(mock_azure, subscription_id, inventory): mock_azure.set_express_route_ports(inventory) - assert az_net_016.scan(mock_azure, subscription_id) == [] - assert az_net_017.scan(mock_azure, subscription_id) == [] + assert az_dl_001.scan(mock_azure, subscription_id) == [] + assert az_dl_002.scan(mock_azure, subscription_id) == [] def test_enabled_link_without_macsec_creates_only_absence_finding(mock_azure, subscription_id): mock_azure.set_express_route_ports([_port(_link(cipher=None))]) - findings = az_net_016.scan(mock_azure, subscription_id) + findings = az_dl_001.scan(mock_azure, subscription_id) assert len(findings) == 1 - assert findings[0]["rule_id"] == "AZ-NET-016" - assert az_net_017.scan(mock_azure, subscription_id) == [] + assert findings[0]["rule_id"] == "AZ-DL-001" + assert findings[0]["category"] == "Data Link" + assert findings[0]["playbook"] == "playbooks/cli/fix_az_dl_001.sh" + assert az_dl_002.scan(mock_azure, subscription_id) == [] def test_high_speed_non_xpn_cipher_creates_finding(mock_azure, subscription_id): mock_azure.set_express_route_ports([_port(_link(cipher="GcmAes256"), bandwidth=100)]) - findings = az_net_017.scan(mock_azure, subscription_id) + findings = az_dl_002.scan(mock_azure, subscription_id) assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-DL-002" + assert findings[0]["category"] == "Data Link" + assert findings[0]["playbook"] == "playbooks/cli/fix_az_dl_002.sh" assert findings[0]["metadata"]["cipher"] == "GcmAes256" @@ -138,18 +143,18 @@ def test_high_speed_non_xpn_cipher_creates_finding(mock_azure, subscription_id): ) def test_low_speed_or_xpn_links_do_not_create_cipher_findings(mock_azure, subscription_id, bandwidth, cipher): mock_azure.set_express_route_ports([_port(_link(cipher=cipher), bandwidth=bandwidth)]) - assert az_net_017.scan(mock_azure, subscription_id) == [] + assert az_dl_002.scan(mock_azure, subscription_id) == [] def test_disabled_links_are_not_customer_actionable(mock_azure, subscription_id): mock_azure.set_express_route_ports([_port(_link(state="Disabled", cipher=None))]) - assert az_net_016.scan(mock_azure, subscription_id) == [] - assert az_net_017.scan(mock_azure, subscription_id) == [] + assert az_dl_001.scan(mock_azure, subscription_id) == [] + assert az_dl_002.scan(mock_azure, subscription_id) == [] def test_findings_never_expose_cak_or_ckn(mock_azure, subscription_id): mock_azure.set_express_route_ports([_port(_link(cipher="GcmAes256"))]) - serialized = json.dumps(az_net_017.scan(mock_azure, subscription_id)) + serialized = json.dumps(az_dl_002.scan(mock_azure, subscription_id)) assert "cak-sensitive-value" not in serialized assert "ckn-sensitive-value" not in serialized assert "secret_identifier" not in serialized From 311f02f10cbe265836ad4649acfa54afa7f775fa Mon Sep 17 00:00:00 2001 From: ritiksah141 Date: Tue, 11 Aug 2026 00:24:26 +0100 Subject: [PATCH 3/3] fix(network): align MACsec checks with Azure SDK (#241) Signed-off-by: ritiksah141 --- scanner/rules/_data_link_common.py | 25 +++++++++++-- scanner/rules/az_dl_001.py | 18 ++++++++-- scanner/rules/az_dl_002.py | 20 +++++++++-- tests/test_data_link_assurance.py | 58 +++++++++++++++++++++++++++--- 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/scanner/rules/_data_link_common.py b/scanner/rules/_data_link_common.py index 96b17d2..fe23462 100644 --- a/scanner/rules/_data_link_common.py +++ b/scanner/rules/_data_link_common.py @@ -1,8 +1,17 @@ """Secret-safe helpers for ExpressRoute Direct Data Link checks.""" +from enum import Enum from typing import Any, Iterator, Tuple +class InventoryState(str, Enum): + """Applicability state for ExpressRoute Direct inventory.""" + + APPLICABLE = "APPLICABLE" + NOT_APPLICABLE = "NOT_APPLICABLE" + INDETERMINATE = "INDETERMINATE" + + def value(item: Any, field: str, default: Any = None) -> Any: """Read an SDK model or test dictionary field.""" if isinstance(item, dict): @@ -18,14 +27,23 @@ def enabled_links(port: Any) -> Iterator[Tuple[Any, Any]]: yield port, link +def inventory_state(ports: Any) -> InventoryState: + """Distinguish inventory absence from an Azure API failure.""" + if ports is None: + return InventoryState.INDETERMINATE + if not ports: + return InventoryState.NOT_APPLICABLE + return InventoryState.APPLICABLE + + def has_macsec(link: Any) -> bool: """Return whether a link has a MACsec configuration without reading secrets.""" - return value(link, "macsec_config") is not None + return value(link, "mac_sec_config") is not None def cipher_name(link: Any) -> str: """Return only the non-secret MACsec cipher identifier.""" - config = value(link, "macsec_config") + config = value(link, "mac_sec_config") cipher = value(config, "cipher", "") if config is not None else "" return str(getattr(cipher, "value", cipher)) @@ -35,4 +53,5 @@ def resource_identity(port: Any, link: Any) -> tuple[str, str, str]: port_id = str(value(port, "id", "")) port_name = str(value(port, "name", "ExpressRoute Direct port")) link_name = str(value(link, "name", value(link, "interface_name", "link"))) - return port_id, port_name, link_name + link_id = str(value(link, "id", "")) or f"{port_id.rstrip('/')}/links/{link_name}" + return link_id, f"{port_name}/{link_name}", link_name diff --git a/scanner/rules/az_dl_001.py b/scanner/rules/az_dl_001.py index 5fb6a5c..e1cb595 100644 --- a/scanner/rules/az_dl_001.py +++ b/scanner/rules/az_dl_001.py @@ -1,8 +1,17 @@ """AZ-DL-001: Enabled ExpressRoute Direct link has no MACsec configuration.""" +import logging from typing import Any, Dict, List -from scanner.rules._data_link_common import enabled_links, has_macsec, resource_identity +from scanner.rules._data_link_common import ( + InventoryState, + enabled_links, + has_macsec, + inventory_state, + resource_identity, +) + +logger = logging.getLogger(__name__) RULE_ID = "AZ-DL-001" RULE_NAME = "ExpressRoute Direct Link Does Not Use MACsec" @@ -20,7 +29,12 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Report enabled links without MACsec and preserve API failures as indeterminate.""" ports = azure_client.get_express_route_ports() - if ports is None: + state = inventory_state(ports) + if state is InventoryState.INDETERMINATE: + logger.warning("%s: ExpressRoute Direct inventory unavailable; result is indeterminate", RULE_ID) + return [] + if state is InventoryState.NOT_APPLICABLE: + logger.info("%s: no ExpressRoute Direct ports; rule is not applicable", RULE_ID) return [] findings: List[Dict[str, Any]] = [] diff --git a/scanner/rules/az_dl_002.py b/scanner/rules/az_dl_002.py index 2d7dff7..5017942 100644 --- a/scanner/rules/az_dl_002.py +++ b/scanner/rules/az_dl_002.py @@ -1,8 +1,19 @@ """AZ-DL-002: High-speed ExpressRoute Direct link uses a non-XPN MACsec cipher.""" +import logging from typing import Any, Dict, List -from scanner.rules._data_link_common import cipher_name, enabled_links, has_macsec, resource_identity, value +from scanner.rules._data_link_common import ( + InventoryState, + cipher_name, + enabled_links, + has_macsec, + inventory_state, + resource_identity, + value, +) + +logger = logging.getLogger(__name__) RULE_ID = "AZ-DL-002" RULE_NAME = "High-Speed ExpressRoute Direct Link Uses Non-XPN MACsec" @@ -20,7 +31,12 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Report high-speed enabled links whose configured MACsec cipher is not XPN.""" ports = azure_client.get_express_route_ports() - if ports is None: + state = inventory_state(ports) + if state is InventoryState.INDETERMINATE: + logger.warning("%s: ExpressRoute Direct inventory unavailable; result is indeterminate", RULE_ID) + return [] + if state is InventoryState.NOT_APPLICABLE: + logger.info("%s: no ExpressRoute Direct ports; rule is not applicable", RULE_ID) return [] findings: List[Dict[str, Any]] = [] diff --git a/tests/test_data_link_assurance.py b/tests/test_data_link_assurance.py index 0db0917..99cb8ae 100644 --- a/tests/test_data_link_assurance.py +++ b/tests/test_data_link_assurance.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest +from azure.mgmt.network.models import ExpressRouteLink, ExpressRouteLinkMacSecConfig, ExpressRoutePort from api.services.data_link_assurance import ( EXPECTED_DOMAIN_IDS, @@ -17,6 +18,7 @@ ) from api.services.physical_assurance import CatalogValidationError from scanner.rules import az_dl_001, az_dl_002 +from scanner.rules._data_link_common import InventoryState, inventory_state def _link( @@ -29,7 +31,12 @@ def _link( config = None if cipher is not None: config = SimpleNamespace(cipher=cipher, cak_secret_identifier=cak, ckn_secret_identifier=ckn) - return SimpleNamespace(name="link1", admin_state=state, macsec_config=config) + return SimpleNamespace( + id="/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/expressRoutePorts/erd1/links/link1", + name="link1", + admin_state=state, + mac_sec_config=config, + ) def _port(link: SimpleNamespace, bandwidth: int = 100) -> SimpleNamespace: @@ -110,13 +117,21 @@ def test_data_link_endpoint_hides_catalog_errors(client, auth_headers): assert "sensitive path" not in response.get_data(as_text=True) -@pytest.mark.parametrize("inventory", [[], None]) -def test_empty_or_failed_inventory_creates_no_findings(mock_azure, subscription_id, inventory): - mock_azure.set_express_route_ports(inventory) +def test_empty_inventory_is_not_applicable_and_creates_no_findings(mock_azure, subscription_id): + mock_azure.set_express_route_ports([]) + assert inventory_state(mock_azure.get_express_route_ports()) is InventoryState.NOT_APPLICABLE assert az_dl_001.scan(mock_azure, subscription_id) == [] assert az_dl_002.scan(mock_azure, subscription_id) == [] +def test_failed_inventory_is_indeterminate_and_creates_no_findings(mock_azure, subscription_id, caplog): + mock_azure.set_express_route_ports(None) + assert inventory_state(mock_azure.get_express_route_ports()) is InventoryState.INDETERMINATE + assert az_dl_001.scan(mock_azure, subscription_id) == [] + assert az_dl_002.scan(mock_azure, subscription_id) == [] + assert caplog.text.count("result is indeterminate") == 2 + + def test_enabled_link_without_macsec_creates_only_absence_finding(mock_azure, subscription_id): mock_azure.set_express_route_ports([_port(_link(cipher=None))]) findings = az_dl_001.scan(mock_azure, subscription_id) @@ -124,6 +139,8 @@ def test_enabled_link_without_macsec_creates_only_absence_finding(mock_azure, su assert findings[0]["rule_id"] == "AZ-DL-001" assert findings[0]["category"] == "Data Link" assert findings[0]["playbook"] == "playbooks/cli/fix_az_dl_001.sh" + assert findings[0]["resource_id"].endswith("/expressRoutePorts/erd1/links/link1") + assert findings[0]["resource_name"] == "erd1/link1" assert az_dl_002.scan(mock_azure, subscription_id) == [] @@ -134,6 +151,8 @@ def test_high_speed_non_xpn_cipher_creates_finding(mock_azure, subscription_id): assert findings[0]["rule_id"] == "AZ-DL-002" assert findings[0]["category"] == "Data Link" assert findings[0]["playbook"] == "playbooks/cli/fix_az_dl_002.sh" + assert findings[0]["resource_id"].endswith("/expressRoutePorts/erd1/links/link1") + assert findings[0]["resource_name"] == "erd1/link1" assert findings[0]["metadata"]["cipher"] == "GcmAes256" @@ -160,6 +179,37 @@ def test_findings_never_expose_cak_or_ckn(mock_azure, subscription_id): assert "secret_identifier" not in serialized +def test_rules_use_real_azure_sdk_link_structure(mock_azure, subscription_id): + """Protect the exact Azure SDK field names and child-link resource identity.""" + config = ExpressRouteLinkMacSecConfig( + cipher="GcmAes256", + cak_secret_identifier="https://vault.example/secrets/cak", + ckn_secret_identifier="https://vault.example/secrets/ckn", + ) + link_id = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/expressRoutePorts/erd1/links/link2" + link = ExpressRouteLink( + id=link_id, + name="link2", + admin_state="Enabled", + mac_sec_config=config, + ) + port = ExpressRoutePort( + id="/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/expressRoutePorts/erd1", + bandwidth_in_gbps=100, + links=[link], + ) + port.name = "erd1" + mock_azure.set_express_route_ports([port]) + + assert az_dl_001.scan(mock_azure, subscription_id) == [] + findings = az_dl_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["resource_id"] == link_id + assert findings[0]["resource_name"] == "erd1/link2" + serialized = json.dumps(findings) + assert "vault.example" not in serialized + + def test_express_route_inventory_uses_azure_client_abstraction(): from scanner.azure_client import AzureClient