Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
return generateCode(input.target, input.request, {
inlineVariables: input.inlineVariables,
variables,
secretKeys,
});
},
);
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/workspace-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ export class WorkspaceManager {
return entry.envs.resolveVariables(state.activeEnvironment);
}

async resolveActiveSecretKeys(
workspacePath: string,
): Promise<Set<string>> {
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<RecentWorkspace[]> {
const file = join(app.getPath('userData'), RECENT_FILE);
try {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/components/CodePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function CodePanel(): JSX.Element {
const workspace = useAppStore((s) => s.workspace);

const [target, setTarget] = useState<CodegenTarget>('curl');
const [inlineVars, setInlineVars] = useState(true);
const [inlineVars, setInlineVars] = useState(false);
const [code, setCode] = useState<string>('');
const [copied, setCopied] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
Expand Down
1 change: 1 addition & 0 deletions packages/http-core/src/codegen/index.ts
Original file line number Diff line number Diff line change
@@ -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';
9 changes: 9 additions & 0 deletions packages/http-core/src/codegen/mask.ts
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 23 additions & 3 deletions packages/http-core/src/codegen/prepare.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string>();
// Build a variables map where secret values are masked.
const maskedVars: Record<string, string> = {};
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) {
Expand Down
5 changes: 5 additions & 0 deletions packages/http-core/src/codegen/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export interface CodegenOptions {
*/
inlineVariables: boolean;
variables: Record<string, string>;
/**
* Variable keys whose values are secret. When inlineVariables is true,
* these values are partially masked instead of shown in plain text.
*/
secretKeys?: ReadonlySet<string>;
}

export interface Codegen {
Expand Down
17 changes: 17 additions & 0 deletions packages/http-core/src/workspace/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>> {
if (!activeEnv) return new Set();
const env = await this.readEnvironment(activeEnv);
if (!env) return new Set();
const keys = new Set<string>();
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<void> {
Expand Down
109 changes: 108 additions & 1 deletion packages/http-core/tests/codegen.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string>(),
});
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');
});
});
}
});
Loading