From 37b271b0674c55e2b882fe72838e3dc3dae92d75 Mon Sep 17 00:00:00 2001 From: Ameya Purao Date: Fri, 20 Feb 2026 22:24:05 -0600 Subject: [PATCH 1/4] better windows baseline --- baseline/cmd_windows.go | 132 +++++++++++++++++- baseline/compare_windows.go | 201 +++++++++++++++++++++++++++ baseline/services_windows.go | 16 ++- embed/windows/baseline/adobjects.ps1 | 17 +++ embed/windows/baseline/autoruns.ps1 | 27 ++++ embed/windows/baseline/ports.ps1 | 14 ++ embed/windows/baseline/processes.ps1 | 22 +++ embed/windows/baseline/services.ps1 | 68 +-------- embed/windows/baseline/users.ps1 | 19 +++ misc/tools_windows.go | 23 +++ 10 files changed, 473 insertions(+), 66 deletions(-) create mode 100644 baseline/compare_windows.go create mode 100644 embed/windows/baseline/adobjects.ps1 create mode 100644 embed/windows/baseline/autoruns.ps1 create mode 100644 embed/windows/baseline/ports.ps1 create mode 100644 embed/windows/baseline/processes.ps1 create mode 100644 embed/windows/baseline/users.ps1 diff --git a/baseline/cmd_windows.go b/baseline/cmd_windows.go index 6d44196..e8646b1 100644 --- a/baseline/cmd_windows.go +++ b/baseline/cmd_windows.go @@ -2,13 +2,143 @@ package baseline import ( "fmt" + "path/filepath" + "strings" + "github.com/UT-CTF/landschaft/misc" "github.com/UT-CTF/landschaft/util" "github.com/spf13/cobra" ) func SetupCommand(cmd *cobra.Command) { - setupServicesCmd(cmd) + // create and compare parent commands + createCmd := &cobra.Command{ + Use: "create", + Short: "Create baselines", + } + compareCmd := &cobra.Command{ + Use: "compare", + Short: "Compare baselines", + } + + // list of components and their script names + components := map[string]string{ + "services": "services.ps1", + "processes": "processes.ps1", + "autoruns": "autoruns.ps1", + "users": "users.ps1", + "adobjects": "adobjects.ps1", + "ports": "ports.ps1", + } + + // create all (positional: output-dir) + 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.", + Args: cobra.ExactArgs(1), + Example: " landschaft baseline create all C:\\baselines", + Run: func(cmd *cobra.Command, args []string) { + out := args[0] + out, _ = filepath.Abs(out) + // ensure sysinternals + siPath := `C:\\ProgramData\\landschaft\\sysinternals` + _ = misc.EnsureSysinternals(siPath) + for name, script := range components { + fmt.Printf("Running %s baseline...\n", name) + scriptPath := fmt.Sprintf("baseline/%s", script) + // autoruns requires sysinternals path + if name == "autoruns" { + util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath)) + } else { + util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out)) + } + } + }, + } + + createCmd.AddCommand(createAllCmd) + + // per-component create (positional: output-dir) + for name, script := range components { + n := name + s := script + cmdC := &cobra.Command{ + Use: n + " ", + Short: fmt.Sprintf("Create %s baseline", n), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + out := args[0] + out, _ = filepath.Abs(out) + siPath := `C:\\ProgramData\\landschaft\\sysinternals` + _ = misc.EnsureSysinternals(siPath) + scriptPath := fmt.Sprintf("baseline/%s", s) + if n == "autoruns" { + util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath)) + } else { + util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out)) + } + }, + } + + createCmd.AddCommand(cmdC) + } + + // compare all + 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.", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + compareCSVDirs(args[0], args[1]) + }, + } + compareCmd.AddCommand(compareAllCmd) + + // per-component compare (positional: fileA fileB) + for name := range components { + n := name + cmdCmp := &cobra.Command{ + Use: n + " ", + Short: fmt.Sprintf("Compare %s baselines", n), + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + fileA := args[0] + fileB := args[1] + // for services we have a specialized comparator + if n == "services" { + compareServices(fileA, fileB) + return + } + mA, errA := loadGenericCSV(fileA) + mB, errB := loadGenericCSV(fileB) + if errA != nil || errB != nil { + fmt.Printf("Error loading files: %v %v\n", errA, errB) + return + } + added, removed, changed := diffMaps(mA, mB) + if len(added) > 0 { + fmt.Printf("Added:\n\t%s\n", strings.Join(added, "\n\t")) + } + if len(removed) > 0 { + fmt.Printf("Removed:\n\t%s\n", strings.Join(removed, "\n\t")) + } + if len(changed) > 0 { + fmt.Println("Changed entries:") + for _, c := range changed { + fmt.Printf("\t%s\n", c) + } + } + }, + } + + compareCmd.AddCommand(cmdCmp) + } + + // register with parent + cmd.AddCommand(createCmd) + cmd.AddCommand(compareCmd) } func Run(cmd *cobra.Command) { diff --git a/baseline/compare_windows.go b/baseline/compare_windows.go new file mode 100644 index 0000000..2c66d36 --- /dev/null +++ b/baseline/compare_windows.go @@ -0,0 +1,201 @@ +package baseline + +import ( + "encoding/csv" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// compareCSVDirs compares two baseline directories. Each directory is expected to +// contain CSV files named services.csv, processes.csv, autoruns.csv, users.csv, +// adobjects.csv, ports.csv. The function will print added/removed/changed items +// per-file using component-specific primary keys and prints full objects for +// additions/removals. +func compareCSVDirs(dirA, dirB string) { + dirA, _ = filepath.Abs(dirA) + dirB, _ = filepath.Abs(dirB) + + files := []string{"services.csv", "processes.csv", "autoruns.csv", "users.csv", "adobjects.csv", "ports.csv"} + + for _, f := range files { + pathA := filepath.Join(dirA, f) + pathB := filepath.Join(dirB, f) + + keyCols := keyColumnsForFile(f) + mA, errA := loadCSVWithKey(pathA, keyCols) + mB, errB := loadCSVWithKey(pathB, keyCols) + + fmt.Println(strings.Repeat("=", 60)) + fmt.Printf("Comparing %s\n", f) + + if errA != nil { + fmt.Printf("Could not load %s: %v\n", pathA, errA) + } + if errB != nil { + fmt.Printf("Could not load %s: %v\n", pathB, errB) + } + if errA != nil || errB != nil { + continue + } + + addedKeys, removedKeys, changed := diffMaps(mA, mB) + + 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, mB[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, mA[k])) + } + } + if len(changed) > 0 { + fmt.Println("Changed entries:") + for _, c := range changed { + fmt.Printf("\t%s\n", c) + } + } + } +} + +// keyColumnsForFile returns the list of CSV column names to be used as the +// primary key for a given file name. +func keyColumnsForFile(file string) []string { + switch file { + case "adobjects.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 "users.csv": + return []string{"SamAccountName"} + default: + return nil + } +} + +// loadCSVWithKey loads a CSV into a map keyed by the composite key defined by +// keyCols. If keyCols is nil or empty, the first column is used as the key. +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 +} + +// formatObject returns a single-line representation of the object's fields in +// key:value pairs separated by ", ". +func formatObject(key string, obj map[string]string) string { + if obj == nil { + return key + } + // pretty multi-line output: key on first line, then each field on its own indented line + 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") +} + +// loadGenericCSV remains as a convenience wrapper using first-column key. +func loadGenericCSV(path string) (map[string]map[string]string, error) { + return loadCSVWithKey(path, nil) +} + +// diffMaps returns added keys (in b but not a), removed keys (in a but not b), and +// changed descriptions for keys present in both where values differ. +func diffMaps(a, b map[string]map[string]string) (added, 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/services_windows.go b/baseline/services_windows.go index 63f1754..c426a90 100644 --- a/baseline/services_windows.go +++ b/baseline/services_windows.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/UT-CTF/landschaft/misc" "github.com/UT-CTF/landschaft/util" "github.com/spf13/cobra" ) @@ -37,7 +38,8 @@ func baselineServices(cmd *cobra.Command, cfg baselineConfig) { _ = cmd.Usage() return } - compareServices(cfg.files[0], cfg.files[1]) + // Allow comparing directories which contain CSVs + compareCSVDirs(cfg.files[0], cfg.files[1]) } else { fmt.Println("Invalid options") } @@ -62,7 +64,17 @@ func createBaseline(csvPath string) { fmt.Println("Could not get absolute path: ", err) return } - util.RunAndPrintScript("baseline/services.ps1", "-ExportPath", fmt.Sprintf("'%s'", csvPath)) + + // Ensure sysinternals are available at C:\\ProgramData\\landschaft\\sysinternals + siPath := `C:\\ProgramData\\landschaft\\sysinternals` + if err := misc.EnsureSysinternals(siPath); err != nil { + fmt.Println("Warning: could not ensure sysinternals: ", err) + // continue; autoruns collection is optional per the PowerShell script + } + + // Use the new embedded baseline.ps1 for full baseline collection. Pass the Sysinternals path + // so the script can use autorunsc64 if available. + util.RunAndPrintScript("embed/windows/baseline/baseline.ps1", "-BaselinePath", fmt.Sprintf("'%s'", filepath.Dir(csvPath)), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath)) } func compareServices(csvPath1 string, csvPath2 string) { diff --git a/embed/windows/baseline/adobjects.ps1 b/embed/windows/baseline/adobjects.ps1 new file mode 100644 index 0000000..a2d10c3 --- /dev/null +++ b/embed/windows/baseline/adobjects.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\adobjects.csv" -NoTypeInformation diff --git a/embed/windows/baseline/autoruns.ps1 b/embed/windows/baseline/autoruns.ps1 new file mode 100644 index 0000000..e9943da --- /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" -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/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..174b841 --- /dev/null +++ b/embed/windows/baseline/processes.ps1 @@ -0,0 +1,22 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +if (-not (Test-Path $BaselinePath)) { + New-Item -ItemType Directory -Path $BaselinePath | Out-Null +} + +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/users.ps1 b/embed/windows/baseline/users.ps1 new file mode 100644 index 0000000..9a9cf74 --- /dev/null +++ b/embed/windows/baseline/users.ps1 @@ -0,0 +1,19 @@ +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 ';' + } +} | Sort-Object Name | Export-Csv "$BaselinePath\users.csv" -NoTypeInformation 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") // } From d65dd1901cf49fbeb06983ba1b758442df583ec8 Mon Sep 17 00:00:00 2001 From: Ameya Purao Date: Tue, 24 Feb 2026 12:44:29 -0600 Subject: [PATCH 2/4] cleanup --- baseline/cmd_windows.go | 144 ++------------ baseline/compare_windows.go | 180 ++++++++++++------ baseline/create_windows.go | 83 ++++++++ baseline/services_windows.go | 153 --------------- .../{adobjects.ps1 => ad-objects.ps1} | 2 +- .../baseline/{users.ps1 => ad-users.ps1} | 2 +- embed/windows/baseline/autoruns.ps1 | 2 +- embed/windows/baseline/local-users.ps1 | 10 + embed/windows/baseline/processes.ps1 | 34 ++-- 9 files changed, 252 insertions(+), 358 deletions(-) create mode 100644 baseline/create_windows.go delete mode 100644 baseline/services_windows.go rename embed/windows/baseline/{adobjects.ps1 => ad-objects.ps1} (83%) rename embed/windows/baseline/{users.ps1 => ad-users.ps1} (86%) create mode 100644 embed/windows/baseline/local-users.ps1 diff --git a/baseline/cmd_windows.go b/baseline/cmd_windows.go index e8646b1..3431b6b 100644 --- a/baseline/cmd_windows.go +++ b/baseline/cmd_windows.go @@ -2,143 +2,29 @@ package baseline import ( "fmt" - "path/filepath" - "strings" - "github.com/UT-CTF/landschaft/misc" "github.com/UT-CTF/landschaft/util" "github.com/spf13/cobra" ) -func SetupCommand(cmd *cobra.Command) { - // create and compare parent commands - createCmd := &cobra.Command{ - Use: "create", - Short: "Create baselines", - } - compareCmd := &cobra.Command{ - Use: "compare", - Short: "Compare baselines", - } - - // list of components and their script names - components := map[string]string{ - "services": "services.ps1", - "processes": "processes.ps1", - "autoruns": "autoruns.ps1", - "users": "users.ps1", - "adobjects": "adobjects.ps1", - "ports": "ports.ps1", - } - - // create all (positional: output-dir) - 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.", - Args: cobra.ExactArgs(1), - Example: " landschaft baseline create all C:\\baselines", - Run: func(cmd *cobra.Command, args []string) { - out := args[0] - out, _ = filepath.Abs(out) - // ensure sysinternals - siPath := `C:\\ProgramData\\landschaft\\sysinternals` - _ = misc.EnsureSysinternals(siPath) - for name, script := range components { - fmt.Printf("Running %s baseline...\n", name) - scriptPath := fmt.Sprintf("baseline/%s", script) - // autoruns requires sysinternals path - if name == "autoruns" { - util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath)) - } else { - util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out)) - } - } - }, - } - - createCmd.AddCommand(createAllCmd) - - // per-component create (positional: output-dir) - for name, script := range components { - n := name - s := script - cmdC := &cobra.Command{ - Use: n + " ", - Short: fmt.Sprintf("Create %s baseline", n), - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - out := args[0] - out, _ = filepath.Abs(out) - siPath := `C:\\ProgramData\\landschaft\\sysinternals` - _ = misc.EnsureSysinternals(siPath) - scriptPath := fmt.Sprintf("baseline/%s", s) - if n == "autoruns" { - util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath)) - } else { - util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out)) - } - }, - } - - createCmd.AddCommand(cmdC) - } - - // compare all - 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.", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - compareCSVDirs(args[0], args[1]) - }, - } - compareCmd.AddCommand(compareAllCmd) +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", +} - // per-component compare (positional: fileA fileB) - for name := range components { - n := name - cmdCmp := &cobra.Command{ - Use: n + " ", - Short: fmt.Sprintf("Compare %s baselines", n), - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - fileA := args[0] - fileB := args[1] - // for services we have a specialized comparator - if n == "services" { - compareServices(fileA, fileB) - return - } - mA, errA := loadGenericCSV(fileA) - mB, errB := loadGenericCSV(fileB) - if errA != nil || errB != nil { - fmt.Printf("Error loading files: %v %v\n", errA, errB) - return - } - added, removed, changed := diffMaps(mA, mB) - if len(added) > 0 { - fmt.Printf("Added:\n\t%s\n", strings.Join(added, "\n\t")) - } - if len(removed) > 0 { - fmt.Printf("Removed:\n\t%s\n", strings.Join(removed, "\n\t")) - } - if len(changed) > 0 { - fmt.Println("Changed entries:") - for _, c := range changed { - fmt.Printf("\t%s\n", c) - } - } - }, - } +var dcScripts = []string{"services", "processes", "autoruns", "ad-users", "adobjects", "ports"} +var localScripts = []string{"services", "processes", "autoruns", "local-users", "ports"} - compareCmd.AddCommand(cmdCmp) - } +var sysinternalsDirectory = `C:\\ProgramData\\landschaft\\sysinternals` - // register with parent - cmd.AddCommand(createCmd) - cmd.AddCommand(compareCmd) +func SetupCommand(cmd *cobra.Command) { + setupCompareCmd(cmd) + setupCreateCmd(cmd) } func Run(cmd *cobra.Command) { diff --git a/baseline/compare_windows.go b/baseline/compare_windows.go index 2c66d36..dc637f4 100644 --- a/baseline/compare_windows.go +++ b/baseline/compare_windows.go @@ -5,72 +5,142 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" + + "github.com/spf13/cobra" ) -// compareCSVDirs compares two baseline directories. Each directory is expected to -// contain CSV files named services.csv, processes.csv, autoruns.csv, users.csv, -// adobjects.csv, ports.csv. The function will print added/removed/changed items -// per-file using component-specific primary keys and prints full objects for -// additions/removals. -func compareCSVDirs(dirA, dirB string) { - dirA, _ = filepath.Abs(dirA) - dirB, _ = filepath.Abs(dirB) +func setupCompareCmd(cmd *cobra.Command) { + compareCmd := &cobra.Command{ + Use: "compare", + Short: "Compare baselines", + } - files := []string{"services.csv", "processes.csv", "autoruns.csv", "users.csv", "adobjects.csv", "ports.csv"} + 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.", + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + compareCSVDirs(args[0], args[1]) + }, + } + compareCmd.AddCommand(compareAllCmd) - for _, f := range files { - pathA := filepath.Join(dirA, f) - pathB := filepath.Join(dirB, f) + 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.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + err := compareCSVFiles(fmt.Sprintf("%s.csv", name), args[0], args[1]) + if err != nil { + fmt.Printf("Error comparing %s: %v\n", name, err) + } + }, + } - keyCols := keyColumnsForFile(f) - mA, errA := loadCSVWithKey(pathA, keyCols) - mB, errB := loadCSVWithKey(pathB, keyCols) + compareCmd.AddCommand(cmdCmp) + } - fmt.Println(strings.Repeat("=", 60)) - fmt.Printf("Comparing %s\n", f) + cmd.AddCommand(compareCmd) +} - if errA != nil { - fmt.Printf("Could not load %s: %v\n", pathA, errA) +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()) } - if errB != nil { - fmt.Printf("Could not load %s: %v\n", pathB, errB) + } + 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 errA != nil || errB != nil { - continue + } + 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 +} - addedKeys, removedKeys, changed := diffMaps(mA, mB) +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 + } - 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, mB[k])) - } + sharedFiles := []string{} + for _, fA := range filesA { + if slices.Contains(filesB, fA) { + sharedFiles = append(sharedFiles, fA) } - 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, mA[k])) - } - } - if len(changed) > 0 { - fmt.Println("Changed entries:") - for _, c := range changed { - fmt.Printf("\t%s\n", c) - } + } + + for _, f := range sharedFiles { + err := compareCSVFiles(f, dirA, dirB) + if err != nil { + fmt.Printf("Error comparing file %s: %v\n", f, err) } } } -// keyColumnsForFile returns the list of CSV column names to be used as the -// primary key for a given file name. func keyColumnsForFile(file string) []string { switch file { - case "adobjects.csv": + case "ad-objects.csv": return []string{"DistinguishedName"} case "autoruns.csv": return []string{"Location", "Name", "LaunchString"} @@ -80,15 +150,15 @@ func keyColumnsForFile(file string) []string { return []string{"Name", "Path"} case "services.csv": return []string{"Name"} - case "users.csv": + case "ad-users.csv": return []string{"SamAccountName"} + case "local-users.csv": + return []string{"Name"} default: return nil } } -// loadCSVWithKey loads a CSV into a map keyed by the composite key defined by -// keyCols. If keyCols is nil or empty, the first column is used as the key. func loadCSVWithKey(path string, keyCols []string) (map[string]map[string]string, error) { f, err := os.Open(path) if err != nil { @@ -129,7 +199,7 @@ func loadCSVWithKey(path string, keyCols []string) (map[string]map[string]string vals = append(vals, "") } } - return strings.Join(vals, "|") + return strings.Join(vals, " | ") } for _, row := range recs[1:] { @@ -150,13 +220,10 @@ func loadCSVWithKey(path string, keyCols []string) (map[string]map[string]string return out, nil } -// formatObject returns a single-line representation of the object's fields in -// key:value pairs separated by ", ". func formatObject(key string, obj map[string]string) string { if obj == nil { return key } - // pretty multi-line output: key on first line, then each field on its own indented line lines := []string{key} keys := make([]string, 0, len(obj)) for k := range obj { @@ -169,14 +236,7 @@ func formatObject(key string, obj map[string]string) string { return strings.Join(lines, "\n") } -// loadGenericCSV remains as a convenience wrapper using first-column key. -func loadGenericCSV(path string) (map[string]map[string]string, error) { - return loadCSVWithKey(path, nil) -} - -// diffMaps returns added keys (in b but not a), removed keys (in a but not b), and -// changed descriptions for keys present in both where values differ. -func diffMaps(a, b map[string]map[string]string) (added, removed []string, changed []string) { +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) diff --git a/baseline/create_windows.go b/baseline/create_windows.go new file mode 100644 index 0000000..969fa58 --- /dev/null +++ b/baseline/create_windows.go @@ -0,0 +1,83 @@ +package baseline + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + + "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.", + Args: cobra.ExactArgs(1), + Example: " landschaft baseline create all C:\\baselines", + Run: func(cmd *cobra.Command, args []string) { + out := args[0] + out, _ = filepath.Abs(out) + _ = 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) + + for name := range baselineComponents { + cmdC := &cobra.Command{ + Use: name + " ", + Short: fmt.Sprintf("Create %s baseline", name), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + out := args[0] + out, err := filepath.Abs(out) + if err != nil { + fmt.Printf("Error getting absolute path for output directory: %v\n", err) + return + } + createSingleBaseline(name, out) + }, + } + + 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) { + if name == "autoruns" { + _ = misc.EnsureSysinternals(sysinternalsDirectory) + util.RunAndRedirectScript(fmt.Sprintf("baseline/%s", baselineComponents[name]), "-BaselinePath", fmt.Sprintf("'%s'", baselineDir), "-SysinternalsPath", fmt.Sprintf("'%s'", sysinternalsDirectory)) + } else { + util.RunAndRedirectScript(fmt.Sprintf("baseline/%s", baselineComponents[name]), "-BaselinePath", fmt.Sprintf("'%s'", baselineDir)) + } +} diff --git a/baseline/services_windows.go b/baseline/services_windows.go deleted file mode 100644 index c426a90..0000000 --- a/baseline/services_windows.go +++ /dev/null @@ -1,153 +0,0 @@ -package baseline - -import ( - "encoding/csv" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/UT-CTF/landschaft/misc" - "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 - } - // Allow comparing directories which contain CSVs - compareCSVDirs(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 - } - - // Ensure sysinternals are available at C:\\ProgramData\\landschaft\\sysinternals - siPath := `C:\\ProgramData\\landschaft\\sysinternals` - if err := misc.EnsureSysinternals(siPath); err != nil { - fmt.Println("Warning: could not ensure sysinternals: ", err) - // continue; autoruns collection is optional per the PowerShell script - } - - // Use the new embedded baseline.ps1 for full baseline collection. Pass the Sysinternals path - // so the script can use autorunsc64 if available. - util.RunAndPrintScript("embed/windows/baseline/baseline.ps1", "-BaselinePath", fmt.Sprintf("'%s'", filepath.Dir(csvPath)), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath)) -} - -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/adobjects.ps1 b/embed/windows/baseline/ad-objects.ps1 similarity index 83% rename from embed/windows/baseline/adobjects.ps1 rename to embed/windows/baseline/ad-objects.ps1 index a2d10c3..178dd8a 100644 --- a/embed/windows/baseline/adobjects.ps1 +++ b/embed/windows/baseline/ad-objects.ps1 @@ -14,4 +14,4 @@ Get-ADObject -Filter * -Properties * | ForEach-Object { DistinguishedName = $_.DistinguishedName ObjectClass = $_.ObjectClass } -} | Sort-Object Name | Export-Csv "$BaselinePath\adobjects.csv" -NoTypeInformation +} | Sort-Object Name | Export-Csv "$BaselinePath\ad-objects.csv" -NoTypeInformation diff --git a/embed/windows/baseline/users.ps1 b/embed/windows/baseline/ad-users.ps1 similarity index 86% rename from embed/windows/baseline/users.ps1 rename to embed/windows/baseline/ad-users.ps1 index 9a9cf74..ac7f7e8 100644 --- a/embed/windows/baseline/users.ps1 +++ b/embed/windows/baseline/ad-users.ps1 @@ -16,4 +16,4 @@ Get-ADUser -Filter * -Properties * | ForEach-Object { Enabled = $_.Enabled Groups = $groupNames -join ';' } -} | Sort-Object Name | Export-Csv "$BaselinePath\users.csv" -NoTypeInformation +} | Sort-Object Name | Export-Csv "$BaselinePath\ad-users.csv" -NoTypeInformation diff --git a/embed/windows/baseline/autoruns.ps1 b/embed/windows/baseline/autoruns.ps1 index e9943da..25a3868 100644 --- a/embed/windows/baseline/autoruns.ps1 +++ b/embed/windows/baseline/autoruns.ps1 @@ -9,7 +9,7 @@ if (-not (Test-Path $BaselinePath)) { New-Item -ItemType Directory -Path $BaselinePath | Out-Null } -& "$SysinternalsPath\autorunsc64.exe" -a * -x * -h -nobanner > "$BaselinePath\autoruns.xml" +& "$SysinternalsPath\autorunsc64.exe" -accepteula -a * -x * -h -nobanner > "$BaselinePath\autoruns.xml" $xml = [xml](Get-Content "$BaselinePath\autoruns.xml") $xml.autoruns.item | ForEach-Object { [PSCustomObject]@{ 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/processes.ps1 b/embed/windows/baseline/processes.ps1 index 174b841..def1414 100644 --- a/embed/windows/baseline/processes.ps1 +++ b/embed/windows/baseline/processes.ps1 @@ -7,16 +7,24 @@ if (-not (Test-Path $BaselinePath)) { New-Item -ItemType Directory -Path $BaselinePath | Out-Null } -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 +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 +} From f819f81f6a6360fcc454b7497b46cbafc9a68029 Mon Sep 17 00:00:00 2001 From: Ameya Purao Date: Tue, 24 Feb 2026 12:49:54 -0600 Subject: [PATCH 3/4] file setup --- embed/windows/harden/apply_firewall.ps1 | 56 +++++++++++++++++ embed/windows/harden/auto_firewall.ps1 | 57 +++++++++++++++++ .../harden/firewall_rules_inbound.json | 7 +++ harden/firewall_rules.go | 61 ------------------- harden/firewall_rules_linux.go | 12 +--- harden/firewall_rules_windows.go | 55 +++++++++++++++++ 6 files changed, 178 insertions(+), 70 deletions(-) create mode 100644 embed/windows/harden/apply_firewall.ps1 create mode 100644 embed/windows/harden/auto_firewall.ps1 delete mode 100644 harden/firewall_rules.go diff --git a/embed/windows/harden/apply_firewall.ps1 b/embed/windows/harden/apply_firewall.ps1 new file mode 100644 index 0000000..b7fc3c8 --- /dev/null +++ b/embed/windows/harden/apply_firewall.ps1 @@ -0,0 +1,56 @@ +param( + [switch]$ClearScheduledTask, + [string]$RulesFile, + [string]$BackupFile, + [string]$Direction +) + +$scheduledTaskName = "LSCWFW - Restore Firewall Rules" + +if ($ClearScheduledTask) { + Unregister-ScheduledTask -TaskName $scheduledTaskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Host "Cleared scheduled task: $scheduledTaskName" + exit 0 +} + +try { +netsh advfirewall export $BackupFile +# create a scheduled task to restore the firewall rules in 10 minutes +$action = New-ScheduledTaskAction -Execute "netsh" -Argument "advfirewall import $BackupFile" +$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) +$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount +Register-ScheduledTask -TaskName $scheduledTaskName -Action $action -Trigger $trigger -Principal $principal +} catch { + Write-Host "Error creating scheduled backup task: $($_.Exception.Message)" + exit 1 +} + +$oldRules = Get-NetFirewallRule -Direction $Direction + +try { + (Get-Content $RulesFile | ConvertFrom-Json -ErrorAction Stop) | % { + $params = @{} + $_.PSObject.Properties | % { + $params[$_.Name] = $_.Value + } + $params.Enabled = "True" + $params.Profile = "Any" + $params.ErrorAction = "Stop" + Write-Host "Creating rule: $($params.DisplayName)" + New-NetFirewallRule @params + } +} catch { + Write-Host "Error applying firewall rules: $($_.Exception.Message)" + exit 1 +} + +Write-Host "Successfully added new firewall rules" +Write-Host "Removing old firewall rules ..." +try { + $oldRules | Remove-NetFirewallRule +} catch { + Write-Host "Error removing old rules: $($_.Exception.Message)" + exit 1 +} +Write-Host "Successfully removed old firewall rules" +Write-Host "Firewall rules updated successfully. If you need to restore the previous rules, a scheduled task has been created to do so in 5 minutes. You can also manually run 'netsh advfirewall import $BackupFile' to restore the backup immediately." diff --git a/embed/windows/harden/auto_firewall.ps1 b/embed/windows/harden/auto_firewall.ps1 new file mode 100644 index 0000000..9647edf --- /dev/null +++ b/embed/windows/harden/auto_firewall.ps1 @@ -0,0 +1,57 @@ +param ( + [string]$RulesFile +) + +$portInfo = Get-NetTCPConnection -State Listen | +Where-Object { $_.LocalAddress -notin @('127.0.0.1', '::1') } | +Sort-Object LocalPort | +Select-Object -Unique LocalPort, @{Name = 'ProcessName'; Expression = { (Get-Process -Id $_.OwningProcess).Name } } +$openPorts = $portInfo.LocalPort + +$prebuiltRulesFile = "firewall_rules_inbound.json" + +try { + $loadedRules = Get-Content $prebuiltRulesFile | ConvertFrom-Json +} +catch { + Write-Host "Error loading prebuilt rules: $($_.Exception.Message)" + exit 1 +} + +# Find all prebuilt rules that match the open ports +$matchingRules = $loadedRules | Where-Object { $_.LocalPort -in $openPorts } +$unmatchedPorts = $openPorts | Where-Object { $_ -notin $matchingRules.LocalPort } + +$matchingRules | ConvertTo-Json -Depth 5 | Out-File $RulesFile -Encoding UTF8 +if ($unmatchedPorts.Count -gt 0) { + Write-Host "Warning: The following open ports do not have matching prebuilt rules and will not be included in the auto-generated firewall rules file:" + $unmatchedPorts | ForEach-Object { Write-Host " - Port $_" } +} + +# map of common windows ports to service name +# $portMap = @{ +# 22 = @('SSH', 'TCP') +# 25 = @('SMTP', 'TCP') +# 53 = @('DNS', 'BOTH') +# 80 = @('HTTP', 'TCP') +# 88 = @('Kerberos', 'BOTH') +# 110 = @('POP3', 'TCP') +# 123 = @('NTP', 'TCP') +# 135 = @('RPC', 'TCP') +# 139 = @('NetBIOS', 'TCP') +# 143 = @('IMAP', 'TCP') +# 389 = @('LDAP', 'BOTH') +# 443 = @('HTTPS', 'TCP') +# 445 = @('SMB', 'TCP') +# 465 = @('SMTPS', 'TCP') +# 587 = @('SMTP', 'TCP') +# 636 = @('LDAPS', 'TCP') +# 993 = @('IMAPS', 'TCP') +# 995 = @('POP3S', 'TCP') +# 3268 = @('LDAP GC', 'TCP') +# 3269 = @('LDAPS GC', 'TCP') +# 3389 = @('RDP', 'TCP') +# 5985 = @('WinRM', 'TCP') +# 5986 = @('WinRM SSL', 'TCP') +# 6001 = @('MAPI', 'TCP') +# } diff --git a/embed/windows/harden/firewall_rules_inbound.json b/embed/windows/harden/firewall_rules_inbound.json index 74a11ee..c3e1cae 100644 --- a/embed/windows/harden/firewall_rules_inbound.json +++ b/embed/windows/harden/firewall_rules_inbound.json @@ -207,5 +207,12 @@ "Direction": "Inbound", "Action": "Allow", "Protocol": "ICMPv4" + }, + { + "DisplayName": "NetBIOS Legacy SMB (TCP 139 Inbound)", + "Direction": "Inbound", + "Action": "Allow", + "LocalPort": "139", + "Protocol": "TCP" } ] diff --git a/harden/firewall_rules.go b/harden/firewall_rules.go deleted file mode 100644 index 01c9bdd..0000000 --- a/harden/firewall_rules.go +++ /dev/null @@ -1,61 +0,0 @@ -package harden - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var firewallArgs struct { - outbound bool - inbound bool - apply bool - ruleFile string - oldRuleFile string - backupPath string - removeOld bool - oldRulesIn string -} - -var firewallCmd = &cobra.Command{ - Use: "firewall", - Short: "Setup firewall rules", - Run: func(cmd *cobra.Command, args []string) { - if firewallArgs.apply { - if firewallArgs.inbound == firewallArgs.outbound { - fmt.Println("Error: You must specify either --outbound or --inbound, but not both.") - cmd.Usage() - return - } - - direction := "inbound" - if firewallArgs.outbound { - direction = "outbound" - } - - applyFirewallRules(firewallArgs.ruleFile, firewallArgs.backupPath, firewallArgs.oldRuleFile, direction) - } else if firewallArgs.removeOld { - removeOldFirewallRules(firewallArgs.oldRulesIn) - } else { - generateFirewallRules(firewallArgs.outbound) - } - }, -} - -func setupFirewallCmd(cmd *cobra.Command) { - firewallCmd.Flags().BoolVar(&firewallArgs.outbound, "outbound", false, "Configure/apply outbound rules") - firewallCmd.Flags().BoolVar(&firewallArgs.inbound, "inbound", false, "Configure/apply outbound rules") - firewallCmd.Flags().BoolVar(&firewallArgs.apply, "apply", false, "Apply the rules") - firewallCmd.Flags().StringVarP(&firewallArgs.ruleFile, "file", "f", "", "Path to the rules file") - firewallCmd.Flags().StringVarP(&firewallArgs.oldRuleFile, "out", "o", "old_rules.txt", "Path to output old rules after applying new ones") - firewallCmd.Flags().StringVarP(&firewallArgs.backupPath, "backup", "b", "firewall_backup.wfw", "Path to backup the current rules") - firewallCmd.Flags().BoolVar(&firewallArgs.removeOld, "remove-old", false, "Remove old rules after applying new ones") - firewallCmd.Flags().StringVarP(&firewallArgs.oldRulesIn, "old-rules", "i", "", "Path to the old rules file") - - firewallCmd.MarkFlagsRequiredTogether("file", "apply") - firewallCmd.MarkFlagsRequiredTogether("remove-old", "old-rules") - firewallCmd.MarkFlagsMutuallyExclusive("apply", "remove-old") - firewallCmd.MarkFlagsMutuallyExclusive("outbound", "inbound") - - cmd.AddCommand(firewallCmd) -} diff --git a/harden/firewall_rules_linux.go b/harden/firewall_rules_linux.go index 705a7ac..11d489c 100644 --- a/harden/firewall_rules_linux.go +++ b/harden/firewall_rules_linux.go @@ -1,13 +1,7 @@ package harden -func generateFirewallRules(outbound bool) { - // placeholder -} - -func applyFirewallRules(rulesFile string, backupPath string, oldRulesFile string, direction string) { - // placeholder -} +import "github.com/spf13/cobra" -func removeOldFirewallRules(oldRulesFile string) { - // placeholder +func setupFirewallCmd(cmd *cobra.Command) { + // No firewall rules for Linux yet, so this is a no-op } diff --git a/harden/firewall_rules_windows.go b/harden/firewall_rules_windows.go index 4131e4d..262ef4e 100644 --- a/harden/firewall_rules_windows.go +++ b/harden/firewall_rules_windows.go @@ -11,11 +11,66 @@ import ( "github.com/UT-CTF/landschaft/embed" "github.com/UT-CTF/landschaft/util" "github.com/charmbracelet/huh" + "github.com/spf13/cobra" ) const OutboundRulesPath = "firewall_rules_outbound.json" const InboundRulesPath = "firewall_rules_inbound.json" +var firewallArgs struct { + outbound bool + inbound bool + apply bool + ruleFile string + oldRuleFile string + backupPath string + removeOld bool + oldRulesIn string +} + +var firewallCmd = &cobra.Command{ + Use: "firewall", + Short: "Setup firewall rules", + Run: func(cmd *cobra.Command, args []string) { + if firewallArgs.apply { + if firewallArgs.inbound == firewallArgs.outbound { + fmt.Println("Error: You must specify either --outbound or --inbound, but not both.") + cmd.Usage() + return + } + + direction := "inbound" + if firewallArgs.outbound { + direction = "outbound" + } + + applyFirewallRules(firewallArgs.ruleFile, firewallArgs.backupPath, firewallArgs.oldRuleFile, direction) + } else if firewallArgs.removeOld { + removeOldFirewallRules(firewallArgs.oldRulesIn) + } else { + generateFirewallRules(firewallArgs.outbound) + } + }, +} + +func setupFirewallCmd(cmd *cobra.Command) { + firewallCmd.Flags().BoolVar(&firewallArgs.outbound, "outbound", false, "Configure/apply outbound rules") + firewallCmd.Flags().BoolVar(&firewallArgs.inbound, "inbound", false, "Configure/apply outbound rules") + firewallCmd.Flags().BoolVar(&firewallArgs.apply, "apply", false, "Apply the rules") + firewallCmd.Flags().StringVarP(&firewallArgs.ruleFile, "file", "f", "", "Path to the rules file") + firewallCmd.Flags().StringVarP(&firewallArgs.oldRuleFile, "out", "o", "old_rules.txt", "Path to output old rules after applying new ones") + firewallCmd.Flags().StringVarP(&firewallArgs.backupPath, "backup", "b", "firewall_backup.wfw", "Path to backup the current rules") + firewallCmd.Flags().BoolVar(&firewallArgs.removeOld, "remove-old", false, "Remove old rules after applying new ones") + firewallCmd.Flags().StringVarP(&firewallArgs.oldRulesIn, "old-rules", "i", "", "Path to the old rules file") + + firewallCmd.MarkFlagsRequiredTogether("file", "apply") + firewallCmd.MarkFlagsRequiredTogether("remove-old", "old-rules") + firewallCmd.MarkFlagsMutuallyExclusive("apply", "remove-old") + firewallCmd.MarkFlagsMutuallyExclusive("outbound", "inbound") + + cmd.AddCommand(firewallCmd) +} + func generateFirewallRules(outbound bool) { var rulesPath = InboundRulesPath if outbound { From 49dcb08c530bc22f87b52cb99b5e553074ee393f Mon Sep 17 00:00:00 2001 From: Ameya Purao Date: Tue, 24 Feb 2026 16:58:12 -0600 Subject: [PATCH 4/4] better firewall fs --- embed/windows/harden/apply_firewall.ps1 | 54 +++-- embed/windows/harden/auto_firewall.ps1 | 57 ----- embed/windows/harden/firewall.ps1 | 70 ------ .../harden/firewall_rules_inbound.json | 60 ++--- embed/windows/harden/get_firewall_rules.ps1 | 22 ++ harden/firewall_rules_windows.go | 219 +++++++++--------- 6 files changed, 196 insertions(+), 286 deletions(-) delete mode 100644 embed/windows/harden/auto_firewall.ps1 delete mode 100644 embed/windows/harden/firewall.ps1 create mode 100644 embed/windows/harden/get_firewall_rules.ps1 diff --git a/embed/windows/harden/apply_firewall.ps1 b/embed/windows/harden/apply_firewall.ps1 index b7fc3c8..e080e2c 100644 --- a/embed/windows/harden/apply_firewall.ps1 +++ b/embed/windows/harden/apply_firewall.ps1 @@ -8,38 +8,57 @@ param( $scheduledTaskName = "LSCWFW - Restore Firewall Rules" if ($ClearScheduledTask) { - Unregister-ScheduledTask -TaskName $scheduledTaskName -Confirm:$false -ErrorAction SilentlyContinue - Write-Host "Cleared scheduled task: $scheduledTaskName" + try { + Unregister-ScheduledTask -TaskName $scheduledTaskName -Confirm:$false -ErrorAction Stop + Write-Host "Cleared scheduled task: $scheduledTaskName" + } + catch { + Write-Host "Error clearing scheduled task: $($_.Exception.Message)" + exit 1 + } exit 0 } try { -netsh advfirewall export $BackupFile -# create a scheduled task to restore the firewall rules in 10 minutes -$action = New-ScheduledTaskAction -Execute "netsh" -Argument "advfirewall import $BackupFile" -$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) -$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -Register-ScheduledTask -TaskName $scheduledTaskName -Action $action -Trigger $trigger -Principal $principal -} catch { + if(Test-Path $BackupFile) { + Write-Host "Backup file already exists at $BackupFile. Please remove it before running this script to avoid overwriting the backup." + exit 1 + } + netsh advfirewall export $BackupFile | Out-Null + Write-Host "Successfully backed up current firewall rules to $BackupFile" + + if (Get-ScheduledTask -TaskName $scheduledTaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $scheduledTaskName -Confirm:$false + } + + $action = New-ScheduledTaskAction -Execute "netsh" -Argument "advfirewall import $BackupFile" -ErrorAction Stop + $timeTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(3) -ErrorAction Stop + $rebootTrigger = New-ScheduledTaskTrigger -AtStartup -ErrorAction Stop + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -ErrorAction Stop + Register-ScheduledTask -TaskName $scheduledTaskName -Action $action -Trigger $timeTrigger, $rebootTrigger -Principal $principal -ErrorAction Stop | Out-Null + Write-Host "Scheduled task created to restore firewall rules in 3 minutes or on next reboot: $scheduledTaskName" +} +catch { Write-Host "Error creating scheduled backup task: $($_.Exception.Message)" exit 1 } -$oldRules = Get-NetFirewallRule -Direction $Direction +$oldRules = Get-NetFirewallRule | Where-Object { $_.Direction -eq $Direction } try { - (Get-Content $RulesFile | ConvertFrom-Json -ErrorAction Stop) | % { + (Get-Content $RulesFile | ConvertFrom-Json -ErrorAction Stop) | ForEach-Object { $params = @{} - $_.PSObject.Properties | % { + $_.PSObject.Properties | ForEach-Object { $params[$_.Name] = $_.Value } $params.Enabled = "True" $params.Profile = "Any" $params.ErrorAction = "Stop" Write-Host "Creating rule: $($params.DisplayName)" - New-NetFirewallRule @params + New-NetFirewallRule @params | Out-Null } -} catch { +} +catch { Write-Host "Error applying firewall rules: $($_.Exception.Message)" exit 1 } @@ -47,10 +66,11 @@ try { Write-Host "Successfully added new firewall rules" Write-Host "Removing old firewall rules ..." try { - $oldRules | Remove-NetFirewallRule -} catch { + $oldRules | Remove-NetFirewallRule -ErrorAction Stop +} +catch { Write-Host "Error removing old rules: $($_.Exception.Message)" exit 1 } Write-Host "Successfully removed old firewall rules" -Write-Host "Firewall rules updated successfully. If you need to restore the previous rules, a scheduled task has been created to do so in 5 minutes. You can also manually run 'netsh advfirewall import $BackupFile' to restore the backup immediately." +Write-Host "Firewall rules updated successfully. If you need to restore the previous rules, a scheduled task has been created to do so in 3 minutes. You can also manually run 'netsh advfirewall import $BackupFile' to restore the backup immediately." diff --git a/embed/windows/harden/auto_firewall.ps1 b/embed/windows/harden/auto_firewall.ps1 deleted file mode 100644 index 9647edf..0000000 --- a/embed/windows/harden/auto_firewall.ps1 +++ /dev/null @@ -1,57 +0,0 @@ -param ( - [string]$RulesFile -) - -$portInfo = Get-NetTCPConnection -State Listen | -Where-Object { $_.LocalAddress -notin @('127.0.0.1', '::1') } | -Sort-Object LocalPort | -Select-Object -Unique LocalPort, @{Name = 'ProcessName'; Expression = { (Get-Process -Id $_.OwningProcess).Name } } -$openPorts = $portInfo.LocalPort - -$prebuiltRulesFile = "firewall_rules_inbound.json" - -try { - $loadedRules = Get-Content $prebuiltRulesFile | ConvertFrom-Json -} -catch { - Write-Host "Error loading prebuilt rules: $($_.Exception.Message)" - exit 1 -} - -# Find all prebuilt rules that match the open ports -$matchingRules = $loadedRules | Where-Object { $_.LocalPort -in $openPorts } -$unmatchedPorts = $openPorts | Where-Object { $_ -notin $matchingRules.LocalPort } - -$matchingRules | ConvertTo-Json -Depth 5 | Out-File $RulesFile -Encoding UTF8 -if ($unmatchedPorts.Count -gt 0) { - Write-Host "Warning: The following open ports do not have matching prebuilt rules and will not be included in the auto-generated firewall rules file:" - $unmatchedPorts | ForEach-Object { Write-Host " - Port $_" } -} - -# map of common windows ports to service name -# $portMap = @{ -# 22 = @('SSH', 'TCP') -# 25 = @('SMTP', 'TCP') -# 53 = @('DNS', 'BOTH') -# 80 = @('HTTP', 'TCP') -# 88 = @('Kerberos', 'BOTH') -# 110 = @('POP3', 'TCP') -# 123 = @('NTP', 'TCP') -# 135 = @('RPC', 'TCP') -# 139 = @('NetBIOS', 'TCP') -# 143 = @('IMAP', 'TCP') -# 389 = @('LDAP', 'BOTH') -# 443 = @('HTTPS', 'TCP') -# 445 = @('SMB', 'TCP') -# 465 = @('SMTPS', 'TCP') -# 587 = @('SMTP', 'TCP') -# 636 = @('LDAPS', 'TCP') -# 993 = @('IMAPS', 'TCP') -# 995 = @('POP3S', 'TCP') -# 3268 = @('LDAP GC', 'TCP') -# 3269 = @('LDAPS GC', 'TCP') -# 3389 = @('RDP', 'TCP') -# 5985 = @('WinRM', 'TCP') -# 5986 = @('WinRM SSL', 'TCP') -# 6001 = @('MAPI', 'TCP') -# } diff --git a/embed/windows/harden/firewall.ps1 b/embed/windows/harden/firewall.ps1 deleted file mode 100644 index e99aab5..0000000 --- a/embed/windows/harden/firewall.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -param ( - [switch]$Apply, - [switch]$Prune, - [string]$RulePath, - [string]$BackupPath, - [string]$OldRulesPath, - [string]$Direction -) - -if ($Prune) { - Write-Host "Removing all old inbound rules ..." - Get-Content $OldRulesPath | % { - if ($_.Trim().Length -eq 0) { - return - } - Write-Host "Removing rule with ID: $_" - try { - Remove-NetFirewallRule $_ - } - catch { - Write-Host "Error removing rule: $($_.Exception.Message)" - } - } - exit 0 -} - -if (-not $Apply) { - Get-Content $RulePath | Write-Host - exit 0 -} - -try { - Write-Host "Backing up current rules to $BackupPath ..." - netsh advfirewall export $BackupPath -} -catch { - Write-Host "Error backing up rules: $($_.Exception.Message)" - exit 1 -} - -try { - $OldRules = Get-NetFirewallRule | ? { $_.Direction -eq $Direction } -} -catch { - Write-Host "Error getting current rules: $($_.Exception.Message)" - exit 1 -} - -try { -(Get-Content $RulePath | ConvertFrom-Json -ErrorAction Stop) | % { - $params = @{} - $_.PSObject.Properties | % { - $params[$_.Name] = $_.Value - } - $params.Enabled = "True" - $params.Profile = "Any" - $params.ErrorAction = "Stop" - Write-Host "Creating rule: $($params.DisplayName)" - New-NetFirewallRule @params - } -} -catch { - Write-Host "Error creating rules: $($_.Exception.Message)" - exit 1 -} - -Write-Host "Firewall rules applied successfully." - -Write-Host "Writing old rules IDs to $OldRulesPath" -$OldRules | Select-Object InstanceID | % {$_.InstanceID} | Out-File -FilePath $OldRulesPath diff --git a/embed/windows/harden/firewall_rules_inbound.json b/embed/windows/harden/firewall_rules_inbound.json index c3e1cae..93f5f96 100644 --- a/embed/windows/harden/firewall_rules_inbound.json +++ b/embed/windows/harden/firewall_rules_inbound.json @@ -3,203 +3,203 @@ "DisplayName": "DNS Server (UDP 53 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "53", + "LocalPort": 53, "Protocol": "UDP" }, { "DisplayName": "DNS Server Optional (TCP 53 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "53", + "LocalPort": 53, "Protocol": "TCP" }, { "DisplayName": "DHCP Server (UDP 67 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "67", + "LocalPort": 67, "Protocol": "UDP" }, { "DisplayName": "DHCP Client (UDP 68 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "68", + "LocalPort": 68, "Protocol": "UDP" }, { "DisplayName": "Kerberos (UDP 88 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "88", + "LocalPort": 88, "Protocol": "UDP" }, { "DisplayName": "Kerberos (TCP 88 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "88", + "LocalPort": 88, "Protocol": "TCP" }, { "DisplayName": "LDAP (TCP 389 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "389", + "LocalPort": 389, "Protocol": "TCP" }, { "DisplayName": "LDAP Optional (UDP 389 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "389", + "LocalPort": 389, "Protocol": "UDP" }, { "DisplayName": "LDAP SSL (TCP 636 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "636", + "LocalPort": 636, "Protocol": "TCP" }, { "DisplayName": "LDAP GC (TCP 3268 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3268", + "LocalPort": 3268, "Protocol": "TCP" }, { "DisplayName": "LDAP GC SSL (TCP 3269 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3269", + "LocalPort": 3269, "Protocol": "TCP" }, { "DisplayName": "RPC Endpoint Mapper (TCP 135 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "135", + "LocalPort": 135, "Protocol": "TCP" }, { "DisplayName": "SMB (TCP 445 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "445", + "LocalPort": 445, "Protocol": "TCP" }, { "DisplayName": "HTTP (TCP 80 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "80", + "LocalPort": 80, "Protocol": "TCP" }, { "DisplayName": "HTTPS (TCP 443 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "443", + "LocalPort": 443, "Protocol": "TCP" }, { "DisplayName": "MySQL (TCP 3306 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3306", + "LocalPort": 3306, "Protocol": "TCP" }, { "DisplayName": "OpenSSH (TCP 22 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "22", + "LocalPort": 22, "Protocol": "TCP" }, { "DisplayName": "RDP (TCP 3389 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "3389", + "LocalPort": 3389, "Protocol": "TCP" }, { "DisplayName": "WinRM (TCP 5985 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "5985", + "LocalPort": 5985, "Protocol": "TCP" }, { "DisplayName": "WinRM SSL (TCP 5986 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "5986", + "LocalPort": 5986, "Protocol": "TCP" }, { "DisplayName": "SMTP (TCP 25 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "25", + "LocalPort": 25, "Protocol": "TCP" }, { "DisplayName": "POP3 (TCP 101 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "101", + "LocalPort": 101, "Protocol": "TCP" }, { "DisplayName": "NTP (UDP 123 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "123", + "LocalPort": 123, "Protocol": "UDP" }, { "DisplayName": "IMAP4 (TCP 143 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "143", + "LocalPort": 143, "Protocol": "TCP" }, { "DisplayName": "SMTP SSL (TCP 465 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "465", + "LocalPort": 465, "Protocol": "TCP" }, { "DisplayName": "SMTP (TCP 587 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "587", + "LocalPort": 587, "Protocol": "TCP" }, { "DisplayName": "IMAP4 SSL (TCP 993 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "993", + "LocalPort": 993, "Protocol": "TCP" }, { "DisplayName": "POP3 SSL (TCP 995 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "995", + "LocalPort": 995, "Protocol": "TCP" }, { "DisplayName": "MAPI (TCP 6001 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "6001", + "LocalPort": 6001, "Protocol": "TCP" }, { @@ -212,7 +212,7 @@ "DisplayName": "NetBIOS Legacy SMB (TCP 139 Inbound)", "Direction": "Inbound", "Action": "Allow", - "LocalPort": "139", + "LocalPort": 139, "Protocol": "TCP" } ] diff --git a/embed/windows/harden/get_firewall_rules.ps1 b/embed/windows/harden/get_firewall_rules.ps1 new file mode 100644 index 0000000..d6cbb82 --- /dev/null +++ b/embed/windows/harden/get_firewall_rules.ps1 @@ -0,0 +1,22 @@ +param( + [switch]$Outbound +) + +$inboundRulesFile = "firewall_rules_inbound.json" +$outboundRulesFile = "firewall_rules_outbound.json" + +if ($Outbound) { + $openPorts = @() + $rulesFile = $outboundRulesFile +} +else { + $openPorts = (Get-NetTCPConnection -State Listen | + Where-Object { $_.LocalAddress -notin @('127.0.0.1', '::1') } | + Select-Object -Unique LocalPort).LocalPort + $rulesFile = $inboundRulesFile +} + +(Get-Content $rulesFile | ConvertFrom-Json -ErrorAction Stop) | +Select-Object *, @{Name = "Enabled"; Expression = { $_.LocalPort -in $openPorts } } | +Sort-Object @{Expression = "Enabled"; Desc = $true }, @{Expression = "LocalPort"; Descending = $false } | +ConvertTo-Json -Depth 10 diff --git a/harden/firewall_rules_windows.go b/harden/firewall_rules_windows.go index 262ef4e..a4b398e 100644 --- a/harden/firewall_rules_windows.go +++ b/harden/firewall_rules_windows.go @@ -5,124 +5,138 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "github.com/UT-CTF/landschaft/embed" - "github.com/UT-CTF/landschaft/util" "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) -const OutboundRulesPath = "firewall_rules_outbound.json" -const InboundRulesPath = "firewall_rules_inbound.json" - -var firewallArgs struct { - outbound bool - inbound bool - apply bool - ruleFile string - oldRuleFile string - backupPath string - removeOld bool - oldRulesIn string +var configFirewallArgs struct { + outbound bool + outputPath string + skipPrompt bool } -var firewallCmd = &cobra.Command{ - Use: "firewall", - Short: "Setup firewall rules", - Run: func(cmd *cobra.Command, args []string) { - if firewallArgs.apply { - if firewallArgs.inbound == firewallArgs.outbound { - fmt.Println("Error: You must specify either --outbound or --inbound, but not both.") - cmd.Usage() - return - } - - direction := "inbound" - if firewallArgs.outbound { - direction = "outbound" - } - - applyFirewallRules(firewallArgs.ruleFile, firewallArgs.backupPath, firewallArgs.oldRuleFile, direction) - } else if firewallArgs.removeOld { - removeOldFirewallRules(firewallArgs.oldRulesIn) - } else { - generateFirewallRules(firewallArgs.outbound) - } - }, +var applyFirewallArgs struct { + outbound bool + rulesFile string + backupPath string } func setupFirewallCmd(cmd *cobra.Command) { - firewallCmd.Flags().BoolVar(&firewallArgs.outbound, "outbound", false, "Configure/apply outbound rules") - firewallCmd.Flags().BoolVar(&firewallArgs.inbound, "inbound", false, "Configure/apply outbound rules") - firewallCmd.Flags().BoolVar(&firewallArgs.apply, "apply", false, "Apply the rules") - firewallCmd.Flags().StringVarP(&firewallArgs.ruleFile, "file", "f", "", "Path to the rules file") - firewallCmd.Flags().StringVarP(&firewallArgs.oldRuleFile, "out", "o", "old_rules.txt", "Path to output old rules after applying new ones") - firewallCmd.Flags().StringVarP(&firewallArgs.backupPath, "backup", "b", "firewall_backup.wfw", "Path to backup the current rules") - firewallCmd.Flags().BoolVar(&firewallArgs.removeOld, "remove-old", false, "Remove old rules after applying new ones") - firewallCmd.Flags().StringVarP(&firewallArgs.oldRulesIn, "old-rules", "i", "", "Path to the old rules file") - - firewallCmd.MarkFlagsRequiredTogether("file", "apply") - firewallCmd.MarkFlagsRequiredTogether("remove-old", "old-rules") - firewallCmd.MarkFlagsMutuallyExclusive("apply", "remove-old") - firewallCmd.MarkFlagsMutuallyExclusive("outbound", "inbound") + var firewallCmd = &cobra.Command{ + Use: "firewall", + Short: "Setup firewall rules", + } + + var firewallConfigCmd = &cobra.Command{ + Use: "config", + Short: "Configure firewall rules to apply", + Run: func(cmd *cobra.Command, args []string) { + generateFirewallRules(configFirewallArgs.outbound, configFirewallArgs.outputPath, configFirewallArgs.skipPrompt) + }, + } + + firewallConfigCmd.Flags().BoolVar(&configFirewallArgs.outbound, "outbound", false, "Configure outbound rules instead of inbound") + firewallConfigCmd.Flags().StringVarP(&configFirewallArgs.outputPath, "output", "o", "", "Path to output the generated rules") + firewallConfigCmd.Flags().BoolVar(&configFirewallArgs.skipPrompt, "skip", false, "Skip the confirmation prompt and select all rules") + + firewallConfigCmd.MarkFlagRequired("output") + + firewallCmd.AddCommand(firewallConfigCmd) + + var firewallApplyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply firewall rules from a file, create a backup of existing rules, and create a scheduled task to re-apply old rules in 3 minutes", + Run: func(cmd *cobra.Command, args []string) { + applyFirewallRules(applyFirewallArgs.outbound, applyFirewallArgs.rulesFile, applyFirewallArgs.backupPath) + }, + } + + firewallApplyCmd.Flags().BoolVar(&applyFirewallArgs.outbound, "outbound", false, "Apply outbound rules instead of inbound") + firewallApplyCmd.Flags().StringVarP(&applyFirewallArgs.rulesFile, "rules", "f", "", "Path to the firewall rules file") + firewallApplyCmd.Flags().StringVarP(&applyFirewallArgs.backupPath, "backup", "b", "firewall_backup.wfw", "Path to backup existing firewall rules") + + firewallApplyCmd.MarkFlagRequired("rules") + + firewallCmd.AddCommand(firewallApplyCmd) + + firewallFinalizeCmd := &cobra.Command{ + Use: "finalize", + Short: "Finalize firewall rules application by clearing the scheduled task that restores the previous firewall rules", + Run: func(cmd *cobra.Command, args []string) { + finalizeFirewallRules() + }, + } + + firewallCmd.AddCommand(firewallFinalizeCmd) cmd.AddCommand(firewallCmd) } -func generateFirewallRules(outbound bool) { - var rulesPath = InboundRulesPath +func generateFirewallRules(outbound bool, outputPath string, skipPrompt bool) { + jsonStr := "" + err := error(nil) + if outbound { - rulesPath = OutboundRulesPath + jsonStr, err = embed.ExecuteScript("harden/get_firewall_rules.ps1", false, "-Outbound") + } else { + jsonStr, err = embed.ExecuteScript("harden/get_firewall_rules.ps1", false) } - jsonStr, err := embed.ExecuteScript("harden/firewall.ps1", false, "-RulePath", rulesPath) if err != nil { fmt.Println("Error executing script: ", err) return } - var rules []map[string]string - err = json.Unmarshal([]byte(jsonStr), &rules) + var raw []map[string]interface{} + err = json.Unmarshal([]byte(jsonStr), &raw) if err != nil { fmt.Println("Error parsing JSON: ", err) + fmt.Println("Raw JSON: ", jsonStr) return } - sort.Slice(rules, func(i, j int) bool { - return rules[i]["DisplayName"] < rules[j]["DisplayName"] - }) + var rules []map[string]string + for _, item := range raw { + converted := make(map[string]string) + for k, v := range item { + converted[k] = fmt.Sprintf("%v", v) + } + rules = append(rules, converted) + } + + // any rules with enabled set to true should be pre-selected in the form + // and then remove the enabled field for all rules since it's not needed for the actual application of the rules var selected []int + for i, rule := range rules { + if strings.ToLower(rule["Enabled"]) == "true" { + selected = append(selected, i) + } + delete(rule, "Enabled") + } - ruleSelect := huh.NewMultiSelect[int]().OptionsFunc(func() []huh.Option[int] { - var ruleNames []huh.Option[int] + if !skipPrompt { + var options []huh.Option[int] for i, rule := range rules { - ruleNames = append(ruleNames, huh.NewOption(rule["DisplayName"], i)) + options = append(options, huh.NewOption(rule["DisplayName"], i)) } - return ruleNames - }, - nil, - ).Title("Select Firewall Rules to Enable"). - Value(&selected) - - var outputPath string - defaultPath := rulesPath - pathInput := huh.NewInput().Title("Rules file path").Placeholder(defaultPath).Value(&outputPath) - - fullForm := huh.NewForm( - huh.NewGroup(ruleSelect, pathInput), - ) - err = fullForm.Run() - if err != nil { - fmt.Println("Error running form: ", err) - return - } - outputPath = strings.TrimSpace(outputPath) - if len(outputPath) == 0 { - outputPath = defaultPath + ruleSelect := huh.NewMultiSelect[int](). + Options(options...). + Title("Select Firewall Rules to Enable"). + Value(&selected) + + fullForm := huh.NewForm( + huh.NewGroup(ruleSelect), + ) + err = fullForm.Run() + if err != nil { + fmt.Println("Error running form: ", err) + return + } } var rulesToEnable []map[string]string @@ -151,7 +165,7 @@ func generateFirewallRules(outbound bool) { fmt.Println("Rules written to: ", outputPath) } -func applyFirewallRules(rulesFile string, backupPath string, oldRulesFile string, direction string) { +func applyFirewallRules(outbound bool, rulesFile string, backupPath string) { rulesFile, err := filepath.Abs(rulesFile) if err != nil { fmt.Println("Error getting absolute path: ", err) @@ -164,41 +178,22 @@ func applyFirewallRules(rulesFile string, backupPath string, oldRulesFile string return } - oldRulesFile, err = filepath.Abs(oldRulesFile) - if err != nil { - fmt.Println("Error getting absolute path: ", err) - return + direction := "Inbound" + if outbound { + direction = "Outbound" } - fmt.Println("This will save IDs for all existing rules to a file and apply the selected rules.") - fmt.Println("This will also create a backup of the current rules.") - fmt.Println("The new rules are in the file: ", rulesFile) - fmt.Print("Are you sure you want to continue? (y/n) ") - var confirm string - fmt.Scanln(&confirm) - if confirm != "y" { - fmt.Println("Aborting.") + _, err = embed.ExecuteScript("harden/apply_firewall.ps1", true, "-Direction", direction, "-RulesFile", rulesFile, "-BackupFile", backupPath) + if err != nil { + fmt.Println("Error executing script: ", err) return } - - util.RunAndPrintScript("harden/firewall.ps1", "-RulePath", "'"+rulesFile+"'", "-BackupPath", "'"+backupPath+"'", "-OldRulesPath", "'"+oldRulesFile+"'", "-Direction", "'"+direction+"'", "-Apply") } -func removeOldFirewallRules(oldRulesFile string) { - oldRulesFile, err := filepath.Abs(oldRulesFile) +func finalizeFirewallRules() { + _, err := embed.ExecuteScript("harden/apply_firewall.ps1", true, "-ClearScheduledTask") if err != nil { - fmt.Println("Error getting absolute path: ", err) - return - } - - fmt.Println("This will remove ALL the old rules from the system.") - fmt.Print("Are you sure you want to continue? (y/n) ") - var confirm string - fmt.Scanln(&confirm) - if confirm != "y" { - fmt.Println("Aborting.") + fmt.Println("Error executing script: ", err) return } - - util.RunAndPrintScript("harden/firewall.ps1", "-OldRulesPath", "'"+oldRulesFile+"'", "-Prune") }