class/Class.ps1
class ApiClient { [System.Net.Http.HttpClientHandler]$Handler [System.Net.Http.HttpClient]$Client [System.Collections.Hashtable]$Collector [string]$UserAgent ApiClient() { $this.Collector = $null $this.Handler = [System.Net.Http.HttpClientHandler]::New() $this.Client = [System.Net.Http.HttpClient]::New($this.Handler) $this.Client.Timeout = [System.TimeSpan]::New(0,5,30) $this.UserAgent = 'crowdstrike-psfalcon/2.2.8' } [string] Path([string]$Path) { $Output = if (![IO.Path]::IsPathRooted($Path)) { # Convert partial file path to a full file path $FullPath = Join-Path -Path (Get-Location).Path -ChildPath $Path $FullPath = Join-Path -Path $FullPath -ChildPath '.' [IO.Path]::GetFullPath($FullPath) } else { $Path } return $Output } [System.Object] Invoke([System.Object]$Param) { # Send API endpoint and headers to verbose stream $this.Verbose('ApiClient.Invoke',($Param.Method.ToUpper(),$Param.Path -join ' ')) if ($Param.Headers) { [string]$Verbose = $Param.Headers.GetEnumerator().foreach{ "$($_.Key)=$($_.Value)" } -join ', ' if ($Verbose) { $this.Verbose('ApiClient.Invoke',$Verbose) } } $Output = try { # Create basic HTTP request message and add headers $Message = [System.Net.Http.HttpRequestMessage]::New($Param.Method.ToUpper(),$Param.Path) $Param.Headers.GetEnumerator().foreach{ $Message.Headers.Add($_.Key,$_.Value) } if ($Param.Formdata) { # Create Formdata message $Message.Content = [System.Net.Http.MultipartFormDataContent]::New() $Param.Formdata.GetEnumerator().foreach{ $Verbose = if ($_.Key -match '^((data_)?file|upfile)$') { # With 'file' or 'upfile', create StreamContent from key/value pair $FileStream = [System.IO.FileStream]::New($this.Path($_.Value),[System.IO.FileMode]::Open) $Filename = [System.IO.Path]::GetFileName($this.Path($_.Value)) $StreamContent = [System.Net.Http.StreamContent]::New($FileStream) $FileType = $this.StreamType($Filename) if ($FileType) { $StreamContent.Headers.ContentType = $FileType } $Message.Content.Add($StreamContent,$_.Key,$Filename) @($_.Key,'<StreamContent>') -join '=' } else { # Add StringContent for other Formdata key/value pairs $Message.Content.Add([System.Net.Http.StringContent]::New($_.Value),$_.Key) @($_.Key,$_.Value) -join '=' } $this.Verbose('ApiClient.Invoke',($Verbose -join ', ')) } } elseif ($Param.Body) { $Message.Content = if ($Param.Body -is [string] -and $Param.Headers.ContentType) { # Add 'Body' as StringContent [System.Net.Http.StringContent]::New($Param.Body,[System.Text.Encoding]::UTF8,$Param.Headers.ContentType) if ($Param.Path -notmatch '/oauth2/token$') { $this.Verbose('ApiClient.Invoke',$Param.Body) } } else { $Param.Body } } # Log request when enabled if ($this.Collector.Enable -contains 'requests') { $this.Log($Message) } $Request = if ($Param.Outfile) { # Download file @($Param.Headers.Keys).foreach{ $this.Client.DefaultRequestHeaders.Add($_,$Param.Headers.$_) } $this.Verbose('ApiClient.Invoke','Receiving ByteArray content...') $this.Client.GetByteArrayAsync($Param.Path) } else { # Send request $this.Client.SendAsync($Message,[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead) } if ($Request -and $Param.Outfile) { try { # Download file to provided 'OutFile' path and display file information when successful $LocalPath = $this.Path($Param.Outfile) $this.Verbose('ApiClient.Invoke',"Creating '$LocalPath'.") [System.IO.File]::WriteAllBytes($LocalPath,$Request.Result) if (Test-Path $LocalPath) { Get-ChildItem $LocalPath | Select-Object FullName,Length,LastWriteTime } } catch { throw $_ } finally { @($Param.Headers.Keys).foreach{ if ($this.Client.DefaultRequestHeaders.$_) { [void]($this.Client.DefaultRequestHeaders.Remove($_)) } } } } elseif ($Request) { # Output HTTP response code to verbose stream $HashCode = if ($Request.Result.StatusCode) { $Request.Result.StatusCode.GetHashCode() } if ($HashCode) { $this.Verbose('ApiClient.Invoke',($HashCode,$Request.Result.StatusCode -join ': ')) } if ($Request.Result.Headers) { # Output response headers to verbose stream and warn when 'X-Api-Deprecation' appears $this.Verbose('ApiClient.Invoke',"$($Request.Result.Headers.GetEnumerator().foreach{ @($_.Key,(@($_.Value) -join ', ')) -join '=' } -join ', ')") @($Request.Result.Headers.GetEnumerator().Where({$_.Key -match '^X-Api-Deprecation'})).foreach{ Write-Warning ([string]$_.Key,[string]$_.Value -join ': ') } } if ($Request.Result.Content -and $this.Collector.Enable -contains 'responses') { # Log response when enabled $this.Log($Request.Result) } $RetryAfter = if ($HashCode -eq 429 -and $Param.Path -notmatch '/oauth2/token$') { # Capture 'X-Ratelimit-Retryafter' when present $Request.Result.Headers.GetEnumerator().Where({$_.Key -eq 'X-Ratelimit-Retryafter'}).Value } if ($RetryAfter) { # Subtract current time from 'X-Ratelimit-Retryafter', warn, and retry when rate limited [int32]$Wait = (([System.DateTimeOffset]::FromUnixTimeSeconds($RetryAfter)).LocalDateTime - (Get-Date)).Seconds $Limit = $Request.Result.Headers.GetEnumerator().Where({$_.Key -eq 'X-Ratelimit-Limit'}).Value $Remaining = $Request.Result.Headers.GetEnumerator().Where({$_.Key -eq 'X-Ratelimit-Remaining'}).Value Write-Warning ('Rate limited for {0} second(s). [{1}, {2}]' -f $Wait,('Limit',$Limit -join '='), ('Remaining',$Remaining -join '=')) Start-Sleep -Seconds $Wait $this.Invoke($Param) } elseif ($Request.Result.Content -and $Param.Path -notmatch '/oauth2/token$') { # Convert from Json or output string content if ($Request.Result.Content.Headers.ContentType -eq 'application/json' -or $Request.Result.Content.Headers.ContentType.MediaType -eq 'application/json') { ConvertFrom-Json ($Request.Result.Content).ReadAsStringAsync().Result } else { ($Request.Result.Content).ReadAsStringAsync().Result } } else { # Output entire response $Request } } } catch { throw $_ } finally { if ($Request) { $Request.Dispose() } } return $Output } [void] Log([System.Object]$Object) { # Create Falcon LogScale/NGSIEM message payload from 'HttpRequestMessage' or 'HttpResponseMessage' $Timestamp = if ($this.Collector.Token -match '^[a-fA-F0-9]{32}$') { [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() } else { Get-Date -Format o } if ($this.Collector.Token -match '^[a-fA-F0-9]{32}$') { $Item = @{ event = @{ '@timestamp' = $Timestamp '@sourcetype' = $this.UserAgent '#ecs.version' = '8.11.0' '#Cps.version' = '1.0.0' 'Parser.version' = '1.0.0' } } if ($Object -is [System.Net.Http.HttpRequestMessage]) { # Add request URI, method and headers (without 'authorization') $Item.event['url.full'] = $Object.RequestUri.ToString() $Item.event['http.request.method'] = $Object.Method.ToString() $Item.event['http.request.headers'] = @{} $Object.Headers.GetEnumerator().Where({$_.Key -ne 'Authorization'}).foreach{ $Item.event.'http.request.headers'[$_.Key] = $_.Value } if ($Object.Content) { # Redact 'client_secret' from request $Item.event['http.request.body.content'] = $Object.Content.ReadAsStringAsync().Result -replace 'client_secret=\w+&?','client_secret=redacted' } } elseif ($Object -is [System.Net.Http.HttpResponseMessage]) { $Item.event['http.response.headers'] = @{} $Object.Headers.GetEnumerator().foreach{ $Item.event.'http.response.headers'[$_.Key] = $_.Value } if ($Object.Content -and ($Object.Content.Headers.ContentType -eq 'application/json' -or $Object.Content.Headers.ContentType.MediaType -eq 'application/json')) { # Add response content (excluding 'access_token') $Content = @{} @(($Object.Content.ReadAsStringAsync().Result | ConvertFrom-Json).PSObject.Properties).Where({ $_.Name -ne 'access_token'}).foreach{ $Content[$_.Name] = $_.Value } $Item.event['http.response.body.content'] = $Content } elseif ($Object.Content) { # Add content as a string when unable to determine if 'HttpRequestMessage' or 'HttpResponseMessage' $Item.event['http.response.body.content'] = $Object.Content.ReadAsStringAsync().Result } } } else { $Item = @{ timestamp = $Timestamp; attributes = @{ Headers = @{} }} if ($Object -is [System.Net.Http.HttpRequestMessage]) { @('RequestUri','Method').foreach{ $Item.attributes[$_] = $Object.$_.ToString() } $Object.Headers.GetEnumerator().Where({$_.Key -ne 'Authorization'}).foreach{ $Item.attributes.Headers[$_.Key] = $_.Value } if ($Object.Content) { # Redact 'client_secret' from request $Item.attributes['Content'] = $Object.Content.ReadAsStringAsync().Result -replace 'client_secret=\w+&?', 'client_secret=redacted' } } elseif ($Object -is [System.Net.Http.HttpResponseMessage]) { $Object.Headers.GetEnumerator().foreach{ $Item.attributes.Headers[$_.Key] = $_.Value } if ($Object.Content -and ($Object.Content.Headers.ContentType -eq 'application/json' -or $Object.Content.Headers.ContentType.MediaType -eq 'application/json')) { @(($Object.Content.ReadAsStringAsync().Result | ConvertFrom-Json).PSObject.Properties).Where({ $_.Name -ne 'access_token'}).foreach{ # Add content, but exclude 'access_token' $Item.attributes[$_.Name] = $_.Value } } elseif ($Object.Content) { # Add content as a string when unable to determine if 'HttpRequestMessage' or 'HttpResponseMessage' $Item.attributes['Content'] = $Object.Content.ReadAsStringAsync().Result } } } # Use Invoke-RestMethod to send to Falcon LogScale/NGSIEM within a background job $Job = @{ Name = 'ApiClient_Log',$Timestamp -join '.' ScriptBlock = { $Param = $args[0]; Invoke-RestMethod @Param } ArgumentList = @{ Uri = $this.Collector.Uri Method = 'post' Headers = @{ Authorization = 'Bearer',$this.Collector.Token -join ' '; ContentType = 'application/json' } Body = if ($this.Collector.Token -match '^[a-fA-F0-9]{32}') { ConvertTo-Json $Item -Depth 32 -Compress } else { ConvertTo-Json @( @{ tags = @{ host = [System.Net.Dns]::GetHostName() source = $this.UserAgent } events = @(,$Item) } ) -Depth 32 -Compress } } } [void](Start-Job @Job) $this.Verbose('ApiClient.Log',"Submitted job '$($Job.Name)'.") @(Get-Job | Where-Object { $_.Name -match '^ApiClient_Log' -and $_.State -eq 'Completed' }).foreach{ # Remove completed background jobs $this.Verbose('ApiClient.Log',"Removed job '$($_.Name)'.") Remove-Job -Id $_.Id } } [string] StreamType([string]$String) { [string]$Extension = [System.IO.Path]::GetExtension($String) -replace '^\.',$null $Output = switch -Regex ($Extension) { # Output string based on file extension '^(bmp|gif|jp(e?)g|png)$' { "image/$_" } '^(pdf|zip)$' { "application/$_" } '^7z$' { 'application/x-7z-compressed' } '^(yaml|yml)$' { 'application/yaml' } '^(csv|txt)$' { if ($_ -eq 'txt') { 'text/plain' } else { "text/$_" }} '^doc(x?)$' { if ($_ -match 'x$') { 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } else { 'application/msword' } } '^ppt(x?)$' { if ($_ -match 'x$') { 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } else { 'application/vnd.ms-powerpoint' } } '^xls(x?)$' { if ($_ -match 'x$') { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } else { 'application/vnd.ms-excel' } } } return $Output } [void] Verbose([string]$Function,[string]$String) { Write-Verbose ((Get-Date -Format 'HH:mm:ss'),"[$Function]",$String -join ' ') } } |