diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d0cfafa..0110bfd 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,6 +25,10 @@ jobs: with: go-version: ${{ matrix.version }} + - name: Install libpcap-dev (Linux only) + if: matrix.os == 'ubuntu-latest' + run: sudo apt update && sudo apt-get install -y libpcap-dev + - name: Build run: go build -v . diff --git a/baseline/cmd_windows.go b/baseline/cmd_windows.go index 6d44196..6b36734 100644 --- a/baseline/cmd_windows.go +++ b/baseline/cmd_windows.go @@ -7,8 +7,26 @@ 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", + "startup-status": "startup-status.ps1", +} + +var dcScripts = []string{"services", "processes", "autoruns", "ad-users", "ad-objects", "ports", "wmi", "startup-status"} +var localScripts = []string{"services", "processes", "autoruns", "local-users", "ports", "wmi", "startup-status"} + +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/check/check_kerberos.go b/check/check_kerberos.go new file mode 100644 index 0000000..0aa1e61 --- /dev/null +++ b/check/check_kerberos.go @@ -0,0 +1,231 @@ +package check + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/jcmturner/gokrb5/v8/client" + "github.com/jcmturner/gokrb5/v8/config" + "github.com/jcmturner/gokrb5/v8/credentials" + "github.com/spf13/cobra" +) + +var kerberosCheckCmd = &cobra.Command{ + Use: "kerberos", + Short: "Check Kerberos KDC connectivity and authentication", + Long: `Verify Kerberos Key Distribution Center (KDC) is accessible and +credentials are valid. Obtains a Ticket Granting Ticket (TGT) to verify authentication.`, + Run: func(cmd *cobra.Command, args []string) { + kdc, _ := cmd.Flags().GetString("kdc") + realm, _ := cmd.Flags().GetString("realm") + fqdn, _ := cmd.Flags().GetString("fqdn") + username, _ := cmd.Flags().GetString("username") + password, _ := cmd.Flags().GetString("password") + csvFile, _ := cmd.Flags().GetString("csv") + timeout, _ := cmd.Flags().GetInt("timeout") + + // If CSV file is provided, batch check + if csvFile != "" { + results, err := checkKerberosBatch(kdc, realm, fqdn, csvFile, timeout) + if err != nil { + log.Error("Kerberos batch check failed", "error", err) + os.Exit(1) + } + + // Print results + fmt.Printf("\n=== Kerberos Batch Check Results ===\n") + fmt.Printf("Total: %d | Valid: %d | Invalid: %d\n\n", + results.Total, results.Valid, results.Invalid) + + if len(results.ValidCreds) > 0 { + fmt.Println("✓ Valid credentials:") + for _, cred := range results.ValidCreds { + fmt.Printf(" • %s\n", cred) + } + } + + if len(results.InvalidCreds) > 0 { + fmt.Println("\n✗ Invalid credentials:") + for _, cred := range results.InvalidCreds { + fmt.Printf(" • %s\n", cred) + } + } + + if results.Invalid > 0 { + os.Exit(2) + } + os.Exit(0) + } + + // Single credential check + if username == "" || password == "" { + log.Error("Either provide --username and --password, or use --csv for batch checking") + os.Exit(1) + } + + if err := checkKerberos(kdc, realm, fqdn, username, password, timeout); err != nil { + log.Error("Kerberos check failed", "error", err) + os.Exit(2) + } + fmt.Println("✓ Kerberos check passed") + os.Exit(0) + }, +} + +func setupKerberosCheckCmd(cmd *cobra.Command) { + kerberosCheckCmd.Flags().StringP("kdc", "k", "", "KDC server address (IP or hostname)") + kerberosCheckCmd.Flags().StringP("realm", "r", "", "Kerberos realm (e.g., EXAMPLE.COM)") + kerberosCheckCmd.Flags().StringP("fqdn", "f", "", "Fully qualified domain name of the KDC") + kerberosCheckCmd.Flags().StringP("username", "u", "", "Username for authentication (not used with --csv)") + kerberosCheckCmd.Flags().StringP("password", "p", "", "Password for authentication (not used with --csv)") + kerberosCheckCmd.Flags().StringP("csv", "c", "", "CSV file with username,password pairs for batch checking") + kerberosCheckCmd.Flags().IntP("timeout", "t", 10, "Connection timeout in seconds") + + kerberosCheckCmd.MarkFlagRequired("kdc") + kerberosCheckCmd.MarkFlagRequired("realm") + kerberosCheckCmd.MarkFlagRequired("fqdn") + + cmd.AddCommand(kerberosCheckCmd) +} + +func checkKerberos(kdc, realm, fqdn, username, password string, timeoutSec int) error { + fmt.Printf("Connecting to Kerberos KDC at %s (realm: %s)...\n", kdc, realm) + + // Ensure KDC has port specified (default to 88) + kdcAddress := kdc + if !contains(kdcAddress, ":") { + kdcAddress = fmt.Sprintf("%s:88", kdc) + } + + // Create a minimal Kerberos configuration using IP:port to avoid DNS lookups + krb5conf := `[libdefaults] + default_realm = %s + dns_lookup_realm = false + dns_lookup_kdc = false + ticket_lifetime = 24h + forwardable = yes + udp_preference_limit = 1 + +[realms] + %s = { + kdc = %s + admin_server = %s + } + +[domain_realm] + .%s = %s + %s = %s +` + confString := fmt.Sprintf(krb5conf, realm, realm, kdcAddress, kdcAddress, fqdn, realm, fqdn, realm) + + // Parse the configuration + cfg, err := config.NewFromString(confString) + if err != nil { + return fmt.Errorf("failed to create Kerberos config: %w", err) + } + + // Create a client with username and password + cl := client.NewWithPassword(username, realm, password, cfg, client.DisablePAFXFAST(true)) + + // Login to obtain TGT + done := make(chan error, 1) + go func() { + done <- cl.Login() + }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + case <-time.After(time.Duration(timeoutSec) * time.Second): + return fmt.Errorf("connection timeout after %d seconds", timeoutSec) + } + + fmt.Println("✓ Connection established") + fmt.Printf("✓ Authentication successful (obtained TGT for %s@%s)\n", username, realm) + + // Verify we have credentials by checking IsConfigured (returns bool and error) + configured, err := cl.IsConfigured() + if err != nil { + return fmt.Errorf("failed to verify client configuration: %w", err) + } + if !configured { + return fmt.Errorf("client not properly configured after login") + } + + // Try to get the credentials to verify they exist + creds := credentials.New(username, realm) + if creds == nil { + return fmt.Errorf("failed to verify credentials") + } + + fmt.Println("✓ Ticket verification successful") + + // Destroy the session + cl.Destroy() + + return nil +} + +// contains checks if a string contains a substring +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} + +// checkKerberosBatch checks multiple username/password pairs from a CSV file +func checkKerberosBatch(kdc, realm, fqdn, csvFile string, timeoutSec int) (*BatchCheckResult, error) { + // Read CSV file + file, err := os.Open(csvFile) + if err != nil { + return nil, fmt.Errorf("failed to open CSV file: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("failed to read CSV file: %w", err) + } + + if len(records) == 0 { + return nil, fmt.Errorf("CSV file is empty") + } + + results := &BatchCheckResult{ + ValidCreds: []string{}, + InvalidCreds: []string{}, + } + + fmt.Printf("Checking %d credential(s) from %s...\n\n", len(records), csvFile) + + for i, record := range records { + if len(record) < 2 { + fmt.Printf("[%d/%d] ✗ Skipping invalid CSV row (need username,password)\n", i+1, len(records)) + continue + } + + username := record[0] + password := record[1] + results.Total++ + + fmt.Printf("[%d/%d] Testing %s... ", i+1, len(records), username) + + err := checkKerberos(kdc, realm, fqdn, username, password, timeoutSec) + if err != nil { + fmt.Println("✗ INVALID") + results.Invalid++ + results.InvalidCreds = append(results.InvalidCreds, username) + } else { + fmt.Println("✓ VALID") + results.Valid++ + results.ValidCreds = append(results.ValidCreds, username) + } + } + + return results, nil +} diff --git a/check/check_ldap.go b/check/check_ldap.go new file mode 100644 index 0000000..15af8ba --- /dev/null +++ b/check/check_ldap.go @@ -0,0 +1,285 @@ +package check + +import ( + "crypto/tls" + "encoding/csv" + "fmt" + "os" + "time" + + "github.com/charmbracelet/log" + "github.com/go-ldap/ldap/v3" + "github.com/spf13/cobra" +) + +var ldapCheckCmd = &cobra.Command{ + Use: "ldap", + Short: "Check LDAP server connectivity and authentication", + Long: `Verify LDAP server is accessible and credentials are valid. +Performs a simple bind operation and optionally searches the directory.`, + Run: func(cmd *cobra.Command, args []string) { + server, _ := cmd.Flags().GetString("server") + username, _ := cmd.Flags().GetString("username") + password, _ := cmd.Flags().GetString("password") + csvFile, _ := cmd.Flags().GetString("csv") + useTLS, _ := cmd.Flags().GetBool("tls") + timeout, _ := cmd.Flags().GetInt("timeout") + + // If CSV file is provided, batch check + if csvFile != "" { + results, err := checkLDAPBatch(server, csvFile, useTLS, timeout) + if err != nil { + log.Error("LDAP batch check failed", "error", err) + os.Exit(1) + } + + // Print results + fmt.Printf("\n=== LDAP Batch Check Results ===\n") + fmt.Printf("Total: %d | Valid: %d | Invalid: %d\n\n", + results.Total, results.Valid, results.Invalid) + + if len(results.ValidCreds) > 0 { + fmt.Println("✓ Valid credentials:") + for _, cred := range results.ValidCreds { + fmt.Printf(" • %s\n", cred) + } + } + + if len(results.InvalidCreds) > 0 { + fmt.Println("\n✗ Invalid credentials:") + for _, cred := range results.InvalidCreds { + fmt.Printf(" • %s\n", cred) + } + } + + if results.Invalid > 0 { + os.Exit(2) + } + os.Exit(0) + } + + // Single credential check + if username == "" || password == "" { + log.Error("Either provide --username and --password, or use --csv for batch checking") + os.Exit(1) + } + + if err := checkLDAP(server, username, password, useTLS, timeout); err != nil { + log.Error("LDAP check failed", "error", err) + os.Exit(2) + } + fmt.Println("✓ LDAP check passed") + os.Exit(0) + }, +} + +func setupLdapCheckCmd(cmd *cobra.Command) { + ldapCheckCmd.Flags().StringP("server", "s", "", "LDAP server address (IP or hostname)") + ldapCheckCmd.Flags().StringP("username", "u", "", "Username for authentication (not used with --csv)") + ldapCheckCmd.Flags().StringP("password", "p", "", "Password for authentication (not used with --csv)") + ldapCheckCmd.Flags().StringP("csv", "f", "", "CSV file with username,password pairs for batch checking") + ldapCheckCmd.Flags().Bool("tls", false, "Use StartTLS to upgrade connection to TLS") + ldapCheckCmd.Flags().IntP("timeout", "t", 10, "Connection timeout in seconds") + + ldapCheckCmd.MarkFlagRequired("server") + + cmd.AddCommand(ldapCheckCmd) +} + +func checkLDAP(server, username, password string, useTLS bool, timeoutSec int) error { + // Always connect on port 389 initially + port := "389" + address := fmt.Sprintf("%s:%s", server, port) + + if useTLS { + fmt.Printf("Connecting to ldap://%s (will upgrade to TLS via StartTLS)...\n", address) + } else { + fmt.Printf("Connecting to ldap://%s...\n", address) + } + + // Set dial timeout + ldap.DefaultTimeout = time.Duration(timeoutSec) * time.Second + + // Connect to LDAP server + conn, err := ldap.Dial("tcp", address) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + defer conn.Close() + + fmt.Println("✓ Connection established") + + // If TLS is requested, upgrade the connection using StartTLS + if useTLS { + // Configure TLS to skip certificate verification for service checks + // In production environments, proper certificate validation should be used + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + } + + err = conn.StartTLS(tlsConfig) + if err != nil { + return fmt.Errorf("failed to start TLS: %w", err) + } + fmt.Println("✓ TLS negotiation successful") + } + + // First, try to discover the domain name from RootDSE + var domain string + searchRequest := ldap.NewSearchRequest( + "", + ldap.ScopeBaseObject, + ldap.NeverDerefAliases, + 0, + timeoutSec, + false, + "(objectClass=*)", + []string{"defaultNamingContext", "rootDomainNamingContext"}, + nil, + ) + + sr, err := conn.Search(searchRequest) + if err == nil && len(sr.Entries) > 0 { + entry := sr.Entries[0] + if defaultNC := entry.GetAttributeValue("defaultNamingContext"); defaultNC != "" { + // Extract domain from DC components (e.g., DC=office,DC=local -> office.local) + domain = ldapDNToDomain(defaultNC) + } + } + + // Attempt to bind (authenticate) + // Try various formats for Active Directory and standard LDAP + bindDNs := []string{ + username, // Simple username + } + + // If we discovered a domain, try Active Directory formats + if domain != "" { + bindDNs = append([]string{ + fmt.Sprintf("%s@%s", username, domain), // UserPrincipalName format (user@domain.com) + fmt.Sprintf("%s\\%s", domain[:len(domain)-len(".local")], username), // DOMAIN\user format (for .local domains) + }, bindDNs...) + } + + // Add traditional LDAP DN formats + bindDNs = append(bindDNs, + fmt.Sprintf("cn=%s", username), + fmt.Sprintf("uid=%s", username), + ) + + var bindErr error + for _, bindDN := range bindDNs { + bindErr = conn.Bind(bindDN, password) + if bindErr == nil { + fmt.Printf("✓ Authentication successful (bind DN: %s)\n", bindDN) + + // Perform a simple search to verify the connection is fully functional + verifyRequest := ldap.NewSearchRequest( + "", + ldap.ScopeBaseObject, + ldap.NeverDerefAliases, + 0, + timeoutSec, + false, + "(objectClass=*)", + []string{"namingContexts"}, + nil, + ) + + verifyResult, err := conn.Search(verifyRequest) + if err != nil { + return fmt.Errorf("bind succeeded but search failed: %w", err) + } + + if len(verifyResult.Entries) > 0 { + fmt.Println("✓ Directory search successful") + } + return nil + } + } + + return fmt.Errorf("authentication failed: %w", bindErr) +} + +// ldapDNToDomain converts an LDAP DN like "DC=office,DC=local" to "office.local" +func ldapDNToDomain(dn string) string { + parts, err := ldap.ParseDN(dn) + if err != nil || parts == nil { + return "" + } + + var domainParts []string + for _, rdn := range parts.RDNs { + for _, attr := range rdn.Attributes { + if attr.Type == "DC" { + domainParts = append(domainParts, attr.Value) + } + } + } + + if len(domainParts) == 0 { + return "" + } + + domain := "" + for _, part := range domainParts { + if domain != "" { + domain += "." + } + domain += part + } + return domain +} + +// checkLDAPBatch checks multiple username/password pairs from a CSV file +func checkLDAPBatch(server, csvFile string, useTLS bool, timeoutSec int) (*BatchCheckResult, error) { + // Read CSV file + file, err := os.Open(csvFile) + if err != nil { + return nil, fmt.Errorf("failed to open CSV file: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("failed to read CSV file: %w", err) + } + + if len(records) == 0 { + return nil, fmt.Errorf("CSV file is empty") + } + + results := &BatchCheckResult{ + ValidCreds: []string{}, + InvalidCreds: []string{}, + } + + fmt.Printf("Checking %d credential(s) from %s...\n\n", len(records), csvFile) + + for i, record := range records { + if len(record) < 2 { + fmt.Printf("[%d/%d] ✗ Skipping invalid CSV row (need username,password)\n", i+1, len(records)) + continue + } + + username := record[0] + password := record[1] + results.Total++ + + fmt.Printf("[%d/%d] Testing %s... ", i+1, len(records), username) + + err := checkLDAP(server, username, password, useTLS, timeoutSec) + if err != nil { + fmt.Println("✗ INVALID") + results.Invalid++ + results.InvalidCreds = append(results.InvalidCreds, username) + } else { + fmt.Println("✓ VALID") + results.Valid++ + results.ValidCreds = append(results.ValidCreds, username) + } + } + + return results, nil +} diff --git a/check/check_smb.go b/check/check_smb.go new file mode 100644 index 0000000..0738aa8 --- /dev/null +++ b/check/check_smb.go @@ -0,0 +1,140 @@ +package check + +import ( + "fmt" + "net" + "os" + "time" + + "github.com/charmbracelet/log" + "github.com/hirochachacha/go-smb2" + "github.com/spf13/cobra" +) + +var smbCheckCmd = &cobra.Command{ + Use: "smb", + Short: "Check SMB server connectivity and authentication", + Long: `Verify SMB server is accessible and credentials are valid. +Can list shares or access a specific file on a share.`, + Run: func(cmd *cobra.Command, args []string) { + host, _ := cmd.Flags().GetString("host") + username, _ := cmd.Flags().GetString("username") + password, _ := cmd.Flags().GetString("password") + domain, _ := cmd.Flags().GetString("domain") + share, _ := cmd.Flags().GetString("share") + path, _ := cmd.Flags().GetString("path") + timeout, _ := cmd.Flags().GetInt("timeout") + + if err := checkSMB(host, username, password, domain, share, path, timeout); err != nil { + log.Error("SMB check failed", "error", err) + os.Exit(2) + } + fmt.Println("✓ SMB check passed") + os.Exit(0) + }, +} + +func setupSmbCheckCmd(cmd *cobra.Command) { + smbCheckCmd.Flags().StringP("host", "H", "", "SMB server address (IP or hostname)") + smbCheckCmd.Flags().StringP("username", "u", "", "Username for authentication") + smbCheckCmd.Flags().StringP("password", "p", "", "Password for authentication") + smbCheckCmd.Flags().StringP("domain", "d", "", "Domain name (optional, defaults to WORKGROUP)") + smbCheckCmd.Flags().StringP("share", "s", "", "Share name to list (e.g., C$, IPC$)") + smbCheckCmd.Flags().String("path", "", "Path to a file on the share to access (e.g., /Windows/System32/config)") + smbCheckCmd.Flags().IntP("timeout", "t", 10, "Connection timeout in seconds") + + smbCheckCmd.MarkFlagRequired("host") + smbCheckCmd.MarkFlagRequired("username") + smbCheckCmd.MarkFlagRequired("password") + + cmd.AddCommand(smbCheckCmd) +} + +func checkSMB(host, username, password, domain, share, path string, timeoutSec int) error { + // Default domain if not specified + if domain == "" { + domain = "WORKGROUP" + } + + // Connect to SMB server on port 445 + address := fmt.Sprintf("%s:445", host) + fmt.Printf("Connecting to SMB server at %s...\n", address) + + // Set timeout for dial + conn, err := net.DialTimeout("tcp", address, time.Duration(timeoutSec)*time.Second) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + defer conn.Close() + + fmt.Println("✓ Connection established") + + // Create SMB session + d := &smb2.Dialer{ + Initiator: &smb2.NTLMInitiator{ + User: username, + Password: password, + Domain: domain, + }, + } + + session, err := d.Dial(conn) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + defer session.Logoff() + + fmt.Printf("✓ Authentication successful (domain: %s, user: %s)\n", domain, username) + + // If share is specified, try to mount it + if share != "" { + fmt.Printf("Mounting share: %s\n", share) + fs, err := session.Mount(share) + if err != nil { + return fmt.Errorf("failed to mount share: %w", err) + } + defer fs.Umount() + + fmt.Printf("✓ Share mounted successfully: %s\n", share) + + // If path is specified, try to access it + if path != "" { + fmt.Printf("Accessing path: %s\n", path) + stat, err := fs.Stat(path) + if err != nil { + return fmt.Errorf("failed to access path: %w", err) + } + + if stat.IsDir() { + fmt.Printf("✓ Path accessible (directory): %s\n", path) + } else { + fmt.Printf("✓ Path accessible (file, size: %d bytes): %s\n", stat.Size(), path) + } + } else { + // List the share contents (root level) + entries, err := fs.ReadDir(".") + if err != nil { + return fmt.Errorf("failed to list share contents: %w", err) + } + + fmt.Printf("✓ Share listing successful (%d entries in root)\n", len(entries)) + if len(entries) > 0 { + fmt.Println("\nFirst few entries:") + for i, entry := range entries { + if i >= 5 { + break + } + entryType := "file" + if entry.IsDir() { + entryType = "dir " + } + fmt.Printf(" [%s] %s\n", entryType, entry.Name()) + } + } + } + } else { + fmt.Println("✓ Session verification successful (no share specified)") + } + + return nil +} diff --git a/check/check_ssh.go b/check/check_ssh.go new file mode 100644 index 0000000..1c7dbe6 --- /dev/null +++ b/check/check_ssh.go @@ -0,0 +1,126 @@ +package check + +import ( + "fmt" + "os" + "time" + + "github.com/charmbracelet/log" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" +) + +var sshCheckCmd = &cobra.Command{ + Use: "ssh", + Short: "Check SSH server connectivity and authentication", + Long: `Verify SSH server is accessible and credentials are valid. +Optionally executes a command to verify full session functionality.`, + Run: func(cmd *cobra.Command, args []string) { + host, _ := cmd.Flags().GetString("host") + port, _ := cmd.Flags().GetInt("port") + username, _ := cmd.Flags().GetString("username") + password, _ := cmd.Flags().GetString("password") + keyFile, _ := cmd.Flags().GetString("key") + command, _ := cmd.Flags().GetString("command") + timeout, _ := cmd.Flags().GetInt("timeout") + + if err := checkSSH(host, port, username, password, keyFile, command, timeout); err != nil { + log.Error("SSH check failed", "error", err) + os.Exit(2) + } + fmt.Println("✓ SSH check passed") + os.Exit(0) + }, +} + +func setupSshCheckCmd(cmd *cobra.Command) { + sshCheckCmd.Flags().StringP("host", "H", "", "SSH server address (IP or hostname)") + sshCheckCmd.Flags().IntP("port", "P", 22, "SSH server port") + sshCheckCmd.Flags().StringP("username", "u", "", "Username for authentication") + sshCheckCmd.Flags().StringP("password", "p", "", "Password for authentication") + sshCheckCmd.Flags().StringP("key", "k", "", "Path to private key file for authentication") + sshCheckCmd.Flags().StringP("command", "c", "", "Optional command to execute (e.g., 'whoami')") + sshCheckCmd.Flags().IntP("timeout", "t", 10, "Connection timeout in seconds") + + sshCheckCmd.MarkFlagRequired("host") + sshCheckCmd.MarkFlagRequired("username") + + cmd.AddCommand(sshCheckCmd) +} + +func checkSSH(host string, port int, username, password, keyFile, command string, timeoutSec int) error { + address := fmt.Sprintf("%s:%d", host, port) + fmt.Printf("Connecting to SSH server at %s...\n", address) + + // Prepare authentication methods + var authMethods []ssh.AuthMethod + + // Add password authentication if provided + if password != "" { + authMethods = append(authMethods, ssh.Password(password)) + } + + // Add public key authentication if key file is provided + if keyFile != "" { + key, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("failed to read private key file: %w", err) + } + + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return fmt.Errorf("failed to parse private key: %w", err) + } + + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + + if len(authMethods) == 0 { + return fmt.Errorf("no authentication method provided (need --password or --key)") + } + + // Configure SSH client + config := &ssh.ClientConfig{ + User: username, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For checking, we skip host key verification + Timeout: time.Duration(timeoutSec) * time.Second, + } + + // Connect to SSH server + client, err := ssh.Dial("tcp", address, config) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + defer client.Close() + + fmt.Println("✓ Connection established") + fmt.Printf("✓ Authentication successful (user: %s)\n", username) + + // If a command is specified, execute it + if command != "" { + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + defer session.Close() + + fmt.Printf("Executing command: %s\n", command) + output, err := session.CombinedOutput(command) + if err != nil { + return fmt.Errorf("command execution failed: %w", err) + } + + fmt.Printf("✓ Command executed successfully\nOutput:\n%s\n", string(output)) + } else { + // Just verify session creation works + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + session.Close() + fmt.Println("✓ Session verification successful") + } + + return nil +} diff --git a/check/main.go b/check/main.go new file mode 100644 index 0000000..a218015 --- /dev/null +++ b/check/main.go @@ -0,0 +1,30 @@ +package check + +import ( + "fmt" + + "github.com/UT-CTF/landschaft/util" + "github.com/spf13/cobra" +) + +// BatchCheckResult holds results of batch credential checking +type BatchCheckResult struct { + Total int + Valid int + Invalid int + ValidCreds []string + InvalidCreds []string +} + +func SetupCommand(cmd *cobra.Command) { + setupLdapCheckCmd(cmd) + setupKerberosCheckCmd(cmd) + setupSmbCheckCmd(cmd) + setupSshCheckCmd(cmd) +} + +func Run(cmd *cobra.Command) { + fmt.Println(util.ErrorStyle.Render("Error: No subcommand specified")) + fmt.Println() + _ = cmd.Usage() +} diff --git a/cmd/check.go b/cmd/check.go new file mode 100644 index 0000000..f0056dc --- /dev/null +++ b/cmd/check.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/UT-CTF/landschaft/check" + "github.com/spf13/cobra" +) + +// checkCmd represents the check command +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Perform service checks to verify connectivity and authentication", + Long: `Check various services (LDAP, Kerberos, SMB) to verify they are up and +functioning as intended. Each subcommand tests authentication and optionally +performs additional operations to validate service health.`, + Run: func(cmd *cobra.Command, args []string) { + check.Run(cmd) + }, +} + +func init() { + check.SetupCommand(checkCmd) + rootCmd.AddCommand(checkCmd) +} diff --git a/embed/linux/misc/install-libpcap.sh b/embed/linux/misc/install-libpcap.sh new file mode 100644 index 0000000..5937101 --- /dev/null +++ b/embed/linux/misc/install-libpcap.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -e + +echo "[*] Detecting distribution..." + +if [ -f /etc/debian_version ]; then + echo "[*] Debian/Ubuntu detected" + apt update + apt install -y libpcap0.8 libpcap-dev + +elif [ -f /etc/redhat-release ]; then + echo "[*] RHEL/CentOS/Fedora detected" + yum install -y libpcap libpcap-devel || dnf install -y libpcap libpcap-devel + +elif [ -f /etc/arch-release ]; then + echo "[*] Arch Linux detected" + pacman -Sy --noconfirm libpcap + +else + echo "[!] Unknown distro. Please install libpcap manually." + exit 1 +fi + +echo "[*] Verifying installation..." +ldconfig -p | grep libpcap || echo "[!] libpcap not found in ldconfig cache" + +echo "[+] libpcap installation complete." +echo "[!] Reminder: Packet capture requires root or CAP_NET_RAW capability." 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/startup-status.ps1 b/embed/windows/baseline/startup-status.ps1 new file mode 100644 index 0000000..d1ba0a2 --- /dev/null +++ b/embed/windows/baseline/startup-status.ps1 @@ -0,0 +1,117 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BaselinePath +) + +$startupSubkeys = @( + "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run", + "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32", + "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder" +) + +$results = @() + +function Get-StartupApproved { + param( + [string]$Root, + [string]$UserSid, + [string]$Username = $null + ) + + # Write-Host "Parsing StartupApproved entries under $Root for user $UserSid..." -ForegroundColor Cyan + if (-not $Username) { + try { + $Username = (New-Object System.Security.Principal.SecurityIdentifier($UserSid)). + Translate([System.Security.Principal.NTAccount]).Value + } + catch { + $Username = "Unknown" + } + } + # Write-Host "Username: $Username" -ForegroundColor Green + + foreach ($sub in $startupSubkeys) { + + $path = "$Root\$sub" + + # Write-Host "Checking $path..." -ForegroundColor Yellow + + if (!(Test-Path $path)) { + # Write-Host "Path not found: $path" -ForegroundColor Red + continue + } + + $props = Get-ItemProperty -Path $path + + foreach ($prop in $props.PSObject.Properties | Where-Object {$_.MemberType -eq "NoteProperty"}) { + + $name = $prop.Name + $data = $prop.Value + + if ($data -is [byte[]] -and $data.Length -gt 0) { + + $statusByte = $data[0] + + if ($statusByte % 2 -eq 0) { + $status = "Enabled" + } + else { + $status = "Disabled" + } + + $timestamp = $null + if ($data.Length -ge 12) { + try { + $filetime = [BitConverter]::ToInt64($data,4) + $timestamp = [DateTime]::FromFileTimeUtc($filetime) + } + catch {} + } + + $script:results += [PSCustomObject]@{ + UserSID = $UserSid + Username = $Username + Name = $name + Location = $path + StatusByte = ('0x{0:X2}' -f $statusByte) + Status = $status + LastChange = $timestamp + } + } + } + } +} + +# --- Enumerate all user profiles --- +$profiles = Get-ItemProperty "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\ProfileList\*" + +foreach ($prof in $profiles) { + + $sid = $prof.PSChildName + $profilePath = $prof.ProfileImagePath + $ntuser = Join-Path $profilePath "NTUSER.DAT" + + if (!(Test-Path $ntuser)) { continue } + + $hiveRoot = "Registry::HKEY_USERS\$sid" + $hiveLoaded = Test-Path $hiveRoot + $tempLoaded = $false + + if (!$hiveLoaded) { + reg load "HKU\$sid" $ntuser | Out-Null + $tempLoaded = $true + } + + Get-StartupApproved -Root $hiveRoot -UserSid $sid + + if ($tempLoaded) { + reg unload "HKU\$sid" | Out-Null + } +} + +# --- Machine-wide StartupApproved --- +$machineRoot = "Registry::HKEY_LOCAL_MACHINE" + +Get-StartupApproved -Root $machineRoot -UserSid "SYSTEM" -Username "SYSTEM" + +$results | Sort-Object Username, Name | Select-Object Username, Name, StatusByte, Status | Export-Csv "$BaselinePath\startup-status.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/embed/windows/harden/apply_firewall.ps1 b/embed/windows/harden/apply_firewall.ps1 new file mode 100644 index 0000000..e080e2c --- /dev/null +++ b/embed/windows/harden/apply_firewall.ps1 @@ -0,0 +1,76 @@ +param( + [switch]$ClearScheduledTask, + [string]$RulesFile, + [string]$BackupFile, + [string]$Direction +) + +$scheduledTaskName = "LSCWFW - Restore Firewall Rules" + +if ($ClearScheduledTask) { + 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 { + 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 | Where-Object { $_.Direction -eq $Direction } + +try { + (Get-Content $RulesFile | ConvertFrom-Json -ErrorAction Stop) | ForEach-Object { + $params = @{} + $_.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 | Out-Null + } +} +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 -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 3 minutes. You can also manually run 'netsh advfirewall import $BackupFile' to restore the backup immediately." 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 74a11ee..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" }, { @@ -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/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/embed/windows/misc/install-npcap.ps1 b/embed/windows/misc/install-npcap.ps1 new file mode 100644 index 0000000..8b6d899 --- /dev/null +++ b/embed/windows/misc/install-npcap.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = "Stop" + +Write-Host "[*] Checking for Npcap installation..." +try { + Get-Service -Name "npcap" -ErrorAction Stop | Out-Null + Write-Host "[+] Npcap is already installed." + exit 0 +} catch { + Write-Host "[*] Npcap is not installed." +} + +Write-Host "[*] Downloading Npcap..." + +$npcapUrl = "https://npcap.com/dist/npcap-1.79.exe" +$installerPath = "$env:TEMP\npcap-installer.exe" + +Invoke-WebRequest -Uri $npcapUrl -OutFile $installerPath + +Write-Host "[*] Installing Npcap (requires GUI)..." + +Start-Process -FilePath $installerPath ` + -ArgumentList "/winpcap_mode=yes", "/loopback_support=yes" ` + -Wait -Verb RunAs + +Write-Host "[+] Npcap installation complete." diff --git a/go.mod b/go.mod index 772cc27..fc051d5 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/UT-CTF/landschaft -go 1.23.4 +go 1.26 require ( github.com/Masterminds/sprig/v3 v3.3.0 @@ -14,10 +14,16 @@ require ( require ( github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/x/term v0.2.1 + github.com/go-ldap/ldap/v3 v3.4.12 + github.com/google/gopacket v1.1.19 + github.com/hirochachacha/go-smb2 v1.1.0 + github.com/jcmturner/gokrb5/v8 v8.4.4 + golang.org/x/crypto v0.48.0 ) require ( dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect @@ -31,10 +37,17 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/geoffgarside/ber v1.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -50,9 +63,9 @@ require ( github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index f2cc772..292382d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -8,6 +10,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+ github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -41,6 +45,7 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -49,16 +54,43 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= +github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= +github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150 h1:ta6N7DaOQEACq28cLa0iRqXIbchByN9Lfll08CT2GBc= github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150/go.mod h1:SiNTKDgjKQORnazFVHXhpny7UtU0iJOqtxd7R7sCfDI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -101,36 +133,81 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220921155015-db77216a4ee9/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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..a4b398e 100644 --- a/harden/firewall_rules_windows.go +++ b/harden/firewall_rules_windows.go @@ -5,69 +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 configFirewallArgs struct { + outbound bool + outputPath string + skipPrompt bool +} + +var applyFirewallArgs struct { + outbound bool + rulesFile string + backupPath string +} + +func setupFirewallCmd(cmd *cobra.Command) { + 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, outputPath string, skipPrompt bool) { + jsonStr := "" + err := error(nil) -func generateFirewallRules(outbound bool) { - var rulesPath = InboundRulesPath 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 @@ -96,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) @@ -109,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") } diff --git a/install-go-and-build.sh b/install-go-and-build.sh index 3a2ba54..0a6729a 100755 --- a/install-go-and-build.sh +++ b/install-go-and-build.sh @@ -12,6 +12,8 @@ else export PATH=$PATH:$HOME/go/bin fi +sudo apt update && sudo apt install -y libpcap-dev build-essential + # Run build.sh echo "Running build script..." ./build.sh diff --git a/misc/misc_windows.go b/misc/misc.go similarity index 91% rename from misc/misc_windows.go rename to misc/misc.go index acb399c..a79d1d7 100644 --- a/misc/misc_windows.go +++ b/misc/misc.go @@ -9,6 +9,7 @@ import ( func SetupCommand(cmd *cobra.Command) { setupToolsCommand(cmd) setupExtractCommand(cmd) + setupNetflowCommand(cmd) } func Run(cmd *cobra.Command) { diff --git a/misc/misc_linux.go b/misc/misc_linux.go deleted file mode 100644 index acb399c..0000000 --- a/misc/misc_linux.go +++ /dev/null @@ -1,18 +0,0 @@ -package misc - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -func SetupCommand(cmd *cobra.Command) { - setupToolsCommand(cmd) - setupExtractCommand(cmd) -} - -func Run(cmd *cobra.Command) { - fmt.Println("Error: No subcommand specified") - fmt.Println() - _ = cmd.Usage() -} diff --git a/misc/netflow.go b/misc/netflow.go new file mode 100644 index 0000000..c509d88 --- /dev/null +++ b/misc/netflow.go @@ -0,0 +1,400 @@ +package misc + +import ( + "bufio" + "fmt" + "log" + "net" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/google/gopacket/pcap" + "github.com/spf13/cobra" +) + +type FlowKey struct { + SrcIP string + SrcPort int + DstIP string + DstPort int + Proto string +} + +type FilteredFlowKey struct { + IP string + Port int + Proto string +} + +type FlowStat struct { + Packets int + Bytes int +} + +type PacketSummary struct { + SrcIP string + DstIP string + SrcPort int + DstPort int + Proto string + Length int +} + +var knownPorts = map[int]string{ + 21: "FTP", + 22: "SSH", + 25: "SMTP", + 53: "DNS", + 80: "HTTP", + 88: "Kerberos", + 110: "POP3", + 123: "NTP", + 135: "RPC", + 139: "NetBIOS", + 143: "IMAP", + 161: "SNMP", + 389: "LDAP", + 443: "HTTPS", + 445: "SMB", + 465: "SMTPS", + 587: "SMTP", + 636: "LDAPS", + 993: "IMAPS", + 995: "POP3S", + 1433: "MS SQL", + 3268: "LDAP GC", + 3269: "LDAPS GC", + 3306: "SQL", + 3389: "RDP", + 5900: "VNC", + 5985: "WinRM", + 5986: "WinRM SSL", + 6001: "MAPI", + 8000: "HTTP Alt", + 8008: "HTTP Alt", + 8080: "HTTP Alt", + 8081: "HTTP Alt", + 8443: "HTTPS Alt", + 8888: "HTTP Alt", + 9389: "AD WS", +} + +var netflowFlags struct { + duration int + subnetStr string + ifaceName string + forceBackup bool +} + +func setupNetflowCommand(cmd *cobra.Command) { + netflowCmd := &cobra.Command{ + Use: "netflow", + Short: "Capture and analyze network flows", + Run: func(cmd *cobra.Command, args []string) { + analyzeNetflow(netflowFlags.duration, netflowFlags.subnetStr, netflowFlags.ifaceName, netflowFlags.forceBackup) + }, + } + + netflowCmd.Flags().IntVarP(&netflowFlags.duration, "duration", "d", 60, "Capture duration in seconds") + netflowCmd.Flags().StringVarP(&netflowFlags.subnetStr, "subnet", "s", "0.0.0.0/0", "Subnet(s) in CIDR format (comma-separated, e.g., 192.168.1.0/24,10.0.0.0/8)") + netflowCmd.Flags().StringVarP(&netflowFlags.ifaceName, "iface", "i", "", "Interface to capture on (optional)") + netflowCmd.Flags().BoolVarP(&netflowFlags.forceBackup, "backup", "b", false, "Force use of backup capture method (only on Windows)") + + cmd.AddCommand(netflowCmd) +} + +func getLocalIPs() map[string]bool { + localIPs := make(map[string]bool) + + ifaces, _ := net.Interfaces() + for _, iface := range ifaces { + addrs, _ := iface.Addrs() + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err == nil { + localIPs[ip.String()] = true + } + } + } + return localIPs +} + +func selectInterface(provided string) string { + if provided != "" { + return provided + } + + devices, err := pcap.FindAllDevs() + if err != nil { + log.Fatal(err) + } + + if len(devices) == 0 { + log.Fatal("No network devices found") + } + + fmt.Println("Available interfaces:") + for i, dev := range devices { + desc := dev.Description + if desc == "" { + desc = "(no description)" + } + fmt.Printf("[%d] %s — %s\n", i+1, dev.Name, desc) + } + + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("Enter interface number: ") + line, err := reader.ReadString('\n') + if err != nil { + log.Fatalf("Failed to read input: %v", err) + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + n, err := strconv.Atoi(line) + if err != nil || n < 1 || n > len(devices) { + fmt.Println("Invalid selection, try again.") + continue + } + return devices[n-1].Name + } +} + +func parseSubnets(s string) ([]*net.IPNet, error) { + if strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("empty subnet list") + } + parts := strings.Split(s, ",") + var out []*net.IPNet + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + _, ipnet, err := net.ParseCIDR(p) + if err != nil { + return nil, fmt.Errorf("invalid CIDR '%s': %v", p, err) + } + out = append(out, ipnet) + } + if len(out) == 0 { + return nil, fmt.Errorf("no valid CIDRs provided") + } + return out, nil +} + +func containsAny(nets []*net.IPNet, ip net.IP) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + +func defaultPacketCapture(ifaceName string, duration int) ([]PacketSummary, error) { + iface := selectInterface(ifaceName) + fmt.Printf("Starting packet capturing on interface %s...\n", iface) + h, err := pcap.OpenLive(iface, 65535, true, pcap.BlockForever) + if err != nil { + return nil, err + } + defer h.Close() + if err := h.SetBPFFilter("tcp or udp"); err != nil { + return nil, err + } + + packetSource := gopacket.NewPacketSource(h, h.LinkType()) + out := make([]PacketSummary, 0) + timeout := time.After(time.Duration(duration) * time.Second) + + for { + select { + case packet := <-packetSource.Packets(): + if packet == nil { + continue + } + ipLayer := packet.Layer(layers.LayerTypeIPv4) + if ipLayer == nil { + continue + } + ip := ipLayer.(*layers.IPv4) + + var srcPort, dstPort int + var proto string + if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil { + tcp := tcpLayer.(*layers.TCP) + srcPort = int(tcp.SrcPort) + dstPort = int(tcp.DstPort) + proto = "TCP" + } else if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil { + udp := udpLayer.(*layers.UDP) + srcPort = int(udp.SrcPort) + dstPort = int(udp.DstPort) + proto = "UDP" + } else { + continue + } + + out = append(out, PacketSummary{ + SrcIP: ip.SrcIP.String(), + DstIP: ip.DstIP.String(), + SrcPort: srcPort, + DstPort: dstPort, + Proto: proto, + Length: len(packet.Data()), + }) + + case <-timeout: + return out, nil + } + } +} + +func filterAndAggregate(packets []PacketSummary, subnets []*net.IPNet, localIPs map[string]bool) (map[FlowKey]*FlowStat, map[FlowKey]*FlowStat) { + inbound := make(map[FlowKey]*FlowStat) + outbound := make(map[FlowKey]*FlowStat) + + for _, p := range packets { + src := net.ParseIP(p.SrcIP) + dst := net.ParseIP(p.DstIP) + if src == nil || dst == nil { + continue + } + + if !((containsAny(subnets, src) || localIPs[src.String()]) && (containsAny(subnets, dst) || localIPs[dst.String()])) { + continue + } + + key := FlowKey{ + SrcIP: p.SrcIP, + SrcPort: p.SrcPort, + DstIP: p.DstIP, + DstPort: p.DstPort, + Proto: p.Proto, + } + + if localIPs[dst.String()] { + if inbound[key] == nil { + inbound[key] = &FlowStat{} + } + inbound[key].Packets++ + inbound[key].Bytes += p.Length + } else if localIPs[src.String()] { + if outbound[key] == nil { + outbound[key] = &FlowStat{} + } + outbound[key].Packets++ + outbound[key].Bytes += p.Length + } + } + + return inbound, outbound +} + +func analyzeNetflow(duration int, subnetStr string, ifaceName string, forceBackup bool) { + if subnetStr == "" { + fmt.Println("Usage: netflow -duration 60 -subnet 192.168.1.0/24[,10.0.0.0/8] [-iface eth0]") + os.Exit(1) + } + + subnets, err := parseSubnets(subnetStr) + if err != nil { + log.Fatal("Invalid subnet(s):", err) + } + + localIPs := getLocalIPs() + + var packets []PacketSummary + + if !forceBackup && verifyPacketCapture() { + fmt.Println("Default packet capture available") + packets, err = defaultPacketCapture(ifaceName, duration) + if err != nil { + log.Fatalf("Default packet capture failed: %v", err) + } + } else { + fmt.Println("Default packet capture not available, using backup method") + packets, err = backupPacketCapture(duration) + if err != nil { + log.Fatalf("Backup packet capture failed: %v", err) + } + } + + inbound, outbound := filterAndAggregate(packets, subnets, localIPs) + + printTable("Inbound Flows", inbound) + printTable("Outbound Flows", outbound) + printFiltered("Filtered Inbound (known dest ports)", inbound, true) + printFiltered("Filtered Outbound (known dest ports)", outbound, false) +} + +func printTable(title string, flows map[FlowKey]*FlowStat) { + fmt.Println("\n====", title, "====") + + type row struct { + Key FlowKey + Stat *FlowStat + } + + var rows []row + for k, v := range flows { + rows = append(rows, row{k, v}) + } + + sort.Slice(rows, func(i, j int) bool { + return rows[i].Stat.Bytes > rows[j].Stat.Bytes + }) + + fmt.Printf("%-15s %-6s %-15s %-6s %-5s %-8s %-8s\n", + "SRC IP", "SPORT", "DST IP", "DPORT", "PROTO", "PACKETS", "BYTES") + + for _, r := range rows { + fmt.Printf("%-15s %-6d %-15s %-6d %-5s %-8d %-8d\n", + r.Key.SrcIP, + r.Key.SrcPort, + r.Key.DstIP, + r.Key.DstPort, + r.Key.Proto, + r.Stat.Packets, + r.Stat.Bytes, + ) + } +} + +func printFiltered(title string, flows map[FlowKey]*FlowStat, inbound bool) { + filteredFlows := make(map[FilteredFlowKey]*FlowStat) + for k, v := range flows { + var ip string + if inbound { + ip = k.SrcIP + } else { + ip = k.DstIP + } + if _, ok := knownPorts[k.DstPort]; ok { + fk := FilteredFlowKey{ + IP: ip, + Port: k.DstPort, + Proto: k.Proto, + } + if filteredFlows[fk] == nil { + filteredFlows[fk] = &FlowStat{} + } + filteredFlows[fk].Packets += v.Packets + filteredFlows[fk].Bytes += v.Bytes + } + } + fmt.Println("\n====", title, "====") + fmt.Printf("%-15s %-6s %-20s %-8s %-8s\n", "IP", "PORT", "SERVICE", "PACKETS", "BYTES") + for k, v := range filteredFlows { + svc := knownPorts[k.Port] + fmt.Printf("%-15s %-6d %-20s %-8d %-8d\n", k.IP, k.Port, svc, v.Packets, v.Bytes) + } +} diff --git a/misc/packet_capture_linux.go b/misc/packet_capture_linux.go new file mode 100644 index 0000000..0f3af77 --- /dev/null +++ b/misc/packet_capture_linux.go @@ -0,0 +1,16 @@ +package misc + +import ( + "fmt" + + "github.com/google/gopacket/pcap" +) + +func verifyPacketCapture() bool { + devices, err := pcap.FindAllDevs() + return err == nil && len(devices) > 0 +} + +func backupPacketCapture(duration int) ([]PacketSummary, error) { + return nil, fmt.Errorf("pktmon is only available on Windows") +} diff --git a/misc/packet_capture_windows.go b/misc/packet_capture_windows.go new file mode 100644 index 0000000..cd85d3e --- /dev/null +++ b/misc/packet_capture_windows.go @@ -0,0 +1,102 @@ +package misc + +import ( + "fmt" + "io" + "os" + "os/exec" + "time" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/google/gopacket/pcap" + "github.com/google/gopacket/pcapgo" +) + +func verifyPacketCapture() bool { + devices, err := pcap.FindAllDevs() + return err == nil && len(devices) > 0 +} + +func backupPacketCapture(duration int) ([]PacketSummary, error) { + fmt.Println("Starting pktmon capture...") + + etl := "PktMon.etl" + cmdStart := exec.Command("pktmon", "start", "--capture", "--pkt-size", "0", "--file-size", "4096", "--file-name", etl) + cmdStart.Stdout = io.Discard + cmdStart.Stderr = io.Discard + if err := cmdStart.Run(); err != nil { + return nil, fmt.Errorf("Failed to start pktmon capture: %v", err) + } + + time.Sleep(time.Duration(duration) * time.Second) + + cmdStop := exec.Command("pktmon", "stop") + cmdStop.Stdout = io.Discard + cmdStop.Stderr = io.Discard + if err := cmdStop.Run(); err != nil { + return nil, fmt.Errorf("Failed to stop pktmon capture: %v", err) + } + + pcapOut := "pktmon.pcap" + cmdFmt := exec.Command("pktmon", "etl2pcap", etl, "-o", pcapOut) + cmdFmt.Stdout = io.Discard + cmdFmt.Stderr = io.Discard + if err := cmdFmt.Run(); err != nil { + return nil, fmt.Errorf("Failed to format pktmon ETL to pcap: %v", err) + } + defer os.Remove(etl) + defer os.Remove(pcapOut) + + f, err := os.Open(pcapOut) + if err != nil { + return nil, fmt.Errorf("Failed to open pktmon pcap file: %v", err) + } + defer f.Close() + + reader, err := pcapgo.NewNgReader(f, pcapgo.DefaultNgReaderOptions) + if err != nil { + return nil, fmt.Errorf("Failed to create pcapng reader: %v", err) + } + ps := gopacket.NewPacketSource(reader, reader.LinkType()) + + out := make([]PacketSummary, 0) + + for packet := range ps.Packets() { + if packet == nil { + continue + } + ipLayer := packet.Layer(layers.LayerTypeIPv4) + if ipLayer == nil { + continue + } + ip := ipLayer.(*layers.IPv4) + + var srcPort, dstPort int + var proto string + if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil { + tcp := tcpLayer.(*layers.TCP) + srcPort = int(tcp.SrcPort) + dstPort = int(tcp.DstPort) + proto = "TCP" + } else if udpLayer := packet.Layer(layers.LayerTypeUDP); udpLayer != nil { + udp := udpLayer.(*layers.UDP) + srcPort = int(udp.SrcPort) + dstPort = int(udp.DstPort) + proto = "UDP" + } else { + continue + } + + out = append(out, PacketSummary{ + SrcIP: ip.SrcIP.String(), + DstIP: ip.DstIP.String(), + SrcPort: srcPort, + DstPort: dstPort, + Proto: proto, + Length: len(packet.Data()), + }) + } + + return out, nil +} 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") // }