diff --git a/apps/sim/app/api/secrets/references/route.test.ts b/apps/sim/app/api/secrets/references/route.test.ts new file mode 100644 index 00000000000..b15871f789a --- /dev/null +++ b/apps/sim/app/api/secrets/references/route.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ listReferences: vi.fn() })) + +vi.mock('@/lib/secrets/application/use-cases', () => ({ + listSecretReferencesUseCase: { + operation: { id: 'secrets.references' }, + execute: mocks.listReferences, + }, +})) + +import { GET } from '@/app/api/secrets/references/route' + +const url = 'http://localhost/api/secrets/references?workspaceId=workspace-1&name=API_KEY' + +describe('GET /api/secrets/references', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + }) + + it('returns the workflows, blocks, and resources a secret is wired into', async () => { + mocks.listReferences.mockResolvedValue({ + workflows: [ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' }, + ], + }, + ], + resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }], + truncated: false, + }) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + workflows: [ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' }, + ], + }, + ], + resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }], + truncated: false, + }) + }) + + it('returns empty lists for a secret referenced nowhere', async () => { + mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false }) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ workflows: [], resources: [], truncated: false }) + }) + + it('rejects a request that names no secret', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/secrets/references?workspaceId=workspace-1' + ) + ) + + expect(response.status).toBe(400) + expect(mocks.listReferences).not.toHaveBeenCalled() + }) + + /** + * The contract carries no `scope`. It used to, and because a reference scan is name-based and + * never narrowed by scope, asserting `personal` skipped the admin gate outright — a member + * could read the reference map for any workspace secret. A stray `scope` must therefore reach + * neither the gate nor the scan. + */ + it('ignores a scope the caller tries to assert', async () => { + mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false }) + + const response = await GET(createMockRequest('GET', undefined, {}, `${url}&scope=personal`)) + + expect(response.status).toBe(200) + expect(mocks.listReferences).toHaveBeenCalledTimes(1) + expect(mocks.listReferences.mock.calls[0]?.[0]?.input).toEqual({ + workspaceId: 'workspace-1', + name: 'API_KEY', + }) + }) + + /** + * The use case gates the read behind the same permission that reveals the value. A refusal + * has to reach the client as a refusal — surfacing it as an empty list would read as + * "referenced nowhere" and invite deleting a live key. + */ + it('surfaces the use case refusal rather than an empty list', async () => { + const { ForbiddenOperationError } = await import('@/lib/core/application/forbidden') + mocks.listReferences.mockRejectedValue( + new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required to view this secret usage' + ) + ) + + const response = await GET(createMockRequest('GET', undefined, {}, url)) + + expect(response.status).toBe(403) + }) +}) diff --git a/apps/sim/app/api/secrets/references/route.ts b/apps/sim/app/api/secrets/references/route.ts new file mode 100644 index 00000000000..24cf45f638e --- /dev/null +++ b/apps/sim/app/api/secrets/references/route.ts @@ -0,0 +1,25 @@ +import { getSecretReferencesContract } from '@/lib/api/contracts/secrets' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { secretOperations } from '@/lib/secrets/application/operations' +import { listSecretReferencesUseCase } from '@/lib/secrets/application/use-cases' + +/** GET /api/secrets/references — where one secret is wired in, for the credential detail panel. */ +export const GET = defineInternalJsonRoute({ + contract: getSecretReferencesContract, + auth: internalSessionAuth, + operation: secretOperations.references, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + name: query.name, + }), + useCase: listSecretReferencesUseCase, + /** The scan's shape is already the wire shape — nothing to project or serialize. */ + present: (scan) => scan, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx index 8efb140820d..b19f6aaea86 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx @@ -7,6 +7,13 @@ interface CredentialDetailLayoutProps { back: ReactNode /** Optional controls grouped at the end of the action bar. */ actions?: ReactNode + /** + * Page title, for a view whose subject is the view itself rather than a resource — the same + * slot `SettingsPanel` fills for a detail sub-view like the Forks "Activity" page. A surface + * that leads with a resource uses {@link CredentialDetailHeading} instead; the two are + * alternatives, not a pair. + */ + title?: ReactNode children: ReactNode } @@ -16,7 +23,12 @@ interface CredentialDetailLayoutProps { * supply the slots and body sections; all layout chrome lives here so callsites * stay free of bespoke styling. */ -export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) { +export function CredentialDetailLayout({ + back, + actions, + title, + children, +}: CredentialDetailLayoutProps) { return (
@@ -24,7 +36,11 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe {actions ?
{actions}
: null}
-
{children}
+
+ {/* Same element, class and column position the settings shell gives its page title. */} + {title ?

{title}

: null} + {children} +
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts new file mode 100644 index 00000000000..c9da881f044 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/index.ts @@ -0,0 +1 @@ +export { SecretReferencesPanel } from './secret-references-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx new file mode 100644 index 00000000000..7f602da7e00 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel/secret-references-panel.tsx @@ -0,0 +1,187 @@ +'use client' + +import { Wrench } from '@sim/emcn/icons' +import { McpIcon } from '@/components/icons' +import type { SecretReferenceResourcePayload } from '@/lib/api/contracts' +import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { + customToolIdParam, + mcpServerIdParam, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { focusBlockParam } from '@/app/workspace/[workspaceId]/w/[workflowId]/search-params' +import { getBlock } from '@/blocks/registry' +import { useSecretReferences } from '@/hooks/queries/credentials' + +interface SecretReferencesPanelProps { + workspaceId: string + secretName: string + /** + * This personal secret is overridden by a workspace variable of the same name. References are + * name-based, so every `{{name}}` in the workspace resolves to the workspace variable — whose + * reference map is admin-gated. The owner may still read their own Logs, which is why the view + * is offered at all, so this tab explains the shadowing instead of asking the API for a map it + * will refuse. + */ + shadowed: boolean +} + +/** + * Shown when a capped scan produced nothing to list. Distinct from "not referenced": the scan + * stopped early, so silence here is absence of evidence, and saying otherwise would invite + * deleting a key that four blocks past the cap still depend on. + */ +const TRUNCATED_NOTE = 'This secret is referenced in more places than can be listed here.' + +/** A custom tool and an MCP server can each carry the key more than once, so `id` alone is not a key. */ +function resourceKey(resource: SecretReferenceResourcePayload): string { + return `${resource.kind}:${resource.id}:${resource.field}` +} + +/** + * The field's own label from the block's config — "Tools", "API Key" — rather than the storage + * id the scanner reports. The id is how the value is keyed, not what the block calls it, and a + * reader looking for the field on the canvas is looking for the label. + * + * Falls back to the raw id when the block or field is unregistered, which is honest: an id the + * config cannot name is still better than naming nothing. + */ +function fieldLabel(blockType: string, field: string): string { + return getBlock(blockType)?.subBlocks?.find((subBlock) => subBlock.id === field)?.title ?? field +} + +/** + * The workflow, pointed at the block that carries the reference, so the canvas lands on it + * rather than on its default framing. The target rides in the link itself so it survives a + * middle-click or a reload, which an in-memory handoff could not. + */ +function blockHref(workspaceId: string, workflowId: string, blockId: string): string { + return `/workspace/${workspaceId}/w/${workflowId}?${focusBlockParam.key}=${encodeURIComponent(blockId)}` +} + +/** + * The settings page that owns the resource, deep-linked to its detail through the same param + * that page reads — so a cascade row navigates like a block row instead of dead-ending. + */ +function resourceHref(workspaceId: string, resource: SecretReferenceResourcePayload): string { + const settings = `/workspace/${workspaceId}/settings` + return resource.kind === 'mcp-server' + ? `${settings}/mcp?${mcpServerIdParam.key}=${encodeURIComponent(resource.id)}` + : `${settings}/custom-tools?${customToolIdParam.key}=${encodeURIComponent(resource.id)}` +} + +/** + * Where one secret is wired in: the blocks that name it as `{{KEY}}`, grouped under their + * workflow, then the custom tools and MCP servers whose own bodies carry it. + * + * The companion to the Logs tab, and the half of the question runs cannot answer — a secret + * four blocks depend on but nothing has executed yet has an empty trail and a full list here. + */ +export function SecretReferencesPanel({ + workspaceId, + secretName, + shadowed, +}: SecretReferencesPanelProps) { + const { data, isPending, isError } = useSecretReferences( + { workspaceId, name: secretName }, + !shadowed + ) + + if (shadowed) { + return ( + + Overridden by a workspace variable, so every reference to this name resolves to that + variable instead. + + ) + } + + if (isError) { + return ( + + Could not load references. + + ) + } + + if (isPending) { + return Loading… + } + + if (data.workflows.length === 0 && data.resources.length === 0) { + return ( + + {data.truncated ? TRUNCATED_NOTE : 'This secret is not referenced in any workflow.'} + + ) + } + + return ( +
+ {data.workflows.map((workflow) => ( + +
+ {workflow.blocks.map((block) => { + const BlockIcon = getBlock(block.blockType)?.icon + return ( + + ) : undefined + } + title={block.blockName} + description={fieldLabel(block.blockType, block.field)} + href={blockHref(workspaceId, workflow.workflowId, block.blockId)} + clickLabel={`Open ${block.blockName} in ${workflow.workflowName}`} + navigable + /> + ) + })} +
+
+ ))} + + {data.resources.length > 0 && ( + +
+ {data.resources.map((resource) => ( + + ) : ( + + ) + } + iconFilled + title={resource.name} + description={resource.field} + href={resourceHref(workspaceId, resource)} + clickLabel={`Open ${resource.name}`} + navigable + /> + ))} +
+
+ )} + + {data.truncated &&

{TRUNCATED_NOTE}

} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts index d7585860cc6..c657b225780 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts @@ -15,3 +15,19 @@ export const secretDetailViewUrlKeys = { history: 'push', clearOnDefault: true, } as const + +/** + * Active tab inside the usage view, so a shared `secret-view=usage` link can land on either + * reading. Defaults to `logs`, which is what the header's "See usage" opened before References + * existed — the action's name still promises the trail. + */ +export const secretUsageTabParam = { + key: 'usage-tab', + parser: parseAsStringLiteral(['references', 'logs'] as const).withDefault('logs'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const secretUsageTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 989411cd199..73eac2f7c7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Chip, ChipCopyInput, ChipLink, ChipTextarea } from '@sim/emcn' +import { Chip, ChipCopyInput, ChipLink, ChipModalTabs, ChipTextarea } from '@sim/emcn' import { ArrowLeft, Clock, Key, Send } from '@sim/emcn/icons' import { useQueryState } from 'nuqs' import { SaveDiscardChips } from '@/components/settings/save-discard-actions' @@ -20,10 +20,13 @@ import { import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { useSecretValue } from '@/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SecretReferencesPanel } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-references-panel' import { SecretUsagePanel } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/components/secret-usage-panel' import { secretDetailViewParam, secretDetailViewUrlKeys, + secretUsageTabParam, + secretUsageTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params' import { useWorkspaceCredential } from '@/hooks/queries/credentials' @@ -32,6 +35,14 @@ interface SecretDetailProps { credentialId: string } +type SecretUsageTab = 'references' | 'logs' + +/** Logs leads: it is the default tab, and what "See usage" opened before References existed. */ +const SECRET_USAGE_TABS = [ + { value: 'logs', label: 'Logs' }, + { value: 'references', label: 'References' }, +] as const + export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { const secretsHref = `/workspace/${workspaceId}/settings/secrets` @@ -44,6 +55,10 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { ...secretDetailViewParam.parser, ...secretDetailViewUrlKeys, }) + const [usageTab, setUsageTab] = useQueryState(secretUsageTabParam.key, { + ...secretUsageTabParam.parser, + ...secretUsageTabUrlKeys, + }) const valueField = useSecretValue({ workspaceId, credential }) @@ -159,28 +174,44 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { /** * Usage is a destination reached from the header, the same shape as the Forks tab's * "See activity" — it replaces the secret rather than expanding inside it, so the two - * readings never compete for the same column. Back returns with `replace`, since opening - * already pushed. + * readings never compete for the same column. It leads with a plain page title like that + * view does: the back chip already names the secret, so a resource heading would say it + * twice. Back returns with `replace`, since opening already pushed, and clears the tab in + * the same batched write so no `?usage-tab=` lingers on the secret's own URL. */ if (canViewUsage && view === 'usage') { + const secretName = credential.envKey || '' + const scope = isPersonal ? 'personal' : 'workspace' return ( void setView(null, { history: 'replace' })}> + { + void setView(null, { history: 'replace' }) + void setUsageTab(null, { history: 'replace' }) + }} + > {credential.envKey || credential.displayName} } + title='Usage' > - } - title='Usage' - subtitle={credential.envKey || credential.displayName} - /> - void setUsageTab(value as SecretUsageTab)} + aria-label='Secret usage views' /> + {usageTab === 'references' ? ( + + ) : ( + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/focus-block-deep-link.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/focus-block-deep-link.tsx new file mode 100644 index 00000000000..c898425ed67 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/focus-block-deep-link.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useQueryState } from 'nuqs' +import { focusBlockParam } from '@/app/workspace/[workspaceId]/w/[workflowId]/search-params' + +interface FocusBlockDeepLinkProps { + /** Called once with the inbound target. The canvas owns what "focus" means. */ + onTarget: (blockId: string) => void +} + +/** + * Reads the inbound `?block=` target and hands it to the canvas exactly once. + * + * Exists as its own component for one structural reason: `useQueryState` reads + * `useSearchParams`, which Next requires under a Suspense boundary. Confining that read here + * lets the editor keep its current mount path — no boundary around `Workflow` itself, no + * `loading.tsx` — and an inner boundary with `fallback={null}` is the sanctioned shape for a + * suspending leaf that renders nothing. + * + * Read-then-strip, behind a ref latch: the target is an instruction, not view-state, so once the + * canvas has it the param is cleared with `replace` so it neither lingers on the URL nor + * re-fires when the reader re-renders. Stripping is also what lets a second visit to the same + * block re-assert the camera rather than being swallowed as "already applied". + */ +export function FocusBlockDeepLink({ onTarget }: FocusBlockDeepLinkProps) { + const [blockId, setBlockId] = useQueryState(focusBlockParam.key, focusBlockParam.parser) + + /* Ref, so consuming the target does not depend on the caller memoizing `onTarget`. */ + const onTargetRef = useRef(onTarget) + useEffect(() => { + onTargetRef.current = onTarget + }, [onTarget]) + + /** + * Latched while a target is present and released when the param clears — the same shape the + * canvas's note reveal uses. A latch that only ever set would swallow the second visit to a + * block, since stripping returns the param to null and arriving again re-supplies the very + * same id. + */ + const appliedRef = useRef(false) + useEffect(() => { + if (!blockId) { + appliedRef.current = false + return + } + if (appliedRef.current) return + appliedRef.current = true + onTargetRef.current(blockId) + void setBlockId(null, { history: 'replace', scroll: false }) + }, [blockId, setBlockId]) + + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts new file mode 100644 index 00000000000..d7e2577acc3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link/index.ts @@ -0,0 +1 @@ +export { FocusBlockDeepLink } from './focus-block-deep-link' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts new file mode 100644 index 00000000000..15a2ca55ce7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/search-params.ts @@ -0,0 +1,19 @@ +import { parseAsString } from 'nuqs/server' + +/** + * `block` points an inbound link at one block, so a surface that knows where something lives — + * the secret References tab, naming the block that carries a `{{KEY}}` — can land the reader on + * it instead of on the workflow's default framing. + * + * The lone param on the editor route, and deliberately so: `.claude/rules/sim-url-state.md` keeps + * the canvas's own view-state (pan, zoom, selection, drag) in Zustand because it is + * socket-synced, high-frequency, or a persisted preference. This is none of those. It is a + * read-once navigation signal, consumed on arrival and stripped, in the same family as + * integrations' `?connect=` — not canvas state that lives in the URL. + * + * Nullable with no default: absent means "no target", which is the overwhelmingly common case. + */ +export const focusBlockParam = { + key: 'block', + parser: parseAsString, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index aa839056345..14bb7d539c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -66,6 +66,7 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector' import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' +import { FocusBlockDeepLink } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/focus-block-deep-link' import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace' import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls' import { @@ -4675,6 +4676,61 @@ const WorkflowContent = React.memo( focusBlockInView(node) }, [blocks, displayNodes, embedded, focusBlockInView, searchMatchBlockId, searchMatchId]) + /** + * Inbound `?block=` target, held until the canvas can act on it. State rather than a ref + * because the effect below has to re-run on the commit that finally mounts the node. + */ + const [deepLinkBlockId, setDeepLinkBlockId] = useState(null) + + useEffect(() => { + if (embedded || !deepLinkBlockId) return + + /* Same reason as the note reveal above: read from `displayNodes` so a target that lands + before its node mounts is retried on the mounting commit instead of dropped. */ + const node = displayNodes.find((candidate) => candidate.id === deepLinkBlockId) + if (!node) { + /* Absent is only conclusive once THIS workflow's graph is the one loaded. `isWorkflowReady` + is that test — it pins `hydration.workflowId` and `activeWorkflowId` to the id in the + URL — where a count of mounted nodes is not: arriving from another workflow, the store + still holds that graph, so nodes are present while the linked workflow is still + hydrating and a valid target would be thrown away. + + Releasing it matters because the param is already stripped: a target held forever would + re-check on every canvas update and shadow a later link to the same block. Dropping it + leaves the default framing, which is what the link did before it carried a target. */ + if (isWorkflowReady) setDeepLinkBlockId(null) + return + } + + setDeepLinkBlockId(null) + + /* Claim the framing before the canvas can re-init over it. `onInit` re-reads this ref + inside its own `requestAnimationFrame`, so setting it here suppresses the initial + `fitView` whenever this effect wins the race — and is harmless when it does not, since + `focusBlockInView` animates from wherever the fit left the camera. */ + userFocusedWorkflowIdRef.current = activeWorkflowId ?? workflowIdParam + + setDisplayNodes((currentNodes) => + resolveSelectionConflicts( + currentNodes.map((currentNode) => ({ + ...currentNode, + selected: currentNode.id === node.id, + })), + blocks + ) + ) + focusBlockInView(node) + }, [ + activeWorkflowId, + blocks, + deepLinkBlockId, + displayNodes, + embedded, + focusBlockInView, + isWorkflowReady, + workflowIdParam, + ]) + /** Handles edge selection with container context tracking and Shift-click multi-selection. */ const onEdgeClick = useCallback( (event: React.MouseEvent, edge: any) => { @@ -5141,6 +5197,10 @@ const WorkflowContent = React.memo( {!embedded && ( <> + {/* Renders nothing; the boundary is what `useSearchParams` needs. */} + + + diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index ae181b450e4..6f64d56213f 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -8,6 +8,7 @@ import { createCredentialDraftContract, createWorkspaceCredentialContract, deleteWorkspaceCredentialContract, + getSecretReferencesContract, getSecretUsageContract, getWorkspaceCredentialContract, listWorkspaceCredentialMembersContract, @@ -335,3 +336,33 @@ export function useSecretUsage({ workspaceId, name, scope }: SecretUsageParams, staleTime: SECRET_USAGE_STALE_TIME, }) } + +/** + * References only move when someone edits a workflow, a custom tool, or an MCP server — far + * less often than the usage trail, which every run appends to. A longer window keeps the scan + * (which reads every candidate block in the workspace) off the wire on tab switches. + */ +export const SECRET_REFERENCES_STALE_TIME = 5 * 60 * 1000 + +interface SecretReferencesParams { + workspaceId?: string + name?: string +} + +/** + * Reads where one secret is wired in. Takes no scope: a reference names a key, not a scope, and + * the server authorizes against what the name resolves to. Only credential admins of a workspace + * secret — or the owner of a personal one — are authorized server-side. + */ +export function useSecretReferences({ workspaceId, name }: SecretReferencesParams, enabled = true) { + return useQuery({ + queryKey: workspaceCredentialKeys.references(workspaceId, name), + queryFn: ({ signal }) => + requestJson(getSecretReferencesContract, { + query: { workspaceId: workspaceId as string, name: name as string }, + signal, + }), + enabled: Boolean(workspaceId && name) && enabled, + staleTime: SECRET_REFERENCES_STALE_TIME, + }) +} diff --git a/apps/sim/hooks/queries/utils/credential-keys.ts b/apps/sim/hooks/queries/utils/credential-keys.ts index c81b662fcab..db7c276b29f 100644 --- a/apps/sim/hooks/queries/utils/credential-keys.ts +++ b/apps/sim/hooks/queries/utils/credential-keys.ts @@ -33,4 +33,10 @@ export const workspaceCredentialKeys = { scope ?? 'all', name ?? '', ] as const, + /** + * Keyed by name alone — references are found by name, so neither the credential id nor a + * scope narrows the result, and adding either would split one answer across cache entries. + */ + references: (workspaceId?: string, name?: string) => + [...workspaceCredentialKeys.all, 'references', workspaceId ?? 'none', name ?? ''] as const, } diff --git a/apps/sim/lib/api/contracts/secrets.ts b/apps/sim/lib/api/contracts/secrets.ts index 2aaea797f2a..d4b94bebaf0 100644 --- a/apps/sim/lib/api/contracts/secrets.ts +++ b/apps/sim/lib/api/contracts/secrets.ts @@ -43,6 +43,70 @@ export const getSecretUsageContract = defineRouteContract({ }, }) +/** + * Ceilings on one scan's payload. These match the caps `lib/secrets/references/scan.ts` stops + * at — in particular `resources` is capped on EMITTED entries there, not on rows read, because + * one MCP server expands to an entry per matching header. Raising a bound here without raising + * the scanner's cap is harmless; lowering one below it makes the route reject its own response. + */ +const SECRET_REFERENCE_MAX_WORKFLOWS = 2000 +const SECRET_REFERENCE_MAX_BLOCKS = 2000 +const SECRET_REFERENCE_MAX_RESOURCES = 400 + +/** + * No `scope`, unlike the usage query. A `{{KEY}}` reference names a key and not a scope, so the + * scan is name-based and the use case authorizes against what the name resolves to. A scope here + * would be a caller-controlled assertion that nothing narrows by — a bypass, not an input. + */ +export const secretReferencesQuerySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), + name: z.string().min(1, 'Secret name is required'), +}) + +export const secretReferenceBlockSchema = z.object({ + blockId: z.string().min(1, 'blockId cannot be empty'), + blockName: z.string(), + blockType: z.string().min(1, 'blockType cannot be empty'), + /** A sub-block key carrying the reference — one per block, not necessarily the only one. */ + field: z.string().min(1, 'field cannot be empty'), +}) + +export const secretReferenceWorkflowSchema = z.object({ + workflowId: z.string().min(1, 'workflowId cannot be empty'), + workflowName: z.string(), + blocks: z + .array(secretReferenceBlockSchema) + .min(1, 'A referencing workflow must name at least one block') + .max(SECRET_REFERENCE_MAX_BLOCKS), +}) + +export const secretReferenceResourceSchema = z.object({ + id: z.string().min(1, 'resource id cannot be empty'), + kind: z.enum(['custom-tool', 'mcp-server']), + name: z.string(), + /** Where inside the resource the reference lives — `code`, `url`, or `header: X`. */ + field: z.string().min(1, 'field cannot be empty'), +}) + +export const getSecretReferencesContract = defineRouteContract({ + method: 'GET', + path: '/api/secrets/references', + query: secretReferencesQuerySchema, + response: { + mode: 'json', + schema: z.object({ + workflows: z.array(secretReferenceWorkflowSchema).max(SECRET_REFERENCE_MAX_WORKFLOWS), + resources: z.array(secretReferenceResourceSchema).max(SECRET_REFERENCE_MAX_RESOURCES), + /** True when a scan cap was hit, so the lists are a prefix rather than the whole set. */ + truncated: z.boolean(), + }), + }, +}) + export type SecretUsageScope = z.output export type SecretUsageQuery = z.input export type SecretUsageEntryPayload = z.output +export type SecretReferencesQuery = z.input +export type SecretReferenceWorkflowPayload = z.output +export type SecretReferenceBlockPayload = z.output +export type SecretReferenceResourcePayload = z.output diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index c43e1c2ba55..d9a8f6ff919 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -1,5 +1,11 @@ import { db } from '@sim/db' -import { credential, credentialMember, permissions, workspace } from '@sim/db/schema' +import { + credential, + credentialMember, + permissions, + workspace, + workspaceEnvironment, +} from '@sim/db/schema' import { permissionSatisfies } from '@sim/platform-authz/workspace' import { chunkArray } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -169,6 +175,32 @@ export async function getPersonalEnvKeyRawAccess(params: { return { ownedKeys, adminKeys } } +/** + * Whether the workspace holds a value under this env key, read from the authoritative + * `workspace_environment.variables` map. + * + * Deliberately NOT {@link getWorkspaceEnvKeyAdminAccess}'s `knownKeys`, which answers the + * narrower "does an `env_workspace` credential row exist". A legacy value written before the + * credential ACL existed has no such row yet still wins at run time, so a gate that reads + * `knownKeys` as "there is no workspace secret here" would let a non-admin through on exactly + * the keys that predate the ACL. Callers deciding whether a NAME belongs to the workspace must + * ask this; callers deciding who may administer an ACL keep asking `knownKeys`. + */ +export async function hasWorkspaceEnvValue(params: { + workspaceId: string + envKey: string +}): Promise { + const [row] = await db + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, params.workspaceId)) + .limit(1) + + const variables = row?.variables + if (!variables || typeof variables !== 'object') return false + return Object.hasOwn(variables as Record, params.envKey) +} + /** * For a set of workspace env keys, resolves which the caller may administer * (active `credential_member` with role `admin`) and which already have an diff --git a/apps/sim/lib/secrets/application/operations.ts b/apps/sim/lib/secrets/application/operations.ts index 67c113dee8d..00f408fd84e 100644 --- a/apps/sim/lib/secrets/application/operations.ts +++ b/apps/sim/lib/secrets/application/operations.ts @@ -31,6 +31,17 @@ export const secretOperations = { workspaceApiKey: 'deny', principalKinds: HUMAN_API_PRINCIPAL_KINDS, }), + /** + * Reading where a secret is wired in names workflows, blocks, and the tools and servers that + * carry it — the same shape of disclosure as {@link usage}, so it takes the same floor and + * the same narrowing in the use case. + */ + references: defineWorkspaceOperation({ + id: 'secrets.references', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: HUMAN_API_PRINCIPAL_KINDS, + }), } as const export type SecretOperation = (typeof secretOperations)[keyof typeof secretOperations] diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts index 6bdccfc65a9..f0c10ea4a2b 100644 --- a/apps/sim/lib/secrets/application/use-cases.test.ts +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -21,6 +21,8 @@ const { mocks } = vi.hoisted(() => ({ deletePersonal: vi.fn(), listCredentials: vi.fn(), secretUsage: vi.fn(), + workspaceEnvValue: vi.fn(), + scanReferences: vi.fn(), audit: vi.fn(), }, })) @@ -47,6 +49,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/credentials/environment', () => ({ getWorkspaceEnvKeyAdminAccess: mocks.keyAccess, getPersonalEnvCredentialMetadata: mocks.personalMetadata, + hasWorkspaceEnvValue: mocks.workspaceEnvValue, +})) +vi.mock('@/lib/secrets/references/scan', () => ({ + scanSecretReferences: mocks.scanReferences, })) vi.mock('@/lib/credentials/queries', () => ({ listVisibleWorkspaceCredentials: mocks.listCredentials, @@ -63,6 +69,8 @@ vi.mock('@/lib/credentials/secret-values', () => ({ import { deleteSecretUseCase, + type ListSecretReferencesInput, + listSecretReferencesUseCase, listSecretUsageUseCase, setSecretUseCase, } from '@/lib/secrets/application/use-cases' @@ -399,3 +407,105 @@ describe('listSecretUsageUseCase', () => { expect(mocks.keyAccess).not.toHaveBeenCalled() }) }) + +describe('listSecretReferencesUseCase', () => { + const execute = listSecretReferencesUseCase.execute as (args: { + principal: Principal + input: ListSecretReferencesInput + }) => Promise + + const input: ListSecretReferencesInput = { + workspaceId: workspace.workspaceId, + name: 'STRIPE_API_KEY', + } + const scan = { workflows: [], resources: [], truncated: false } + + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.scanReferences.mockResolvedValue(scan) + }) + + /** + * The map names workflows, blocks, tools and servers. A Member who may use the secret but not + * read it has no claim on it, so this must fail the same way the usage trail does. + */ + it('denies a member who is not an admin of a workspace key', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(true) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.scanReferences).not.toHaveBeenCalled() + }) + + /** + * The gate reads the authoritative variables map, not `knownKeys`. A legacy value predating + * the credential ACL has no `env_workspace` row yet still wins at run time, so treating an + * empty `knownKeys` as "no workspace secret" would hand a non-admin exactly the oldest keys. + */ + it('denies a personal owner when a legacy workspace value shares the name', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(true) + mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' }) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.scanReferences).not.toHaveBeenCalled() + }) + + it('denies a caller who holds no secret of that name', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(false) + mocks.personalMetadata.mockResolvedValue(null) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.scanReferences).not.toHaveBeenCalled() + }) + + it('allows the owner of a personal secret that no workspace value shadows', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.workspaceEnvValue.mockResolvedValue(false) + mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' }) + + await expect(execute({ principal: session, input })).resolves.toEqual(scan) + }) + + it('allows a credential admin of that key', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ + knownKeys: new Set(['STRIPE_API_KEY']), + adminKeys: new Set(['STRIPE_API_KEY']), + }) + + await expect(execute({ principal: session, input })).resolves.toEqual(scan) + // An admin stays authorized however the name resolves, so the volatile input is never read. + expect(mocks.workspaceEnvValue).not.toHaveBeenCalled() + }) + + /** + * A `personal` grant rests on no workspace value existing under the name. If one is created + * between the check and the scan, the map now in hand is admin-gated — so the condition is + * re-read after the scan and the request fails closed rather than returning it. + */ + it('refuses when a workspace value appears between the check and the scan', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) + mocks.personalMetadata.mockResolvedValue({ id: 'cred-1' }) + mocks.workspaceEnvValue.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + await expect(execute({ principal: session, input })).rejects.toThrow( + 'Credential admin permission required to view this secret usage' + ) + expect(mocks.workspaceEnvValue).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index c4d948f47c6..188aa60e664 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -7,6 +7,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { getPersonalEnvCredentialMetadata, getWorkspaceEnvKeyAdminAccess, + hasWorkspaceEnvValue, } from '@/lib/credentials/environment' import { listVisibleWorkspaceCredentials, @@ -19,6 +20,7 @@ import { setWorkspaceSecret, } from '@/lib/credentials/secret-values' import { secretOperations } from '@/lib/secrets/application/operations' +import { scanSecretReferences } from '@/lib/secrets/references/scan' import { getSecretUsage } from '@/lib/secrets/usage/queries' import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -379,6 +381,12 @@ async function requireSecretUsageReadAccess(params: { scope: SecretScope userId: string }): Promise { + /** + * Safe to trust the asserted scope here, and only here: the read below is NARROWED by it to + * `secretOwnerUserId`, so asserting `personal` for a workspace key returns the caller's own + * (empty) trail rather than the workspace one. A read that is not scope-narrowed must not + * reuse this — see {@link requireSecretReferencesReadAccess}. + */ if (params.scope === 'personal') return const [workspaceAccess, keyAccess] = await Promise.all([ @@ -427,3 +435,122 @@ export const listSecretUsageUseCase = defineAuthorizedWorkspaceUseCase({ }) }, }) + +/** + * Deliberately carries no `scope`. A reference is found by name, so a scope here could only be + * an assertion the caller controls and the read never narrows by — exactly the shape that made + * the first cut of this operation bypassable. The name alone decides both access and result. + */ +export interface ListSecretReferencesInput { + workspaceId: string + name: string +} + +/** + * Gates the reference scan on what the NAME resolves to, never on the scope the caller asserts. + * + * The usage trail can trust `scope` because it narrows the read by it — a personal request is + * filtered to `secretOwnerUserId`, so asserting `personal` for a workspace key returns nothing. + * A reference scan cannot: `{{KEY}}` names a key and not a scope, so the scan is name-based and + * workspace-wide by construction. Reusing the trail's gate therefore made `scope=personal` a + * bypass — it returns before any check, and a member could read the admin-gated map for any + * workspace secret by naming it under personal scope. + * + * So the asserted scope is discarded here and the canonical name decides: + * - a workspace secret exists under this name → only its admins (or a workspace admin) may look, + * which is the same predicate that reveals its value; + * - no workspace secret exists → the caller must actually hold a personal secret of that name, + * which stops a member enumerating arbitrary names for a map they have no claim to. + */ +/** + * Which branch authorized the read. `personal` depends on no workspace value existing under the + * name — a condition another request can change — so only that branch needs re-checking after + * the scan. An `admin` caller stays authorized however the name resolves, and pays nothing. + */ +type SecretReferencesGrant = 'admin' | 'personal' + +async function requireSecretReferencesReadAccess(params: { + workspaceId: string + name: string + userId: string +}): Promise { + const [workspaceAccess, keyAccess] = await Promise.all([ + checkWorkspaceAccess(params.workspaceId, params.userId), + getWorkspaceEnvKeyAdminAccess({ + workspaceId: params.workspaceId, + envKeys: [params.name], + userId: params.userId, + }), + ]) + if (workspaceAccess.canAdmin || keyAccess.adminKeys.has(params.name)) return 'admin' + + const forbidden = new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required to view this secret usage' + ) + + /** + * A workspace value under this name is admin-gated outright — a personal secret the caller + * happens to hold under the same name does not unlock the workspace one's reference map, + * and at run time the workspace value is the one that wins anyway. + * + * Read from the authoritative variables map rather than `keyAccess.knownKeys`: that set only + * covers names with an `env_workspace` credential row, so a legacy value written before the + * ACL existed would look like "no workspace secret here" and fall through to the personal + * branch — handing a non-admin the map for exactly the oldest keys. + */ + if (await hasWorkspaceEnvValue({ workspaceId: params.workspaceId, envKey: params.name })) { + throw forbidden + } + + const owned = await getPersonalEnvCredentialMetadata({ + userId: params.userId, + envKey: params.name, + }) + if (!owned) throw forbidden + return 'personal' +} + +export const listSecretReferencesUseCase = defineAuthorizedWorkspaceUseCase({ + operation: secretOperations.references, + resolveContext: ({ input }: { input: ListSecretReferencesInput }) => + resolveWorkspaceContext(input.workspaceId), + authorizationOptions, + async execute({ principal, input, context }) { + const userId = principalUserId(principal) + const grant = await requireSecretReferencesReadAccess({ + workspaceId: context.workspaceId, + name: input.name, + userId, + }) + + /** + * Name-based, not scope-narrowed: the same reference sites answer for a workspace secret and + * for the personal one it shadows. Narrowing by scope would report a personal secret as + * unreferenced the moment a workspace variable of the same name existed. + */ + const scan = await scanSecretReferences({ + workspaceId: context.workspaceId, + name: input.name, + }) + + /** + * Re-check the one input that can change under us. A `personal` grant rests on no workspace + * value existing under this name; a workspace secret created between the check and the scan + * would make the very map now in hand admin-gated. The read is cheap and skipped entirely + * for an `admin` grant, and failing closed here costs a non-admin nothing they were entitled + * to keep — the request is simply refused the way it would have been a moment later. + */ + if ( + grant === 'personal' && + (await hasWorkspaceEnvValue({ workspaceId: context.workspaceId, envKey: input.name })) + ) { + throw new ForbiddenOperationError( + 'SECRET_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required to view this secret usage' + ) + } + + return scan + }, +}) diff --git a/apps/sim/lib/secrets/references/scan.test.ts b/apps/sim/lib/secrets/references/scan.test.ts new file mode 100644 index 00000000000..89f0345bf14 --- /dev/null +++ b/apps/sim/lib/secrets/references/scan.test.ts @@ -0,0 +1,389 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { scanSecretReferences } from '@/lib/secrets/references/scan' + +/** A stored block row as the scan reads it, with one short-input sub-block. */ +function blockRow(overrides: { + blockId: string + blockName: string + workflowId: string + workflowName: string + subBlocks: Record + blockType?: string +}) { + return { + blockType: 'agent', + data: {}, + ...overrides, + } +} + +function shortInput(key: string, value: unknown) { + return { [key]: { id: key, type: 'short-input', value } } +} + +describe('scanSecretReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('groups referencing blocks under their workflow', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }), + blockRow({ + blockId: 'block-2', + blockName: 'Post results', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('headers', 'Bearer {{API_KEY}}'), + }), + blockRow({ + blockId: 'block-3', + blockName: 'Notify', + workflowId: 'workflow-2', + workflowName: 'Alerting', + subBlocks: shortInput('token', '{{API_KEY}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows).toEqual([ + { + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + blocks: [ + { blockId: 'block-1', blockName: 'Fetch orders', blockType: 'agent', field: 'apiKey' }, + { blockId: 'block-2', blockName: 'Post results', blockType: 'agent', field: 'headers' }, + ], + }, + { + workflowId: 'workflow-2', + workflowName: 'Alerting', + blocks: [{ blockId: 'block-3', blockName: 'Notify', blockType: 'agent', field: 'token' }], + }, + ]) + expect(scan.truncated).toBe(false) + }) + + /** + * The SQL prefilter matches the reference syntax, so these never reach the scanner in + * production — but the scanner stays the authority, and these pin that it agrees. Reporting + * them would send someone rotating one key to edit blocks that never touch it. + */ + it('drops a block whose reference only shares a prefix with the name', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Staging call', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY_TEST}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows).toEqual([]) + }) + + /** A prefilter hit with no `{{ }}` around the name is prose, not a reference. */ + it('drops a block that only names the secret in free text', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Docs', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('systemPrompt', 'Ask the admin for the API_KEY value.'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows).toEqual([]) + }) + + /** + * The fork remapper collapses a block's references to one per `(kind, sourceId)`, so a block + * naming the secret twice yields one entry, not two. Pinned here because the panel renders + * `field` as the row's whole description — if this ever became a list, the row would need to + * say so rather than silently naming one of several. + */ + it('reports one entry for a block that references the secret in two fields', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: { + ...shortInput('apiKey', '{{API_KEY}}'), + ...shortInput('headers', 'Bearer {{API_KEY}}'), + ...shortInput('url', 'https://example.com'), + }, + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + const blocks = scan.workflows[0]?.blocks ?? [] + expect(blocks).toHaveLength(1) + expect(['apiKey', 'headers']).toContain(blocks[0]?.field) + }) + + /** + * `{subBlockId}-tool-{index}-{paramId}` keys are a client-only projection of the canonical + * `tool-input` value that older rows persisted anyway. Reporting one puts an internal key + * where the reader expects a field name, so the canonical sub-block has to win. + */ + it('reports the canonical field rather than a persisted tool mirror', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Agent 1', + workflowId: 'workflow-1', + workflowName: 'Exa Tool Demo', + subBlocks: { + ...shortInput('tools', [{ params: { code: 'const k = "{{API_KEY}}"' } }]), + ...shortInput('tools-tool-0-code', 'const k = "{{API_KEY}}"'), + }, + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks[0]?.field).toBe('tools') + }) + + it('finds a reference nested inside a sub-block value', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Call API', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('params', [{ name: 'auth', value: '{{API_KEY}}' }]), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks[0]?.field).toBe('params') + }) + + /** + * `ENV_REF_PATTERN`'s `\s` spans more than ASCII, so a value pasted with a non-breaking or + * ideographic space inside the braces is a reference the executor resolves. Reporting it as + * unreferenced is the one failure direction this feature must never take. + */ + it.each([ + ['ascii space', '{{ API_KEY }}'], + ['tab', '{{\tAPI_KEY\t}}'], + ['newline', '{{\nAPI_KEY\n}}'], + ['non-breaking space', '{{ API_KEY }}'], + ['narrow non-breaking space', '{{ API_KEY }}'], + ['ideographic space', '{{ API_KEY }}'], + ])('tolerates %s inside the reference braces', async (_label, value) => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Call API', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', value), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks[0]?.field).toBe('apiKey') + }) + + /** + * One unreadable block must not blank the whole tab — the other blocks are still the honest + * answer, and a reference reported without the field that carries it is worse than omitted. + */ + it('skips a block whose sub-blocks cannot be scanned', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Corrupt', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: null as unknown as Record, + }), + blockRow({ + blockId: 'block-2', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks.map((block) => block.blockId)).toEqual(['block-2']) + }) + + it('reports custom tools and MCP servers that carry the secret', async () => { + queueTableRows(schemaMock.customTools, [ + { id: 'tool-1', title: 'Order lookup', code: 'fetch(url, { key: "{{API_KEY}}" })' }, + { id: 'tool-2', title: 'Unrelated', code: 'const label = "API_KEY"' }, + ]) + queueTableRows(schemaMock.mcpServers, [ + { + id: 'server-1', + name: 'Billing', + url: 'https://example.com?token={{API_KEY}}', + headers: { Authorization: 'Bearer {{API_KEY}}', 'X-Trace': 'on' }, + }, + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.resources).toEqual([ + { id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }, + { id: 'server-1', kind: 'mcp-server', name: 'Billing', field: 'url' }, + { id: 'server-1', kind: 'mcp-server', name: 'Billing', field: 'header: Authorization' }, + ]) + }) + + it('flags a scan that hit the block cap', async () => { + queueTableRows( + schemaMock.workflowBlocks, + Array.from({ length: 2001 }, (_, index) => + blockRow({ + blockId: `block-${index}`, + blockName: `Block ${index}`, + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }) + ) + ) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.truncated).toBe(true) + expect(scan.workflows[0]?.blocks).toHaveLength(2000) + }) + + /** + * Landing exactly on a bound is a complete scan, not a truncated one. Claiming truncation + * there tells the reader references may be missing when every one was returned — the same + * "absence of evidence" note, on a scan that has none. + */ + it('does not claim truncation for a scan that ends exactly on the result limit', async () => { + queueTableRows( + schemaMock.workflowBlocks, + Array.from({ length: 2000 }, (_, index) => + blockRow({ + blockId: `block-${index}`, + blockName: `Block ${index}`, + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }) + ) + ) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks).toHaveLength(2000) + expect(scan.truncated).toBe(false) + }) + + /** + * The cap counts what is reported, not what is read. The prefilter judges syntax only, so a + * block naming the secret in prose is a genuine candidate that the scanner then rejects — and + * when the cap counted candidates, enough of those sorted earlier pushed real references out of + * the answer entirely. + */ + it('does not let filtered candidates displace real references', async () => { + const noise = Array.from({ length: 2500 }, (_, index) => + blockRow({ + blockId: `noise-${index}`, + blockName: `Noise ${index}`, + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('systemPrompt', 'the API_KEY is configured elsewhere'), + }) + ) + const real = blockRow({ + blockId: 'block-real', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }) + queueTableRows(schemaMock.workflowBlocks, [...noise, real]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.workflows[0]?.blocks.map((block) => block.blockId)).toEqual(['block-real']) + }) + + it('returns nothing for a secret referenced nowhere', async () => { + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan).toEqual({ workflows: [], resources: [], truncated: false }) + }) + + /** + * A name outside the env-key charset can never sit inside `{{ }}`, so the scan short-circuits + * before touching the database — which is also what makes it safe to inline the name into the + * SQL regex without escaping. + */ + it('scans nothing for a name that cannot be an env reference', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + blockRow({ + blockId: 'block-1', + blockName: 'Fetch orders', + workflowId: 'workflow-1', + workflowName: 'Nightly sync', + subBlocks: shortInput('apiKey', '{{API_KEY}}'), + }), + ]) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API.*KEY' }) + + expect(scan).toEqual({ workflows: [], resources: [], truncated: false }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + /** + * The row caps bound what is READ; one MCP server expands to an entry per matching header, so + * the emitted total is what has to stay inside the contract's array bound. Without this the + * route rejects its own response and a successful scan surfaces as a 500. + */ + it('caps emitted resources so the response bound holds', async () => { + const headers: Record = {} + for (let index = 0; index < 300; index++) headers[`X-Key-${index}`] = 'Bearer {{API_KEY}}' + queueTableRows( + schemaMock.mcpServers, + Array.from({ length: 3 }, (_, index) => ({ + id: `server-${index}`, + name: `Server ${index}`, + url: 'https://example.com', + headers, + })) + ) + + const scan = await scanSecretReferences({ workspaceId: 'workspace-1', name: 'API_KEY' }) + + expect(scan.resources).toHaveLength(400) + expect(scan.truncated).toBe(true) + }) +}) diff --git a/apps/sim/lib/secrets/references/scan.ts b/apps/sim/lib/secrets/references/scan.ts new file mode 100644 index 00000000000..3bbcfc93b6b --- /dev/null +++ b/apps/sim/lib/secrets/references/scan.ts @@ -0,0 +1,399 @@ +import { db } from '@sim/db' +import { customTools, mcpServers, workflow, workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, eq, isNull, sql } from 'drizzle-orm' +import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' +import { ENV_REF_PATTERN, remapSubBlocks } from '@/ee/workspace-forking/lib/remap/remap-references' + +const logger = createLogger('SecretReferenceScan') + +/** + * Cap on blocks REPORTED — confirmed references, not rows read. + * + * Capping candidates instead made the answer depend on how many irrelevant rows happened to sort + * first. The prefilter can only judge syntax, while `remapSubBlocks` additionally drops dormant + * canonical members and condition-hidden fields — semantics no SQL predicate can replicate — so a + * block whose only `{{name}}` sits in a hidden field is a real candidate that yields nothing, and + * enough of them sorted earlier displaced active references out of the result. + */ +const BLOCK_RESULT_LIMIT = 2000 + +/** + * Ceiling on candidate rows read, so a scan cannot walk an unbounded set. + * + * Sits above {@link BLOCK_RESULT_LIMIT} so semantically filtered rows — dormant members and + * condition-hidden fields, which the SQL cannot judge — are absorbed as extra reads rather than + * displacing results. The headroom is deliberately modest: every candidate carries its block's + * `sub_blocks`, so this is the memory bound as much as the work bound. + * + * It is also the irreducible one. Bounded work and guaranteed completeness cannot both hold, so + * the only real choices are where the bound sits and whether it counts something the reader can + * see. It counts results. + */ +const BLOCK_CANDIDATE_CEILING = 4000 + +/** Matching cap for each cascade table, which are far smaller than the block table. */ +const RESOURCE_SCAN_LIMIT = 200 + +/** + * Ceiling on EMITTED resource entries, matching `secretReferenceResourceSchema`'s array bound in + * the secrets contract. Capping rows alone is not enough: one MCP server yields an entry per + * matching header plus one for its url, so 200 server rows can expand past the declared bound and + * make the route reject its own response. The producer stops at the bound instead. + */ +const RESOURCE_EMIT_LIMIT = 400 + +/** + * The env-key charset `ENV_REF_PATTERN` accepts. A name outside it can never appear inside + * `{{ }}`, so the scan short-circuits — which also means the name is safe to inline into the + * SQL regex below without escaping, since it cannot carry a metacharacter. + */ +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +/** + * Drops the `{subBlockId}-tool-{index}-{paramId}` mirrors a tool row renders with. + * + * Those ids are documented as an ephemeral, client-only projection of the value held canonically + * at `tool.params[paramId]` inside the aggregate `tool-input` sub-block — they are not supposed + * to be persisted at all, but rows predating that rule still carry them. Left in, the scanner + * reports whichever the record happens to yield last, which surfaced an internal key like + * `tools-tool-0-code` where the reader expects a field. + * + * Removing them is right even when the two disagree: the canonical `tool.params` is what + * executes, so a mirror the canonical no longer matches describes a reference that no longer + * runs. + */ +function withoutToolMirrors(subBlocks: SubBlockRecord): SubBlockRecord { + const canonical: SubBlockRecord = {} + for (const [key, value] of Object.entries(subBlocks)) { + if (isSyntheticToolSubBlockId(key)) continue + canonical[key] = value + } + return canonical +} + +export interface SecretReferenceBlock { + blockId: string + blockName: string + blockType: string + /** + * A sub-block key on this block whose value carries the reference — not necessarily the + * only one. The fork remapper collapses a block's references to one entry per + * `(kind, sourceId)`, so a block naming the secret in two fields reports one of them. The + * block is the unit the reader acts on; the field is there to locate it inside the block. + */ + field: string +} + +export interface SecretReferenceWorkflow { + workflowId: string + workflowName: string + blocks: SecretReferenceBlock[] +} + +/** + * One reference site inside a resource a workflow reaches through rather than a block field. + * An MCP server carrying the key in two headers yields two entries, so `id` alone is not + * unique — `(kind, id, field)` is. + */ +export interface SecretReferenceResource { + id: string + kind: 'custom-tool' | 'mcp-server' + name: string + /** Where inside the resource the reference lives — `code`, `url`, or `header: X`. */ + field: string +} + +export interface SecretReferenceScan { + workflows: SecretReferenceWorkflow[] + resources: SecretReferenceResource[] + /** True when a scan cap was hit, so the lists are a prefix rather than the whole set. */ + truncated: boolean +} + +interface ScanSecretReferencesParams { + workspaceId: string + name: string +} + +/** Whether `text` carries a `{{name}}` reference, using the fork remapper's own pattern. */ +function referencesEnvKey(text: string, name: string): boolean { + for (const match of text.matchAll(ENV_REF_PATTERN)) { + if (match[1] === name) return true + } + return false +} + +/** + * Code points JS `\s` matches beyond ASCII, as inclusive ranges. Spelled out as numbers and + * rendered to `\uXXXX` below rather than written literally, so the source stays readable ASCII + * instead of carrying a run of invisible characters no reviewer could check. + */ +const UNICODE_SPACE_RANGES: ReadonlyArray = [ + [0x00a0, 0x00a0], + [0x1680, 0x1680], + [0x2000, 0x200a], + [0x2028, 0x2029], + [0x202f, 0x202f], + [0x205f, 0x205f], + [0x3000, 0x3000], + [0xfeff, 0xfeff], +] + +/** A code point as the `\uXXXX` escape a Postgres regex understands. */ +function toPgEscape(codePoint: number): string { + return `\\u${codePoint.toString(16).padStart(4, '0')}` +} + +const UNICODE_SPACE_CLASS = UNICODE_SPACE_RANGES.map(([low, high]) => + low === high ? toPgEscape(low) : `${toPgEscape(low)}-${toPgEscape(high)}` +).join('') + +/** + * One unit of what may sit between `{{` and the name: exactly the whitespace + * `ENV_REF_PATTERN` accepts, in each encoding it can arrive in. + * + * The prefilter reads a `::text` rendering of a JSON column, and the two regex engines + * disagree about whitespace, so all three forms are spelled out: `[[:space:]]` for raw ASCII; + * the JSON escapes, since `jsonb::text` renders a real tab as the literal pair `\\` `t` and a + * vertical tab as `\\u000b`; and the Unicode class above, which Postgres emits verbatim and + * `[[:space:]]` does not match though JS `\\s` does. + * + * Enumerating whitespace rather than excluding word characters costs a list to keep in step + * with `\\s`, and buys a prefilter exactly as tight as the authority. A looser gap admitted + * `{{-NAME-}}` and `{{"NAME"}}`, and those are not free: each occupies a row under + * {@link BLOCK_SCAN_LIMIT}, so a workspace with enough of them sorted earlier would exhaust + * the cap before a genuine reference was read — reporting a live key as unused. + */ +const REFERENCE_GAP = `([[:space:]]|\\\\[tnrf]|\\\\u000[bB]|[${UNICODE_SPACE_CLASS}])` + +/** + * Matches the name sitting inside `{{ }}` with only {@link REFERENCE_GAP} units between. + * + * Deliberately not `LIKE '%name%'`: `_` is a LIKE single-character wildcard and nearly every env + * key contains one, so `SB_ACTION_ROUTER_SECRET` would match text it does not occur in. And + * deliberately not a bare `strpos` either: that matched the name in prose and as a prefix of a + * longer key (`API_KEY` inside `{{API_KEY_TEST}}`), and those false positives counted against the + * row cap — so on a workspace with enough of them, genuine references sorted later were never + * read at all. + * + * The gap accepts exactly the whitespace `ENV_REF_PATTERN` does, so a candidate row is always a + * real occurrence and the cap counts only references. The scanners below still re-check each one + * and remain the authority; this decides what is worth reading. + */ +function referencesKey(column: unknown, envKey: string) { + return sql`${column} ~ ${`\\{\\{${REFERENCE_GAP}*${envKey}${REFERENCE_GAP}*\\}\\}`}` +} + +/** + * Every place in a workspace that names one secret: the blocks that reference it as + * `{{KEY}}`, plus the custom tools and MCP servers whose own bodies carry it. + * + * Detection is the workspace-fork remapper's — {@link remapSubBlocks} already walks nested + * `tool-input` params, resolves canonical basic/advanced pairs, and skips dormant and + * condition-hidden members. Only the aggregation is new: `scanWorkflowReferences` collapses + * its output to unique `(kind, sourceId)` pairs, which is right for building a mapping table + * and wrong for answering "where is this wired in". + * + * The scan is name-based and therefore identical for a workspace and a personal secret — a + * `{{KEY}}` in a workflow names a key, not a scope, and resolves to whichever slice wins at + * run time. The detail page already reports shadowing separately. + */ +export async function scanSecretReferences({ + workspaceId, + name, +}: ScanSecretReferencesParams): Promise { + // A name outside the env-key charset cannot appear inside `{{ }}`, so nothing can reference it. + if (!ENV_KEY_PATTERN.test(name)) return { workflows: [], resources: [], truncated: false } + + /** + * All candidates in one read, up to the ceiling. + * + * Deliberately not paged. Paging bought headroom but paid for it with drift: `OFFSET` is + * positional, so a block renamed, inserted or deleted between pages shifts the result set and + * the scan silently skips a live reference or reports one twice. One statement is one snapshot, + * so neither can happen — and the ceiling is what bounds the read instead. + * + * `blockId` still closes the ordering, so repeated scans of an unchanged workspace return the + * same list in the same order rather than an arbitrary one among ties. + */ + const readCandidates = () => + db + .select({ + blockId: workflowBlocks.id, + blockName: workflowBlocks.name, + blockType: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + data: workflowBlocks.data, + workflowId: workflow.id, + workflowName: workflow.name, + }) + .from(workflowBlocks) + .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId)) + .where( + and( + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + referencesKey(sql`${workflowBlocks.subBlocks}::text`, name) + ) + ) + .orderBy( + asc(workflow.name), + asc(workflow.id), + asc(workflowBlocks.name), + asc(workflowBlocks.id) + ) + // Limit-plus-one, like the resource reads: the extra row is how "there were more" is known + // without claiming truncation on a set that ended exactly on the bound. + .limit(BLOCK_CANDIDATE_CEILING + 1) + + const [candidates, tools, servers] = await Promise.all([ + readCandidates(), + db + .select({ id: customTools.id, title: customTools.title, code: customTools.code }) + .from(customTools) + .where(and(eq(customTools.workspaceId, workspaceId), referencesKey(customTools.code, name))) + .orderBy(asc(customTools.title)) + .limit(RESOURCE_SCAN_LIMIT + 1), + db + .select({ + id: mcpServers.id, + name: mcpServers.name, + url: mcpServers.url, + headers: mcpServers.headers, + }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt), + // `headers` is a `json` column, so `::text` is the only safe read here — a jsonb + // operator would raise 42883 and abort the statement. + sql`(${referencesKey(mcpServers.url, name)} OR ${referencesKey(sql`${mcpServers.headers}::text`, name)})` + ) + ) + .orderBy(asc(mcpServers.name)) + .limit(RESOURCE_SCAN_LIMIT + 1), + ]) + + /** Every bound is `> limit` on a limit-plus-one read, so landing exactly on one is not truncation. */ + let truncated = + tools.length > RESOURCE_SCAN_LIMIT || + servers.length > RESOURCE_SCAN_LIMIT || + candidates.length > BLOCK_CANDIDATE_CEILING + + const workflows: SecretReferenceWorkflow[] = [] + const workflowIndex = new Map() + let reported = 0 + + for (const row of candidates.slice(0, BLOCK_CANDIDATE_CEILING)) { + /* Reporting stops at the result limit, but the read does not: the remaining candidates are + still worth walking only insofar as they cannot add results, so stop here and say so. */ + if (reported >= BLOCK_RESULT_LIMIT) { + truncated = true + break + } + scanCandidate(row) + } + + function scanCandidate(row: { + blockId: string + blockName: string + blockType: string + subBlocks: unknown + data: unknown + workflowId: string + workflowName: string + }): void { + let field: string | undefined + try { + const { references } = remapSubBlocks( + withoutToolMirrors(row.subBlocks as SubBlockRecord), + () => null, + { + blockId: row.blockId, + blockName: row.blockName, + blockType: row.blockType, + canonicalModes: (row.data as { canonicalModes?: CanonicalModeOverrides } | null) + ?.canonicalModes, + } + ) + field = references.find( + (reference) => reference.kind === 'env-var' && reference.sourceId === name + )?.subBlockKey + } catch (error) { + // One malformed block must not blank the whole tab. The block is dropped rather than + // reported without the field that carries the reference, which would read as a + // reference we cannot locate — and the log names it so the shape can be fixed. + logger.error('Failed to scan block for secret references', { + blockId: row.blockId, + workflowId: row.workflowId, + error, + }) + return + } + if (!field) return + + let entry = workflowIndex.get(row.workflowId) + if (!entry) { + entry = { workflowId: row.workflowId, workflowName: row.workflowName, blocks: [] } + workflowIndex.set(row.workflowId, entry) + workflows.push(entry) + } + entry.blocks.push({ + blockId: row.blockId, + blockName: row.blockName, + blockType: row.blockType, + field, + }) + reported += 1 + } + + const resources: SecretReferenceResource[] = [] + + /** + * Stops at {@link RESOURCE_EMIT_LIMIT} rather than trusting the row caps to bound the output. + * One server expands to an entry per matching header, so the emitted total is what has to be + * checked against the contract's array bound — exceeding it would make the route reject its + * own response and turn a successful scan into a 500. + */ + const emitResource = (resource: SecretReferenceResource): boolean => { + if (resources.length >= RESOURCE_EMIT_LIMIT) { + truncated = true + return false + } + resources.push(resource) + return true + } + + for (const tool of tools.slice(0, RESOURCE_SCAN_LIMIT)) { + if (!referencesEnvKey(tool.code ?? '', name)) continue + if (!emitResource({ id: tool.id, kind: 'custom-tool', name: tool.title, field: 'code' })) break + } + + for (const server of servers.slice(0, RESOURCE_SCAN_LIMIT)) { + if (resources.length >= RESOURCE_EMIT_LIMIT) { + truncated = true + break + } + if (server.url && referencesEnvKey(server.url, name)) { + emitResource({ id: server.id, kind: 'mcp-server', name: server.name, field: 'url' }) + } + const headers = (server.headers ?? {}) as Record + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== 'string') continue + if (!referencesEnvKey(headerValue, name)) continue + const emitted = emitResource({ + id: server.id, + kind: 'mcp-server', + name: server.name, + field: `header: ${headerName}`, + }) + if (!emitted) break + } + } + + return { workflows, resources, truncated } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 2b92482ad92..3f66bbc8a2f 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1161, - zodRoutes: 1161, + totalRoutes: 1162, + zodRoutes: 1162, nonZodRoutes: 0, } as const