#!/usr/bin/env python3
"""Read-only multicloud assessment. Python 3.11+, authenticated az/aws/gcloud CLIs.
No shell evaluation, passwords, secret values, write APIs, or remote commands.
Run: python cloud_audit.py --providers Azure AWS GCP --output report.json
Serve behind HTTPS: python cloud_audit.py --serve
"""
import argparse, concurrent.futures, datetime, hashlib, hmac, json, os, re, subprocess, threading, time, traceback, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
NOW=lambda:datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z')
RUNNER_VERSION='3.6-evidence-progress'
DOCS={'Azure':'https://learn.microsoft.com/security/benchmark/azure/overview','AWS':'https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-standards-fsbp.html','GCP':'https://cloud.google.com/security/best-practices'}
FRAMEWORKS=['PCI DSS 4.0.1','HIPAA','HITRUST CSF','SOX ITGC','ISO 27001:2022','Microsoft Cloud Security Benchmark','CIS Azure Foundations']
MODULE_COUNTS={'Azure':28,'AWS':10,'GCP':7}
CONTROL_MAP={
 'Identity':{'PCI DSS 4.0.1':'7, 8','HIPAA':'164.308(a)(3)-(5), 164.312(a),(d)','HITRUST CSF':'Access Control / Identity','SOX ITGC':'Logical Access','ISO 27001:2022':'A.5.15-A.5.18, A.8.2','Microsoft Cloud Security Benchmark':'IM-1 through IM-8','CIS Azure Foundations':'Identity and Access Management domain'},
 'Privileged access':{'PCI DSS 4.0.1':'7.2, 7.3, 8.2-8.4','HIPAA':'164.308(a)(3)-(4), 164.312(a),(d)','HITRUST CSF':'Privileged Access Management','SOX ITGC':'Privileged Logical Access','ISO 27001:2022':'A.5.15-A.5.18, A.8.2-A.8.5','Microsoft Cloud Security Benchmark':'PA-1 through PA-8','CIS Azure Foundations':'Identity and Access Management domain'},
 'Network':{'PCI DSS 4.0.1':'1, 2','HIPAA':'164.312(e)','HITRUST CSF':'Network Protection','SOX ITGC':'IT Operations / Access','ISO 27001:2022':'A.8.20-A.8.22','Microsoft Cloud Security Benchmark':'NS-1 through NS-4','CIS Azure Foundations':'Networking domain'},
 'Data protection':{'PCI DSS 4.0.1':'3, 4','HIPAA':'164.312(a),(c),(e)','HITRUST CSF':'Data Protection / Cryptography','SOX ITGC':'Data Integrity / Operations','ISO 27001:2022':'A.8.10-A.8.13, A.8.24','Microsoft Cloud Security Benchmark':'DP-1 through DP-8','CIS Azure Foundations':'Storage, database and Key Vault domains'},
 'Logging':{'PCI DSS 4.0.1':'10','HIPAA':'164.308(a)(1), 164.312(b)','HITRUST CSF':'Audit Logging / Monitoring','SOX ITGC':'IT Operations / Monitoring','ISO 27001:2022':'A.8.15-A.8.17','Microsoft Cloud Security Benchmark':'LT-1 through LT-7','CIS Azure Foundations':'Logging and Monitoring domain'},
 'Detection':{'PCI DSS 4.0.1':'10, 11, 12','HIPAA':'164.308(a)(1),(6)','HITRUST CSF':'Incident Management','SOX ITGC':'IT Operations / Incident Management','ISO 27001:2022':'A.5.24-A.5.28, A.8.16','Microsoft Cloud Security Benchmark':'LT, IR and PV control families','CIS Azure Foundations':'Microsoft Defender for Cloud domain'},
 'Compute':{'PCI DSS 4.0.1':'2, 5, 6, 11','HIPAA':'164.308(a)(5), 164.312(a),(c)','HITRUST CSF':'Endpoint / Vulnerability Management','SOX ITGC':'Change Management / Operations','ISO 27001:2022':'A.8.7-A.8.9','Microsoft Cloud Security Benchmark':'ES, PV and DS control families','CIS Azure Foundations':'Virtual Machines domain'},
 'Governance':{'PCI DSS 4.0.1':'12','HIPAA':'164.308(a)(1),(8)','HITRUST CSF':'Risk Management','SOX ITGC':'Governance / Risk','ISO 27001:2022':'Clauses 4-10, A.5.1-A.5.4','Microsoft Cloud Security Benchmark':'GS-1 through GS-10','CIS Azure Foundations':'Governance and Policy domain'}
 ,'Cost optimization':{'PCI DSS 4.0.1':'Evidence context only','HIPAA':'Evidence context only','HITRUST CSF':'Risk Management context','SOX ITGC':'IT financial governance context','ISO 27001:2022':'A.5.9 asset inventory context','Microsoft Cloud Security Benchmark':'Governance context','CIS Azure Foundations':'Inventory context'}
}

def command(args):
    """Only server-defined argv. CLI pagination remains enabled. Never return stderr."""
    try:
        p=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,timeout=150,env={**os.environ,'AWS_PAGER':'','CLOUDSDK_CORE_DISABLE_PROMPTS':'1'})
        if p.returncode:return None,'Collection failed: verify scope, API enablement, licensing and read permissions (CLI exit %s).'%p.returncode
        if len(p.stdout)>20_000_000:return None,'Evidence exceeds collection size limit; narrow the configured scope.'
        data=json.loads(p.stdout)
        if args[:2]==['az','rest'] and isinstance(data,dict):
            seen=set()
            while data.get('@odata.nextLink'):
                link=data['@odata.nextLink']; u=urlparse(link)
                if u.scheme!='https' or u.hostname!='graph.microsoft.com' or u.username or u.password or u.port not in [None,443] or link in seen or len(seen)>=100:
                    return None,'Graph pagination was unsafe or exceeded the 100-page limit. Narrow scope; no partial pass reported.'
                seen.add(link); nextargs=list(args); nextargs[nextargs.index('--url')+1]=link
                page=subprocess.run(nextargs,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,timeout=150)
                if page.returncode:return None,'Graph pagination failed. Full collection was not assessed.'
                more=json.loads(page.stdout)
                if not isinstance(more.get('value'),list):return None,'Graph pagination returned invalid evidence.'
                data['value'].extend(more['value'])
                if len(data['value'])>50000:return None,'Graph collection exceeds 50,000 objects. Narrow scope.'
                data['@odata.nextLink']=more.get('@odata.nextLink')
        return data,None
    except subprocess.TimeoutExpired:return None,'Collection timed out after 150 seconds; coverage is incomplete.'
    except (OSError,ValueError):return None,'CLI unavailable or returned invalid JSON.'

def path(o,key,default=None):
    for k in key.split('.'):
        if not isinstance(o,dict) or k not in o:return default
        o=o[k]
    return o

def exposed(rules,azure=False,ports_to_check=(22,3389)):
    for r in rules:
        if azure:
            if r.get('direction')!='Inbound' or r.get('access')!='Allow':continue
            sources=[r.get('sourceAddressPrefix')]+r.get('sourceAddressPrefixes',[])
            ports=[r.get('destinationPortRange')]+r.get('destinationPortRanges',[])
            if r.get('protocol','*').lower() not in ['*','tcp']:continue
        else:
            if r.get('IpProtocol') not in ['-1','tcp','6']:continue
            sources=[x.get('CidrIp') for x in r.get('IpRanges',[])]+[x.get('CidrIpv6') for x in r.get('Ipv6Ranges',[])]
            ports=['*'] if r.get('IpProtocol')=='-1' else [str(r.get('FromPort'))+'-'+str(r.get('ToPort'))]
        if not any(s in ['*','Internet','0.0.0.0/0','::/0'] for s in sources):continue
        for p in ports:
            if p=='*':return True
            if p and re.fullmatch(r'\d+(-\d+)?',p):
                bounds=[int(x) for x in p.split('-')]
                if any(bounds[0]<=v<=bounds[-1] for v in ports_to_check):return True
    return False

class Audit:
    def __init__(self,providers,scope_config=None,frameworks=None,progress=None):self.providers=providers;self.scope_config=scope_config or {};self.frameworks=frameworks or [];self.findings=[];self.scope=[];self.started=NOW();self.progress=progress or (lambda **_:None);self.step=0;self.total_steps=max(1,sum(MODULE_COUNTS.get(p,1) for p in providers)+len(self.frameworks)+1);self.current_provider='Runner'
    def notify(self,module,control,percent=None):
        if percent is None:percent=min(96,max(2,round((self.step/max(1,self.total_steps))*100)))
        self.progress(percent=percent,provider=self.current_provider,module=module,control=control,step=min(self.step,self.total_steps),totalSteps=self.total_steps,completedControls=len(self.findings),phase='Collecting evidence')
    def add(self,p,id,title,category,status,severity,resource,evidence,remediation,automation='automated',source=None):
        def clean(value):
            if isinstance(value,dict):return {k:clean(v) for k,v in value.items() if not re.search(r'(?i)(^key$|secret|token|passwordCredentials|keyCredentials|certificate|customKeyIdentifier|rawData)',k)}
            if isinstance(value,list):return [clean(x) for x in value[:100]]
            if isinstance(value,str) and len(value)>500:return '[large encoded or verbose value removed]'
            return value
        evidence=clean(evidence)
        evidence=evidence if isinstance(evidence,str) else json.dumps(evidence,sort_keys=True)
        if len(evidence)>11900:evidence=evidence[:11800]+' [EVIDENCE TRUNCATED: obtain full source export before sign-off]';status='review' if status=='pass' else status
        used={f['id'] for f in self.findings}
        unique=id if id not in used else id+'-'+str(len(self.findings)+1)
        mapped=[f+' · '+CONTROL_MAP.get(category,{}).get(f,'Evidence mapping requires validation') for f in self.frameworks]
        self.findings.append(dict(id=unique,provider=p,title=title,category=category,status=status,severity=severity,resource=str(resource)[:1000],evidence=(evidence if isinstance(evidence,str) else json.dumps(evidence,sort_keys=True))[:12000],remediation=remediation,source=source or DOCS[p],compliance=mapped,automation=automation))
    def collect(self,p,id,title,category,severity,args,root,checks,fix,resource='scope'):
        self.step+=1
        self.notify(category,title)
        data,error=command(args)
        if error:self.add(p,id,title,category,'unknown',severity,resource,error,fix);return
        objects=path(data,root) if root else data
        if isinstance(objects,dict):objects=[objects]
        if not isinstance(objects,list):self.add(p,id,title,category,'unknown',severity,resource,'Expected evidence structure was absent.',fix);return
        if not objects:self.add(p,id,title,category,'review','info',resource,'No resources returned in this collection scope. Validate scope and applicability; this is not a pass.',fix);return
        for i,o in enumerate(objects):
            if not isinstance(o,dict):continue
            name=o.get('id') or o.get('Arn') or o.get('InstanceId') or o.get('name') or o.get('UserName') or resource
            for suffix,label,test,fields in checks:
                try:status=test(o)
                except (KeyError,TypeError,ValueError):status='unknown'
                ev={f:path(o,f,'not returned') for f in fields}
                self.add(p,id+'-'+suffix+'-'+str(i),label or title,category,status,severity,name,ev,fix)
    def manual(self,p,id,title,cat,fix):self.add(p,id,title,cat,'unknown','high','Configured scope','Additional evidence and human validation required; not automatically tested.',fix,'manual')
    def azure(self):
        tenant=self.scope_config.get('azureTenantId') or os.getenv('AZURE_TENANT_ID','')
        subs=self.scope_config.get('azureSubscriptionIds') or [x.strip() for x in os.getenv('AZURE_SUBSCRIPTION_ID','').split(',') if x.strip()]
        if not re.fullmatch(r'[a-fA-F0-9-]{36}',tenant) or not subs or any(not re.fullmatch(r'[a-fA-F0-9-]{36}',x) for x in subs):self.manual('Azure','AZ-SCOPE','Azure scope not configured','Governance','Enter the tenant and subscription IDs in the web console or runner environment.');return
        for sub in subs:self._azure_subscription(sub,tenant)
    def _azure_subscription(self,sub,tenant):
        p='Azure'
        if not re.fullmatch(r'[a-fA-F0-9-]{36}',sub) or not re.fullmatch(r'[a-fA-F0-9-]{36}',tenant):self.manual(p,'AZ-SCOPE','Azure scope not configured','Governance','Set AZURE_SUBSCRIPTION_ID and AZURE_TENANT_ID.');return
        identity,error=command(['az','account','show','--subscription',sub,'-o','json'])
        if error or not identity or identity.get('tenantId','').lower()!=tenant.lower():self.manual(p,'AZ-SCOPE','Azure tenant verification failed','Governance','Authenticate the CLI to the configured tenant and subscription.');return
        self.scope.append('Azure subscription '+sub+'; tenant '+tenant)
        def az(*a):return ['az',*a,'--subscription',sub,'-o','json']
        def graph(endpoint):return ['az','rest','--method','GET','--url','https://graph.microsoft.com/v1.0/'+endpoint,'--subscription',sub,'-o','json']
        def arm(endpoint):return ['az','rest','--method','GET','--url','https://management.azure.com'+endpoint,'--subscription',sub,'-o','json']
        c=self.collect
        self.step+=1;self.notify('Cost optimization','Month-to-date resource cost and idle-resource recommendations')
        cost,error=command(['az','costmanagement','query','--type','ActualCost','--scope','/subscriptions/'+sub,'--timeframe','MonthToDate','--dataset-aggregation','{"totalCost":{"name":"Cost","function":"Sum"}}','--dataset-grouping','[{"type":"Dimension","name":"ResourceId"}]','-o','json'])
        if error:self.add(p,'AZ-COST','Azure monthly cost overview','Cost optimization','unknown','info',sub,'Cost data unavailable. Cost Management Reader permission may be required.','Grant Cost Management Reader only if cost reporting is approved, then rerun. Security checks continue without billing access.')
        elif isinstance(cost,dict):
            names=[str(x.get('name','')).lower() for x in cost.get('columns',[])];rows=cost.get('rows',[]);ci=next((i for i,x in enumerate(names) if 'cost' in x),None);ri=next((i for i,x in enumerate(names) if 'resourceid' in x),None);ui=next((i for i,x in enumerate(names) if 'currency' in x),None)
            ranked=[]
            for row in rows:
                try:ranked.append((float(row[ci]),str(row[ri]),str(row[ui]) if ui is not None else 'USD'))
                except (TypeError,ValueError,IndexError):continue
            for i,(amount,rid,currency) in enumerate(sorted(ranked,reverse=True)[:15]):self.add(p,'AZ-COST-'+str(i),'Month-to-date Azure resource cost','Cost optimization','review','info',rid,{'cost':round(amount,2),'currency':currency,'name':rid.rsplit('/',1)[-1]},'Review cost ownership, utilization and business purpose. Cost alone does not prove waste; validate with metrics and Azure Advisor before resizing or deleting.')
        c(p,'AZ-ADVISOR-COST','Azure Advisor cost and idle-resource recommendations','Cost optimization','medium',az('advisor','recommendation','list','--category','Cost'),None,[('recommendation','Potential cost optimization or idle resource',lambda o:'review',['resourceMetadata.resourceId','shortDescription.problem','shortDescription.solution','impact','extendedProperties'])],'Validate utilization, reservations, dependencies and recovery requirements before resizing, stopping or deleting any resource.')
        c(p,'AZ-VM','VM security configuration','Compute','high',az('vm','list'),None,[('identity','Managed identity on VM',lambda o:'pass' if path(o,'identity.type') else 'review',['name','identity.type']),('encryption','OS disk encryption configuration',lambda o:'review',['name','storageProfile.osDisk.managedDisk','securityProfile'])],'Review identity privileges, encryption at rest, encryption at host and Trusted Launch against workload requirements.')
        c(p,'AZ-NSG','Public management access rule','Network','critical',az('network','nsg','list'),None,[('ports','Unrestricted SSH or RDP rule',lambda o:'fail' if exposed(o.get('securityRules',[]),True) else 'pass',['name','securityRules'])],'Restrict Internet management rules. This checks NSG rule definitions; verify priorities, subnet/NIC associations and effective reachability.')
        c(p,'AZ-NSG-SERVICE','Public database and DNS exposure','Network','high',az('network','nsg','list'),None,[('ports','Unrestricted SQL, PostgreSQL or DNS rule',lambda o:'fail' if exposed(o.get('securityRules',[]),True,(53,1433,5432)) else 'pass',['name','securityRules'])],'Remove unrestricted service exposure or constrain it to approved sources and private connectivity. Verify rule priority, associations and effective reachability.')
        c(p,'AZ-ST','Storage configuration','Data protection','high',az('storage','account','list'),None,[('tls','Storage minimum TLS 1.2',lambda o:'pass' if o['minimumTlsVersion']=='TLS1_2' else 'fail',['minimumTlsVersion']),('https','Storage HTTPS only',lambda o:'pass' if o['enableHttpsTrafficOnly'] else 'fail',['enableHttpsTrafficOnly']),('public','Storage anonymous blob access disabled',lambda o:'pass' if o['allowBlobPublicAccess'] is False else 'fail',['allowBlobPublicAccess']),('network','Storage firewall or private access enforced',lambda o:'pass' if o.get('publicNetworkAccess')=='Disabled' or path(o,'networkRuleSet.defaultAction')=='Deny' else 'fail',['publicNetworkAccess','networkRuleSet.defaultAction','networkRuleSet.bypass','privateEndpointConnections']),('cmk','Storage encryption key source',lambda o:'review',['encryption.keySource','encryption.requireInfrastructureEncryption'])],'Enforce HTTPS and TLS 1.2; disable anonymous blob access; restrict networks and validate CMK/infrastructure encryption against data classification.')
        c(p,'AZ-KV','Key Vault recovery and authorization','Data protection','high',az('keyvault','list'),None,[('softdelete','Key Vault soft delete',lambda o:'pass' if o['properties']['enableSoftDelete'] is not False else 'fail',['properties.enableSoftDelete','properties.softDeleteRetentionInDays']),('purge','Key Vault purge protection',lambda o:'pass' if o['properties']['enablePurgeProtection'] else 'fail',['properties.enablePurgeProtection']),('rbac','Key Vault RBAC authorization',lambda o:'pass' if o['properties']['enableRbacAuthorization'] else 'review',['properties.enableRbacAuthorization']),('network','Key Vault firewall or private access enforced',lambda o:'pass' if path(o,'properties.publicNetworkAccess')=='Disabled' or path(o,'properties.networkAcls.defaultAction')=='Deny' else 'fail',['properties.publicNetworkAccess','properties.networkAcls.defaultAction','properties.networkAcls.bypass','properties.privateEndpointConnections'])],'Enable soft delete and purge protection, least-privilege RBAC, and private/firewalled access. Secret contents are never read.')
        c(p,'AZ-DEF','Defender plan coverage','Detection','medium',az('security','pricing','list'),'value',[('tier','Defender pricing tier',lambda o:'pass' if o['pricingTier']=='Standard' else 'review',['name','pricingTier','subPlan'])],'Review paid plan coverage and workload applicability with the security owner before changing licensing.')
        c(p,'AZ-RBAC','Azure role assignments','Privileged access','high',az('role','assignment','list','--all'),None,[('privilege','Azure privileged role assignment',lambda o:'review',['roleDefinitionName','principalName','principalId','principalType','scope','condition'])],'Review Owner, User Access Administrator, broad scopes, service principals, conditions and PIM eligibility. Collection contains assignments visible in configured scope.')
        roles,error=command(graph('roleManagement/directory/roleAssignments?$expand=principal,roleDefinition&$top=999'))
        if error:self.add(p,'AZ-ENTRA-ROLES','Entra privileged role inventory','Privileged access','unknown','critical',tenant,error,'Grant the runner application RoleManagement.Read.Directory and admin consent; then rerun.','automated','https://learn.microsoft.com/en-us/graph/api/rbacapplication-list-roleassignments')
        else:
            assignments=roles.get('value',[]) if isinstance(roles,dict) else []
            privileged=[x for x in assignments if path(x,'roleDefinition.isPrivileged') is True]
            globals=[x for x in assignments if path(x,'roleDefinition.displayName')=='Global Administrator']
            def principal(x):
                q=x.get('principal') or {};return {'role':path(x,'roleDefinition.displayName','Unknown'),'displayName':q.get('displayName'),'userPrincipalName':q.get('userPrincipalName'),'principalType':q.get('@odata.type','').split('.')[-1] or q.get('userType'),'userType':q.get('userType'),'accountEnabled':q.get('accountEnabled'),'directoryScopeId':x.get('directoryScopeId')}
            ga_status='fail' if len(globals)>=5 else ('review' if len(globals)<2 else 'pass')
            self.add(p,'AZ-GLOBAL-ADMINS','Global Administrator population','Privileged access',ga_status,'critical',tenant,{'count':len(globals),'assignments':[principal(x) for x in globals]},'Keep fewer than five Global Administrators, protect all with MFA, and maintain two cloud-only emergency access accounts. Validate account ownership and emergency-account monitoring.','hybrid','https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices')
            guest=[principal(x) for x in privileged if path(x,'principal.userType')=='Guest']
            self.add(p,'AZ-GUEST-ADMINS','Guest identities with privileged Entra roles','Privileged access','fail' if guest else 'pass','high',tenant,{'count':len(guest),'assignments':guest},'Remove unnecessary guest privilege; time-bound and review approved external administration.','automated','https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices')
            workloads=[principal(x) for x in privileged if 'servicePrincipal' in path(x,'principal.@odata.type','')]
            self.add(p,'AZ-WORKLOAD-ADMINS','Workload identities with privileged Entra roles','Privileged access','review' if workloads else 'pass','high',tenant,{'count':len(workloads),'assignments':workloads},'Confirm business owner, credential type, least privilege, monitoring, and lifecycle for every privileged workload identity.','hybrid','https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices')
        pim,error=command(graph('roleManagement/directory/roleEligibilityScheduleInstances?$expand=principal,roleDefinition&$top=999'))
        if error:self.add(p,'AZ-PIM','PIM eligible role assignments','Privileged access','unknown','high',tenant,error,'Grant RoleManagement.Read.Directory, confirm PIM licensing, and provide eligible plus active assignment evidence.','automated','https://learn.microsoft.com/en-us/graph/api/resources/privilegedidentitymanagementv3-overview')
        else:
            eligible=pim.get('value',[]) if isinstance(pim,dict) else []
            self.add(p,'AZ-PIM','Entra PIM just-in-time eligible role assignments','Privileged access','review' if eligible else 'unknown','high',tenant,{'eligibleAssignmentCount':len(eligible),'assignments':[{'role':path(x,'roleDefinition.displayName'),'principal':path(x,'principal.userPrincipalName') or path(x,'principal.displayName'),'startDateTime':x.get('startDateTime'),'endDateTime':x.get('endDateTime'),'assignmentType':x.get('assignmentType')} for x in eligible]},'Validate that privileged users are eligible rather than permanently active; require approval, MFA, justification, bounded activation, notification and recurring access reviews.','hybrid','https://learn.microsoft.com/en-us/graph/api/resources/privilegedidentitymanagementv3-overview')
        active_pim,error=command(graph('roleManagement/directory/roleAssignmentScheduleInstances?$expand=principal,roleDefinition&$top=999'))
        if error:self.add(p,'AZ-PIM-ACTIVE','Active and standing Entra PIM assignments','Privileged access','unknown','critical',tenant,error,'Grant RoleManagement.Read.Directory and review active, activated and standing privileged assignments.','automated','https://learn.microsoft.com/en-us/graph/api/resources/privilegedidentitymanagementv3-overview')
        else:
            active=active_pim.get('value',[]) if isinstance(active_pim,dict) else []
            standing=[x for x in active if path(x,'roleDefinition.isPrivileged') is True and x.get('assignmentType')!='Activated' and not x.get('endDateTime')]
            self.add(p,'AZ-PIM-ACTIVE','Active and standing Entra PIM assignments','Privileged access','review' if active else 'unknown','critical',tenant,{'activeAssignmentCount':len(active),'standingPrivilegedCount':len(standing),'standingAssignments':[{'role':path(x,'roleDefinition.displayName'),'principal':path(x,'principal.userPrincipalName') or path(x,'principal.displayName'),'assignmentType':x.get('assignmentType'),'endDateTime':x.get('endDateTime')} for x in standing]},'Confirm every standing privileged assignment is an approved exception. Prefer eligible, time-bound activation with MFA, approval, justification, notifications and audit review.','hybrid','https://learn.microsoft.com/en-us/graph/api/resources/privilegedidentitymanagementv3-overview')
        c(p,'AZ-DEFAULTS','Entra security defaults','Identity','high',graph('policies/identitySecurityDefaultsEnforcementPolicy'),None,[('enabled','Security defaults enabled',lambda o:'pass' if o['isEnabled'] else 'review',['isEnabled'])],'If disabled, validate equivalent enforced Conditional Access policies; disabled defaults alone do not establish a security failure.')
        ca,error=command(graph('identity/conditionalAccess/policies'))
        if error:self.add(p,'AZ-CA','Conditional Access policy collection','Identity','unknown','critical',tenant,error,'Grant Policy.Read.All or Policy.Read.ConditionalAccess with admin consent and rerun.','automated','https://learn.microsoft.com/en-us/graph/api/conditionalaccessroot-list-policies')
        else:
            policies=ca.get('value',[]) if isinstance(ca,dict) else []
            enabled=[x for x in policies if x.get('state')=='enabled']
            report_only=[x.get('displayName') for x in policies if x.get('state')=='enabledForReportingButNotEnforced']
            disabled=[x.get('displayName') for x in policies if x.get('state')=='disabled']
            def grants(x):return path(x,'grantControls.builtInControls',[]) or []
            def admin_target(x):
                users=path(x,'conditions.users',{}) or {};return bool(users.get('includeRoles')) or 'All' in users.get('includeUsers',[])
            admin_mfa=[x for x in enabled if admin_target(x) and ('mfa' in grants(x) or path(x,'grantControls.authenticationStrength'))]
            legacy=[x for x in enabled if 'block' in grants(x) and {'exchangeActiveSync','other'}.issubset(set(path(x,'conditions.clientAppTypes',[]) or []))]
            risk=[x for x in enabled if path(x,'conditions.userRiskLevels',[]) or path(x,'conditions.signInRiskLevels',[])]
            exclusions=[{'policy':x.get('displayName'),'excludeUsers':path(x,'conditions.users.excludeUsers',[]),'excludeGroups':path(x,'conditions.users.excludeGroups',[]),'excludeRoles':path(x,'conditions.users.excludeRoles',[])} for x in enabled if path(x,'conditions.users.excludeUsers',[]) or path(x,'conditions.users.excludeGroups',[]) or path(x,'conditions.users.excludeRoles',[])]
            self.add(p,'AZ-CA-ADMIN-MFA','Conditional Access MFA for administrators','Identity','review' if admin_mfa else 'unknown','critical',tenant,{'matchingEnabledPolicies':[x.get('displayName') for x in admin_mfa],'policyCount':len(policies),'reportOnlyPolicies':report_only},'Require MFA or phishing-resistant authentication strength for administrator roles. Validate target roles, resources, exclusions, emergency accounts and effective results with the What If tool.','hybrid','https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-old-require-mfa-admin')
            self.add(p,'AZ-CA-LEGACY','Conditional Access blocks legacy authentication','Identity','review' if legacy else 'unknown','high',tenant,{'matchingEnabledPolicies':[x.get('displayName') for x in legacy],'clientTypesRequired':['exchangeActiveSync','other']},'Block legacy authentication after identifying dependencies. Validate exclusions and effective results; policy presence alone is not proof of enforcement.','hybrid','https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions')
            self.add(p,'AZ-CA-RISK','Conditional Access risk-based controls','Identity','review' if risk else 'unknown','high',tenant,{'matchingEnabledPolicies':[x.get('displayName') for x in risk]},'Use supported user and sign-in risk conditions, then validate licensing, exclusions, remediation behavior and effective enforcement.','hybrid','https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview')
            self.add(p,'AZ-CA-EXCLUSIONS','Conditional Access exclusions and inactive policies','Identity','review','high',tenant,{'exclusions':exclusions,'reportOnlyPolicies':report_only,'disabledPolicies':disabled},'Confirm every exclusion has an owner, purpose, approval and expiry. Test emergency access separately; report-only and disabled policies do not enforce access.','hybrid','https://learn.microsoft.com/en-us/entra/identity/conditional-access/plan-conditional-access')
            for i,o in enumerate(policies):self.add(p,'AZ-CA-POLICY-'+str(i),'Conditional Access policy: '+str(o.get('displayName') or 'Unnamed'),'Identity','review','high',o.get('id') or tenant,{'displayName':o.get('displayName'),'state':o.get('state'),'conditions':o.get('conditions'),'grantControls':o.get('grantControls'),'sessionControls':o.get('sessionControls')},'Validate effective scope, target resources, administrator MFA, authentication strength, legacy authentication, device/risk conditions, exclusions and emergency access. Presence alone never passes coverage.','hybrid','https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy')
        c(p,'AZ-AUTH','Authentication methods policy','Identity','high',graph('policies/authenticationMethodsPolicy'),None,[('methods','Allowed authentication methods',lambda o:'review',['policyMigrationState','authenticationMethodConfigurations','registrationEnforcement'])],'Review phishing-resistant methods, SMS/voice exposure, registration campaigns and targeted groups.')
        c(p,'AZ-USERS','User account policy metadata','Identity','high',graph('users?$select=id,userPrincipalName,accountEnabled,userType,passwordPolicies,onPremisesSyncEnabled&$top=999'),'value',[('policy','User password flags and guest status',lambda o:'review',['userPrincipalName','accountEnabled','userType','passwordPolicies','onPremisesSyncEnabled'])],'Review exceptions, stale identities, guest access and hybrid policy authority. Graph pagination is followed; verify full tenant and policy scope for assurance.')
        stale,error=command(graph('users?$select=id,displayName,userPrincipalName,accountEnabled,userType,createdDateTime,signInActivity&$top=999'))
        if error:self.add(p,'AZ-STALE-USERS','Inactive and stale identity review','Identity','unknown','high',tenant,error,'Grant User.Read.All and AuditLog.Read.All, confirm licensing for sign-in activity, and rerun.','automated','https://learn.microsoft.com/en-us/graph/api/user-list')
        else:
            cutoff=datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(days=90); inactive=[]; stale_guests=[]
            users=stale.get('value',[]) if isinstance(stale,dict) else []
            for u in users:
                raw=path(u,'signInActivity.lastSuccessfulSignInDateTime') or path(u,'signInActivity.lastSignInDateTime'); dt=None
                try:dt=datetime.datetime.fromisoformat(raw.replace('Z','+00:00')) if raw else None
                except (TypeError,ValueError):pass
                if u.get('accountEnabled') and (dt is None or dt<cutoff):
                    item={'userPrincipalName':u.get('userPrincipalName'),'userType':u.get('userType'),'lastSuccessfulSignInDateTime':raw,'createdDateTime':u.get('createdDateTime')};inactive.append(item)
                    if u.get('userType')=='Guest':stale_guests.append(item)
            self.add(p,'AZ-STALE-USERS','Enabled accounts inactive for more than 90 days','Identity','review' if inactive else ('pass' if users else 'unknown'),'high',tenant,{'thresholdDays':90,'usersEvaluated':len(users),'count':len(inactive),'accounts':inactive},'Validate service/emergency accounts and data availability, then disable or remove stale accounts through the approved identity lifecycle. An empty result is not a pass.','hybrid','https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-manage-inactive-user-accounts')
            self.add(p,'AZ-STALE-GUESTS','Stale external guest identities','Identity','review' if stale_guests else ('pass' if users else 'unknown'),'high',tenant,{'thresholdDays':90,'usersEvaluated':len(users),'count':len(stale_guests),'accounts':stale_guests},'Confirm sponsorship and business need; remove or disable stale guests and establish recurring access reviews. An empty result is not a pass.','hybrid','https://learn.microsoft.com/en-us/entra/id-governance/access-reviews-overview')
        c(p,'AZ-MFA','MFA registration report','Identity','high',graph('reports/authenticationMethods/userRegistrationDetails?$top=999'),'value',[('registered','MFA registration (not enforcement)',lambda o:'pass' if o['isMfaRegistered'] else 'review',['userPrincipalName','isMfaRegistered','isMfaCapable','methodsRegistered'])],'Address unregistered accounts. Registration does not prove MFA enforcement; validate Conditional Access. Graph pagination is followed; review collection failures.')
        domains,error=command(graph('domains'))
        if error:self.add(p,'AZ-PASSWORD-POLICY','Entra password length, complexity and expiration','Identity','unknown','high',tenant,error,'Grant Domain.Read.All and provide hybrid directory evidence.','automated','https://learn.microsoft.com/en-us/entra/identity/authentication/concept-password-ban-bad-combined-policy')
        else:
            values=domains.get('value',[]) if isinstance(domains,dict) else []
            policy=[{'domain':x.get('id'),'authenticationType':x.get('authenticationType'),'passwordValidityPeriodInDays':x.get('passwordValidityPeriodInDays'),'passwordNotificationWindowInDays':x.get('passwordNotificationWindowInDays')} for x in values]
            self.add(p,'AZ-PASSWORD-POLICY','Entra password length, complexity and expiration','Identity','review' if values else 'unknown','high',tenant,{'cloudOnlyPlatformBaseline':{'length':'8-256 characters (Microsoft-enforced and not tenant-customizable)','complexity':'3 of 4 character categories (Microsoft-enforced; education tenant exception may apply)'},'domainExpirationPolicies':policy,'limitations':['Custom banned-password settings and on-premises AD fine-grained policies are not exposed by this collection.','Password expiration authority differs for cloud-only, federated and synchronized identities.']},'1. Confirm domain password expiration is intentionally configured; current Microsoft guidance generally discourages periodic expiration for cloud-only accounts. 2. Validate Entra global/custom banned password protection and smart lockout. 3. For synchronized or federated identities, collect the authoritative AD/IdP minimum length, complexity, history, lockout and expiration policies. 4. Prefer phishing-resistant MFA and passwordless authentication for privileged users.','hybrid','https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-password-policy-overview-frequently-asked-questions')
        c(p,'AZ-APP','App credential metadata','Identity','high',graph('applications?$select=id,displayName,passwordCredentials,keyCredentials&$top=999'),'value',[('expiry','Application credential lifetimes',lambda o:'review',['displayName','passwordCredentials','keyCredentials'])],'Review credential expiry, owners, unused applications and federated credentials. Metadata only; never retrieve secret values.')
        for suffix,endpoint,label,datefield in [('AUDIT','auditLogs/directoryAudits?$orderby=activityDateTime%20desc&$top=1','Entra directory audit log availability','activityDateTime'),('SIGNIN','auditLogs/signIns?$orderby=createdDateTime%20desc&$top=1','Entra sign-in log availability','createdDateTime')]:
            logs,error=command(graph(endpoint))
            if error:self.add(p,'AZ-LOG-'+suffix,label,'Logging','unknown','high',tenant,error,'Grant AuditLog.Read.All with admin consent and confirm the required Entra license.','automated','https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-access-activity-logs')
            else:
                recent=logs.get('value',[]) if isinstance(logs,dict) else []
                self.add(p,'AZ-LOG-'+suffix,label,'Logging','review' if recent else 'unknown','high',tenant,{'recordsReturned':len(recent),'latestEventTime':recent[0].get(datefield) if recent else None},'Confirm expected events are arriving, investigate collection gaps, and validate alerting plus retention at the configured destination. One recent record proves access, not completeness.','hybrid','https://learn.microsoft.com/en-us/entra/identity/monitoring-health/howto-access-activity-logs')
        entra_diag,error=command(arm('/providers/microsoft.aadiam/diagnosticSettings?api-version=2017-04-01-preview'))
        if error:self.add(p,'AZ-ENTRA-DIAG','Entra diagnostic log export','Logging','unknown','high',tenant,error,'Grant read access to Entra diagnostic settings and validate export of audit, sign-in, risk, service-principal and provisioning logs.','automated','https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-diagnostic-settings-logs-options')
        else:
            settings=entra_diag.get('value',[]) if isinstance(entra_diag,dict) else []
            enabled=sorted({x.get('category') for s in settings for x in path(s,'properties.logs',[]) if x.get('enabled')})
            destinations=[{'name':s.get('name'),'workspaceId':path(s,'properties.workspaceId'),'storageAccountId':path(s,'properties.storageAccountId'),'eventHubAuthorizationRuleId':path(s,'properties.eventHubAuthorizationRuleId')} for s in settings]
            self.add(p,'AZ-ENTRA-DIAG','Entra diagnostic log export','Logging','pass' if {'AuditLogs','SignInLogs'}.issubset(enabled) and destinations else 'fail','high',tenant,{'enabledCategories':enabled,'destinations':destinations},'Route AuditLogs and SignInLogs, plus applicable risk, workload identity, provisioning and lifecycle logs, to approved Log Analytics, storage or SIEM destinations.','automated','https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-diagnostic-settings-logs-options')
        diag,error=command(az('monitor','diagnostic-settings','subscription','list'))
        if error:self.add(p,'AZ-ACTIVITY-EXPORT','Subscription Activity Log export','Logging','unknown','high',sub,error,'Grant monitoring read access and rerun.','automated','https://learn.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings')
        else:
            settings=diag.get('value',[]) if isinstance(diag,dict) else []
            enabled=sorted({x.get('category') for s in settings for x in s.get('logs',[]) if x.get('enabled')})
            routed=any(s.get('workspaceId') or s.get('storageAccountId') or s.get('eventHubAuthorizationRuleId') for s in settings)
            required={'Administrative','Security','Policy','Alert','Recommendation','ServiceHealth','ResourceHealth'}
            self.add(p,'AZ-ACTIVITY-EXPORT','Subscription Activity Log export','Logging','pass' if routed and required.issubset(enabled) else 'fail','high',sub,{'enabledCategories':enabled,'destinations':[{'name':s.get('name'),'workspaceId':s.get('workspaceId'),'storageAccountId':s.get('storageAccountId'),'eventHubAuthorizationRuleId':s.get('eventHubAuthorizationRuleId')} for s in settings]},'Export all applicable Activity Log categories to an approved Log Analytics, immutable storage or SIEM destination and validate ingestion.','automated','https://learn.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings')
        c(p,'AZ-LAW','Log Analytics retention','Logging','medium',az('monitor','log-analytics','workspace','list'),None,[('retention','Log Analytics workspace retention',lambda o:'review',['name','retentionInDays','features','publicNetworkAccessForIngestion','publicNetworkAccessForQuery'])],'Validate retention against legal, regulatory and incident-response requirements; restrict public access where architecture permits.')
        c(p,'AZ-CUSTOM-ROLE','Custom Azure role permissions','Privileged access','critical',az('role','definition','list','--custom-role-only','true'),None,[('wildcard','Custom role wildcard actions',lambda o:'fail' if any('*' in str(x) for perm in o.get('permissions',[]) for x in perm.get('actions',[])+perm.get('dataActions',[])) else 'pass',['roleName','description','assignableScopes','permissions'])],'Replace broad wildcard permissions with the minimum required management-plane and data-plane actions; review assignments before changing the role.')
        c(p,'AZ-PUBLIC-IP','Public IP attachment review','Network','medium',az('network','public-ip','list'),None,[('orphan','Orphaned public IP address',lambda o:'review' if not o.get('ipConfiguration') else 'pass',['name','ipAddress','publicIPAllocationMethod','ipConfiguration','ddosSettings'])],'Remove unattached public IPs and separately document every required public exposure. An attached address passes only the orphan-resource test, not an exposure review.')
        c(p,'AZ-BASTION','Azure Bastion deployment','Network','medium',az('network','bastion','list'),None,[('config','Azure Bastion configuration',lambda o:'review',['name','sku','enableTunneling','enableIpConnect','ipConfigurations'])],'Use Azure Bastion or another approved private administration path for applicable workloads; validate SKU, network placement, logging and access controls.')
        c(p,'AZ-FIREWALL','Azure Firewall configuration','Network','high',az('network','firewall','list'),None,[('threat','Azure Firewall threat intelligence mode',lambda o:'pass' if o.get('threatIntelMode')=='Deny' else 'review',['name','sku','threatIntelMode','firewallPolicy','ipConfigurations','managementIpConfiguration'])],'Route applicable egress through an approved firewall and validate Alert and Deny threat intelligence, DNS, TLS inspection, rules, logs and exceptions.')
        c(p,'AZ-ROUTES','Route table egress review','Network','high',az('network','route-table','list'),None,[('egress','Route table and default egress paths',lambda o:'review',['name','disableBgpRoutePropagation','routes','subnets'])],'Confirm spoke default routes send applicable outbound traffic through the approved firewall/NVA and that bypass paths are documented.')
        c(p,'AZ-PRIVATE-ENDPOINT','Private Endpoint inventory','Network','high',az('network','private-endpoint','list'),None,[('coverage','Private Endpoint configuration',lambda o:'review',['name','subnet','privateLinkServiceConnections','customDnsConfigs','networkInterfaces'])],'Map private endpoints to every applicable PaaS resource; verify public network access is disabled and Private DNS resolves correctly.')
        c(p,'AZ-APPGW-WAF','Application Gateway WAF protection','Network','high',az('network','application-gateway','list'),None,[('waf','Application Gateway WAF enabled',lambda o:'pass' if path(o,'webApplicationFirewallConfiguration.enabled') is True or path(o,'firewallPolicy.id') else 'fail',['name','sku','webApplicationFirewallConfiguration','firewallPolicy','frontendIPConfigurations'])],'Place applicable Internet-facing applications behind a WAF in Prevention mode; tune and monitor managed rules and exclusions.')
        c(p,'AZ-DISK','Managed disk attachment and encryption','Data protection','high',az('disk','list'),None,[('orphan','Unattached managed disk',lambda o:'review' if not o.get('managedBy') else 'pass',['name','managedBy','diskState','encryption','encryptionSettingsCollection']),('encryption','Managed disk encryption configuration',lambda o:'review',['name','encryption.type','diskEncryptionSetId','encryptionSettingsCollection'])],'Remove approved-orphan disks after retention review; validate platform/CMK encryption and encryption at host against workload requirements.')
        c(p,'AZ-SQL','Azure SQL server network and TLS configuration','Data protection','high',az('sql','server','list'),None,[('public','Azure SQL public network access',lambda o:'pass' if o.get('publicNetworkAccess')=='Disabled' else 'review',['name','publicNetworkAccess','restrictOutboundNetworkAccess']),('tls','Azure SQL minimum TLS 1.2',lambda o:'pass' if o.get('minimalTlsVersion') in ['1.2','1.3'] else 'fail',['name','minimalTlsVersion','administratorLogin'])],'Use private endpoints, deny broad firewall access, enforce TLS 1.2+, configure Entra administration, verify TDE/auditing/threat protection and review database principals.')
        c(p,'AZ-DEF-ASSESS','Defender for Cloud recommendations','Detection','high',az('security','assessment','list'),None,[('health','Defender recommendation status',lambda o:'fail' if path(o,'status.code')=='Unhealthy' else ('pass' if path(o,'status.code')=='Healthy' else 'review'),['displayName','status.code','status.cause','resourceDetails','metadata.severity'])],'Prioritize high-severity unhealthy recommendations, document exemptions and track remediation SLA plus recurring review.')
        c(p,'AZ-DEF-AUTO','Defender continuous export and security automation','Detection','high',az('security','automation','list'),None,[('routing','Defender security automation',lambda o:'review',['name','isEnabled','scopes','sources','actions'])],'Continuously export applicable Defender alerts and recommendations to the monitored SIEM/Log Analytics/Event Hub destination and test routing.')
        sentinel,error=command(az('resource','list','--resource-type','Microsoft.OperationsManagement/solutions'))
        if error:self.add(p,'AZ-SENTINEL','Microsoft Sentinel or external SIEM integration','Detection','unknown','high',sub,error,'Grant read access to Operations Management solutions or provide approved external SIEM evidence.','automated','https://learn.microsoft.com/en-us/azure/sentinel/overview')
        else:
            solutions=sentinel if isinstance(sentinel,list) else []
            security_insights=[{'name':x.get('name'),'resourceGroup':x.get('resourceGroup'),'location':x.get('location')} for x in solutions if 'SecurityInsights' in str(x.get('name',''))]
            self.add(p,'AZ-SENTINEL','Microsoft Sentinel or external SIEM integration','Detection','review' if security_insights else 'unknown','high',sub,{'sentinelSolutions':security_insights,'solutionsEvaluated':len(solutions)},'Confirm Microsoft Sentinel data connectors, analytics, retention and incident routing, or provide equivalent evidence for the approved external SIEM. Presence does not prove ingestion or response effectiveness.','hybrid','https://learn.microsoft.com/en-us/azure/sentinel/overview')
        c(p,'AZ-POLICY-STATE','Azure Policy compliance summary','Governance','high',az('policy','state','summarize'),None,[('compliance','Azure Policy noncompliant resources',lambda o:'pass' if path(o,'results.nonCompliantResources')==0 else 'fail',['results','policyAssignments'])],'Remediate noncompliant resources, review exemptions, and use Deny/Modify/DeployIfNotExists effects only after staged impact testing.')
        vms,vm_error=command(az('vm','list'))
        jit,jit_error=command(az('security','jit-policy','list'))
        if vm_error or jit_error:self.add(p,'AZ-JIT','Defender for Cloud VM just-in-time access','Network','unknown','high',sub,vm_error or jit_error,'Grant read access to Microsoft Defender for Cloud JIT policies and VM inventory, then rerun.','automated','https://learn.microsoft.com/en-us/cli/azure/security/jit-policy')
        else:
            vm_list=vms if isinstance(vms,list) else []
            policies=jit.get('value',[]) if isinstance(jit,dict) else (jit if isinstance(jit,list) else [])
            protected={str(x.get('id','')).lower():x for policy in policies for x in policy.get('virtualMachines',[]) if x.get('id')}
            if not vm_list:self.add(p,'AZ-JIT-NONE','Defender for Cloud VM just-in-time access','Network','review','info',sub,{'virtualMachineCount':0,'jitPolicyCount':len(policies)},'Validate that no virtual machines are in scope; an empty inventory is not a pass.','automated','https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-just-in-time-access')
            for i,vm in enumerate(vm_list):
                rid=str(vm.get('id','')); policy=protected.get(rid.lower()); ports=policy.get('ports',[]) if policy else []
                self.add(p,'AZ-JIT-VM-'+str(i),'VM protected by just-in-time network access','Network','pass' if policy else 'review','high',rid or vm.get('name','VM'),{'jitConfigured':bool(policy),'ports':ports},'Enable and validate Defender for Cloud JIT for applicable management ports. Confirm allowed sources, maximum duration, request authorization, logging and emergency procedures.','hybrid','https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-just-in-time-access')
        for id,title,args in [('AZ-ACTIVITY-ALERT','Activity Log alert rules',az('monitor','activity-log','alert','list')),('AZ-ACTION-GROUP','Alert notification action groups',az('monitor','action-group','list')),('AZ-POLICY','Azure Policy assignments',az('policy','assignment','list'))]:c(p,id,title,'Detection' if 'ALERT' in id or 'ACTION' in id else 'Governance','high',args,None,[('inventory',title,lambda o:'review',['name','enabled','condition','actions','scope','policyDefinitionId','enforcementMode'])],'Validate coverage, severity, routing, ownership, testing and exceptions against the approved monitoring and governance baseline.')
        vaults,vault_error=command(az('keyvault','list'))
        if not vault_error:
            for i,v in enumerate(vaults if isinstance(vaults,list) else []):
                rid=v.get('id'); name=v.get('name','Key Vault')
                if not rid:continue
                ds,err=command(['az','monitor','diagnostic-settings','list','--resource',rid,'--subscription',sub,'-o','json'])
                if err:self.add(p,'AZ-KV-DIAG-'+str(i),'Key Vault audit logging','Logging','unknown','high',name,err,'Enable Key Vault audit logging to an approved destination and validate ingestion.','automated','https://learn.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings')
                else:
                    values=ds.get('value',[]) if isinstance(ds,dict) else []
                    audit=any(any(x.get('enabled') and (x.get('category')=='AuditEvent' or x.get('categoryGroup') in ['audit','allLogs']) for x in s.get('logs',[])) and (s.get('workspaceId') or s.get('storageAccountId') or s.get('eventHubAuthorizationRuleId')) for s in values)
                    self.add(p,'AZ-KV-DIAG-'+str(i),'Key Vault audit logging','Logging','pass' if audit else 'fail','high',rid,{'diagnosticSettings':values},'Enable AuditEvent or audit/allLogs and route to an approved monitored destination.','automated','https://learn.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings')
        for id,title,cat,fix in [('ACCESS-REVIEWS','Privileged access reviews and emergency-account tests','Privileged access','Provide completed access-review decisions, emergency-access monitoring alerts and test records; configuration alone does not prove operation.'),('GUEST','Guest invitation, consent and cross-tenant restrictions','Identity','Provide authorization policy, guest inviter restrictions, consent settings, cross-tenant access and guest lifecycle evidence.'),('SEGMENT','Prod, Dev and QA network segmentation','Network','Provide the approved network architecture, effective routes, ASG/NSG associations, firewall policy and segmentation test evidence.'),('SAS','Shared Access Signature governance','Data protection','Provide SAS issuance controls or logs proving short lifetimes, HTTPS-only use, scoped permissions and approved source IP restrictions. Existing SAS tokens cannot be comprehensively inventoried from management-plane configuration.'),('SQL-DATA','SQL TDE, auditing, threat protection, masking and classification','Data protection','Provide database-level TDE, auditing destination, Defender, Dynamic Data Masking, classification and privileged database-user evidence.'),('SECRETS','Key, certificate and secret expiration and rotation','Data protection','Provide Key Vault metadata-only expiration/rotation exports and rotation-job evidence. Secret values must never be collected.'),('OS','Guest OS patching, EDR and local account controls','Compute','Use an approved inventory agent or read-only OS exports for supported OS status, Azure Update Manager, patch compliance, EDR coverage, local accounts and SSH/PAM or Windows policies.'),('IAC','Infrastructure-as-code security gates and drift control','Governance','Provide Terraform/Bicep repositories, branch protection, security review, policy-as-code scanning and deployment-path evidence.'),('LOG-OPS','SIEM response, log integrity and recovery testing','Logging','Provide ingestion health, security alert tests, log access-control, immutability, retention and incident-response exercise evidence.')]:self.manual(p,'AZ-'+id,title,cat,fix)
    def aws(self):
        p='AWS';account=self.scope_config.get('awsAccountId') or os.getenv('AWS_ACCOUNT_ID','');regions=self.scope_config.get('awsRegions') or [x.strip() for x in os.getenv('AWS_REGIONS','').split(',') if x.strip()]
        ident,err=command(['aws','sts','get-caller-identity','--output','json'])
        if not re.fullmatch(r'\d{12}',account) or err or not ident or ident.get('Account')!=account or not regions or any(not re.fullmatch(r'[a-z]{2}(?:-gov)?-[a-z]+-\d',r) for r in regions):self.manual(p,'AWS-SCOPE','AWS identity or regional scope not verified','Governance','Set AWS_ACCOUNT_ID and AWS_REGIONS. Authenticate using a read-only role in that account.');return
        self.scope.append('AWS account '+account+'; regions '+', '.join(regions))
        def aws(*a):return ['aws',*a,'--output','json']
        c=self.collect
        c(p,'AWS-ROOT','Root account security','Identity','critical',aws('iam','get-account-summary'),'SummaryMap',[('mfa','Root account MFA enabled',lambda o:'pass' if o['AccountMFAEnabled']==1 else 'fail',['AccountMFAEnabled']),('keys','No root access keys',lambda o:'pass' if o['AccountAccessKeysPresent']==0 else 'fail',['AccountAccessKeysPresent'])],'Enable root MFA, remove root access keys and use centrally governed emergency access.')
        c(p,'AWS-PWD','IAM password length, complexity, reuse and expiration','Identity','high',aws('iam','get-account-password-policy'),'PasswordPolicy',[('policy','IAM password policy controls',lambda o:'fail' if o.get('MinimumPasswordLength',0)<14 or not all(o.get(k) is True for k in ['RequireSymbols','RequireNumbers','RequireUppercaseCharacters','RequireLowercaseCharacters']) else 'review',['MinimumPasswordLength','PasswordReusePrevention','RequireSymbols','RequireNumbers','RequireUppercaseCharacters','RequireLowercaseCharacters','ExpirePasswords','MaxPasswordAge','HardExpiry','AllowUsersToChangePassword'])],'1. Prefer federation and short-lived sessions. 2. For remaining IAM users, require at least 14 characters and the approved complexity controls. 3. Configure password reuse prevention. 4. Review expiration against the organization policy; forced periodic rotation is not a substitute for MFA and compromise-driven reset. 5. Review IAM Identity Center separately. Missing policy evidence is not a pass.')
        c(p,'AWS-USERS','IAM user inventory','Identity','high',aws('iam','list-users'),'Users',[('users','IAM user lifecycle metadata',lambda o:'review',['UserName','Arn','CreateDate','PasswordLastUsed'])],'Review inactive users, MFA, access key age/use and policies. This listing alone does not evaluate key or MFA status.')
        c(p,'AWS-S3','S3 account public access block','Data protection','high',aws('s3control','get-public-access-block','--account-id',account),'PublicAccessBlockConfiguration',[('block','All S3 account public access blocks enabled',lambda o:'pass' if all(o[k] is True for k in ['BlockPublicAcls','IgnorePublicAcls','BlockPublicPolicy','RestrictPublicBuckets']) else 'fail',['BlockPublicAcls','IgnorePublicAcls','BlockPublicPolicy','RestrictPublicBuckets'])],'Enable all four account-level public access block controls after checking legitimate public workloads. Review per-bucket policies separately.')
        for r in regions:
            def regional(*a):return aws(*a,'--region',r)
            c(p,'AWS-EC2-'+r,'EC2 metadata options','Compute','high',regional('ec2','describe-instances'),'Reservations',[('inventory','EC2 instance configurations',lambda o:'review',['Instances'])],'Review each instance configuration. Individual IMDSv2 results follow when collection succeeds.',r)
            data,error=command(regional('ec2','describe-instances'))
            if not error:
                for res in data.get('Reservations',[]):
                    for vm in res.get('Instances',[]):self.add(p,'AWS-IMDS-'+vm['InstanceId'],'IMDSv2 required','Compute','pass' if path(vm,'MetadataOptions.HttpTokens')=='required' else 'fail','high',vm['InstanceId'],{'HttpTokens':path(vm,'MetadataOptions.HttpTokens'),'region':r},'Require IMDSv2 after application compatibility testing.')
            c(p,'AWS-SG-'+r,'Security group management exposure','Network','critical',regional('ec2','describe-security-groups'),'SecurityGroups',[('ports','Unrestricted SSH or RDP security group rule',lambda o:'fail' if exposed(o.get('IpPermissions',[])) else 'pass',['GroupId','GroupName','IpPermissions'])],'Restrict public management rules. Confirm attachments, routes and network ACLs; this is rule exposure, not proof of reachability.',r)
            c(p,'AWS-EBS-'+r,'EBS default encryption','Data protection','high',regional('ec2','get-ebs-encryption-by-default'),None,[('default','EBS encryption by default',lambda o:'pass' if o['EbsEncryptionByDefault'] else 'fail',['EbsEncryptionByDefault'])],'Enable default encryption; separately remediate existing unencrypted volumes.',r)
            c(p,'AWS-VOL-'+r,'EBS volume encryption','Data protection','high',regional('ec2','describe-volumes'),'Volumes',[('volume','Existing EBS volume encrypted',lambda o:'pass' if o['Encrypted'] else 'fail',['VolumeId','Encrypted','KmsKeyId'])],'Migrate unencrypted volumes using an approved snapshot/copy procedure.',r)
            c(p,'AWS-RDS-'+r,'RDS protection','Data protection','high',regional('rds','describe-db-instances'),'DBInstances',[('public','RDS public accessibility disabled',lambda o:'pass' if not o['PubliclyAccessible'] else 'fail',['DBInstanceIdentifier','PubliclyAccessible']),('encryption','RDS storage encrypted',lambda o:'pass' if o['StorageEncrypted'] else 'fail',['StorageEncrypted']),('backup','RDS automated backups enabled',lambda o:'pass' if o['BackupRetentionPeriod']>0 else 'fail',['BackupRetentionPeriod']),('delete','RDS deletion protection',lambda o:'pass' if o['DeletionProtection'] else 'review',['DeletionProtection'])],'Use private networking, encrypted storage, recovery-tested backups and appropriate deletion protection.',r)
            c(p,'AWS-TRAIL-'+r,'CloudTrail configuration','Logging','high',regional('cloudtrail','describe-trails'),'trailList',[('multi','CloudTrail multi-region coverage',lambda o:'pass' if o['IsMultiRegionTrail'] else 'review',['Name','IsMultiRegionTrail']),('validation','CloudTrail integrity validation',lambda o:'pass' if o['LogFileValidationEnabled'] else 'fail',['LogFileValidationEnabled','KmsKeyId'])],'Verify active trail delivery, event selectors, retention, KMS permissions and protected log destinations; configured trails may not be actively logging.',r)
        for id,title,cat,fix in [('SSO','IAM Identity Center and federation policy','Identity','Provide identity source MFA, session/password policy, permission sets and emergency account evidence.'),('IAM','Effective permissions and credential lifecycle','Identity','Provide IAM credential report, access key last-used metadata, Access Analyzer findings and SCP/permission boundary evidence.'),('DETECT','Security Hub, GuardDuty and incident readiness','Detection','Review detector enablement across all accounts and regions, Security Hub findings, alert routing and response testing.'),('BACKUP','Backup resilience and guest OS hardening','Compute','Provide backup vault lock/recovery evidence and SSM/EDR/patch inventory. AWS API configuration cannot prove local password or OS controls.')]:self.manual(p,'AWS-'+id,title,cat,fix)
    def gcp(self):
        projects=self.scope_config.get('gcpProjectIds') or [x.strip() for x in os.getenv('GCP_PROJECT_ID','').split(',') if x.strip()]
        if not projects or any(not re.fullmatch(r'[a-z][a-z0-9-]{4,28}[a-z0-9]',x) for x in projects):self.manual('GCP','GCP-SCOPE','GCP project scope not configured','Governance','Enter GCP project IDs in the web console or runner environment.');return
        for project in projects:self._gcp_project(project)
        org=self.scope_config.get('gcpOrganizationId','');folders=self.scope_config.get('gcpFolderIds',[])
        if org:self.scope.append('GCP organization '+org)
        if folders:self.scope.append('GCP folders '+', '.join(folders))
    def _gcp_project(self,project):
        p='GCP'
        if not re.fullmatch(r'[a-z][a-z0-9-]{4,28}[a-z0-9]',project):self.manual(p,'GCP-SCOPE','GCP project scope not configured','Governance','Set GCP_PROJECT_ID and authenticate through workload identity or service account impersonation.');return
        self.scope.append('GCP project '+project)
        def gc(*a):return ['gcloud',*a,'--project',project,'--format=json','--quiet']
        c=self.collect
        c(p,'GCP-VM','Compute Engine hardening','Compute','high',gc('compute','instances','list'),None,[('boot','Shielded VM secure boot',lambda o:'pass' if o['shieldedInstanceConfig']['enableSecureBoot'] else 'review',['name','zone','shieldedInstanceConfig']),('ip','External IP configuration',lambda o:'review' if any(n.get('accessConfigs') for n in o.get('networkInterfaces',[])) else 'pass',['networkInterfaces']),('identity','VM service account scopes',lambda o:'review',['serviceAccounts','metadata'])],'Review Secure Boot compatibility, external IP need, OS Login, project-wide SSH keys and least-privilege service accounts.')
        def fw(o):
            if o.get('disabled') or o.get('direction','INGRESS')!='INGRESS':return 'pass'
            if not any(x in ['0.0.0.0/0','::/0'] for x in o.get('sourceRanges',['0.0.0.0/0'])):return 'pass'
            for rule in o.get('allowed',[]):
                if rule.get('IPProtocol')=='all':return 'fail'
                if rule.get('IPProtocol') in ['tcp','6']:
                    for port in rule.get('ports',['0-65535']):
                        b=[int(x) for x in port.split('-')]
                        if any(b[0]<=x<=b[-1] for x in [22,3389]):return 'fail'
            return 'pass'
        c(p,'GCP-FW','Firewall management exposure','Network','critical',gc('compute','firewall-rules','list'),None,[('ports','Unrestricted SSH or RDP firewall rule',fw,['name','direction','disabled','priority','sourceRanges','allowed','targetTags','targetServiceAccounts'])],'Restrict public management access; validate effective hierarchical policies, rule priority and target applicability.')
        c(p,'GCP-IAM','Project IAM policy','Identity','high',gc('projects','get-iam-policy',project),None,[('bindings','Privileged and public IAM bindings',lambda o:'fail' if any(any(m in ['allUsers','allAuthenticatedUsers'] for m in b.get('members',[])) for b in o.get('bindings',[])) else 'review',['bindings','auditConfigs'])],'Remove unintended public principals. Review basic Owner/Editor roles, inherited access, conditional bindings and data access audit logs.')
        c(p,'GCP-SA','Service account inventory','Identity','high',gc('iam','service-accounts','list'),None,[('accounts','Service account lifecycle',lambda o:'review',['email','disabled','displayName','uniqueId'])],'Review unused identities, user-managed keys, key age and impersonation grants. This inventory does not collect key contents.')
        c(p,'GCP-SQL','Cloud SQL security','Data protection','high',gc('sql','instances','list'),None,[('backup','Cloud SQL backups enabled',lambda o:'pass' if o['settings']['backupConfiguration']['enabled'] else 'fail',['name','settings.backupConfiguration']),('network','Cloud SQL public networking and TLS',lambda o:'review',['settings.ipConfiguration','settings.databaseFlags'])],'Validate private networking, restricted authorized networks, enforced TLS, supported database versions and tested point-in-time recovery.')
        c(p,'GCP-LOG','Logging sink configuration','Logging','high',gc('logging','sinks','list'),None,[('sinks','Logging destinations and exclusions',lambda o:'review',['name','destination','filter','disabled','exclusions'])],'Validate audit coverage, exclusion filters, log bucket retention, protected destinations and alert routing.')
        c(p,'GCP-BUCKET','Cloud Storage access settings','Data protection','high',gc('storage','buckets','list'),None,[('access','Bucket public access prevention',lambda o:'review',['name','public_access_prevention','uniform_bucket_level_access','retention_policy','soft_delete_policy'])],'Review effective public access prevention, bucket IAM, uniform access, recovery and retention requirements.')
        for id,title,cat,fix in [('DIRECTORY','Cloud Identity / Workspace authentication policies','Identity','Provide separate directory policy evidence for password length, reuse, 2SV enforcement, recovery, privileged admins and federation. GCP IAM alone cannot expose these.'),('ORG','Effective organization constraints and hierarchy','Governance','Provide organization/folder IDs and read access to effective org policies, inherited IAM and service account key restrictions.'),('SCC','Security Command Center coverage','Detection','Provide SCC findings read access and confirm tier, sources and organization/project activation.'),('OS','Guest OS and Kubernetes hardening','Compute','Provide approved guest OS inventory, local password/lockout policy, EDR and patch status; separately review GKE control plane, RBAC and workload policies.')]:self.manual(p,'GCP-'+id,title,cat,fix)
    def run(self):
        for p in self.providers:
            self.current_provider=p;self.notify(p+' security controls','Starting '+p+' evidence collection')
            try:getattr(self,{'Azure':'azure','AWS':'aws','GCP':'gcp'}[p])()
            except Exception:self.manual(p,p+'-ERROR','Provider assessment interrupted','Governance','Review runner logs and API response shape. Partial evidence is not full coverage.')
        for framework in self.frameworks:
            self.step+=1;self.current_provider='Compliance';self.notify('Compliance mapping',framework+' evidence coverage')
            for provider in self.providers:self.add(provider,'CMP-'+re.sub(r'[^A-Z0-9]','',framework.upper())+'-'+provider,framework+' non-technical evidence','Governance','unknown','high','Assessment engagement','Automated cloud configuration evidence covers only applicable technical controls. Administrative, procedural, contractual, physical, population-completeness and operating-effectiveness evidence was not automatically tested.','Complete the authoritative control-by-control assessment with the compliance owner and qualified assessor. HITRUST mappings require licensed MyCSF validation; SOX scope and key controls require auditor agreement.')
        self.step=self.total_steps-1;self.current_provider='Runner';self.notify('Report assembly','Validating and consolidating assessment evidence',96)
        return dict(schemaVersion=1,mode='live',startedAt=self.started,completedAt=NOW(),scope=self.scope or ['No verified cloud scope'],frameworks=self.frameworks,findings=self.findings)

# One bounded scan at a time. Jobs isolated by authenticated Site user, expire in one hour.
jobs={};lock=threading.Lock();executor=concurrent.futures.ThreadPoolExecutor(max_workers=1)
def finish(id,providers,scope,frameworks):
    try:
        def progress(**data):
            with lock:
                if id in jobs:jobs[id]['progress']=data
        report=Audit(providers,scope,frameworks,progress).run()
        total=sum(MODULE_COUNTS.get(p,1) for p in providers)+len(frameworks)+1
        with lock:jobs[id].update(status='completed',report=report,progress={'percent':100,'provider':'Runner','module':'Assessment complete','control':'Evidence collection and report assembly completed','step':total,'totalSteps':total,'completedControls':len(report.get('findings',[])),'phase':'Completed'})
    except Exception as error:
        with lock:
            progress=dict(jobs.get(id,{}).get('progress') or {})
            message=str(error).strip() or 'Unexpected runner exception'
            message=re.sub(r'(?i)(bearer|token|secret|password|authorization)(\s*[:=]?\s*)\S+',r'\1\2[REDACTED]',message)[:500]
            failure={'code':'RUNNER_EXCEPTION','exceptionType':type(error).__name__,'message':message,'provider':progress.get('provider','Runner'),'module':progress.get('module','Assessment execution'),'control':progress.get('control','Unknown control'),'phase':progress.get('phase','Collecting evidence'),'failedAt':NOW(),'action':'Review the runner service logs and the named module, verify cloud CLI authentication and read permissions, then retry the assessment.'}
            jobs[id].update(status='failed',error='Assessment failed during '+failure['module']+'.',failure=failure)
        print(json.dumps({'event':'assessment_failed','jobId':id,'failure':failure,'traceback':traceback.format_exc(limit=8)}),file=os.sys.stderr,flush=True)
class Handler(BaseHTTPRequestHandler):
    def log_message(self,*a):pass
    def send(self,status,data):
        b=json.dumps(data).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
    def authorized(self):
        token=os.getenv('CLOUD_RUNNER_TOKEN','');given=self.headers.get('Authorization','')
        return len(token)>=32 and hmac.compare_digest(given.encode(),('Bearer '+token).encode()) and bool(self.headers.get('X-Assessment-User'))
    def update_authorized(self):
        root=os.getenv('CLOUD_RUNNER_TOKEN','');given=self.headers.get('X-Runner-Update-Token','')
        token=hashlib.sha256(('runner-update:'+root).encode()).hexdigest()
        return len(root)>=32 and hmac.compare_digest(given.encode(),token.encode())
    def do_POST(self):
        if self.path=='/admin/update':
            if not self.update_authorized():return self.send(401,{'error':'Unauthorized'})
            try:
                size=int(self.headers.get('Content-Length','0'))
                if not 0<size<=500000:raise ValueError('Invalid update size')
                source=self.rfile.read(size);expected=self.headers.get('X-Runner-SHA256','')
                if not re.fullmatch(r'[a-f0-9]{64}',expected) or not hmac.compare_digest(hashlib.sha256(source).hexdigest(),expected):raise ValueError('Update hash mismatch')
                text=source.decode('utf-8');match=re.search(r"^RUNNER_VERSION='([^']+)'$",text,re.M)
                current=tuple(int(x) for x in re.match(r'^(\d+)\.(\d+)',RUNNER_VERSION).groups());incoming=tuple(int(x) for x in re.match(r'^(\d+)\.(\d+)',match.group(1)).groups()) if match and re.match(r'^(\d+)\.(\d+)',match.group(1)) else (0,0)
                if not match or 'class Handler(BaseHTTPRequestHandler):' not in text:raise ValueError('Invalid runner release')
                if incoming==current:return self.send(200,{'updated':False,'fromVersion':RUNNER_VERSION,'toVersion':RUNNER_VERSION,'message':'Runner is already current'})
                if incoming<current:raise ValueError('Refusing an older runner release')
                target=os.path.realpath(__file__);staged=target+'.new';backup=target+'.previous'
                with open(staged,'wb') as f:f.write(source)
                os.chmod(staged,0o660);subprocess.run([os.sys.executable,'-m','py_compile',staged],check=True,timeout=20)
                if os.path.exists(backup):os.unlink(backup)
                os.replace(target,backup);os.replace(staged,target)
                threading.Thread(target=lambda:(time.sleep(.3),os.execv(os.sys.executable,[os.sys.executable,target,'--serve'])),daemon=True).start()
                return self.send(200,{'updated':True,'fromVersion':RUNNER_VERSION,'toVersion':match.group(1)})
            except Exception as error:
                try:
                    if os.path.exists(os.path.realpath(__file__)+'.new'):os.unlink(os.path.realpath(__file__)+'.new')
                except Exception:pass
                return self.send(400,{'error':str(error)[:160]})
        if not self.authorized():return self.send(401,{'error':'Unauthorized'})
        if self.path!='/jobs':return self.send(404,{'error':'Not found'})
        try:
            size=int(self.headers.get('Content-Length','0'))
            if not 0<size<=8192:raise ValueError()
            body=json.loads(self.rfile.read(size));providers=body['providers'];scope=body.get('scope') or {};frameworks=body.get('frameworks') or []
            if not isinstance(providers,list) or not providers or len(providers)>3 or any(p not in ['Azure','AWS','GCP'] for p in providers):raise ValueError()
            if not isinstance(scope,dict):raise ValueError()
            if not isinstance(frameworks,list) or len(frameworks)>7 or any(f not in FRAMEWORKS for f in frameworks):raise ValueError()
            allowed={'azureTenantId','azureSubscriptionIds','awsAccountId','awsRegions','gcpProjectIds','gcpOrganizationId','gcpFolderIds'}
            if set(scope)-allowed:raise ValueError()
            for key,value in scope.items():
                if isinstance(value,str) and len(value)<=100:continue
                if isinstance(value,list) and len(value)<=25 and all(isinstance(x,str) and len(x)<=100 for x in value):continue
                raise ValueError()
            uuid_re=re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',re.I)
            if scope.get('azureTenantId') and not uuid_re.fullmatch(scope['azureTenantId']):raise ValueError()
            if any(not uuid_re.fullmatch(x) for x in scope.get('azureSubscriptionIds',[])):raise ValueError()
            if scope.get('awsAccountId') and not re.fullmatch(r'\d{12}',scope['awsAccountId']):raise ValueError()
            if any(not re.fullmatch(r'[a-z]{2}(?:-gov)?-[a-z]+-\d',x) for x in scope.get('awsRegions',[])):raise ValueError()
            if any(not re.fullmatch(r'[a-z][a-z0-9-]{4,28}[a-z0-9]',x) for x in scope.get('gcpProjectIds',[])):raise ValueError()
            if scope.get('gcpOrganizationId') and not re.fullmatch(r'\d{6,30}',scope['gcpOrganizationId']):raise ValueError()
            if any(not re.fullmatch(r'\d{6,30}',x) for x in scope.get('gcpFolderIds',[])):raise ValueError()
        except (ValueError,KeyError,TypeError):return self.send(400,{'error':'Invalid providers'})
        with lock:
            for key in list(jobs):
                if jobs[key]['status']!='running' and time.time()-jobs[key]['created']>3600:del jobs[key]
            if any(j['status']=='running' for j in jobs.values()) or len(jobs)>=100:return self.send(429,{'error':'Runner busy'})
            id=str(uuid.uuid4());total=sum(MODULE_COUNTS.get(p,1) for p in providers)+len(frameworks)+1;progress={'percent':1,'provider':'Runner','module':'Initialization','control':'Validating scope and preparing assessment modules','step':0,'totalSteps':total,'completedControls':0,'phase':'Starting'};jobs[id]={'id':id,'status':'running','user':self.headers['X-Assessment-User'],'created':time.time(),'progress':progress}
        executor.submit(finish,id,list(dict.fromkeys(providers)),scope,list(dict.fromkeys(frameworks)));return self.send(202,{'id':id,'status':'running','scopeAccepted': True,'runnerVersion':RUNNER_VERSION,'progress':progress})
    def do_GET(self):
        if not self.authorized():return self.send(401,{'error':'Unauthorized'})
        if self.path=='/status':
            user=self.headers['X-Assessment-User']
            with lock:
                active=[j for j in jobs.values() if j.get('user')==user and j.get('status')=='running']
                current=active[0] if active else None
                active_job={'id':current['id'],'status':'running','progress':current.get('progress')} if current else None
            return self.send(200,{'active':True,'valid':True,'runnerVersion':RUNNER_VERSION,'runningJobs':len(active),'activeJob':active_job})
        id=self.path.removeprefix('/jobs/')
        with lock:
            job=jobs.get(id)
            if not job or job['user']!=self.headers['X-Assessment-User'] or (job['status']!='running' and time.time()-job['created']>3600):return self.send(404,{'error':'Not found'})
            result={k:v for k,v in job.items() if k not in ['user','created']}
        return self.send(200,result)
if __name__=='__main__':
    parser=argparse.ArgumentParser();parser.add_argument('--providers',nargs='+',choices=['Azure','AWS','GCP'],default=['Azure','AWS','GCP']);parser.add_argument('--frameworks',nargs='*',choices=FRAMEWORKS,default=[]);parser.add_argument('--output',default='report.json');parser.add_argument('--serve',action='store_true');args=parser.parse_args()
    if args.serve:
        if len(os.getenv('CLOUD_RUNNER_TOKEN',''))<32:raise SystemExit('Set CLOUD_RUNNER_TOKEN to a random secret of at least 32 characters.')
        ThreadingHTTPServer(('127.0.0.1',8788),Handler).serve_forever()
    else:
        report=Audit(args.providers,frameworks=args.frameworks).run()
        fd=os.open(args.output,os.O_CREAT|os.O_TRUNC|os.O_WRONLY,0o600)
        with os.fdopen(fd,'w') as f:json.dump(report,f,indent=2)
        print('Report written. Review unknown and manual controls before drawing conclusions.')
