diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c26b8e8..4553de2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,6 +203,9 @@ Use the existing wrapper methods in `scanner/azure_client.py` rather than constr | `azure_client.get_sql_server_auditing_policy(resource_group, server_name)` | ServerBlobAuditingPolicy or None | | `azure_client.get_key_vaults()` | List of Key Vault objects | | `azure_client.get_managed_clusters()` | List of AKS ManagedCluster objects, or `None` on API failure | +| `azure_client.get_applications()` | Paginated App Registration dictionaries, or `None` on Graph failure | +| `azure_client.get_managed_identity_service_principals()` | Managed Identity service principals, or `None` on Graph failure | +| `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, or `None` on API failure | | `azure_client.get_service_principals()` | List of role assignments for service principals | | `azure_client.get_conditional_access_policies()` | List of Conditional Access policy dicts from Microsoft Graph | diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 7620192..78112c3 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -257,6 +257,36 @@ "control_id": "N/A-AKS-006", "control_name": "AKS node OS upgrade baseline (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends a managed node OS upgrade channel for timely security patches. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-010": { + "control_id": "TBD-IDN-010", + "control_name": "App Registration ownership (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends accountable App Registration ownership. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-011": { + "control_id": "TBD-IDN-011", + "control_name": "App Registration redirect URI security (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft requires secure redirect URI handling. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-012": { + "control_id": "TBD-IDN-012", + "control_name": "OAuth implicit grant security (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends authorization code flow instead of implicit grant. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-013": { + "control_id": "TBD-IDN-013", + "control_name": "App Registration password credentials (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends managed identity, federation, or certificates instead of client secrets. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-014": { + "control_id": "TBD-IDN-014", + "control_name": "Application-instance property lock (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends locking sensitive service-principal instance properties. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-015": { + "control_id": "TBD-IDN-015", + "control_name": "Managed Identity least privilege (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends least-privilege roles and scopes for managed identities. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 87a30fd..c4b88d8 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -257,6 +257,36 @@ "control_id": "A.12.6.1", "control_name": "Management of technical vulnerabilities", "description": "Automatic AKS node OS upgrades help deploy tested security patches within a managed maintenance process." + }, + "AZ-IDN-010": { + "control_id": "A.9.2.1", + "control_name": "User registration and de-registration", + "description": "App Registration ownership supports accountable identity lifecycle administration." + }, + "AZ-IDN-011": { + "control_id": "A.14.1.2", + "control_name": "Securing application services on public networks", + "description": "Secure redirect URIs protect identity protocol responses traversing public networks." + }, + "AZ-IDN-012": { + "control_id": "A.9.4.2", + "control_name": "Secure log-on procedures", + "description": "Modern authorization code flow with PKCE provides stronger token handling than implicit grant." + }, + "AZ-IDN-013": { + "control_id": "A.9.4.3", + "control_name": "Password management system", + "description": "Avoiding client secrets reduces password-style application credential exposure." + }, + "AZ-IDN-014": { + "control_id": "A.12.1.2", + "control_name": "Change management", + "description": "Property lock prevents unauthorized changes to sensitive service-principal instance configuration." + }, + "AZ-IDN-015": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "Subscription Owner and Contributor assignments to managed identities require least-privilege reduction." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index faf101b..563c614 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -257,6 +257,36 @@ "control_id": "PR.IP-12", "control_name": "A vulnerability management plan is developed and implemented", "description": "Managed node OS upgrade channels apply tested security updates to reduce exposure to known operating-system vulnerabilities." + }, + "AZ-IDN-010": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Assigned owners establish accountability for reviewing and maintaining application access." + }, + "AZ-IDN-011": { + "control_id": "PR.DS-2", + "control_name": "Data in transit is protected", + "description": "HTTPS redirect URIs protect authorization responses from interception and modification in transit." + }, + "AZ-IDN-012": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Disabling implicit grant reduces exposure of front-channel tokens used for remote application access." + }, + "AZ-IDN-013": { + "control_id": "PR.AC-1", + "control_name": "Identities and credentials are managed", + "description": "Replacing client secrets with managed credentials reduces credential leakage and rotation risk." + }, + "AZ-IDN-014": { + "control_id": "PR.IP-1", + "control_name": "A baseline configuration is created and maintained", + "description": "Application-instance property lock preserves the approved sensitive-property baseline across tenants." + }, + "AZ-IDN-015": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "Managed identities should receive only the minimum role and scope required by their workloads." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index e931e9f..5645793 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -257,6 +257,36 @@ "control_id": "CC7.1", "control_name": "Detects and Monitors Configuration Changes", "description": "Managed node OS upgrade channels maintain worker-node security patches through an observable Azure-controlled process." + }, + "AZ-IDN-010": { + "control_id": "CC6.2", + "control_name": "Registers and Authorizes Users", + "description": "Assigned application owners establish responsibility for authorization and lifecycle review." + }, + "AZ-IDN-011": { + "control_id": "CC6.7", + "control_name": "Protects Data in Transit", + "description": "HTTPS redirect URIs protect authorization responses transmitted between identity and application endpoints." + }, + "AZ-IDN-012": { + "control_id": "CC6.1", + "control_name": "Logical and Physical Access Controls", + "description": "Disabling implicit grant reduces exposure of browser-delivered access and identity tokens." + }, + "AZ-IDN-013": { + "control_id": "CC6.1", + "control_name": "Logical and Physical Access Controls", + "description": "Secretless or certificate authentication reduces compromise of application access credentials." + }, + "AZ-IDN-014": { + "control_id": "CC6.6", + "control_name": "Restricts Access to Information Assets", + "description": "Property lock prevents tenant service-principal instances from changing sensitive credentials and encryption settings." + }, + "AZ-IDN-015": { + "control_id": "CC6.3", + "control_name": "Role-Based Access", + "description": "Managed identities should not receive broad subscription roles beyond workload requirements." } } } diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index a8ee856..e62f43b 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -125,6 +125,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | `azure_client.get_sql_server_auditing_policy(rg, name)` | ServerBlobAuditingPolicy or None | | `azure_client.get_key_vaults()` | List of Vault objects (with full properties) | | `azure_client.get_managed_clusters()` | List of AKS ManagedCluster objects, or `None` on API failure | +| `azure_client.get_applications()` | Paginated App Registration dictionaries, or `None` on Graph failure | +| `azure_client.get_managed_identity_service_principals()` | Managed Identity service principals, or `None` on Graph failure | +| `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, or `None` on API failure | | `azure_client.get_service_principals()` | List of RoleAssignment objects for service principals | | `azure_client.get_conditional_access_policies()` | List of CA policy dicts from MS Graph | | `azure_client.parse_resource_id(id)` | Dict with `resource_group` and `name` | diff --git a/docs/enterprise-identity-rules.md b/docs/enterprise-identity-rules.md new file mode 100644 index 0000000..d8ff37e --- /dev/null +++ b/docs/enterprise-identity-rules.md @@ -0,0 +1,56 @@ +# Enterprise App Registration and Managed Identity Rules + +OpenShield evaluates Microsoft Entra App Registration configuration and correlates +managed-identity service principals with Azure subscription RBAC assignments. + +## Coverage + +| Rule | Control | +|---|---| +| `AZ-IDN-010` | App Registration ownership | +| `AZ-IDN-011` | Non-loopback HTTP redirect URIs | +| `AZ-IDN-012` | OAuth implicit grant | +| `AZ-IDN-013` | Password credential presence | +| `AZ-IDN-014` | Multi-tenant application-instance property lock | +| `AZ-IDN-015` | Owner/Contributor managed identity at subscription scope | + +`AZ-IDN-006` continues to detect stale, expired, and non-expiring password +credentials, but now reuses the same cached application inventory. + +## Required permissions + +- Microsoft Graph application permission `Application.Read.All` reads App + Registrations, minimal owner IDs, and managed-identity service principals. +- Azure action `Microsoft.Authorization/roleAssignments/read` reads subscription + RBAC assignments for managed-identity correlation. + +The scanner never requests application write permissions. Remediation commands +run separately under an operator identity and require explicit confirmation where +an automated change is safe. + +## Data minimization + +Findings do not contain tokens, credential values, credential key identifiers, +credential hints, complete redirect URIs, or owner personal details. Findings use +counts and boolean configuration states sufficient for triage. + +## Reliability + +Graph inventories follow `@odata.nextLink` and are cached for the scan lifetime. +Graph or Azure RBAC failures return an indeterminate `None` state; rules skip +evaluation and log the unavailable inventory rather than claiming compliance. + +## Assignment restrictions + +Resource-provider assignment restrictions for user-assigned managed identities +were intentionally deferred. Microsoft documents the portal feature, but the +current public Managed Identity REST response reliably exposes only +`isolationScope`. OpenShield will not infer a control from an undocumented field. + +## References + +- [App Registration security guidance](https://learn.microsoft.com/entra/identity-platform/security-best-practices-for-app-registration) +- [List applications](https://learn.microsoft.com/graph/api/application-list) +- [List application owners](https://learn.microsoft.com/graph/api/application-list-owners) +- [Service-principal property lock](https://learn.microsoft.com/graph/api/resources/serviceprincipallockconfiguration) +- [Managed Identity best practices](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/managed-identity-best-practice-recommendations) diff --git a/docs/rules-reference.md b/docs/rules-reference.md index e8cd964..73ecb55 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -21,6 +21,12 @@ OpenShield currently ships 44 Azure scan rules. This table is generated from the | AZ-IDN-007 | Active User with No MFA Registered in Entra ID | HIGH | Identity | 1.1 | PR.AC-7 | A.9.4.2 | | AZ-IDN-008 | Custom RBAC Role with Wildcard Permissions at Subscription Scope | HIGH | Identity | 1.23 | PR.AC-4 | A.9.2.3 | | AZ-IDN-009 | No Activity Log Alert for Role Assignment Changes | MEDIUM | Identity | 5.2.1 | DE.CM-3 | A.12.4.1 | +| AZ-IDN-010 | App Registration Has No Owner | MEDIUM | Identity | TBD-IDN-010 | PR.AC-4 | A.9.2.1 | +| AZ-IDN-011 | App Registration Uses Insecure Redirect URI | HIGH | Identity | TBD-IDN-011 | PR.DS-2 | A.14.1.2 | +| AZ-IDN-012 | App Registration Enables OAuth Implicit Grant | MEDIUM | Identity | TBD-IDN-012 | PR.AC-3 | A.9.4.2 | +| AZ-IDN-013 | App Registration Uses Password Credentials | MEDIUM | Identity | TBD-IDN-013 | PR.AC-1 | A.9.4.3 | +| AZ-IDN-014 | Multi-Tenant App Registration Lacks Property Lock | HIGH | Identity | TBD-IDN-014 | PR.IP-1 | A.12.1.2 | +| AZ-IDN-015 | Managed Identity Has Privileged Subscription Role | HIGH | Identity | TBD-IDN-015 | PR.AC-4 | A.9.2.3 | | AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | 8.8 | PR.IP-4 | A.17.2.1 | | AZ-KV-002 | Key Vault Allows Public Network Access Without Private Endpoint | HIGH | Key Vault | 8.7 | AC-17 | A.13.1.1 | | AZ-KV-003 | Key Vault Without Diagnostic Logging Enabled | MEDIUM | Key Vault | 8.4 | DE.CM-7 | A.12.4.1 | diff --git a/playbooks/cli/fix_az_idn_010.sh b/playbooks/cli/fix_az_idn_010.sh new file mode 100644 index 0000000..95068b1 --- /dev/null +++ b/playbooks/cli/fix_az_idn_010.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-010 - App Registration has no owner +# Usage: ./fix_az_idn_010.sh + +set -euo pipefail + +APP_ID=${1:-} +OWNER_ID=${2:-} +if [ -z "$APP_ID" ] || [ -z "$OWNER_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az ad app show --id "$APP_ID" --query "{appId:appId,displayName:displayName}" --output table +read -r -p "Type APPLY to add owner '$OWNER_ID': " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az ad app owner add --id "$APP_ID" --owner-object-id "$OWNER_ID" +az ad app owner list --id "$APP_ID" --query "[].id" --output table diff --git a/playbooks/cli/fix_az_idn_011.sh b/playbooks/cli/fix_az_idn_011.sh new file mode 100644 index 0000000..a6fbd45 --- /dev/null +++ b/playbooks/cli/fix_az_idn_011.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-011 - App Registration uses insecure redirect URI +# Usage: ./fix_az_idn_011.sh + +set -euo pipefail + +APP_ID=${1:-} +if [ -z "$APP_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "Current redirect URIs:" +az ad app show --id "$APP_ID" \ + --query "{web:web.redirectUris,spa:spa.redirectUris,publicClient:publicClient.redirectUris}" +echo "Review every client first, then replace non-loopback HTTP entries with exact HTTPS URIs." +echo "Use 'az ad app update --id --web-redirect-uris ' for web clients." +echo "No automatic change was made because replacing an incomplete URI list can break authentication." diff --git a/playbooks/cli/fix_az_idn_012.sh b/playbooks/cli/fix_az_idn_012.sh new file mode 100644 index 0000000..2b5eaca --- /dev/null +++ b/playbooks/cli/fix_az_idn_012.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-012 - App Registration enables OAuth implicit grant +# Usage: ./fix_az_idn_012.sh + +set -euo pipefail + +OBJECT_ID=${1:-} +if [ -z "$OBJECT_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "WARNING: Existing implicit-flow clients must migrate to authorization code with PKCE first." +read -r -p "Type APPLY to disable implicit token issuance: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az rest --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body '{"web":{"implicitGrantSettings":{"enableAccessTokenIssuance":false,"enableIdTokenIssuance":false}}}' +echo "Implicit access-token and ID-token issuance disabled." diff --git a/playbooks/cli/fix_az_idn_013.sh b/playbooks/cli/fix_az_idn_013.sh new file mode 100644 index 0000000..9a30410 --- /dev/null +++ b/playbooks/cli/fix_az_idn_013.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-013 - App Registration uses password credentials +# Usage: ./fix_az_idn_013.sh + +set -euo pipefail + +APP_ID=${1:-} +if [ -z "$APP_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az ad app credential list --id "$APP_ID" --query "[].{type:type,displayName:displayName,endDateTime:endDateTime}" --output table +echo "Migrate the workload to managed identity, workload federation, or a certificate before deleting secrets." +echo "No credential was deleted automatically because doing so can cause an immediate production outage." diff --git a/playbooks/cli/fix_az_idn_014.sh b/playbooks/cli/fix_az_idn_014.sh new file mode 100644 index 0000000..f5e863c --- /dev/null +++ b/playbooks/cli/fix_az_idn_014.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-014 - Multi-tenant app lacks application-instance property lock +# Usage: ./fix_az_idn_014.sh + +set -euo pipefail + +OBJECT_ID=${1:-} +if [ -z "$OBJECT_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "WARNING: Property lock prevents tenant service-principal instances from changing sensitive properties." +read -r -p "Type APPLY to enable the full sensitive-property lock: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az rest --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body '{"servicePrincipalLockConfiguration":{"isEnabled":true,"allProperties":true}}' +echo "Application-instance sensitive-property lock enabled." diff --git a/playbooks/cli/fix_az_idn_015.sh b/playbooks/cli/fix_az_idn_015.sh new file mode 100644 index 0000000..885017e --- /dev/null +++ b/playbooks/cli/fix_az_idn_015.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-015 - Managed identity has privileged subscription role +# Usage: ./fix_az_idn_015.sh + +set -euo pipefail + +ASSIGNMENT_ID=${1:-} +if [ -z "$ASSIGNMENT_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +az role assignment list --query "[?id=='$ASSIGNMENT_ID'].{principalId:principalId,role:roleDefinitionName,scope:scope}" \ + --output table +echo "WARNING: Confirm the workload has a narrower replacement assignment before removal." +read -r -p "Type APPLY to delete this privileged assignment: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az role assignment delete --ids "$ASSIGNMENT_ID" +echo "Privileged subscription-scope assignment removed." diff --git a/scanner/azure_client.py b/scanner/azure_client.py index c44aa9d..a458b40 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -35,6 +35,9 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No self.subscription_id = subscription_id self.credential = credential or DefaultAzureCredential() self._managed_clusters_cache: Any = _UNSET + self._applications_cache: Any = _UNSET + self._managed_identity_principals_cache: Any = _UNSET + self._subscription_role_assignments_cache: Any = _UNSET # ------------------------------------------------------------------ # # Static helpers # @@ -491,16 +494,72 @@ def get_diagnostic_settings(self, resource_id: str) -> Optional[bool]: # Identity / Authorization # # ------------------------------------------------------------------ # - def get_service_principals(self) -> List[Any]: - """Return role assignments whose principal type is ServicePrincipal.""" + def _get_graph_collection(self, url: str, operation: str) -> Optional[List[Dict[str, Any]]]: + """Fetch a paginated Microsoft Graph collection or return ``None`` on failure.""" + import requests + + items: List[Dict[str, Any]] = [] + try: + token = self.credential.get_token("https://graph.microsoft.com/.default") + headers = { + "Authorization": f"Bearer {token.token}", + "ConsistencyLevel": "eventual", + } + while url: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + items.extend(data.get("value", [])) + url = data.get("@odata.nextLink", "") + return items + except Exception as exc: + logger.error("%s failed: %s", operation, exc) + return None + + def get_applications(self) -> Optional[List[Dict[str, Any]]]: + """Return cached App Registrations with security-relevant properties and owner IDs.""" + if self._applications_cache is _UNSET: + select = ( + "id,displayName,appId,signInAudience,passwordCredentials,keyCredentials," + "web,spa,publicClient,servicePrincipalLockConfiguration" + ) + url = f"https://graph.microsoft.com/v1.0/applications?$select={select}&$expand=owners($select=id)&$top=100" + self._applications_cache = self._get_graph_collection(url, "get_applications") + return self._applications_cache + + def get_managed_identity_service_principals(self) -> Optional[List[Dict[str, Any]]]: + """Return cached Microsoft Entra service principals representing managed identities.""" + if self._managed_identity_principals_cache is _UNSET: + url = ( + "https://graph.microsoft.com/v1.0/servicePrincipals" + "?$filter=servicePrincipalType eq 'ManagedIdentity'" + "&$select=id,displayName,servicePrincipalType&$count=true&$top=100" + ) + self._managed_identity_principals_cache = self._get_graph_collection( + url, + "get_managed_identity_service_principals", + ) + return self._managed_identity_principals_cache + + def get_subscription_role_assignments(self) -> Optional[List[Any]]: + """Return cached subscription-scope RBAC assignments, preserving API failure as ``None``.""" + if self._subscription_role_assignments_cache is not _UNSET: + return self._subscription_role_assignments_cache try: client = AuthorizationManagementClient(self.credential, self.subscription_id) scope = f"/subscriptions/{self.subscription_id}" - assignments = list(client.role_assignments.list_for_scope(scope)) - return [a for a in assignments if getattr(a, "principal_type", "") == "ServicePrincipal"] + self._subscription_role_assignments_cache = list(client.role_assignments.list_for_scope(scope)) except Exception as exc: - logger.error("get_service_principals failed: %s", exc) + logger.error("get_subscription_role_assignments failed: %s", exc) + self._subscription_role_assignments_cache = None + return self._subscription_role_assignments_cache + + def get_service_principals(self) -> List[Any]: + """Return role assignments whose principal type is ServicePrincipal.""" + assignments = self.get_subscription_role_assignments() + if assignments is None: return [] + return [a for a in assignments if getattr(a, "principal_type", "") == "ServicePrincipal"] def get_postgresql_flexible_servers(self) -> List[Any]: """List all PostgreSQL Flexible Server instances in the subscription.""" diff --git a/scanner/rules/_identity_common.py b/scanner/rules/_identity_common.py new file mode 100644 index 0000000..b122dba --- /dev/null +++ b/scanner/rules/_identity_common.py @@ -0,0 +1,40 @@ +"""Shared helpers for App Registration and managed-identity rules.""" + +from typing import Any, Dict, Mapping + + +def application_identity(application: Mapping[str, Any]) -> tuple[str, str]: + """Return the Graph application object ID and a safe display name.""" + object_id = str(application.get("id", "") or "") + display_name = str(application.get("displayName") or application.get("appId") or object_id) + return object_id, display_name + + +def application_finding( + application: Mapping[str, Any], + *, + rule_id: str, + rule_name: str, + severity: str, + description: str, + remediation: str, + playbook: str, + frameworks: Mapping[str, str], + metadata: Mapping[str, Any], +) -> Dict[str, Any]: + """Build the repository-standard finding for a Microsoft Graph application.""" + object_id, display_name = application_identity(application) + return { + "rule_id": rule_id, + "rule_name": rule_name, + "severity": severity, + "category": "Identity", + "resource_id": f"/applications/{object_id}", + "resource_name": display_name, + "resource_type": "Microsoft.Graph/applications", + "description": description, + "remediation": remediation, + "playbook": playbook, + "frameworks": dict(frameworks), + "metadata": dict(metadata), + } diff --git a/scanner/rules/az_idn_006.py b/scanner/rules/az_idn_006.py index 9a8065a..ebffe97 100644 --- a/scanner/rules/az_idn_006.py +++ b/scanner/rules/az_idn_006.py @@ -33,25 +33,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Detect service principals with stale or non-expiring client secrets.""" findings: List[Dict[str, Any]] = [] - try: - import requests - - token = azure_client.credential.get_token("https://graph.microsoft.com/.default") - headers = {"Authorization": f"Bearer {token.token}"} - - next_url = ( - "https://graph.microsoft.com/v1.0/applications?$select=id,displayName,appId,passwordCredentials&$top=100" - ) - applications = [] - while next_url: - response = requests.get(next_url, headers=headers, timeout=30) - response.raise_for_status() - data = response.json() - applications.extend(data.get("value", [])) - next_url = data.get("@odata.nextLink") - - except Exception as exc: - logger.error("AZ-IDN-006: Failed to fetch applications from Graph API: %s", exc) + applications = azure_client.get_applications() + if applications is None: + logger.error("AZ-IDN-006: Application inventory is unavailable") logger.warning( "AZ-IDN-006: Ensure the service principal has Application.Read.All permission on Microsoft Graph." ) diff --git a/scanner/rules/az_idn_010.py b/scanner/rules/az_idn_010.py new file mode 100644 index 0000000..e09b94f --- /dev/null +++ b/scanner/rules/az_idn_010.py @@ -0,0 +1,51 @@ +"""AZ-IDN-010: App Registration has no assigned owner.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-010" +RULE_NAME = "App Registration Has No Owner" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-010", "NIST": "PR.AC-4", "ISO27001": "A.9.2.1", "SOC2": "CC6.2"} +DESCRIPTION = ( + "The App Registration has no assigned owner. Unowned applications can escape periodic review, " + "credential rotation, permission cleanup, and accountable incident response." +) +REMEDIATION = "Assign at least one accountable owner and establish a periodic application ownership review." +PLAYBOOK = "playbooks/cli/fix_az_idn_010.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + owners = application.get("owners") + if owners is None: + logger.warning("%s: owner state is unavailable for application %s", RULE_ID, object_id) + continue + if not owners: + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"owner_count": 0}, + ) + ) + return findings diff --git a/scanner/rules/az_idn_011.py b/scanner/rules/az_idn_011.py new file mode 100644 index 0000000..c93e6f1 --- /dev/null +++ b/scanner/rules/az_idn_011.py @@ -0,0 +1,75 @@ +"""AZ-IDN-011: App Registration contains an insecure HTTP redirect URI.""" + +import ipaddress +import logging +from typing import Any, Dict, Iterable, List, Mapping +from urllib.parse import urlparse + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-011" +RULE_NAME = "App Registration Uses Insecure Redirect URI" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-011", "NIST": "PR.DS-2", "ISO27001": "A.14.1.2", "SOC2": "CC6.7"} +DESCRIPTION = ( + "The App Registration contains an HTTP redirect URI for a non-loopback host. Authorization " + "responses can be intercepted or modified before reaching the application." +) +REMEDIATION = "Replace non-loopback HTTP redirect URIs with exact HTTPS URIs and remove obsolete entries." +PLAYBOOK = "playbooks/cli/fix_az_idn_011.sh" + +logger = logging.getLogger(__name__) + + +def _redirect_uris(application: Mapping[str, Any]) -> Iterable[str]: + for profile_name in ("web", "spa", "publicClient"): + profile = application.get(profile_name) or {} + for uri in profile.get("redirectUris", []) or []: + if isinstance(uri, str): + yield uri + + +def _is_loopback(hostname: str) -> bool: + if hostname.lower() == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _is_insecure_http_redirect(uri: str) -> bool: + parsed = urlparse(uri) + if parsed.scheme.lower() != "http": + return False + return not parsed.hostname or not _is_loopback(parsed.hostname) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + insecure_uris = [uri for uri in _redirect_uris(application) if _is_insecure_http_redirect(uri)] + if insecure_uris: + schemes = sorted({urlparse(uri).scheme.lower() for uri in insecure_uris}) + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"insecure_redirect_count": len(insecure_uris), "schemes": schemes}, + ) + ) + return findings diff --git a/scanner/rules/az_idn_012.py b/scanner/rules/az_idn_012.py new file mode 100644 index 0000000..a9674fc --- /dev/null +++ b/scanner/rules/az_idn_012.py @@ -0,0 +1,50 @@ +"""AZ-IDN-012: App Registration enables OAuth implicit grant.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-012" +RULE_NAME = "App Registration Enables OAuth Implicit Grant" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-012", "NIST": "PR.AC-3", "ISO27001": "A.9.4.2", "SOC2": "CC6.1"} +DESCRIPTION = ( + "The App Registration enables access-token or ID-token issuance through the legacy implicit " + "grant flow. Tokens can be exposed to browser history, extensions, or front-channel leakage." +) +REMEDIATION = "Disable implicit grant and migrate clients to authorization code flow with PKCE." +PLAYBOOK = "playbooks/cli/fix_az_idn_012.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + settings = (application.get("web") or {}).get("implicitGrantSettings") or {} + access_tokens = bool(settings.get("enableAccessTokenIssuance", False)) + id_tokens = bool(settings.get("enableIdTokenIssuance", False)) + if access_tokens or id_tokens: + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={"access_token_issuance": access_tokens, "id_token_issuance": id_tokens}, + ) + ) + return findings diff --git a/scanner/rules/az_idn_013.py b/scanner/rules/az_idn_013.py new file mode 100644 index 0000000..703021d --- /dev/null +++ b/scanner/rules/az_idn_013.py @@ -0,0 +1,51 @@ +"""AZ-IDN-013: App Registration uses password credentials.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-013" +RULE_NAME = "App Registration Uses Password Credentials" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-013", "NIST": "PR.AC-1", "ISO27001": "A.9.4.3", "SOC2": "CC6.1"} +DESCRIPTION = ( + "The App Registration has one or more password credentials. Client secrets are commonly copied, " + "logged, leaked, or left unrotated and are weaker than managed identity or certificate authentication." +) +REMEDIATION = "Migrate to managed identity, workload identity federation, or certificates, then remove client secrets." +PLAYBOOK = "playbooks/cli/fix_az_idn_013.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id: + continue + password_credentials = application.get("passwordCredentials") or [] + if password_credentials: + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={ + "password_credential_count": len(password_credentials), + "certificate_credential_count": len(application.get("keyCredentials") or []), + }, + ) + ) + return findings diff --git a/scanner/rules/az_idn_014.py b/scanner/rules/az_idn_014.py new file mode 100644 index 0000000..0fc0ffd --- /dev/null +++ b/scanner/rules/az_idn_014.py @@ -0,0 +1,55 @@ +"""AZ-IDN-014: Multi-tenant App Registration lacks application-instance property lock.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._identity_common import application_finding, application_identity + +RULE_ID = "AZ-IDN-014" +RULE_NAME = "Multi-Tenant App Registration Lacks Property Lock" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-014", "NIST": "PR.IP-1", "ISO27001": "A.12.1.2", "SOC2": "CC6.6"} +DESCRIPTION = ( + "The multi-tenant App Registration does not lock all sensitive properties on its service-principal " + "instances. Tenant administrators can modify credentials or token-encryption settings unexpectedly." +) +REMEDIATION = "Enable application-instance property lock and lock all sensitive service-principal properties." +PLAYBOOK = "playbooks/cli/fix_az_idn_014.sh" + +logger = logging.getLogger(__name__) +_MULTI_TENANT_AUDIENCES = {"AzureADMultipleOrgs", "AzureADandPersonalMicrosoftAccount"} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + applications = azure_client.get_applications() + if applications is None: + logger.warning("%s: application inventory is unavailable", RULE_ID) + return findings + for application in applications: + object_id, _ = application_identity(application) + if not object_id or application.get("signInAudience") not in _MULTI_TENANT_AUDIENCES: + continue + lock = application.get("servicePrincipalLockConfiguration") or {} + enabled = bool(lock.get("isEnabled", False)) + all_properties = bool(lock.get("allProperties", False)) + if not (enabled and all_properties): + findings.append( + application_finding( + application, + rule_id=RULE_ID, + rule_name=RULE_NAME, + severity=SEVERITY, + description=DESCRIPTION, + remediation=REMEDIATION, + playbook=PLAYBOOK, + frameworks=FRAMEWORKS, + metadata={ + "sign_in_audience": application.get("signInAudience"), + "lock_enabled": enabled, + "all_sensitive_properties_locked": all_properties, + }, + ) + ) + return findings diff --git a/scanner/rules/az_idn_015.py b/scanner/rules/az_idn_015.py new file mode 100644 index 0000000..1b78a4d --- /dev/null +++ b/scanner/rules/az_idn_015.py @@ -0,0 +1,63 @@ +"""AZ-IDN-015: Managed identity has a privileged subscription-scope role.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-015" +RULE_NAME = "Managed Identity Has Privileged Subscription Role" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = {"CIS": "TBD-IDN-015", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"} +DESCRIPTION = ( + "A managed identity holds Owner or Contributor at subscription scope. Compromise of any resource " + "that can use the identity would provide an unnecessarily large Azure control-plane blast radius." +) +REMEDIATION = "Replace the assignment with the narrowest role and resource scope required by the workload." +PLAYBOOK = "playbooks/cli/fix_az_idn_015.sh" + +OWNER_ROLE_GUID = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" +CONTRIBUTOR_ROLE_GUID = "b24988ac-6180-42a0-ab88-20f7382dd24c" +_PRIVILEGED_ROLES = {OWNER_ROLE_GUID: "Owner", CONTRIBUTOR_ROLE_GUID: "Contributor"} + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + principals = azure_client.get_managed_identity_service_principals() + assignments = azure_client.get_subscription_role_assignments() + if principals is None or assignments is None: + logger.warning("%s: managed-identity or RBAC inventory is unavailable", RULE_ID) + return findings + + principal_names = {str(item.get("id")): str(item.get("displayName") or item.get("id")) for item in principals} + subscription_scope = f"/subscriptions/{subscription_id}".lower() + for assignment in assignments: + principal_id = str(getattr(assignment, "principal_id", "") or "") + if principal_id not in principal_names: + continue + scope = str(getattr(assignment, "scope", "") or "") + if scope.rstrip("/").lower() != subscription_scope: + continue + role_definition_id = str(getattr(assignment, "role_definition_id", "") or "") + role_guid = role_definition_id.rstrip("/").split("/")[-1].lower() + role_name = _PRIVILEGED_ROLES.get(role_guid) + if not role_name: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": str(getattr(assignment, "id", "") or ""), + "resource_name": principal_names[principal_id], + "resource_type": "Microsoft.Authorization/roleAssignments", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"principal_id": principal_id, "role": role_name, "scope": scope}, + } + ) + return findings diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 508619e..80a151b 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -82,6 +82,9 @@ def __init__(self) -> None: self._dns_record_sets: Dict[Tuple[str, str], List[Any]] = {} self._web_apps: List[Any] = [] self._managed_clusters: Optional[List[Any]] = [] + self._applications: Optional[List[Dict[str, Any]]] = [] + self._managed_identity_principals: Optional[List[Dict[str, Any]]] = [] + self._subscription_role_assignments: Optional[List[Any]] = [] # Some rules read azure_client.subscription_id when constructing an # SDK management client inside scan() (e.g. AZ-NET-007..010). self.subscription_id = "00000000-0000-0000-0000-000000000001" @@ -98,6 +101,27 @@ def set_managed_clusters(self, clusters: Optional[List[Any]]) -> "MockAzureClien def get_managed_clusters(self) -> Optional[List[Any]]: return self._managed_clusters + def set_applications(self, applications: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._applications = applications + return self + + def get_applications(self) -> Optional[List[Dict[str, Any]]]: + return self._applications + + def set_managed_identity_service_principals(self, principals: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._managed_identity_principals = principals + return self + + def get_managed_identity_service_principals(self) -> Optional[List[Dict[str, Any]]]: + return self._managed_identity_principals + + def set_subscription_role_assignments(self, assignments: Optional[List[Any]]) -> "MockAzureClient": + self._subscription_role_assignments = assignments + return self + + def get_subscription_role_assignments(self) -> Optional[List[Any]]: + return self._subscription_role_assignments + def set_network_security_groups(self, nsgs: List[Any]) -> "MockAzureClient": self._network_security_groups = nsgs return self diff --git a/tests/test_azure_client_identity_inventory.py b/tests/test_azure_client_identity_inventory.py new file mode 100644 index 0000000..9728c4c --- /dev/null +++ b/tests/test_azure_client_identity_inventory.py @@ -0,0 +1,63 @@ +"""Tests for cached Microsoft Graph and RBAC identity inventory accessors.""" + +from unittest.mock import MagicMock, patch + +from scanner.azure_client import AzureClient + + +class Response: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +@patch("requests.get") +def test_application_inventory_follows_pagination_and_caches(request_get): + request_get.side_effect = [ + Response({"value": [{"id": "a1"}], "@odata.nextLink": "https://graph.microsoft.com/page2"}), + Response({"value": [{"id": "a2"}]}), + ] + client = AzureClient("sub-1", credential=MagicMock()) + + assert client.get_applications() == [{"id": "a1"}, {"id": "a2"}] + assert client.get_applications() == [{"id": "a1"}, {"id": "a2"}] + assert request_get.call_count == 2 + + +@patch("requests.get") +def test_application_inventory_preserves_graph_failure(request_get): + request_get.side_effect = RuntimeError("graph unavailable") + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_applications() is None + assert client.get_applications() is None + request_get.assert_called_once() + + +@patch("requests.get") +def test_managed_identity_principal_inventory_uses_type_filter(request_get): + request_get.return_value = Response({"value": [{"id": "mi-1"}]}) + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_managed_identity_service_principals() == [{"id": "mi-1"}] + requested_url = request_get.call_args.args[0] + assert "servicePrincipalType eq 'ManagedIdentity'" in requested_url + + +@patch("scanner.azure_client.AuthorizationManagementClient") +def test_subscription_role_assignments_cache_success(auth_client_type): + auth_client_type.return_value.role_assignments.list_for_scope.return_value = ["assignment"] + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_subscription_role_assignments() == ["assignment"] + assert client.get_subscription_role_assignments() == ["assignment"] + auth_client_type.return_value.role_assignments.list_for_scope.assert_called_once_with("/subscriptions/sub-1") + + +@patch("scanner.azure_client.AuthorizationManagementClient") +def test_subscription_role_assignments_preserves_failure(auth_client_type): + auth_client_type.return_value.role_assignments.list_for_scope.side_effect = RuntimeError("denied") + client = AzureClient("sub-1", credential=MagicMock()) + assert client.get_subscription_role_assignments() is None diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py index aa797a8..90401ef 100644 --- a/tests/test_engine_integration.py +++ b/tests/test_engine_integration.py @@ -43,11 +43,11 @@ def _nsg_id(name): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/networkSecurityGroups/{name}" -def test_engine_loads_all_51_rules(monkeypatch): +def test_engine_loads_all_57_rules(monkeypatch): """The engine must dynamically load the complete rule set.""" _patch_engine_client(monkeypatch, _offline_mock()) eng = ScanEngine(_SUB) - assert len(eng.rules) >= 51 + assert len(eng.rules) >= 57 # Every loaded rule must expose a callable scan() and a RULE_ID. for rule in eng.rules: assert callable(getattr(rule, "scan", None)) diff --git a/tests/test_rules_identity.py b/tests/test_rules_identity.py index 18be4ef..b2ea214 100644 --- a/tests/test_rules_identity.py +++ b/tests/test_rules_identity.py @@ -258,7 +258,7 @@ def test_idn_006_compliant_fresh_secret_returns_no_findings(mock_azure, subscrip } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) assert az_idn_006.scan(mock_azure, subscription_id) == [] @@ -280,7 +280,7 @@ def test_idn_006_noncompliant_secret_no_expiry_returns_finding(mock_azure, subsc } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) findings = az_idn_006.scan(mock_azure, subscription_id) assert len(findings) == 1 assert findings[0]["rule_id"] == "AZ-IDN-006" @@ -311,7 +311,7 @@ def test_idn_006_malformed_end_date_time_does_not_log_key_id(mock_azure, subscri } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) with caplog.at_level("DEBUG", logger="scanner.rules.az_idn_006"): findings = az_idn_006.scan(mock_azure, subscription_id) diff --git a/tests/test_rules_identity_enterprise.py b/tests/test_rules_identity_enterprise.py new file mode 100644 index 0000000..74d21ee --- /dev/null +++ b/tests/test_rules_identity_enterprise.py @@ -0,0 +1,154 @@ +"""Tests for enterprise identity rules AZ-IDN-010 through AZ-IDN-015.""" + +from types import SimpleNamespace + +import pytest + +from scanner.rules import az_idn_010, az_idn_011, az_idn_012, az_idn_013, az_idn_014, az_idn_015 + + +def app(**overrides): + values = { + "id": "app-object-1", + "displayName": "Enterprise App", + "appId": "client-id-1", + "owners": [{"id": "owner-1"}], + "signInAudience": "AzureADMyOrg", + "passwordCredentials": [], + "keyCredentials": [], + "web": {"redirectUris": ["https://app.example/callback"], "implicitGrantSettings": {}}, + "spa": {"redirectUris": []}, + "publicClient": {"redirectUris": []}, + "servicePrincipalLockConfiguration": None, + } + values.update(overrides) + return values + + +APP_RULES = [az_idn_010, az_idn_011, az_idn_012, az_idn_013, az_idn_014] + + +@pytest.mark.parametrize("rule", APP_RULES) +def test_compliant_application_returns_no_findings(rule, mock_azure, subscription_id): + mock_azure.set_applications([app()]) + assert rule.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize("rule", APP_RULES) +def test_application_inventory_failure_returns_no_findings(rule, mock_azure, subscription_id): + mock_azure.set_applications(None) + assert rule.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize("rule", APP_RULES) +def test_application_without_object_id_is_skipped(rule, mock_azure, subscription_id): + mock_azure.set_applications([app(id="")]) + assert rule.scan(mock_azure, subscription_id) == [] + + +def test_idn_010_unowned_application_returns_finding(mock_azure, subscription_id): + mock_azure.set_applications([app(owners=[])]) + findings = az_idn_010.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"] == {"owner_count": 0} + + +def test_idn_010_missing_expanded_owner_state_is_unknown(mock_azure, subscription_id): + application = app() + application.pop("owners") + mock_azure.set_applications([application]) + assert az_idn_010.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize( + "uri", + [ + "http://app.example/callback", + "http://10.1.2.3/callback", + "http://example.com/callback", + ], +) +def test_idn_011_non_loopback_http_redirect_returns_finding(uri, mock_azure, subscription_id): + mock_azure.set_applications([app(web={"redirectUris": [uri], "implicitGrantSettings": {}})]) + findings = az_idn_011.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["insecure_redirect_count"] == 1 + assert uri not in str(findings[0]["metadata"]) + + +@pytest.mark.parametrize( + "uri", + [ + "http://localhost/callback", + "http://127.0.0.1:8080/callback", + "http://[::1]/callback", + "https://app.example/callback", + "msalclient-id://auth", + ], +) +def test_idn_011_secure_or_native_redirect_is_allowed(uri, mock_azure, subscription_id): + mock_azure.set_applications([app(web={"redirectUris": [uri], "implicitGrantSettings": {}})]) + assert az_idn_011.scan(mock_azure, subscription_id) == [] + + +def test_idn_012_implicit_access_token_returns_finding(mock_azure, subscription_id): + web = {"redirectUris": [], "implicitGrantSettings": {"enableAccessTokenIssuance": True}} + mock_azure.set_applications([app(web=web)]) + findings = az_idn_012.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["access_token_issuance"] is True + + +def test_idn_013_password_credential_returns_finding_without_identifiers(mock_azure, subscription_id): + credential = {"keyId": "must-not-leak", "hint": "xy", "displayName": "prod-secret"} + mock_azure.set_applications([app(passwordCredentials=[credential])]) + findings = az_idn_013.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["password_credential_count"] == 1 + assert "must-not-leak" not in str(findings[0]) + + +def test_idn_014_multitenant_without_lock_returns_finding(mock_azure, subscription_id): + mock_azure.set_applications([app(signInAudience="AzureADMultipleOrgs")]) + findings = az_idn_014.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["lock_enabled"] is False + + +def test_idn_014_multitenant_full_lock_is_compliant(mock_azure, subscription_id): + lock = {"isEnabled": True, "allProperties": True} + mock_azure.set_applications([app(signInAudience="AzureADMultipleOrgs", servicePrincipalLockConfiguration=lock)]) + assert az_idn_014.scan(mock_azure, subscription_id) == [] + + +def assignment(role_guid, principal_id="mi-1", scope="/subscriptions/sub-id"): + return SimpleNamespace( + id="/subscriptions/sub-id/providers/Microsoft.Authorization/roleAssignments/assignment-1", + principal_id=principal_id, + role_definition_id=f"/subscriptions/sub-id/providers/Microsoft.Authorization/roleDefinitions/{role_guid}", + scope=scope, + ) + + +@pytest.mark.parametrize("role_guid", [az_idn_015.OWNER_ROLE_GUID, az_idn_015.CONTRIBUTOR_ROLE_GUID]) +def test_idn_015_privileged_managed_identity_returns_finding(role_guid, mock_azure, subscription_id): + mock_azure.set_managed_identity_service_principals([{"id": "mi-1", "displayName": "payments-mi"}]) + mock_azure.set_subscription_role_assignments([assignment(role_guid, scope=f"/subscriptions/{subscription_id}")]) + findings = az_idn_015.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["resource_name"] == "payments-mi" + + +def test_idn_015_resource_group_scope_is_compliant(mock_azure, subscription_id): + mock_azure.set_managed_identity_service_principals([{"id": "mi-1", "displayName": "payments-mi"}]) + mock_azure.set_subscription_role_assignments( + [assignment(az_idn_015.CONTRIBUTOR_ROLE_GUID, scope=f"/subscriptions/{subscription_id}/resourceGroups/rg")] + ) + assert az_idn_015.scan(mock_azure, subscription_id) == [] + + +@pytest.mark.parametrize("failed_inventory", ["principals", "assignments"]) +def test_idn_015_inventory_failure_returns_no_findings(failed_inventory, mock_azure, subscription_id): + mock_azure.set_managed_identity_service_principals(None if failed_inventory == "principals" else []) + mock_azure.set_subscription_role_assignments(None if failed_inventory == "assignments" else []) + assert az_idn_015.scan(mock_azure, subscription_id) == []