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
51 changes: 51 additions & 0 deletions packages/app/src/cli/commands/app/security/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import securityCheck from '../../../services/security-check.js'
import AppLinkedCommand from '../../../utilities/app-linked-command.js'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {resolvePath} from '@shopify/cli-kit/node/path'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../../services/security-check.js')
Expand Down Expand Up @@ -34,9 +35,45 @@ describe('app security check command', () => {
skipInstructions: true,
findingsPath: undefined,
clean: false,
ignorePatterns: [],
})
})

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

expect(securityCheck).toHaveBeenCalledWith(
expect.objectContaining({ignorePatterns: ['generated/', '!build/', '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(() => {})

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()
}
})

test('forwards --yes without requiring an app configuration', async () => {
await SecurityCheck.run(['--path', '/tmp/directory-without-shopify-toml', '--yes'], import.meta.url)

Expand All @@ -50,6 +87,7 @@ describe('app security check command', () => {
skipInstructions: false,
findingsPath: undefined,
clean: false,
ignorePatterns: [],
})
})

Expand Down Expand Up @@ -108,6 +146,19 @@ describe('app security check command', () => {
expect(SecurityCheck.descriptionWithMarkdown).toContain('Pass `--clean` to discard that work and start over')
})

test('documents --ignore as ordered .gitignore patterns that follow-up commands 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.',
)
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('`--findings`')
})

test('allows --yes in JSON mode while preserving non-interactive output behavior', async () => {
await SecurityCheck.run(['--json', '--yes'], import.meta.url)

Expand Down
17 changes: 17 additions & 0 deletions packages/app/src/cli/commands/app/security/check.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {appFlags} from '../../../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'
import {resolvePath} from '@shopify/cli-kit/node/path'
import type {AppSecurityBlockingLevel} from '../../../services/app-security-api.js'

Expand All @@ -17,6 +19,8 @@ export default class SecurityCheck extends BaseCommand {

Pass \`--findings\` after completing the review pack to validate agent findings and compile them into the trace. A new scan stops when local agent findings or a compiled trace already exist. Pass \`--clean\` to discard that work and start over. Use \`--config\` to select a specific app configuration when the project has multiple \`shopify.app*.toml\` files; App Security inspects only that configuration.

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 generated follow-up commands repeat the patterns, and \`--findings\` must use the same patterns as the scan it validates.

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.`

static description = this.descriptionWithoutMarkdown()
Expand All @@ -25,6 +29,18 @@ In interactive terminals, the command offers to copy the coding-agent instructio
...globalFlags,
path: appFlags.path,
config: appFlags.config,
// 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({
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.',
multiple: true,
parse: async (input) => {
const problem = ignorePatternProblem(input)
if (problem) throw new AbortError(problem)
return input
},
}),
...jsonFlag,
findings: Flags.string({
description: 'Validate agent findings from a JSON file and compile them into the trace.',
Expand Down Expand Up @@ -73,6 +89,7 @@ In interactive terminals, the command offers to copy the coding-agent instructio
skipInstructions: flags['skip-instructions'],
findingsPath: flags.findings,
clean: flags.clean,
ignorePatterns: flags.ignore ?? [],
})
}
}
38 changes: 38 additions & 0 deletions packages/app/src/cli/services/app-security-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,43 @@ describe('App Security CLI integration', () => {
})
})

test('compiles findings when the compile repeats the scan --ignore patterns', async () => {
await inTemporaryDirectory(async (directory) => {
await createApp(directory)
await mkdir(joinPath(directory, 'generated'))
await writeFile(joinPath(directory, 'generated', 'client.ts'), 'export const generated = true\n')
const appRoot = resolveAppSecurityRoot(directory)
const ignorePatterns = ['generated/']

const initial = await executeAppSecurity({appRoot, ignorePatterns})
expect(Object.keys(initial.scan.scan.file_hashes ?? {})).not.toContain('generated/client.ts')
const findings = {schema_version: 1 as const, source_scan_id: initial.scan.scan.input_hash, findings: []}

const compiled = await executeAppSecurity({appRoot, findings, ignorePatterns})
expect(compiled.operation).toBe('compile')
expect(compiled.operation === 'compile' && compiled.findings.rejected).toEqual([])
})
})

test('rejects findings when the compile uses different --ignore patterns than the scan', async () => {
await inTemporaryDirectory(async (directory) => {
await createApp(directory)
await mkdir(joinPath(directory, 'generated'))
await writeFile(joinPath(directory, 'generated', 'client.ts'), 'export const generated = true\n')
const appRoot = resolveAppSecurityRoot(directory)

const initial = await executeAppSecurity({appRoot, ignorePatterns: ['generated/']})
const findings = {schema_version: 1 as const, source_scan_id: initial.scan.scan.input_hash, findings: []}

const compiled = await executeAppSecurity({appRoot, findings})
expect(compiled.operation === 'compile' && compiled.findings.rejected).toEqual([
expect.stringMatching(
/^Findings source scan \S+ does not match the current scan \S+; compile with the same --ignore and --config values used for the scan, since ignore patterns are not recorded in the trace\.$/,
),
])
})
})

test('does not apply a stale suppression whose fingerprint still matches a current finding', async () => {
await inTemporaryDirectory(async (directory) => {
const testToken = ['shpat', '0123456789abcdef0123456789abcdef'].join('_')
Expand Down Expand Up @@ -613,6 +650,7 @@ describe('App Security CLI integration', () => {
yes: false,
skipInstructions: true,
clean: false,
ignorePatterns: [],
},
{
resolveRoot: resolveAppSecurityRoot,
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/cli/services/app-security-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ export async function executeAppSecurity(options: {
appRoot: string
findings?: FindingsDocument
configFileName?: string
ignorePatterns?: ReadonlyArray<string>
}): Promise<AppSecurityExecution> {
const startTime = Date.now()
const scanOptions = {ignorePatterns: options.ignorePatterns}
const result = options.findings
? await compileFindings(options.appRoot, options.findings, options.configFileName)
: await scanApp(options.appRoot, options.configFileName)
? await compileFindings(options.appRoot, options.findings, options.configFileName, scanOptions)
: await scanApp(options.appRoot, options.configFileName, scanOptions)
return {
...result,
elapsedMilliseconds: Date.now() - startTime,
Expand Down
135 changes: 125 additions & 10 deletions packages/app/src/cli/services/app-security-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,36 +138,151 @@ describe('quoteShellArgument', () => {

describe('resolveAppSecurityCommands', () => {
test('omits --config for the default shopify.app.toml', () => {
expect(resolveAppSecurityCommands('/tmp/app').scan.args).toEqual(['app', 'security', 'check', '--path', '/tmp/app'])
expect(resolveAppSecurityCommands('/tmp/app').scan.args).toEqual([
'app',
'security',
'check',
{flag: '--path', value: '/tmp/app'},
])
expect(resolveAppSecurityCommands('/tmp/app', 'shopify.app.toml').scan.args).toEqual([
'app',
'security',
'check',
'--path',
'/tmp/app',
{flag: '--path', value: '/tmp/app'},
])
})

test('includes --config on scan and compile for a named configuration', () => {
const commands = resolveAppSecurityCommands('/tmp/app', 'shopify.app.staging.toml')
const findingsPath = joinPath('/tmp/app', '.shopify', 'app-security', 'findings.json')

expect(commands.scan.args).toEqual(['app', 'security', 'check', '--path', '/tmp/app', '--config', 'staging'])
expect(commands.scan.args).toEqual([
'app',
'security',
'check',
{flag: '--path', value: '/tmp/app'},
{flag: '--config', value: 'staging'},
])
expect(commands.compile.args).toEqual([
'app',
'security',
'check',
'--path',
'/tmp/app',
'--config',
'staging',
'--findings',
findingsPath,
{flag: '--path', value: '/tmp/app'},
{flag: '--config', value: 'staging'},
{flag: '--findings', value: findingsPath},
])
})

test('repeats --ignore patterns in order, after --config, on scan, compile, and clean', () => {
const commands = resolveAppSecurityCommands('/tmp/app', 'shopify.app.staging.toml', ['generated/', '!build/'])
const findingsPath = joinPath('/tmp/app', '.shopify', 'app-security', 'findings.json')
const scanArgs = [
'app',
'security',
'check',
{flag: '--path', value: '/tmp/app'},
{flag: '--config', value: 'staging'},
{flag: '--ignore', value: 'generated/'},
{flag: '--ignore', value: '!build/'},
]

expect(commands.scan.args).toEqual(scanArgs)
expect(commands.compile.args).toEqual([...scanArgs, {flag: '--findings', value: findingsPath}])
expect(commands.clean.args).toEqual([...scanArgs, '--clean'])
})

test('omits --ignore when there are no patterns', () => {
expect(resolveAppSecurityCommands('/tmp/app', undefined, []).scan.args).toEqual([
'app',
'security',
'check',
{flag: '--path', value: '/tmp/app'},
])
})
})

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)

for (const shell of ['posix', 'cmd', 'powershell'] as const) {
const formatted = formatAppSecurityCommand(commands.scan, shell)
expect(splitQuotedCommand(formatted, shell)).toEqual([
'shopify',
'app',
'security',
'check',
'--path',
'/tmp/app',
'--ignore',
'!build/',
'--ignore',
'*.log',
'--ignore',
'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/'",
)
expect(formatAppSecurityCommand(commands.scan, 'powershell')).toContain(
"--ignore '!build/' --ignore '*.log' --ignore 'a b/'",
)
expect(formatAppSecurityCommand(commands.scan, 'cmd')).toContain(
'--ignore "!build/" --ignore "*.log" --ignore "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)

for (const shell of ['posix', 'cmd', 'powershell'] as const) {
const formatted = formatAppSecurityCommand(commands.scan, shell)
expect(splitQuotedCommand(formatted, shell)).toEqual([
'shopify',
'app',
'security',
'check',
'--path',
'/tmp/app',
'--ignore',
'-*.log',
'--ignore',
'-tmp/',
'--ignore',
'check',
])
expect(formatted).not.toMatch(/ -\*\.log/)
expect(formatted).not.toMatch(/ -tmp\//)
expect(formatted).not.toMatch(/--ignore check/)
}
expect(formatAppSecurityCommand(commands.scan, 'posix')).toContain(
"--ignore '-*.log' --ignore '-tmp/' --ignore 'check'",
)
expect(formatAppSecurityCommand(commands.scan, 'powershell')).toContain(
"--ignore '-*.log' --ignore '-tmp/' --ignore 'check'",
)
expect(formatAppSecurityCommand(commands.scan, 'cmd')).toContain(
'--ignore "-*.log" --ignore "-tmp/" --ignore "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 findingsPath = joinPath('/tmp/app', '.shopify', 'app-security', 'findings.json')

expect(formatAppSecurityCommand(commands.compile, 'posix')).toBe(
`shopify app security check --path '/tmp/app' --config 'staging' --ignore 'generated/' --findings '${findingsPath}'`,
)
expect(formatAppSecurityCommand(commands.clean, 'posix')).toBe(
"shopify app security check --path '/tmp/app' --config 'staging' --ignore 'generated/' --clean",
)
})

test('quotes a Windows path with spaces and percents for terminal and instruction shells', () => {
const commands = resolveAppSecurityCommands(WINDOWS_APP_ROOT)
const findingsPath = joinPath(WINDOWS_APP_ROOT, '.shopify', 'app-security', 'findings.json')
Expand Down
Loading
Loading