Issue UI: redact private data like (user, locations, ...) (#25039)

This commit is contained in:
Copilot 2025-11-26 13:34:57 +01:00 • committed by GitHub
parent f8dcd4dd78
commit a65a1ce2d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 277 additions and 109 deletions

View file

@ -475,7 +475,8 @@ export default defineComponent({
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
try { 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) { if (response.data && Object.keys(response.data).length > 0) {
const key = endpoint.replace("config/", "").replace("devices/", ""); const key = endpoint.replace("config/", "").replace("devices/", "");
let data = response.data; let data = response.data;

View file

@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/redact"
"github.com/evcc-io/evcc/util/templates" "github.com/evcc-io/evcc/util/templates"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -54,7 +55,7 @@ func runConfig(cmd *cobra.Command, args []string) {
} }
for _, c := range configurable { 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("") fmt.Println("")

View file

@ -10,6 +10,7 @@ import (
"github.com/cli/browser" "github.com/cli/browser"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/redact"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -44,7 +45,7 @@ func runDiscuss(cmd *cobra.Command, args []string) {
var redacted string var redacted string
if src, err := os.ReadFile(cfgFile); err == nil { 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)) tmpl := template.Must(template.New("discuss").Parse(discussTmpl))

View file

@ -13,6 +13,7 @@ import (
"github.com/evcc-io/evcc/core" "github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/redact"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -67,7 +68,7 @@ func runDump(cmd *cobra.Command, args []string) {
var redacted string var redacted string
if src, err := os.ReadFile(cfgFile); err == nil { if src, err := os.ReadFile(cfgFile); err == nil {
redacted = redact(string(src)) redacted = redact.String(string(src))
} }
tmpl := template.Must( tmpl := template.Must(

View file

@ -49,14 +49,6 @@ func unwrap(err error) (res []string) {
return 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 // fatal logs a fatal error and runs shutdown functions before terminating
func fatal(err error) { func fatal(err error) {
log.FATAL.Println(err) log.FATAL.Println(err)

View file

@ -6,6 +6,8 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"github.com/evcc-io/evcc/util/redact"
) )
func TestUnwrap(t *testing.T) { func TestUnwrap(t *testing.T) {
@ -23,17 +25,17 @@ func TestRedact(t *testing.T) {
sponsortoken: geheim sponsortoken: geheim
user: geheim user: geheim
password: geheim password: geheim
secret: geheim clientsecret: geheim
token: token:
access: geheim accesstoken: geheim
refresh: geheim refreshtoken: geheim
pin: geheim pin: geheim
mac: geheim mac: geheim
secret: geheim # comment clientsecret: geheim # comment
secret : geheim 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) t.Errorf("secret exposed: %v", res)
} }
} }

View file

@ -15,6 +15,7 @@ export default defineConfig({
video: "on-first-retry", video: "on-first-retry",
screenshot: "only-on-failure", screenshot: "only-on-failure",
permissions: ["clipboard-write"], permissions: ["clipboard-write"],
actionTimeout: 20000, // 20s for individual actions
}, },
projects: [ projects: [
{ {

View file

@ -23,11 +23,11 @@ import (
"go.yaml.in/yaml/v4" "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 var res []map[string]any
for _, dev := range h.Devices() { for _, dev := range h.Devices() {
dc, err := deviceConfigMap(class, dev) dc, err := deviceConfigMap(class, dev, hidePrivate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -54,20 +54,23 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
return 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 var res []map[string]any
switch class { switch class {
case templates.Meter: case templates.Meter:
res, err = devicesConfig(class, config.Meters()) res, err = devicesConfig(class, config.Meters(), hidePrivate)
case templates.Charger: case templates.Charger:
res, err = devicesConfig(class, config.Chargers()) res, err = devicesConfig(class, config.Chargers(), hidePrivate)
case templates.Vehicle: case templates.Vehicle:
res, err = devicesConfig(class, config.Vehicles()) res, err = devicesConfig(class, config.Vehicles(), hidePrivate)
case templates.Circuit: case templates.Circuit:
res, err = devicesConfig(class, config.Circuits()) res, err = devicesConfig(class, config.Circuits(), hidePrivate)
} }
if err != nil { if err != nil {
@ -78,7 +81,7 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, res) 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() conf := dev.Config()
dc := map[string]any{ 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 { if conf.Type == typeTemplate {
params, err := sanitizeMasked(class, conf.Other) params, err := sanitizeMasked(class, conf.Other, hidePrivate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -146,13 +149,13 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st
return dc, nil 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)) dev, err := h.ByName(config.NameForID(id))
if err != nil { if err != nil {
return nil, err return nil, err
} }
return deviceConfigMap(class, dev) return deviceConfigMap(class, dev, hidePrivate)
} }
// deviceConfigHandler returns a device configuration by class // deviceConfigHandler returns a device configuration by class
@ -171,20 +174,23 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
return 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 var res map[string]any
switch class { switch class {
case templates.Meter: case templates.Meter:
res, err = deviceConfig(class, id, config.Meters()) res, err = deviceConfig(class, id, config.Meters(), hidePrivate)
case templates.Charger: case templates.Charger:
res, err = deviceConfig(class, id, config.Chargers()) res, err = deviceConfig(class, id, config.Chargers(), hidePrivate)
case templates.Vehicle: case templates.Vehicle:
res, err = deviceConfig(class, id, config.Vehicles()) res, err = deviceConfig(class, id, config.Vehicles(), hidePrivate)
case templates.Circuit: case templates.Circuit:
res, err = deviceConfig(class, id, config.Circuits()) res, err = deviceConfig(class, id, config.Circuits(), hidePrivate)
} }
if err != nil { if err != nil {

View file

@ -133,7 +133,7 @@ func filterValidTemplateParams(tmpl *templates.Template, conf map[string]any) ma
return res 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) tmpl, err := templateForConfig(class, conf)
if err != nil { if err != nil {
return nil, err 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)) res := make(map[string]any, len(conf))
for k, v := range conf { for k, v := range conf {
if i, p := tmpl.ParamByName(k); i >= 0 && p.IsMasked() { if i, p := tmpl.ParamByName(k); i >= 0 {
v = masked if p.IsMasked() {
v = masked
} else if hidePrivate && p.IsPrivate() {
v = masked
}
} }
res[k] = v res[k] = v

View file

@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/redact"
) )
// configYamlHandler returns the redacted evcc.yaml configuration file // configYamlHandler returns the redacted evcc.yaml configuration file
@ -25,7 +25,7 @@ func configYamlHandler(configFilePath string) http.HandlerFunc {
} }
// Redact sensitive information // Redact sensitive information
redacted := util.RedactConfigString(string(src)) redacted := redact.String(string(src))
// Return the redacted content as plain text // Return the redacted content as plain text
w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Type", "text/plain; charset=utf-8")

View file

@ -11,6 +11,7 @@ import (
"github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/redact"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@ -18,6 +19,12 @@ import (
func settingsGetStringHandler(key string) http.HandlerFunc { func settingsGetStringHandler(key string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
res, _ := settings.String(key) 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) jsonWrite(w, res)
} }
} }

View file

@ -13,7 +13,6 @@ params:
required: true required: true
- name: pin - name: pin
required: true required: true
mask: true
render: | render: |
type: nrgkick-bluetooth type: nrgkick-bluetooth
mac: {{ .mac }} mac: {{ .mac }}

View file

@ -9,7 +9,6 @@ params:
- name: usage - name: usage
choice: ["grid"] choice: ["grid"]
- name: token - name: token
mask: true
required: true required: true
example: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE example: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE
- name: homeid - name: homeid

View file

@ -7,7 +7,6 @@ params:
- name: vin - name: vin
example: ZFAE... example: ZFAE...
- name: pin - name: pin
mask: true
help: 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. 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. 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.

View file

@ -6,24 +6,21 @@ products:
en: Auth Demo Meter en: Auth Demo Meter
auth: auth:
type: demo type: demo
params: ["region", "token"] params: ["region", "server"]
params: params:
- name: usage - name: usage
choice: ["grid"] choice: ["grid"]
- name: region - name: region
description: description:
de: Server generic: Region
en: Server
type: choice type: choice
choice: ["EU", "US", "CN"] choice: ["EU", "US", "CN"]
- name: token - name: server
description: description:
de: Token generic: Server
en: Token
- name: power - name: power
description: description:
de: Leistung generic: Power
en: Power
unit: W unit: W
type: int type: int

View file

@ -35,13 +35,13 @@ test.describe("config device auth", async () => {
await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter");
// step 1: auth view // step 1: auth view
await expect(meterModal.getByLabel("Region")).toBeVisible();
await expect(meterModal.getByLabel("Server")).toBeVisible(); await expect(meterModal.getByLabel("Server")).toBeVisible();
await expect(meterModal.getByLabel("Token")).toBeVisible();
await expect(meterModal.getByLabel("Power")).not.toBeVisible(); await expect(meterModal.getByLabel("Power")).not.toBeVisible();
await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible();
await expect(meterModal.getByRole("button", { name: "Save" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Save" })).not.toBeVisible();
await meterModal.getByLabel("Server").selectOption("EU"); await meterModal.getByLabel("Region").selectOption("EU");
await meterModal.getByLabel("Token").fill("test-token-123"); await meterModal.getByLabel("Server").fill("my.server.org");
await meterModal.getByRole("button", { name: "Prepare connection" }).click(); await meterModal.getByRole("button", { name: "Prepare connection" }).click();
await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible(); 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 // step 2: show regular device form
await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); await expect(meterModal.getByLabel("Region")).toHaveValue("EU");
await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); await expect(meterModal.getByLabel("Server")).toHaveValue("my.server.org");
await expect(meterModal.getByLabel("Power")).toBeVisible(); await expect(meterModal.getByLabel("Power")).toBeVisible();
await meterModal.getByLabel("Power").fill("5000"); await meterModal.getByLabel("Power").fill("5000");
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
@ -69,8 +69,8 @@ test.describe("config device auth", async () => {
// re-open meter for editing // re-open meter for editing
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); await expect(meterModal.getByLabel("Region")).toHaveValue("EU");
await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); await expect(meterModal.getByLabel("Server")).toHaveValue("my.server.org");
await expect(meterModal.getByLabel("Power")).toHaveValue("5000"); await expect(meterModal.getByLabel("Power")).toHaveValue("5000");
await expect(meterModal.getByRole("button", { name: "Prepare connection" })).not.toBeVisible(); await expect(meterModal.getByRole("button", { name: "Prepare connection" })).not.toBeVisible();
await expect(meterModal.getByRole("button", { name: "Validate & save" })).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 // re-open meter for editing after restart, auth status as to be reestablished
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Server")).toHaveValue("EU"); await expect(meterModal.getByLabel("Region")).toHaveValue("EU");
await expect(meterModal.getByLabel("Token")).toHaveValue("test-token-123"); await expect(meterModal.getByLabel("Server")).toHaveValue("my.server.org");
await expect(meterModal.getByLabel("Power")).not.toBeVisible(); await expect(meterModal.getByLabel("Power")).not.toBeVisible();
// note: prepare connection step is auto-executed, since all required fields (server, token) are already present // 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(); await expect(meterModal.getByRole("link", { name: "Connect with localhost" })).toBeVisible();

View file

@ -1,9 +1,18 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc"; import { start, stop, restart, baseUrl } from "./evcc";
import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
import { enableExperimental, expectModalVisible, expectModalHidden } from "./utils"; import { enableExperimental, expectModalVisible, expectModalHidden } from "./utils";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await startSimulator();
});
test.afterAll(async () => {
await stopSimulator();
});
test.afterEach(async () => { test.afterEach(async () => {
await stop(); await stop();
}); });
@ -47,16 +56,18 @@ test.describe("issue creation", () => {
// Enable experimental features // Enable experimental features
await enableExperimental(page, false); await enableExperimental(page, false);
// Create a battery meter // Create a Shelly meter with username (to test private data redaction)
await page.getByRole("button", { name: "Add solar or battery" }).click(); await page.getByRole("button", { name: "Add grid meter" }).click();
await page.getByRole("button", { name: "Add battery meter" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await meterModal.getByLabel("Title").fill("BigBlueBattery"); await meterModal.getByLabel("Manufacturer").selectOption("Shelly 1PM");
await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); 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 meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
await expect(page.getByTestId("battery")).toBeVisible(); await expect(page.getByTestId("grid")).toBeVisible();
// Restart to apply changes // Restart to apply changes
await restart(CONFIG); await restart(CONFIG);
@ -77,7 +88,7 @@ test.describe("issue creation", () => {
.fill("This is a test issue created from the config page workflow"); .fill("This is a test issue created from the config page workflow");
await page await page
.getByLabel("Steps to reproduce") .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 // check yaml data
const yamlItem = page.getByTestId("issueYamlConfig-additional-item"); const yamlItem = page.getByTestId("issueYamlConfig-additional-item");
@ -88,12 +99,19 @@ test.describe("issue creation", () => {
await yamlModal.getByRole("button", { name: "Close" }).first().click(); await yamlModal.getByRole("button", { name: "Close" }).first().click();
await expectModalHidden(yamlModal); await expectModalHidden(yamlModal);
// check ui data // check ui data and verify private data redaction
const uiItem = page.getByTestId("issueUiConfig-additional-item"); const uiItem = page.getByTestId("issueUiConfig-additional-item");
await uiItem.getByRole("button", { name: "show details" }).click(); await uiItem.getByRole("button", { name: "show details" }).click();
const uiModal = page.getByTestId("issueUiConfig-modal"); const uiModal = page.getByTestId("issueUiConfig-modal");
await expectModalVisible(uiModal); 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 uiModal.getByRole("button", { name: "Close" }).first().click();
await expectModalHidden(uiModal); await expectModalHidden(uiModal);
@ -133,7 +151,7 @@ test.describe("issue creation", () => {
await expect(textarea).toBeVisible(); await expect(textarea).toBeVisible();
const textareaContent = await textarea.inputValue(); const textareaContent = await textarea.inputValue();
expect(textareaContent).toContain("carport_pv"); // from evcc.yaml 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("DEBUG"); // from logs
expect(textareaContent).toContain('"telemetry":'); // from state expect(textareaContent).toContain('"telemetry":'); // from state
@ -142,7 +160,7 @@ test.describe("issue creation", () => {
.getByRole("link", { name: "Create GitHub Issue" }) .getByRole("link", { name: "Create GitHub Issue" })
.getAttribute("href"); .getAttribute("href");
expect(href).toContain("https://github.com/evcc-io/evcc/issues/new?title=Kaboom&body="); 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 expect(href).not.toContain("carport_pv"); // from evcc.yaml
// close modal // close modal
@ -167,7 +185,7 @@ test.describe("issue creation", () => {
.getByRole("link", { name: "Create GitHub Issue" }) .getByRole("link", { name: "Create GitHub Issue" })
.getAttribute("href"); .getAttribute("href");
expect(href).toContain("https://github.com/evcc-io/evcc/issues/new?title=Kaboom&body="); 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("carport_pv"); // from evcc.yaml
expect(href).toContain("DEBUG"); // from logs expect(href).toContain("DEBUG"); // from logs
expect(href).toContain("MyFancyState"); // from state expect(href).toContain("MyFancyState"); // from state

View file

@ -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
}

64
util/redact/redactor.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -43,6 +43,7 @@ params:
choice: ["https", "http"] choice: ["https", "http"]
default: https default: https
- name: user - name: user
private: true
description: description:
de: Benutzerkonto de: Benutzerkonto
en: Username en: Username
@ -76,6 +77,7 @@ params:
usages: ["pv"] usages: ["pv"]
advanced: true advanced: true
- name: vin - name: vin
private: true
description: description:
de: Fahrzeugidentifikationsnummer de: Fahrzeugidentifikationsnummer
en: Vehicle Identification Number en: Vehicle Identification Number
@ -247,6 +249,7 @@ params:
- name: ski - name: ski
required: true required: true
private: true
description: description:
en: Subject Key Identifier (SKI) en: Subject Key Identifier (SKI)
de: Identifikationsschlüssel (SKI) de: Identifikationsschlüssel (SKI)
@ -254,16 +257,18 @@ params:
en: Usually found on the web interface of the wallbox en: Usually found on the web interface of the wallbox
de: Üblicherweise im Web Interface der Wallbox zu finden de: Üblicherweise im Web Interface der Wallbox zu finden
- name: uri - name: uri
private: true
description: description:
generic: URI generic: URI
help: help:
en: HTTP(S) address en: HTTP(S) address
de: HTTP(S) Adresse de: HTTP(S) Adresse
- name: url - name: url
private: true
description: description:
generic: URL generic: URL
- name: ain - name: ain
mask: true private: true
example: "307788992233" example: "307788992233"
description: description:
en: Actor Identification Number (AIN) en: Actor Identification Number (AIN)
@ -340,12 +345,15 @@ params:
advanced: true advanced: true
- name: accesstoken - name: accesstoken
mask: true
description: description:
generic: Access token generic: Access token
- name: refreshtoken - name: refreshtoken
mask: true
description: description:
generic: Refresh token generic: Refresh token
- name: serial - name: serial
private: true
description: description:
en: Serial en: Serial
de: Seriennummer de: Seriennummer
@ -360,9 +368,11 @@ params:
description: description:
generic: Client ID generic: Client ID
- name: clientsecret - name: clientsecret
mask: true
description: description:
generic: Client Secret generic: Client Secret
- name: token - name: token
mask: true
description: description:
generic: Token generic: Token
- name: interval - name: interval
@ -370,13 +380,16 @@ params:
de: Intervall de: Intervall
en: Interval en: Interval
- name: mac - name: mac
private: true
description: description:
en: MAC Address en: MAC Address
de: MAC Adresse de: MAC Adresse
- name: pin - name: pin
mask: true
description: description:
generic: PIN generic: PIN
- name: serial_number - name: serial_number
private: true
description: description:
en: Serial Number en: Serial Number
de: Seriennummer de: Seriennummer
@ -384,9 +397,11 @@ params:
description: description:
generic: Watchdog generic: Watchdog
- name: uuid - name: uuid
private: true
description: description:
generic: UUID generic: UUID
- name: zip - name: zip
private: true
description: description:
en: ZIP code en: ZIP code
de: Postleitzahl de: Postleitzahl
@ -420,12 +435,14 @@ params:
de: Temperaturquelle de: Temperaturquelle
en: Temperature source en: Temperature source
- name: lat - name: lat
private: true
description: description:
en: Latitude en: Latitude
de: Breitengrad de: Breitengrad
type: float type: float
example: 55.7351 example: 55.7351
- name: lon - name: lon
private: true
description: description:
en: Longitude en: Longitude
de: Längengrad 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) 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 - name: idtag
advanced: true advanced: true
private: true
type: string type: string
description: description:
en: Authentication token en: Authentication token

View file

@ -62,6 +62,11 @@ func (t *Template) Validate() error {
continue 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") == "" { if p.Description.String("en") == "" || p.Description.String("de") == "" {
return fmt.Errorf("param %s: description can't be empty", p.Name) return fmt.Errorf("param %s: description can't be empty", p.Name)
} }

View file

@ -189,6 +189,7 @@ type Param struct {
Preset string `json:"-"` // Reference a predefined set of params Preset string `json:"-"` // Reference a predefined set of params
Required bool `json:",omitempty"` // cli if the user has to provide a non empty value 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 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. 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 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 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 return p.Mask
} }
func (p *Param) IsPrivate() bool {
return p.Private
}
func (p *Param) IsRequired() bool { func (p *Param) IsRequired() bool {
return p.Required return p.Required
} }