Public/Send-DoctorDOReport.ps1

function Send-DoctorDOReport {
    <#
    .SYNOPSIS
        Sends one inventory or telemetry report to your DoctorDO ingest service as a request signed with the device
        certificate's key, and returns what happened.
    .DESCRIPTION
        Send-DoctorDOReport is the transport that Invoke-DoctorDOCollector -Fleet uses; you can call it yourself to
        test a device's identity and its path to the ingest service. It serialises the report (ConvertTo-Json
        -Compress), compresses it with gzip and POSTs it over TLS 1.2 or later to https://<host>/v1/ingest, with the
        request signed by the device key (signed-request protocol v1). There is no key, token or password, and no TLS
        client certificate is presented: the device certificate travels in the X-DoctorDO-Device-Cert header and the
        signature in X-DoctorDO-Signature, with X-DoctorDO-Signed-At and a random X-DoctorDO-Nonce.

        -DeviceAnchor entra-device (default) signs with the Entra device certificate in LocalMachine\My: issuer
        MS-Organization-Access, subject CN equal to the DeviceId that dsregcmd /status reports, and the thumbprint
        dsregcmd reports. The device must be Microsoft Entra joined or hybrid joined. -DeviceAnchor tenant-ca signs
        with the tenant's Intune SCEP or PKCS certificate whose subject alternative name (or subject) contains the
        Entra device ID and which has the client authentication EKU.

        The exchange is one way: only the HTTP status code (and Retry-After) is used, and the response body is never
        read, logged or acted on. 202 means accepted; 429 means rate limited; 401 and 503 mean back off; anything
        else is a rejection. Reports over the 64 KB compressed hard limit are not sent. This command never retries
        and keeps no state: Invoke-DoctorDOCollector handles back-off and the state file.
    .PARAMETER Report
        The report to send: an inventory or telemetry dictionary with schemaVersion 1 and kind 'inventory' or
        'telemetry', as returned by Get-DOInventory or Get-DOTelemetry.
    .PARAMETER IngestUri
        The ingest URL of your DoctorDO deployment, https://<host>/v1/ingest. A bare https://<host> is completed with
        /v1/ingest. Plain http, other paths and query strings are refused, because the path is part of the signed
        request.
    .PARAMETER DeviceAnchor
        Which certificate identifies the device and signs the request: entra-device (default) or tenant-ca. It must
        match the anchor your DoctorDO deployment was set up with.
    .PARAMETER EntraDeviceId
        tenant-ca: the Microsoft Entra device ID used to choose the certificate (read from dsregcmd /status when
        omitted). entra-device: an optional cross-check that must equal the DeviceId dsregcmd reports.
    .PARAMETER CertificateThumbprint
        Use only the certificate with this thumbprint in LocalMachine\My.
    .PARAMETER CertificateIssuer
        tenant-ca only: accept only certificates from this issuer (distinguished name or common name), the
        certification authority registered with your DoctorDO deployment.
    .PARAMETER DsregStatus
        Internal: an already parsed dsregcmd /status result, so dsregcmd does not run twice. Invoke-DoctorDOCollector
        passes the one it collected. Leave it out when calling the command yourself.
    .PARAMETER TimeoutSec
        How long to wait for the ingest service, 5 to 300 seconds. Default 30.
    .EXAMPLE
        $r = Send-DoctorDOReport -Report (Get-DOTelemetry) -IngestUri https://ingest.contoso.example/v1/ingest
        $r | Format-List Outcome, StatusCode, ExitCode, Message

        Sends one telemetry report and shows the outcome. Run it as SYSTEM (for example with psexec -s), because the
        device key is a machine key. Accepted with StatusCode 202 means the device's identity and network path work.
        This report does not update the collector's state, so the next fleet run reports the same period again.
    .EXAMPLE
        Send-DoctorDOReport -Report (Get-DOInventory -Anonymise) -IngestUri https://ingest.contoso.example -DeviceAnchor tenant-ca -CertificateIssuer 'CN=Contoso Issuing CA'

        Sends an anonymised inventory signed with the Intune SCEP or PKCS certificate from Contoso Issuing CA. The
        bare host is completed with /v1/ingest. NoCertificate (exit code 6) means the certificate profile has not
        reached the device yet.
    .EXAMPLE
        $r = Send-DoctorDOReport -Report (Get-DOTelemetry -SkipConnectivityChecks) -IngestUri https://ingest.contoso.example/v1/ingest
        if ($r.ExitCode -eq 6) { $r.Message; dsregcmd /status | Select-String 'AzureAdJoined|DeviceId|Thumbprint|DeviceAuthStatus' }

        Checks a device's identity before you deploy the remediation to it: when the device cannot identify itself
        (exit code 6), prints the reason and the dsregcmd /status lines the collector depends on.
    .INPUTS
        None. You cannot pipe objects to Send-DoctorDOReport; pass the report with -Report.
    .OUTPUTS
        System.Management.Automation.PSCustomObject with Outcome, StatusCode, RetryAfterSeconds, Kind,
        CompressedBytes, BudgetResult, ExitCode and Message. Outcome is one of Accepted, RateLimited, BackOff,
        Rejected, NotJoined, NoCertificate, SigningFailed, NetworkError, TooLarge or InvalidReport.
    .NOTES
        Run as SYSTEM: the private key of the Entra device certificate (and usually of a SCEP certificate) is a
        machine key that a user session cannot use (SigningFailed). The device clock must be within five minutes of
        the service's.

        What is sent: the report you pass, plus the device certificate (public part only). With entra-device the
        certificate's subject is the Entra device ID, so the service learns that ID from every request. Nothing is
        sent anywhere but -IngestUri, and nothing on the device is changed.

        ExitCode on the result: 0 accepted; 3 the report is not a valid inventory or telemetry dictionary; 5 not
        accepted (network error, HTTP error, rate limited or back-off, too large, or an IngestUri that is not
        https://<host>/v1/ingest); 6 the device cannot identify itself (NotJoined, NoCertificate, SigningFailed).
    .LINK
        https://www.doctor-do.com/get-started/
    .LINK
        about_DoctorDO.Collector
    .LINK
        Invoke-DoctorDOCollector
    .LINK
        https://learn.microsoft.com/entra/identity/devices/troubleshoot-device-dsregcmd
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)] $Report,
        [Parameter(Mandatory)][uri] $IngestUri,
        [ValidateSet('entra-device', 'tenant-ca')][string] $DeviceAnchor = 'entra-device',
        [string] $EntraDeviceId,
        [string] $CertificateThumbprint,
        [string] $CertificateIssuer,
        [Parameter(DontShow)] $DsregStatus,
        [ValidateRange(5, 300)][int] $TimeoutSec = 30
    )

    $result = {
        param($outcome, $code, $retry, $kind, $bytes, $budget, $exit, $msg)
        [pscustomobject]@{ Outcome = $outcome; StatusCode = $code; RetryAfterSeconds = $retry; Kind = $kind; CompressedBytes = $bytes; BudgetResult = $budget; ExitCode = $exit; Message = $msg }
    }

    $kind = $null
    if ($Report -is [System.Collections.IDictionary]) { $kind = [string]$Report['kind'] }
    if ($Report -isnot [System.Collections.IDictionary] -or $kind -notin 'inventory', 'telemetry' -or $Report['schemaVersion'] -ne 1) {
        return (& $result 'InvalidReport' 0 $null $kind 0 $null 3 'Report must be a schemaVersion 1 inventory or telemetry dictionary.')
    }
    if ($IngestUri.IsAbsoluteUri -and $IngestUri.Scheme -ne 'https') {
        return (& $result 'InvalidReport' 0 $null $kind 0 $null 5 'IngestUri must be https: reports are only ever sent over TLS.')
    }
    $uri = Resolve-DDIngestUri -Uri $IngestUri
    if ($null -eq $uri) {
        return (& $result 'InvalidReport' 0 $null $kind 0 $null 5 "IngestUri must be https://<host>/v1/ingest (the path is part of the signed request); got '$IngestUri'.")
    }

    # ---- identity: the certificate whose key signs the request ------------------------------------------------
    $dsreg = $DsregStatus
    if ($null -eq $dsreg) {
        $dsreg = Invoke-DDStep -Context (New-DDContext) -Field 'dsreg' -Script { ConvertFrom-DDDsregStatus -Lines (Get-DDDsregStatusText) }
    }
    $cert = $null
    if ($DeviceAnchor -eq 'entra-device') {
        if ($null -eq $dsreg -or -not $dsreg.Parsed) {
            return (& $result 'NotJoined' 0 $null $kind 0 $null 6 'Could not read the join state from dsregcmd /status, so the Entra device certificate cannot be identified. The entra-device anchor needs a Microsoft Entra joined or hybrid joined device; run dsregcmd /status as SYSTEM to check.')
        }
        if (-not $dsreg.AzureAdJoined -or -not $dsreg.DeviceId) {
            return (& $result 'NotJoined' 0 $null $kind 0 $null 6 "dsregcmd /status reports join state '$($dsreg.JoinType)' (AzureAdJoined: $(if ($dsreg.AzureAdJoined) { 'YES' } else { 'NO' })). The entra-device anchor needs a Microsoft Entra joined or hybrid joined device with an Entra device ID; Entra registered and domain-only devices cannot report.")
        }
        if ($EntraDeviceId -and -not [string]::Equals($EntraDeviceId.Trim(), $dsreg.DeviceId, [System.StringComparison]::OrdinalIgnoreCase)) {
            return (& $result 'NoCertificate' 0 $null $kind 0 $null 6 "-EntraDeviceId $EntraDeviceId does not match the DeviceId dsregcmd /status reports ($($dsreg.DeviceId)); the device certificate is chosen for the dsregcmd device.")
        }
        $pick = Select-DDEntraDeviceCertificate -Certificates @(Get-DDMachineCertificates) -EntraDeviceId $dsreg.DeviceId -DsregThumbprint $dsreg.DeviceCertificateThumbprint -Thumbprint $CertificateThumbprint
        if ($null -eq $pick.Certificate) {
            return (& $result 'NoCertificate' 0 $null $kind 0 $null 6 "No usable Entra device certificate in LocalMachine\My: $($pick.Reason). dsregcmd /status reports join state '$($dsreg.JoinType)' and DeviceId $($dsreg.DeviceId); check its Device Details (Thumbprint, DeviceCertificateValidity, DeviceAuthStatus) as SYSTEM.")
        }
        $cert = $pick.Certificate
    } else {
        if (-not $EntraDeviceId -and $dsreg) { $EntraDeviceId = $dsreg.DeviceId }
        if ($EntraDeviceId) {
            $cert = Select-DDDeviceCertificate -Certificates @(Get-DDMachineCertificates) -EntraDeviceId $EntraDeviceId -Thumbprint $CertificateThumbprint -Issuer $CertificateIssuer
        }
        if ($null -eq $cert) {
            return (& $result 'NoCertificate' 0 $null $kind 0 $null 6 'No usable device certificate in LocalMachine\My. Deploy the Intune SCEP or PKCS certificate profile (client authentication, SAN containing {{AAD_Device_ID}}) to this device.')
        }
    }

    $payload = ConvertTo-DDReportBytes -Report $Report
    $gz = $payload.Gzip
    $budget = Get-DDReportBudget -Kind $kind -CompressedBytes $gz.Length
    if ($budget -eq 'over-hard') {
        return (& $result 'TooLarge' 0 $null $kind $gz.Length $budget 5 "Report is $($gz.Length) bytes compressed, over the 64 KB hard cap; not sent.")
    }
    if ($budget -eq 'over-typical') { Write-Warning "Report is $($gz.Length) bytes compressed, over the typical budget for $kind." }

    $collectedAt = [string]$Report['collectedAt']
    $headers = [ordered]@{
        'Content-Type'            = 'application/vnd.doctordo.report+json'
        'Content-Encoding'        = 'gzip'
        'X-DoctorDO-Schema'       = '1'
        'X-DoctorDO-Kind'         = $kind
        'X-DoctorDO-Collected-At' = $collectedAt
    }
    $keyArgs = @{}
    if ($DeviceAnchor -eq 'entra-device' -and $null -ne $dsreg.PSObject.Properties['KeyContainerId'] -and $dsreg.KeyContainerId -and $dsreg.KeyProvider) { $keyArgs = @{ KeyContainer = $dsreg.KeyContainerId; KeyProvider = $dsreg.KeyProvider } }
    try {
        # Signed as late as possible: signed-at must be within five minutes of the verifier's clock.
        $signed = New-DDSignedHeaders -Certificate $cert -Uri $uri -Kind $kind -CollectedAt $collectedAt -Body $gz @keyArgs
    } catch {
        return (& $result 'SigningFailed' 0 $null $kind $gz.Length $budget 6 "The device key could not sign the request ($($_.Exception.GetType().Name): $($_.Exception.Message)). The collector must run as SYSTEM to use the device key; certificate $($cert.Thumbprint).")
    }
    foreach ($k in $signed.Keys) { $headers[$k] = $signed[$k] }

    # The same proxy the endpoint probes would use for the ingest host (Resolve-DDUploadProxy); named in a failure's
    # message by its source only, never its address.
    $px = Resolve-DDUploadProxy -Uri $uri
    $postArgs = @{}
    if ($px.Mode -eq 'proxy') { $postArgs.Proxy = $px.Proxy } elseif ($px.Mode -eq 'direct') { $postArgs.NoProxy = $true }
    $path = $(switch ($px.Mode) { 'proxy' { " through the proxy WinHTTP chooses for the ingest host (from $($px.Source))" } 'direct' { " directly (WinHTTP bypasses the $($px.Source) proxy for the ingest host)" } default { '' } })
    $resp = Invoke-DDHttpPost -Uri $uri -Body $gz -Headers $headers -TimeoutSec $TimeoutSec @postArgs
    $code = [int]$resp.StatusCode
    $retry = ConvertTo-DDRetryAfterSeconds -Value $resp.RetryAfter
    switch ($code) {
        202 { return (& $result 'Accepted' $code $null $kind $gz.Length $budget 0 'Accepted.') }
        429 { return (& $result 'RateLimited' $code $retry $kind $gz.Length $budget 5 'Rate limited; honour Retry-After.') }
        401 { return (& $result 'BackOff' $code $retry $kind $gz.Length $budget 5 'HTTP 401: the service did not accept the device''s signed identity; backing off. Check the device clock (more than five minutes behind the service''s is refused this way), that the device is enabled in Microsoft Entra, and that it uses the device anchor your DoctorDO deployment expects (-DeviceAnchor).') }
        422 { return (& $result 'Rejected' $code $null $kind $gz.Length $budget 5 'HTTP 422: the service refused the report''s collection time, which comes from the device clock. Check the device clock: more than five minutes ahead of the service''s (or more than 48 hours behind) is refused.') }
        407 { return (& $result 'Rejected' $code $null $kind $gz.Length $budget 5 "HTTP 407$($path): the proxy asked for credentials the account running the collector could not give. Exempt the ingest host from the proxy, or let the proxy accept the computer account.") }
        { $script:DDBackOffStatuses -contains $_ } { return (& $result 'BackOff' $code $retry $kind $gz.Length $budget 5 "HTTP $($code): the service or a gateway in front of it is unavailable; backing off.") }
        0 { return (& $result 'NetworkError' 0 $null $kind $gz.Length $budget 5 "Network error ($($resp.Error)) reaching the ingest host$path; backing off.") }
        default { return (& $result 'Rejected' $code $null $kind $gz.Length $budget 5 "HTTP $code.") }
    }
}