| package main |
|
|
| import ( |
| "encoding/json" |
| "fmt" |
| "os" |
| "slices" |
| "strings" |
|
|
| "github.com/pinchtab/pinchtab/internal/cli" |
| "github.com/pinchtab/pinchtab/internal/config" |
| "github.com/spf13/cobra" |
| ) |
|
|
| var securityCmd = &cobra.Command{ |
| Use: "security", |
| Short: "Review runtime security posture", |
| Long: "Shows runtime security posture and offers to restore recommended security defaults.", |
| Run: func(cmd *cobra.Command, args []string) { |
| cfg := loadConfig() |
| handleSecurityCommand(cfg) |
| }, |
| } |
|
|
| func init() { |
| securityCmd.GroupID = "config" |
| securityCmd.AddCommand(&cobra.Command{ |
| Use: "up", |
| Short: "Apply recommended security defaults", |
| Run: func(cmd *cobra.Command, args []string) { |
| handleSecurityUpCommand() |
| }, |
| }) |
| securityCmd.AddCommand(&cobra.Command{ |
| Use: "down", |
| Short: "Lower guards while keeping loopback bind and API auth enabled", |
| Run: func(cmd *cobra.Command, args []string) { |
| handleSecurityDownCommand() |
| }, |
| }) |
| rootCmd.AddCommand(securityCmd) |
| } |
|
|
| func handleSecurityCommand(cfg *config.RuntimeConfig) { |
| interactive := isInteractiveTerminal() |
|
|
| for { |
| posture := cli.AssessSecurityPosture(cfg) |
| warnings := cli.AssessSecurityWarnings(cfg) |
| recommended := cli.RecommendedSecurityDefaultLines(cfg) |
|
|
| printSecuritySummary(posture, interactive) |
|
|
| if len(warnings) > 0 { |
| fmt.Println() |
| fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Warnings")) |
| fmt.Println() |
| for _, warning := range warnings { |
| fmt.Printf(" - %s\n", cli.StyleStdout(cli.WarningStyle, warning.Message)) |
| for i := 0; i+1 < len(warning.Attrs); i += 2 { |
| key, ok := warning.Attrs[i].(string) |
| if !ok || key == "hint" { |
| continue |
| } |
| fmt.Printf(" %s: %s\n", cli.StyleStdout(cli.MutedStyle, key), cli.StyleStdout(cli.ValueStyle, formatSecurityValue(warning.Attrs[i+1]))) |
| } |
| for i := 0; i+1 < len(warning.Attrs); i += 2 { |
| key, ok := warning.Attrs[i].(string) |
| if ok && key == "hint" { |
| fmt.Printf(" %s: %s\n", cli.StyleStdout(cli.MutedStyle, "hint"), cli.StyleStdout(cli.ValueStyle, formatSecurityValue(warning.Attrs[i+1]))) |
| } |
| } |
| } |
| } |
|
|
| if len(recommended) == 0 && len(warnings) == 0 { |
| fmt.Println() |
| fmt.Println(" " + cli.StyleStdout(cli.SuccessStyle, "All recommended security defaults are active.")) |
| } else if len(recommended) > 0 { |
| fmt.Println() |
| fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Recommended defaults")) |
| fmt.Println() |
| printRecommendedSecurityDefaults(recommended) |
| } |
|
|
| if !interactive { |
| if len(recommended) > 0 { |
| fmt.Println() |
| fmt.Println(cli.StyleStdout(cli.MutedStyle, "Interactive editing skipped because stdin/stdout is not a terminal.")) |
| } |
| return |
| } |
|
|
| nextCfg, changed, done, err := promptSecurityEdit(cfg, posture, len(recommended) > 0) |
| if err != nil { |
| fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error())) |
| os.Exit(1) |
| } |
| if done { |
| return |
| } |
| if !changed { |
| fmt.Println() |
| fmt.Println(cli.StyleStdout(cli.MutedStyle, "No changes made.")) |
| return |
| } |
| cfg = nextCfg |
| fmt.Println() |
| } |
| } |
|
|
| func formatSecurityValue(value any) string { |
| switch v := value.(type) { |
| case []string: |
| return strings.Join(v, ", ") |
| default: |
| return fmt.Sprint(v) |
| } |
| } |
|
|
| func printRecommendedSecurityDefaults(lines []string) { |
| for _, line := range lines { |
| fmt.Printf(" - %s\n", cli.StyleStdout(cli.ValueStyle, line)) |
| } |
| } |
|
|
| func printSecuritySummary(posture cli.SecurityPosture, interactive bool) { |
| fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Security")) |
| fmt.Println() |
| fmt.Printf(" %s %s\n", posture.Bar, cli.StyleStdout(cli.ValueStyle, posture.Level)) |
| for i, check := range posture.Checks { |
| indicator := cli.StyleStdout(cli.WarningStyle, "!!") |
| if check.Passed { |
| indicator = cli.StyleStdout(cli.SuccessStyle, "ok") |
| } |
| if interactive { |
| fmt.Printf(" %d. %s %-20s %s\n", i+1, indicator, check.Label, check.Detail) |
| continue |
| } |
| fmt.Printf(" %s %-20s %s\n", indicator, check.Label, check.Detail) |
| } |
| } |
|
|
| func promptSecurityEdit(cfg *config.RuntimeConfig, posture cli.SecurityPosture, canRestoreDefaults bool) (*config.RuntimeConfig, bool, bool, error) { |
| fmt.Println() |
| prompt := "Edit item (1-8" |
| if canRestoreDefaults { |
| prompt += ", u = security up" |
| } |
| prompt += ", d = security down, blank to exit):" |
|
|
| choice, err := promptInput(cli.StyleStdout(cli.HeadingStyle, prompt), "") |
| if err != nil { |
| return nil, false, false, err |
| } |
| choice = strings.ToLower(strings.TrimSpace(choice)) |
| if choice == "" { |
| return nil, false, true, nil |
| } |
|
|
| if (choice == "u" || choice == "up") && canRestoreDefaults { |
| nextCfg, changed, err := applySecurityUp() |
| return nextCfg, changed, false, err |
| } |
|
|
| if choice == "d" || choice == "down" { |
| nextCfg, changed, err := applySecurityDown() |
| return nextCfg, changed, false, err |
| } |
|
|
| index := strings.TrimSpace(choice) |
| for i, check := range posture.Checks { |
| if index == fmt.Sprint(i+1) { |
| nextCfg, changed, err := editSecurityCheck(cfg, check) |
| return nextCfg, changed, false, err |
| } |
| } |
|
|
| return nil, false, false, fmt.Errorf("invalid selection %q", choice) |
| } |
|
|
| func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck) (*config.RuntimeConfig, bool, error) { |
| switch check.ID { |
| case "bind_loopback": |
| value, err := promptInput("Set server.bind:", cfg.Bind) |
| if err != nil { |
| return nil, false, err |
| } |
| return updateConfigValue("server.bind", value) |
| case "api_auth_enabled": |
| picked, err := promptSelect("API authentication", []menuOption{ |
| {label: "Generate new token (Recommended)", value: "generate"}, |
| {label: "Set custom token", value: "custom"}, |
| {label: "Disable token", value: "disable"}, |
| {label: "Cancel", value: "cancel"}, |
| }) |
| if err != nil || picked == "" || picked == "cancel" { |
| return cfg, false, nil |
| } |
| switch picked { |
| case "generate": |
| token, err := config.GenerateAuthToken() |
| if err != nil { |
| return nil, false, err |
| } |
| return updateConfigValue("server.token", token) |
| case "custom": |
| token, err := promptInput("Set server.token:", cfg.Token) |
| if err != nil { |
| return nil, false, err |
| } |
| return updateConfigValue("server.token", token) |
| case "disable": |
| return updateConfigValue("server.token", "") |
| } |
| case "sensitive_endpoints_disabled": |
| current := strings.Join(cfg.EnabledSensitiveEndpoints(), ",") |
| value, err := promptInput("Enable sensitive endpoints (evaluate,macro,screencast,download,upload; blank = disable all):", current) |
| if err != nil { |
| return nil, false, err |
| } |
| return updateSensitiveEndpoints(value) |
| case "attach_disabled": |
| picked, err := promptSelect("Attach endpoint", []menuOption{ |
| {label: "Disable (Recommended)", value: "disable"}, |
| {label: "Enable", value: "enable"}, |
| {label: "Cancel", value: "cancel"}, |
| }) |
| if err != nil || picked == "" || picked == "cancel" { |
| return cfg, false, nil |
| } |
| return updateConfigValue("security.attach.enabled", fmt.Sprintf("%t", picked == "enable")) |
| case "attach_local_only": |
| value, err := promptInput("Set security.attach.allowHosts (comma-separated):", strings.Join(cfg.AttachAllowHosts, ",")) |
| if err != nil { |
| return nil, false, err |
| } |
| return updateConfigValue("security.attach.allowHosts", value) |
| case "idpi_whitelist_scoped": |
| value, err := promptInput("Set security.idpi.allowedDomains (comma-separated):", strings.Join(cfg.IDPI.AllowedDomains, ",")) |
| if err != nil { |
| return nil, false, err |
| } |
| return updateConfigValue("security.idpi.allowedDomains", value) |
| case "idpi_strict_mode": |
| picked, err := promptSelect("IDPI strict mode", []menuOption{ |
| {label: "Enforce (Recommended)", value: "true"}, |
| {label: "Warn only", value: "false"}, |
| {label: "Cancel", value: "cancel"}, |
| }) |
| if err != nil || picked == "" || picked == "cancel" { |
| return cfg, false, nil |
| } |
| return updateConfigValue("security.idpi.strictMode", picked) |
| case "idpi_content_protection": |
| picked, err := promptSelect("IDPI content guard", []menuOption{ |
| {label: "Active: scan + wrap (Recommended)", value: "both"}, |
| {label: "Scan only", value: "scan"}, |
| {label: "Wrap only", value: "wrap"}, |
| {label: "Disable", value: "off"}, |
| {label: "Cancel", value: "cancel"}, |
| }) |
| if err != nil || picked == "" || picked == "cancel" { |
| return cfg, false, nil |
| } |
| return updateContentGuard(picked) |
| } |
| return cfg, false, nil |
| } |
|
|
| func updateConfigValue(path, value string) (*config.RuntimeConfig, bool, error) { |
| fc, configPath, err := config.LoadFileConfig() |
| if err != nil { |
| return nil, false, fmt.Errorf("load config: %w", err) |
| } |
| if err := config.SetConfigValue(fc, path, value); err != nil { |
| return nil, false, fmt.Errorf("set %s: %w", path, err) |
| } |
| if errs := config.ValidateFileConfig(fc); len(errs) > 0 { |
| return nil, false, errs[0] |
| } |
| if err := config.SaveFileConfig(fc, configPath); err != nil { |
| return nil, false, fmt.Errorf("save config: %w", err) |
| } |
| return config.Load(), true, nil |
| } |
|
|
| func updateSensitiveEndpoints(value string) (*config.RuntimeConfig, bool, error) { |
| fc, configPath, err := config.LoadFileConfig() |
| if err != nil { |
| return nil, false, fmt.Errorf("load config: %w", err) |
| } |
|
|
| selected := map[string]bool{} |
| for _, item := range splitCommaList(value) { |
| selected[item] = true |
| } |
| for endpoint, path := range map[string]string{ |
| "evaluate": "security.allowEvaluate", |
| "macro": "security.allowMacro", |
| "screencast": "security.allowScreencast", |
| "download": "security.allowDownload", |
| "upload": "security.allowUpload", |
| } { |
| enabled := selected[endpoint] |
| if err := config.SetConfigValue(fc, path, fmt.Sprintf("%t", enabled)); err != nil { |
| return nil, false, fmt.Errorf("set %s: %w", endpoint, err) |
| } |
| } |
| if errs := config.ValidateFileConfig(fc); len(errs) > 0 { |
| return nil, false, errs[0] |
| } |
| if err := config.SaveFileConfig(fc, configPath); err != nil { |
| return nil, false, fmt.Errorf("save config: %w", err) |
| } |
| return config.Load(), true, nil |
| } |
|
|
| func updateContentGuard(mode string) (*config.RuntimeConfig, bool, error) { |
| fc, configPath, err := config.LoadFileConfig() |
| if err != nil { |
| return nil, false, fmt.Errorf("load config: %w", err) |
| } |
|
|
| scan := mode == "both" || mode == "scan" |
| wrap := mode == "both" || mode == "wrap" |
| for _, item := range []struct { |
| path string |
| value bool |
| }{ |
| {path: "security.idpi.scanContent", value: scan}, |
| {path: "security.idpi.wrapContent", value: wrap}, |
| } { |
| if err := config.SetConfigValue(fc, item.path, fmt.Sprintf("%t", item.value)); err != nil { |
| return nil, false, fmt.Errorf("set %s: %w", item.path, err) |
| } |
| } |
| if errs := config.ValidateFileConfig(fc); len(errs) > 0 { |
| return nil, false, errs[0] |
| } |
| if err := config.SaveFileConfig(fc, configPath); err != nil { |
| return nil, false, fmt.Errorf("save config: %w", err) |
| } |
| return config.Load(), true, nil |
| } |
|
|
| func applyGuardsDownPreset() (*config.RuntimeConfig, string, bool, error) { |
| fc, configPath, err := config.LoadFileConfig() |
| if err != nil { |
| return nil, "", false, fmt.Errorf("load config: %w", err) |
| } |
| originalJSON, err := formatFileConfigJSON(fc) |
| if err != nil { |
| return nil, "", false, err |
| } |
|
|
| original, err := config.GetConfigValue(fc, "server.token") |
| if err != nil { |
| return nil, "", false, fmt.Errorf("read server.token: %w", err) |
| } |
| if strings.TrimSpace(original) == "" { |
| token, err := config.GenerateAuthToken() |
| if err != nil { |
| return nil, "", false, fmt.Errorf("generate token: %w", err) |
| } |
| if err := config.SetConfigValue(fc, "server.token", token); err != nil { |
| return nil, "", false, fmt.Errorf("set server.token: %w", err) |
| } |
| } |
|
|
| for _, item := range []struct { |
| path string |
| value string |
| }{ |
| {path: "server.bind", value: "127.0.0.1"}, |
| {path: "security.allowEvaluate", value: "true"}, |
| {path: "security.allowMacro", value: "true"}, |
| {path: "security.allowScreencast", value: "true"}, |
| {path: "security.allowDownload", value: "true"}, |
| {path: "security.allowUpload", value: "true"}, |
| {path: "security.attach.enabled", value: "true"}, |
| {path: "security.attach.allowHosts", value: "127.0.0.1,localhost,::1"}, |
| {path: "security.attach.allowSchemes", value: "ws,wss"}, |
| {path: "security.idpi.enabled", value: "false"}, |
| {path: "security.idpi.strictMode", value: "false"}, |
| {path: "security.idpi.scanContent", value: "false"}, |
| {path: "security.idpi.wrapContent", value: "false"}, |
| } { |
| if err := config.SetConfigValue(fc, item.path, item.value); err != nil { |
| return nil, "", false, fmt.Errorf("set %s: %w", item.path, err) |
| } |
| } |
|
|
| if errs := config.ValidateFileConfig(fc); len(errs) > 0 { |
| return nil, "", false, errs[0] |
| } |
|
|
| nextJSON, err := formatFileConfigJSON(fc) |
| if err != nil { |
| return nil, "", false, err |
| } |
| changed := originalJSON != nextJSON |
| if !changed { |
| return config.Load(), configPath, false, nil |
| } |
|
|
| if err := config.SaveFileConfig(fc, configPath); err != nil { |
| return nil, "", false, fmt.Errorf("save config: %w", err) |
| } |
| return config.Load(), configPath, true, nil |
| } |
|
|
| func applySecurityUp() (*config.RuntimeConfig, bool, error) { |
| configPath, changed, err := cli.RestoreSecurityDefaults() |
| if err != nil { |
| return nil, false, fmt.Errorf("restore defaults: %w", err) |
| } |
| if !changed { |
| fmt.Println(cli.StyleStdout(cli.MutedStyle, fmt.Sprintf("Security defaults already match %s", configPath))) |
| return config.Load(), false, nil |
| } |
| fmt.Println(cli.StyleStdout(cli.SuccessStyle, fmt.Sprintf("Security defaults restored in %s", configPath))) |
| fmt.Println(cli.StyleStdout(cli.MutedStyle, "Restart PinchTab to apply file-based changes.")) |
| return config.Load(), true, nil |
| } |
|
|
| func applySecurityDown() (*config.RuntimeConfig, bool, error) { |
| nextCfg, configPath, changed, err := applyGuardsDownPreset() |
| if err != nil { |
| return nil, false, fmt.Errorf("guards down: %w", err) |
| } |
| if !changed { |
| fmt.Println(cli.StyleStdout(cli.MutedStyle, fmt.Sprintf("Guards down preset already matches %s", configPath))) |
| return nextCfg, false, nil |
| } |
| fmt.Println(cli.StyleStdout(cli.WarningStyle, fmt.Sprintf("Guards down preset applied in %s", configPath))) |
| fmt.Println(cli.StyleStdout(cli.MutedStyle, "Loopback bind and API auth remain enabled; sensitive endpoints and attach are enabled, IDPI is disabled.")) |
| return nextCfg, true, nil |
| } |
|
|
| func handleSecurityUpCommand() { |
| if _, _, err := applySecurityUp(); err != nil { |
| fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error())) |
| os.Exit(1) |
| } |
| } |
|
|
| func handleSecurityDownCommand() { |
| if _, _, err := applySecurityDown(); err != nil { |
| fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error())) |
| os.Exit(1) |
| } |
| } |
|
|
| func formatFileConfigJSON(fc *config.FileConfig) (string, error) { |
| data, err := json.Marshal(fc) |
| if err != nil { |
| return "", fmt.Errorf("marshal config: %w", err) |
| } |
| return string(data), nil |
| } |
|
|
| func splitCommaList(value string) []string { |
| parts := strings.Split(value, ",") |
| items := make([]string, 0, len(parts)) |
| for _, part := range parts { |
| trimmed := strings.TrimSpace(strings.ToLower(part)) |
| if trimmed != "" { |
| items = append(items, trimmed) |
| } |
| } |
| slices.Sort(items) |
| return slices.Compact(items) |
| } |
|
|