| package report |
|
|
| import ( |
| "log/slog" |
| "strings" |
|
|
| "github.com/pinchtab/pinchtab/internal/config" |
| ) |
|
|
| type SecurityWarning struct { |
| ID string |
| Message string |
| Attrs []any |
| } |
|
|
| type SecurityPostureCheck struct { |
| ID string |
| Label string |
| Passed bool |
| Detail string |
| } |
|
|
| type SecurityPosture struct { |
| Checks []SecurityPostureCheck |
| Passed int |
| Total int |
| Level string |
| Bar string |
| } |
|
|
| func AssessSecurityPosture(cfg *config.RuntimeConfig) SecurityPosture { |
| if cfg == nil { |
| return SecurityPosture{Level: "UNKNOWN"} |
| } |
|
|
| checks := []SecurityPostureCheck{ |
| { |
| ID: "bind_loopback", |
| Label: "loopback bind", |
| Passed: isLoopbackBind(cfg.Bind), |
| Detail: cfg.Bind, |
| }, |
| { |
| ID: "api_auth_enabled", |
| Label: "api auth", |
| Passed: strings.TrimSpace(cfg.Token) != "", |
| Detail: map[bool]string{true: "required", false: "disabled"}[strings.TrimSpace(cfg.Token) != ""], |
| }, |
| { |
| ID: "sensitive_endpoints_disabled", |
| Label: "sensitive endpoints", |
| Passed: len(cfg.EnabledSensitiveEndpoints()) == 0, |
| Detail: formatEndpointStatus(cfg.EnabledSensitiveEndpoints()), |
| }, |
| { |
| ID: "attach_local_only", |
| Label: "attach host scope", |
| Passed: !attachAllowsNonLocalHosts(cfg.AttachAllowHosts), |
| Detail: formatHostScope(cfg.AttachAllowHosts), |
| }, |
| { |
| ID: "idpi_whitelist_scoped", |
| Label: "website whitelist", |
| Passed: cfg.IDPI.Enabled && len(cfg.IDPI.AllowedDomains) > 0 && !allowsAllDomains(cfg.IDPI.AllowedDomains), |
| Detail: formatWhitelistStatus(cfg), |
| }, |
| { |
| ID: "idpi_strict_mode", |
| Label: "IDPI strict mode", |
| Passed: cfg.IDPI.Enabled && cfg.IDPI.StrictMode, |
| Detail: formatStrictModeStatus(cfg), |
| }, |
| { |
| ID: "idpi_content_protection", |
| Label: "IDPI content guard", |
| Passed: cfg.IDPI.Enabled && (cfg.IDPI.ScanContent || cfg.IDPI.WrapContent), |
| Detail: formatContentGuardStatus(cfg), |
| }, |
| } |
|
|
| passed := 0 |
| for _, check := range checks { |
| if check.Passed { |
| passed++ |
| } |
| } |
|
|
| return SecurityPosture{ |
| Checks: checks, |
| Passed: passed, |
| Total: len(checks), |
| Level: securityPostureLevel(passed, len(checks)), |
| Bar: securityPostureBar(passed, len(checks)), |
| } |
| } |
|
|
| func assessSecurityPosture(cfg *config.RuntimeConfig) SecurityPosture { |
| return AssessSecurityPosture(cfg) |
| } |
|
|
| func securityPostureBar(passed, total int) string { |
| if total == 0 { |
| return "[ ]" |
| } |
| const width = 10 |
| pct := float64(passed) / float64(total) |
| filled := int(pct * width) |
| if filled > width { |
| filled = width |
| } |
| bar := "[" + strings.Repeat("■", filled) + strings.Repeat(" ", width-filled) + "]" |
| return bar |
| } |
|
|
| func AssessSecurityWarnings(cfg *config.RuntimeConfig) []SecurityWarning { |
| if cfg == nil { |
| return nil |
| } |
|
|
| warnings := make([]SecurityWarning, 0, 8) |
| enabled := cfg.EnabledSensitiveEndpoints() |
|
|
| if len(enabled) > 0 { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "sensitive_endpoints_enabled", |
| Message: "sensitive endpoints enabled", |
| Attrs: []any{"endpoints", enabled, "hint", "only enable them in trusted environments"}, |
| }) |
| } |
|
|
| if cfg.Token == "" { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "api_auth_disabled", |
| Message: "api authentication disabled", |
| Attrs: []any{"hint", "set PINCHTAB_TOKEN to require bearer auth for all endpoints"}, |
| }) |
| } |
|
|
| if len(enabled) > 0 && cfg.Token == "" { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "sensitive_endpoints_without_auth", |
| Message: "high-risk configuration: sensitive endpoints enabled without API authentication", |
| Attrs: []any{"endpoints", enabled, "hint", "set PINCHTAB_TOKEN or disable the sensitive endpoints"}, |
| }) |
| } |
|
|
| if !isLoopbackBind(cfg.Bind) { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "non_loopback_bind", |
| Message: "server exposed on a non-loopback bind address", |
| Attrs: []any{"bind", cfg.Bind, "hint", "prefer 127.0.0.1 or localhost unless remote access is intentional"}, |
| }) |
| } |
|
|
| if !cfg.IDPI.Enabled { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "idpi_disabled", |
| Message: "IDPI disabled; website whitelist inactive", |
| Attrs: []any{"setting", "security.idpi.enabled", "hint", "enable IDPI and keep security.idpi.allowedDomains scoped to approved websites"}, |
| }) |
| } else { |
| if len(cfg.IDPI.AllowedDomains) == 0 { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "idpi_whitelist_not_set", |
| Message: "website whitelist is not set for IDPI", |
| Attrs: []any{"setting", "security.idpi.allowedDomains", "hint", "configure allowedDomains to restrict which websites navigation may reach"}, |
| }) |
| } else if allowsAllDomains(cfg.IDPI.AllowedDomains) { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "idpi_whitelist_allows_all", |
| Message: "website whitelist allows all domains", |
| Attrs: []any{"setting", "security.idpi.allowedDomains", "hint", "remove '*' and list only approved domains"}, |
| }) |
| } |
|
|
| if !cfg.IDPI.StrictMode { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "idpi_warn_mode", |
| Message: "IDPI strict mode disabled", |
| Attrs: []any{"setting", "security.idpi.strictMode", "hint", "enable strict mode to block requests instead of only emitting warnings"}, |
| }) |
| } |
|
|
| if !cfg.IDPI.ScanContent && !cfg.IDPI.WrapContent { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "idpi_content_protection_disabled", |
| Message: "IDPI content protections are disabled", |
| Attrs: []any{"hint", "enable security.idpi.scanContent or security.idpi.wrapContent to protect text and snapshot responses"}, |
| }) |
| } |
| } |
|
|
| if attachAllowsNonLocalHosts(cfg.AttachAllowHosts) { |
| warnings = append(warnings, SecurityWarning{ |
| ID: "attach_external_hosts", |
| Message: "attach allowHosts includes non-local hosts", |
| Attrs: []any{"allowHosts", cfg.AttachAllowHosts, "hint", "keep security.attach.allowHosts limited to local addresses unless external Chrome instances are intentional"}, |
| }) |
| } |
|
|
| return warnings |
| } |
|
|
| func assessSecurityWarnings(cfg *config.RuntimeConfig) []SecurityWarning { |
| return AssessSecurityWarnings(cfg) |
| } |
|
|
| func LogSecurityWarnings(cfg *config.RuntimeConfig) { |
| for _, warning := range AssessSecurityWarnings(cfg) { |
| attrs := append([]any{"category", "security", "warningId", warning.ID}, warning.Attrs...) |
| slog.Warn(warning.Message, attrs...) |
| } |
| } |
|
|
| func isLoopbackBind(bind string) bool { |
| switch strings.TrimSpace(strings.ToLower(bind)) { |
| case "127.0.0.1", "localhost", "::1", "": |
| return true |
| default: |
| return false |
| } |
| } |
|
|
| func allowsAllDomains(domains []string) bool { |
| for _, domain := range domains { |
| if strings.TrimSpace(domain) == "*" { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| func attachAllowsNonLocalHosts(hosts []string) bool { |
| if len(hosts) == 0 { |
| return false |
| } |
| for _, host := range hosts { |
| switch strings.TrimSpace(strings.ToLower(host)) { |
| case "", "127.0.0.1", "localhost", "::1": |
| default: |
| return true |
| } |
| } |
| return false |
| } |
|
|
| func securityPostureLevel(passed, total int) string { |
| if total == 0 { |
| return "UNKNOWN" |
| } |
| switch { |
| case passed == total: |
| return "LOCKED" |
| case passed >= total-1: |
| return "GUARDED" |
| case passed >= 3: |
| return "ELEVATED" |
| default: |
| return "EXPOSED" |
| } |
| } |
|
|
| func formatEndpointStatus(enabled []string) string { |
| if len(enabled) == 0 { |
| return "disabled" |
| } |
| return strings.Join(enabled, ", ") |
| } |
|
|
| func formatHostScope(hosts []string) string { |
| if attachAllowsNonLocalHosts(hosts) { |
| return "external hosts allowed" |
| } |
| return "local-only" |
| } |
|
|
| func formatWhitelistStatus(cfg *config.RuntimeConfig) string { |
| if !cfg.IDPI.Enabled { |
| return "disabled" |
| } |
| if len(cfg.IDPI.AllowedDomains) == 0 { |
| return "not set" |
| } |
| if allowsAllDomains(cfg.IDPI.AllowedDomains) { |
| return "wildcard" |
| } |
| return strings.Join(cfg.IDPI.AllowedDomains, ", ") |
| } |
|
|
| func formatStrictModeStatus(cfg *config.RuntimeConfig) string { |
| if !cfg.IDPI.Enabled { |
| return "disabled" |
| } |
| if cfg.IDPI.StrictMode { |
| return "enforcing" |
| } |
| return "warn-only" |
| } |
|
|
| func formatContentGuardStatus(cfg *config.RuntimeConfig) string { |
| if !cfg.IDPI.Enabled { |
| return "disabled" |
| } |
| if cfg.IDPI.ScanContent || cfg.IDPI.WrapContent { |
| return "active" |
| } |
| return "disabled" |
| } |
|
|