public/Get-MsecAzureDevOpsAlert.ps1

function Get-MsecAzureDevOpsAlert {
    <#
    .SYNOPSIS
        Advanced Security alerts across an Azure DevOps organization - secret, dependency and
        code scanning findings - as one row per alert.

    .DESCRIPTION
        What the Security Overview page shows, as objects. Secret scanning finds credentials
        committed to source; the alert is a live exposure, not a code-quality opinion.

        THERE IS NO ORGANIZATION-WIDE ALERTS ENDPOINT. Confirmed by enumerating the Advanced
        Security service's own routes: every alerts route is
        {project}/_apis/alert/repositories/{repository}/alerts. The portal's org-level view
        aggregates client-side, and so does this - one call per enabled repository.

        THE REPOSITORY LIST COMES FROM ENABLEMENT, NOT FROM THE GIT API, and that is deliberate.
        _apis/git/repositories returns only what the caller can see - measured on a live
        organization, an app saw 95 repositories where a person with a PAT saw 220 - and it
        returns them with a 200, so the shortfall is invisible. _apis/management/enablement is
        ORGANIZATION-scoped, lists every repository with Advanced Security switched on, and is
        readable by an org member. The git call is used only to put names to ids; a repository
        whose name cannot be resolved is still queried and reported by id.

        A REPOSITORY THAT CANNOT BE READ FAILS LOUDLY. Alerts return 403, never an empty list,
        so unreadable repositories are counted and named rather than passing as clean. That is
        the property that makes this command trustworthy where a service-connection inventory
        was not.

        THE SECRET ITSELF IS NOT RETURNED. The API includes a truncatedSecret field holding a
        fragment of the credential it found. This command drops it: the output of a security
        report ends up in mailboxes and spreadsheets, and a partial credential in a spreadsheet
        is a second exposure. Title carries the secret TYPE, which is what triage needs.

    .PARAMETER Organization
        Azure DevOps organization name: the path segment after dev.azure.com/, e.g. 'contoso'.

    .PARAMETER State
        Filter by alert state. Default 'active' - the alerts that still matter. 'all' includes
        fixed and dismissed ones.

    .PARAMETER AlertType
        Filter by kind: secret, dependency, code. All kinds by default.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec
        Get-MsecAzureDevOpsAlert -Organization 'contoso' |
            Format-Table Project, Repository, Severity, AlertType, Title, AgeDays

    .EXAMPLE
        # The ones to act on first: live credentials, high confidence, oldest first.
        Get-MsecAzureDevOpsAlert -Organization 'contoso' |
            Where-Object { $_.AlertType -eq 'secret' -and $_.Confidence -eq 'high' } |
            Sort-Object AgeDays -Descending

    .OUTPUTS
        PSCustomObject per alert, PSTypeName 'MsecAzureDevOpsAlert'.

    .NOTES
        Needs Connect-Msec, and the msec app must be a member of the ADO organization AND hold
        Advanced Security alert read. Organization membership alone is not enough - the alerts
        call returns 403 while enablement and repository listing succeed.

        One call per enabled repository, so this is slow on a large organization: 87 enabled
        repositories on the tenant it was built against.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string] $Organization,

        [ValidateSet('active', 'fixed', 'dismissed', 'all')]
        [string] $State = 'active',

        [ValidateSet('secret', 'dependency', 'code')]
        [string[]] $AlertType
    )

    Assert-MsecSession

    # Organization-scoped and authoritative about what is switched on. See the help for why the
    # git repository list is not used for this.
    $enablement = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization `
                        -HostName 'advsec.dev.azure.com' -ApiVersion '7.2-preview.1' `
                        -Path '_apis/management/enablement')
    $enabled = @($enablement.reposEnablementStatus | Where-Object { $_.advSecEnabled })

    if (-not $enabled.Count) {
        Write-Warning "No repositories report Advanced Security as enabled in '$Organization'. If the portal disagrees, treat this as UNREAD rather than as an organization with no scanning."
        return
    }
    Write-Verbose "$($enabled.Count) repository(ies) with Advanced Security enabled."

    # Names only. Ids that do not resolve are still queried - see the help.
    $repoName = @{}
    $projectName = @{}
    try {
        foreach ($repo in @(Invoke-MsecAzureDevOpsRequest -Organization $Organization -HostName 'dev.azure.com' -ApiVersion '7.1' -Path '_apis/git/repositories' -All)) {
            $repoName[[string] $repo.id] = $repo.name
            $projectName[[string] $repo.project.id] = $repo.project.name
        }
    }
    catch {
        Write-Warning "Could not list repositories to resolve names, so alerts are reported by id: $($_.Exception.Message)"
    }

    $unreadable = [System.Collections.Generic.List[string]]::new()

    foreach ($entry in $enabled) {
        $projectId = [string] $entry.projectId
        $repositoryId = [string] $entry.repositoryId
        $label = "$($projectName[$projectId] ?? $projectId)/$($repoName[$repositoryId] ?? $repositoryId)"

        $path = "$projectId/_apis/alert/repositories/$repositoryId/alerts"
        if ($State -ne 'all') { $path += "?criteria.states=$State" }

        try {
            $alerts = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization `
                            -HostName 'advsec.dev.azure.com' -ApiVersion '7.2-preview.1' `
                            -Path $path -All)
        }
        catch {
            # Named, not skipped. 403 here means the app lacks Advanced Security alert read on
            # that repository - a repository whose findings are missing from the report, which
            # must not read as a repository with none.
            $unreadable.Add($label)
            Write-Verbose "Could not read alerts for '$label': $($_.Exception.Message)"
            continue
        }

        foreach ($alert in $alerts) {
            if ($AlertType -and $alert.alertType -notin $AlertType) { continue }

            # First physical location is where the finding is; the rest are duplicates of the
            # same secret elsewhere in history.
            $location = @($alert.physicalLocations)[0]

            [PSCustomObject]@{
                PSTypeName    = 'MsecAzureDevOpsAlert'
                Organization  = $Organization
                Project       = $projectName[$projectId] ?? $projectId
                Repository    = $repoName[$repositoryId] ?? $repositoryId
                AlertId       = $alert.alertId
                AlertType     = $alert.alertType
                Severity      = $alert.severity
                State         = $alert.state
                Confidence    = $alert.confidence
                # The secret TYPE, not the secret. truncatedSecret is deliberately dropped.
                Title         = $alert.title
                FilePath      = $location.filePath
                Tool          = @($alert.tools)[0].name
                FirstSeen     = $alert.firstSeenDate
                LastSeen      = $alert.lastSeenDate
                FixedDate     = $alert.fixedDate
                # How long it has been sitting there. For a committed credential this is the
                # number that matters: exposure is cumulative and does not stop at detection.
                AgeDays       = if ($alert.firstSeenDate) { [int] ([DateTime]::UtcNow - [DateTime] $alert.firstSeenDate).TotalDays } else { $null }
                IsAutoFixable = $alert.isAutoFixable
            }
        }
    }

    if ($unreadable.Count) {
        $shown = ($unreadable | Select-Object -First 5) -join ', '
        $more  = if ($unreadable.Count -gt 5) { " and $($unreadable.Count - 5) more" } else { '' }
        Write-Warning "$($unreadable.Count) of $($enabled.Count) enabled repository(ies) refused their alerts: $shown$more. Those findings are NOT in this output. The msec app needs Advanced Security alert read - organization membership alone returns 403 here while enablement and repository listing succeed."
    }
}