Skip to content

Commit a3c8b2a

Browse files
authored
Merge pull request #8767 from Shopify/app-security/extension-directories
Scan extension and web directories outside the app directory
2 parents a9bc823 + 9cb585b commit a3c8b2a

12 files changed

Lines changed: 381 additions & 25 deletions

File tree

‎packages/app/src/cli/commands/app/security/check.integration.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import SecurityCheck from './check.js'
22
import {appSecurityArtifactPaths} from '../../../services/app-security-artifacts.js'
3+
import {deterministicFindingsDocumentSchema} from '../../../services/app-security-engine/results/schema.js'
34
import {validAppConfiguration} from '../../../services/app-security-selection.test-data.js'
45
import {Config} from '@oclif/core'
56
import {fileRealPath, inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
@@ -125,6 +126,48 @@ describe('app security check command boundary', () => {
125126
})
126127
})
127128

129+
test('scans an extension directory that the TOML adds outside the app directory and records it in coverage', async () => {
130+
await inTemporaryDirectory(async (directory) => {
131+
const appRoot = joinPath(directory, 'app')
132+
await createApp(appRoot)
133+
await writeFile(
134+
joinPath(appRoot, 'shopify.app.toml'),
135+
`extension_directories = ["../shared/*"]\n${validAppConfiguration()}`,
136+
)
137+
await mkdir(joinPath(directory, 'shared', 'theme', 'blocks'), {recursive: true})
138+
await writeFile(
139+
joinPath(directory, 'shared', 'theme', 'shopify.extension.toml'),
140+
'name = "theme"\ntype = "theme"\n',
141+
)
142+
await writeFile(
143+
joinPath(directory, 'shared', 'theme', 'blocks', 'banner.liquid'),
144+
'<p>{{ block.settings.text }}</p>\n',
145+
)
146+
const appDirectory = await fileRealPath(appRoot)
147+
const sharedTheme = await fileRealPath(joinPath(directory, 'shared', 'theme'))
148+
149+
const result = await runCommand(['--path', appRoot, '--json', '--skip-instructions'])
150+
151+
expect(result.exitCode).toBe(0)
152+
const output = JSON.parse(result.stdout)
153+
expect(output.selection.scan_directories).toEqual([
154+
{directory: appDirectory, origin: 'app_directory'},
155+
{directory: sharedTheme, origin: 'app_config_directory'},
156+
])
157+
const deterministicFindings = await readJson(
158+
appSecurityArtifactPaths(appDirectory, 'shopify.app').deterministicFindingsPath,
159+
)
160+
// `review` reads the results back through this schema.
161+
expect(deterministicFindingsDocumentSchema.parse(deterministicFindings).coverage).toMatchObject({
162+
scope: {include_dirs: [], excludes: [], no_git_ignore: false},
163+
scan_directories: [
164+
{directory: '.', origin: 'app_directory'},
165+
{directory: '../shared/theme', origin: 'app_config_directory'},
166+
],
167+
})
168+
})
169+
})
170+
128171
test('writes the results under the --client-id results key and creates .shopify/.gitignore', async () => {
129172
await inTemporaryDirectory(async (directory) => {
130173
await createApp(directory)

‎packages/app/src/cli/services/app-context.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,14 @@ async function logMetadata(app: {apiKey: string}, organization: Organization, re
179179
interface LocalAppContextOutput {
180180
app: AppInterface
181181
project: Project
182+
activeConfig: ActiveConfig
182183
}
183184

184185
/**
185186
* This function loads an app locally without making any network calls.
186187
* It uses local specifications and doesn't require the app to be linked.
187188
*
188-
* @returns The local app and project instances.
189+
* @returns The local app and project instances, and the selected app configuration.
189190
*/
190191
export async function localAppContext({
191192
directory,
@@ -205,5 +206,5 @@ export async function localAppContext({
205206
throw new AbortError(styledConfigurationError(app.errors.getErrors()[0]!))
206207
}
207208

208-
return {app, project}
209+
return {app, project, activeConfig}
209210
}

‎packages/app/src/cli/services/app-security-engine/results/schema.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export const coverageSchema = zod.object({
124124
),
125125
scope: createScopeSchema(),
126126
scan_directories: zod.array(
127-
zod.object({directory: zod.string(), origin: zod.enum(['app_directory', 'include_dir'])}),
127+
zod.object({directory: zod.string(), origin: zod.enum(['app_directory', 'include_dir', 'app_config_directory'])}),
128128
),
129129
})
130130

‎packages/app/src/cli/services/app-security-engine/run.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,27 @@ export function getAgentInstructions(): string {
2929
return EMBEDDED_APP_SECURITY_INSTRUCTIONS
3030
}
3131

32-
/** A scan directory equal to the app directory is the app directory itself, however it was requested. */
33-
function coverageScanDirectories({appDirectory, scanDirectories}: ScanInput): CoverageScanDirectory[] {
32+
function coverageScanDirectories({
33+
appDirectory,
34+
scanDirectories,
35+
appConfigDirectories = [],
36+
}: ScanInput): CoverageScanDirectory[] {
3437
return scanDirectories.map((directory) => ({
3538
directory: normalizePath(relativePath(appDirectory, directory)) || '.',
36-
origin: directory === appDirectory ? 'app_directory' : 'include_dir',
39+
origin: coverageOrigin(directory, appDirectory, appConfigDirectories),
3740
}))
3841
}
3942

43+
/** A scan directory equal to the app directory is the app directory itself, however it was requested. */
44+
function coverageOrigin(
45+
directory: string,
46+
appDirectory: string,
47+
appConfigDirectories: ReadonlyArray<string>,
48+
): CoverageScanDirectory['origin'] {
49+
if (directory === appDirectory) return 'app_directory'
50+
return appConfigDirectories.includes(directory) ? 'app_config_directory' : 'include_dir'
51+
}
52+
4053
export async function scanApp(input: ScanInput, options: ScanOptions = {}): Promise<AppSecurityScan> {
4154
const {ignoredScanDirectories, otherAppDirectories, ...result} = await scan(input, options)
4255
const engineVersion = getEngineVersion()

‎packages/app/src/cli/services/app-security-engine/scanners/discover.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,8 @@ function groupSourcePathsByExtensionDirectory(
387387
* `extension_directories`. The app security check still scans every `shopify.extension.toml`
388388
* inside the repository boundary, including unconfigured extensions, because
389389
* those files can still contain secrets, XSS, and other security evidence.
390-
* Extensions in a nested app or an `--include-dir` directory count as this app's.
390+
* Extensions in a nested app, an `--include-dir` directory, or a directory that the selected app configuration
391+
* file's `extension_directories` or `web_directories` add count as this app's.
391392
*/
392393
export function findExtensions(appRoot: string, repositoryFiles: ReadonlyArray<string>): ExtensionInfo[] {
393394
const extensionTomls = repositoryFiles.filter((path) => basename(path) === 'shopify.extension.toml')

‎packages/app/src/cli/services/app-security-engine/tests/layout-catalogue.test.ts‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,11 @@ interface CheckFlags {
3333
json?: boolean
3434
}
3535

36-
function toml(path: string, clientId = 'client-app'): FileSpec {
36+
/** `topLevelKeys` come first, since top-level keys must precede tables. */
37+
function toml(path: string, clientId = 'client-app', topLevelKeys = ''): FileSpec {
3738
return [
3839
path,
39-
`name = "test-app"
40+
`${topLevelKeys}name = "test-app"
4041
client_id = "${clientId}"
4142
application_url = "https://example.com"
4243
embedded = true
@@ -943,4 +944,43 @@ describe('layout catalogue: check --list-files', () => {
943944
})
944945
})
945946
})
947+
948+
test('28. extension-and-web-directories-outside-the-app', async () => {
949+
const layout: Layout = {
950+
repositories: ['monorepo'],
951+
files: [
952+
toml(
953+
'monorepo/app/shopify.app.toml',
954+
'client-app',
955+
'extension_directories = ["../shared-extensions/*"]\nweb_directories = ["../backend"]\n',
956+
),
957+
'monorepo/app/app/routes/index.tsx',
958+
['monorepo/shared-extensions/theme/shopify.extension.toml', 'name = "theme"\ntype = "theme"\n'],
959+
'monorepo/shared-extensions/theme/blocks/banner.liquid',
960+
'monorepo/shared-extensions/README.md',
961+
['monorepo/backend/shopify.web.toml', 'name = "backend"\nroles = ["backend"]\n\n[commands]\ndev = "dev"\n'],
962+
'monorepo/backend/src/server.ts',
963+
],
964+
}
965+
await inLayout(layout, async (root) => {
966+
const {resolution, stdout, stderr} = await checkListFiles(root, 'monorepo/app')
967+
968+
expectConfigSelection(resolution, {
969+
appDirectory: join(root, 'monorepo/app'),
970+
tomlFileName: 'shopify.app.toml',
971+
resultsKey: 'shopify.app',
972+
})
973+
// Each matched TOML's directory is scanned, not every directory the glob starts from.
974+
expect(listedPaths(stdout)).toEqual([
975+
'../backend/shopify.web.toml',
976+
'../backend/src/server.ts',
977+
'../shared-extensions/theme/blocks/banner.liquid',
978+
'../shared-extensions/theme/shopify.extension.toml',
979+
'app/routes/index.tsx',
980+
'shopify.app.toml',
981+
])
982+
expect(warningText(stderr)).not.toContain("another app's configuration")
983+
expect(resolution.commands.scan.args).toEqual(checkArgs())
984+
})
985+
})
946986
})

‎packages/app/src/cli/services/app-security-engine/types.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ export interface ScanInput {
8282
requestedScanDirectories: ReadonlyArray<string>
8383
/** Absolute path of the selected app configuration file. Absent when scanning without app configuration. */
8484
appConfigFilePath?: string
85+
/** The scan directories that the selected app configuration file's `extension_directories` and `web_directories` add. */
86+
appConfigDirectories?: ReadonlyArray<string>
8587
clientId?: string
8688
}
8789

@@ -95,7 +97,7 @@ export interface AppSecurityScope {
9597
/** A scan directory as recorded in coverage: relative to the app directory, `.` for the app directory itself. */
9698
export interface CoverageScanDirectory {
9799
directory: string
98-
origin: 'app_directory' | 'include_dir'
100+
origin: 'app_directory' | 'include_dir' | 'app_config_directory'
99101
}
100102

101103
export interface ScanOptions {

‎packages/app/src/cli/services/app-security-selection.test.ts‎

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {fetchOrCreateOrganizationApp} from './context.js'
1717
import use from './app/config/use.js'
1818
import {AbortError} from '@shopify/cli-kit/node/error'
1919
import {fileRealPath, inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
20-
import {joinPath} from '@shopify/cli-kit/node/path'
20+
import {basename, joinPath} from '@shopify/cli-kit/node/path'
2121
import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui'
2222
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
2323
import {symlink} from 'node:fs/promises'
@@ -77,6 +77,7 @@ describe('resolveAppSecuritySelection with an app configuration', () => {
7777
kind: 'config',
7878
appDirectory,
7979
appConfigFilePath: joinPath(appDirectory, 'shopify.app.toml'),
80+
appConfigDirectories: [],
8081
configClientId: 'default-client-id',
8182
clientIdOverride: undefined,
8283
})
@@ -326,6 +327,124 @@ describe('resolveAppSecuritySelection with several TOMLs and none selected', ()
326327
})
327328
})
328329

330+
/** A TOML whose `extension_directories` and `web_directories` come first, since top-level keys must precede tables. */
331+
async function writeConfigurationWithDirectories(
332+
appDirectory: string,
333+
directories: {extension_directories?: string[]; web_directories?: string[]},
334+
name = 'shopify.app.toml',
335+
): Promise<void> {
336+
const keys = Object.entries(directories).map(([key, entries]) => `${key} = ${JSON.stringify(entries)}\n`)
337+
await mkdir(appDirectory)
338+
await writeFile(joinPath(appDirectory, name), `${keys.join('')}${validAppConfiguration()}`)
339+
}
340+
341+
/** A theme extension named after its directory, since extension handles must be unique. */
342+
async function writeThemeExtension(directory: string): Promise<void> {
343+
await mkdir(directory)
344+
await writeFile(joinPath(directory, 'shopify.extension.toml'), `name = "${basename(directory)}"\ntype = "theme"\n`)
345+
}
346+
347+
async function writeBackendWeb(directory: string): Promise<void> {
348+
await mkdir(directory)
349+
await writeFile(
350+
joinPath(directory, 'shopify.web.toml'),
351+
'name = "backend"\nroles = ["backend"]\n\n[commands]\ndev = "dev"\n',
352+
)
353+
}
354+
355+
describe('resolveAppSecuritySelection app configuration directories', () => {
356+
test('are the directories of the extension and web TOMLs that relative entries match outside the app directory', async () => {
357+
await inTemporaryDirectory(async (directory) => {
358+
const appDirectory = joinPath(directory, 'app')
359+
await writeConfigurationWithDirectories(appDirectory, {
360+
extension_directories: ['extensions/*', '../shared/*', '../no-extensions/*'],
361+
web_directories: ['../backend'],
362+
})
363+
await writeThemeExtension(joinPath(appDirectory, 'extensions', 'inside'))
364+
await writeThemeExtension(joinPath(directory, 'shared', 'theme'))
365+
await writeFile(joinPath(directory, 'shared', 'README.md'), 'Shared extensions\n')
366+
await mkdir(joinPath(directory, 'no-extensions', 'empty'))
367+
await writeBackendWeb(joinPath(directory, 'backend'))
368+
369+
const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false})
370+
371+
expect(selection).toMatchObject({
372+
appConfigDirectories: [
373+
await fileRealPath(joinPath(directory, 'backend')),
374+
await fileRealPath(joinPath(directory, 'shared', 'theme')),
375+
],
376+
})
377+
})
378+
})
379+
380+
test('ignore absolute entries, the way the CLI does', async () => {
381+
await inTemporaryDirectory(async (directory) => {
382+
const appDirectory = joinPath(directory, 'app')
383+
await writeConfigurationWithDirectories(appDirectory, {
384+
extension_directories: [joinPath(directory, 'shared', '*')],
385+
web_directories: [joinPath(directory, 'backend')],
386+
})
387+
await writeThemeExtension(joinPath(directory, 'shared', 'theme'))
388+
await writeBackendWeb(joinPath(directory, 'backend'))
389+
390+
const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false})
391+
392+
expect(selection).toMatchObject({appConfigDirectories: []})
393+
})
394+
})
395+
396+
test('leave out a directory reached through a symbolic link outside the app directory', async () => {
397+
await inTemporaryDirectory(async (directory) => {
398+
const appDirectory = joinPath(directory, 'app')
399+
await writeConfigurationWithDirectories(appDirectory, {extension_directories: ['../linked-shared/*']})
400+
await writeThemeExtension(joinPath(directory, 'shared', 'theme'))
401+
await symlink(joinPath(directory, 'shared'), joinPath(directory, 'linked-shared'), 'dir')
402+
403+
const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false})
404+
405+
expect(selection).toMatchObject({appConfigDirectories: []})
406+
})
407+
})
408+
409+
test('leave out an extension directory inside the app directory that links outside it', async () => {
410+
await inTemporaryDirectory(async (directory) => {
411+
const appDirectory = joinPath(directory, 'app')
412+
await writeConfigurationWithDirectories(appDirectory, {extension_directories: ['extensions/*']})
413+
await writeThemeExtension(joinPath(directory, 'shared', 'theme'))
414+
await mkdir(joinPath(appDirectory, 'extensions'))
415+
await symlink(joinPath(directory, 'shared', 'theme'), joinPath(appDirectory, 'extensions', 'theme'), 'dir')
416+
417+
const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false})
418+
419+
expect(selection).toMatchObject({appConfigDirectories: []})
420+
})
421+
})
422+
423+
test('come from the selected TOML only', async () => {
424+
await inTemporaryDirectory(async (directory) => {
425+
const appDirectory = joinPath(directory, 'app')
426+
await writeConfigurationWithDirectories(appDirectory, {})
427+
await writeFile(
428+
joinPath(appDirectory, 'shopify.app.staging.toml'),
429+
`extension_directories = ["../shared/*"]\n${validAppConfiguration('staging-client-id')}`,
430+
)
431+
await writeThemeExtension(joinPath(directory, 'shared', 'theme'))
432+
433+
const defaultSelection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false})
434+
const stagingSelection = await resolveAppSecuritySelection({
435+
path: appDirectory,
436+
config: 'staging',
437+
allowPrompts: false,
438+
})
439+
440+
expect(defaultSelection).toMatchObject({appConfigDirectories: []})
441+
expect(stagingSelection).toMatchObject({
442+
appConfigDirectories: [await fileRealPath(joinPath(directory, 'shared', 'theme'))],
443+
})
444+
})
445+
})
446+
})
447+
329448
describe('resolveAppSecuritySelection with --without-app-config', () => {
330449
test('aborts without --client-id', async () => {
331450
await inTemporaryDirectory(async (directory) => {
@@ -773,4 +892,35 @@ describe('mergeScanDirectories', () => {
773892
test('accepts an include directory elsewhere on the same Windows drive', () => {
774893
expect(mergeScanDirectories('C:/work/app', ['C:/backend']).scanDirectories).toHaveLength(2)
775894
})
895+
896+
test('lists each app configuration directory after the include directories', () => {
897+
expect(mergeScanDirectories('/work/app', ['/work/backend'], ['/work/shared/theme'])).toEqual({
898+
scanDirectories: [
899+
{directory: '/work/app', origin: 'app_directory'},
900+
{directory: '/work/backend', origin: 'include_dir'},
901+
{directory: '/work/shared/theme', origin: 'app_config_directory'},
902+
],
903+
requestedScanDirectories: ['/work/app', '/work/backend', '/work/shared/theme'],
904+
})
905+
})
906+
907+
test('counts an app configuration directory that is also an include directory as the include directory', () => {
908+
expect(mergeScanDirectories('/work/app', ['/work/shared/theme'], ['/work/shared/theme']).scanDirectories).toEqual([
909+
{directory: '/work/app', origin: 'app_directory'},
910+
{directory: '/work/shared/theme', origin: 'include_dir'},
911+
])
912+
})
913+
914+
test('drops an app configuration directory inside an include directory', () => {
915+
expect(mergeScanDirectories('/work/app', ['/work/shared'], ['/work/shared/theme']).scanDirectories).toEqual([
916+
{directory: '/work/app', origin: 'app_directory'},
917+
{directory: '/work/shared', origin: 'include_dir'},
918+
])
919+
})
920+
921+
test('rejects an app configuration directory on another Windows drive', () => {
922+
expect(() => mergeScanDirectories('C:/work/app', [], ['D:/shared/theme'])).toThrowError(
923+
new AbortError('Extension or web directory D:/shared/theme: must be on the same drive as the app directory.'),
924+
)
925+
})
776926
})

0 commit comments

Comments
 (0)