|
| 1 | +//go:build windows && arm64 |
| 2 | +// +build windows,arm64 |
| 3 | + |
| 4 | +package collectors |
| 5 | + |
| 6 | +import ( |
| 7 | + "github.com/utmstack/UTMStack/agent/config" |
| 8 | + "github.com/utmstack/UTMStack/agent/logservice" |
| 9 | + "os" |
| 10 | + "os/exec" |
| 11 | + "path/filepath" |
| 12 | + "strings" |
| 13 | + "time" |
| 14 | + |
| 15 | + "github.com/threatwinds/validations" |
| 16 | + "github.com/utmstack/UTMStack/agent/utils" |
| 17 | +) |
| 18 | + |
| 19 | +type Windows struct{} |
| 20 | + |
| 21 | +const PowerShellScript = ` |
| 22 | +<# |
| 23 | +.SYNOPSIS |
| 24 | + Collects Windows Application, System, and Security logs from the last 5 minutes, then prints them to the console in a compact, single-line JSON format, |
| 25 | + emulating the field structure that Winlogbeat typically produces. |
| 26 | +
|
| 27 | +.DESCRIPTION |
| 28 | + 1. Retrieves the last 5 minutes of Windows logs (Application, System, Security) using FilterHashtable (no post-fetch filtering). |
| 29 | + 2. Maps event properties to a schema similar to Winlogbeat's, including: |
| 30 | + - @timestamp |
| 31 | + - message |
| 32 | + - event.code |
| 33 | + - event.provider |
| 34 | + - event.kind |
| 35 | + - winlog fields (e.g. record_id, channel, activity_id, etc.) |
| 36 | + 3. Prints each log record as a single-line JSON object with no indentation/extra spacing. |
| 37 | + 4. If no logs are found, the script produces no output at all. |
| 38 | +#> |
| 39 | +
|
| 40 | +# Suppress any runtime errors that would clutter the console. |
| 41 | +$ErrorActionPreference = 'SilentlyContinue' |
| 42 | +
|
| 43 | +# Calculate the start time for filtering |
| 44 | +$startTime = (Get-Date).AddSeconds(-30) |
| 45 | +
|
| 46 | +# Retrieve logs with filter hashtable |
| 47 | +$applicationLogs = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$startTime } |
| 48 | +$systemLogs = Get-WinEvent -FilterHashtable @{ LogName='System'; StartTime=$startTime } |
| 49 | +$securityLogs = Get-WinEvent -FilterHashtable @{ LogName='Security'; StartTime=$startTime } |
| 50 | +
|
| 51 | +# Safeguard against null results |
| 52 | +if (-not $applicationLogs) { $applicationLogs = @() } |
| 53 | +if (-not $systemLogs) { $systemLogs = @() } |
| 54 | +if (-not $securityLogs) { $securityLogs = @() } |
| 55 | +
|
| 56 | +# Combine them |
| 57 | +$recentLogs = $applicationLogs + $systemLogs + $securityLogs |
| 58 | +
|
| 59 | +# If no logs are found, produce no output at all |
| 60 | +if (-not $recentLogs) { |
| 61 | + return |
| 62 | +} |
| 63 | +
|
| 64 | +# Function to convert the raw Properties array to a dictionary-like object under winlog.event_data |
| 65 | +function Convert-PropertiesToEventData { |
| 66 | + param([Object[]] $Properties) |
| 67 | +
|
| 68 | + # If nothing is there, return an empty hashtable |
| 69 | + if (-not $Properties) { return @{} } |
| 70 | +
|
| 71 | + # Winlogbeat places custom fields under winlog.event_data. |
| 72 | + # Typically, it tries to parse known keys, but we'll do a simple best-effort approach: |
| 73 | + # We'll create paramN = <value> pairs for each array index. |
| 74 | + $eventData = [ordered]@{} |
| 75 | +
|
| 76 | + for ($i = 0; $i -lt $Properties.Count; $i++) { |
| 77 | + $value = $Properties[$i].Value |
| 78 | +
|
| 79 | + # If the property is itself an object with nested fields, we can flatten or store as-is. |
| 80 | + # We'll store as-is for clarity. |
| 81 | + # We'll name them param1, param2, param3,... unless you'd like more specific field logic. |
| 82 | + $paramName = "param$($i+1)" |
| 83 | +
|
| 84 | + $eventData[$paramName] = $value |
| 85 | + } |
| 86 | +
|
| 87 | + return $eventData |
| 88 | +} |
| 89 | +
|
| 90 | +# Transform each event into a structure emulating Winlogbeat |
| 91 | +foreach ($rawEvent in $recentLogs) { |
| 92 | + # Convert TimeCreated to a universal ISO8601 string |
| 93 | + $timestamp = $rawEvent.TimeCreated.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') |
| 94 | +
|
| 95 | + # Build the top-level document |
| 96 | + $doc = [ordered]@{ |
| 97 | + # Matches Winlogbeat's typical top-level timestamp field |
| 98 | + '@timestamp' = $timestamp |
| 99 | +
|
| 100 | + # The main message content from the event |
| 101 | + 'message' = $rawEvent.Message |
| 102 | +
|
| 103 | + # "event" block: minimal example |
| 104 | + 'event' = [ordered]@{ |
| 105 | + 'code' = $rawEvent.Id # event_id in Winlogbeat is typically a string or numeric |
| 106 | + 'provider' = $rawEvent.ProviderName |
| 107 | + 'kind' = 'event' |
| 108 | + 'created' = $timestamp # or you could omit if desired |
| 109 | + } |
| 110 | +
|
| 111 | + # "winlog" block: tries to mirror Winlogbeat's structure for Windows |
| 112 | + 'winlog' = [ordered]@{ |
| 113 | + 'record_id' = $rawEvent.RecordId |
| 114 | + 'computer_name' = $rawEvent.MachineName |
| 115 | + 'channel' = $rawEvent.LogName |
| 116 | + 'provider_name' = $rawEvent.ProviderName |
| 117 | + 'provider_guid' = $rawEvent.ProviderId |
| 118 | + 'process' = [ordered]@{ |
| 119 | + 'pid' = $rawEvent.ProcessId |
| 120 | + 'thread' = @{ |
| 121 | + 'id' = $rawEvent.ThreadId |
| 122 | + } |
| 123 | + } |
| 124 | + 'event_id' = $rawEvent.Id |
| 125 | + 'version' = $rawEvent.Version |
| 126 | + 'activity_id' = $rawEvent.ActivityId |
| 127 | + 'related_activity_id'= $rawEvent.RelatedActivityId |
| 128 | + 'task' = $rawEvent.TaskDisplayName |
| 129 | + 'opcode' = $rawEvent.OpcodeDisplayName |
| 130 | + 'keywords' = $rawEvent.KeywordsDisplayNames |
| 131 | + 'time_created' = $timestamp |
| 132 | + # Convert "Properties" into a dictionary for event_data |
| 133 | + 'event_data' = Convert-PropertiesToEventData $rawEvent.Properties |
| 134 | + } |
| 135 | + } |
| 136 | +
|
| 137 | + # Convert our object to JSON (with no extra formatting). |
| 138 | + $json = $doc | ConvertTo-Json -Depth 20 |
| 139 | +
|
| 140 | + # Remove all newlines and indentation for a single-line representation |
| 141 | + $compactJson = $json -replace '(\r?\n\s*)+', '' |
| 142 | +
|
| 143 | + # Output the line |
| 144 | + Write-Output $compactJson |
| 145 | +} |
| 146 | +` |
| 147 | + |
| 148 | +func (w Windows) Install() error { |
| 149 | + path := utils.GetMyPath() |
| 150 | + collectorPath := filepath.Join(path, "collector.ps1") |
| 151 | + err := os.WriteFile(collectorPath, []byte(PowerShellScript), 0644) |
| 152 | + return err |
| 153 | +} |
| 154 | + |
| 155 | +func (w Windows) SendSystemLogs() { |
| 156 | + path := utils.GetMyPath() |
| 157 | + collectorPath := filepath.Join(path, "collector.ps1") |
| 158 | + |
| 159 | + for { |
| 160 | + select { |
| 161 | + case <-time.After(30 * time.Second): |
| 162 | + go func() { |
| 163 | + cmd := exec.Command("Powershell.exe", "-File", collectorPath) |
| 164 | + |
| 165 | + output, err := cmd.Output() |
| 166 | + if err != nil { |
| 167 | + _ = utils.Logger.ErrorF("error executing powershell script: %v", err) |
| 168 | + return |
| 169 | + } |
| 170 | + |
| 171 | + logLines := strings.Split(string(output), "\n") |
| 172 | + |
| 173 | + validatedLogs := make([]string, 0, len(logLines)) |
| 174 | + |
| 175 | + for logLine := range logLines { |
| 176 | + validatedLog, _, err := validations.ValidateString(logLine, false) |
| 177 | + if err != nil { |
| 178 | + _ = utils.Logger.ErrorF("error validating log: %s: %v", logLine, err) |
| 179 | + continue |
| 180 | + } |
| 181 | + |
| 182 | + validatedLogs = append(validatedLogs, validatedLog) |
| 183 | + } |
| 184 | + |
| 185 | + logservice.LogQueue <- logservice.LogPipe{ |
| 186 | + Src: string(config.DataTypeWindowsAgent), |
| 187 | + Logs: validatedLogs, |
| 188 | + } |
| 189 | + }() |
| 190 | + } |
| 191 | + } |
| 192 | +} |
| 193 | + |
| 194 | +func (w Windows) Uninstall() error { |
| 195 | + path := utils.GetMyPath() |
| 196 | + collectorPath := filepath.Join(path, "collector.ps1") |
| 197 | + err := os.Remove(collectorPath) |
| 198 | + return err |
| 199 | +} |
0 commit comments