Skip to content

Commit 5bedaa1

Browse files
committed
improvement(provenance): cleanup boundary
1 parent 43d0be7 commit 5bedaa1

250 files changed

Lines changed: 9719 additions & 5248 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/function/execute/route.test.ts

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ describe('Function Execute API Route', () => {
590590
createMockRequest(
591591
'POST',
592592
{
593-
code: 'return environmentVariables.API_KEY',
593+
code: 'return {{API_KEY}}',
594594
envVars: { API_KEY: 'secret-at-the-end' },
595595
workflowId: 'workflow-1',
596596
workspaceId: 'workspace-1',
@@ -686,7 +686,7 @@ describe('Function Execute API Route', () => {
686686
createMockRequest(
687687
'POST',
688688
{
689-
code: 'print("done")',
689+
code: 'print("{{API_KEY}}")',
690690
language: 'python',
691691
workspaceId: 'workspace-1',
692692
envVars: { API_KEY: 'secret-value' },
@@ -821,6 +821,54 @@ describe('Function Execute API Route', () => {
821821
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
822822
})
823823

824+
it('runs with authenticated incomplete mount provenance and marks exported bytes unknown', async () => {
825+
envFlagsMock.isRemoteSandboxEnabled = true
826+
mockExecuteInSandbox.mockResolvedValueOnce({
827+
result: 'raw result',
828+
stdout: '',
829+
sandboxId: 'sandbox-123',
830+
exportedFiles: { '/home/user/output.txt': 'raw output' },
831+
})
832+
833+
const response = await POST(
834+
createMockRequest(
835+
'POST',
836+
{
837+
code: 'print("done")',
838+
language: 'python',
839+
workspaceId: 'workspace-1',
840+
outputs: {
841+
files: [
842+
{
843+
path: 'files/output.txt',
844+
sandboxPath: '/home/user/output.txt',
845+
mimeType: 'text/plain',
846+
},
847+
],
848+
},
849+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
850+
version: 1,
851+
complete: false,
852+
selections: [],
853+
},
854+
},
855+
{ [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 }
856+
)
857+
)
858+
859+
expect(response.status).toBe(200)
860+
expect((await response.json()).output.result).toEqual(
861+
expect.objectContaining({ fileId: 'wf_output_txt', vfsPath: 'files/output.txt' })
862+
)
863+
expect(mockExecuteInSandbox).toHaveBeenCalledOnce()
864+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
865+
expect.objectContaining({
866+
buffer: Buffer.from('raw output'),
867+
secretProvenance: { status: 'unknown' },
868+
})
869+
)
870+
})
871+
824872
it('does not rewrite a static export path that happens to equal a resolved secret', async () => {
825873
envFlagsMock.isRemoteSandboxEnabled = true
826874
mockExecuteInSandbox.mockResolvedValueOnce({
@@ -853,6 +901,7 @@ describe('Function Execute API Route', () => {
853901
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
854902
expect.objectContaining({
855903
target: expect.objectContaining({ path: 'files/report-secret-value.txt' }),
904+
secretProvenance: { status: 'exact', entries: [] },
856905
})
857906
)
858907
expect(JSON.stringify(data)).toContain('files/report-secret-value.txt')
@@ -890,7 +939,7 @@ describe('Function Execute API Route', () => {
890939
)
891940
})
892941

893-
it('keeps a binary export unknown when files were mounted without a provenance envelope', async () => {
942+
it('classifies a binary export exact-empty when ordinary files were mounted without secret provenance', async () => {
894943
envFlagsMock.isRemoteSandboxEnabled = true
895944
mockExecuteInSandbox.mockResolvedValueOnce({
896945
result: 'done',
@@ -919,7 +968,7 @@ describe('Function Execute API Route', () => {
919968

920969
expect(response.status).toBe(200)
921970
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith(
922-
expect.objectContaining({ secretProvenance: { status: 'unknown' } })
971+
expect.objectContaining({ secretProvenance: { status: 'exact', entries: [] } })
923972
)
924973
})
925974

@@ -987,7 +1036,7 @@ describe('Function Execute API Route', () => {
9871036

9881037
const response = await POST(
9891038
createMockRequest('POST', {
990-
code: 'print("done")',
1039+
code: 'print("{{API_KEY}}")',
9911040
language: 'python',
9921041
workspaceId: 'workspace-1',
9931042
envVars: { API_KEY: 'secret-value' },
@@ -2002,6 +2051,73 @@ describe('Function Execute API Route', () => {
20022051
expect(Object.values(request.contextVariables)).not.toContain('must-not-bind')
20032052
})
20042053

2054+
it('does not infer provenance from an unused low-entropy environment value', async () => {
2055+
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'Box eSign', stdout: '' })
2056+
2057+
const response = await POST(
2058+
createMockRequest(
2059+
'POST',
2060+
{
2061+
code: 'return "Box eSign"',
2062+
envVars: { SERVICENOW_PASSWORD: 'x' },
2063+
},
2064+
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
2065+
)
2066+
)
2067+
const data = await response.json()
2068+
2069+
expect(response.status).toBe(200)
2070+
expect(data.output.result).toBe('Box eSign')
2071+
expect(data.__resolvedSecretNames).toEqual([])
2072+
})
2073+
2074+
it('does not build provenance matchers for unused oversized environment values', async () => {
2075+
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'safe', stdout: '' })
2076+
2077+
const response = await POST(
2078+
createMockRequest(
2079+
'POST',
2080+
{
2081+
code: 'return "safe"',
2082+
envVars: { UNUSED: 'x'.repeat(65 * 1024) },
2083+
},
2084+
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
2085+
)
2086+
)
2087+
2088+
expect(response.status).toBe(200)
2089+
expect((await response.json()).__resolvedSecretNames).toEqual([])
2090+
})
2091+
2092+
it('tracks only compiled names when configured secrets share the same value', async () => {
2093+
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'true', stdout: '' })
2094+
const oneResponse = await POST(
2095+
createMockRequest(
2096+
'POST',
2097+
{
2098+
code: 'return {{SECOND}}',
2099+
envVars: { FIRST: 'true', SECOND: 'true' },
2100+
},
2101+
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
2102+
)
2103+
)
2104+
2105+
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'true', stdout: '' })
2106+
const bothResponse = await POST(
2107+
createMockRequest(
2108+
'POST',
2109+
{
2110+
code: 'const first = {{FIRST}}; return {{SECOND}}',
2111+
envVars: { FIRST: 'true', SECOND: 'true' },
2112+
},
2113+
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
2114+
)
2115+
)
2116+
2117+
expect((await oneResponse.json()).__resolvedSecretNames).toEqual(['SECOND'])
2118+
expect((await bothResponse.json()).__resolvedSecretNames).toEqual(['FIRST', 'SECOND'])
2119+
})
2120+
20052121
it('lowers missing shell placeholders while preserving comments and heredoc delimiters', async () => {
20062122
envFlagsMock.isRemoteSandboxEnabled = true
20072123
const response = await POST(
@@ -2134,7 +2250,7 @@ describe('Function Execute API Route', () => {
21342250
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
21352251
})
21362252

2137-
it('reports exact secret values returned through placeholders and the environment map', async () => {
2253+
it('reports exact secret values returned through placeholders without inferring direct environment reads', async () => {
21382254
mockExecuteInIsolatedVM.mockResolvedValueOnce({
21392255
result: 'secret-valueother-secret',
21402256
stdout: '',
@@ -2171,14 +2287,15 @@ describe('Function Execute API Route', () => {
21712287
const directData = await directResponse.json()
21722288

21732289
expect(envData.__resolvedSecretNames).toEqual(['ENV_ONLY', 'SHARED'])
2174-
expect(directData.__resolvedSecretNames).toEqual(['API_KEY'])
2290+
expect(directData.output.result).toBe('secret-value')
2291+
expect(directData.__resolvedSecretNames).toEqual([])
21752292
})
21762293

21772294
it.each([
21782295
{ name: 'numeric', secret: '123', result: 123 },
21792296
{ name: 'boolean', secret: 'true', result: true },
21802297
])(
2181-
'records provenance for a typed $name secret returned through direct environment access',
2298+
'preserves a typed $name value returned through legacy direct environment access without inferred provenance',
21822299
async ({ secret, result }) => {
21832300
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result, stdout: '' })
21842301

@@ -2197,11 +2314,11 @@ describe('Function Execute API Route', () => {
21972314
const data = await response.json()
21982315

21992316
expect(data.output.result).toBe(result)
2200-
expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
2317+
expect(data.__resolvedSecretNames).toEqual([])
22012318
}
22022319
)
22032320

2204-
it('reports shell substitutions and exact secret output from direct environment access', async () => {
2321+
it('reports placeholder output without inferring provenance from legacy shell environment access', async () => {
22052322
envFlagsMock.isRemoteSandboxEnabled = true
22062323
mockExecuteShellInSandbox.mockResolvedValueOnce({
22072324
result: null,
@@ -2245,7 +2362,8 @@ describe('Function Execute API Route', () => {
22452362
const directData = await directResponse.json()
22462363

22472364
expect(referencedData.__resolvedSecretNames).toEqual(['API_KEY'])
2248-
expect(directData.__resolvedSecretNames).toEqual(['API_KEY'])
2365+
expect(directData.output.stdout).toBe('secret-value')
2366+
expect(directData.__resolvedSecretNames).toEqual([])
22492367
})
22502368

22512369
it('returns nonzero shell stderr as a visible 422 error and diagnostic output', async () => {
@@ -2289,8 +2407,8 @@ describe('Function Execute API Route', () => {
22892407
)
22902408

22912409
expect(response.status).toBe(200)
2292-
expect((await response.json()).__resolvedSecretNames).toBeUndefined()
2293-
expect(response.headers.get('x-sim-private-tool-metadata')).toBeNull()
2410+
expect((await response.json()).__resolvedSecretNames).toEqual([])
2411+
expect(response.headers.get('x-sim-private-tool-metadata')).toBe('resolved-secret-names-v1')
22942412
expect(mockExecuteInIsolatedVM).toHaveBeenCalled()
22952413
})
22962414

apps/sim/app/api/function/execute/route.ts

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,6 @@ interface FunctionRouteExecutionContext {
989989
outputSecretNamesByScanLiteral: Map<string, string[]>
990990
outputSecretPlaintextsByName: Map<string, string>
991991
mountedFileSecretProvenanceScanner?: MountedFileSecretProvenanceScanner
992-
hasMountedSandboxFiles: boolean
993992
}
994993

995994
type ResolvedSecretNamesMetadataType =
@@ -1007,10 +1006,16 @@ function inspectMountedWorkspaceFileProvenance(
10071006
): MountedWorkspaceFileProvenanceInspection {
10081007
const inspection = inspectPrivateSecretProvenanceRequest(headers, body)
10091008
if (inspection.status === 'unsupported') return { status: 'none' }
1009+
if (inspection.status !== 'verified' || !isPrivateSecretProvenanceBundleV1(inspection.value)) {
1010+
return { status: 'invalid' }
1011+
}
1012+
if (!inspection.value.complete) {
1013+
return {
1014+
status: 'verified',
1015+
provenance: { version: 1, complete: false, entries: [] },
1016+
}
1017+
}
10101018
if (
1011-
inspection.status !== 'verified' ||
1012-
!isPrivateSecretProvenanceBundleV1(inspection.value) ||
1013-
!inspection.value.complete ||
10141019
inspection.value.selections.length !== 1 ||
10151020
inspection.value.selections[0]?.key !== MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY
10161021
) {
@@ -1211,19 +1216,13 @@ function activateOutputSecretProvenance(
12111216
}
12121217

12131218
/**
1214-
* True when any secret material was in scope for this execution — a mounted environment secret, or
1215-
* a secret carried by a mounted input file. When false, nothing secret ever reached the sandbox, so
1216-
* no export of any kind can carry one.
1217-
*
1218-
* Mounted bytes are classified from the caller's provenance envelope. Files mounted *without* one
1219-
* are unclassifiable rather than clean: absence of an envelope is absence of evidence, not evidence
1220-
* the mount carried nothing. Those fail closed here so the classification can never be stronger
1221-
* than what the caller actually attested to.
1219+
* True when this execution compiled a secret placeholder or received a mounted file with verified
1220+
* secret provenance. Ordinary mounts without a provenance envelope are user data, not evidence that
1221+
* a Sim secret was resolved in this call.
12221222
*/
12231223
function hasSecretMaterialInScope(context: FunctionRouteExecutionContext): boolean {
12241224
if (context.outputSecretPlaintextsByName.size > 0) return true
1225-
const scanner = context.mountedFileSecretProvenanceScanner
1226-
return scanner ? scanner.hasSecrets : context.hasMountedSandboxFiles
1225+
return context.mountedFileSecretProvenanceScanner?.hasSecrets ?? false
12271226
}
12281227

12291228
/**
@@ -2030,29 +2029,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20302029
outputSecretNamesByScanLiteral: new Map(),
20312030
outputSecretPlaintextsByName: new Map(),
20322031
mountedFileSecretProvenanceScanner,
2033-
hasMountedSandboxFiles: (_sandboxFiles?.length ?? 0) > 0,
2034-
}
2035-
for (const [name, plaintext] of Object.entries(envVars)) {
2036-
if (!plaintext) continue
2037-
routeContext.outputSecretPlaintextsByName.set(name, plaintext)
2038-
const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])
2039-
for (const scanLiteral of scanLiterals) {
2040-
const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []
2041-
names.push(name)
2042-
routeContext.outputSecretNamesByScanLiteral.set(scanLiteral, names)
2043-
}
2044-
}
2045-
if (routeContext.outputSecretNamesByScanLiteral.size > 0) {
2046-
try {
2047-
routeContext.outputSecretMatcher = createResolvedSecretMatcher(
2048-
[...routeContext.outputSecretNamesByScanLiteral].map(([plaintext, names]) => ({
2049-
plaintext,
2050-
replacement: `{{${names[0]}}}`,
2051-
}))
2052-
)
2053-
} catch {
2054-
routeContext.outputProvenanceComplete = false
2055-
}
20562032
}
20572033

20582034
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
@@ -2081,6 +2057,30 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20812057
environmentVariables: envVars,
20822058
reservedNames: Object.keys(contextVariables),
20832059
})
2060+
for (const name of compilation.resolvedSecretNames) {
2061+
if (!Object.hasOwn(envVars, name)) continue
2062+
const plaintext = envVars[name]
2063+
if (!plaintext) continue
2064+
routeContext.outputSecretPlaintextsByName.set(name, plaintext)
2065+
const scanLiterals = new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])
2066+
for (const scanLiteral of scanLiterals) {
2067+
const names = routeContext.outputSecretNamesByScanLiteral.get(scanLiteral) ?? []
2068+
names.push(name)
2069+
routeContext.outputSecretNamesByScanLiteral.set(scanLiteral, names)
2070+
}
2071+
}
2072+
if (routeContext.outputSecretNamesByScanLiteral.size > 0) {
2073+
try {
2074+
routeContext.outputSecretMatcher = createResolvedSecretMatcher(
2075+
[...routeContext.outputSecretNamesByScanLiteral].map(([plaintext, names]) => ({
2076+
plaintext,
2077+
replacement: `{{${[...names].sort()[0]}}}`,
2078+
}))
2079+
)
2080+
} catch {
2081+
routeContext.outputProvenanceComplete = false
2082+
}
2083+
}
20842084
resolvedCode = compilation.code
20852085
compilerInternalIdentifiers = [...compilation.internalIdentifiers]
20862086
compilerPrivateInputs = [...compilation.privateInputs]

0 commit comments

Comments
 (0)