Files
andreicscs-HoneyWire/wizard/internal/commands/discover.go
T

486 lines
15 KiB
Go

package commands
import (
"context"
"fmt"
// codeql[go/insecure-randomness] Non-cryptographic use case.
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
"math/rand"
"net"
"os"
"path/filepath"
"strings"
// codeql[go/xss] CLI tool generating local text files, not HTML.
// nosemgrep: go.lang.security.audit.xss.import-text-template.import-text-template
"text/template"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/honeywire/wizard/core/discovery"
"github.com/honeywire/wizard/core/scanner"
"github.com/honeywire/wizard/core/schema"
"github.com/honeywire/wizard/internal/app"
"github.com/honeywire/wizard/internal/cli"
"github.com/honeywire/wizard/internal/deploy"
"github.com/honeywire/wizard/internal/system"
)
var hubInjectedVars = map[string]bool{
"HW_HUB_ENDPOINT": true,
"HW_HUB_KEY": true,
"HW_SENSOR_ID": true,
"HW_CONFIG_REV": true,
"HW_TEST_MODE": true,
}
func HandleDiscover(force bool) error {
app, err := loadApp()
if err != nil {
return err
}
cli.PrintSectionHeader("HoneyWire Discover", cli.Cyan)
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
installed, err := app.Hub.FetchInstalledSensors(ctx, app.Config.NodeID, app.Config.APIKey)
cancel() // Release context immediately after network call
if err != nil {
fmt.Printf("%s[!] Warning: Could not fetch installed sensors from Hub: %v%s\n", cli.Yellow, err, cli.Reset)
fmt.Printf("%s Continuing without duplicate detection.%s\n\n", cli.Dim, cli.Reset)
installed = make(map[string]bool)
}
if err := runPreFlightChecks(force); err != nil {
return err
}
hostState, dockerMap, systemState, err := auditEnvironment()
if err != nil {
return err
}
recommendations, err := buildStrategy(app, hostState, systemState, dockerMap, app.Random())
if err != nil {
return err
}
recommendations = filterInstalledSensors(recommendations, installed)
if len(recommendations) == 0 {
fmt.Printf(" %sNo new recommendations. All applicable sensors are already deployed.%s\n\n", cli.Dim, cli.Reset)
return nil
}
cli.PrintDeploymentPlan(recommendations)
cli.PrintExpectedImpact(recommendations)
promptLoop:
for {
if len(recommendations) == 0 {
fmt.Printf(" %sNo recommendations left to apply.%s\n\n", cli.Dim, cli.Reset)
return nil
}
var choice string
if force {
fmt.Printf(" Apply all %s%d%s sensor suggestions? (or 'e' Edit) [y/N/e]: y (forced)\n", cli.Green, len(recommendations), cli.Reset)
choice = "y"
} else {
var err error
choice, err = cli.PromptInput(fmt.Sprintf(" Apply all %s%d%s sensor suggestions? (or 'e' Edit) [y/N/e]: ", cli.Green, len(recommendations), cli.Reset))
if err != nil {
return err
}
}
choice = strings.ToLower(strings.TrimSpace(choice))
switch choice {
case "y", "yes":
break promptLoop
case "e", "edit":
editLoop:
for {
fmt.Printf("\n %sCurrent Suggestions:%s\n", cli.Cyan, cli.Reset)
for i, rec := range recommendations {
fmt.Printf(" [%d] %s (%s)\n", i+1, rec.SensorName, rec.SensorID)
}
fmt.Printf("\n Type numbers to remove (e.g. '1,3'), 'i <num>' to inspect, or 'c' to continue.\n")
editChoice, _ := cli.PromptInput(" Edit: ")
editChoice = strings.ToLower(strings.TrimSpace(editChoice))
if editChoice == "c" || editChoice == "continue" || editChoice == "" {
cli.PrintDeploymentPlan(recommendations)
cli.PrintExpectedImpact(recommendations)
break editLoop
}
if strings.HasPrefix(editChoice, "i ") {
idxStr := strings.TrimSpace(strings.TrimPrefix(editChoice, "i "))
var idx int
if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 1 && idx <= len(recommendations) {
rec := recommendations[idx-1]
fmt.Printf("\n %s--- Sensor Inspection ---%s\n", cli.Bold, cli.Reset)
fmt.Printf(" Name: %s\n", rec.SensorName)
fmt.Printf(" ID: %s\n", rec.SensorID)
fmt.Printf(" Reason: %s\n", rec.Reason)
fmt.Printf("\n Env Vars:\n")
for _, env := range rec.DeploymentTemplate.EnvVars {
fmt.Printf(" %s = %s\n", env.Name, env.Default)
}
fmt.Printf(" %s-------------------------%s\n\n", cli.Bold, cli.Reset)
} else {
fmt.Printf(" %s[!] Invalid sensor number.%s\n", cli.Red, cli.Reset)
}
continue editLoop
}
parts := strings.Split(editChoice, ",")
toRemove := make(map[int]bool)
valid := true
for _, p := range parts {
var idx int
if _, err := fmt.Sscanf(strings.TrimSpace(p), "%d", &idx); err == nil && idx >= 1 && idx <= len(recommendations) {
toRemove[idx-1] = true
} else if strings.TrimSpace(p) != "" {
fmt.Printf(" %s[!] Invalid input: '%s'.%s\n", cli.Red, cli.Reset, p)
valid = false
break
}
}
if valid && len(toRemove) > 0 {
var nextRecs []*discovery.Recommendation
for i, rec := range recommendations {
if !toRemove[i] {
nextRecs = append(nextRecs, rec)
} else {
fmt.Printf(" %s- Removed: %s%s\n", cli.Yellow, rec.SensorName, cli.Reset)
}
}
recommendations = nextRecs
if len(recommendations) == 0 {
break editLoop
}
}
}
case "", "n", "no":
fmt.Printf("\n %sSuggestions not applied. Run 'honeywire apply' when ready.%s\n\n", cli.Dim, cli.Reset)
return nil
}
}
return applySuggestions(app, recommendations, force)
}
func applySuggestions(app *app.App, recs []*discovery.Recommendation, force bool) error {
fmt.Printf("\n %s[*] Dashboard auth required to register sensors.%s\n", cli.Cyan, cli.Reset)
if err := app.RequireDashboardAuth(); err != nil {
return fmt.Errorf("dashboard authentication required: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
fmt.Printf(" %s[*] Registering sensors with Hub...%s\n", cli.Cyan, cli.Reset)
for _, rec := range recs {
configValues := make(map[string]string)
for _, env := range rec.DeploymentTemplate.EnvVars {
if !hubInjectedVars[env.Name] {
configValues[env.Name] = env.Default
}
}
if err := app.Hub.AddSensor(ctx, app.Config.NodeID, app.DashboardCookie(), rec.SensorID, rec.SensorName, configValues); err != nil {
return fmt.Errorf("failed to register sensors with Hub: %w", err)
}
fmt.Printf(" %s↳ Registered: %s%s\n", cli.Green, rec.SensorID, cli.Reset)
}
fmt.Printf(" %s[*] Reconciling node against Hub's desired state...%s\n", cli.Cyan, cli.Reset)
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), composeTimeout)
defer reconcileCancel()
composeData, err := app.Hub.FetchCompose(reconcileCtx, app.Config.APIKey)
if err != nil {
return fmt.Errorf("failed to fetch deployment bundle: %w", err)
}
if err := deploy.Apply(reconcileCtx, composeData); err != nil {
fmt.Printf("\n %s[!] The Hub saved your sensor config, but local deployment failed.%s\n", cli.Red, cli.Reset)
fmt.Printf(" %sThe node is now in a 'Pending Sync' state. Check Docker logs, resolve the issue, and run 'honeywire apply' to retry.%s\n\n", cli.Yellow, cli.Reset)
return fmt.Errorf("local reconciliation failed: %w", err)
}
fmt.Printf(" %s✅ Node reconciled. Run 'docker compose -f %s -p %s ps' to view sensors.%s\n\n", cli.Green, filepath.Join(deploy.DeployDir, deploy.ComposeFile), deploy.ProjectName, cli.Reset)
if cli.IsTerminal() {
if cli.ConfirmAction("Trigger a firedrill to test deployed sensors", force) {
return HandleFiredrill()
}
fmt.Printf("\n %sRun 'honeywire firedrill' when ready.%s\n\n", cli.Dim, cli.Reset)
} else {
fmt.Printf(" %sRun 'honeywire firedrill' to test deployed sensors.%s\n\n", cli.Dim, cli.Reset)
}
return nil
}
func runPreFlightChecks(force bool) error {
var hasWarnings bool
checkDocker := func() (string, error) {
if _, err := deploy.ValidateDockerState(); err != nil {
return err.Error(), nil
}
return "", nil
}
checks := []func() (string, error){system.CheckMemory, system.CheckLoad, system.CheckDiskSpace, checkDocker}
for _, check := range checks {
if warning, err := check(); err == nil && warning != "" {
fmt.Printf("%s%s%s\n", cli.Yellow, warning, cli.Reset)
hasWarnings = true
}
}
if hasWarnings {
fmt.Printf("\n%s⚠️ Host environment is severely degraded. Proceeding may cause instability.%s\n", cli.Red, cli.Reset)
if !cli.ConfirmAction("Continue anyway", force) {
return fmt.Errorf("aborted due to failing health checks")
}
fmt.Println()
}
return nil
}
func auditEnvironment() (*scanner.HostState, map[int]string, *system.SystemState, error) {
systemState, err := system.LoadCurrentState()
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to load system state: %w", err)
}
fmt.Printf("%s[*] Step 1/3: Analyzing Host OS & Sockets...%s\n", cli.Bold, cli.Reset)
hostScanner := scanner.NewProcScanner()
hostState, err := hostScanner.Scan(systemState)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to scan host: %w", err)
}
fmt.Printf("%s[*] Step 2/3: Interrogating Docker Daemon...%s\n", cli.Bold, cli.Reset)
dockerMap, dockerErr := buildDockerPortMap()
if dockerErr != nil {
fmt.Printf(" %s↳ Warning: Cannot access Docker API (%v). Proceeding with native scanning.%s\n", cli.Yellow, dockerErr, cli.Reset)
} else {
fmt.Printf(" %s↳ Successfully mapped %d containerized ports.%s\n", cli.Green, len(dockerMap), cli.Reset)
}
fmt.Println()
cli.PrintCorrelatedServices(hostState, dockerMap)
return hostState, dockerMap, systemState, nil
}
func buildStrategy(appInstance *app.App, hostState *scanner.HostState, systemState *system.SystemState, dockerMap map[int]string, rng *rand.Rand) ([]*discovery.Recommendation, error) {
fmt.Printf("\n%s[*] Step 3/3: Formulating Deception Strategy...%s\n", cli.Bold, cli.Reset)
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
defer cancel()
// Pull the manifest directly from the Hub using the dedicated authentication function.
manifests, apiErr := appInstance.Hub.FetchManifests(ctx, appInstance.Config.APIKey)
if apiErr != nil {
return nil, fmt.Errorf("Registry error: %w", apiErr)
}
fmt.Printf(" %s↳ Synced %d active sensors from registry%s\n", cli.Green, len(manifests), cli.Reset)
engine := discovery.NewEngine(manifests)
recommendations := engine.GetRecommendations(hostState, systemState)
renderManifestTemplates(recommendations, dockerMap, hostState, rng)
for _, rec := range recommendations {
updateEnvVar(rec, "HW_HUB_ENDPOINT", appInstance.Config.HubURL)
updateEnvVar(rec, "HW_SENSOR_ID", rec.SensorID)
}
return recommendations, nil
}
func filterInstalledSensors(recs []*discovery.Recommendation, installed map[string]bool) []*discovery.Recommendation {
var filtered []*discovery.Recommendation
for _, rec := range recs {
if installed[rec.SensorID] {
fmt.Printf(" %s⊘ Skipped: %s (already installed)%s\n", cli.Dim, rec.SensorID, cli.Reset)
continue
}
filtered = append(filtered, rec)
}
return filtered
}
func renderManifestTemplates(recs []*discovery.Recommendation, dockerMap map[int]string, hostState *scanner.HostState, rng *rand.Rand) {
var portStrs []string
usedPorts := make(map[int]bool)
for _, svc := range hostState.Services {
portStrs = append(portStrs, fmt.Sprintf("%d", svc.Port))
usedPorts[svc.Port] = true
}
ignorePorts := strings.Join(portStrs, ",")
usedFiles := make(map[string]bool)
safeFilePick := func(files []string, basePath string) string {
// codeql[go/insecure-randomness] Non-cryptographic use case for decoy file shuffling.
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
rng.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })
for _, f := range files {
if !usedFiles[f] {
if _, err := os.Stat(filepath.Join(basePath, f)); os.IsNotExist(err) {
usedFiles[f] = true
return f
}
}
}
// codeql[go/insecure-randomness] Non-cryptographic use case for fallback filename.
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
fallback := fmt.Sprintf("backup_hw_%d.bak", rng.Intn(99999))
usedFiles[fallback] = true
return fallback
}
funcMap := template.FuncMap{
"randWebFile": func(basePath string) string {
return safeFilePick([]string{"wp-config.old.php", ".env.staging", "config.bak.php", "aws_s3_keys.txt"}, basePath)
},
"randDBFile": func(basePath string) string {
return safeFilePick([]string{"dump_2023.sql", "db_backup_prod.sql", ".pgpass.old", "migration_rollback.sql"}, basePath)
},
"availablePort": func(base int) string {
p := base
for {
if !usedPorts[p] {
addr := fmt.Sprintf(":%d", p)
ln, err := net.Listen("tcp", addr)
if err == nil {
ln.Close()
usedPorts[p] = true
return fmt.Sprintf("%d", p)
}
}
p++
}
},
}
for _, rec := range recs {
var trapPath string
hasWeb := false
hasDB := false
for _, svc := range rec.MatchedServices {
name := strings.ToLower(svc.ProcessName)
if name == "docker-proxy" && dockerMap != nil {
if img, ok := dockerMap[svc.Port]; ok {
name = strings.ToLower(img)
}
}
if strings.Contains(name, "postgres") || strings.Contains(name, "mysql") || strings.Contains(name, "redis") {
hasDB = true
}
if strings.Contains(name, "nginx") || strings.Contains(name, "apache") || strings.Contains(name, "httpd") {
hasWeb = true
}
}
if hasWeb {
trapPath = "/var/www/html/.backups"
} else if hasDB {
trapPath = "/var/lib/db_backups"
} else {
trapPath = "/opt/app_data/backups"
}
ctx := struct {
HasWeb bool
HasDB bool
IgnorePorts string
TrapPath string
MatchedServices []discovery.MatchedService
}{
HasWeb: hasWeb,
HasDB: hasDB,
IgnorePorts: ignorePorts,
TrapPath: trapPath,
MatchedServices: rec.MatchedServices,
}
for i, vol := range rec.DeploymentTemplate.VolumeMounts {
t, err := template.New("vol").Funcs(funcMap).Parse(vol.Source)
if err != nil {
continue
}
var buf strings.Builder
if err := t.Execute(&buf, ctx); err == nil {
rec.DeploymentTemplate.VolumeMounts[i].Source = buf.String()
}
}
for i, env := range rec.DeploymentTemplate.EnvVars {
t, err := template.New("env").Funcs(funcMap).Parse(env.Default)
if err != nil {
continue
}
var buf strings.Builder
if err := t.Execute(&buf, ctx); err == nil {
rec.DeploymentTemplate.EnvVars[i].Default = buf.String()
}
}
}
}
func updateEnvVar(rec *discovery.Recommendation, key, value string) {
for i, env := range rec.DeploymentTemplate.EnvVars {
if env.Name == key {
rec.DeploymentTemplate.EnvVars[i].Default = value
return
}
}
rec.DeploymentTemplate.EnvVars = append(rec.DeploymentTemplate.EnvVars, schema.ConfigVar{
Name: key,
Default: value,
Hidden: true,
})
}
func buildDockerPortMap() (map[int]string, error) {
portMap := make(map[int]string)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
defer cli.Close()
containers, err := cli.ContainerList(ctx, container.ListOptions{})
if err != nil {
return nil, err
}
for _, c := range containers {
imageName := strings.Split(c.Image, "@")[0]
for _, port := range c.Ports {
if port.PublicPort > 0 {
portMap[int(port.PublicPort)] = imageName
}
}
}
return portMap, nil
}