diff --git a/packages/app/src/cli/commands/app/security/check.integration.test.ts b/packages/app/src/cli/commands/app/security/check.integration.test.ts index be1d78c2135..50d401f770d 100644 --- a/packages/app/src/cli/commands/app/security/check.integration.test.ts +++ b/packages/app/src/cli/commands/app/security/check.integration.test.ts @@ -1,5 +1,6 @@ import SecurityCheck from './check.js' import {appSecurityArtifactPaths} from '../../../services/app-security-artifacts.js' +import {deterministicFindingsDocumentSchema} from '../../../services/app-security-engine/results/schema.js' import {validAppConfiguration} from '../../../services/app-security-selection.test-data.js' import {Config} from '@oclif/core' import {fileRealPath, inTemporaryDirectory} from '@shopify/cli-kit/node/fs' @@ -125,6 +126,48 @@ describe('app security check command boundary', () => { }) }) + test('scans an extension directory that the TOML adds outside the app directory and records it in coverage', async () => { + await inTemporaryDirectory(async (directory) => { + const appRoot = joinPath(directory, 'app') + await createApp(appRoot) + await writeFile( + joinPath(appRoot, 'shopify.app.toml'), + `extension_directories = ["../shared/*"]\n${validAppConfiguration()}`, + ) + await mkdir(joinPath(directory, 'shared', 'theme', 'blocks'), {recursive: true}) + await writeFile( + joinPath(directory, 'shared', 'theme', 'shopify.extension.toml'), + 'name = "theme"\ntype = "theme"\n', + ) + await writeFile( + joinPath(directory, 'shared', 'theme', 'blocks', 'banner.liquid'), + '

{{ block.settings.text }}

\n', + ) + const appDirectory = await fileRealPath(appRoot) + const sharedTheme = await fileRealPath(joinPath(directory, 'shared', 'theme')) + + const result = await runCommand(['--path', appRoot, '--json', '--skip-instructions']) + + expect(result.exitCode).toBe(0) + const output = JSON.parse(result.stdout) + expect(output.selection.scan_directories).toEqual([ + {directory: appDirectory, origin: 'app_directory'}, + {directory: sharedTheme, origin: 'app_config_directory'}, + ]) + const deterministicFindings = await readJson( + appSecurityArtifactPaths(appDirectory, 'shopify.app').deterministicFindingsPath, + ) + // `review` reads the results back through this schema. + expect(deterministicFindingsDocumentSchema.parse(deterministicFindings).coverage).toMatchObject({ + scope: {include_dirs: [], excludes: [], no_git_ignore: false}, + scan_directories: [ + {directory: '.', origin: 'app_directory'}, + {directory: '../shared/theme', origin: 'app_config_directory'}, + ], + }) + }) + }) + test('writes the results under the --client-id results key and creates .shopify/.gitignore', async () => { await inTemporaryDirectory(async (directory) => { await createApp(directory) diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index 00d37b86fce..ada0a87c3e3 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -179,13 +179,14 @@ async function logMetadata(app: {apiKey: string}, organization: Organization, re interface LocalAppContextOutput { app: AppInterface project: Project + activeConfig: ActiveConfig } /** * This function loads an app locally without making any network calls. * It uses local specifications and doesn't require the app to be linked. * - * @returns The local app and project instances. + * @returns The local app and project instances, and the selected app configuration. */ export async function localAppContext({ directory, @@ -205,5 +206,5 @@ export async function localAppContext({ throw new AbortError(styledConfigurationError(app.errors.getErrors()[0]!)) } - return {app, project} + return {app, project, activeConfig} } diff --git a/packages/app/src/cli/services/app-security-engine/results/schema.ts b/packages/app/src/cli/services/app-security-engine/results/schema.ts index 57c75ff1461..a30a5e8efc6 100644 --- a/packages/app/src/cli/services/app-security-engine/results/schema.ts +++ b/packages/app/src/cli/services/app-security-engine/results/schema.ts @@ -124,7 +124,7 @@ export const coverageSchema = zod.object({ ), scope: createScopeSchema(), scan_directories: zod.array( - zod.object({directory: zod.string(), origin: zod.enum(['app_directory', 'include_dir'])}), + zod.object({directory: zod.string(), origin: zod.enum(['app_directory', 'include_dir', 'app_config_directory'])}), ), }) diff --git a/packages/app/src/cli/services/app-security-engine/run.ts b/packages/app/src/cli/services/app-security-engine/run.ts index 1f8f43d59f1..e0f75045786 100644 --- a/packages/app/src/cli/services/app-security-engine/run.ts +++ b/packages/app/src/cli/services/app-security-engine/run.ts @@ -29,14 +29,27 @@ export function getAgentInstructions(): string { return EMBEDDED_APP_SECURITY_INSTRUCTIONS } -/** A scan directory equal to the app directory is the app directory itself, however it was requested. */ -function coverageScanDirectories({appDirectory, scanDirectories}: ScanInput): CoverageScanDirectory[] { +function coverageScanDirectories({ + appDirectory, + scanDirectories, + appConfigDirectories = [], +}: ScanInput): CoverageScanDirectory[] { return scanDirectories.map((directory) => ({ directory: normalizePath(relativePath(appDirectory, directory)) || '.', - origin: directory === appDirectory ? 'app_directory' : 'include_dir', + origin: coverageOrigin(directory, appDirectory, appConfigDirectories), })) } +/** A scan directory equal to the app directory is the app directory itself, however it was requested. */ +function coverageOrigin( + directory: string, + appDirectory: string, + appConfigDirectories: ReadonlyArray, +): CoverageScanDirectory['origin'] { + if (directory === appDirectory) return 'app_directory' + return appConfigDirectories.includes(directory) ? 'app_config_directory' : 'include_dir' +} + export async function scanApp(input: ScanInput, options: ScanOptions = {}): Promise { const {ignoredScanDirectories, otherAppDirectories, ...result} = await scan(input, options) const engineVersion = getEngineVersion() diff --git a/packages/app/src/cli/services/app-security-engine/scanners/discover.ts b/packages/app/src/cli/services/app-security-engine/scanners/discover.ts index 8722273e3c0..a3dbf7f9c54 100644 --- a/packages/app/src/cli/services/app-security-engine/scanners/discover.ts +++ b/packages/app/src/cli/services/app-security-engine/scanners/discover.ts @@ -387,7 +387,8 @@ function groupSourcePathsByExtensionDirectory( * `extension_directories`. The app security check still scans every `shopify.extension.toml` * inside the repository boundary, including unconfigured extensions, because * those files can still contain secrets, XSS, and other security evidence. - * Extensions in a nested app or an `--include-dir` directory count as this app's. + * Extensions in a nested app, an `--include-dir` directory, or a directory that the selected app configuration + * file's `extension_directories` or `web_directories` add count as this app's. */ export function findExtensions(appRoot: string, repositoryFiles: ReadonlyArray): ExtensionInfo[] { const extensionTomls = repositoryFiles.filter((path) => basename(path) === 'shopify.extension.toml') diff --git a/packages/app/src/cli/services/app-security-engine/tests/layout-catalogue.test.ts b/packages/app/src/cli/services/app-security-engine/tests/layout-catalogue.test.ts index 912ab83bf48..f706048b0a0 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/layout-catalogue.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/layout-catalogue.test.ts @@ -33,10 +33,11 @@ interface CheckFlags { json?: boolean } -function toml(path: string, clientId = 'client-app'): FileSpec { +/** `topLevelKeys` come first, since top-level keys must precede tables. */ +function toml(path: string, clientId = 'client-app', topLevelKeys = ''): FileSpec { return [ path, - `name = "test-app" + `${topLevelKeys}name = "test-app" client_id = "${clientId}" application_url = "https://example.com" embedded = true @@ -943,4 +944,43 @@ describe('layout catalogue: check --list-files', () => { }) }) }) + + test('28. extension-and-web-directories-outside-the-app', async () => { + const layout: Layout = { + repositories: ['monorepo'], + files: [ + toml( + 'monorepo/app/shopify.app.toml', + 'client-app', + 'extension_directories = ["../shared-extensions/*"]\nweb_directories = ["../backend"]\n', + ), + 'monorepo/app/app/routes/index.tsx', + ['monorepo/shared-extensions/theme/shopify.extension.toml', 'name = "theme"\ntype = "theme"\n'], + 'monorepo/shared-extensions/theme/blocks/banner.liquid', + 'monorepo/shared-extensions/README.md', + ['monorepo/backend/shopify.web.toml', 'name = "backend"\nroles = ["backend"]\n\n[commands]\ndev = "dev"\n'], + 'monorepo/backend/src/server.ts', + ], + } + await inLayout(layout, async (root) => { + const {resolution, stdout, stderr} = await checkListFiles(root, 'monorepo/app') + + expectConfigSelection(resolution, { + appDirectory: join(root, 'monorepo/app'), + tomlFileName: 'shopify.app.toml', + resultsKey: 'shopify.app', + }) + // Each matched TOML's directory is scanned, not every directory the glob starts from. + expect(listedPaths(stdout)).toEqual([ + '../backend/shopify.web.toml', + '../backend/src/server.ts', + '../shared-extensions/theme/blocks/banner.liquid', + '../shared-extensions/theme/shopify.extension.toml', + 'app/routes/index.tsx', + 'shopify.app.toml', + ]) + expect(warningText(stderr)).not.toContain("another app's configuration") + expect(resolution.commands.scan.args).toEqual(checkArgs()) + }) + }) }) diff --git a/packages/app/src/cli/services/app-security-engine/types.ts b/packages/app/src/cli/services/app-security-engine/types.ts index 838fb58dd95..47ee0ba2e8c 100644 --- a/packages/app/src/cli/services/app-security-engine/types.ts +++ b/packages/app/src/cli/services/app-security-engine/types.ts @@ -82,6 +82,8 @@ export interface ScanInput { requestedScanDirectories: ReadonlyArray /** Absolute path of the selected app configuration file. Absent when scanning without app configuration. */ appConfigFilePath?: string + /** The scan directories that the selected app configuration file's `extension_directories` and `web_directories` add. */ + appConfigDirectories?: ReadonlyArray clientId?: string } @@ -95,7 +97,7 @@ export interface AppSecurityScope { /** A scan directory as recorded in coverage: relative to the app directory, `.` for the app directory itself. */ export interface CoverageScanDirectory { directory: string - origin: 'app_directory' | 'include_dir' + origin: 'app_directory' | 'include_dir' | 'app_config_directory' } export interface ScanOptions { diff --git a/packages/app/src/cli/services/app-security-selection.test.ts b/packages/app/src/cli/services/app-security-selection.test.ts index 8f16887ac58..5e02373c5b1 100644 --- a/packages/app/src/cli/services/app-security-selection.test.ts +++ b/packages/app/src/cli/services/app-security-selection.test.ts @@ -17,7 +17,7 @@ import {fetchOrCreateOrganizationApp} from './context.js' import use from './app/config/use.js' import {AbortError} from '@shopify/cli-kit/node/error' import {fileRealPath, inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' +import {basename, joinPath} from '@shopify/cli-kit/node/path' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {symlink} from 'node:fs/promises' @@ -77,6 +77,7 @@ describe('resolveAppSecuritySelection with an app configuration', () => { kind: 'config', appDirectory, appConfigFilePath: joinPath(appDirectory, 'shopify.app.toml'), + appConfigDirectories: [], configClientId: 'default-client-id', clientIdOverride: undefined, }) @@ -326,6 +327,124 @@ describe('resolveAppSecuritySelection with several TOMLs and none selected', () }) }) +/** A TOML whose `extension_directories` and `web_directories` come first, since top-level keys must precede tables. */ +async function writeConfigurationWithDirectories( + appDirectory: string, + directories: {extension_directories?: string[]; web_directories?: string[]}, + name = 'shopify.app.toml', +): Promise { + const keys = Object.entries(directories).map(([key, entries]) => `${key} = ${JSON.stringify(entries)}\n`) + await mkdir(appDirectory) + await writeFile(joinPath(appDirectory, name), `${keys.join('')}${validAppConfiguration()}`) +} + +/** A theme extension named after its directory, since extension handles must be unique. */ +async function writeThemeExtension(directory: string): Promise { + await mkdir(directory) + await writeFile(joinPath(directory, 'shopify.extension.toml'), `name = "${basename(directory)}"\ntype = "theme"\n`) +} + +async function writeBackendWeb(directory: string): Promise { + await mkdir(directory) + await writeFile( + joinPath(directory, 'shopify.web.toml'), + 'name = "backend"\nroles = ["backend"]\n\n[commands]\ndev = "dev"\n', + ) +} + +describe('resolveAppSecuritySelection app configuration directories', () => { + test('are the directories of the extension and web TOMLs that relative entries match outside the app directory', async () => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = joinPath(directory, 'app') + await writeConfigurationWithDirectories(appDirectory, { + extension_directories: ['extensions/*', '../shared/*', '../no-extensions/*'], + web_directories: ['../backend'], + }) + await writeThemeExtension(joinPath(appDirectory, 'extensions', 'inside')) + await writeThemeExtension(joinPath(directory, 'shared', 'theme')) + await writeFile(joinPath(directory, 'shared', 'README.md'), 'Shared extensions\n') + await mkdir(joinPath(directory, 'no-extensions', 'empty')) + await writeBackendWeb(joinPath(directory, 'backend')) + + const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false}) + + expect(selection).toMatchObject({ + appConfigDirectories: [ + await fileRealPath(joinPath(directory, 'backend')), + await fileRealPath(joinPath(directory, 'shared', 'theme')), + ], + }) + }) + }) + + test('ignore absolute entries, the way the CLI does', async () => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = joinPath(directory, 'app') + await writeConfigurationWithDirectories(appDirectory, { + extension_directories: [joinPath(directory, 'shared', '*')], + web_directories: [joinPath(directory, 'backend')], + }) + await writeThemeExtension(joinPath(directory, 'shared', 'theme')) + await writeBackendWeb(joinPath(directory, 'backend')) + + const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false}) + + expect(selection).toMatchObject({appConfigDirectories: []}) + }) + }) + + test('leave out a directory reached through a symbolic link outside the app directory', async () => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = joinPath(directory, 'app') + await writeConfigurationWithDirectories(appDirectory, {extension_directories: ['../linked-shared/*']}) + await writeThemeExtension(joinPath(directory, 'shared', 'theme')) + await symlink(joinPath(directory, 'shared'), joinPath(directory, 'linked-shared'), 'dir') + + const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false}) + + expect(selection).toMatchObject({appConfigDirectories: []}) + }) + }) + + test('leave out an extension directory inside the app directory that links outside it', async () => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = joinPath(directory, 'app') + await writeConfigurationWithDirectories(appDirectory, {extension_directories: ['extensions/*']}) + await writeThemeExtension(joinPath(directory, 'shared', 'theme')) + await mkdir(joinPath(appDirectory, 'extensions')) + await symlink(joinPath(directory, 'shared', 'theme'), joinPath(appDirectory, 'extensions', 'theme'), 'dir') + + const selection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false}) + + expect(selection).toMatchObject({appConfigDirectories: []}) + }) + }) + + test('come from the selected TOML only', async () => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = joinPath(directory, 'app') + await writeConfigurationWithDirectories(appDirectory, {}) + await writeFile( + joinPath(appDirectory, 'shopify.app.staging.toml'), + `extension_directories = ["../shared/*"]\n${validAppConfiguration('staging-client-id')}`, + ) + await writeThemeExtension(joinPath(directory, 'shared', 'theme')) + + const defaultSelection = await resolveAppSecuritySelection({path: appDirectory, allowPrompts: false}) + const stagingSelection = await resolveAppSecuritySelection({ + path: appDirectory, + config: 'staging', + allowPrompts: false, + }) + + expect(defaultSelection).toMatchObject({appConfigDirectories: []}) + expect(stagingSelection).toMatchObject({ + appConfigDirectories: [await fileRealPath(joinPath(directory, 'shared', 'theme'))], + }) + }) + }) +}) + describe('resolveAppSecuritySelection with --without-app-config', () => { test('aborts without --client-id', async () => { await inTemporaryDirectory(async (directory) => { @@ -773,4 +892,35 @@ describe('mergeScanDirectories', () => { test('accepts an include directory elsewhere on the same Windows drive', () => { expect(mergeScanDirectories('C:/work/app', ['C:/backend']).scanDirectories).toHaveLength(2) }) + + test('lists each app configuration directory after the include directories', () => { + expect(mergeScanDirectories('/work/app', ['/work/backend'], ['/work/shared/theme'])).toEqual({ + scanDirectories: [ + {directory: '/work/app', origin: 'app_directory'}, + {directory: '/work/backend', origin: 'include_dir'}, + {directory: '/work/shared/theme', origin: 'app_config_directory'}, + ], + requestedScanDirectories: ['/work/app', '/work/backend', '/work/shared/theme'], + }) + }) + + test('counts an app configuration directory that is also an include directory as the include directory', () => { + expect(mergeScanDirectories('/work/app', ['/work/shared/theme'], ['/work/shared/theme']).scanDirectories).toEqual([ + {directory: '/work/app', origin: 'app_directory'}, + {directory: '/work/shared/theme', origin: 'include_dir'}, + ]) + }) + + test('drops an app configuration directory inside an include directory', () => { + expect(mergeScanDirectories('/work/app', ['/work/shared'], ['/work/shared/theme']).scanDirectories).toEqual([ + {directory: '/work/app', origin: 'app_directory'}, + {directory: '/work/shared', origin: 'include_dir'}, + ]) + }) + + test('rejects an app configuration directory on another Windows drive', () => { + expect(() => mergeScanDirectories('C:/work/app', [], ['D:/shared/theme'])).toThrowError( + new AbortError('Extension or web directory D:/shared/theme: must be on the same drive as the app directory.'), + ) + }) }) diff --git a/packages/app/src/cli/services/app-security-selection.ts b/packages/app/src/cli/services/app-security-selection.ts index 0c074e15336..c3ba4fa1959 100644 --- a/packages/app/src/cli/services/app-security-selection.ts +++ b/packages/app/src/cli/services/app-security-selection.ts @@ -2,14 +2,26 @@ import {localAppContext} from './app-context.js' import {appCreationDefaults} from './app/config/link.js' import {fetchOrCreateOrganizationApp} from './context.js' import {getCachedAppInfo} from './local-storage.js' +import {extensionFilesForConfig, webFilesForConfig} from '../models/project/config-selection.js' import {NoAppConfigurationFoundError, Project} from '../models/project/project.js' import {getAppConfigurationShorthand} from '../models/app/config-file-naming.js' import {findConfigFiles, selectConfigFile} from '../prompts/config.js' import {configurationFileNames} from '../constants.js' import {AbortError} from '@shopify/cli-kit/node/error' import {fileExistsSync, fileRealPath, isDirectory} from '@shopify/cli-kit/node/fs' -import {basename, cwd, isSubpath, joinPath, normalizePath, relativePath, resolvePath} from '@shopify/cli-kit/node/path' +import { + basename, + cwd, + dirname, + isSubpath, + joinPath, + normalizePath, + relativePath, + resolvePath, +} from '@shopify/cli-kit/node/path' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import {lstat} from 'node:fs/promises' +import type {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' export type AppSecuritySelection = | { @@ -18,6 +30,11 @@ export type AppSecuritySelection = appDirectory: string /** Absolute path of the selected TOML, as found (not the symbolic link's target). */ appConfigFilePath: string + /** + * Absolute real paths of the extension and web directories that the TOML's `extension_directories` and + * `web_directories` add outside the app directory, if any. + */ + appConfigDirectories?: string[] /** The TOML's `client_id`, if any. */ configClientId?: string /** The `--client-id` value, if passed. */ @@ -37,7 +54,7 @@ export type AppSecuritySelection = export interface AppSecurityScanDirectory { /** Absolute; the real path. */ directory: string - origin: 'app_directory' | 'include_dir' + origin: 'app_directory' | 'include_dir' | 'app_config_directory' } interface AppSecuritySelectionOptions { @@ -131,7 +148,7 @@ export async function resolveAppSecuritySelection( try { const unselectedConfigFile = options.config ? undefined : await configFileWhenNoneIsSelected(options, dependencies) - const {app} = await localAppContext({ + const {app, project, activeConfig} = await localAppContext({ directory: options.path, userProvidedConfigName: options.config ?? unselectedConfigFile?.fileName, skipPrompts: !options.allowPrompts, @@ -142,6 +159,7 @@ export async function resolveAppSecuritySelection( appDirectory, // Re-rooted on the real app directory so the engine's containment check compares like with like. appConfigFilePath: joinPath(appDirectory, basename(app.configPath)), + appConfigDirectories: await resolveAppConfigDirectories(project, activeConfig.file), configClientId: app.configuration.client_id || undefined, clientIdOverride: options.clientId, appConfigFilePicked: unselectedConfigFile?.picked, @@ -215,27 +233,76 @@ export async function resolveIncludeDirectories(includeDirs: ReadonlyArray { + const outsideDirectories = [ + ...extensionFilesForConfig(project, activeConfigFile), + ...webFilesForConfig(project, activeConfigFile), + ] + .map((file) => dirname(file.path)) + .filter((directory) => !isSubpath(project.directory, directory)) + + const directories: string[] = [] + for (const directory of new Set(outsideDirectories)) { + // eslint-disable-next-line no-await-in-loop + if (await isReachedThroughSymbolicLink(project.directory, directory)) continue + // eslint-disable-next-line no-await-in-loop + directories.push(await fileRealPath(directory)) + } + return directories.sort() +} + +/** + * Whether a directory outside the app directory is reached through a symbolic link: checks each path segment below the + * ancestor that the two directories share. + */ +async function isReachedThroughSymbolicLink(appDirectory: string, directory: string): Promise { + const segments = relativePath(appDirectory, directory).split('/') + let path = joinPath(appDirectory, ...segments.filter((segment) => segment === '..')) + for (const segment of segments.filter((segment) => segment !== '..')) { + path = joinPath(path, segment) + // eslint-disable-next-line no-await-in-loop + if ((await lstat(path)).isSymbolicLink()) return true + } + return false +} + +/** + * The app directory, then each include directory, then each app configuration directory, compared by real path. A + * duplicate is dropped, so a directory keeps the first origin it has. A directory inside another one is dropped too, + * because walking the outer one covers it; that includes the app directory when another directory contains it. * `requestedScanDirectories` keeps the directories that were dropped for being nested, since each still gets the * ignored-scan-directory warning. * - * Aborts on an include directory on another Windows drive or network share. Gathered files are stored relative to the - * app directory and read back with `joinPath`, which can't reach those, so their files would be silently left unscanned. + * Aborts on a directory on another Windows drive or network share. Gathered files are stored relative to the app + * directory and read back with `joinPath`, which can't reach those, so their files would be silently left unscanned. */ export function mergeScanDirectories( appDirectory: string, includeDirectories: ReadonlyArray, + appConfigDirectories: ReadonlyArray = [], ): {scanDirectories: AppSecurityScanDirectory[]; requestedScanDirectories: string[]} { - const otherDrive = includeDirectories.find( - (directory) => joinPath(appDirectory, relativePath(appDirectory, directory)) !== normalizePath(directory), - ) - if (otherDrive) throw new AbortError(`--include-dir ${otherDrive}: must be on the same drive as the app directory.`) + const isOnAnotherDrive = (directory: string) => + joinPath(appDirectory, relativePath(appDirectory, directory)) !== normalizePath(directory) + const otherDriveIncludeDirectory = includeDirectories.find(isOnAnotherDrive) + if (otherDriveIncludeDirectory) { + throw new AbortError(`--include-dir ${otherDriveIncludeDirectory}: must be on the same drive as the app directory.`) + } + const otherDriveAppConfigDirectory = appConfigDirectories.find(isOnAnotherDrive) + if (otherDriveAppConfigDirectory) { + throw new AbortError( + `Extension or web directory ${otherDriveAppConfigDirectory}: must be on the same drive as the app directory.`, + ) + } const requested = [ {directory: appDirectory, origin: 'app_directory' as const}, ...includeDirectories.map((directory) => ({directory, origin: 'include_dir' as const})), + ...appConfigDirectories.map((directory) => ({directory, origin: 'app_config_directory' as const})), ].filter((candidate, index, all) => all.findIndex(({directory}) => directory === candidate.directory) === index) return { diff --git a/packages/app/src/cli/services/security-check.test.ts b/packages/app/src/cli/services/security-check.test.ts index 47bfdf0239d..aca71c5327b 100644 --- a/packages/app/src/cli/services/security-check.test.ts +++ b/packages/app/src/cli/services/security-check.test.ts @@ -254,6 +254,7 @@ describe('securityCheck', () => { appDirectory, scanDirectories: [appDirectory], requestedScanDirectories: [appDirectory], + appConfigDirectories: [], appConfigFilePath: `${appDirectory}/shopify.app.toml`, clientId: 'toml-client-id', includeDirs: [], @@ -375,6 +376,34 @@ describe('securityCheck', () => { }) }) + test('scans the app configuration directories of the selection after the include directories, without repeating them in generated commands', async () => { + await inTemporaryDirectory(async (directory) => { + await mkdir(joinPath(directory, 'backend')) + vi.stubEnv('INIT_CWD', directory) + const backend = await fileRealPath(joinPath(directory, 'backend')) + const sharedTheme = '/tmp/shared/theme' + const dependencies = testDependencies(scanExecution, {...configSelection, appConfigDirectories: [sharedTheme]}) + + const resolution = await securityCheck({...testOptions(), json: true, includeDirs: ['backend']}, dependencies) + + expect(dependencies.execute).toHaveBeenCalledWith( + expect.objectContaining({ + scanDirectories: [appDirectory, backend, sharedTheme], + requestedScanDirectories: [appDirectory, backend, sharedTheme], + appConfigDirectories: [sharedTheme], + }), + ) + expect(JSON.parse(dependencies.output.mock.calls[0]![0]).selection.scan_directories).toEqual([ + {directory: appDirectory, origin: 'app_directory'}, + {directory: backend, origin: 'include_dir'}, + {directory: sharedTheme, origin: 'app_config_directory'}, + ]) + expect(resolution.commands).toEqual( + commandsFor(configSelection, {include_dirs: ['backend'], excludes: [], no_git_ignore: false}), + ) + }) + }) + test('leaves out an --include-dir inside the app directory, but still passes it for the Git ignore warning', async () => { await inTemporaryDirectory(async (directory) => { const realDirectory = await fileRealPath(directory) @@ -558,6 +587,7 @@ describe('securityCheck', () => { appDirectory, scanDirectories: [appDirectory], requestedScanDirectories: [appDirectory], + appConfigDirectories: [], appConfigFilePath: undefined, clientId: 'flag-client-id', includeDirs: [], @@ -928,6 +958,7 @@ describe('securityCheck --list-files', () => { appDirectory, scanDirectories: [appDirectory, backend], requestedScanDirectories: [appDirectory, backend], + appConfigDirectories: [], appConfigFilePath: `${appDirectory}/shopify.app.toml`, clientId: 'toml-client-id', includeDirs: ['backend'], diff --git a/packages/app/src/cli/services/security-check.ts b/packages/app/src/cli/services/security-check.ts index b3f56acd865..02d1bda20ea 100644 --- a/packages/app/src/cli/services/security-check.ts +++ b/packages/app/src/cli/services/security-check.ts @@ -236,12 +236,20 @@ export default async function securityCheck( body: [{command: formatAppSecurityCommand(commands.scan)}], }) } - const {scanDirectories, requestedScanDirectories} = mergeScanDirectories(appDirectory, includeDirectories) + const appConfigDirectories = selection.kind === 'config' ? (selection.appConfigDirectories ?? []) : [] + const {scanDirectories, requestedScanDirectories} = mergeScanDirectories( + appDirectory, + includeDirectories, + appConfigDirectories, + ) const scanOptions = { appDirectory, scanDirectories: scanDirectories.map(({directory}) => directory), requestedScanDirectories, + appConfigDirectories: scanDirectories + .filter(({origin}) => origin === 'app_config_directory') + .map(({directory}) => directory), appConfigFilePath: selection.kind === 'config' ? selection.appConfigFilePath : undefined, clientId: effectiveClientId(selection), includeDirs: options.includeDirs, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 20822604f7b..d508ea94c55 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4511,7 +4511,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Combines the deterministic results (`deterministic-findings.json`, written by `shopify app security check`) with the recorded agent results (`agent-findings.json`, written by `shopify app security record`) and shows one view of every check: its findings, status and source. Both files are in the results directory, `.shopify/app-security//`.\n\nThe summary shows the scan directories and the scope of the latest scan, and the scope the agent reported. It notes when the agent findings were recorded for a different scope than the latest scan; that doesn't change the exit code.\n\nThe agent results are optional. Use `--check-id` to narrow the review to specific checks, `--verbose` for full reasoning, evidence and suppressed findings, and `--blocking` to exit with code 1 when a check with findings is at or above a severity.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppSecurityReviewResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"filter\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"check_ids\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"check_ids\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"sources\": {\n \"type\": \"object\",\n \"properties\": {\n \"deterministic\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppSecurityDeterministicSource\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"agent\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppSecurityAgentSource\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"deterministic\",\n \"agent\"\n ],\n \"additionalProperties\": false\n },\n \"scope_differs\": {\n \"type\": \"boolean\"\n },\n \"checks\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck\"\n }\n }\n },\n \"required\": [\n \"filter\",\n \"sources\",\n \"scope_differs\",\n \"checks\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppSecurityReviewResult\",\n \"definitions\": {\n \"AppSecurityDeterministicSource\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"schema_version\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"engine\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"const\": \"shopify-app-security\"\n },\n \"version\": {\n \"type\": \"string\"\n },\n \"ruleset\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"version\",\n \"ruleset\"\n ],\n \"additionalProperties\": false\n },\n \"generated_at\": {\n \"type\": \"string\"\n },\n \"detection\": {\n \"type\": \"object\",\n \"properties\": {\n \"framework\": {\n \"type\": \"string\",\n \"enum\": [\n \"react_router\",\n \"none\",\n \"unknown\",\n \"mixed\"\n ]\n },\n \"surface\": {\n \"type\": \"string\",\n \"enum\": [\n \"react_router\",\n \"theme_app_extension\",\n \"config_only\",\n \"unknown\",\n \"mixed\"\n ]\n },\n \"languages\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"support\": {\n \"type\": \"string\",\n \"enum\": [\n \"supported\",\n \"unsupported\"\n ]\n },\n \"files\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"name\",\n \"support\",\n \"files\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"framework\",\n \"surface\",\n \"languages\"\n ],\n \"additionalProperties\": false\n },\n \"coverage\": {\n \"type\": \"object\",\n \"properties\": {\n \"files_scanned\": {\n \"type\": \"number\"\n },\n \"files_skipped\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\n \"too_large\",\n \"unreadable\"\n ]\n },\n \"size_bytes\": {\n \"type\": \"number\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"path\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n },\n \"gaps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"skipped_file\",\n \"unsupported_framework\",\n \"unsupported_language\",\n \"unresolved_check\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"check_id\": {\n \"type\": \"string\"\n },\n \"file\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n },\n \"scope\": {\n \"type\": \"object\",\n \"properties\": {\n \"include_dirs\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"excludes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"no_git_ignore\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"include_dirs\",\n \"excludes\",\n \"no_git_ignore\"\n ],\n \"additionalProperties\": false\n },\n \"scan_directories\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"directory\": {\n \"type\": \"string\"\n },\n \"origin\": {\n \"type\": \"string\",\n \"enum\": [\n \"app_directory\",\n \"include_dir\"\n ]\n }\n },\n \"required\": [\n \"directory\",\n \"origin\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"files_scanned\",\n \"files_skipped\",\n \"gaps\",\n \"scope\",\n \"scan_directories\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"path\",\n \"schema_version\",\n \"engine\",\n \"generated_at\",\n \"detection\",\n \"coverage\"\n ],\n \"additionalProperties\": false\n },\n \"AppSecurityAgentSource\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"schema_version\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"engine\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"const\": \"shopify-app-security\"\n },\n \"version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"version\"\n ],\n \"additionalProperties\": false\n },\n \"generated_at\": {\n \"type\": \"string\"\n },\n \"scope\": {\n \"type\": \"object\",\n \"properties\": {\n \"include_dirs\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"excludes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"no_git_ignore\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"include_dirs\",\n \"excludes\",\n \"no_git_ignore\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"path\",\n \"schema_version\",\n \"engine\",\n \"generated_at\",\n \"scope\"\n ],\n \"additionalProperties\": false\n },\n \"AppSecurityCombinedCheck\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"title\": {\n \"type\": \"string\"\n },\n \"severity\": {\n \"type\": \"string\",\n \"enum\": [\n \"high\",\n \"medium\",\n \"low\"\n ]\n },\n \"description\": {\n \"type\": \"string\"\n },\n \"guide\": {\n \"type\": \"string\"\n },\n \"docs_url\": {\n \"type\": \"string\"\n },\n \"precedence\": {\n \"type\": \"string\",\n \"enum\": [\n \"union\",\n \"prefer-agent\"\n ]\n },\n \"applied_precedence\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck/properties/precedence\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"executed\",\n \"not_applicable\",\n \"unresolved\"\n ]\n },\n \"by_source\": {\n \"type\": \"object\",\n \"properties\": {\n \"deterministic\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"version\": {\n \"type\": \"number\"\n },\n \"status\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck/properties/status\"\n },\n \"reason\": {\n \"type\": \"object\",\n \"properties\": {\n \"code\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"analysis_mode\": {\n \"type\": \"string\",\n \"enum\": [\n \"regex\",\n \"structured_config\",\n \"ast\"\n ]\n },\n \"generated_at\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"version\",\n \"status\",\n \"generated_at\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"agent\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck/properties/by_source/properties/deterministic/anyOf/0\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"deterministic\",\n \"agent\"\n ],\n \"additionalProperties\": false\n },\n \"findings\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedFinding\"\n }\n }\n },\n \"required\": [\n \"id\",\n \"title\",\n \"severity\",\n \"description\",\n \"precedence\",\n \"applied_precedence\",\n \"status\",\n \"by_source\",\n \"findings\"\n ],\n \"additionalProperties\": false\n },\n \"AppSecurityCombinedFinding\": {\n \"type\": \"object\",\n \"properties\": {\n \"source\": {\n \"type\": \"string\",\n \"enum\": [\n \"deterministic\",\n \"agent\"\n ]\n },\n \"disposition\": {\n \"type\": \"string\",\n \"enum\": [\n \"active\",\n \"suppressed\",\n \"superseded\"\n ]\n },\n \"location\": {\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"line\": {\n \"type\": \"number\"\n },\n \"column\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\n \"file\"\n ],\n \"additionalProperties\": false\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"evidence\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"location\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedFinding/properties/location\"\n },\n \"quote\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"location\"\n ],\n \"additionalProperties\": false\n }\n },\n \"snippet\": {\n \"type\": \"string\"\n },\n \"fix\": {\n \"type\": \"object\",\n \"properties\": {\n \"automated\": {\n \"type\": \"boolean\"\n },\n \"guide\": {\n \"type\": \"string\"\n },\n \"description\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"automated\",\n \"description\"\n ],\n \"additionalProperties\": false\n },\n \"confidence\": {\n \"type\": \"string\",\n \"enum\": [\n \"high\",\n \"medium\",\n \"low\"\n ]\n },\n \"reasoning\": {\n \"type\": \"string\"\n },\n \"suppression\": {\n \"type\": \"object\",\n \"properties\": {\n \"justification\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"justification\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"source\",\n \"disposition\",\n \"location\",\n \"message\",\n \"evidence\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Combines the deterministic results (`deterministic-findings.json`, written by `shopify app security check`) with the recorded agent results (`agent-findings.json`, written by `shopify app security record`) and shows one view of every check: its findings, status and source. Both files are in the results directory, `.shopify/app-security//`.\n\nThe summary shows the scan directories and the scope of the latest scan, and the scope the agent reported. It notes when the agent findings were recorded for a different scope than the latest scan; that doesn't change the exit code.\n\nThe agent results are optional. Use `--check-id` to narrow the review to specific checks, `--verbose` for full reasoning, evidence and suppressed findings, and `--blocking` to exit with code 1 when a check with findings is at or above a severity.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppSecurityReviewResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"filter\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"check_ids\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"check_ids\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"sources\": {\n \"type\": \"object\",\n \"properties\": {\n \"deterministic\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppSecurityDeterministicSource\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"agent\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppSecurityAgentSource\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"deterministic\",\n \"agent\"\n ],\n \"additionalProperties\": false\n },\n \"scope_differs\": {\n \"type\": \"boolean\"\n },\n \"checks\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck\"\n }\n }\n },\n \"required\": [\n \"filter\",\n \"sources\",\n \"scope_differs\",\n \"checks\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppSecurityReviewResult\",\n \"definitions\": {\n \"AppSecurityDeterministicSource\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"schema_version\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"engine\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"const\": \"shopify-app-security\"\n },\n \"version\": {\n \"type\": \"string\"\n },\n \"ruleset\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"version\",\n \"ruleset\"\n ],\n \"additionalProperties\": false\n },\n \"generated_at\": {\n \"type\": \"string\"\n },\n \"detection\": {\n \"type\": \"object\",\n \"properties\": {\n \"framework\": {\n \"type\": \"string\",\n \"enum\": [\n \"react_router\",\n \"none\",\n \"unknown\",\n \"mixed\"\n ]\n },\n \"surface\": {\n \"type\": \"string\",\n \"enum\": [\n \"react_router\",\n \"theme_app_extension\",\n \"config_only\",\n \"unknown\",\n \"mixed\"\n ]\n },\n \"languages\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"support\": {\n \"type\": \"string\",\n \"enum\": [\n \"supported\",\n \"unsupported\"\n ]\n },\n \"files\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"name\",\n \"support\",\n \"files\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"framework\",\n \"surface\",\n \"languages\"\n ],\n \"additionalProperties\": false\n },\n \"coverage\": {\n \"type\": \"object\",\n \"properties\": {\n \"files_scanned\": {\n \"type\": \"number\"\n },\n \"files_skipped\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\n \"too_large\",\n \"unreadable\"\n ]\n },\n \"size_bytes\": {\n \"type\": \"number\"\n },\n \"detail\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"path\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n },\n \"gaps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"skipped_file\",\n \"unsupported_framework\",\n \"unsupported_language\",\n \"unresolved_check\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"check_id\": {\n \"type\": \"string\"\n },\n \"file\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n },\n \"scope\": {\n \"type\": \"object\",\n \"properties\": {\n \"include_dirs\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"excludes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"no_git_ignore\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"include_dirs\",\n \"excludes\",\n \"no_git_ignore\"\n ],\n \"additionalProperties\": false\n },\n \"scan_directories\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"directory\": {\n \"type\": \"string\"\n },\n \"origin\": {\n \"type\": \"string\",\n \"enum\": [\n \"app_directory\",\n \"include_dir\",\n \"app_config_directory\"\n ]\n }\n },\n \"required\": [\n \"directory\",\n \"origin\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"files_scanned\",\n \"files_skipped\",\n \"gaps\",\n \"scope\",\n \"scan_directories\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"path\",\n \"schema_version\",\n \"engine\",\n \"generated_at\",\n \"detection\",\n \"coverage\"\n ],\n \"additionalProperties\": false\n },\n \"AppSecurityAgentSource\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"schema_version\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"engine\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"const\": \"shopify-app-security\"\n },\n \"version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"version\"\n ],\n \"additionalProperties\": false\n },\n \"generated_at\": {\n \"type\": \"string\"\n },\n \"scope\": {\n \"type\": \"object\",\n \"properties\": {\n \"include_dirs\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"excludes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"no_git_ignore\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"include_dirs\",\n \"excludes\",\n \"no_git_ignore\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"path\",\n \"schema_version\",\n \"engine\",\n \"generated_at\",\n \"scope\"\n ],\n \"additionalProperties\": false\n },\n \"AppSecurityCombinedCheck\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"title\": {\n \"type\": \"string\"\n },\n \"severity\": {\n \"type\": \"string\",\n \"enum\": [\n \"high\",\n \"medium\",\n \"low\"\n ]\n },\n \"description\": {\n \"type\": \"string\"\n },\n \"guide\": {\n \"type\": \"string\"\n },\n \"docs_url\": {\n \"type\": \"string\"\n },\n \"precedence\": {\n \"type\": \"string\",\n \"enum\": [\n \"union\",\n \"prefer-agent\"\n ]\n },\n \"applied_precedence\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck/properties/precedence\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"executed\",\n \"not_applicable\",\n \"unresolved\"\n ]\n },\n \"by_source\": {\n \"type\": \"object\",\n \"properties\": {\n \"deterministic\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"version\": {\n \"type\": \"number\"\n },\n \"status\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck/properties/status\"\n },\n \"reason\": {\n \"type\": \"object\",\n \"properties\": {\n \"code\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"code\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n \"analysis_mode\": {\n \"type\": \"string\",\n \"enum\": [\n \"regex\",\n \"structured_config\",\n \"ast\"\n ]\n },\n \"generated_at\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"version\",\n \"status\",\n \"generated_at\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"agent\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppSecurityCombinedCheck/properties/by_source/properties/deterministic/anyOf/0\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"deterministic\",\n \"agent\"\n ],\n \"additionalProperties\": false\n },\n \"findings\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedFinding\"\n }\n }\n },\n \"required\": [\n \"id\",\n \"title\",\n \"severity\",\n \"description\",\n \"precedence\",\n \"applied_precedence\",\n \"status\",\n \"by_source\",\n \"findings\"\n ],\n \"additionalProperties\": false\n },\n \"AppSecurityCombinedFinding\": {\n \"type\": \"object\",\n \"properties\": {\n \"source\": {\n \"type\": \"string\",\n \"enum\": [\n \"deterministic\",\n \"agent\"\n ]\n },\n \"disposition\": {\n \"type\": \"string\",\n \"enum\": [\n \"active\",\n \"suppressed\",\n \"superseded\"\n ]\n },\n \"location\": {\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"string\"\n },\n \"line\": {\n \"type\": \"number\"\n },\n \"column\": {\n \"type\": \"number\"\n }\n },\n \"required\": [\n \"file\"\n ],\n \"additionalProperties\": false\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"evidence\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"location\": {\n \"$ref\": \"#/definitions/AppSecurityCombinedFinding/properties/location\"\n },\n \"quote\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"location\"\n ],\n \"additionalProperties\": false\n }\n },\n \"snippet\": {\n \"type\": \"string\"\n },\n \"fix\": {\n \"type\": \"object\",\n \"properties\": {\n \"automated\": {\n \"type\": \"boolean\"\n },\n \"guide\": {\n \"type\": \"string\"\n },\n \"description\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"automated\",\n \"description\"\n ],\n \"additionalProperties\": false\n },\n \"confidence\": {\n \"type\": \"string\",\n \"enum\": [\n \"high\",\n \"medium\",\n \"low\"\n ]\n },\n \"reasoning\": {\n \"type\": \"string\"\n },\n \"suppression\": {\n \"type\": \"object\",\n \"properties\": {\n \"justification\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"justification\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"source\",\n \"disposition\",\n \"location\",\n \"message\",\n \"evidence\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Combines the deterministic results (`deterministic-findings.json`, written by `shopify app security check`) with the recorded agent results (`agent-findings.json`, written by `shopify app security record`) and shows one view of every check: its findings, status and source. Both files are in the results directory, `.shopify/app-security//`.\n\nThe summary shows the scan directories and the scope of the latest scan, and the scope the agent reported. It notes when the agent findings were recorded for a different scope than the latest scan; that doesn't change the exit code.\n\nThe agent results are optional. Use `--check-id` to narrow the review to specific checks, `--verbose` for full reasoning, evidence and suppressed findings, and `--blocking` to exit with code 1 when a check with findings is at or above a severity.", "enableJsonFlag": false, "flags": {