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": {