diff --git a/README.md b/README.md index 0ee5a368..a9566180 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Profiles are named credential environments. Keep secret values outside JSON when "upstream": { "transport": "stdio", "command": "docker", - "args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"] + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"] }, "profiles": { "work": { @@ -100,6 +100,8 @@ Profiles are named credential environments. Keep secret values outside JSON when } ``` +The GitHub preset pins `ghcr.io/github/github-mcp-server:v1.5.0`. To upgrade safely, read the release notes first, update the tag in your config, run `miftah validate`, then smoke-test both profiles before rollout. + Claude can call `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, and `miftah_route_preview`. Upstream tools are exposed unchanged unless they collide with a reserved `miftah_` name. For account bundles, define `upstreams` instead of `upstream`. Tools are exposed as `__` (for example `github__search_issues`) and each profile can provide per-upstream environment or header overrides. See `examples/multi-upstream.miftah.json`. @@ -124,7 +126,7 @@ Routing can use the active profile or rules matching tool arguments: } ``` -When several profiles match, Miftah refuses to guess. Use explicit profile switching for write and destructive actions. Local policies can deny risky tools or return a confirmation-needed result. Provider token scopes still matter: local policy cannot make a write-capable provider token read-only. +When several profiles match, Miftah refuses to guess. Use explicit profile switching for write and destructive actions. Local policies can deny risky tools or return a confirmation-needed result. Provider token scopes still matter: local policy cannot make a write-capable provider token read-only. Profiles that set a policy name must reference an existing entry in `policies`, while profiles with no `policy` field keep the default allow behavior. ## Secret handling diff --git a/docs/examples/github.md b/docs/examples/github.md index 3e90cb53..3e5afbd9 100644 --- a/docs/examples/github.md +++ b/docs/examples/github.md @@ -10,7 +10,7 @@ Use the generic wrapper to run GitHub MCP with separate work and personal tokens "upstream": { "transport": "stdio", "command": "docker", - "args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"] + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"] }, "profiles": { "work": { @@ -33,10 +33,13 @@ Use the generic wrapper to run GitHub MCP with separate work and personal tokens }, "safe-write": { "allowRisk": ["read", "write"], - "denyRisk": ["destructive"] + "denyRisk": ["destructive"], + "requireConfirmation": ["write"] } } } ``` This config contains references only. Set the variables in the shell that launches Claude Desktop. + +When upgrading the pinned image tag, review upstream release notes first, then run `miftah validate --config ` and test both profiles before adopting the new tag. diff --git a/examples/github.miftah.json b/examples/github.miftah.json index afff1921..12e85b5d 100644 --- a/examples/github.miftah.json +++ b/examples/github.miftah.json @@ -10,7 +10,9 @@ "run", "-i", "--rm", - "ghcr.io/github/github-mcp-server" + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:v1.5.0" ] }, "profiles": { diff --git a/examples/multi-upstream.miftah.json b/examples/multi-upstream.miftah.json index bd342916..23062f5a 100644 --- a/examples/multi-upstream.miftah.json +++ b/examples/multi-upstream.miftah.json @@ -7,7 +7,7 @@ "github": { "transport": "stdio", "command": "docker", - "args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"] + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"] }, "sentry": { "transport": "stdio", diff --git a/src/config/presets.ts b/src/config/presets.ts index 12524a28..aa8d8c03 100644 --- a/src/config/presets.ts +++ b/src/config/presets.ts @@ -1,24 +1,14 @@ -import type { MiftahConfig } from "./types.js"; +import type { MiftahConfig, UpstreamConfig } from "./types.js"; -export function presetConfig(name: string, preset = "generic"): MiftahConfig { - const upstream = - preset === "github" - ? { transport: "stdio" as const, command: "docker", args: ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"] } - : preset === "sentry" - ? { transport: "stdio" as const, command: "npx", args: ["-y", "@sentry/mcp-server"] } - : { transport: "stdio" as const, command: "npx", args: ["-y", "your-mcp-server"] }; +/** Pinned GitHub MCP server image used by the GitHub preset. */ +export const GITHUB_MCP_IMAGE = "ghcr.io/github/github-mcp-server:v1.5.0"; + +type SharedDefaults = Pick; +type PresetBuilder = (name: string) => MiftahConfig; + +/** Builds fresh shared runtime defaults so generated configs never share mutable state. */ +function buildSharedDefaults(): SharedDefaults { return { - version: "1", - name, - description: `${name} wrapped by Miftah`, - defaultProfile: "default", - upstream, - profiles: { - default: { - description: "Default account", - env: {} - } - }, routing: { mode: "hybrid", fallback: "activeProfile", rules: [] }, security: { allowPlaintextSecrets: false, @@ -37,3 +27,83 @@ export function presetConfig(name: string, preset = "generic"): MiftahConfig { tooling: { managementToolPrefix: "miftah_", collisionStrategy: "prefix-upstream" } }; } + +/** Builds the common single-profile shape used by package-based presets. */ +function buildStandardPreset(name: string, upstream: UpstreamConfig): MiftahConfig { + return { + version: "1", + name, + description: `${name} wrapped by Miftah`, + defaultProfile: "default", + upstream, + profiles: { + default: { + description: "Default account", + env: {} + } + }, + policies: undefined, + ...buildSharedDefaults() + }; +} + +/** Builds the generic starter preset for an unspecified MCP package. */ +function buildGenericPreset(name: string): MiftahConfig { + return buildStandardPreset(name, { + transport: "stdio", + command: "npx", + args: ["-y", "your-mcp-server"] + }); +} + +/** Builds the Sentry MCP package preset. */ +function buildSentryPreset(name: string): MiftahConfig { + return buildStandardPreset(name, { + transport: "stdio", + command: "npx", + args: ["-y", "@sentry/mcp-server"] + }); +} + +/** Builds the multi-profile GitHub preset and its referenced policies. */ +function buildGithubPreset(name: string): MiftahConfig { + return { + version: "1", + name, + description: "GitHub MCP wrapped by Miftah", + defaultProfile: "work", + upstream: { + transport: "stdio", + command: "docker", + args: ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", GITHUB_MCP_IMAGE] + }, + profiles: { + work: { + description: "Work GitHub account", + env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_WORK_TOKEN}" }, + policy: "safe-write" + }, + personal: { + description: "Personal GitHub account", + env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PERSONAL_TOKEN}" }, + policy: "readonly" + } + }, + policies: { + readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] }, + "safe-write": { allowRisk: ["read", "write"], denyRisk: ["destructive"], requireConfirmation: ["write"] } + }, + ...buildSharedDefaults() + }; +} + +const presetBuilders = new Map([ + ["generic", buildGenericPreset], + ["sentry", buildSentryPreset], + ["github", buildGithubPreset] +]); + +/** Builds a named configuration preset, falling back to the generic template. */ +export function presetConfig(name: string, preset = "generic"): MiftahConfig { + return (presetBuilders.get(preset) ?? buildGenericPreset)(name); +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 6ddd8d74..b764b319 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -43,6 +43,7 @@ const policySchema = z.object({ requireConfirmation: z.array(z.string()).optional() }); +/** Zod schema for validating the complete Miftah configuration format. */ export const miftahConfigSchema = z .object({ version: z.literal("1"), @@ -120,9 +121,22 @@ export const miftahConfigSchema = z context.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultProfile"], + params: { miftahCode: "DEFAULT_PROFILE_NOT_FOUND" }, message: `DEFAULT_PROFILE_NOT_FOUND: profile '${value.defaultProfile}' does not exist` }); } + const policyNames = new Set(Object.keys(value.policies ?? {})); + for (const [profileName, profile] of Object.entries(value.profiles)) { + if (profile.policy !== undefined && !policyNames.has(profile.policy)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["profiles", profileName, "policy"], + params: { miftahCode: "POLICY_NOT_FOUND" }, + message: `POLICY_NOT_FOUND: policy '${profile.policy}' does not exist` + }); + } + } }); +/** Input accepted by {@link miftahConfigSchema} before validation. */ export type MiftahConfigInput = z.input; diff --git a/src/config/validate-config.ts b/src/config/validate-config.ts index 70cdd2a9..d449f378 100644 --- a/src/config/validate-config.ts +++ b/src/config/validate-config.ts @@ -1,16 +1,30 @@ +import { z } from "zod"; import { miftahConfigSchema } from "./schema.js"; import type { MiftahConfig } from "./types.js"; -import { MiftahError } from "../utils/errors.js"; +import { MiftahError, type MiftahErrorCode } from "../utils/errors.js"; +/** Narrows custom Zod metadata to error codes emitted by config refinements. */ +function isConfigIssueCode(value: unknown): value is MiftahErrorCode { + return value === "DEFAULT_PROFILE_NOT_FOUND" || value === "POLICY_NOT_FOUND"; +} + +/** Reads a trusted Miftah error code from a custom Zod issue, if present. */ +function getIssueCode(issue: z.ZodIssue): MiftahErrorCode | undefined { + if (issue.code !== z.ZodIssueCode.custom) { + return undefined; + } + const code = issue.params?.miftahCode; + return isConfigIssueCode(code) ? code : undefined; +} + +/** Validates unknown input and returns a normalized Miftah configuration. */ export function validateConfig(input: unknown): MiftahConfig { const result = miftahConfigSchema.safeParse(input); if (!result.success) { const message = result.error.issues .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`) .join("; "); - const code = message.includes("DEFAULT_PROFILE_NOT_FOUND") - ? "DEFAULT_PROFILE_NOT_FOUND" - : "CONFIG_SCHEMA_INVALID"; + const code = result.error.issues.map(getIssueCode).find((issueCode) => issueCode !== undefined) ?? "CONFIG_SCHEMA_INVALID"; throw new MiftahError(code, `${code}: ${message}`); } return result.data; diff --git a/src/policy/policy-engine.ts b/src/policy/policy-engine.ts index c210cd5b..5a8c4874 100644 --- a/src/policy/policy-engine.ts +++ b/src/policy/policy-engine.ts @@ -2,24 +2,30 @@ import type { PolicyConfig, RiskLevel } from "../config/types.js"; import type { PolicyDecision } from "./policy-types.js"; import { classifyRisk } from "./risk-classifier.js"; +/** Matches a tool name against an anchored glob pattern where `*` spans any characters. */ function matchesPattern(value: string, pattern: string): boolean { const regex = new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`); return regex.test(value); } +/** Escapes regular-expression metacharacters before a glob segment is compiled. */ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** Evaluates tool calls against named policies and risk overrides. */ export class PolicyEngine { + /** Creates an engine from policy definitions and optional per-tool risk overrides. */ constructor( private readonly policies: Record = {}, private readonly riskOverrides: Record = {} ) {} + /** Returns whether a tool call is allowed, denied, or requires confirmation. */ evaluate(policyName: string | undefined, toolName: string): PolicyDecision { const risk = classifyRisk(toolName, this.riskOverrides); - const policy = policyName ? this.policies[policyName] : undefined; + if (policyName !== undefined && !Object.hasOwn(this.policies, policyName)) return { action: "deny", risk }; + const policy = policyName !== undefined ? this.policies[policyName] : undefined; if (!policy) return { action: "allow", risk }; if (policy.deny?.some((pattern) => matchesPattern(toolName, pattern))) { return { action: "deny", risk }; diff --git a/src/secrets/redact.ts b/src/secrets/redact.ts index 47a134e5..8b08fde8 100644 --- a/src/secrets/redact.ts +++ b/src/secrets/redact.ts @@ -1,7 +1,36 @@ -const secretKeyPattern = /(token|secret|password|api[_-]?key|auth|private|credential|authorization)/i; const bearerPattern = /(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi; -const tokenPattern = /\b[A-Za-z0-9_-]{32,}\b/g; +const providerTokenPatterns = [ + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g +]; +const camelCaseBoundaryPattern = /([a-z0-9])([A-Z])/g; +const nonAlphanumericPattern = /[^a-z0-9]+/; +const secretKeyTerms = new Set([ + "token", + "tokens", + "secret", + "secrets", + "password", + "passwords", + "credential", + "credentials", + "authorization", + "auth", + "apikey", + "privatekey" +]); +/** Identifies structured-data keys whose values must be redacted in full. */ +function isSecretKey(key: string): boolean { + const normalized = key.replace(camelCaseBoundaryPattern, "$1_$2").toLowerCase(); + const parts = normalized.split(nonAlphanumericPattern).filter(Boolean); + if (parts.some((part) => secretKeyTerms.has(part))) { + return true; + } + return (parts.includes("api") && parts.includes("key")) || (parts.includes("private") && parts.includes("key")); +} + +/** Redacts configured values and recognized credential formats from text. */ function redactString(value: string, secretValues: readonly string[]): string { let result = value; for (const secret of secretValues) { @@ -9,11 +38,16 @@ function redactString(value: string, secretValues: readonly string[]): string { result = result.split(secret).join("[REDACTED]"); } } - return result.replace(bearerPattern, "$1[REDACTED]").replace(tokenPattern, "[REDACTED]"); + result = result.replace(bearerPattern, "$1[REDACTED]"); + for (const pattern of providerTokenPatterns) { + result = result.replace(pattern, "[REDACTED]"); + } + return result; } +/** Recursively redacts secrets while preserving the input's data shape. */ function redactValue(value: unknown, secretValues: readonly string[], key?: string): unknown { - if (key && secretKeyPattern.test(key)) { + if (key && isSecretKey(key)) { return "[REDACTED]"; } if (typeof value === "string") { @@ -33,10 +67,12 @@ function redactValue(value: unknown, secretValues: readonly string[], key?: stri return value; } +/** Creates a reusable deep redactor for a fixed collection of secret values. */ export function createRedactor(secretValues: readonly string[] = []): (value: T) => T { return (value: T) => redactValue(value, secretValues) as T; } +/** Redacts secret values and secret-bearing keys from an arbitrary value. */ export function redactSecrets(value: T, secretValues: readonly string[] = []): T { return createRedactor(secretValues)(value); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index b48bf813..db9d1ea4 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,8 +1,10 @@ +/** Stable error codes exposed to Miftah callers and command-line consumers. */ export type MiftahErrorCode = | "CONFIG_NOT_FOUND" | "CONFIG_INVALID_JSON" | "CONFIG_SCHEMA_INVALID" | "DEFAULT_PROFILE_NOT_FOUND" + | "POLICY_NOT_FOUND" | "PROFILE_NOT_FOUND" | "PROFILE_SWITCH_DISABLED" | "SECRET_ENV_MISSING" @@ -16,10 +18,12 @@ export type MiftahErrorCode = | "POLICY_BLOCKED" | "TOOL_COLLISION"; +/** Error carrying a machine-readable Miftah code and optional structured context. */ export class MiftahError extends Error { readonly code: MiftahErrorCode; readonly details?: Record; + /** Creates a Miftah error with a stable code, message, and optional diagnostic details. */ constructor(code: MiftahErrorCode, message: string, details?: Record) { super(message); this.name = "MiftahError"; diff --git a/tests/config.test.ts b/tests/config.test.ts index c202c6fd..92889287 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { validateConfig } from "../src/config/validate-config.js"; import { expandEnvironmentReferences } from "../src/config/env-expand.js"; +import { MiftahError } from "../src/utils/errors.js"; + +const policyNotFoundPattern = /POLICY_NOT_FOUND/u; describe("config foundation", () => { it("accepts a valid wrapper and expands profile environment references", () => { @@ -39,4 +42,56 @@ describe("config foundation", () => { expandEnvironmentReferences({ API_TOKEN: "${MISSING_TOKEN}" }, {}) ).toThrow(/MISSING_TOKEN/); }); + + it.each([ + ["missing-policy", { readonly: { allowRisk: ["read"] } }], + ["", undefined] + ])("rejects an undefined policy reference with contextual diagnostics", (policy, policies) => { + let thrown: unknown; + + try { + validateConfig({ + version: "1", + name: "github", + defaultProfile: "work", + upstream: { transport: "stdio", command: "node" }, + policies, + profiles: { + work: { policy } + } + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(MiftahError); + if (!(thrown instanceof MiftahError)) { + throw new Error("Expected a MiftahError for an undefined policy reference"); + } + expect(thrown.code).toBe("POLICY_NOT_FOUND"); + expect(thrown.message).toMatch(policyNotFoundPattern); + expect(thrown.message).toContain("profiles.work.policy"); + expect(thrown.message).toContain(`policy '${policy}'`); + }); + + it("does not derive the error code from user-controlled policy names", () => { + let thrown: unknown; + + try { + validateConfig({ + version: "1", + name: "github", + defaultProfile: "work", + upstream: { transport: "stdio", command: "node" }, + profiles: { + work: { policy: "DEFAULT_PROFILE_NOT_FOUND" } + } + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(MiftahError); + expect(thrown).toMatchObject({ code: "POLICY_NOT_FOUND" }); + }); }); diff --git a/tests/mcp-wrapper.test.ts b/tests/mcp-wrapper.test.ts index e757b042..e8c2056d 100644 --- a/tests/mcp-wrapper.test.ts +++ b/tests/mcp-wrapper.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { validateConfig } from "../src/config/validate-config.js"; +import type { MiftahConfig } from "../src/config/types.js"; import { ProfileManager } from "../src/profiles/profile-manager.js"; import { MiftahServer } from "../src/mcp/server/miftah-server.js"; import { UpstreamProcessManager } from "../src/upstream/upstream-process-manager.js"; @@ -63,4 +64,33 @@ describe("Miftah MCP wrapper", () => { await client.close(); await wrapper.close(); }); + + it("blocks destructive calls when runtime policy lookup misses an explicitly named policy", async () => { + const config: MiftahConfig = { + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { env: { TEST_ACCOUNT_NAME: "work", API_TOKEN: "hidden-token" }, policy: "missing-policy" } + }, + audit: { enabled: false } + }; + + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const profiles = new ProfileManager(config); + const wrapper = new MiftahServer(config, profiles, manager); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "1.0.0" }); + + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + const blocked = await client.callTool({ name: "create_item", arguments: { name: "x" } }); + expect(blocked).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("POLICY_BLOCKED") }] + }); + + await client.close(); + await wrapper.close(); + }); }); diff --git a/tests/presets.test.ts b/tests/presets.test.ts new file mode 100644 index 00000000..87d34579 --- /dev/null +++ b/tests/presets.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { presetConfig } from "../src/config/presets.js"; + +describe("preset config", () => { + it("generates a pinned, token-forwarding GitHub Docker preset with profile-specific env refs", () => { + const config = presetConfig("github", "github"); + + expect(config.upstream).toBeDefined(); + expect(config.upstream?.command).toBe("docker"); + expect(config.upstream?.args).toEqual([ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:v1.5.0" + ]); + + expect(config.defaultProfile).toBe("work"); + expect(config.profiles.work?.env?.GITHUB_PERSONAL_ACCESS_TOKEN).toBe("${GITHUB_WORK_TOKEN}"); + expect(config.profiles.personal?.env?.GITHUB_PERSONAL_ACCESS_TOKEN).toBe("${GITHUB_PERSONAL_TOKEN}"); + + const refs = [ + config.profiles.work?.env?.GITHUB_PERSONAL_ACCESS_TOKEN, + config.profiles.personal?.env?.GITHUB_PERSONAL_ACCESS_TOKEN + ]; + expect(refs.every((value) => typeof value === "string" && value.startsWith("${") && value.endsWith("}"))).toBe(true); + for (const profile of Object.values(config.profiles)) { + if (profile.policy) { + expect(config.policies).toHaveProperty(profile.policy); + } + } + }); + + it("preserves the generic preset defaults", () => { + const config = presetConfig("example"); + + expect(config).toMatchObject({ + description: "example wrapped by Miftah", + defaultProfile: "default", + upstream: { transport: "stdio", command: "npx", args: ["-y", "your-mcp-server"] }, + profiles: { default: { description: "Default account", env: {} } } + }); + }); + + it("uses the Sentry package with the shared preset defaults", () => { + const config = presetConfig("sentry", "sentry"); + + expect(config).toMatchObject({ + description: "sentry wrapped by Miftah", + defaultProfile: "default", + upstream: { transport: "stdio", command: "npx", args: ["-y", "@sentry/mcp-server"] }, + profiles: { default: { description: "Default account", env: {} } } + }); + }); +}); diff --git a/tests/routing-policy.test.ts b/tests/routing-policy.test.ts index ec571499..0eb78585 100644 --- a/tests/routing-policy.test.ts +++ b/tests/routing-policy.test.ts @@ -59,4 +59,27 @@ describe("routing and policy", () => { }); expect(engine.evaluate("safe", "get_item")).toEqual({ action: "allow", risk: "read" }); }); + + it("fails closed when a profile references a missing named policy", () => { + const engine = new PolicyEngine( + { + readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] } + }, + { create_item: "write" } + ); + + expect(engine.evaluate("missing-policy", "create_item")).toEqual({ action: "deny", risk: "write" }); + }); + + it("fails closed when a policy name resolves to an inherited object property", () => { + const engine = new PolicyEngine(); + + expect(engine.evaluate("toString", "delete_repository")).toEqual({ action: "deny", risk: "destructive" }); + }); + + it("fails closed when a policy name is explicitly empty", () => { + const engine = new PolicyEngine(); + + expect(engine.evaluate("", "delete_repository")).toEqual({ action: "deny", risk: "destructive" }); + }); }); diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts index 345b1d65..af03596a 100644 --- a/tests/secrets.test.ts +++ b/tests/secrets.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; -import { redactSecrets, createRedactor } from "../src/secrets/redact.js"; +import { createRedactor, redactSecrets } from "../src/secrets/redact.js"; describe("secret redaction", () => { - it("redacts configured values in nested data and token-like strings", () => { + it("redacts configured secret values in nested data", () => { const redact = createRedactor(["super-secret-token"]); expect( redact({ token: "super-secret-token", - message: "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345", + message: "Authorization: super-secret-token", nested: ["super-secret-token"] }) ).toEqual({ token: "[REDACTED]", - message: "Authorization: Bearer [REDACTED]", + message: "Authorization: [REDACTED]", nested: ["[REDACTED]"] }); }); @@ -23,4 +23,48 @@ describe("secret redaction", () => { ACCOUNT: "work" }); }); + + it("redacts plural secret-key variants", () => { + expect( + redactSecrets({ + tokens: ["token-value"], + clientSecrets: ["secret-value"], + user_passwords: ["password-value"], + GOOGLE_APPLICATION_CREDENTIALS: "credential-value" + }) + ).toEqual({ + tokens: "[REDACTED]", + clientSecrets: "[REDACTED]", + user_passwords: "[REDACTED]", + GOOGLE_APPLICATION_CREDENTIALS: "[REDACTED]" + }); + }); + + it("redacts bearer credentials and known provider token formats", () => { + const bearerToken = ["Bearer", "not-a-real-token-value"].join(" "); + const cases = [ + { key: "githubClassic", value: "ghp_abcdefghijklmnopqrstuvwxyz1234567890ABCD" }, + { key: "githubPat", value: "github_pat_11ABCDEF_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP" } + ] as const; + + expect(redactSecrets({ header: bearerToken }).header).toBe(["Bearer", "[REDACTED]"].join(" ")); + for (const testCase of cases) { + expect(redactSecrets({ [testCase.key]: testCase.value })[testCase.key]).toBe("[REDACTED]"); + } + }); + + it("preserves non-secret identifiers and benign key names", () => { + const cases = [ + { key: "gitSha", value: "0123456789abcdef0123456789abcdef01234567" }, + { key: "sentryEventId", value: "4c79f60c11214eb38604f4ae0781bfb2" }, + { key: "uuid", value: "550e8400-e29b-41d4-a716-446655440000" }, + { key: "checksum", value: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }, + { key: "graphqlId", value: "gid://shopify/Product/1234567890" }, + { key: "author", value: "mohanagy" } + ] as const; + + for (const testCase of cases) { + expect(redactSecrets({ [testCase.key]: testCase.value })[testCase.key]).toBe(testCase.value); + } + }); });