Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 131 additions & 1 deletion baseline/cmd_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,143 @@ package baseline

import (
"fmt"
"path/filepath"
"strings"

"github.com/UT-CTF/landschaft/misc"
"github.com/UT-CTF/landschaft/util"
"github.com/spf13/cobra"
)

func SetupCommand(cmd *cobra.Command) {
setupServicesCmd(cmd)
// create and compare parent commands
createCmd := &cobra.Command{
Use: "create",
Short: "Create baselines",
}
compareCmd := &cobra.Command{
Use: "compare",
Short: "Compare baselines",
}

// list of components and their script names
components := map[string]string{
"services": "services.ps1",
"processes": "processes.ps1",
"autoruns": "autoruns.ps1",
Comment thread
AmeyaPurao marked this conversation as resolved.
Outdated
"users": "users.ps1",
"adobjects": "adobjects.ps1",
"ports": "ports.ps1",
}

// create all (positional: output-dir)
createAllCmd := &cobra.Command{
Use: "all <output-dir>",
Comment thread
AmeyaPurao marked this conversation as resolved.
Outdated
Short: "Create all baselines into a directory",
Long: "Create all baselines (services, processes, autoruns, users, adobjects, ports) and save CSV files into the provided output directory.",
Args: cobra.ExactArgs(1),
Example: " landschaft baseline create all C:\\baselines",
Run: func(cmd *cobra.Command, args []string) {
out := args[0]
out, _ = filepath.Abs(out)
// ensure sysinternals
siPath := `C:\\ProgramData\\landschaft\\sysinternals`
_ = misc.EnsureSysinternals(siPath)
for name, script := range components {
fmt.Printf("Running %s baseline...\n", name)
scriptPath := fmt.Sprintf("baseline/%s", script)
// autoruns requires sysinternals path
if name == "autoruns" {
util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath))
} else {
util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out))
}
}
},
}

createCmd.AddCommand(createAllCmd)

// per-component create (positional: output-dir)
for name, script := range components {
n := name
s := script
cmdC := &cobra.Command{
Use: n + " <output-dir>",
Short: fmt.Sprintf("Create %s baseline", n),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
out := args[0]
out, _ = filepath.Abs(out)
siPath := `C:\\ProgramData\\landschaft\\sysinternals`
_ = misc.EnsureSysinternals(siPath)
scriptPath := fmt.Sprintf("baseline/%s", s)
if n == "autoruns" {
util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath))
} else {
util.RunAndRedirectScript(scriptPath, "-BaselinePath", fmt.Sprintf("'%s'", out))
}
},
}

createCmd.AddCommand(cmdC)
}

// compare all
compareAllCmd := &cobra.Command{
Use: "all <dirA> <dirB>",
Short: "Compare two baseline directories",
Long: "Compare two directories produced by 'baseline create all' and report added/removed/changed entries for each component.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
compareCSVDirs(args[0], args[1])
},
}
compareCmd.AddCommand(compareAllCmd)

// per-component compare (positional: fileA fileB)
for name := range components {
n := name
cmdCmp := &cobra.Command{
Use: n + " <fileA> <fileB>",
Short: fmt.Sprintf("Compare %s baselines", n),
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
fileA := args[0]
fileB := args[1]
// for services we have a specialized comparator
if n == "services" {
compareServices(fileA, fileB)
return
}
mA, errA := loadGenericCSV(fileA)
mB, errB := loadGenericCSV(fileB)
if errA != nil || errB != nil {
fmt.Printf("Error loading files: %v %v\n", errA, errB)
return
}
added, removed, changed := diffMaps(mA, mB)
if len(added) > 0 {
fmt.Printf("Added:\n\t%s\n", strings.Join(added, "\n\t"))
}
if len(removed) > 0 {
fmt.Printf("Removed:\n\t%s\n", strings.Join(removed, "\n\t"))
}
if len(changed) > 0 {
fmt.Println("Changed entries:")
for _, c := range changed {
fmt.Printf("\t%s\n", c)
}
}
},
}

compareCmd.AddCommand(cmdCmp)
}

// register with parent
cmd.AddCommand(createCmd)
cmd.AddCommand(compareCmd)
}

func Run(cmd *cobra.Command) {
Expand Down
201 changes: 201 additions & 0 deletions baseline/compare_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package baseline

import (
"encoding/csv"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)

// compareCSVDirs compares two baseline directories. Each directory is expected to
// contain CSV files named services.csv, processes.csv, autoruns.csv, users.csv,
// adobjects.csv, ports.csv. The function will print added/removed/changed items
// per-file using component-specific primary keys and prints full objects for
// additions/removals.
func compareCSVDirs(dirA, dirB string) {
dirA, _ = filepath.Abs(dirA)
dirB, _ = filepath.Abs(dirB)
Comment thread
AmeyaPurao marked this conversation as resolved.
Outdated

files := []string{"services.csv", "processes.csv", "autoruns.csv", "users.csv", "adobjects.csv", "ports.csv"}

for _, f := range files {
pathA := filepath.Join(dirA, f)
pathB := filepath.Join(dirB, f)

keyCols := keyColumnsForFile(f)
mA, errA := loadCSVWithKey(pathA, keyCols)
mB, errB := loadCSVWithKey(pathB, keyCols)

fmt.Println(strings.Repeat("=", 60))
fmt.Printf("Comparing %s\n", f)

if errA != nil {
fmt.Printf("Could not load %s: %v\n", pathA, errA)
}
if errB != nil {
fmt.Printf("Could not load %s: %v\n", pathB, errB)
}
if errA != nil || errB != nil {
continue
}

addedKeys, removedKeys, changed := diffMaps(mA, mB)

if len(addedKeys) > 0 {
sort.Strings(addedKeys)
fmt.Printf("Added in %s:\n", dirB)
for _, k := range addedKeys {
fmt.Printf("\t%s\n", formatObject(k, mB[k]))
}
}
if len(removedKeys) > 0 {
sort.Strings(removedKeys)
fmt.Printf("Removed from %s:\n", dirB)
for _, k := range removedKeys {
fmt.Printf("\t%s\n", formatObject(k, mA[k]))
}
}
if len(changed) > 0 {
fmt.Println("Changed entries:")
for _, c := range changed {
fmt.Printf("\t%s\n", c)
}
}
}
}

// keyColumnsForFile returns the list of CSV column names to be used as the
// primary key for a given file name.
func keyColumnsForFile(file string) []string {
switch file {
case "adobjects.csv":
return []string{"DistinguishedName"}
case "autoruns.csv":
return []string{"Location", "Name", "LaunchString"}
case "ports.csv":
return []string{"LocalAddress", "LocalPort"}
case "processes.csv":
return []string{"Name", "Path"}
case "services.csv":
return []string{"Name"}
case "users.csv":
return []string{"SamAccountName"}
default:
return nil
}
}

// loadCSVWithKey loads a CSV into a map keyed by the composite key defined by
// keyCols. If keyCols is nil or empty, the first column is used as the key.
func loadCSVWithKey(path string, keyCols []string) (map[string]map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()

r := csv.NewReader(f)
recs, err := r.ReadAll()
if err != nil {
return nil, err
}
if len(recs) < 1 {
return nil, fmt.Errorf("empty csv: %s", path)
}
headers := recs[0]
out := make(map[string]map[string]string)

buildKey := func(row []string) string {
if len(keyCols) == 0 {
if len(row) > 0 {
return row[0]
}
return ""
}
vals := make([]string, 0, len(keyCols))
for _, kc := range keyCols {
idx := -1
for i, h := range headers {
if h == kc {
idx = i
break
}
}
if idx >= 0 && idx < len(row) {
vals = append(vals, row[idx])
} else {
vals = append(vals, "")
}
}
return strings.Join(vals, "|")
}

for _, row := range recs[1:] {
if len(row) == 0 {
continue
}
m := make(map[string]string)
for i, cell := range row {
if i < len(headers) {
m[headers[i]] = cell
} else {
m[fmt.Sprintf("col_%d", i)] = cell
}
}
key := buildKey(row)
out[key] = m
}
return out, nil
}

// formatObject returns a single-line representation of the object's fields in
// key:value pairs separated by ", ".
func formatObject(key string, obj map[string]string) string {
if obj == nil {
return key
}
// pretty multi-line output: key on first line, then each field on its own indented line
lines := []string{key}
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
lines = append(lines, fmt.Sprintf("\t\t%s: %s", k, obj[k]))
}
return strings.Join(lines, "\n")
}

// loadGenericCSV remains as a convenience wrapper using first-column key.
func loadGenericCSV(path string) (map[string]map[string]string, error) {
return loadCSVWithKey(path, nil)
}

// diffMaps returns added keys (in b but not a), removed keys (in a but not b), and
// changed descriptions for keys present in both where values differ.
func diffMaps(a, b map[string]map[string]string) (added, removed []string, changed []string) {
for k := range a {
if _, ok := b[k]; !ok {
removed = append(removed, k)
}
}
for k := range b {
if _, ok := a[k]; !ok {
added = append(added, k)
}
}
for k := range a {
if vb, ok := b[k]; ok {
va := a[k]
for hk, hv := range va {
if vb[hk] != hv {
changed = append(changed, fmt.Sprintf("%s: %s changed from '%s' to '%s'", k, hk, hv, vb[hk]))
}
}
}
}
return
}
16 changes: 14 additions & 2 deletions baseline/services_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"

"github.com/UT-CTF/landschaft/misc"
"github.com/UT-CTF/landschaft/util"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -37,7 +38,8 @@ func baselineServices(cmd *cobra.Command, cfg baselineConfig) {
_ = cmd.Usage()
return
}
compareServices(cfg.files[0], cfg.files[1])
// Allow comparing directories which contain CSVs
compareCSVDirs(cfg.files[0], cfg.files[1])
} else {
fmt.Println("Invalid options")
}
Expand All @@ -62,7 +64,17 @@ func createBaseline(csvPath string) {
fmt.Println("Could not get absolute path: ", err)
return
}
util.RunAndPrintScript("baseline/services.ps1", "-ExportPath", fmt.Sprintf("'%s'", csvPath))

// Ensure sysinternals are available at C:\\ProgramData\\landschaft\\sysinternals
siPath := `C:\\ProgramData\\landschaft\\sysinternals`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe install in a sub directory of landshaft, not in programdata if possible

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think its best to leave it as program data so if you move around landschaft you don't keep reinstalling sysinternals

if err := misc.EnsureSysinternals(siPath); err != nil {
fmt.Println("Warning: could not ensure sysinternals: ", err)
// continue; autoruns collection is optional per the PowerShell script
}

// Use the new embedded baseline.ps1 for full baseline collection. Pass the Sysinternals path
// so the script can use autorunsc64 if available.
util.RunAndPrintScript("embed/windows/baseline/baseline.ps1", "-BaselinePath", fmt.Sprintf("'%s'", filepath.Dir(csvPath)), "-SysinternalsPath", fmt.Sprintf("'%s'", siPath))
}

func compareServices(csvPath1 string, csvPath2 string) {
Expand Down
Loading