diff --git a/baseline/cmd_windows.go b/baseline/cmd_windows.go index 6d44196..486eae3 100644 --- a/baseline/cmd_windows.go +++ b/baseline/cmd_windows.go @@ -7,8 +7,25 @@ import ( "github.com/spf13/cobra" ) +var baselineComponents = map[string]string{ + "services": "services.ps1", + "processes": "processes.ps1", + "autoruns": "autoruns.ps1", + "ad-users": "ad-users.ps1", + "local-users": "local-users.ps1", + "ad-objects": "ad-objects.ps1", + "ports": "ports.ps1", + "wmi": "wmi-subscriptions.ps1", +} + +var dcScripts = []string{"services", "processes", "autoruns", "ad-users", "ad-objects", "ports", "wmi"} +var localScripts = []string{"services", "processes", "autoruns", "local-users", "ports", "wmi"} + +var sysinternalsDirectory = `C:\\ProgramData\\landschaft\\sysinternals` + func SetupCommand(cmd *cobra.Command) { - setupServicesCmd(cmd) + setupCompareCmd(cmd) + setupCreateCmd(cmd) } func Run(cmd *cobra.Command) { diff --git a/baseline/compare_windows.go b/baseline/compare_windows.go new file mode 100644 index 0000000..b58ea63 --- /dev/null +++ b/baseline/compare_windows.go @@ -0,0 +1,323 @@ +package baseline + +import ( + "encoding/csv" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +func setupCompareCmd(cmd *cobra.Command) { + compareCmd := &cobra.Command{ + Use: "compare", + Short: "Compare baselines", + } + + compareAllCmd := &cobra.Command{ + Use: "all", + Short: "Compare two baseline directories", + Long: "Compare two directories produced by 'baseline create all' and report added/removed/changed entries for each component. Baselines must be specified via flags -a/--baseline-a and -b/--baseline-b or will be discovered automatically by filename.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + dirA, _ := cmd.Flags().GetString("baseline-a") + dirB, _ := cmd.Flags().GetString("baseline-b") + if dirA == "" || dirB == "" { + foundA, foundB, err := findLatestBaselinesByName(".") + if err != nil { + fmt.Printf("Error finding latest baselines: %v\n", err) + return + } + dirA = foundA + dirB = foundB + fmt.Printf(`Comparing baselines: "%s" with "%s"`, dirA, dirB) + fmt.Println() + } + compareCSVDirs(dirA, dirB) + }, + } + compareCmd.AddCommand(compareAllCmd) + compareAllCmd.Flags().StringP("baseline-a", "a", "", "Baseline A directory") + compareAllCmd.Flags().StringP("baseline-b", "b", "", "Baseline B directory") + compareAllCmd.MarkFlagsRequiredTogether("baseline-a", "baseline-b") + + for name := range baselineComponents { + cmdCmp := &cobra.Command{ + Use: name, + Short: fmt.Sprintf("Compare %s baselines", name), + Long: fmt.Sprintf("Compare the %s.csv files in two baseline directories and report added/removed/changed entries.", name), + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + dirA, _ := cmd.Flags().GetString("baseline-a") + dirB, _ := cmd.Flags().GetString("baseline-b") + if dirA == "" || dirB == "" { + foundA, foundB, err := findLatestBaselinesByName(".") + if err != nil { + fmt.Printf("Error finding latest baselines: %v\n", err) + return + } + dirA = foundA + dirB = foundB + fmt.Printf(`Comparing baselines: "%s" with "%s"`, dirA, dirB) + fmt.Println() + } + err := compareCSVFiles(fmt.Sprintf("%s.csv", name), dirA, dirB) + if err != nil { + fmt.Printf("Error comparing %s: %v\n", name, err) + } + }, + } + + cmdCmp.Flags().StringP("baseline-a", "a", "", "Baseline A directory") + cmdCmp.Flags().StringP("baseline-b", "b", "", "Baseline B directory") + cmdCmp.MarkFlagsRequiredTogether("baseline-a", "baseline-b") + compareCmd.AddCommand(cmdCmp) + } + + cmd.AddCommand(compareCmd) +} + +func getAllCSVFiles(dir string) ([]string, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("could not get absolute path of %s: %v", dir, err) + } + files := []string{} + dirEntries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("could not read directory %s: %v", dir, err) + } + for _, entry := range dirEntries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".csv") { + files = append(files, entry.Name()) + } + } + return files, nil +} + +func compareCSVFiles(fileName, dirA, dirB string) error { + fmt.Println() + fmt.Println(strings.Repeat("=", 60)) + fmt.Println() + + keyCols := keyColumnsForFile(fileName) + pathA := filepath.Join(dirA, fileName) + pathB := filepath.Join(dirB, fileName) + mapA, err := loadCSVWithKey(pathA, keyCols) + if err != nil { + return fmt.Errorf("could not load %s: %v", pathA, err) + } + mapB, err := loadCSVWithKey(pathB, keyCols) + if err != nil { + return fmt.Errorf("could not load %s: %v", pathB, err) + } + + fmt.Printf("Comparing %s\n", fileName) + + addedKeys, removedKeys, changed := diffMaps(mapA, mapB) + + if len(addedKeys) > 0 { + sort.Strings(addedKeys) + fmt.Printf("Added in %s:\n", dirB) + for _, k := range addedKeys { + fmt.Printf("\t%s\n", formatObject(k, mapB[k])) + } + } + if len(removedKeys) > 0 { + sort.Strings(removedKeys) + fmt.Printf("Removed from %s:\n", dirB) + for _, k := range removedKeys { + fmt.Printf("\t%s\n", formatObject(k, mapA[k])) + } + } + if len(changed) > 0 { + fmt.Println("Changed entries:") + for _, c := range changed { + fmt.Printf("\t%s\n", c) + } + } + return nil +} + +func compareCSVDirs(dirA, dirB string) { + filesA, err := getAllCSVFiles(dirA) + if err != nil { + fmt.Printf("Error getting CSV files from directory %s: %v\n", dirA, err) + return + } + filesB, err := getAllCSVFiles(dirB) + if err != nil { + fmt.Printf("Error getting CSV files from directory %s: %v\n", dirB, err) + return + } + + sharedFiles := []string{} + for _, fA := range filesA { + if slices.Contains(filesB, fA) { + sharedFiles = append(sharedFiles, fA) + } + } + + for _, f := range sharedFiles { + err := compareCSVFiles(f, dirA, dirB) + if err != nil { + fmt.Printf("Error comparing file %s: %v\n", f, err) + } + } +} + +func keyColumnsForFile(file string) []string { + switch file { + case "ad-objects.csv": + return []string{"DistinguishedName"} + case "autoruns.csv": + return []string{"Location", "Name", "LaunchString"} + case "ports.csv": + return []string{"LocalAddress", "LocalPort"} + case "processes.csv": + return []string{"Name", "Path"} + case "services.csv": + return []string{"Name"} + case "ad-users.csv": + return []string{"SamAccountName"} + case "local-users.csv": + return []string{"Name"} + case "wmi-bindings.csv": + return []string{"Filter", "Consumer"} + default: + return nil + } +} + +// findLatestBaselines scans baseDir for directories named baseline-MMDD-HHMM and returns the two +// most recent directories by filename ordering (latest, previous) based on MMDD-HHMM parsed from the name. +func findLatestBaselinesByName(baseDir string) (string, string, error) { + re := regexp.MustCompile(`^baseline-\d{4}-\d{4}$`) + entries, err := os.ReadDir(baseDir) + if err != nil { + return "", "", fmt.Errorf("could not read base dir %s: %v", baseDir, err) + } + candidates := []string{} + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if re.MatchString(name) { + candidates = append(candidates, name) + } + } + if len(candidates) < 2 { + return "", "", fmt.Errorf("not enough baseline folders found in %s", baseDir) + } + + sort.Strings(candidates) + nts := candidates[len(candidates)-2:] + return filepath.Join(baseDir, nts[0]), filepath.Join(baseDir, nts[1]), nil +} + +func loadCSVWithKey(path string, keyCols []string) (map[string]map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + r := csv.NewReader(f) + recs, err := r.ReadAll() + if err != nil { + return nil, err + } + if len(recs) < 1 { + return nil, fmt.Errorf("empty csv: %s", path) + } + headers := recs[0] + out := make(map[string]map[string]string) + + buildKey := func(row []string) string { + if len(keyCols) == 0 { + if len(row) > 0 { + return row[0] + } + return "" + } + vals := make([]string, 0, len(keyCols)) + for _, kc := range keyCols { + idx := -1 + for i, h := range headers { + if h == kc { + idx = i + break + } + } + if idx >= 0 && idx < len(row) { + vals = append(vals, row[idx]) + } else { + vals = append(vals, "") + } + } + return strings.Join(vals, " | ") + } + + for _, row := range recs[1:] { + if len(row) == 0 { + continue + } + m := make(map[string]string) + for i, cell := range row { + if i < len(headers) { + m[headers[i]] = cell + } else { + m[fmt.Sprintf("col_%d", i)] = cell + } + } + key := buildKey(row) + out[key] = m + } + return out, nil +} + +func formatObject(key string, obj map[string]string) string { + if obj == nil { + return key + } + lines := []string{key} + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + lines = append(lines, fmt.Sprintf("\t\t%s: %s", k, obj[k])) + } + return strings.Join(lines, "\n") +} + +func diffMaps(a, b map[string]map[string]string) (added []string, removed []string, changed []string) { + for k := range a { + if _, ok := b[k]; !ok { + removed = append(removed, k) + } + } + for k := range b { + if _, ok := a[k]; !ok { + added = append(added, k) + } + } + for k := range a { + if vb, ok := b[k]; ok { + va := a[k] + for hk, hv := range va { + if vb[hk] != hv { + changed = append(changed, fmt.Sprintf("%s: %s changed from '%s' to '%s'", k, hk, hv, vb[hk])) + } + } + } + } + return +} diff --git a/baseline/create_windows.go b/baseline/create_windows.go new file mode 100644 index 0000000..e7ad803 --- /dev/null +++ b/baseline/create_windows.go @@ -0,0 +1,108 @@ +package baseline + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/UT-CTF/landschaft/misc" + "github.com/UT-CTF/landschaft/util" + "github.com/spf13/cobra" +) + +func setupCreateCmd(cmd *cobra.Command) { + createCmd := &cobra.Command{ + Use: "create", + Short: "Create baselines", + } + + createAllCmd := &cobra.Command{ + Use: "all", + Short: "Create all baselines into a directory", + Long: "Create all baselines (services, processes, autoruns, users, adobjects, ports) and save CSV files into the provided output directory. Use -o/--output to specify the output directory; if omitted a new directory named baseline-MMDD-HHMM will be created in the current working directory.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + out := "" + // output flag selects the output folder + if of, _ := cmd.Flags().GetString("output"); of != "" { + out = of + } + if out == "" { + // create default folder baseline-MMDD-HHMM in cwd + now := time.Now() + out = fmt.Sprintf("baseline-%02d%02d-%02d%02d", int(now.Month()), now.Day(), now.Hour(), now.Minute()) + } + // ensure absolute path and create directory + out, _ = filepath.Abs(out) + _ = os.MkdirAll(out, 0755) + _ = misc.EnsureSysinternals(sysinternalsDirectory) + componentList := localScripts + if checkIfDomainController() { + componentList = dcScripts + } + + for _, component := range componentList { + fmt.Printf("Creating baseline for %s...\n", component) + createSingleBaseline(component, out) + } + }, + } + createCmd.AddCommand(createAllCmd) + createAllCmd.Flags().StringP("output", "o", "", "Custom output folder name") + + for name := range baselineComponents { + cmdC := &cobra.Command{ + Use: name, + Short: fmt.Sprintf("Create %s baseline", name), + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + out, _ := cmd.Flags().GetString("output") + if out == "" { + // create default folder baseline-MMDD-HHMM in cwd + now := time.Now() + out = fmt.Sprintf("baseline-%02d%02d-%02d%02d", int(now.Month()), now.Day(), now.Hour(), now.Minute()) + } + out, err := filepath.Abs(out) + if err != nil { + fmt.Printf("Error getting absolute path for output directory: %v\n", err) + return + } + _ = os.MkdirAll(out, 0755) + createSingleBaseline(name, out) + }, + } + + cmdC.Flags().StringP("output", "o", "", "Output folder for this baseline (defaults to timestamped folder)") + createCmd.AddCommand(cmdC) + } + + cmd.AddCommand(createCmd) +} + +func checkIfDomainController() bool { + cmd := exec.Command("wmic", "computersystem", "get", "domainrole") + output, err := cmd.Output() + if err != nil { + fmt.Printf("Error checking domain role: %v\n", err) + return false + } + outStr := string(output) + return strings.Contains(outStr, "4") || strings.Contains(outStr, "5") +} + +func createSingleBaseline(name, baselineDir string) { + scriptName, ok := baselineComponents[name] + if !ok { + fmt.Printf("Unknown baseline component: %s\n", name) + return + } + if name == "autoruns" { + _ = misc.EnsureSysinternals(sysinternalsDirectory) + util.RunAndRedirectScript(fmt.Sprintf("baseline/%s", scriptName), "-BaselinePath", fmt.Sprintf("'%s'", baselineDir), "-SysinternalsPath", fmt.Sprintf("'%s'", sysinternalsDirectory)) + } else { + util.RunAndRedirectScript(fmt.Sprintf("baseline/%s", scriptName), "-BaselinePath", fmt.Sprintf("'%s'", baselineDir)) + } +} diff --git a/baseline/services_windows.go b/baseline/services_windows.go deleted file mode 100644 index 63f1754..0000000 --- a/baseline/services_windows.go +++ /dev/null @@ -1,141 +0,0 @@ -package baseline - -import ( - "encoding/csv" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/UT-CTF/landschaft/util" - "github.com/spf13/cobra" -) - -type baselineConfig struct { - create bool - compare bool - output string - files []string -} - -var baselineCfg baselineConfig - -var servicesCmd = &cobra.Command{ - Use: "services", - Short: "Create or compare services baseline", - Run: func(cmd *cobra.Command, args []string) { - baselineServices(cmd, baselineCfg) - }, -} - -func baselineServices(cmd *cobra.Command, cfg baselineConfig) { - if cfg.create { - createBaseline(cfg.output) - } else if cfg.compare { - if len(cfg.files) != 2 { - fmt.Printf("Expected 2 files, got %d ([%s])\n", len(cfg.files), strings.Join(cfg.files, ", ")) - _ = cmd.Usage() - return - } - compareServices(cfg.files[0], cfg.files[1]) - } else { - fmt.Println("Invalid options") - } -} - -func setupServicesCmd(cmd *cobra.Command) { - servicesCmd.Flags().BoolVarP(&baselineCfg.create, "create", "c", false, "Create baseline") - servicesCmd.Flags().BoolVarP(&baselineCfg.compare, "compare", "m", false, "Compare baseline") - servicesCmd.Flags().StringVarP(&baselineCfg.output, "output", "o", "", "Output file (expected .csv file)") - servicesCmd.Flags().StringSliceVarP(&baselineCfg.files, "files", "f", []string{}, "Files to compare (expected 2 .csv files)") - servicesCmd.MarkFlagsMutuallyExclusive("create", "compare") - servicesCmd.MarkFlagsOneRequired("create", "compare") - servicesCmd.MarkFlagsRequiredTogether("create", "output") - servicesCmd.MarkFlagsRequiredTogether("compare", "files") - - cmd.AddCommand(servicesCmd) -} - -func createBaseline(csvPath string) { - csvPath, err := filepath.Abs(csvPath) - if err != nil { - fmt.Println("Could not get absolute path: ", err) - return - } - util.RunAndPrintScript("baseline/services.ps1", "-ExportPath", fmt.Sprintf("'%s'", csvPath)) -} - -func compareServices(csvPath1 string, csvPath2 string) { - services1, err := loadServices(csvPath1) - if err != nil { - fmt.Println(err) - return - } - - services2, err := loadServices(csvPath2) - if err != nil { - fmt.Println(err) - return - } - - newServices := make([]string, 0) - removedServices := make([]string, 0) - commonServices := make([]string, 0) - - for name := range services1 { - if _, ok := services2[name]; !ok { - removedServices = append(removedServices, name) - } else { - commonServices = append(commonServices, name) - } - } - - for name := range services2 { - if _, ok := services1[name]; !ok { - newServices = append(newServices, name) - } - } - - fmt.Printf("New services: \n\t%s\n", strings.Join(newServices, "\n\t")) - fmt.Println(strings.Repeat("-", 50)) - fmt.Printf("Removed services: \n\t%s\n", strings.Join(removedServices, "\n\t")) - fmt.Println(strings.Repeat("-", 50)) - fmt.Println("Changed services:") - for _, name := range commonServices { - service1 := services1[name] - service2 := services2[name] - - for key, value := range service1 { - if service2[key] != value { - fmt.Printf("\tService %s: %s changed from %s to %s\n", name, key, value, service2[key]) - } - } - } -} - -func loadServices(csvFile string) (map[string]map[string]string, error) { - file, err := os.Open(csvFile) - if err != nil { - return nil, fmt.Errorf("could not open %s: %w", csvFile, err) - } - defer file.Close() - - reader := csv.NewReader(file) - records, err := reader.ReadAll() - if err != nil { - return nil, fmt.Errorf("could not read %s: %w", csvFile, err) - } - - services := make(map[string]map[string]string) - - rowNames := records[0] - for _, row := range records[1:] { - service := make(map[string]string) - for i, cell := range row { - service[rowNames[i]] = cell - } - // fmt.Println(service) - services[service["Name"]] = service - } - return services, nil -} diff --git a/embed/windows/baseline/ad-objects.ps1 b/embed/windows/baseline/ad-objects.ps1 new file mode 100644 index 0000000..178dd8a --- /dev/null +++ b/embed/windows/baseline/ad-objects.ps1 @@ -0,0 +1,17 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Import-Module ActiveDirectory +Get-ADObject -Filter * -Properties * | ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + DistinguishedName = $_.DistinguishedName + ObjectClass = $_.ObjectClass + } +} | Sort-Object Name | Export-Csv "$BaselinePath\ad-objects.csv" -NoTypeInformation diff --git a/embed/windows/baseline/ad-users.ps1 b/embed/windows/baseline/ad-users.ps1 new file mode 100644 index 0000000..13a25b9 --- /dev/null +++ b/embed/windows/baseline/ad-users.ps1 @@ -0,0 +1,26 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Import-Module ActiveDirectory +Get-ADUser -Filter * -Properties * | ForEach-Object { + $groupNames = $_.MemberOf | ForEach-Object { (Get-ADGroup $_).Name } | Sort-Object + [PSCustomObject]@{ + Name = $_.Name + SamAccountName = $_.SamAccountName + Enabled = $_.Enabled + Groups = $groupNames -join ';' + AccountNotDelegated = $_.AccountNotDelegated + AllowReversiblePasswordEncryption = $_.AllowReversiblePasswordEncryption + DoesNotRequirePreAuth = $_.DoesNotRequirePreAuth + PasswordNeverExpires = $_.PasswordNeverExpires + PasswordNotRequired = $_.PasswordNotRequired + TrustedForDelegation = $_.TrustedForDelegation + TrustedToAuthForDelegation = $_.TrustedToAuthForDelegation + } +} | Sort-Object Name | Export-Csv "$BaselinePath\ad-users.csv" -NoTypeInformation diff --git a/embed/windows/baseline/autoruns.ps1 b/embed/windows/baseline/autoruns.ps1 new file mode 100644 index 0000000..25a3868 --- /dev/null +++ b/embed/windows/baseline/autoruns.ps1 @@ -0,0 +1,27 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath, + [Parameter(Mandatory = $true)] + [string]$SysinternalsPath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +& "$SysinternalsPath\autorunsc64.exe" -accepteula -a * -x * -h -nobanner > "$BaselinePath\autoruns.xml" +$xml = [xml](Get-Content "$BaselinePath\autoruns.xml") +$xml.autoruns.item | ForEach-Object { + [PSCustomObject]@{ + Location = $_.location + Name = $_.itemname + Enabled = $_.enabled + Profile = $_.profile + LaunchString = $_.launchstring + Description = $_.description + Company = $_.company + ImagePath = $_.imagepath + Hash = $_.sha256hash + } +} | Sort-Object Location, Name | Export-Csv "$BaselinePath\autoruns.csv" -NoTypeInformation +Remove-Item "$BaselinePath\autoruns.xml" -Force diff --git a/embed/windows/baseline/local-users.ps1 b/embed/windows/baseline/local-users.ps1 new file mode 100644 index 0000000..e2679fb --- /dev/null +++ b/embed/windows/baseline/local-users.ps1 @@ -0,0 +1,10 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Get-LocalUser | Select-Object Name, Enabled, Description | Sort-Object Name | Export-Csv "$BaselinePath\local-users.csv" -NoTypeInformation diff --git a/embed/windows/baseline/ports.ps1 b/embed/windows/baseline/ports.ps1 new file mode 100644 index 0000000..5fd8a84 --- /dev/null +++ b/embed/windows/baseline/ports.ps1 @@ -0,0 +1,14 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +Get-NetTCPConnection -State Listen | + Select-Object LocalAddress, LocalPort, @{Name = "Process"; Expression = {(Get-Process -Id $_.OwningProcess -ea 0).ProcessName}} | + Where-Object {$_.LocalAddress -notin @("127.0.0.1","::1")} | + Sort-Object LocalPort | + Export-Csv "$BaselinePath\ports.csv" -NoTypeInformation diff --git a/embed/windows/baseline/processes.ps1 b/embed/windows/baseline/processes.ps1 new file mode 100644 index 0000000..def1414 --- /dev/null +++ b/embed/windows/baseline/processes.ps1 @@ -0,0 +1,30 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +try { + Get-Process -IncludeUserName | + Select-Object Name, Path, UserName | + Sort-Object Name | + Export-Csv "$BaselinePath\processes.csv" -NoTypeInformation +} +catch { + Get-Process | ForEach-Object { + try { + $owner = (Get-WmiObject -Class Win32_Process -Filter "ProcessId = $($_.Id)").GetOwner() + $user = "$($owner.Domain)\$($owner.User)" + } + catch { $user = "N/A" } + $path = try { $_.Path } catch { "N/A" } + [PSCustomObject]@{ + Name = $_.Name + Path = $path + User = $user + } + } | Sort-Object Name | Export-Csv "$BaselinePath\processes.csv" -NoTypeInformation +} diff --git a/embed/windows/baseline/services.ps1 b/embed/windows/baseline/services.ps1 index 9f72602..dd5fcd9 100644 --- a/embed/windows/baseline/services.ps1 +++ b/embed/windows/baseline/services.ps1 @@ -1,68 +1,10 @@ -param ( +param( [Parameter(Mandatory = $true)] - [String]$ExportPath + [string]$BaselinePath ) -function Get-HashBackup($filePath, $hashType = "SHA256") { - $hasher = [System.Security.Cryptography.HashAlgorithm]::Create($hashType) - $stream = [System.IO.File]::OpenRead($filePath) - $hashBytes = $hasher.ComputeHash($stream) - $stream.Close() - return -join ($hashBytes | ForEach-Object { "{0:X2}" -f $_ }) -} -function Get-ServiceInfoList { - $serviceList = Get-WmiObject -Class Win32_Service | Select-Object Name, StartMode, State, PathName, DisplayName - - $outList = @() - - foreach ($service in $serviceList) { - $pathName = $service.PathName - if ($null -eq $pathName) { - $pathName = "N/A" - } - $exePath = $pathName - if ($exePath -match '"([^"]+)"') { - $exePath = $Matches[1] - } - else { - $spaceInd = $exePath.IndexOf(' ') - if ($spaceInd -gt 0) { - $exePath = $exePath.Substring(0, $spaceInd) - } - } - - if ($exePath -eq "N/A") { - $tmpHash = "N/A" - } - else { - try { - $tmpHash = (Get-FileHash -Path $exePath -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash - } - catch { - try { - $tmpHash = Get-HashBackup -filePath $exePath - } - catch { - $tmpHash = "N/A" - } - } - if ($null -eq $tmpHash) { - Write-Host "Error hashing $exePath" - $tmpHash = "N/A" - } - } - $outList += [PSCustomObject]@{ - Name = $service.Name - DisplayName = $service.DisplayName - StartMode = $service.StartMode - State = $service.State - Path = $pathName - Executable = $exePath - ExecutableHash = $tmpHash - } - } - return $outList +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null } -Get-ServiceInfoList | ConvertTo-Csv -NoTypeInformation | Set-Content -Path $ExportPath -Write-Host "Wrote service baseline to $ExportPath" +Get-Service | Select-Object Name, DisplayName, Status, StartType | Sort-Object Name | Export-Csv "$BaselinePath\services.csv" -NoTypeInformation diff --git a/embed/windows/baseline/wmi-subscriptions.ps1 b/embed/windows/baseline/wmi-subscriptions.ps1 new file mode 100644 index 0000000..f52d7fc --- /dev/null +++ b/embed/windows/baseline/wmi-subscriptions.ps1 @@ -0,0 +1,84 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +$Namespace = "root\subscription" + +# Write-Host "[*] Enumerating WMI Event Filters..." +$filters = Get-CimInstance -Namespace $Namespace -ClassName __EventFilter -ErrorAction SilentlyContinue | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + Query = $_.Query + QueryLanguage = $_.QueryLanguage + EventNamespace = $_.EventNamespace + CreatorSID = ($_.CreatorSID -join ",") + } + } | Sort-Object Name + +$filters | Export-Csv "$BaselinePath\wmi-eventfilters.csv" -NoTypeInformation + + +# Write-Host "[*] Enumerating CommandLineEventConsumers..." +$cmdConsumers = Get-CimInstance -Namespace $Namespace -ClassName CommandLineEventConsumer -ErrorAction SilentlyContinue | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + CommandLineTemplate = $_.CommandLineTemplate + RunInteractively = $_.RunInteractively + WorkingDirectory = $_.WorkingDirectory + CreatorSID = ($_.CreatorSID -join ",") + } + } | Sort-Object Name + +$cmdConsumers | Export-Csv "$BaselinePath\wmi-commandlineconsumers.csv" -NoTypeInformation + + +# Write-Host "[*] Enumerating ActiveScriptEventConsumers..." +$scriptConsumers = Get-CimInstance -Namespace $Namespace -ClassName ActiveScriptEventConsumer -ErrorAction SilentlyContinue | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + ScriptingEngine = $_.ScriptingEngine + ScriptText = $_.ScriptText + CreatorSID = ($_.CreatorSID -join ",") + } + } | Sort-Object Name + +$scriptConsumers | Export-Csv "$BaselinePath\wmi-activescriptconsumers.csv" -NoTypeInformation + + +# Write-Host "[*] Enumerating Other EventConsumer types..." +$allConsumers = Get-CimInstance -Namespace $Namespace -ClassName __EventConsumer -ErrorAction SilentlyContinue | + Where-Object { + $_.CimClass.CimClassName -notin @("CommandLineEventConsumer","ActiveScriptEventConsumer") + } | + ForEach-Object { + [PSCustomObject]@{ + Name = $_.Name + ClassName = $_.CimClass.CimClassName + CreatorSID = ($_.CreatorSID -join ",") + } + } | Sort-Object Name + +$allConsumers | Export-Csv "$BaselinePath\wmi-allconsumers.csv" -NoTypeInformation + + +# Write-Host "[*] Enumerating Filter-To-Consumer Bindings..." +$bindings = Get-CimInstance -Namespace $Namespace -ClassName __FilterToConsumerBinding -ErrorAction SilentlyContinue | + ForEach-Object { + [PSCustomObject]@{ + Filter = ($_.Filter).Name + Consumer = ($_.Consumer).Name + } + } | Sort-Object Filter, Consumer + +$bindings | Export-Csv "$BaselinePath\wmi-bindings.csv" -NoTypeInformation + + +# Write-Host "[+] WMI subscription baseline completed." diff --git a/misc/tools_windows.go b/misc/tools_windows.go index fe33354..39b04f7 100644 --- a/misc/tools_windows.go +++ b/misc/tools_windows.go @@ -100,6 +100,29 @@ func installSysinternals(targetDir string) { fmt.Println("Sysinternals Suite installed successfully") } +// EnsureSysinternals ensures the Sysinternals suite is present at targetDir. If not +// present it will download and extract it. Returns the path to the directory or an +// error string printed to stdout. +func EnsureSysinternals(targetDir string) error { + // If target exists and looks populated, assume it's fine + info, err := os.Stat(targetDir) + if err == nil && info.IsDir() { + // quick check: expect autorunsc64.exe to exist + if _, err := os.Stat(filepath.Join(targetDir, "autorunsc64.exe")); err == nil { + return nil + } + } + + fmt.Printf("Sysinternals not found at %s, downloading and extracting...\n", targetDir) + installSysinternals(targetDir) + + // final check + if _, err := os.Stat(filepath.Join(targetDir, "autorunsc64.exe")); err != nil { + return fmt.Errorf("sysinternals extraction failed or autorunsc64.exe missing: %w", err) + } + return nil +} + // func installPython() { // fmt.Println("Not implemented") // }