diff --git a/assets/js/views/Issue.vue b/assets/js/views/Issue.vue index 130f0553d..5a7ba531c 100644 --- a/assets/js/views/Issue.vue +++ b/assets/js/views/Issue.vue @@ -475,7 +475,8 @@ export default defineComponent({ for (const endpoint of endpoints) { try { - const response = await api.get(endpoint); + // Add private=false for device endpoints to hide private data in bug reports + const response = await api.get(endpoint, { params: { private: false } }); if (response.data && Object.keys(response.data).length > 0) { const key = endpoint.replace("config/", "").replace("devices/", ""); let data = response.data; diff --git a/cmd/config.go b/cmd/config.go index b1320cb39..080609c10 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/redact" "github.com/evcc-io/evcc/util/templates" "github.com/spf13/cobra" ) @@ -54,7 +55,7 @@ func runConfig(cmd *cobra.Command, args []string) { } for _, c := range configurable { - fmt.Println(config.NameForID(c.ID), fmt.Sprintf("%+v", c.Properties), redactMap(c.Data)) + fmt.Println(config.NameForID(c.ID), fmt.Sprintf("%+v", c.Properties), redact.Map(c.Data)) } fmt.Println("") diff --git a/cmd/discuss.go b/cmd/discuss.go index 4041c1e6b..0a2a7ee21 100644 --- a/cmd/discuss.go +++ b/cmd/discuss.go @@ -10,6 +10,7 @@ import ( "github.com/cli/browser" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/redact" "github.com/spf13/cobra" ) @@ -44,7 +45,7 @@ func runDiscuss(cmd *cobra.Command, args []string) { var redacted string if src, err := os.ReadFile(cfgFile); err == nil { - redacted = redact(string(src)) + redacted = redact.String(string(src)) } tmpl := template.Must(template.New("discuss").Parse(discussTmpl)) diff --git a/cmd/dump.go b/cmd/dump.go index 9685bd8f0..e620f82de 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -13,6 +13,7 @@ import ( "github.com/evcc-io/evcc/core" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/redact" "github.com/spf13/cobra" ) @@ -67,7 +68,7 @@ func runDump(cmd *cobra.Command, args []string) { var redacted string if src, err := os.ReadFile(cfgFile); err == nil { - redacted = redact(string(src)) + redacted = redact.String(string(src)) } tmpl := template.Must( diff --git a/cmd/helper.go b/cmd/helper.go index ae4a6a2e8..981aaea32 100644 --- a/cmd/helper.go +++ b/cmd/helper.go @@ -49,14 +49,6 @@ func unwrap(err error) (res []string) { return } -func redact(src string) string { - return util.RedactConfigString(src) -} - -func redactMap(src map[string]any) map[string]any { - return util.RedactConfigMap(src) -} - // fatal logs a fatal error and runs shutdown functions before terminating func fatal(err error) { log.FATAL.Println(err) diff --git a/cmd/root_test.go b/cmd/root_test.go index b17dafe7e..3243ad204 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + "github.com/evcc-io/evcc/util/redact" ) func TestUnwrap(t *testing.T) { @@ -23,17 +25,17 @@ func TestRedact(t *testing.T) { sponsortoken: geheim user: geheim password: geheim - secret: geheim + clientsecret: geheim token: - access: geheim - refresh: geheim + accesstoken: geheim + refreshtoken: geheim pin: geheim mac: geheim - secret: geheim # comment - secret : geheim + clientsecret: geheim # comment + clientsecret : geheim ` - if res := redact(secret); strings.Contains(res, "geheim") || !strings.Contains(res, "public") { + if res := redact.String(secret); strings.Contains(res, "geheim") || !strings.Contains(res, "public") { t.Errorf("secret exposed: %v", res) } } diff --git a/playwright.config.ts b/playwright.config.ts index b711268ae..efc47de86 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ video: "on-first-retry", screenshot: "only-on-failure", permissions: ["clipboard-write"], + actionTimeout: 20000, // 20s for individual actions }, projects: [ { diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index 41732db63..06da0875d 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -23,11 +23,11 @@ import ( "go.yaml.in/yaml/v4" ) -func devicesConfig[T any](class templates.Class, h config.Handler[T]) ([]map[string]any, error) { +func devicesConfig[T any](class templates.Class, h config.Handler[T], hidePrivate bool) ([]map[string]any, error) { var res []map[string]any for _, dev := range h.Devices() { - dc, err := deviceConfigMap(class, dev) + dc, err := deviceConfigMap(class, dev, hidePrivate) if err != nil { return nil, err } @@ -54,20 +54,23 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) { return } + // Check if private data should be hidden (default: true, showing private data) + hidePrivate := r.URL.Query().Get("private") == "false" + var res []map[string]any switch class { case templates.Meter: - res, err = devicesConfig(class, config.Meters()) + res, err = devicesConfig(class, config.Meters(), hidePrivate) case templates.Charger: - res, err = devicesConfig(class, config.Chargers()) + res, err = devicesConfig(class, config.Chargers(), hidePrivate) case templates.Vehicle: - res, err = devicesConfig(class, config.Vehicles()) + res, err = devicesConfig(class, config.Vehicles(), hidePrivate) case templates.Circuit: - res, err = devicesConfig(class, config.Circuits()) + res, err = devicesConfig(class, config.Circuits(), hidePrivate) } if err != nil { @@ -78,7 +81,7 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) { jsonWrite(w, res) } -func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[string]any, error) { +func deviceConfigMap[T any](class templates.Class, dev config.Device[T], hidePrivate bool) (map[string]any, error) { conf := dev.Config() dc := map[string]any{ @@ -102,7 +105,7 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st } if conf.Type == typeTemplate { - params, err := sanitizeMasked(class, conf.Other) + params, err := sanitizeMasked(class, conf.Other, hidePrivate) if err != nil { return nil, err } @@ -146,13 +149,13 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st return dc, nil } -func deviceConfig[T any](class templates.Class, id int, h config.Handler[T]) (map[string]any, error) { +func deviceConfig[T any](class templates.Class, id int, h config.Handler[T], hidePrivate bool) (map[string]any, error) { dev, err := h.ByName(config.NameForID(id)) if err != nil { return nil, err } - return deviceConfigMap(class, dev) + return deviceConfigMap(class, dev, hidePrivate) } // deviceConfigHandler returns a device configuration by class @@ -171,20 +174,23 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) { return } + // Check if private data should be hidden (default: true, showing private data) + hidePrivate := r.URL.Query().Get("private") == "false" + var res map[string]any switch class { case templates.Meter: - res, err = deviceConfig(class, id, config.Meters()) + res, err = deviceConfig(class, id, config.Meters(), hidePrivate) case templates.Charger: - res, err = deviceConfig(class, id, config.Chargers()) + res, err = deviceConfig(class, id, config.Chargers(), hidePrivate) case templates.Vehicle: - res, err = deviceConfig(class, id, config.Vehicles()) + res, err = deviceConfig(class, id, config.Vehicles(), hidePrivate) case templates.Circuit: - res, err = deviceConfig(class, id, config.Circuits()) + res, err = deviceConfig(class, id, config.Circuits(), hidePrivate) } if err != nil { diff --git a/server/http_config_helper.go b/server/http_config_helper.go index ce94c9800..ff83dfb4d 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -133,7 +133,7 @@ func filterValidTemplateParams(tmpl *templates.Template, conf map[string]any) ma return res } -func sanitizeMasked(class templates.Class, conf map[string]any) (map[string]any, error) { +func sanitizeMasked(class templates.Class, conf map[string]any, hidePrivate bool) (map[string]any, error) { tmpl, err := templateForConfig(class, conf) if err != nil { return nil, err @@ -142,8 +142,12 @@ func sanitizeMasked(class templates.Class, conf map[string]any) (map[string]any, res := make(map[string]any, len(conf)) for k, v := range conf { - if i, p := tmpl.ParamByName(k); i >= 0 && p.IsMasked() { - v = masked + if i, p := tmpl.ParamByName(k); i >= 0 { + if p.IsMasked() { + v = masked + } else if hidePrivate && p.IsPrivate() { + v = masked + } } res[k] = v diff --git a/server/http_config_yaml_handler.go b/server/http_config_yaml_handler.go index e09e98d52..e3abca88a 100644 --- a/server/http_config_yaml_handler.go +++ b/server/http_config_yaml_handler.go @@ -5,7 +5,7 @@ import ( "net/http" "os" - "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/redact" ) // configYamlHandler returns the redacted evcc.yaml configuration file @@ -25,7 +25,7 @@ func configYamlHandler(configFilePath string) http.HandlerFunc { } // Redact sensitive information - redacted := util.RedactConfigString(string(src)) + redacted := redact.String(string(src)) // Return the redacted content as plain text w.Header().Set("Content-Type", "text/plain; charset=utf-8") diff --git a/server/http_global_settings_handler.go b/server/http_global_settings_handler.go index 8708752c6..c4fef2ddb 100644 --- a/server/http_global_settings_handler.go +++ b/server/http_global_settings_handler.go @@ -11,6 +11,7 @@ import ( "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/redact" "github.com/gorilla/mux" "go.yaml.in/yaml/v4" ) @@ -18,6 +19,12 @@ import ( func settingsGetStringHandler(key string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { res, _ := settings.String(key) + + // Check if private data should be hidden + if r.URL.Query().Get("private") == "false" && res != "" { + res = redact.String(res) + } + jsonWrite(w, res) } } diff --git a/templates/definition/charger/nrgkick-bluetooth.yaml b/templates/definition/charger/nrgkick-bluetooth.yaml index 0b8271ef7..04cb8ae9a 100644 --- a/templates/definition/charger/nrgkick-bluetooth.yaml +++ b/templates/definition/charger/nrgkick-bluetooth.yaml @@ -13,7 +13,6 @@ params: required: true - name: pin required: true - mask: true render: | type: nrgkick-bluetooth mac: {{ .mac }} diff --git a/templates/definition/meter/tibber-pulse.yaml b/templates/definition/meter/tibber-pulse.yaml index d435ea3cb..19e9958ee 100644 --- a/templates/definition/meter/tibber-pulse.yaml +++ b/templates/definition/meter/tibber-pulse.yaml @@ -9,7 +9,6 @@ params: - name: usage choice: ["grid"] - name: token - mask: true required: true example: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE - name: homeid diff --git a/templates/definition/vehicle/fiat.yaml b/templates/definition/vehicle/fiat.yaml index 021ebca8b..b1db92d85 100644 --- a/templates/definition/vehicle/fiat.yaml +++ b/templates/definition/vehicle/fiat.yaml @@ -7,7 +7,6 @@ params: - name: vin example: ZFAE... - name: pin - mask: true help: en: Required for evcc to wake up the vehicle for charging and to refresh the SoC while charging. When connected to TWC3, used to start/stop charging. de: Benötigt um das Fahrzeug zum Laden aufzuwecken and zur Aktualisierung des Ladestands während der Ladung. Bei Nutzung mit TWC3 kann der Ladevorgang mittels PIN gestartet und gestoppt werden. diff --git a/tests/config-device-auth-demo.tpl.yaml b/tests/config-device-auth-demo.tpl.yaml index 9f2196e10..fc4472306 100644 --- a/tests/config-device-auth-demo.tpl.yaml +++ b/tests/config-device-auth-demo.tpl.yaml @@ -6,24 +6,21 @@ products: en: Auth Demo Meter auth: type: demo - params: ["region", "token"] + params: ["region", "server"] params: - name: usage choice: ["grid"] - name: region description: - de: Server - en: Server + generic: Region type: choice choice: ["EU", "US", "CN"] - - name: token + - name: server description: - de: Token - en: Token + generic: Server - name: power description: - de: Leistung - en: Power + generic: Power unit: W type: int diff --git a/tests/config-device-auth.spec.ts b/tests/config-device-auth.spec.ts index f46fa704b..9ee178072 100644 --- a/tests/config-device-auth.spec.ts +++ b/tests/config-device-auth.spec.ts @@ -35,13 +35,13 @@ test.describe("config device auth", async () => { await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); // step 1: auth view + await expect(meterModal.getByLabel("Region")).toBeVisible(); await expect(meterModal.getByLabel("Server")).toBeVisible(); - await expect(meterModal.getByLabel("Token")).toBeVisible(); await expect(meterModal.getByLabel("Power")).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Save" })).not.toBeVisible(); - await meterModal.getByLabel("Server").selectOption("EU"); - await meterModal.getByLabel("Token").fill("test-token-123"); + await meterModal.getByLabel("Region").selectOption("EU"); + await meterModal.getByLabel("Server").fill("my.server.org"); await meterModal.getByRole("button", { name: "Prepare connection" }).click(); await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible(); @@ -51,8 +51,8 @@ test.describe("config device auth", async () => { }); // step 2: show regular device form - await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); - await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); + await expect(meterModal.getByLabel("Region")).toHaveValue("EU"); + await expect(meterModal.getByLabel("Server")).toHaveValue("my.server.org"); await expect(meterModal.getByLabel("Power")).toBeVisible(); await meterModal.getByLabel("Power").fill("5000"); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); @@ -69,8 +69,8 @@ test.describe("config device auth", async () => { // re-open meter for editing await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); await expectModalVisible(meterModal); - await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); - await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); + await expect(meterModal.getByLabel("Region")).toHaveValue("EU"); + await expect(meterModal.getByLabel("Server")).toHaveValue("my.server.org"); await expect(meterModal.getByLabel("Power")).toHaveValue("5000"); await expect(meterModal.getByRole("button", { name: "Prepare connection" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); @@ -84,8 +84,8 @@ test.describe("config device auth", async () => { // re-open meter for editing after restart, auth status as to be reestablished await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); await expectModalVisible(meterModal); - await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); - await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); + await expect(meterModal.getByLabel("Region")).toHaveValue("EU"); + await expect(meterModal.getByLabel("Server")).toHaveValue("my.server.org"); await expect(meterModal.getByLabel("Power")).not.toBeVisible(); // note: prepare connection step is auto-executed, since all required fields (server, token) are already present await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible(); diff --git a/tests/issue.spec.ts b/tests/issue.spec.ts index 2a1df2702..5bee6db1f 100644 --- a/tests/issue.spec.ts +++ b/tests/issue.spec.ts @@ -1,9 +1,18 @@ import { test, expect } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; +import { startSimulator, stopSimulator, simulatorHost } from "./simulator"; import { enableExperimental, expectModalVisible, expectModalHidden } from "./utils"; test.use({ baseURL: baseUrl() }); +test.beforeAll(async () => { + await startSimulator(); +}); + +test.afterAll(async () => { + await stopSimulator(); +}); + test.afterEach(async () => { await stop(); }); @@ -47,16 +56,18 @@ test.describe("issue creation", () => { // Enable experimental features await enableExperimental(page, false); - // Create a battery meter - await page.getByRole("button", { name: "Add solar or battery" }).click(); - await page.getByRole("button", { name: "Add battery meter" }).click(); + // Create a Shelly meter with username (to test private data redaction) + await page.getByRole("button", { name: "Add grid meter" }).click(); const meterModal = page.getByTestId("meter-modal"); await expectModalVisible(meterModal); - await meterModal.getByLabel("Title").fill("BigBlueBattery"); - await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); + await meterModal.getByLabel("Manufacturer").selectOption("Shelly 1PM"); + await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); + await meterModal.getByLabel("Username").fill("testuser@example.com"); + await meterModal.getByLabel("Password").fill("secretpass"); + await meterModal.getByRole("button", { name: "Validate & save" }).click(); await expectModalHidden(meterModal); - await expect(page.getByTestId("battery")).toBeVisible(); + await expect(page.getByTestId("grid")).toBeVisible(); // Restart to apply changes await restart(CONFIG); @@ -77,7 +88,7 @@ test.describe("issue creation", () => { .fill("This is a test issue created from the config page workflow"); await page .getByLabel("Steps to reproduce") - .fill("1. Go to config\n2. Enable experimental\n3. Add battery\n4. Report issue"); + .fill("1. Go to config\n2. Enable experimental\n3. Add meter\n4. Report issue"); // check yaml data const yamlItem = page.getByTestId("issueYamlConfig-additional-item"); @@ -88,12 +99,19 @@ test.describe("issue creation", () => { await yamlModal.getByRole("button", { name: "Close" }).first().click(); await expectModalHidden(yamlModal); - // check ui data + // check ui data and verify private data redaction const uiItem = page.getByTestId("issueUiConfig-additional-item"); await uiItem.getByRole("button", { name: "show details" }).click(); const uiModal = page.getByTestId("issueUiConfig-modal"); await expectModalVisible(uiModal); - await expect(uiModal.getByRole("textbox")).toHaveValue(/BigBlueBattery/); + const uiContent = await uiModal.getByRole("textbox").inputValue(); + + // Verify meter is present but private data is redacted + expect(uiContent).toContain("shelly"); // meter type should be visible + expect(uiContent).not.toContain("testuser@example.com"); // user should be redacted + expect(uiContent).not.toContain("secretpass"); // password should be redacted + expect(uiContent).toContain("***"); // redaction marker should be present + await uiModal.getByRole("button", { name: "Close" }).first().click(); await expectModalHidden(uiModal); @@ -133,7 +151,7 @@ test.describe("issue creation", () => { await expect(textarea).toBeVisible(); const textareaContent = await textarea.inputValue(); expect(textareaContent).toContain("carport_pv"); // from evcc.yaml - expect(textareaContent).toContain("BigBlueBattery"); // from ui config + expect(textareaContent).toContain("shelly"); // from ui config expect(textareaContent).toContain("DEBUG"); // from logs expect(textareaContent).toContain('"telemetry":'); // from state @@ -142,7 +160,7 @@ test.describe("issue creation", () => { .getByRole("link", { name: "Create GitHub Issue" }) .getAttribute("href"); expect(href).toContain("https://github.com/evcc-io/evcc/issues/new?title=Kaboom&body="); - expect(href).not.toContain("BigBlueBattery"); // from ui config + expect(href).not.toContain("TestShelly"); // from ui config expect(href).not.toContain("carport_pv"); // from evcc.yaml // close modal @@ -167,7 +185,7 @@ test.describe("issue creation", () => { .getByRole("link", { name: "Create GitHub Issue" }) .getAttribute("href"); expect(href).toContain("https://github.com/evcc-io/evcc/issues/new?title=Kaboom&body="); - expect(href).toContain("BigBlueBattery"); // from ui config + expect(href).toContain("shelly"); // from ui config expect(href).toContain("carport_pv"); // from evcc.yaml expect(href).toContain("DEBUG"); // from logs expect(href).toContain("MyFancyState"); // from state diff --git a/util/config_redactor.go b/util/config_redactor.go deleted file mode 100644 index 57731767d..000000000 --- a/util/config_redactor.go +++ /dev/null @@ -1,39 +0,0 @@ -package util - -import ( - "fmt" - "maps" - "regexp" - "slices" - "strings" -) - -// configRedactSecrets defines keys that should be redacted from configuration files -var configRedactSecrets = []string{ - "mac", // infrastructure - "sponsortoken", "plant", // global settings - "apikey", "user", "password", "pin", // users - "token", "access", "refresh", "accesstoken", "refreshtoken", // tokens, including template variations - "ain", "secret", "serial", "deviceid", "machineid", "idtag", // devices - "app", "chats", "recipients", // push messaging - "vin", // vehicles - "lat", "lon", "zip", // solar forecast -} - -var configRedactRegex = regexp.MustCompile(fmt.Sprintf(`(?i)\b(%s)\b.*?:.*`, strings.Join(configRedactSecrets, "|"))) - -// RedactConfigString redacts a configuration string by replacing sensitive values with ***** -func RedactConfigString(src string) string { - return configRedactRegex.ReplaceAllString(src, "$1: *****") -} - -// RedactConfigMap redacts sensitive keys in a configuration map -func RedactConfigMap(src map[string]any) map[string]any { - res := maps.Clone(src) - for k := range res { - if slices.Contains(configRedactSecrets, k) { - res[k] = "*****" - } - } - return res -} diff --git a/util/redact/redactor.go b/util/redact/redactor.go new file mode 100644 index 000000000..0de64d337 --- /dev/null +++ b/util/redact/redactor.go @@ -0,0 +1,64 @@ +package redact + +import ( + "fmt" + "maps" + "regexp" + "slices" + "strings" + + "github.com/evcc-io/evcc/util/templates" + "github.com/samber/lo" +) + +var ( + configRedactRegex *regexp.Regexp + configRedactSecrets []string +) + +func init() { + // fields that are not covered by template params (yet) + additional := []string{ + "sponsortoken", "plant", // global settings + "app", "chats", "recipients", // push messaging + } + + // Combine generated params with additional fields + configRedactSecrets = slices.Concat(redactableParams(), additional) + + configRedactRegex = regexp.MustCompile(fmt.Sprintf(`(?i)\b(%s)\b.*?:.*`, strings.Join(configRedactSecrets, "|"))) +} + +func redactableParams() []string { + // Collect all sensitive params from templates (includes defaults) + var params []string + for _, class := range templates.ClassValues() { + for _, tmpl := range templates.ByClass(class) { + for _, p := range tmpl.Params { + if p.IsMasked() || p.IsPrivate() { + params = append(params, strings.ToLower(p.Name)) + } + } + } + } + + return lo.Uniq(params) +} + +// String redacts a configuration string by replacing sensitive values with ***** +func String(src string) string { + return configRedactRegex.ReplaceAllString(src, "$1: *****") +} + +// Map redacts sensitive keys in a configuration map +func Map(src map[string]any) map[string]any { + res := maps.Clone(src) + for k := range res { + if slices.ContainsFunc(configRedactSecrets, func(s string) bool { + return strings.EqualFold(k, s) + }) { + res[k] = "*****" + } + } + return res +} diff --git a/util/redact/redactor_test.go b/util/redact/redactor_test.go new file mode 100644 index 000000000..dd3bb312c --- /dev/null +++ b/util/redact/redactor_test.go @@ -0,0 +1,87 @@ +package redact + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestString(t *testing.T) { + tests := []struct { + name string + input string + expected []string // Strings that should appear + redacted []string // Strings that should NOT appear + }{ + { + name: "redact password", + input: `site: + title: My Home + meters: + grid: my-grid +user: testuser +password: secretpass123`, + expected: []string{"password: *****", "title: My Home", "grid: my-grid"}, + redacted: []string{"secretpass123"}, + }, + { + name: "redact params marked as private", + input: `vehicle: + vin: W1234567890123456 + user: john@example.com + capacity: 50`, + expected: []string{"vin: *****", "user: *****", "capacity: 50"}, + redacted: []string{"W1234567890123456", "john@example.com"}, + }, + { + name: "redact multiple sensitive fields", + input: `config: + lat: 52.520008 + lon: 13.404954 + zip: 10115 + sponsortoken: abc123 + apikey: xyz789`, + expected: []string{"lat: *****", "lon: *****", "zip: *****", "sponsortoken: *****", "apikey: *****"}, + redacted: []string{"52.520008", "13.404954", "10115", "abc123", "xyz789"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := String(tt.input) + + // Check expected strings are present + for _, exp := range tt.expected { + assert.Contains(t, result, exp, "Expected to find %q in result", exp) + } + + // Check redacted strings are NOT present + for _, red := range tt.redacted { + assert.NotContains(t, result, red, "Expected %q to be redacted", red) + } + }) + } +} + +func TestMap(t *testing.T) { + input := map[string]any{ + "title": "My Home", + "password": "secret123", + "user": "john@example.com", + "vin": "W1234567890", + "capacity": 50, + "apikey": "abc-def-123", + } + + result := Map(input) + + // Check non-sensitive fields are unchanged + assert.Equal(t, "My Home", result["title"], "title should not be redacted") + assert.Equal(t, 50, result["capacity"], "capacity should not be redacted") + + // Check sensitive fields are redacted + sensitiveFields := []string{"password", "user", "vin", "apikey"} + for _, field := range sensitiveFields { + assert.Equal(t, "*****", result[field], "%s should be redacted", field) + } +} diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 0ea7ccebf..1fb447956 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -43,6 +43,7 @@ params: choice: ["https", "http"] default: https - name: user + private: true description: de: Benutzerkonto en: Username @@ -76,6 +77,7 @@ params: usages: ["pv"] advanced: true - name: vin + private: true description: de: Fahrzeugidentifikationsnummer en: Vehicle Identification Number @@ -247,6 +249,7 @@ params: - name: ski required: true + private: true description: en: Subject Key Identifier (SKI) de: Identifikationsschlüssel (SKI) @@ -254,16 +257,18 @@ params: en: Usually found on the web interface of the wallbox de: Üblicherweise im Web Interface der Wallbox zu finden - name: uri + private: true description: generic: URI help: en: HTTP(S) address de: HTTP(S) Adresse - name: url + private: true description: generic: URL - name: ain - mask: true + private: true example: "307788992233" description: en: Actor Identification Number (AIN) @@ -340,12 +345,15 @@ params: advanced: true - name: accesstoken + mask: true description: generic: Access token - name: refreshtoken + mask: true description: generic: Refresh token - name: serial + private: true description: en: Serial de: Seriennummer @@ -360,9 +368,11 @@ params: description: generic: Client ID - name: clientsecret + mask: true description: generic: Client Secret - name: token + mask: true description: generic: Token - name: interval @@ -370,13 +380,16 @@ params: de: Intervall en: Interval - name: mac + private: true description: en: MAC Address de: MAC Adresse - name: pin + mask: true description: generic: PIN - name: serial_number + private: true description: en: Serial Number de: Seriennummer @@ -384,9 +397,11 @@ params: description: generic: Watchdog - name: uuid + private: true description: generic: UUID - name: zip + private: true description: en: ZIP code de: Postleitzahl @@ -420,12 +435,14 @@ params: de: Temperaturquelle en: Temperature source - name: lat + private: true description: en: Latitude de: Breitengrad type: float example: 55.7351 - name: lon + private: true description: en: Longitude de: Längengrad @@ -601,6 +618,7 @@ presets: en: Only enable this option if there is no way to initiate transactions from the charger side! This is only the case if e.g. no RFID reader is available and charging processes would have to be released individually via app. Normally, the charger should always be configured at the device so that either an RFID card is used for activation or the charger is set to "Autostart", "Free Charging" or similar. First check the documentation and configuration possibilities of the charger, ask the manufacturer if necessary! (Uses OCPP RemoteStartTransaction) - name: idtag advanced: true + private: true type: string description: en: Authentication token diff --git a/util/templates/template.go b/util/templates/template.go index f462701d2..eeb6c9605 100644 --- a/util/templates/template.go +++ b/util/templates/template.go @@ -62,6 +62,11 @@ func (t *Template) Validate() error { continue } + // Validate that a param cannot be both masked and private + if p.Mask && p.Private { + return fmt.Errorf("param %s: 'mask' and 'private' cannot be used together. Use 'mask' for sensitive data like passwords/tokens that should be hidden in UI. Use 'private' for personal data like emails/locations that should only be redacted from bug reports", p.Name) + } + if p.Description.String("en") == "" || p.Description.String("de") == "" { return fmt.Errorf("param %s: description can't be empty", p.Name) } diff --git a/util/templates/types.go b/util/templates/types.go index 813ded760..50e26cbc7 100644 --- a/util/templates/types.go +++ b/util/templates/types.go @@ -189,6 +189,7 @@ type Param struct { Preset string `json:"-"` // Reference a predefined set of params Required bool `json:",omitempty"` // cli if the user has to provide a non empty value Mask bool `json:",omitempty"` // cli if the value should be masked, e.g. for passwords + Private bool `json:",omitempty"` // value should be redacted in bug reports, e.g. email, locations, ... Advanced bool `json:",omitempty"` // cli if the user does not need to be asked. Requires a "Default" to be defined. Deprecated bool `json:",omitempty"` // if the parameter is deprecated and thus should not be presented in the cli or docs Default string `json:",omitempty"` // default value if no user value is provided in the configuration @@ -238,6 +239,10 @@ func (p *Param) IsMasked() bool { return p.Mask } +func (p *Param) IsPrivate() bool { + return p.Private +} + func (p *Param) IsRequired() bool { return p.Required }