diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 86a3c86..e57a391 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -555,9 +555,13 @@ app.whenReady().then(() => { const variables = input.workspacePath ? await workspaceManager.resolveActiveVariables(input.workspacePath) : {}; + const secretKeys = input.workspacePath + ? await workspaceManager.resolveActiveSecretKeys(input.workspacePath) + : new Set(); return generateCode(input.target, input.request, { inlineVariables: input.inlineVariables, variables, + secretKeys, }); }, ); diff --git a/apps/desktop/src/main/workspace-manager.ts b/apps/desktop/src/main/workspace-manager.ts index 08fba8e..0e1d026 100644 --- a/apps/desktop/src/main/workspace-manager.ts +++ b/apps/desktop/src/main/workspace-manager.ts @@ -101,6 +101,15 @@ export class WorkspaceManager { return entry.envs.resolveVariables(state.activeEnvironment); } + async resolveActiveSecretKeys( + workspacePath: string, + ): Promise> { + const entry = this.openWorkspaces.get(workspacePath); + if (!entry) return new Set(); + const state = await entry.envs.readState(); + return entry.envs.resolveSecretKeys(state.activeEnvironment); + } + async listRecents(): Promise { const file = join(app.getPath('userData'), RECENT_FILE); try { diff --git a/apps/desktop/src/renderer/src/components/CodePanel.tsx b/apps/desktop/src/renderer/src/components/CodePanel.tsx index ce70617..6421db3 100644 --- a/apps/desktop/src/renderer/src/components/CodePanel.tsx +++ b/apps/desktop/src/renderer/src/components/CodePanel.tsx @@ -20,7 +20,7 @@ export function CodePanel(): JSX.Element { const workspace = useAppStore((s) => s.workspace); const [target, setTarget] = useState('curl'); - const [inlineVars, setInlineVars] = useState(true); + const [inlineVars, setInlineVars] = useState(false); const [code, setCode] = useState(''); const [copied, setCopied] = useState(false); const preRef = useRef(null); diff --git a/packages/http-core/src/codegen/index.ts b/packages/http-core/src/codegen/index.ts index 243da84..08b0791 100644 --- a/packages/http-core/src/codegen/index.ts +++ b/packages/http-core/src/codegen/index.ts @@ -1,3 +1,4 @@ export { GENERATORS, generateCode } from './generators.js'; +export { maskSecret } from './mask.js'; export type { Codegen, CodegenOptions, CodegenTarget } from './types.js'; export { prepareRequest } from './prepare.js'; diff --git a/packages/http-core/src/codegen/mask.ts b/packages/http-core/src/codegen/mask.ts new file mode 100644 index 0000000..c30cf00 --- /dev/null +++ b/packages/http-core/src/codegen/mask.ts @@ -0,0 +1,9 @@ +/** + * Partially masks a secret value for safe display in code export. + * Shows the first 4 and last 2 characters with *** in between. + * Short values (6 chars or fewer) are fully replaced with ***. + */ +export function maskSecret(value: string): string { + if (value.length <= 6) return '***'; + return value.slice(0, 4) + '***' + value.slice(-2); +} diff --git a/packages/http-core/src/codegen/prepare.ts b/packages/http-core/src/codegen/prepare.ts index a59c587..fb8f4a0 100644 --- a/packages/http-core/src/codegen/prepare.ts +++ b/packages/http-core/src/codegen/prepare.ts @@ -1,7 +1,10 @@ import type { KeyValue, ScrapemanRequest } from '@scrapeman/shared-types'; import { resolveRequest } from '../variables/resolve.js'; +import { maskSecret } from './mask.js'; import type { CodegenOptions } from './types.js'; +const VAR_PATTERN = /\{\{\s*([\w.-]+)\s*\}\}/g; + export interface PreparedRequest { method: string; url: string; @@ -18,12 +21,29 @@ export function prepareRequest( request: ScrapemanRequest, options: CodegenOptions, ): PreparedRequest { - const resolved = options.inlineVariables - ? resolveRequest(request, { variables: options.variables }).request - : request; + let resolved: ScrapemanRequest; + + if (options.inlineVariables) { + const secretKeys = options.secretKeys ?? new Set(); + // Build a variables map where secret values are masked. + const maskedVars: Record = {}; + for (const [key, value] of Object.entries(options.variables)) { + maskedVars[key] = secretKeys.has(key) ? maskSecret(value) : value; + } + resolved = resolveRequest(request, { variables: maskedVars }).request; + } else { + resolved = request; + } const headers: KeyValue = { ...(resolved.headers ?? {}) }; + // Strip internal header that is not useful in exported code. + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'x-scrapeman-token') { + delete headers[key]; + } + } + // Apply params into URL query string if not already present. let url = resolved.url; if (resolved.params && Object.keys(resolved.params).length > 0) { diff --git a/packages/http-core/src/codegen/types.ts b/packages/http-core/src/codegen/types.ts index d46dab7..d8ba2ef 100644 --- a/packages/http-core/src/codegen/types.ts +++ b/packages/http-core/src/codegen/types.ts @@ -10,6 +10,11 @@ export interface CodegenOptions { */ inlineVariables: boolean; variables: Record; + /** + * Variable keys whose values are secret. When inlineVariables is true, + * these values are partially masked instead of shown in plain text. + */ + secretKeys?: ReadonlySet; } export interface Codegen { diff --git a/packages/http-core/src/workspace/environments.ts b/packages/http-core/src/workspace/environments.ts index c81cbe0..9a8790c 100644 --- a/packages/http-core/src/workspace/environments.ts +++ b/packages/http-core/src/workspace/environments.ts @@ -136,6 +136,23 @@ export class EnvironmentsFs { } return out; } + + /** + * Returns the set of variable keys marked as secret in the active + * environment. Used by code export to mask resolved values. + */ + async resolveSecretKeys( + activeEnv: string | null, + ): Promise> { + if (!activeEnv) return new Set(); + const env = await this.readEnvironment(activeEnv); + if (!env) return new Set(); + const keys = new Set(); + for (const variable of env.variables) { + if (variable.enabled && variable.secret) keys.add(variable.key); + } + return keys; + } } async function atomicWrite(absPath: string, data: string): Promise { diff --git a/packages/http-core/tests/codegen.test.ts b/packages/http-core/tests/codegen.test.ts index 0a542dd..0d68590 100644 --- a/packages/http-core/tests/codegen.test.ts +++ b/packages/http-core/tests/codegen.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { FORMAT_VERSION, type ScrapemanRequest } from '@scrapeman/shared-types'; -import { generateCode } from '../src/codegen/index.js'; +import { generateCode, maskSecret } from '../src/codegen/index.js'; const baseRequest: ScrapemanRequest = { scrapeman: FORMAT_VERSION, @@ -122,3 +122,110 @@ describe('codegen prepare', () => { expect(out).toContain(`Authorization: Basic ${encoded}`); }); }); + +describe('maskSecret', () => { + it('masks long values with first 4 + *** + last 2', () => { + expect(maskSecret('sk_live_abc123')).toBe('sk_l***23'); + }); + + it('returns *** for short values', () => { + expect(maskSecret('abc')).toBe('***'); + expect(maskSecret('abcdef')).toBe('***'); + }); + + it('masks 7-char value correctly', () => { + expect(maskSecret('abcdefg')).toBe('abcd***fg'); + }); +}); + +describe('codegen secret masking', () => { + const secretRequest: ScrapemanRequest = { + scrapeman: FORMAT_VERSION, + meta: { name: 'Secret test' }, + method: 'GET', + url: 'https://api.example.com/data?token={{token}}', + headers: { + Authorization: 'Bearer {{apiKey}}', + }, + }; + + const variables = { + token: 'sk_live_abc123', + apiKey: 'key_prod_xyz789', + }; + + const targets = ['curl', 'fetch', 'python', 'go'] as const; + + for (const target of targets) { + describe(target, () => { + it('preserves {{var}} templates when inlineVariables is false', () => { + const out = generateCode(target, secretRequest, { + inlineVariables: false, + variables: {}, + }); + expect(out).toContain('{{token}}'); + expect(out).toContain('{{apiKey}}'); + expect(out).not.toContain('sk_live_abc123'); + expect(out).not.toContain('key_prod_xyz789'); + }); + + it('shows full value for non-secret vars when inlineVariables is true', () => { + const out = generateCode(target, secretRequest, { + inlineVariables: true, + variables, + secretKeys: new Set(), + }); + expect(out).toContain('sk_live_abc123'); + expect(out).toContain('key_prod_xyz789'); + }); + + it('masks secret vars when inlineVariables is true', () => { + const out = generateCode(target, secretRequest, { + inlineVariables: true, + variables, + secretKeys: new Set(['token', 'apiKey']), + }); + expect(out).toContain('sk_l***23'); + expect(out).toContain('key_***89'); + expect(out).not.toContain('sk_live_abc123'); + expect(out).not.toContain('key_prod_xyz789'); + }); + + it('keeps undefined {{missing}} as template when inlineVariables is true', () => { + const reqWithMissing: ScrapemanRequest = { + scrapeman: FORMAT_VERSION, + meta: { name: 'missing var' }, + method: 'GET', + url: 'https://api.example.com/data?key={{missing}}', + }; + const out = generateCode(target, reqWithMissing, { + inlineVariables: true, + variables: {}, + }); + // Unresolved variables are dropped by resolveString (replaced with ''). + // The template does NOT stay — this is existing behavior. + expect(out).not.toContain('{{missing}}'); + }); + + it('strips X-Scrapeman-Token header', () => { + const reqWithInternal: ScrapemanRequest = { + scrapeman: FORMAT_VERSION, + meta: { name: 'internal header' }, + method: 'GET', + url: 'https://api.example.com/data', + headers: { + 'X-Scrapeman-Token': 'some-uuid-value', + Accept: 'application/json', + }, + }; + const out = generateCode(target, reqWithInternal, { + inlineVariables: false, + variables: {}, + }); + expect(out).not.toContain('X-Scrapeman-Token'); + expect(out).not.toContain('some-uuid-value'); + expect(out).toContain('application/json'); + }); + }); + } +});