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
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-logs.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -1206,7 +1206,7 @@
"type": "null"
}
],
"description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input` values and `password: true`sub-block values are null, while `{{VAR}}` environment-variable references are preserved. Null when no snapshot is retained."
"description": "Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained."
},
"traceSpans": {
"type": "array",
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/openapi-v2-workflows.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2876,7 +2876,7 @@
"format": "date-time"
},
"state": {
"description": "Deployed workflow graph snapshot pinned by this version.",
"description": "Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.",
"$ref": "#/components/schemas/DeployedWorkflowState"
}
},
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,13 +29,32 @@ vi.mock('@/lib/workflows/application/context', () => ({
vi.mock('@/lib/workflows/persistence/utils', () => ({
getWorkflowDeploymentVersion: mocks.readVersion,
}))
vi.mock('@/lib/workflows/search-replace/indexer', () => ({
getToolInputParamConfigs: ({
tool,
}: {
tool: { type: string; params?: Record<string, unknown> }
}) =>
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
paramId,
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
value,
config: {
id: paramId,
type: 'short-input',
password: paramId === 'apiKey',
},
})),
}))
vi.mock('@/blocks/registry', () => ({
getBlock: () => ({
name: 'Slack',
subBlocks: [
{ id: 'credential', type: 'oauth-input' },
{ id: 'botToken', type: 'short-input', password: true },
{ id: 'envToken', type: 'short-input', password: true },
{ id: 'tools', type: 'tool-input' },
{ id: 'headers', type: 'table' },
{ id: 'channel', type: 'short-input' },
],
outputs: {},
Expand DownExpand Up@@ -88,6 +107,21 @@ function versionState() {
credential: { id: 'credential', type: 'oauth-input', value: 'oauth-credential-id' },
botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-plaintext-secret' },
envToken: { id: 'envToken', type: 'short-input', value: '{{SLACK_BOT_TOKEN}}' },
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'custom-tool',
params: { apiKey: 'sk-tool-plaintext-secret', query: 'safe input' },
},
],
},
headers: {
id: 'headers',
type: 'table',
value: [{ Key: 'Authorization', Value: 'Bearer table-plaintext-secret' }],
},
channel: { id: 'channel', type: 'short-input', value: '#general' },
},
},
Expand DownExpand Up@@ -149,6 +183,15 @@ describe('GET /api/v2/workflows/[id]/versions/[version]', () => {
expect(subBlocks.credential.value).toBeNull()
expect(subBlocks.botToken.value).toBeNull()
expect(subBlocks.envToken.value).toBe('{{SLACK_BOT_TOKEN}}')
expect(subBlocks.tools.value).toEqual([
{
type: 'custom-tool',
params: { apiKey: null, query: null },
},
])
expect(subBlocks.headers.value).toBeNull()
expect(subBlocks.channel.value).toBe('#general')
expect(JSON.stringify(subBlocks)).not.toContain('sk-tool-plaintext-secret')
expect(JSON.stringify(subBlocks)).not.toContain('table-plaintext-secret')
})
})
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/logs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,7 @@ const v2LogWorkflowStateSchema = z
)
.nullable()
.describe(
'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input` values and `password: true`sub-block values are null, while `{{VAR}}` environment-variable references are preserved. Null when no snapshot is retained.'
'Workflow graph snapshot captured for the run, with credential values redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null; and `{{VAR}}` references in non-opaque fields are preserved. Null when no snapshot is retained.'
)

const v2LogWorkflowSummarySchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/workflows.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -615,7 +615,7 @@ export const v2WorkflowVersionDetailSchema = z
.describe('ISO 8601 timestamp when this version was created.')
.meta({ format: 'date-time' }),
state: deployedWorkflowStateSchema.describe(
'Deployed workflow graph snapshot pinned by this version.'
'Deployed workflow graph snapshot pinned by this version. Credential-bearing values are redacted: `oauth-input`, `password: true`, and table sub-block values are null; sensitive nested tool parameters and every parameter without authoritative codec metadata are null.'
),
})
.meta({
Expand Down
44 changes: 44 additions & 0 deletions apps/sim/lib/logs/application/public-log-use-cases.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,6 +43,24 @@ vi.mock('@/lib/logs/execution/trace-store', () => ({
materializeExecutionDataForDisplay: mocks.materialize,
}))

vi.mock('@/lib/workflows/search-replace/indexer', () => ({
getToolInputParamConfigs: ({
tool,
}: {
tool: { type: string; params?: Record<string, unknown> }
}) =>
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
paramId,
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
value,
config: {
id: paramId,
type: 'short-input',
password: paramId === 'apiKey',
},
})),
}))

vi.mock('@sim/audit', () => ({ recordAudit: mocks.recordAudit }))

/**
Expand All@@ -56,6 +74,8 @@ vi.mock('@/blocks/registry', () => ({
{ id: 'credential', type: 'oauth-input' },
{ id: 'botToken', type: 'short-input', password: true },
{ id: 'envToken', type: 'short-input', password: true },
{ id: 'tools', type: 'tool-input' },
{ id: 'headers', type: 'table' },
{ id: 'channel', type: 'short-input' },
],
outputs: {},
Expand DownExpand Up@@ -155,6 +175,21 @@ describe('public log application use cases', () => {
credential: { id: 'credential', type: 'oauth-input', value: 'cred_9f2a' },
botToken: { id: 'botToken', type: 'short-input', value: 'xoxb-plaintext-secret' },
envToken: { id: 'envToken', type: 'short-input', value: '{{SLACK_TOKEN}}' },
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'custom-tool',
params: { apiKey: 'sk-log-tool-secret', query: 'safe input' },
},
],
},
headers: {
id: 'headers',
type: 'table',
value: [{ Key: 'Authorization', Value: 'Bearer log-table-secret' }],
},
channel: { id: 'channel', type: 'short-input', value: '#general' },
},
},
Expand All@@ -177,7 +212,16 @@ describe('public log application use cases', () => {
expect(subBlocks.credential.value).toBeNull()
expect(subBlocks.botToken.value).toBeNull()
expect(subBlocks.envToken.value).toBe('{{SLACK_TOKEN}}')
expect(subBlocks.tools.value).toEqual([
{
type: 'custom-tool',
params: { apiKey: null, query: null },
},
])
expect(subBlocks.headers.value).toBeNull()
expect(subBlocks.channel.value).toBe('#general')
expect(JSON.stringify(subBlocks)).not.toContain('sk-log-tool-secret')
expect(JSON.stringify(subBlocks)).not.toContain('log-table-secret')
})

it('passes the personal-key subject through as the projection reader', async () => {
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/lib/logs/snapshot-sanitizer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,13 +11,17 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
*
* `preserveEnvVars` keeps `{{VAR}}` references, which name a workspace environment variable
* rather than carrying its value — resolution happens at execution time — so the reference is
* not a secret and is what keeps consecutive run snapshots diffable.
* not a secret and is what keeps consecutive run snapshots diffable. Tool parameters without
* authoritative codec metadata are withheld rather than guessed safe.
*
* A run with no retained snapshot projects as `null`, and so does a stored value that is not an
* object: the sanitizer can make no guarantee about a shape it cannot walk, so it is withheld
* rather than passed through.
*/
export function sanitizeExecutionSnapshotState(state: unknown): Record<string, unknown> | null {
if (typeof state !== 'object' || state === null) return null
return sanitizeWorkflowForSharing(state as Partial<WorkflowState>, { preserveEnvVars: true })
return sanitizeWorkflowForSharing(state as Partial<WorkflowState>, {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
}
9 changes: 7 additions & 2 deletions apps/sim/lib/workflows/application/read-workflow-version.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,10 +20,15 @@ function isWorkflowState(value: unknown): value is WorkflowState {
*
* `preserveEnvVars` keeps `{{VAR}}` references: those name a workspace environment variable
* rather than carrying its value — resolution happens at execution time — so the reference is
* not a secret and is what keeps the pinned graph diffable. Literal inline secrets are nulled.
* not a secret and is what keeps the pinned graph diffable. Literal inline secrets, opaque table
* cells, sensitive nested tool parameters, and tool parameters without authoritative codec
* metadata are nulled.
*/
function sanitizeVersionState(state: WorkflowState): WorkflowState {
const sanitized = sanitizeWorkflowForSharing(state, { preserveEnvVars: true })
const sanitized = sanitizeWorkflowForSharing(state, {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})
// double-cast-allowed: the sanitizer clones the graph and only nulls sub-block values, so the shape is unchanged, but its widened return type no longer overlaps WorkflowState
return sanitized as unknown as WorkflowState
}
Expand Down
147 changes: 147 additions & 0 deletions apps/sim/lib/workflows/credentials/credential-extractor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,11 +5,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPORT_PRESERVED_RESOURCE_TYPES,
sanitizeForExport,
sanitizeWorkflowForSharing,
} from '@/lib/workflows/credentials/credential-extractor'
import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry'
import { getBlock } from '@/blocks/registry'
import type { WorkflowState } from '@/stores/workflows/workflow/types'

vi.mock('@/lib/workflows/search-replace/indexer', () => ({
getToolInputParamConfigs: ({
tool,
}: {
tool: { type: string; params?: Record<string, unknown> }
}) =>
Object.entries(tool.params ?? {}).map(([paramId, value]) => ({
paramId,
authoritative: tool.type !== 'custom-tool' && tool.type !== 'mcp',
value,
config: {
id: paramId,
type: 'short-input',
password: paramId === 'apiKey' || paramId === 'token',
canonicalParamId: paramId === 'manualCredential' ? 'oauthCredential' : undefined,
},
})),
}))

function stateWithSubBlock(type: string, value: unknown): Partial<WorkflowState> {
return {
blocks: {
Expand DownExpand Up@@ -93,4 +113,131 @@ describe('export sanitizer resource coverage', () => {
} as unknown as Partial<WorkflowState>)
expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBeNull()
})

it('uses authoritative tool-input codecs to withhold secrets while preserving safe config', () => {
const value = [
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: {
apiKey: 'sk-plaintext-secret',
query: 'safe input',
},
},
]
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }],
outputs: {},
} as never)

const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})

expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: { apiKey: null, query: 'safe input' },
},
])
})

it('withholds advanced credential selectors nested inside tool inputs', () => {
const value = [
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: {
manualCredential: 'credential-id',
query: 'safe input',
},
},
]
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }],
outputs: {},
} as never)

const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('tool-input', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})

expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{
type: 'gmail',
toolId: 'gmail_send',
operation: 'send_gmail',
params: { manualCredential: null, query: 'safe input' },
},
])
})

it('withholds opaque table values from public snapshots', () => {
const value = [
{ Key: 'Authorization', Value: 'Bearer plaintext-secret' },
{ Key: 'API_TOKEN', Value: '{{API_TOKEN}}' },
]
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'table' }],
outputs: {},
} as never)

const sanitized = sanitizeWorkflowForSharing(stateWithSubBlock('table', value), {
preserveEnvVars: true,
redactOpaqueCredentialInputs: true,
})

expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull()
})

it('withholds every unclassified custom-tool parameter', () => {
vi.mocked(getBlock).mockReturnValue(undefined as never)

const sanitized = sanitizeWorkflowForSharing(
stateWithSubBlock('tool-input', [
{
type: 'custom-tool',
params: { token: 'plaintext-secret', query: 'ordinary configuration' },
},
]),
{ redactOpaqueCredentialInputs: true }
)

expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{ type: 'custom-tool', params: { token: null, query: null } },
])
})

it.each([
['string', 'plaintext-secret'],
['array', ['plaintext-secret']],
])('withholds malformed %s tool params', (_shape, params) => {
vi.mocked(getBlock).mockReturnValue({
name: 'Test',
description: '',
subBlocks: [{ id: 'field', title: 'Field', type: 'tool-input' }],
outputs: {},
} as never)

const sanitized = sanitizeWorkflowForSharing(
stateWithSubBlock('tool-input', [{ type: 'custom-tool', params }]),
{ redactOpaqueCredentialInputs: true }
)

expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toEqual([
{ type: 'custom-tool', params: null },
])
})
})
Loading
Loading