Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1501b8b
Initial plan
Copilot Nov 4, 2025
0101e2e
Add private data masking support for bug reporter
Copilot Nov 4, 2025
5a99f81
Add e2e test for private data redaction in device configs
Copilot Nov 4, 2025
7e86b2c
Refine e2e test for better compatibility
Copilot Nov 4, 2025
8de4b86
Address feedback: keep host/ip public, mark ski as private
Copilot Nov 4, 2025
f1a77b5
Fix e2e test: use semantic selectors and Shelly meter for private dat…
Copilot Nov 4, 2025
f3dac0a
Mark lat/lon as private and accesstoken/refreshtoken as masked
Copilot Nov 4, 2025
255f464
Use simulator for Shelly meter test in issue.spec.ts
Copilot Nov 4, 2025
e9fbe56
Improve comment style in Param struct
Copilot Nov 4, 2025
a0686b8
Remove advanced settings toggle and fix comment style
Copilot Nov 4, 2025
4b95c7e
fix test
naltatis Nov 4, 2025
7046787
clean
naltatis Nov 4, 2025
4aea91b
remove redundancy; use template params also for yaml redact
naltatis Nov 25, 2025
d6d19ef
Merge branch 'master' into copilot/mask-private-data-in-bug-reporter
naltatis Nov 25, 2025
d1086b6
fmt
naltatis Nov 25, 2025
d2b7e09
wip
andig Nov 25, 2025
050273c
minimize diff; shorten fixed values
naltatis Nov 25, 2025
ef386cf
Simplify
andig Nov 25, 2025
abe5330
wip
andig Nov 25, 2025
86f2017
Apply suggestion from @andig
andig Nov 25, 2025
9bb2ab1
pin > mask
naltatis Nov 25, 2025
e05abf9
lint
naltatis Nov 25, 2025
c6ea580
Merge branch 'master' into copilot/mask-private-data-in-bug-reporter
naltatis Nov 26, 2025
d91a4ea
fix assertion; adjust test
naltatis Nov 26, 2025
28789a6
unmask token for e2e test
naltatis Nov 26, 2025
96a63f9
adjust e2e example
naltatis Nov 26, 2025
3e75079
Simplify
andig Nov 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion assets/js/views/Issue.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
34 changes: 20 additions & 14 deletions server/http_config_device_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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{
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions server/http_config_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions server/http_global_settings_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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 = util.RedactConfigString(res)
}

jsonWrite(w, res)
}
}
Expand Down
42 changes: 30 additions & 12 deletions tests/issue.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Expand Down Expand Up @@ -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("[email protected]");
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);
Expand All @@ -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");
Expand All @@ -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("[email protected]"); // 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);

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
38 changes: 25 additions & 13 deletions util/config_redactor.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package util

//go:generate go run redact_gen.go

import (
"fmt"
"maps"
Expand All @@ -9,18 +11,24 @@ import (
)

// 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 configRedactSecrets []string

var configRedactRegex = regexp.MustCompile(fmt.Sprintf(`(?i)\b(%s)\b.*?:.*`, strings.Join(configRedactSecrets, "|")))
var configRedactRegex *regexp.Regexp

func init() {
// fields that are not covered by template params (yet)
additional := []string{
"sponsortoken", "plant", // global settings
"access", "refresh", "secret", // tokens not in params
"deviceid", "machineid", "idtag", // devices
"app", "chats", "recipients", // push messaging
}

// Combine generated params with additional fields
configRedactSecrets = slices.Concat(generatedRedactParams, additional)

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 {
Expand All @@ -31,8 +39,12 @@ func RedactConfigString(src string) string {
func RedactConfigMap(src map[string]any) map[string]any {
res := maps.Clone(src)
for k := range res {
if slices.Contains(configRedactSecrets, k) {
res[k] = "*****"
for _, secret := range configRedactSecrets {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Du meinst slices.ContainsFunc ;)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Siehe

	if !slices.ContainsFunc(customTypes, func(s string) bool {
		return strings.EqualFold(res.Type, s)
	}) {
		return configReq{}, errors.New("invalid config: yaml only allowed for types " + strings.Join(customTypes, ", "))
	}

// ignore case
if strings.EqualFold(k, secret) {
res[k] = "*****"
break
}
}
}
return res
Expand Down
Loading
Loading