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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 39 additions & 38 deletions packages/app/src/cli/commands/app/security/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ describe('app security check command', () => {
'blocking',
'client-id',
'config',
'ignore',
'exclude',
'json',
'no-git-ignore',
'path',
'skip-instructions',
'without-app-config',
Expand All @@ -52,43 +53,35 @@ describe('app security check command', () => {
blocking: 'high',
yes: false,
skipInstructions: true,
ignorePatterns: [],
excludePatterns: [],
noGitIgnore: false,
})
})

test('forwards repeated --ignore patterns in command-line order', async () => {
test('forwards repeated --exclude globs exactly as typed, in command-line order', async () => {
await SecurityCheck.run(
['--ignore', 'generated/', '--ignore', '!build/', '--ignore', 'a b/', '--skip-instructions'],
['--exclude', 'generated', '--exclude', '../shared/**', '--exclude', 'a b/', '--skip-instructions'],
import.meta.url,
)

expect(securityCheck).toHaveBeenCalledWith(
expect.objectContaining({ignorePatterns: ['generated/', '!build/', 'a b/'], skipInstructions: true}),
expect.objectContaining({excludePatterns: ['generated', '../shared/**', 'a b/'], skipInstructions: true}),
)
})

test.each([
['#generated/', 'comment'],
['', 'empty'],
['!', 'nothing after'],
['build\\', 'ends with a backslash'],
['build\\\\\\', 'ends with a backslash'],
['src/[id/x.ts', "can't be read as a .gitignore pattern"],
['build/\ngenerated/', 'single line'],
])('rejects the unusable --ignore pattern %j', async (value, expectedMessage) => {
const outputMock = mockAndCaptureOutput()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
test('forwards --no-git-ignore', async () => {
await SecurityCheck.run(['--no-git-ignore', '--skip-instructions'], import.meta.url)

try {
await expect(SecurityCheck.run(['--ignore', value, '--skip-instructions'], import.meta.url)).rejects.toThrow(
'process.exit unexpectedly called with "1"',
)
expect(outputMock.error()).toContain(expectedMessage)
expect(securityCheck).not.toHaveBeenCalled()
} finally {
consoleErrorSpy.mockRestore()
outputMock.clear()
}
expect(securityCheck).toHaveBeenCalledWith(expect.objectContaining({noGitIgnore: true, skipInstructions: true}))
})

test('does not read --exclude or --no-git-ignore from the environment', () => {
expect(SecurityCheck.flags.exclude.env).toBeUndefined()
expect(SecurityCheck.flags['no-git-ignore'].env).toBe('SHOPIFY_FLAG_NO_GIT_IGNORE')
})

test('rejects the removed --ignore flag', async () => {
await expect(SecurityCheck.run(['--ignore', 'build/', '--skip-instructions'], import.meta.url)).rejects.toThrow()
})

test('forwards --yes without requiring an app configuration', async () => {
Expand All @@ -104,7 +97,8 @@ describe('app security check command', () => {
blocking: 'none',
yes: true,
skipInstructions: false,
ignorePatterns: [],
excludePatterns: [],
noGitIgnore: false,
})
})

Expand Down Expand Up @@ -165,20 +159,27 @@ describe('app security check command', () => {
expect(SecurityCheck.descriptionWithMarkdown).not.toMatch(/--findings|--clean|compile|trace/)
})

test('documents --ignore as ordered .gitignore patterns that the coding-agent instructions repeat', () => {
expect(SecurityCheck.flags.ignore.multiple).toBe(true)
expect(SecurityCheck.flags.ignore.description).toBe(
'Ignore files that match this .gitignore pattern, relative to the app directory. Start the pattern with ! to include matching files again. Repeat the flag to add patterns; later patterns take precedence.',
test('documents what is scanned, --exclude and --no-git-ignore', () => {
expect(SecurityCheck.flags.exclude.multiple).toBe(true)
expect(SecurityCheck.flags.exclude.description).toBe(
"Skip paths that match this glob, relative to the working directory. Repeat the flag to add globs. The selected app configuration file can't be excluded.",
)
expect(SecurityCheck.flags['no-git-ignore'].description).toBe(
'Turn off Git ignore rules for every scanned directory, so files that Git ignores are scanned too. Files that Git tracks are always scanned.',
)
expect(SecurityCheck.descriptionWithMarkdown).toContain('scans the app directory and each `--include-dir`')
expect(SecurityCheck.descriptionWithMarkdown).toContain('files that Git tracks are always scanned')
expect(SecurityCheck.descriptionWithMarkdown).toContain('relative to the working directory')
expect(SecurityCheck.descriptionWithMarkdown).toContain("--exclude '**/generated'")
expect(SecurityCheck.descriptionWithMarkdown).toContain(
"An exclusion can't remove the selected app configuration file.",
)
expect(SecurityCheck.descriptionWithMarkdown).toContain('`--ignore`')
expect(SecurityCheck.descriptionWithMarkdown).toContain('relative to the app directory')
expect(SecurityCheck.descriptionWithMarkdown).toContain('later patterns take precedence')
expect(SecurityCheck.descriptionWithMarkdown).toContain("--ignore '!build/'")
expect(SecurityCheck.descriptionWithMarkdown).toContain('single quotes in POSIX shells and PowerShell')
expect(SecurityCheck.descriptionWithMarkdown).toContain('`--no-git-ignore` to turn Git ignore rules off')
expect(SecurityCheck.descriptionWithMarkdown).toContain('`.shopify/app-security/<results key>/`')
expect(SecurityCheck.descriptionWithMarkdown).toContain(
'The coding-agent instructions this check offers repeat the patterns.',
"Other `app security` commands don't take `--exclude` or `--no-git-ignore`",
)
expect(SecurityCheck.descriptionWithMarkdown).toContain("Other `app security` commands don't take `--ignore`")
expect(SecurityCheck.descriptionWithMarkdown).not.toContain('--ignore')
})

test('allows --yes in JSON mode while preserving non-interactive output behavior', async () => {
Expand Down
23 changes: 12 additions & 11 deletions packages/app/src/cli/commands/app/security/check.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import {appSecurityBlockingFlag} from './blocking-flag.js'
import {appSecuritySelectionFlags} from './selection-flags.js'
import {ignorePatternProblem} from '../../../services/app-security-engine/index.js'
import securityCheck from '../../../services/security-check.js'
import {Flags} from '@oclif/core'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {AbortError} from '@shopify/cli-kit/node/error'

export default class SecurityCheck extends BaseCommand {
static hidden = true
Expand All @@ -17,7 +15,9 @@ export default class SecurityCheck extends BaseCommand {

\`deterministic-findings.json\` holds the deterministic scan results. \`agent-checks.json\` holds the checks for your coding agent to investigate; the agent's results are recorded with \`shopify app security record\`. Use \`--config\` to select a specific app configuration when the project has multiple \`shopify.app*.toml\` files; App Security inspects only that configuration. Use \`--client-id\` to replace the configuration's client ID for this run. When no app configuration exists, use \`--without-app-config --client-id <client-id>\` to scan \`--path\` anyway with config checks skipped; in an interactive terminal the command offers to do this.

Use \`--ignore\` to change which files are scanned. Each value is one \`.gitignore\` pattern relative to the app directory; prefix it with \`!\` to include a file again when it is ignored by default or by \`.gitignore\`. Repeat the flag to add patterns; later patterns take precedence. A file can't be included again while its parent folder is ignored, so include the folder again instead, for example \`--ignore '!build/'\`. Quote each value so your shell doesn't expand \`!\` or \`*\` (single quotes in POSIX shells and PowerShell). The coding-agent instructions this check offers repeat the patterns. Other \`app security\` commands don't take \`--ignore\`, so pass the same patterns each time you run the check.
The check scans the app directory and each \`--include-dir\`. Git ignore rules apply by default: a file or directory that Git ignores is skipped, using the rules of the repository that contains it, while files that Git tracks are always scanned. Use \`--no-git-ignore\` to turn Git ignore rules off for every scanned directory.

Use \`--exclude\` to skip more paths. Each value is a glob that is matched against the path relative to the working directory, so a path above it starts with \`../\`, and a name at any depth needs \`**/\`, for example \`--exclude '**/generated'\`. Repeat the flag to add globs. An exclusion can't remove the selected app configuration file. Quote each value so your shell doesn't expand \`*\`. The coding-agent instructions this check offers repeat the globs. Other \`app security\` commands don't take \`--exclude\` or \`--no-git-ignore\`, so pass the same flags each time you run the check.

In interactive terminals, the command offers to copy the coding-agent instructions, print them, or choose nothing; copying is the default. In CI and other non-interactive environments, instructions aren't offered unless you pass \`--yes\`, which prints them. JSON output never prompts or prints those instructions. You can also run \`shopify app security instructions\` to print, copy, or write them later.`

Expand All @@ -28,15 +28,15 @@ In interactive terminals, the command offers to copy the coding-agent instructio
...appSecuritySelectionFlags,
// No environment variable: oclif passes a repeatable flag's variable as one string, so it could hold only one pattern.
// eslint-disable-next-line @shopify/cli/command-flags-with-env
ignore: Flags.string({
exclude: Flags.string({
description:
'Ignore files that match this .gitignore pattern, relative to the app directory. Start the pattern with ! to include matching files again. Repeat the flag to add patterns; later patterns take precedence.',
"Skip paths that match this glob, relative to the working directory. Repeat the flag to add globs. The selected app configuration file can't be excluded.",
multiple: true,
parse: async (input) => {
const problem = ignorePatternProblem(input)
if (problem) throw new AbortError(problem)
return input
},
}),
'no-git-ignore': Flags.boolean({
description:
'Turn off Git ignore rules for every scanned directory, so files that Git ignores are scanned too. Files that Git tracks are always scanned.',
env: 'SHOPIFY_FLAG_NO_GIT_IGNORE',
}),
...jsonFlag,
...appSecurityBlockingFlag,
Expand Down Expand Up @@ -67,7 +67,8 @@ In interactive terminals, the command offers to copy the coding-agent instructio
blocking: flags.blocking,
yes: flags.yes,
skipInstructions: flags['skip-instructions'],
ignorePatterns: flags.ignore ?? [],
excludePatterns: flags.exclude ?? [],
noGitIgnore: Boolean(flags['no-git-ignore']),
})
}
}
17 changes: 13 additions & 4 deletions packages/app/src/cli/services/app-security-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {writeCheckArtifacts} from './app-security-artifacts.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {describe, expect, test} from 'vitest'
import {afterEach, describe, expect, test, vi} from 'vitest'
import {symlink} from 'node:fs/promises'
import type {Issue} from './app-security-engine/index.js'

Expand All @@ -17,9 +17,17 @@ function artifactPath(directory: string, name: string): string {
}

function scanInputFor(directory: string) {
return {appDirectory: directory, appConfigFilePath: joinPath(directory, 'shopify.app.toml')}
return {
appDirectory: directory,
scanDirectories: [directory],
appConfigFilePath: joinPath(directory, 'shopify.app.toml'),
}
}

afterEach(() => {
vi.unstubAllEnvs()
})

async function runSecurity(options: {directory: string; blocking: AppSecurityBlockingLevel}) {
const execution = await executeAppSecurity(scanInputFor(options.directory))
const artifacts = await writeCheckArtifacts(options.directory, 'shopify.app', execution)
Expand Down Expand Up @@ -111,15 +119,16 @@ describe('App Security CLI integration', () => {
})
})

test('forwards --ignore patterns to the scan', async () => {
test('forwards --exclude patterns to the scan', async () => {
await inTemporaryDirectory(async (directory) => {
vi.stubEnv('INIT_CWD', directory)
await createApp(directory)
await mkdir(joinPath(directory, 'generated'))
await writeFile(joinPath(directory, 'generated', 'client.ts'), 'export const generated = true\n')
const scanInput = scanInputFor(directory)

const unfiltered = await executeAppSecurity(scanInput)
const filtered = await executeAppSecurity({...scanInput, ignorePatterns: ['generated/']})
const filtered = await executeAppSecurity({...scanInput, excludePatterns: ['generated']})

expect(unfiltered.scan.scan.files_scanned - filtered.scan.scan.files_scanned).toBe(1)
})
Expand Down
8 changes: 5 additions & 3 deletions packages/app/src/cli/services/app-security-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type AppSecurityEngineMetadata,
type AppSecurityScan,
type ScanInput,
type ScanOptions,
type Severity,
} from './app-security-engine/index.js'

Expand All @@ -20,11 +21,12 @@ export function securityExitCode(execution: AppSecurityExecution, blocking: AppS
}

export async function executeAppSecurity({
ignorePatterns,
excludePatterns,
noGitIgnore,
...scanInput
}: ScanInput & {ignorePatterns?: ReadonlyArray<string>}): Promise<AppSecurityExecution> {
}: ScanInput & ScanOptions): Promise<AppSecurityExecution> {
const startTime = Date.now()
const result = await scanApp(scanInput, {ignorePatterns})
const result = await scanApp(scanInput, {excludePatterns, noGitIgnore})
return {
...result,
elapsedMilliseconds: Date.now() - startTime,
Expand Down
60 changes: 33 additions & 27 deletions packages/app/src/cli/services/app-security-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,25 +169,31 @@ describe('resolveAppSecurityCommands', () => {
expect(commands.clean.args).toEqual(['app', 'security', 'clean', {flag: '--path', value: '/tmp/app'}])
})

test('repeats --ignore patterns in order, after --config, on scan only', () => {
const commands = resolveAppSecurityCommands('/tmp/app', 'shopify.app.staging.toml', ['generated/', '!build/'])
test('repeats --exclude globs in order, after --config, then --no-git-ignore, on scan only', () => {
const commands = resolveAppSecurityCommands(
'/tmp/app',
'shopify.app.staging.toml',
['generated', '../shared/**'],
true,
)

expect(commands.scan.args).toEqual([
'app',
'security',
'check',
{flag: '--path', value: '/tmp/app'},
{flag: '--config', value: 'staging'},
{flag: '--ignore', value: 'generated/'},
{flag: '--ignore', value: '!build/'},
{flag: '--exclude', value: 'generated'},
{flag: '--exclude', value: '../shared/**'},
'--no-git-ignore',
])
expect(commands.record.args).toEqual(['app', 'security', 'record', {flag: '--path', value: '/tmp/app'}])
expect(commands.review.args).toEqual(['app', 'security', 'review', {flag: '--path', value: '/tmp/app'}])
expect(commands.clean.args).toEqual(['app', 'security', 'clean', {flag: '--path', value: '/tmp/app'}])
})

test('omits --ignore when there are no patterns', () => {
expect(resolveAppSecurityCommands('/tmp/app', undefined, []).scan.args).toEqual([
test('omits --exclude and --no-git-ignore when they were not passed', () => {
expect(resolveAppSecurityCommands('/tmp/app', undefined, [], false).scan.args).toEqual([
'app',
'security',
'check',
Expand Down Expand Up @@ -229,9 +235,9 @@ describe('resolveAppSecurityCommands', () => {
})

describe('formatAppSecurityCommand', () => {
test('quotes --ignore patterns so the shell does not expand `!`, `*`, or spaces', () => {
const ignorePatterns = ['!build/', '*.log', 'a b/']
const commands = resolveAppSecurityCommands('/tmp/app', undefined, ignorePatterns)
test('quotes --exclude globs so the shell does not expand `!`, `*`, or spaces', () => {
const excludePatterns = ['!build/', '*.log', 'a b/']
const commands = resolveAppSecurityCommands('/tmp/app', undefined, excludePatterns)

for (const shell of ['posix', 'cmd', 'powershell'] as const) {
const formatted = formatAppSecurityCommand(commands.scan, shell)
Expand All @@ -242,30 +248,30 @@ describe('formatAppSecurityCommand', () => {
'check',
'--path',
'/tmp/app',
'--ignore',
'--exclude',
'!build/',
'--ignore',
'--exclude',
'*.log',
'--ignore',
'--exclude',
'a b/',
])
expect(formatted).not.toMatch(/ !build\//)
expect(formatted).not.toMatch(/ \*\.log/)
}
expect(formatAppSecurityCommand(commands.scan, 'posix')).toContain(
"--ignore '!build/' --ignore '*.log' --ignore 'a b/'",
"--exclude '!build/' --exclude '*.log' --exclude 'a b/'",
)
expect(formatAppSecurityCommand(commands.scan, 'powershell')).toContain(
"--ignore '!build/' --ignore '*.log' --ignore 'a b/'",
"--exclude '!build/' --exclude '*.log' --exclude 'a b/'",
)
expect(formatAppSecurityCommand(commands.scan, 'cmd')).toContain(
'--ignore "!build/" --ignore "*.log" --ignore "a b/"',
'--exclude "!build/" --exclude "*.log" --exclude "a b/"',
)
})

test('quotes an --ignore pattern that starts with `-` or repeats a command word', () => {
const ignorePatterns = ['-*.log', '-tmp/', 'check']
const commands = resolveAppSecurityCommands('/tmp/app', undefined, ignorePatterns)
test('quotes an --exclude glob that starts with `-` or repeats a command word', () => {
const excludePatterns = ['-*.log', '-tmp/', 'check']
const commands = resolveAppSecurityCommands('/tmp/app', undefined, excludePatterns)

for (const shell of ['posix', 'cmd', 'powershell'] as const) {
const formatted = formatAppSecurityCommand(commands.scan, shell)
Expand All @@ -276,33 +282,33 @@ describe('formatAppSecurityCommand', () => {
'check',
'--path',
'/tmp/app',
'--ignore',
'--exclude',
'-*.log',
'--ignore',
'--exclude',
'-tmp/',
'--ignore',
'--exclude',
'check',
])
expect(formatted).not.toMatch(/ -\*\.log/)
expect(formatted).not.toMatch(/ -tmp\//)
expect(formatted).not.toMatch(/--ignore check/)
expect(formatted).not.toMatch(/--exclude check/)
}
expect(formatAppSecurityCommand(commands.scan, 'posix')).toContain(
"--ignore '-*.log' --ignore '-tmp/' --ignore 'check'",
"--exclude '-*.log' --exclude '-tmp/' --exclude 'check'",
)
expect(formatAppSecurityCommand(commands.scan, 'powershell')).toContain(
"--ignore '-*.log' --ignore '-tmp/' --ignore 'check'",
"--exclude '-*.log' --exclude '-tmp/' --exclude 'check'",
)
expect(formatAppSecurityCommand(commands.scan, 'cmd')).toContain(
'--ignore "-*.log" --ignore "-tmp/" --ignore "check"',
'--exclude "-*.log" --exclude "-tmp/" --exclude "check"',
)
})

test('leaves the command words and every flag name bare and quotes every flag value', () => {
const commands = resolveAppSecurityCommands('/tmp/app', 'shopify.app.staging.toml', ['generated/'])
const commands = resolveAppSecurityCommands('/tmp/app', 'shopify.app.staging.toml', ['generated'], true)

expect(formatAppSecurityCommand(commands.scan, 'posix')).toBe(
"shopify app security check --path '/tmp/app' --config 'staging' --ignore 'generated/'",
"shopify app security check --path '/tmp/app' --config 'staging' --exclude 'generated' --no-git-ignore",
)
expect(formatAppSecurityCommand(commands.clean, 'posix')).toBe("shopify app security clean --path '/tmp/app'")
})
Expand Down
Loading
Loading