From 9c9bd16d30d35ab7429f8206aea089052a9a250a Mon Sep 17 00:00:00 2001 From: CTO Date: Sun, 2 Aug 2026 13:15:00 +0000 Subject: [PATCH 1/2] fix(plugins): mask plugin-config secrets on read, lossless masked round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/plugins/:pluginId/config returned the stored config row verbatim to any board actor holding one company membership, while writing it required instance admin (BLO-20794). Every plugin credential kept inline in plugin_config.config_json was readable that way — including the production Alertmanager bearer. Two changes at the generic route boundary: - Authority: GET now requires instance admin, matching POST. config/test moves with it, because it restores masked-out stored secrets before handing the config to the worker. - Masking: a new plugin-config-masking service replaces secret-bearing values with `__redacted__` on the way out, and restores the stored value when an unchanged masked payload is posted back, so the round-trip is lossless and the sentinel is never persisted. Secret *pointers* are preserved (minus any resolved plaintext riding along) so the config form still renders bindings. A field is secret-bearing when the manifest declares it — `format: "secret-ref"`, the standard `writeOnly: true`, or the new `x-paperclip-secret: true`, which lets an ordinary string field be covered without moving it to the currently-unusable secret-ref path (BLO-20219) — or when its key name reads as a credential and the manifest has not opted out with `x-paperclip-secret: false`. The heuristic is what covers `webhookToken` today, since that manifest cannot be edited while #924 is live in it. Masking is applied at the route only. The worker bridge, bootstrap and host services keep reading registry.getConfig() directly and still get plaintext. Refs BLO-20871, BLO-20794. Co-Authored-By: Claude --- packages/shared/src/types/plugin.ts | 15 + .../__tests__/plugin-config-masking.test.ts | 256 +++++++++++++ .../src/__tests__/plugin-routes-authz.test.ts | 251 +++++++++++++ server/src/routes/openapi.ts | 9 + server/src/routes/plugins.ts | 79 +++- server/src/services/plugin-config-masking.ts | 340 ++++++++++++++++++ 6 files changed, 938 insertions(+), 12 deletions(-) create mode 100644 server/src/__tests__/plugin-config-masking.test.ts create mode 100644 server/src/services/plugin-config-masking.ts diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 0c090d7acf1..9e4f8588eaa 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -54,6 +54,21 @@ export type JsonSchema = { * `x-paperclip-advanced` is not true. */ "x-paperclip-group"?: string; + /** + * Marks a property as secret-bearing, so the host masks its value in every + * config API response and restores the stored value when an unchanged masked + * payload is posted back (BLO-20794). + * + * Use this to cover an ordinary `type: "string"` credential without moving it + * to `format: "secret-ref"`. The standard `writeOnly: true` keyword is + * honoured identically; prefer this marker when `writeOnly`'s other form + * semantics are unwanted. + * + * Setting it to `false` opts the property out of the host's key-name + * heuristic — use it for a field whose name looks credential-ish (`token`, + * `secret`, …) but whose value is not sensitive. + */ + "x-paperclip-secret"?: boolean; [key: string]: unknown; }; diff --git a/server/src/__tests__/plugin-config-masking.test.ts b/server/src/__tests__/plugin-config-masking.test.ts new file mode 100644 index 00000000000..bccf763800c --- /dev/null +++ b/server/src/__tests__/plugin-config-masking.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { + PLUGIN_CONFIG_SECRET_MASK, + collectSecretBearingPaths, + maskPluginConfigJson, + mergeMaskedPluginConfig, +} from "../services/plugin-config-masking.js"; + +const SECRET = "super-secret-bearer-value"; +const SECRET_ID = "77777777-7777-4777-8777-777777777777"; + +describe("collectSecretBearingPaths", () => { + it("collects every declaration marker and records explicit exemptions", () => { + const { secret, exempt } = collectSecretBearingPaths({ + type: "object", + properties: { + refField: { type: "string", format: "secret-ref" }, + writeOnlyField: { type: "string", writeOnly: true }, + markedField: { type: "string", "x-paperclip-secret": true }, + exemptField: { type: "string", "x-paperclip-secret": false }, + plainField: { type: "string" }, + }, + }); + + expect([...secret].sort()).toEqual(["markedField", "refField", "writeOnlyField"]); + expect([...exempt]).toEqual(["exemptField"]); + }); + + it("walks nested properties and composition keywords", () => { + const { secret } = collectSecretBearingPaths({ + type: "object", + properties: { + auth: { + type: "object", + properties: { password: { type: "string", writeOnly: true } }, + }, + }, + allOf: [ + { + properties: { extraToken: { type: "string", "x-paperclip-secret": true } }, + }, + ], + }); + + expect([...secret].sort()).toEqual(["auth.password", "extraToken"]); + }); + + it("returns empty sets for a missing or non-object schema", () => { + expect(collectSecretBearingPaths(undefined).secret.size).toBe(0); + expect(collectSecretBearingPaths(null).exempt.size).toBe(0); + }); +}); + +describe("maskPluginConfigJson", () => { + it("masks a declared secret and leaves non-secret fields intact", () => { + const masked = maskPluginConfigJson( + { webhookToken: SECRET, endpoint: "https://alerts.example.com", timeoutMs: 5000, enabled: true }, + { + type: "object", + properties: { + webhookToken: { type: "string", writeOnly: true }, + endpoint: { type: "string" }, + timeoutMs: { type: "number" }, + enabled: { type: "boolean" }, + }, + }, + ); + + expect(masked).toEqual({ + webhookToken: PLUGIN_CONFIG_SECRET_MASK, + endpoint: "https://alerts.example.com", + timeoutMs: 5000, + enabled: true, + }); + }); + + it("masks a raw value sitting at a secret-ref path", () => { + // The live shape from BLO-20219: the secret-ref path is unusable, so the + // field holds the credential inline. + const masked = maskPluginConfigJson( + { webhookTokenRef: SECRET }, + { type: "object", properties: { webhookTokenRef: { type: "string", format: "secret-ref" } } }, + ); + + expect(masked).toEqual({ webhookTokenRef: PLUGIN_CONFIG_SECRET_MASK }); + }); + + it("preserves a secret_ref pointer but strips resolved plaintext riding along", () => { + const masked = maskPluginConfigJson( + { + apiKeyRef: { type: "secret_ref", secretId: SECRET_ID, version: "latest", value: SECRET }, + }, + { type: "object", properties: { apiKeyRef: { type: "string", format: "secret-ref" } } }, + ); + + expect(masked).toEqual({ + apiKeyRef: { type: "secret_ref", secretId: SECRET_ID, version: "latest" }, + }); + expect(JSON.stringify(masked)).not.toContain(SECRET); + }); + + it("preserves a legacy bare-UUID binding at a secret-ref path", () => { + const masked = maskPluginConfigJson( + { apiKeyRef: SECRET_ID }, + { type: "object", properties: { apiKeyRef: { type: "string", format: "secret-ref" } } }, + ); + + expect(masked).toEqual({ apiKeyRef: SECRET_ID }); + }); + + it("masks a credential-named string the manifest never declared", () => { + // The BLO-20794 case: `webhookToken` is `type: "string"` with no marker, and + // undeclared keys can appear in config_json at all. + const masked = maskPluginConfigJson( + { webhookToken: SECRET, clientSecret: SECRET, baseUrl: "https://example.com" }, + { type: "object", properties: { baseUrl: { type: "string" } } }, + ); + + expect(masked).toEqual({ + webhookToken: PLUGIN_CONFIG_SECRET_MASK, + clientSecret: PLUGIN_CONFIG_SECRET_MASK, + baseUrl: "https://example.com", + }); + }); + + it("honours an explicit x-paperclip-secret:false opt-out of the name heuristic", () => { + const masked = maskPluginConfigJson( + { tokenStrategy: "oauth" }, + { type: "object", properties: { tokenStrategy: { type: "string", "x-paperclip-secret": false } } }, + ); + + expect(masked).toEqual({ tokenStrategy: "oauth" }); + }); + + it("does not mask non-credential field names or non-string values", () => { + const masked = maskPluginConfigJson( + { baseUrl: "https://example.com", maxTokens: 4096, region: "us-east-1" }, + undefined, + ); + + expect(masked).toEqual({ baseUrl: "https://example.com", maxTokens: 4096, region: "us-east-1" }); + }); + + it("masks nested declared secrets", () => { + const masked = maskPluginConfigJson( + { auth: { username: "svc", password: SECRET } }, + { + type: "object", + properties: { + auth: { + type: "object", + properties: { username: { type: "string" }, password: { type: "string", writeOnly: true } }, + }, + }, + }, + ); + + expect(masked).toEqual({ auth: { username: "svc", password: PLUGIN_CONFIG_SECRET_MASK } }); + }); + + it("masks a declared secret whatever its shape, so an odd value cannot slip through", () => { + const masked = maskPluginConfigJson( + { creds: { nested: SECRET } }, + { type: "object", properties: { creds: { writeOnly: true } } }, + ); + + expect(masked).toEqual({ creds: PLUGIN_CONFIG_SECRET_MASK }); + expect(JSON.stringify(masked)).not.toContain(SECRET); + }); + + it("leaves null and undefined declared secrets alone", () => { + const schema = { type: "object", properties: { token: { type: "string", writeOnly: true } } }; + expect(maskPluginConfigJson({ token: null }, schema)).toEqual({ token: null }); + }); + + it("returns non-object input unchanged", () => { + expect(maskPluginConfigJson(null)).toBeNull(); + expect(maskPluginConfigJson("nope")).toBe("nope"); + }); +}); + +describe("mergeMaskedPluginConfig", () => { + it("restores the stored secret when the mask is posted back unchanged", () => { + const merged = mergeMaskedPluginConfig( + { webhookToken: PLUGIN_CONFIG_SECRET_MASK, endpoint: "https://new.example.com" }, + { webhookToken: SECRET, endpoint: "https://old.example.com" }, + ); + + expect(merged).toEqual({ webhookToken: SECRET, endpoint: "https://new.example.com" }); + }); + + it("accepts a genuinely new secret value", () => { + const merged = mergeMaskedPluginConfig({ webhookToken: "rotated" }, { webhookToken: SECRET }); + + expect(merged).toEqual({ webhookToken: "rotated" }); + }); + + it("drops the sentinel rather than persisting it when nothing is stored", () => { + const merged = mergeMaskedPluginConfig({ webhookToken: PLUGIN_CONFIG_SECRET_MASK }, {}); + + expect(merged).toEqual({}); + expect(JSON.stringify(merged)).not.toContain(PLUGIN_CONFIG_SECRET_MASK); + }); + + it("never persists the sentinel when storage is missing entirely", () => { + expect(mergeMaskedPluginConfig({ token: PLUGIN_CONFIG_SECRET_MASK }, null)).toEqual({}); + }); + + it("restores nested secrets", () => { + const merged = mergeMaskedPluginConfig( + { auth: { username: "svc", password: PLUGIN_CONFIG_SECRET_MASK } }, + { auth: { username: "svc", password: SECRET } }, + ); + + expect(merged).toEqual({ auth: { username: "svc", password: SECRET } }); + }); + + it("restores secrets inside arrays and drops unrestorable sentinels", () => { + const merged = mergeMaskedPluginConfig( + { targets: [{ token: PLUGIN_CONFIG_SECRET_MASK }, { token: "explicit" }], keys: [PLUGIN_CONFIG_SECRET_MASK] }, + { targets: [{ token: SECRET }, { token: "old" }], keys: [] }, + ); + + expect(merged).toEqual({ targets: [{ token: SECRET }, { token: "explicit" }], keys: [] }); + }); + + it("does not resurrect a key the caller deliberately removed", () => { + const merged = mergeMaskedPluginConfig({ endpoint: "https://example.com" }, { endpoint: "https://example.com", staleToken: SECRET }); + + expect(merged).toEqual({ endpoint: "https://example.com" }); + }); + + it("round-trips a masked read losslessly", () => { + const schema = { + type: "object", + properties: { + webhookToken: { type: "string", writeOnly: true }, + apiKeyRef: { type: "string", format: "secret-ref" }, + endpoint: { type: "string" }, + }, + }; + const stored = { + webhookToken: SECRET, + apiKeyRef: { type: "secret_ref", secretId: SECRET_ID, version: "latest" }, + endpoint: "https://alerts.example.com", + }; + + const masked = maskPluginConfigJson(stored, schema) as Record; + expect(JSON.stringify(masked)).not.toContain(SECRET); + + // The client posts the masked payload straight back, unmodified. + const merged = mergeMaskedPluginConfig(JSON.parse(JSON.stringify(masked)), stored); + + expect(merged).toEqual(stored); + }); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index bb38c7681a6..6149b07fe80 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -7,6 +7,7 @@ const mockRegistry = vi.hoisted(() => ({ getById: vi.fn(), getByKey: vi.fn(), listByStatus: vi.fn(), + getConfig: vi.fn(), upsertConfig: vi.fn(), getCompanySettings: vi.fn(), upsertCompanySettings: vi.fn(), @@ -1460,3 +1461,253 @@ describe.sequential("GET /api/plugins/alerts/plugin-health", () => { expect(mockRegistry.listByStatus).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// BLO-20794 / BLO-20871 — plugin config secret masking and masked round-trip +// --------------------------------------------------------------------------- + +const CONFIG_SECRET = "sentinel-live-bearer-do-not-leak"; + +/** Manifest shape under test: one declared secret, one plain field. */ +const maskingSchema = { + type: "object", + properties: { + webhookToken: { type: "string", writeOnly: true }, + // Declared without `type` on purpose. A manifest that writes + // `type: "string", format: "secret-ref"` makes Ajv reject the pointer + // object form outright — that is the pre-existing BLO-20219 defect and is + // out of scope here; constraining it would only test that bug. + apiKeyRef: { format: "secret-ref" }, + endpoint: { type: "string" }, + }, +}; + +function maskingPlugin(schema: Record | undefined = maskingSchema) { + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey: "paperclip.example", + version: "1.0.0", + status: "ready", + manifestJson: schema ? { instanceConfigSchema: schema } : {}, + }); +} + +/** + * Back the registry config methods with a mutable store so a GET → POST → read + * sequence exercises real persistence semantics rather than a fixed stub. The + * value left in `store.configJson` after a POST is exactly what `upsertConfig` + * writes to `plugin_config.config_json`. + */ +function seedConfigStore(configJson: Record) { + const store: { configJson: Record } = { configJson: structuredClone(configJson) }; + mockRegistry.getConfig.mockImplementation(async () => ({ + id: "config-1", + pluginId, + companyId: companyA, + configJson: store.configJson, + })); + mockRegistry.upsertConfig.mockImplementation(async (_pluginId, _companyId, input) => { + store.configJson = input.configJson; + return { id: "config-1", pluginId, companyId: companyA, configJson: store.configJson }; + }); + return store; +} + +function adminActor() { + return { + type: "board", + userId: "admin-1", + source: "session", + isInstanceAdmin: true, + companyIds: [companyA], + }; +} + +describe.sequential("plugin config secret masking (BLO-20794)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRegistry.getConfig.mockReset(); + mockRegistry.upsertConfig.mockReset(); + ragHealthBucketCache.clear(); + mockSecretService.getById.mockResolvedValue({ id: secretId, companyId: companyA, status: "active" }); + mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]); + }); + + it("rejects a config read from a board org member who cannot write it", async () => { + maskingPlugin(); + seedConfigStore({ webhookToken: CONFIG_SECRET, endpoint: "https://alerts.example.com" }); + const { app } = await createApp(boardActor()); + + const res = await request(app).get(`/api/plugins/${pluginId}/config?companyId=${companyA}`); + + expect(res.status).toBe(403); + expect(mockRegistry.getConfig).not.toHaveBeenCalled(); + expect(JSON.stringify(res.body)).not.toContain(CONFIG_SECRET); + }, 20_000); + + it("rejects a config test from a board org member who cannot write it", async () => { + maskingPlugin(); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/config/test`) + .send({ companyId: companyA, configJson: { webhookToken: "__redacted__" } }); + + expect(res.status).toBe(403); + expect(mockRegistry.getConfig).not.toHaveBeenCalled(); + }, 20_000); + + it("never emits the stored secret to an authorized reader, and keeps non-secret fields intact", async () => { + maskingPlugin(); + seedConfigStore({ + webhookToken: CONFIG_SECRET, + apiKeyRef: { type: "secret_ref", secretId, version: "latest" }, + endpoint: "https://alerts.example.com", + }); + const { app } = await createApp(adminActor()); + + const res = await request(app).get(`/api/plugins/${pluginId}/config?companyId=${companyA}`); + + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).not.toContain(CONFIG_SECRET); + expect(res.body.configJson.webhookToken).toBe("__redacted__"); + // Non-secret field survives — guards against blanking the whole response. + expect(res.body.configJson.endpoint).toBe("https://alerts.example.com"); + // The pointer is not a secret and must keep rendering in the config form. + expect(res.body.configJson.apiKeyRef).toEqual({ type: "secret_ref", secretId, version: "latest" }); + }, 20_000); + + it("masks a credential-named field the manifest never declared", async () => { + maskingPlugin({ type: "object", properties: { endpoint: { type: "string" } } }); + seedConfigStore({ webhookToken: CONFIG_SECRET, endpoint: "https://alerts.example.com" }); + const { app } = await createApp(adminActor()); + + const res = await request(app).get(`/api/plugins/${pluginId}/config?companyId=${companyA}`); + + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).not.toContain(CONFIG_SECRET); + expect(res.body.configJson.endpoint).toBe("https://alerts.example.com"); + }, 20_000); + + it("preserves the stored secret when the masked response is posted back unchanged", async () => { + maskingPlugin(); + const store = seedConfigStore({ + webhookToken: CONFIG_SECRET, + apiKeyRef: { type: "secret_ref", secretId, version: "latest" }, + endpoint: "https://alerts.example.com", + }); + const { app } = await createApp(adminActor()); + + // 1. Read it masked. + const readRes = await request(app).get(`/api/plugins/${pluginId}/config?companyId=${companyA}`); + expect(readRes.status).toBe(200); + expect(readRes.body.configJson.webhookToken).toBe("__redacted__"); + + // 2. Post the exact masked payload back, unmodified — the UI's save path. + const writeRes = await request(app) + .post(`/api/plugins/${pluginId}/config`) + .send({ companyId: companyA, configJson: readRes.body.configJson }); + expect(writeRes.status).toBe(200); + + // 3. Read storage directly: the original secret must still be there. + expect(store.configJson.webhookToken).toBe(CONFIG_SECRET); + expect(JSON.stringify(store.configJson)).not.toContain("__redacted__"); + expect(store.configJson.endpoint).toBe("https://alerts.example.com"); + expect(store.configJson.apiKeyRef).toEqual({ type: "secret_ref", secretId, version: "latest" }); + + // The write response must not hand the secret back either. + expect(JSON.stringify(writeRes.body)).not.toContain(CONFIG_SECRET); + }, 20_000); + + it("persists a genuinely rotated secret instead of restoring the old one", async () => { + maskingPlugin(); + const store = seedConfigStore({ webhookToken: CONFIG_SECRET, endpoint: "https://alerts.example.com" }); + const { app } = await createApp(adminActor()); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/config`) + .send({ + companyId: companyA, + configJson: { webhookToken: "rotated-bearer", endpoint: "https://alerts.example.com" }, + }); + + expect(res.status).toBe(200); + expect(store.configJson.webhookToken).toBe("rotated-bearer"); + }, 20_000); + + it("drops the mask sentinel rather than persisting it when nothing is stored", async () => { + maskingPlugin(); + const store = seedConfigStore({}); + mockRegistry.getConfig.mockResolvedValue(null); + mockRegistry.upsertConfig.mockImplementation(async (_pluginId, _companyId, input) => { + store.configJson = input.configJson; + return { id: "config-1", pluginId, companyId: companyA, configJson: store.configJson }; + }); + const { app } = await createApp(adminActor()); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/config`) + .send({ companyId: companyA, configJson: { webhookToken: "__redacted__", endpoint: "https://x.example.com" } }); + + expect(res.status).toBe(200); + expect(store.configJson).toEqual({ endpoint: "https://x.example.com" }); + }, 20_000); + + it("restores the stored secret before validating against a constrained schema", async () => { + // `__redacted__` is 12 chars; a minLength of 20 proves the merge runs first. + maskingPlugin({ + type: "object", + properties: { webhookToken: { type: "string", writeOnly: true, minLength: 20 } }, + required: ["webhookToken"], + }); + const store = seedConfigStore({ webhookToken: CONFIG_SECRET }); + const { app } = await createApp(adminActor()); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/config`) + .send({ companyId: companyA, configJson: { webhookToken: "__redacted__" } }); + + expect(res.status).toBe(200); + expect(store.configJson.webhookToken).toBe(CONFIG_SECRET); + }, 20_000); + + it("hands the worker the restored secret, never the sentinel", async () => { + maskingPlugin(); + seedConfigStore({ webhookToken: CONFIG_SECRET, endpoint: "https://alerts.example.com" }); + const workerCall = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(adminActor(), {}, { + bridgeDeps: { workerManager: { isRunning: () => true, call: workerCall } }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/config`) + .send({ companyId: companyA, configJson: { webhookToken: "__redacted__", endpoint: "https://alerts.example.com" } }); + + expect(res.status).toBe(200); + expect(workerCall).toHaveBeenCalledWith( + pluginId, + "configChanged", + expect.objectContaining({ config: expect.objectContaining({ webhookToken: CONFIG_SECRET }) }), + ); + }, 20_000); + + it("tests an unchanged masked config against the stored secret", async () => { + maskingPlugin(); + seedConfigStore({ webhookToken: CONFIG_SECRET, endpoint: "https://alerts.example.com" }); + const workerCall = vi.fn().mockResolvedValue({ ok: true }); + const { app } = await createApp(adminActor(), {}, { + bridgeDeps: { workerManager: { isRunning: () => true, call: workerCall } }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/config/test`) + .send({ companyId: companyA, configJson: { webhookToken: "__redacted__", endpoint: "https://alerts.example.com" } }); + + expect(res.status).toBe(200); + expect(workerCall).toHaveBeenCalledWith( + pluginId, + "validateConfig", + { config: expect.objectContaining({ webhookToken: CONFIG_SECRET }) }, + ); + }, 20_000); +}); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 0870517a822..0ba788a7b7d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4956,6 +4956,9 @@ registry.registerPath({ path: "/api/plugins/{pluginId}/config", tags: ["plugins"], summary: "Get company-scoped plugin config", + description: + "Requires instance admin. Secret-bearing values are returned as `__redacted__`; " + + "posting the response back unchanged preserves the stored secret.", request: { params: z.object({ pluginId: z.string() }), query: z.object({ companyId: z.string() }), @@ -4968,6 +4971,9 @@ registry.registerPath({ path: "/api/plugins/{pluginId}/config", tags: ["plugins"], summary: "Set company-scoped plugin config", + description: + "Requires instance admin. A field sent as `__redacted__` keeps its stored value; " + + "the sentinel is never persisted.", request: { params: z.object({ pluginId: z.string() }), body: jsonBody(z.object({ companyId: z.string(), configJson: z.record(z.unknown()) })), @@ -4980,6 +4986,9 @@ registry.registerPath({ path: "/api/plugins/{pluginId}/config/test", tags: ["plugins"], summary: "Test company-scoped plugin config", + description: + "Requires instance admin. Restores masked (`__redacted__`) fields from stored config " + + "before handing them to the plugin worker.", request: { params: z.object({ pluginId: z.string() }), body: jsonBody(z.object({ companyId: z.string(), configJson: z.record(z.unknown()) })), diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index e6f1221b7b4..e26b0181f20 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -77,6 +77,10 @@ import { getActorInfo, } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; +import { + maskPluginConfigJson, + mergeMaskedPluginConfig, +} from "../services/plugin-config-masking.js"; import { findLocalFolderDeclaration, getStoredLocalFolders, @@ -2515,11 +2519,23 @@ export function pluginRoutes( * Returns the `PluginConfig` record if one exists, or `null` if the plugin * has not yet been configured. * + * **Authority (BLO-20794):** instance admin, matching the POST write side. + * This row holds live plugin credentials, so reading it is at least as + * sensitive as writing it; the previous `assertBoardOrgAccess` gate let any + * board actor with one company membership read every plugin's secrets. + * + * **Masking:** secret-bearing values are replaced with `__redacted__` before + * the row leaves the process — defence in depth behind the authority gate, + * and what keeps the secret off an admin's screen and out of a browser cache. + * Secret *pointers* survive intact so the config form can still render the + * binding. POST restores anything echoed back unchanged, so a read/write + * round-trip is lossless (see `plugin-config-masking.ts`). + * * Response: `PluginConfig | null` * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/config", async (req, res) => { - assertBoardOrgAccess(req); + assertInstanceAdmin(req); const { pluginId } = req.params; const companyId = requirePluginConfigCompanyId(req, req.query.companyId); @@ -2530,7 +2546,16 @@ export function pluginRoutes( } const config = await registry.getConfig(plugin.id, companyId); - res.json(config); + if (!config) { + res.json(config); + return; + } + + const schema = plugin.manifestJson?.instanceConfigSchema; + res.json({ + ...config, + configJson: maskPluginConfigJson(config.configJson, schema), + }); }); /** @@ -2577,11 +2602,22 @@ export function pluginRoutes( delete body.configJson.devUiUrl; } + // Restore any value the caller echoed back as the mask sentinel from the + // masked GET (BLO-20794). Must run before validation: `__redacted__` would + // otherwise fail a `pattern`/`minLength` constraint on the secret field, and + // must run before the secret-ref extraction so bindings are read from the + // real stored pointers. + const storedConfig = await registry.getConfig(plugin.id, companyId); + const configJson = mergeMaskedPluginConfig( + body.configJson, + storedConfig && typeof storedConfig === "object" ? storedConfig.configJson : null, + ); + // Validate configJson against the plugin's instanceConfigSchema (if declared). // This ensures CLI/API callers get the same validation the UI performs client-side. const schema = plugin.manifestJson?.instanceConfigSchema; if (schema && Object.keys(schema).length > 0) { - const validation = validateInstanceConfig(body.configJson, schema); + const validation = validateInstanceConfig(configJson, schema); if (!validation.valid) { res.status(400).json({ error: "Configuration does not match the plugin's instanceConfigSchema", @@ -2592,7 +2628,7 @@ export function pluginRoutes( } try { - const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema); + const secretRefs = extractSecretRefBindingsFromConfig(configJson, schema); await validatePluginSecretRefsForCompany(companyId, secretRefs); await secretService(db).syncSecretRefsForTarget( companyId, @@ -2603,14 +2639,14 @@ export function pluginRoutes( const result = await registry.upsertConfig(plugin.id, companyId, { companyId, - configJson: body.configJson, + configJson, }); await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, { pluginId: plugin.id, pluginKey: plugin.pluginKey, companyId, secretRefCount: secretRefs.length, - configKeyCount: Object.keys(body.configJson).length, + configKeyCount: Object.keys(configJson).length, }); // Notify the running worker about the config change (PLUGIN_SPEC §25.4.4). @@ -2622,7 +2658,7 @@ export function pluginRoutes( await bridgeDeps.workerManager.call( plugin.id, "configChanged", - { config: body.configJson, companyId }, + { config: configJson, companyId }, ); } catch (rpcErr) { if ( @@ -2641,7 +2677,13 @@ export function pluginRoutes( } } - res.json(result); + // Mask the echo for the same reason the GET is masked — the persisted row + // holds plaintext and this response would otherwise hand it straight back. + res.json( + result + ? { ...result, configJson: maskPluginConfigJson(result.configJson, schema) } + : result, + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); @@ -2669,7 +2711,11 @@ export function pluginRoutes( * - 502 if the worker is unavailable */ router.post("/plugins/:pluginId/config/test", async (req, res) => { - assertBoardOrgAccess(req); + // Instance admin, matching GET and POST on this resource (BLO-20794). This + // handler restores masked-out stored secrets before handing the config to + // the worker, so a lesser actor could otherwise post `__redacted__` and have + // the real credential exercised against a destination of their choosing. + assertInstanceAdmin(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -2698,10 +2744,19 @@ export function pluginRoutes( return; } + // Same masked round-trip as the save path: testing an unmodified config + // read back from the masked GET must exercise the stored secret, not the + // sentinel. + const storedTestConfig = await registry.getConfig(plugin.id, companyId); + const testConfigJson = mergeMaskedPluginConfig( + body.configJson, + storedTestConfig && typeof storedTestConfig === "object" ? storedTestConfig.configJson : null, + ); + // Fast schema-level rejection before hitting the worker RPC. const schema = plugin.manifestJson?.instanceConfigSchema; if (schema && Object.keys(schema).length > 0) { - const validation = validateInstanceConfig(body.configJson, schema); + const validation = validateInstanceConfig(testConfigJson, schema); if (!validation.valid) { res.status(400).json({ error: "Configuration does not match the plugin's instanceConfigSchema", @@ -2712,13 +2767,13 @@ export function pluginRoutes( } try { - const secretRefs = extractSecretRefBindingsFromConfig(body.configJson, schema); + const secretRefs = extractSecretRefBindingsFromConfig(testConfigJson, schema); await validatePluginSecretRefsForCompany(companyId, secretRefs); const result = await bridgeDeps.workerManager.call( plugin.id, "validateConfig", - { config: body.configJson }, + { config: testConfigJson }, ); // The worker returns PluginConfigValidationResult { ok, warnings?, errors? } diff --git a/server/src/services/plugin-config-masking.ts b/server/src/services/plugin-config-masking.ts new file mode 100644 index 00000000000..26f3fe45468 --- /dev/null +++ b/server/src/services/plugin-config-masking.ts @@ -0,0 +1,340 @@ +/** + * @fileoverview Masks secret-bearing plugin instance-config values on the way + * out of the operator-facing config API, and restores them on the way back in. + * + * `plugin_config.config_json` routinely holds live credentials — either as a + * `secret_ref` pointer or, while the secret-ref path is unusable (BLO-20219), + * as a raw inline string. `GET /api/plugins/:pluginId/config` used to return + * that row verbatim (BLO-20794), so every reader of the config API received the + * plaintext. + * + * Two operations make up the contract: + * + * - {@link maskPluginConfigJson} replaces secret-bearing plaintext with + * {@link PLUGIN_CONFIG_SECRET_MASK}. Secret *pointers* are preserved (reduced + * to their schema-owned fields) because a pointer carries no plaintext and the + * config form needs it to keep rendering the binding. + * - {@link mergeMaskedPluginConfig} takes a posted config and restores the + * stored value anywhere the caller echoed the mask back unchanged. This makes + * a masked read → unmodified write round-trip lossless, and guarantees the + * sentinel itself is never persisted over a real secret. + * + * Masking happens at the route boundary only. Internal consumers — the worker + * bridge, bootstrap, host services — keep reading `registry.getConfig()` + * directly and still receive plaintext, which is what makes the plugin work. + * + * @module server/services/plugin-config-masking + */ + +import { envBindingSecretRefSchema, envBindingUserSecretRefSchema } from "@paperclipai/shared"; +import { isUuidSecretRef } from "./json-schema-secret-refs.js"; + +/** + * Sentinel returned in place of a secret-bearing value. Chosen to be visually + * obvious in the config form and impossible to confuse with a real credential. + * + * A posted value equal to this sentinel is always treated as "unchanged" and is + * never written to storage — see {@link mergeMaskedPluginConfig}. + */ +export const PLUGIN_CONFIG_SECRET_MASK = "__redacted__"; + +/** + * Word tokens that make a field credential-bearing on their own. + * + * This heuristic exists because a manifest cannot be assumed correct: the field + * that prompted BLO-20794 (`webhookToken`) is declared `type: "string"` with no + * secret marker at all, and plugins may write keys that appear in no schema. + * Manifest authors who own a matching field that is genuinely not a secret can + * opt out with `x-paperclip-secret: false`. + * + * Deliberately narrower than the log-redaction heuristic in `redaction.ts`: + * over-masking is free in a log line, but here it would put the sentinel in + * front of an operator editing real configuration. In particular `baseUrl` and + * a bare `key` are *not* matched. + */ +const SECRET_WORDS = new Set([ + "token", + "tokens", + "secret", + "secrets", + "password", + "passwords", + "passwd", + "passphrase", + "credential", + "credentials", + "bearer", + "authorization", + "jwt", +]); + +/** Two-word combinations that are credential-bearing only together. */ +const SECRET_WORD_PAIRS = new Set([ + "api key", + "access key", + "private key", + "signing key", + "secret key", + "encryption key", + "client key", + "consumer key", +]); + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Split a config key into lowercase words, handling camelCase, snake_case and + * kebab-case alike — `webhookToken`, `webhook_token` and `WEBHOOK-TOKEN` all + * yield `["webhook", "token"]`. + */ +function splitKeyWords(key: string): string[] { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()); +} + +function matchesSecretFieldName(key: string): boolean { + const words = splitKeyWords(key); + if (words.some((word) => SECRET_WORDS.has(word))) return true; + for (let index = 0; index < words.length - 1; index += 1) { + if (SECRET_WORD_PAIRS.has(`${words[index]} ${words[index + 1]}`)) return true; + } + return false; +} + +/** + * A `secret_ref` / `user_secret_ref` binding is a pointer, not a secret. Keep + * only its schema-owned fields so a stray `value` — resolved plaintext riding + * along with the pointer — cannot survive the mask. + * + * Returns `null` when the object is not a well-formed pointer, in which case + * the caller must treat it as opaque and mask it. + */ +function sanitizeSecretPointer(value: Record): Record | null { + const asSecretRef = envBindingSecretRefSchema.safeParse(value); + if (asSecretRef.success) { + const data = asSecretRef.data; + const pointer: Record = { type: data.type, secretId: data.secretId }; + for (const key of ["version", "projectionClass", "projectionAllowlistKey"] as const) { + if (key in value && data[key] !== undefined) pointer[key] = data[key]; + } + return pointer; + } + + const asUserSecretRef = envBindingUserSecretRefSchema.safeParse(value); + if (asUserSecretRef.success) { + const data = asUserSecretRef.data; + const pointer: Record = { type: data.type, key: data.key }; + for (const key of ["version", "required", "allowMissingOverride"] as const) { + if (key in value && data[key] !== undefined) pointer[key] = data[key]; + } + return pointer; + } + + return null; +} + +function isSecretPointerCandidate(value: unknown): value is Record { + return isPlainRecord(value) && (value.type === "secret_ref" || value.type === "user_secret_ref"); +} + +/** + * Whether a schema node declares its value secret-bearing. + * + * Three markers are honoured, so a plain string field can be covered without + * being converted to the (currently unusable) `secret-ref` path: + * + * - `format: "secret-ref"` — the existing pointer-valued declaration. + * - `writeOnly: true` — the standard JSON Schema / OpenAPI keyword meaning + * "may be sent by the client, must not be returned in responses". + * - `x-paperclip-secret: true` — explicit Paperclip marker for authors who do + * not want `writeOnly`'s other UI implications. + */ +function declaresSecret(node: Record): boolean { + return ( + node.format === "secret-ref" || + node.writeOnly === true || + node["x-paperclip-secret"] === true + ); +} + +export interface PluginConfigSecretPaths { + /** Dot-paths the manifest declares secret-bearing. */ + secret: Set; + /** Dot-paths the manifest explicitly declares NOT secret (`x-paperclip-secret: false`). */ + exempt: Set; +} + +/** + * Collect the dot-paths a manifest marks secret-bearing (and those it + * explicitly exempts), following `properties` plus the `allOf` / `anyOf` / + * `oneOf` composition keywords — same traversal shape as + * `collectSecretRefPaths`, widened to the markers in {@link declaresSecret}. + */ +export function collectSecretBearingPaths( + schema: Record | null | undefined, +): PluginConfigSecretPaths { + const secret = new Set(); + const exempt = new Set(); + if (!schema || typeof schema !== "object") return { secret, exempt }; + + function walk(node: Record, prefix: string): void { + for (const keyword of ["allOf", "anyOf", "oneOf"] as const) { + const branches = node[keyword]; + if (!Array.isArray(branches)) continue; + for (const branch of branches) { + if (!isPlainRecord(branch)) continue; + walk(branch, prefix); + } + } + + const properties = node.properties; + if (!isPlainRecord(properties)) return; + for (const [key, propertySchema] of Object.entries(properties)) { + if (!isPlainRecord(propertySchema)) continue; + const path = prefix ? `${prefix}.${key}` : key; + if (declaresSecret(propertySchema)) { + secret.add(path); + } else if (propertySchema["x-paperclip-secret"] === false) { + exempt.add(path); + } + walk(propertySchema, path); + } + } + + walk(schema, ""); + return { secret, exempt }; +} + +/** + * Return a copy of `configJson` with every secret-bearing value replaced by + * {@link PLUGIN_CONFIG_SECRET_MASK}. + * + * A value is secret-bearing when the manifest declares it (see + * {@link declaresSecret}) or when its key name matches {@link SECRET_WORDS} / + * {@link SECRET_WORD_PAIRS} and the manifest has not exempted it. + * + * Secret pointers are preserved rather than masked — they name a secret without + * disclosing it, and dropping them would break the config form's binding + * picker. Any non-pointer value at a declared-secret path is masked whatever its + * type, so an unexpected shape cannot leak through. + */ +export function maskPluginConfigJson( + configJson: unknown, + schema?: Record | null, +): unknown { + if (!isPlainRecord(configJson)) return configJson; + const { secret, exempt } = collectSecretBearingPaths(schema); + + function maskRecord(record: Record, prefix: string): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + const path = prefix ? `${prefix}.${key}` : key; + result[key] = maskValue(key, value, path); + } + return result; + } + + function maskValue(key: string, value: unknown, path: string): unknown { + // A pointer names a secret without disclosing it — keep it, minus baggage. + if (isSecretPointerCandidate(value)) { + return sanitizeSecretPointer(value) ?? PLUGIN_CONFIG_SECRET_MASK; + } + + const declared = secret.has(path); + + if (declared) { + // A bare UUID at a declared-secret path is a legacy binding (see + // `coerceLegacySecretRef`), i.e. a pointer, not a credential. + if (typeof value === "string" && isUuidSecretRef(value)) return value; + if (value === null || value === undefined) return value; + return PLUGIN_CONFIG_SECRET_MASK; + } + + if (isPlainRecord(value)) return maskRecord(value, path); + + if (Array.isArray(value)) { + return value.map((entry, index) => + isPlainRecord(entry) ? maskRecord(entry, `${path}.${index}`) : entry, + ); + } + + // Undeclared field: fall back to the key-name heuristic, strings only. + if (typeof value === "string" && value.length > 0 && !exempt.has(path) && matchesSecretFieldName(key)) { + return PLUGIN_CONFIG_SECRET_MASK; + } + + return value; + } + + return maskRecord(configJson, ""); +} + +/** + * Restore stored values anywhere the caller posted back + * {@link PLUGIN_CONFIG_SECRET_MASK} unchanged, so a masked read followed by an + * unmodified write does not clobber the stored secret. + * + * The sentinel is stripped at every path, not only declared-secret ones: it can + * only have come from a masked read, and persisting the literal string is never + * the caller's intent. When storage holds nothing at that path the key is + * dropped rather than written, so the sentinel never reaches the database. + * + * Callers that supply a genuinely new value overwrite the stored secret as + * before — only the exact sentinel is treated as "unchanged". + */ +export function mergeMaskedPluginConfig( + incomingConfig: Record, + storedConfig: unknown, +): Record { + const stored = isPlainRecord(storedConfig) ? storedConfig : {}; + + function mergeRecord( + incoming: Record, + storedNode: Record, + ): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(incoming)) { + const storedValue = storedNode[key]; + + if (value === PLUGIN_CONFIG_SECRET_MASK) { + // Nothing stored to restore — drop the key rather than persist the mask. + if (storedValue === undefined) continue; + result[key] = storedValue; + continue; + } + + if (isPlainRecord(value)) { + result[key] = mergeRecord(value, isPlainRecord(storedValue) ? storedValue : {}); + continue; + } + + if (Array.isArray(value)) { + const storedArray = Array.isArray(storedValue) ? storedValue : []; + result[key] = value + .map((entry, index) => { + const storedEntry = storedArray[index]; + if (entry === PLUGIN_CONFIG_SECRET_MASK) return storedEntry; + if (isPlainRecord(entry)) { + return mergeRecord(entry, isPlainRecord(storedEntry) ? storedEntry : {}); + } + return entry; + }) + .filter((entry) => entry !== undefined); + continue; + } + + result[key] = value; + } + return result; + } + + return mergeRecord(incomingConfig, stored); +} From 5b5de18e3f795c62e9fdc31ffa74de48e1bdb880 Mon Sep 17 00:00:00 2001 From: CTO Date: Sun, 2 Aug 2026 13:21:12 +0000 Subject: [PATCH 2/2] fix(plugins): cover string leaves under credential-shaped containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review gap: a key the name heuristic suspects but whose value is an object or array was recursed into rather than covered, so `credentials: { user, pass }` and `tokens: [...]` still emitted plaintext — `pass` and the array entries match no secret word on their own. Suspicion now propagates into the subtree. A *declared* secret is still masked wholesale, because the author said so explicitly; a merely suspected one keeps its structure and has only its string leaves masked. An explicit `x-paperclip-secret: false` overrides a suspicious ancestor. Round-trip stays lossless — the merge restores by path regardless of depth. Refs BLO-20871, BLO-20794. Co-Authored-By: Claude --- .../__tests__/plugin-config-masking.test.ts | 56 +++++++++++++++++++ server/src/services/plugin-config-masking.ts | 47 +++++++++++----- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/server/src/__tests__/plugin-config-masking.test.ts b/server/src/__tests__/plugin-config-masking.test.ts index bccf763800c..9079784fa70 100644 --- a/server/src/__tests__/plugin-config-masking.test.ts +++ b/server/src/__tests__/plugin-config-masking.test.ts @@ -254,3 +254,59 @@ describe("mergeMaskedPluginConfig", () => { expect(merged).toEqual(stored); }); }); + +describe("maskPluginConfigJson — credential-shaped containers", () => { + it("masks string leaves inside a credential-named object while keeping its shape", () => { + const masked = maskPluginConfigJson({ credentials: { user: "svc", pass: SECRET, port: 8443 } }); + + expect(masked).toEqual({ + credentials: { user: PLUGIN_CONFIG_SECRET_MASK, pass: PLUGIN_CONFIG_SECRET_MASK, port: 8443 }, + }); + expect(JSON.stringify(masked)).not.toContain(SECRET); + }); + + it("masks strings in a credential-named array", () => { + const masked = maskPluginConfigJson({ tokens: [SECRET, "second"] }); + + expect(masked).toEqual({ tokens: [PLUGIN_CONFIG_SECRET_MASK, PLUGIN_CONFIG_SECRET_MASK] }); + }); + + it("does not let suspicion leak into unrelated sibling subtrees", () => { + const masked = maskPluginConfigJson({ + credentials: { pass: SECRET }, + transport: { endpoint: "https://example.com" }, + }); + + expect(masked).toEqual({ + credentials: { pass: PLUGIN_CONFIG_SECRET_MASK }, + transport: { endpoint: "https://example.com" }, + }); + }); + + it("lets an explicit exemption override a suspicious ancestor", () => { + const masked = maskPluginConfigJson( + { credentials: { scheme: "basic", pass: SECRET } }, + { + type: "object", + properties: { + credentials: { + type: "object", + properties: { scheme: { type: "string", "x-paperclip-secret": false } }, + }, + }, + }, + ); + + expect(masked).toEqual({ + credentials: { scheme: "basic", pass: PLUGIN_CONFIG_SECRET_MASK }, + }); + }); + + it("round-trips a credential container losslessly", () => { + const stored = { credentials: { user: "svc", pass: SECRET }, endpoint: "https://example.com" }; + const masked = maskPluginConfigJson(stored) as Record; + + expect(JSON.stringify(masked)).not.toContain(SECRET); + expect(mergeMaskedPluginConfig(JSON.parse(JSON.stringify(masked)), stored)).toEqual(stored); + }); +}); diff --git a/server/src/services/plugin-config-masking.ts b/server/src/services/plugin-config-masking.ts index 26f3fe45468..fb863e53a5a 100644 --- a/server/src/services/plugin-config-masking.ts +++ b/server/src/services/plugin-config-masking.ts @@ -225,6 +225,11 @@ export function collectSecretBearingPaths( * disclosing it, and dropping them would break the config form's binding * picker. Any non-pointer value at a declared-secret path is masked whatever its * type, so an unexpected shape cannot leak through. + * + * A *declared* secret is masked wholesale, because the author said so + * explicitly. A field the heuristic merely suspects is treated more gently: its + * structure survives and only the string leaves beneath it are masked, so + * `credentials: { user, pass }` keeps its shape while `pass` is covered. */ export function maskPluginConfigJson( configJson: unknown, @@ -233,24 +238,31 @@ export function maskPluginConfigJson( if (!isPlainRecord(configJson)) return configJson; const { secret, exempt } = collectSecretBearingPaths(schema); - function maskRecord(record: Record, prefix: string): Record { + function maskRecord( + record: Record, + prefix: string, + inSecretContainer: boolean, + ): Record { const result: Record = {}; for (const [key, value] of Object.entries(record)) { const path = prefix ? `${prefix}.${key}` : key; - result[key] = maskValue(key, value, path); + result[key] = maskValue(key, value, path, inSecretContainer); } return result; } - function maskValue(key: string, value: unknown, path: string): unknown { + function maskValue( + key: string, + value: unknown, + path: string, + inSecretContainer: boolean, + ): unknown { // A pointer names a secret without disclosing it — keep it, minus baggage. if (isSecretPointerCandidate(value)) { return sanitizeSecretPointer(value) ?? PLUGIN_CONFIG_SECRET_MASK; } - const declared = secret.has(path); - - if (declared) { + if (secret.has(path)) { // A bare UUID at a declared-secret path is a legacy binding (see // `coerceLegacySecretRef`), i.e. a pointer, not a credential. if (typeof value === "string" && isUuidSecretRef(value)) return value; @@ -258,23 +270,32 @@ export function maskPluginConfigJson( return PLUGIN_CONFIG_SECRET_MASK; } - if (isPlainRecord(value)) return maskRecord(value, path); + // An explicit `x-paperclip-secret: false` wins over the heuristic, and over + // a suspicious ancestor. + const suspect = exempt.has(path) + ? false + : inSecretContainer || matchesSecretFieldName(key); + + if (isPlainRecord(value)) return maskRecord(value, path, suspect); if (Array.isArray(value)) { - return value.map((entry, index) => - isPlainRecord(entry) ? maskRecord(entry, `${path}.${index}`) : entry, - ); + return value.map((entry, index) => { + if (isPlainRecord(entry)) return maskRecord(entry, `${path}.${index}`, suspect); + if (suspect && typeof entry === "string" && entry.length > 0) { + return PLUGIN_CONFIG_SECRET_MASK; + } + return entry; + }); } - // Undeclared field: fall back to the key-name heuristic, strings only. - if (typeof value === "string" && value.length > 0 && !exempt.has(path) && matchesSecretFieldName(key)) { + if (suspect && typeof value === "string" && value.length > 0) { return PLUGIN_CONFIG_SECRET_MASK; } return value; } - return maskRecord(configJson, ""); + return maskRecord(configJson, "", false); } /**