Issue UI: redact private data like (user, locations, ...) (#25039)
This commit is contained in:
parent
f8dcd4dd78
commit
a65a1ce2d7
23 changed files with 277 additions and 109 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ params:
|
|||
required: true
|
||||
- name: pin
|
||||
required: true
|
||||
mask: true
|
||||
render: |
|
||||
type: nrgkick-bluetooth
|
||||
mac: {{ .mac }}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ params:
|
|||
- name: usage
|
||||
choice: ["grid"]
|
||||
- name: token
|
||||
mask: true
|
||||
required: true
|
||||
example: 5K4MVS-OjfWhK_4yrjOlFe1F6kJXPVf7eQYggo8ebAE
|
||||
- name: homeid
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
64
util/redact/redactor.go
Normal 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
|
||||
}
|
||||
87
util/redact/redactor_test.go
Normal file
87
util/redact/redactor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue