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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 37 additions & 17 deletions apps/sim/lib/execution/mounted-file-secret-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('mounted file output provenance scanner', () => {
}))
})

it('classifies only mounted secrets present in the exact exported bytes', async () => {
it('retains full mounted lineage while narrowing text exports to matching literals', async () => {
const scanner = await createMountedFileSecretProvenanceScanner({
version: 1,
complete: true,
Expand All @@ -23,6 +23,23 @@ describe('mounted file output provenance scanner', () => {
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})

expect(scanner?.provenance).toEqual({
status: 'exact',
entries: [
{
name: 'MOUNTED_FILE_SECRET',
encryptedValue: 'encrypted-a',
sourceUserId: 'user-1',
sourceWorkspaceId: 'workspace-1',
},
{
name: 'ORIGINAL_NAME',
encryptedValue: 'encrypted-b',
sourceUserId: 'user-1',
sourceWorkspaceId: 'workspace-1',
},
],
})
expect(scanner?.scan(Buffer.from('ordinary output'))).toEqual({ status: 'exact', entries: [] })
expect(scanner?.scan(Buffer.from('prefix first secret suffix'))).toEqual({
status: 'exact',
Expand All @@ -48,26 +65,18 @@ describe('mounted file output provenance scanner', () => {
})
})

it('reports whether the mount carried any secret material', async () => {
const withSecrets = await createMountedFileSecretProvenanceScanner({
version: 1,
complete: true,
entries: [{ encryptedValue: 'encrypted-a' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})
expect(withSecrets?.hasSecrets).toBe(true)

it('keeps a mount without secret material exact-empty', async () => {
const withoutSecrets = await createMountedFileSecretProvenanceScanner({
version: 1,
complete: true,
entries: [],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})
expect(withoutSecrets?.hasSecrets).toBe(false)
expect(withoutSecrets?.provenance).toEqual({ status: 'exact', entries: [] })
expect(withoutSecrets?.scan(Buffer.from('anything'))).toEqual({ status: 'exact', entries: [] })
})

it('keeps hasSecrets true when attested entries yield no scannable plaintext', async () => {
it('keeps attested entries unknown when they yield no plaintext', async () => {
encryptionMockFns.mockDecryptSecret.mockImplementation(async () => ({ decrypted: '' }))

const scanner = await createMountedFileSecretProvenanceScanner({
Expand All @@ -77,7 +86,8 @@ describe('mounted file output provenance scanner', () => {
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})

expect(scanner?.hasSecrets).toBe(true)
expect(scanner?.provenance).toEqual({ status: 'unknown' })
expect(scanner?.scan(Buffer.from('ordinary output'))).toEqual({ status: 'unknown' })
})

it.each(['false', 'hunter2', '""""'])(
Expand All @@ -96,7 +106,7 @@ describe('mounted file output provenance scanner', () => {
status: 'exact',
entries: [],
})
expect(scanner?.hasSecrets).toBe(false)
expect(scanner?.provenance).toEqual({ status: 'exact', entries: [] })
}
)

Expand All @@ -112,7 +122,17 @@ describe('mounted file output provenance scanner', () => {
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})

expect(scanner?.hasSecrets).toBe(true)
expect(scanner?.provenance).toEqual({
status: 'exact',
entries: [
{
name: 'MOUNTED_FILE_SECRET',
encryptedValue: 'encrypted-boundary',
sourceUserId: 'user-1',
sourceWorkspaceId: 'workspace-1',
},
],
})
expect(scanner?.scan(Buffer.from('false hunter22'))).toEqual({
status: 'exact',
entries: [
Expand All @@ -132,7 +152,7 @@ describe('mounted file output provenance scanner', () => {
complete: false,
entries: [],
})
expect(incomplete?.hasSecrets).toBe(true)
expect(incomplete?.provenance).toEqual({ status: 'unknown' })
expect(incomplete?.scan(Buffer.from('raw output'))).toEqual({ status: 'unknown' })

encryptionMockFns.mockDecryptSecret.mockRejectedValueOnce(new Error('decrypt failed'))
Expand All @@ -142,7 +162,7 @@ describe('mounted file output provenance scanner', () => {
entries: [{ encryptedValue: 'encrypted-a' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
})
expect(unavailable?.hasSecrets).toBe(true)
expect(unavailable?.provenance).toEqual({ status: 'unknown' })
expect(unavailable?.scan(Buffer.from('raw output'))).toEqual({ status: 'unknown' })
})
})
36 changes: 18 additions & 18 deletions apps/sim/lib/execution/mounted-file-secret-provenance.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { WorkspaceFileSecretProvenanceEntry } from '@sim/db/schema'
import { compareStrings } from '@sim/utils/string'
import { decryptSecret } from '@/lib/core/security/encryption'
import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import {
mergeWorkspaceFileSecretProvenance,
type WorkspaceFileSecretProvenance,
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import {
createResolvedSecretMatcher,
scanResolvedSecretString,
Expand All @@ -13,18 +16,13 @@ const MAX_MOUNTED_FILE_SECRET_MATCH_EVENTS = 1_000_000
const ANONYMOUS_MOUNTED_FILE_SECRET_NAME = 'MOUNTED_FILE_SECRET'

export interface MountedFileSecretProvenanceScanner {
/**
* True when the envelope carries material protected by the shared literal policy, or an entry
* cannot be inspected. Successfully decrypted short values do not taint derived binary files.
* Entries that fail to yield plaintext keep this true: losing the ability to scan them makes
* the mount less classifiable, not more.
*/
hasSecrets: boolean
/** Complete protected candidates for opaque exports; text exports may narrow them by scanning. */
provenance: WorkspaceFileSecretProvenance
scan(buffer: Buffer): WorkspaceFileSecretProvenance
}

const UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER: MountedFileSecretProvenanceScanner = {
hasSecrets: true,
provenance: { status: 'unknown' },
scan: () => ({ status: 'unknown' }),
}

Expand All @@ -39,17 +37,13 @@ export async function createMountedFileSecretProvenanceScanner(
if (!provenance.complete) return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER
if (!provenance.scope?.userId) return undefined

let hasSecrets = false
const protectedEntries: WorkspaceFileSecretProvenanceEntry[] = []
const entriesByScanLiteral = new Map<string, Map<string, WorkspaceFileSecretProvenanceEntry>>()
try {
for (const entry of provenance.entries) {
const { decrypted: plaintext } = await decryptSecret(entry.encryptedValue)
if (!plaintext) {
hasSecrets = true
continue
}
if (!plaintext) return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER
if (isNonIdentifyingSecretLiteral(plaintext)) continue
hasSecrets = true
const fileEntry: WorkspaceFileSecretProvenanceEntry = {
name: entry.name || ANONYMOUS_MOUNTED_FILE_SECRET_NAME,
encryptedValue: entry.encryptedValue,
Expand All @@ -58,6 +52,7 @@ export async function createMountedFileSecretProvenanceScanner(
? { sourceWorkspaceId: provenance.scope.workspaceId }
: {}),
}
protectedEntries.push(fileEntry)
for (const scanLiteral of new Set([plaintext, JSON.stringify(plaintext).slice(1, -1)])) {
const entries =
entriesByScanLiteral.get(scanLiteral) ??
Expand All @@ -73,8 +68,13 @@ export async function createMountedFileSecretProvenanceScanner(
return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER
}

const sourceProvenance = mergeWorkspaceFileSecretProvenance({
status: 'exact',
entries: protectedEntries,
})

if (entriesByScanLiteral.size === 0) {
return { hasSecrets, scan: () => ({ status: 'exact', entries: [] }) }
return { provenance: sourceProvenance, scan: () => ({ status: 'exact', entries: [] }) }
}

let matcher
Expand All @@ -86,11 +86,11 @@ export async function createMountedFileSecretProvenanceScanner(
return UNKNOWN_MOUNTED_FILE_SECRET_PROVENANCE_SCANNER
}
if (!matcher) {
return { hasSecrets, scan: () => ({ status: 'exact', entries: [] }) }
return { provenance: sourceProvenance, scan: () => ({ status: 'exact', entries: [] }) }
}

return {
hasSecrets,
provenance: sourceProvenance,
/**
* A scan that cannot finish yields `unknown` — a taint — where the registry's per-value scan
* over-approximates instead. The asymmetry is deliberate: that scan only narrows a candidate
Expand Down
Loading
Loading