From 9a5164b88c7b5d48623c8ca60d90da3d357b6531 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Mon, 28 Sep 2026 19:05:59 -0700 Subject: [PATCH 1/7] Add --ignore to app security check and instructions Each --ignore value is one .gitignore line, relative to the app directory. Patterns take precedence over the default and gitignore exclusions: they can ignore more files or, with a leading !, include ignored ones again. Later patterns take precedence over earlier ones. A file can't be included again while its parent folder is ignored, and a folder git ignores as a whole can only be included again as a folder. Patterns apply to dependency automation configuration too. When any pattern includes a path again, git's listing also covers the default directories, so .gitignore still applies inside a default folder that a pattern includes again. The generated compile and clean commands repeat the patterns, and app security instructions accepts them too, so compiling findings re-scans the same files. A findings file compiled with different patterns is rejected with a hint to reuse the scan's patterns. --ignore has no environment variable. oclif reads a repeatable flag's variable only when the command line has no value for that flag, and passes it as a single string, so it could hold one pattern and any --ignore on the command line would silently replace it. Flag values in the generated commands are always quoted, so a pattern such as -*.log or check isn't mistaken for a flag or a command word. Values that would silently do nothing or crash the scan are rejected: blank values, # comments, a lone !, an odd number of trailing backslashes, values that span more than one line, patterns with a .. path segment, which can never match because scanned paths are relative to the app directory, and any pattern the matcher can't compile, such as an unclosed [ followed by /. --- .changeset/app-security-path-filter.md | 5 + .../cli/commands/app/security/check.test.ts | 51 +++ .../src/cli/commands/app/security/check.ts | 5 + .../src/cli/commands/app/security/flags.ts | 23 ++ .../app/security/instructions.test.ts | 34 ++ .../cli/commands/app/security/instructions.ts | 5 +- .../src/cli/services/app-security-api.test.ts | 38 ++ .../app/src/cli/services/app-security-api.ts | 7 +- .../services/app-security-commands.test.ts | 139 ++++++- .../src/cli/services/app-security-commands.ts | 49 ++- .../cli/services/app-security-engine/index.ts | 5 +- .../cli/services/app-security-engine/run.ts | 24 +- .../app-security-engine/scanners/discover.ts | 83 +++- .../app-security-engine/scanners/index.ts | 17 +- .../scanners/path-rules.ts | 365 ++++++++++++++++-- .../dependency-automation-discovery.test.ts | 3 +- .../tests/dependency-automation.test.ts | 57 ++- .../tests/discovery-safety.test.ts | 112 +++++- .../tests/path-rules.test.ts | 323 ++++++++++++++-- .../cli/services/app-security-engine/types.ts | 10 + .../app-security-instructions.test.ts | 29 ++ .../cli/services/app-security-instructions.ts | 14 +- .../src/cli/services/security-check.test.ts | 42 ++ .../app/src/cli/services/security-check.ts | 19 +- packages/cli/oclif.manifest.json | 22 +- 25 files changed, 1357 insertions(+), 124 deletions(-) create mode 100644 .changeset/app-security-path-filter.md create mode 100644 packages/app/src/cli/commands/app/security/flags.ts diff --git a/.changeset/app-security-path-filter.md b/.changeset/app-security-path-filter.md new file mode 100644 index 00000000000..ef486396253 --- /dev/null +++ b/.changeset/app-security-path-filter.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Add `--ignore` to `app security check` and `app security instructions` to ignore files, or include them again, with .gitignore patterns. diff --git a/packages/app/src/cli/commands/app/security/check.test.ts b/packages/app/src/cli/commands/app/security/check.test.ts index bb62c6f8bdd..822c3acdc9d 100644 --- a/packages/app/src/cli/commands/app/security/check.test.ts +++ b/packages/app/src/cli/commands/app/security/check.test.ts @@ -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') @@ -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) @@ -50,6 +87,7 @@ describe('app security check command', () => { skipInstructions: false, findingsPath: undefined, clean: false, + ignorePatterns: [], }) }) @@ -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) diff --git a/packages/app/src/cli/commands/app/security/check.ts b/packages/app/src/cli/commands/app/security/check.ts index 5bcfaf0313c..84e165e653e 100644 --- a/packages/app/src/cli/commands/app/security/check.ts +++ b/packages/app/src/cli/commands/app/security/check.ts @@ -1,3 +1,4 @@ +import {appSecurityFlags} from './flags.js' import {appFlags} from '../../../flags.js' import securityCheck from '../../../services/security-check.js' import {Flags} from '@oclif/core' @@ -17,6 +18,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() @@ -25,6 +28,7 @@ In interactive terminals, the command offers to copy the coding-agent instructio ...globalFlags, path: appFlags.path, config: appFlags.config, + ...appSecurityFlags, ...jsonFlag, findings: Flags.string({ description: 'Validate agent findings from a JSON file and compile them into the trace.', @@ -73,6 +77,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 ?? [], }) } } diff --git a/packages/app/src/cli/commands/app/security/flags.ts b/packages/app/src/cli/commands/app/security/flags.ts new file mode 100644 index 00000000000..3d50440849d --- /dev/null +++ b/packages/app/src/cli/commands/app/security/flags.ts @@ -0,0 +1,23 @@ +import {ignorePatternProblem} from '../../../services/app-security-engine/index.js' +import {Flags} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' + +/** + * Flags shared by `app security check` and `app security instructions`, so + * the commands that `instructions` generates accept exactly what it was given. + */ +export const appSecurityFlags = { + // Deliberately not bound to an environment variable: oclif reads a repeatable flag's environment variable only + // when the command line has no value for it and passes it as one string. It could carry only one pattern, and any + // --ignore on the command line would silently replace it. + 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 + }, + }), +} diff --git a/packages/app/src/cli/commands/app/security/instructions.test.ts b/packages/app/src/cli/commands/app/security/instructions.test.ts index 26073fd54df..d0201bc07b2 100644 --- a/packages/app/src/cli/commands/app/security/instructions.test.ts +++ b/packages/app/src/cli/commands/app/security/instructions.test.ts @@ -1,9 +1,11 @@ import SecurityInstructions from './instructions.js' +import SecurityCheck from './check.js' import {appFlags} from '../../../flags.js' import deliverAppSecurityInstructions from '../../../services/app-security-instructions.js' import AppLinkedCommand from '../../../utilities/app-linked-command.js' import BaseCommand from '@shopify/cli-kit/node/base-command' import {cwd, 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/app-security-instructions.js') @@ -26,9 +28,39 @@ describe('app security instructions command', () => { configName: undefined, copy: false, writePath: undefined, + ignorePatterns: [], }) }) + test('forwards repeated --ignore patterns in command-line order', async () => { + await SecurityInstructions.run(['--ignore', 'generated/', '--ignore', '!build/'], import.meta.url) + + expect(deliverAppSecurityInstructions).toHaveBeenCalledWith( + expect.objectContaining({ignorePatterns: ['generated/', '!build/']}), + ) + }) + + test('rejects an unusable --ignore pattern', async () => { + const outputMock = mockAndCaptureOutput() + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await expect(SecurityInstructions.run(['--ignore', '#generated/'], import.meta.url)).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ) + expect(outputMock.error()).toContain('comment') + expect(deliverAppSecurityInstructions).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + outputMock.clear() + } + }) + + test('shares the --ignore flag definition with app security check', () => { + expect(SecurityInstructions.flags.ignore).toBe(SecurityCheck.flags.ignore) + expect(SecurityInstructions.descriptionWithMarkdown).toContain('`--ignore`') + }) + test('forwards --path and --copy', async () => { await SecurityInstructions.run(['--path', './fixtures/unlinked-app', '--copy'], import.meta.url) @@ -37,6 +69,7 @@ describe('app security instructions command', () => { configName: undefined, copy: true, writePath: undefined, + ignorePatterns: [], }) }) @@ -48,6 +81,7 @@ describe('app security instructions command', () => { configName: undefined, copy: false, writePath: resolvePath('./instructions.md'), + ignorePatterns: [], }) }) diff --git a/packages/app/src/cli/commands/app/security/instructions.ts b/packages/app/src/cli/commands/app/security/instructions.ts index 02f63b7423e..d8664b35ff5 100644 --- a/packages/app/src/cli/commands/app/security/instructions.ts +++ b/packages/app/src/cli/commands/app/security/instructions.ts @@ -1,3 +1,4 @@ +import {appSecurityFlags} from './flags.js' import {appFlags} from '../../../flags.js' import deliverAppSecurityInstructions from '../../../services/app-security-instructions.js' import {Flags} from '@oclif/core' @@ -12,7 +13,7 @@ export default class SecurityInstructions extends BaseCommand { static descriptionWithMarkdown = `Prints the complete workflow that a coding agent should follow to review App Security results. -By default, the instructions are printed to stdout. Use \`--copy\` to copy them to the clipboard or \`--write\` to write them to a file. Standalone instructions always start by running \`shopify app security check\`; only that invocation's generated review pack is trusted as workflow input.` +By default, the instructions are printed to stdout. Use \`--copy\` to copy them to the clipboard or \`--write\` to write them to a file. Standalone instructions always start by running \`shopify app security check\`; only that invocation's generated review pack is trusted as workflow input. \`--config\` values and \`--ignore\` patterns are included in the generated commands.` static description = this.descriptionWithoutMarkdown() @@ -20,6 +21,7 @@ By default, the instructions are printed to stdout. Use \`--copy\` to copy them ...globalFlags, path: appFlags.path, config: appFlags.config, + ...appSecurityFlags, copy: Flags.boolean({ description: 'Copy the instructions to the clipboard instead of printing them.', default: false, @@ -42,6 +44,7 @@ By default, the instructions are printed to stdout. Use \`--copy\` to copy them configName: flags.config, copy: flags.copy, writePath: flags.write, + ignorePatterns: flags.ignore ?? [], }) } } diff --git a/packages/app/src/cli/services/app-security-api.test.ts b/packages/app/src/cli/services/app-security-api.test.ts index 3f1f62bc11c..c112753e30d 100644 --- a/packages/app/src/cli/services/app-security-api.test.ts +++ b/packages/app/src/cli/services/app-security-api.test.ts @@ -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('_') @@ -613,6 +650,7 @@ describe('App Security CLI integration', () => { yes: false, skipInstructions: true, clean: false, + ignorePatterns: [], }, { resolveRoot: resolveAppSecurityRoot, diff --git a/packages/app/src/cli/services/app-security-api.ts b/packages/app/src/cli/services/app-security-api.ts index 0878cdfd3b8..9cce67bf62c 100644 --- a/packages/app/src/cli/services/app-security-api.ts +++ b/packages/app/src/cli/services/app-security-api.ts @@ -91,11 +91,14 @@ export async function executeAppSecurity(options: { appRoot: string findings?: FindingsDocument configFileName?: string + /** `--ignore` patterns; a compile must repeat the ones its scan used or the findings are rejected. */ + ignorePatterns?: ReadonlyArray }): Promise { 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, diff --git a/packages/app/src/cli/services/app-security-commands.test.ts b/packages/app/src/cli/services/app-security-commands.test.ts index 0b82f009c31..567d9eeeac7 100644 --- a/packages/app/src/cli/services/app-security-commands.test.ts +++ b/packages/app/src/cli/services/app-security-commands.test.ts @@ -138,13 +138,17 @@ 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'}, ]) }) @@ -152,22 +156,137 @@ describe('resolveAppSecurityCommands', () => { 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/', + ]) + // Every pattern is wrapped in quotes; bare `!` or `*` would be expanded by the shell. + 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', () => { + // `-*.log` and `-tmp/` are legitimate .gitignore lines; left bare, a shell could glob-expand them + // and a parser could read them as flags. A pattern literally named `check` must not blend into + // the command words either. A flag value is quoted whatever it looks like. + 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') diff --git a/packages/app/src/cli/services/app-security-commands.ts b/packages/app/src/cli/services/app-security-commands.ts index f9cbf0a7332..7ab8497590a 100644 --- a/packages/app/src/cli/services/app-security-commands.ts +++ b/packages/app/src/cli/services/app-security-commands.ts @@ -3,9 +3,16 @@ import {getAppConfigurationShorthand} from '../models/app/config-file-naming.js' export type AppSecurityShell = 'posix' | 'cmd' | 'powershell' +/** + * One command-line argument. A string is command syntax (`app`, `--clean`) and is + * printed bare. A flag with a value is printed with the value quoted, because a + * value is user input: an app path, a configuration name, or an --ignore pattern. + */ +export type AppSecurityArgument = string | {flag: string; value: string} + export interface AppSecurityCommand { command: string - args: string[] + args: AppSecurityArgument[] } export interface AppSecurityCommands { @@ -14,19 +21,35 @@ export interface AppSecurityCommands { clean: AppSecurityCommand } -export function resolveAppSecurityCommands(appRoot: string, configFileName?: string): AppSecurityCommands { +/** + * Build the scan, compile, and clean commands shown to users and coding agents. + * `ignorePatterns` are repeated on every command, in order, because a compile + * must discover the same files as the scan whose findings it validates. + */ +export function resolveAppSecurityCommands( + appRoot: string, + configFileName?: string, + ignorePatterns: ReadonlyArray = [], +): AppSecurityCommands { const {findingsPath} = appSecurityArtifactPaths(appRoot) const configFlag = configFileName ? getAppConfigurationShorthand(configFileName) : undefined const scan: AppSecurityCommand = { command: 'shopify', - args: ['app', 'security', 'check', '--path', appRoot, ...(configFlag ? ['--config', configFlag] : [])], + args: [ + 'app', + 'security', + 'check', + {flag: '--path', value: appRoot}, + ...(configFlag ? [{flag: '--config', value: configFlag}] : []), + ...ignorePatterns.map((ignorePattern) => ({flag: '--ignore', value: ignorePattern})), + ], } return { scan, compile: { command: scan.command, - args: [...scan.args, '--findings', findingsPath], + args: [...scan.args, {flag: '--findings', value: findingsPath}], }, clean: { command: scan.command, @@ -79,15 +102,19 @@ function quoteCmdSegment(part: string): string { return `"${escapedQuotes}${trailingBackslashes}"` } +/** + * Render a command for a shell. Quoting follows the argument's type, not what + * it looks like: every flag value is quoted and all command syntax stays bare. + * An --ignore pattern such as `-*.log`, `-tmp/` or `check` would otherwise be + * left bare, where a shell could glob-expand it or a reader could mistake it + * for a flag or command word. + */ export function formatAppSecurityCommand( action: AppSecurityCommand, shell: AppSecurityShell = shellForPlatform(), ): string { - return [action.command, ...action.args] - .map((argument, index) => { - const isCommandSyntax = - index === 0 || argument === 'app' || argument === 'security' || argument === 'check' || argument.startsWith('-') - return isCommandSyntax ? argument : quoteShellArgument(argument, shell) - }) - .join(' ') + const words = action.args.map((argument) => + typeof argument === 'string' ? argument : `${argument.flag} ${quoteShellArgument(argument.value, shell)}`, + ) + return [action.command, ...words].join(' ') } diff --git a/packages/app/src/cli/services/app-security-engine/index.ts b/packages/app/src/cli/services/app-security-engine/index.ts index 9c4a4ca9a97..3bc7ed3295b 100644 --- a/packages/app/src/cli/services/app-security-engine/index.ts +++ b/packages/app/src/cli/services/app-security-engine/index.ts @@ -3,8 +3,8 @@ * * CLI code outside this directory should import only these operations and result * types: locate an app, scan, parse/compile findings, parse a stored trace, - * build a submission, and group issues for display. Keep scanners, registries, merge helpers, and redaction - * inside the engine. + * build a submission, group issues for display, and validate `--ignore` + * patterns. Keep scanners, registries, merge helpers, and redaction inside the engine. */ export { AppRootDiscoveryError, @@ -24,6 +24,7 @@ export type { FindingsDocument, ParseTraceResult, } from './run.js' +export {ignorePatternProblem} from './scanners/path-rules.js' export {hasRecordedAgentReview} from './trace/index.js' export {buildSubmission, SUBMISSION_SCHEMA_VERSION} from './submission/index.js' export type {AppSecuritySubmission, AppSecuritySubmissionReport, BuildSubmissionOptions} from './submission/index.js' 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 7089c57b66b..4b5182babd6 100644 --- a/packages/app/src/cli/services/app-security-engine/run.ts +++ b/packages/app/src/cli/services/app-security-engine/run.ts @@ -15,7 +15,7 @@ import {computeResultHash} from './scorer/index.js' import {compileTrace, validateTrace} from './trace/index.js' import {FINDINGS_SCHEMA_VERSION} from './types.js' import {getEngineVersion} from './version.js' -import type {CheckExecution, ScanResult, Suppression, TraceV3} from './types.js' +import type {CheckExecution, ScanOptions, ScanResult, Suppression, TraceV3} from './types.js' export {AppRootDiscoveryError, findAppRoot} @@ -109,9 +109,13 @@ export function parseFindings(value: unknown): FindingsDocument { return value as FindingsDocument } -export async function scanApp(directory?: string, configFileName?: string): Promise { +export async function scanApp( + directory?: string, + configFileName?: string, + options?: ScanOptions, +): Promise { const appRoot = findAppRoot(directory) - const result = await scan(appRoot, configFileName) + const result = await scan(appRoot, configFileName, options) const engineVersion = getEngineVersion() const reviewPack = buildReviewPack(engineVersion, result) const trace = compileTrace(result, {engineVersion, agentChecksExecuted: [], suppressions: []}) @@ -125,19 +129,29 @@ export async function scanApp(directory?: string, configFileName?: string): Prom } } +/** + * Compile agent findings against a fresh scan. The findings' `source_scan_id` + * must equal the fresh scan's `input_hash`, so `options` (`--ignore` patterns) + * must repeat whatever the initial scan used. + */ export async function compileFindings( directory: string, document: FindingsDocument, configFileName?: string, + options?: ScanOptions, ): Promise { const appRoot = findAppRoot(directory) - const result = await scan(appRoot, configFileName) + const result = await scan(appRoot, configFileName, options) const engineVersion = getEngineVersion() const knownFiles = new Set(searchBoundaryFiles(result)) + // Ignore patterns change the input hash but are not recorded in the trace, so a mismatch cannot + // tell a changed file apart from a compile that forgot the scan's flags; the hint covers both. const provenanceRejected = document.source_scan_id === result.scan.input_hash ? [] - : [`Findings source scan ${document.source_scan_id} does not match the current scan ${result.scan.input_hash}.`] + : [ + `Findings source scan ${document.source_scan_id} does not match the current scan ${result.scan.input_hash}; compile with the same --ignore and --config values used for the scan, since ignore patterns are not recorded in the trace.`, + ] const executed = provenanceRejected.length > 0 ? {executions: [] as CheckExecution[], rejected: provenanceRejected, warnings: [] as string[]} 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 6d7c41af229..c72ef8e4329 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 @@ -231,7 +231,17 @@ function recordSectionGap(appRoot: string | undefined, path: string, detail: str recordSkippedFile(appRoot, path, {ok: false, reason: 'unreadable', detail}) } -/** Uses raw entries, so a nested app whose configuration file is gitignored is still a nested app. */ +/** + * A directory holding its own `shopify.app*.toml` is an independent Shopify app. + * Nested apps are independent scan roots and never evidence for their parent + * app, so the walker stops at them: a structural boundary, much as git treats + * a nested repository as opaque to the enclosing one. (Nested git repositories + * themselves are NOT a boundary here; only their `.git` entry is pruned.) + * + * Detection looks at the directory's raw entries, not the rule-filtered ones: + * a nested app whose configuration file happens to be gitignored is still a + * nested app. + */ function isNestedAppDirectory(entries: ReadonlyArray): boolean { return entries.some((entry) => !entry.isDirectory() && isValidFormatAppConfigurationFileName(entry.name)) } @@ -252,13 +262,25 @@ function readDirectoryEntries(appRoot: string, absolutePath: string, displayPath } /** - * Symlinks and special entries are listed but never traversed; - * `readRepositoryFile` enforces containment on anything a finder reads. + * List every repository file the scan may inspect, as sorted app-root-relative + * POSIX paths. + * + * The walk applies `rules` with .gitignore semantics and prunes excluded + * directories: it never descends into them, so the matcher is only ever asked + * about paths whose ancestors are known to be included (its precondition). + * + * Directory entries use lstat semantics, so a symlink is never a directory + * here. Symlinks (to files or directories), FIFOs and other special entries + * are listed but never traversed; `readRepositoryFile` later enforces + * containment and realpath rules on anything a finder decides to read, and + * records failures. Dot-folders and dotfiles are walked unless a rule excludes + * them (see `DEFAULT_EXCLUDE_PATTERNS`). */ export function listRepositoryFiles(appRoot: string, rules: PathRules): string[] { const matcher = createPathMatcher(rules) const files: string[] = [] - // Appended to while iterating; '' is the app root. + // Relative directory paths still to be read; '' is the app root itself. Subdirectories are appended + // while iterating, which `for...of` supports: the array iterator re-checks the length on every step. const pendingDirectories = [''] for (const relativeDirectory of pendingDirectories) { @@ -269,11 +291,14 @@ export function listRepositoryFiles(appRoot: string, rules: PathRules): string[] relativeDirectory === '' ? 'app root' : relativeDirectory, ) if (entries === undefined) continue + // A nested app is a scan root in its own right; nothing beneath it belongs to this scan. if (relativeDirectory !== '' && isNestedAppDirectory(entries)) continue for (const entry of entries) { const relative = relativeDirectory === '' ? entry.name : `${relativeDirectory}/${entry.name}` if (entry.isDirectory()) { + // Never descend into an excluded directory: pruning here is what keeps the matcher's + // precondition (every ancestor of a queried path is included) true. if (!matcher(relative, {directory: true})) pendingDirectories.push(relative) } else if (!matcher(relative, {directory: false})) { files.push(relative) @@ -284,7 +309,13 @@ export function listRepositoryFiles(appRoot: string, rules: PathRules): string[] return files.sort() } -/** A path inside nested extension directories belongs to each of them. */ +/** + * Group source paths by the extension directories that contain them, walking + * each path's ancestors once instead of filtering the whole repository per + * extension. `extensionDirectories` are app-root-relative; `.` is the app root + * and contains every path. A path inside nested extension directories belongs + * to each of them. Paths keep their input order within every group. + */ function groupSourcePathsByExtensionDirectory( repositoryFiles: ReadonlyArray, extensionDirectories: ReadonlySet, @@ -295,6 +326,7 @@ function groupSourcePathsByExtensionDirectory( for (const path of repositoryFiles) { if (!hasSupportedSourceExtension(path)) continue + // `dirname` yields `.` for a top-level file and for `.` itself, which ends the climb. let ancestor = dirname(path) while (ancestor !== '.') { pathsByDirectory.get(ancestor)?.push(path) @@ -307,13 +339,14 @@ function groupSourcePathsByExtensionDirectory( } /** - * Find extension-like repository content under the app root. + * Find extension-like repository content among the walked repository files. * * `Project.load()` only considers paths in each app configuration's * `extension_directories`. App Security 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. - * Nested apps, generated output, and test trees remain excluded. + * Nested apps, generated output, and test trees are already absent from + * `repositoryFiles` (see `listRepositoryFiles`). */ export function findExtensions(appRoot: string, repositoryFiles: ReadonlyArray): ExtensionInfo[] { const extensionTomls = repositoryFiles.filter((path) => basename(path) === 'shopify.extension.toml') @@ -587,6 +620,7 @@ const SOURCE_LANGUAGES = { type SourceExtension = keyof typeof SOURCE_LANGUAGES +/** Extension matching is exact and case-sensitive: `.JS` is not treated as source. */ function sourceLanguageFor(path: string): (typeof SOURCE_LANGUAGES)[SourceExtension] | undefined { const extension = extname(path) return extension in SOURCE_LANGUAGES ? SOURCE_LANGUAGES[extension as SourceExtension] : undefined @@ -607,8 +641,13 @@ export function findSourceCandidates(repositoryFiles: ReadonlyArray): So .sort((left, right) => left.path.localeCompare(right.path)) } -/** Find and read only source languages supported by non-secret deterministic scanners. */ +/** + * Read the source files (backend routes, extension code, etc.) whose language + * is supported by the non-secret deterministic scanners. Results keep the order + * of `repositoryFiles`, which callers pass in `listRepositoryFiles` order. + */ export function findAppSourceFiles(appRoot: string, repositoryFiles: ReadonlyArray): SourceFile[] { + // `findExtensions` passes pre-filtered groups, but the filter is this function's own contract for every caller. return repositoryFiles.filter(hasSupportedSourceExtension).map((path) => { const absolutePath = joinPath(appRoot, path) const result = readRepositoryFile(appRoot, absolutePath) @@ -649,6 +688,7 @@ const SECRET_TEXT_EXTENSIONS = new Set([ '.pem', ]) +/** Extensionless files that routinely carry credentials. */ const SENSITIVE_FILE_NAMES = new Set(['Dockerfile', 'Containerfile', 'Gemfile', 'Rakefile', 'Procfile', 'Makefile']) function isSensitiveFile(path: string): boolean { @@ -673,7 +713,11 @@ function isProbablyBinary(content: Buffer): boolean { return sample.length > 0 && suspiciousControlBytes / sample.length > 0.1 } -/** Text evidence inspected for secrets regardless of app framework support. */ +/** + * Text evidence inspected for secrets regardless of app framework support. + * Results keep the order of `repositoryFiles`, which callers pass in + * `listRepositoryFiles` order. + */ export function findSensitiveFiles( appRoot: string, repositoryFiles: ReadonlyArray, @@ -698,6 +742,12 @@ export function findSensitiveFiles( }) } +/** + * Why repository-level configuration cannot be attributed to the app, if it + * cannot. The app owns its configuration when its own `.git` marker is the + * nearest one (or there is no repository at all); a marker found only above + * the app root means the configuration belongs to an enclosing repository. + */ function nestedRepositoryReason(appRoot: string): string | undefined { const marker = findRepositoryMarker(appRoot) if (marker.status === 'none') return undefined @@ -711,8 +761,14 @@ function recordRejectedAllowlistPath(appRoot: string, relative: string, failure: /** * Read local bot configuration only; hosted integrations and CI workflows are - * outside this check's scope. Paths are read from disk rather than the walked - * list, so a symlinked `.github` is reported as unresolved rather than missing. + * outside this check's scope. + * + * The allowlisted paths are read directly from disk rather than taken from the + * walked file list, so that a symlinked `.github` (which the walker never + * enters) is reported as unresolved rather than missing. The scan's `rules` + * still apply: hosted bots read the repository, so a configuration file git + * ignores configures nothing and is treated exactly like a missing one. The + * same holds for a path the user excludes with an `--ignore` pattern. */ export function findDependencyAutomationInputs(appRoot: string, rules: PathRules): DependencyAutomationInputs { let canonicalRoot: string @@ -767,7 +823,10 @@ export function findDependencyAutomationInputs(appRoot: string, rules: PathRules return files.length > 0 ? {files} : {files, ...(unresolvedReason ? {unresolvedReason} : {})} } -/** Find JavaScript package manifests. Dependency analysis intentionally supports JavaScript only. */ +/** + * Find JavaScript package manifests. Dependency analysis intentionally supports JavaScript only. + * Results keep the order of `repositoryFiles`, which callers pass in `listRepositoryFiles` order. + */ export function findManifestPaths(repositoryFiles: ReadonlyArray): string[] { return repositoryFiles.filter((path) => basename(path) === 'package.json') } diff --git a/packages/app/src/cli/services/app-security-engine/scanners/index.ts b/packages/app/src/cli/services/app-security-engine/scanners/index.ts index ebfac923e78..ebb3de16679 100644 --- a/packages/app/src/cli/services/app-security-engine/scanners/index.ts +++ b/packages/app/src/cli/services/app-security-engine/scanners/index.ts @@ -12,7 +12,7 @@ import { findDependencyAutomationInputs, listRepositoryFiles, } from './discover.js' -import {buildPathRules, listGitIgnoredPaths} from './path-rules.js' +import {buildPathRules, hasIncludeOverride, ignorePatternRules, listGitIgnoredPaths} from './path-rules.js' import {detectCapabilities, detectProject} from '../capabilities/detect.js' import {computeScanMetadata} from '../scorer/index.js' import {deprecatedScriptTagScope, insecureWebhookUrl} from '../rules/config-rules.js' @@ -47,6 +47,7 @@ import type { CheckExecutionStatus, CoverageGap, Issue, + ScanOptions, ScanResult, SkippedFile, } from '../types.js' @@ -581,15 +582,25 @@ function normalizeRunnerResult(value: Issue[] | RunnerResult): RunnerResult { return Array.isArray(value) ? {issues: value} : value } -export async function scan(startPath?: string, configFileName?: string): Promise { +export async function scan( + startPath?: string, + configFileName?: string, + options: ScanOptions = {}, +): Promise { const appRoot = findAppRoot(startPath) resetSkippedFiles() const selectedFileName = getAppConfigurationFileName(configFileName) const appToml = loadAppToml(joinPath(appRoot, selectedFileName), appRoot) const appTomls = appToml ? [appToml] : [] - const gitIgnoreListing = await listGitIgnoredPaths(appRoot) + // The overrides are parsed once, before asking git: an include override can re-open a default + // directory, and git may only skip the default directories while nothing can re-include one. + const overrides = ignorePatternRules(options.ignorePatterns ?? []) + const gitIgnoreListing = await listGitIgnoredPaths(appRoot, { + pruneDefaultDirectories: !hasIncludeOverride(overrides), + }) const pathRules = buildPathRules({ gitIgnoredPaths: gitIgnoreListing.status === 'listed' ? gitIgnoreListing.paths : [], + overrides, }) const repositoryFiles = listRepositoryFiles(appRoot, pathRules) const extensions = findExtensions(appRoot, repositoryFiles) diff --git a/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts b/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts index 3985485212e..7cdb0d3078e 100644 --- a/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts +++ b/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts @@ -1,26 +1,82 @@ import {findRepositoryMarker} from './repository-marker.js' +import {BugError} from '@shopify/cli-kit/node/error' import {outputDebug} from '@shopify/cli-kit/node/output' import {captureOutputWithExitCode} from '@shopify/cli-kit/node/system' import ignore from 'ignore' +/** + * A user-supplied .gitignore pattern relative to the app directory. Include + * rules store the pattern without the leading `!`. `cli` rules come from the + * `--ignore` flag; a future config-file source will join the same phase, + * placed before the CLI rules so the command line keeps the final word. + */ +export interface PathOverride { + action: 'exclude' | 'include' + pattern: string + source: 'cli' +} + +/** + * The scan's path rules, in three phases. The defaults and the git paths are + * both exclusion-only and independent: either excludes a path. The overrides + * win over both, and within the overrides the last matching rule wins, as in + * .gitignore, so `!pattern` re-includes a default or git-ignored path. + */ export interface PathRules { + /** .gitignore exclude patterns applied to every scan. */ defaults: ReadonlyArray - /** App-root-relative paths git reports as untracked and ignored; directories end with `/`. */ + /** Literal paths relative to the app directory that git reports as untracked and ignored; directories end with `/`. */ gitIgnoredPaths: ReadonlyArray + /** User-supplied rules, in precedence order (later wins). */ + overrides: ReadonlyArray } /** - * Only correct for paths whose ancestor directories are not excluded: a git - * directory entry (`tmp/`) never matches `tmp/a.ts`, so callers must prune. + * Decide whether a path relative to the app directory is excluded from the scan. + * + * `relativePath` must be POSIX-separated, relative to the app directory, with no + * `./` prefix and no trailing slash. Pass `directory: true` for directories so + * directory-only patterns (`build/`) can match them. + * + * PRECONDITION: the answer is only correct for paths whose ancestor directories + * the caller has already confirmed are NOT excluded. A collapsed git directory + * literal (`tmp/`) matches only the directory itself, never `tmp/a.ts`, so a + * walker must prune the directory rather than ask about its contents. A + * directory walker that never descends into excluded directories satisfies + * this naturally; do not use the matcher to test arbitrary deep paths. */ type PathMatcher = (relativePath: string, options: {directory: boolean}) => boolean +/** + * Decide whether a file path relative to the app directory is excluded from the scan, + * checking its ancestor directories itself. See `createFilePathMatcher`. + */ type FilePathMatcher = (relativePath: string) => boolean /** - * `.git` has no trailing slash so it also matches the `.git` file used by - * worktrees. `.github/`, `.vscode/` and similar are deliberately not excluded: - * secrets turn up in workflow and editor configuration. + * Paths never worth scanning: build output, dependencies, caches, and test + * fixture trees, expressed in .gitignore syntax. A pattern without a slash + * matches at any depth; a trailing `/` matches directories only. + * + * Patterns are generic on purpose. Earlier versions hardcoded the names of + * this project's own fixture directories, which both leaked internal naming + * into a tool that ships to third-party developers and silently skipped any + * directory a developer happened to give the same name. + * + * The scan walks dot-folders, so generated dot-folders must be listed + * explicitly: framework build output (`.next/`, `.nuxt/`, ...), caches + * (`.cache/`, `.turbo/`), Yarn Berry's committed `.yarn/releases`, and the + * CLI-generated `.shopify/`. `.github/`, `.vscode/`, `.devcontainer/` and + * `.circleci/` are deliberately NOT excluded because hardcoded secrets turn up + * in workflow and editor configuration. + * + * `.git` has no trailing slash so it also matches the `.git` FILE that git + * worktrees use in place of a directory. + * + * Sub-apps (a nested directory with its own shopify.app.toml) are not a path + * rule: `listRepositoryFiles` in `discover.ts` treats them as a structural + * walker boundary, since that requires reading the tree rather than matching + * a name. */ export const DEFAULT_EXCLUDE_PATTERNS: ReadonlyArray = [ 'node_modules/', @@ -52,6 +108,7 @@ export const DEFAULT_EXCLUDE_PATTERNS: ReadonlyArray = [ '.svelte-kit/', ] +/** What asking git for the app's ignored paths produced. */ export type GitIgnoreListing = | {status: 'listed'; paths: string[]} | {status: 'not-a-repository'} @@ -59,31 +116,74 @@ export type GitIgnoreListing = | {status: 'failed'} /** - * Only untracked paths are listed, so tracked files that match .gitignore are - * still scanned. Any status other than `listed` means no git exclusions apply. + * Ask git which paths under `appRoot` are ignored and untracked. + * + * Listed paths are relative to `appRoot` with POSIX separators. A fully + * ignored directory is collapsed to a single `dir/` entry. Only UNTRACKED + * ignored paths appear: a tracked file that matches .gitignore is never + * reported, so a committed-then-ignored `.env` is still scanned. + * + * Every outcome other than `listed` means "no git-based exclusions": the + * caller scans everything the defaults allow. The outcomes are kept apart so + * later findings can say exactly why a git-ignored file was still scanned. + * + * - `not-a-repository`: `appRoot` is not inside a git working tree: no `.git` + * marker exists at or above it, or `appRoot` lies inside the `.git` + * directory itself. + * - `app-root-ignored`: an enclosing repository ignores the app folder (or an + * ancestor of it). That repository does not own the app, so its rules must + * not empty the scan. + * - `failed`: git is missing, refused to read a repository that does exist + * (dubious ownership, corrupt or invalid configuration), or a command failed + * for any other reason. + * + * `pruneDefaultDirectories` lets git skip the default directories entirely + * (see `defaultDirectoryPathspecExcludes`). Pass `false` whenever an override + * could re-include one of them, so that git-ignored paths inside a re-included + * directory are still reported and excluded. */ -export async function listGitIgnoredPaths(appRoot: string): Promise { - const listing = await runGitIgnoreListing(appRoot) +export async function listGitIgnoredPaths( + appRoot: string, + options: {pruneDefaultDirectories: boolean}, +): Promise { + const listing = await runGitIgnoreListing(appRoot, options) if (listing.status !== 'listed') outputDebug(`App Security: git ignore listing skipped (${listing.status})`) return listing } -async function runGitIgnoreListing(appRoot: string): Promise { - // Prints `true` or `false`, then the app root's path below the top level (empty at the top level). +async function runGitIgnoreListing( + appRoot: string, + options: {pruneDefaultDirectories: boolean}, +): Promise { + // One call answers two questions. Verified with git 2.55, the output is two lines: `true` or + // `false`, then the app root's path relative to the repository's top level, which is an empty + // line at the top level itself (`true\n\n`; `true\napps/web/\n` from apps/web). Inside `.git` + // the first line is `false` with exit code 0; outside any repository the exit code is 128. const location = await runGit(appRoot, ['rev-parse', '--is-inside-work-tree', '--show-prefix']) if (location === undefined) return {status: 'failed'} - // Git exits 128 both outside a repository and when it refuses one; the `.git` marker tells them - // apart without parsing git's localized stderr. + // A repository git refuses to read (dubious ownership under `safe.directory`, a corrupt config or + // HEAD) also exits 128, and only git's localized stderr tells the cases apart. The `.git` marker + // on disk does so independently of locale: a marker means git failed on a repository that exists. if (location.exitCode !== 0) { return findRepositoryMarker(appRoot).status === 'none' ? {status: 'not-a-repository'} : {status: 'failed'} } const [insideWorkTree, prefix] = location.stdout.split(/\r?\n/) if (insideWorkTree === 'false') return {status: 'not-a-repository'} - // A missing git binary resolves with exit code 0 and empty output. + // A missing git binary resolves with exit code 0 and empty output (captureOutputWithExitCode + // does not reject), so only an explicit `true` counts as being inside a working tree. if (insideWorkTree !== 'true' || prefix === undefined) return {status: 'failed'} - // `--no-index` so a force-tracked file doesn't hide that the app folder is ignored. Skipped at - // the top level, where `.` becomes the empty path and a whitelist-style `*` would match it. + // Is the app folder itself ignored by the repository? `--no-index` is essential: without it git + // answers "not ignored" as soon as any descendant is force-tracked, and in exactly that case + // `ls-files` below no longer collapses the app to a single `./` entry but lists every untracked + // file in the app individually, which would exclude almost the entire app from the scan. With + // `--no-index` the answer follows the ignore rules alone, for the app folder and its ancestors. + // + // The probe is skipped at the top level (empty prefix): a repository cannot ignore its own top + // level, and `check-ignore .` normalises `.` there to the empty path, which a bare `*` pattern + // matches, so a whitelist-style root .gitignore (`*` then `!src/` ...) would misreport the app + // as ignored. From a subdirectory `.` is resolved to the prefix path, and the same whitelist + // (`*`, `!*/`) correctly answers `1` (not ignored) for a re-included folder. if (prefix !== '') { const appRootIgnored = await runGit(appRoot, ['check-ignore', '--no-index', '-q', '.']) if (appRootIgnored === undefined) return {status: 'failed'} @@ -100,13 +200,36 @@ async function runGitIgnoreListing(appRoot: string): Promise { '--directory', '--', '.', - ...defaultDirectoryPathspecExcludes(), + ...(options.pruneDefaultDirectories ? defaultDirectoryPathspecExcludes() : []), ]) if (listed === undefined || listed.exitCode !== 0) return {status: 'failed'} return {status: 'listed', paths: listed.stdout.split('\0').filter((path) => path !== '')} } -/** The walker always prunes default directories, so stop git walking an unignored `node_modules/`. */ +/** + * Keep git out of the default directories. `ls-files --others --ignored` + * walks every untracked directory that is NOT itself ignored (an unignored + * `node_modules/` with 63,000 files took 0.05 s to list here; 0.02 s with the + * exclusion below). As long as no override can re-include a default directory, the + * walker always prunes every one of them, so git's view of the ignored paths + * inside one can never matter, and skipping them cannot change the scan. + * + * The moment ANY include override exists (`!web/build/`, or even `!keep.ts`) + * the caller must turn the pruning off: a re-included default directory is + * walked, and the git-ignored files inside it (`web/build/x.log` under + * `*.log`) must then be excluded, which requires git to have reported them. + * Pruning is disabled for every include override rather than only those that + * name a default directory, because deciding whether an arbitrary pattern can + * match a directory at some depth would re-implement gitignore matching; the + * cost is only git walking directories it would otherwise skip. + * + * Each pathspec is `:(exclude,glob)` followed by the pattern wrapped in + * leading and trailing `**` segments, which matches the directory at the app + * root and at any depth; a wildcard name such as `*-fixtures/` keeps working. + * Only directory defaults (trailing `/`) become pathspecs; file patterns such + * as `*.test.*` and the `.git` entry do not name a directory git would + * traverse. + */ function defaultDirectoryPathspecExcludes(): string[] { return DEFAULT_EXCLUDE_PATTERNS.filter((pattern) => pattern.endsWith('/')).map( (pattern) => `:(exclude,glob)**/${pattern}**`, @@ -117,35 +240,219 @@ async function runGit(cwd: string, args: string[]): Promise<{exitCode: number; s try { const result = await captureOutputWithExitCode('git', args, {cwd}) return {exitCode: result.exitCode, stdout: result.stdout} + // Defensive only: captureOutputWithExitCode does not reject on a non-zero exit, and a missing git + // binary resolves with empty output rather than throwing. Anything that does throw is treated + // as a failed listing: no git-based exclusions. // eslint-disable-next-line no-catch-all/no-catch-all } catch { return undefined } } -export function buildPathRules(input: {gitIgnoredPaths: ReadonlyArray}): PathRules { - return {defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: input.gitIgnoredPaths} +/** + * Whether the value ends in an unescaped backslash. Each pair of backslashes is + * one escaped backslash, so only an odd-length trailing run leaves one + * unescaped. `ignore` silently drops a pattern that ends in a single backslash + * and throws on three or more, because its own check only recognizes one. + */ +function endsWithUnescapedBackslash(value: string): boolean { + const trailingBackslashCount = /\\+$/.exec(value)?.[0].length ?? 0 + return trailingBackslashCount % 2 === 1 +} + +/** + * Whether `ignore` can compile the value. It turns each pattern into a regular + * expression and throws a SyntaxError for some malformed ones, such as an + * unclosed `[` followed by `/` (`src/[id/x.ts`) or an escaped backslash before + * `(` (`a\\(b`). Asking the matcher itself catches every such pattern without + * re-implementing its parser. + */ +function compilesAsPattern(value: string): boolean { + try { + compilePatterns([value]) + return true + } catch (error) { + if (error instanceof SyntaxError) return false + throw error + } +} + +/** + * Explain why a `--ignore` pattern is unusable, or return `undefined` when + * it is a usable .gitignore line. Pure: callers (the flag parser) decide how + * to surface the message. + * + * Each rejected value would otherwise silently do nothing, or worse: a comment + * line (`#x`) and a blank line are no-ops in .gitignore syntax, a lone `!` + * makes the `ignore` matcher re-include every path, a trailing unescaped + * backslash makes `ignore` drop the pattern or throw, and a value with a line + * break is added as one rule (the matcher only splits lines when given a single + * string, not a list) that no path can ever match. A `..` segment can never + * match either: walked paths are built from directory entry names below the app + * directory, so none of them contains `..`. Last, any value `ignore` cannot + * compile is rejected here rather than crashing the scan. + */ +export function ignorePatternProblem(value: string): string | undefined { + if (value.trim() === '') return "An --ignore pattern can't be empty." + if (/[\r\n]/.test(value)) { + return 'An --ignore pattern must be a single line. Repeat --ignore to add more than one pattern.' + } + if (value.startsWith('#')) { + return `The --ignore pattern "${value}" starts with "#", which .gitignore treats as a comment. To match a path that starts with "#", escape it as "\\#".` + } + if (value.startsWith('!') && value.slice(1).trim() === '') { + return `The --ignore pattern "${value}" has nothing after "!". Add the pattern to include again, for example "!build/".` + } + if (endsWithUnescapedBackslash(value)) { + return `The --ignore pattern "${value}" ends with a backslash, which .gitignore treats as an incomplete escape. Use "/" as the path separator, or escape the backslash as "\\\\".` + } + const pattern = value.startsWith('!') ? value.slice(1) : value + if (pattern.split('/').includes('..')) { + return `The --ignore pattern "${value}" contains "..". Patterns are relative to the app directory and can't point outside it.` + } + if (!compilesAsPattern(value)) { + return `The --ignore pattern "${value}" can't be read as a .gitignore pattern. Check for an unclosed "[" or a backslash before a special character.` + } + return undefined +} + +/** + * Turn `--ignore` patterns into override rules, in command-line order. Each + * value is one .gitignore line relative to the app directory: a leading `!` + * makes an include rule; anything else is passed through unchanged as an + * exclude rule, so gitignore escapes such as `\!` and `\#` keep their meaning. + * + * Values are validated by `ignorePatternProblem` at the flag boundary; a value + * that still fails here is a programming error rather than user input. + */ +export function ignorePatternRules(ignorePatterns: ReadonlyArray): PathOverride[] { + return ignorePatterns.map((value) => { + const problem = ignorePatternProblem(value) + if (problem) throw new BugError(problem) + return value.startsWith('!') + ? {action: 'include', pattern: value.slice(1), source: 'cli'} + : {action: 'exclude', pattern: value, source: 'cli'} + }) +} + +/** Whether any override can re-include a path, which is what makes git's default-directory pruning unsafe. */ +export function hasIncludeOverride(overrides: ReadonlyArray): boolean { + return overrides.some((override) => override.action === 'include') +} + +/** + * Assemble the scan's path rules. Precedence, lowest first: the hardcoded + * defaults and the literal paths git reported as ignored (independent, either + * excludes), then the overrides (`ignorePatternRules` for `--ignore`), so + * users can exclude more paths or re-include defaults and git-ignored paths + * with `!pattern`. A future config-file source belongs in the override phase + * before the CLI rules, keeping the command line the final word. + */ +export function buildPathRules(input: { + gitIgnoredPaths: ReadonlyArray + overrides?: ReadonlyArray +}): PathRules { + return { + defaults: DEFAULT_EXCLUDE_PATTERNS, + gitIgnoredPaths: input.gitIgnoredPaths, + overrides: input.overrides ?? [], + } +} + +function toGitIgnoreLine(override: PathOverride): string { + return override.action === 'include' ? `!${override.pattern}` : override.pattern +} + +/** + * `ignorecase: false` matches the case-sensitive fast-glob discovery this + * replaced and makes results identical on every platform. + * + * `allowRelativePaths: true` stops `ignore` from throwing on a name made only + * of dots (`...` is a legal POSIX filename); callers already guarantee there is + * no `./` prefix, which is the case the check exists for. + * + * `ignore` is a CommonJS module whose typings describe an ESM default export, + * so under NodeNext the factory is reached through `.default` (as in + * `dev/app-events/file-watcher.ts`). + */ +function compilePatterns(lines: ReadonlyArray) { + return ignore.default({ignorecase: false, allowRelativePaths: true}).add([...lines]) } /** - * Git paths are looked up in a Set so names like `[id].ts` match literally. - * `allowRelativePaths` stops `ignore` throwing on names made only of dots. - * `ignore` is CommonJS, so under NodeNext the factory is on `.default`. + * Compile the rules into a matcher with .gitignore semantics: a path is + * excluded if an ancestor directory is excluded, otherwise the LAST rule that + * matches the path itself decides. + * + * Git literals (possibly thousands of them) are looked up in a Set rather than + * compiled into patterns, both for speed and so that a name containing + * gitignore-significant characters (`[id].ts`, `#hash.ts`) matches literally. + * The pattern phases are compiled into two `ignore` instances: `combined` + * (defaults then overrides, in order) and `overridesOnly`. Each lookup is: + * + * 1. an override matching the path itself decides (the last override wins); + * 2. otherwise a git literal for the path excludes it; + * 3. otherwise `combined` decides. + * + * This is exact under the PathMatcher precondition that every ancestor of the + * queried path is included (the walker prunes excluded directories): + * (a) `overridesOnly.test` then reports only the overrides' own match — had an + * override excluded an ancestor, the full rule set would have excluded it + * too, and the walker would never have asked. A parent's un-ignore does not + * propagate to children in `ignore`, so an `!dir/` override does not claim + * the files beneath it; they fall through to steps 2 and 3. + * (b) Git literals only exclude, so leaving them out of `combined` can only + * include more; ancestors included under the full rule set stay included, + * so `combined.ignores` is the own match of defaults+overrides. When no + * override matched, that is the defaults' own match, which the git literal + * already had its chance to add to in step 2. + * (c) A git directory literal (`tmp/`) matches only the directory. If an + * override re-includes `tmp/`, its files are then judged by the pattern + * phases alone, exactly as git treats a re-included directory. The flip + * side: git collapses a fully ignored directory to that single `logs/` + * literal, so an include override for one file inside it (`!logs/debug.log`) + * cannot re-include the file; the walker prunes the directory literal + * before ever asking about its contents. Users must re-include the + * directory itself (`!logs/`), after which the defaults and the other + * overrides judge everything beneath it. + * + * `combined` still applies git's parent-directory rule among pattern rules: a + * file cannot be re-included once a parent directory is excluded, although a + * directory itself can be re-included by a later `!dir/` override. */ export function createPathMatcher(rules: PathRules): PathMatcher { - const defaults = ignore.default({ignorecase: false, allowRelativePaths: true}).add([...rules.defaults]) + const overrideLines = rules.overrides.map(toGitIgnoreLine) + const combined = compilePatterns([...rules.defaults, ...overrideLines]) + const overridesOnly = compilePatterns(overrideLines) const gitIgnored = new Set(rules.gitIgnoredPaths) return (relativePath, {directory}) => { const key = directory ? `${relativePath}/` : relativePath - return gitIgnored.has(key) || defaults.ignores(key) + const overrideMatch = overridesOnly.test(key) + if (overrideMatch.ignored) return true + if (overrideMatch.unignored) return false + if (gitIgnored.has(key)) return true + return combined.ignores(key) } } /** - * For a file path not reached by walking: checks each ancestor directory - * first, as the walker would have pruned it. Ancestors are tested as - * directories, so a symlinked folder git lists as a file isn't excluded here. + * Compile the rules into a matcher for a FILE path the caller already knows + * rather than reached by walking, such as an allowlisted configuration path. + * + * `createPathMatcher` is only correct once every ancestor directory is known + * to be included, so this matcher establishes that itself: it asks about each + * ancestor directory top-down first (an excluded ancestor excludes the file, + * exactly as the walker would have pruned it), then about the file. The path + * must meet `createPathMatcher`'s format: POSIX-separated, app-root-relative, + * no `./` prefix and no trailing slash. + * + * This deliberately diverges from the walker for a symlinked directory. The + * walker uses lstat semantics, so it tests a symlinked `.github` as a FILE + * (`.github`); this matcher tests every ancestor as a directory (`.github/`). + * A gitignored symlinked folder (reported by git as the file literal `.github`) + * is therefore NOT excluded here, and its configuration surfaces as + * unresolved instead, as documented on `findDependencyAutomationInputs`. */ export function createFilePathMatcher(rules: PathRules): FilePathMatcher { const matcher = createPathMatcher(rules) diff --git a/packages/app/src/cli/services/app-security-engine/tests/dependency-automation-discovery.test.ts b/packages/app/src/cli/services/app-security-engine/tests/dependency-automation-discovery.test.ts index 727a419f29c..44a86775bc7 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/dependency-automation-discovery.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/dependency-automation-discovery.test.ts @@ -96,7 +96,8 @@ describe('dependency automation discovery', () => { // No shipped default matches an allowlisted path, hence a custom one. await inTemporaryDirectory(async (root) => { await writeFiles(root, {'.github/dependabot.yml': 'version: 2\nupdates: []\n'}) - expect(findDependencyAutomationInputs(root, {defaults: ['.github/'], gitIgnoredPaths: []})).toEqual({files: []}) + const rules = {defaults: ['.github/'], gitIgnoredPaths: [], overrides: []} + expect(findDependencyAutomationInputs(root, rules)).toEqual({files: []}) expect(findDependencyAutomationInputs(root, NO_GIT_EXCLUSIONS).files).toHaveLength(1) }) }) diff --git a/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts b/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts index bdd02997d65..4d51b613a7f 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts @@ -26,6 +26,7 @@ vi.mock('@shopify/cli-kit/node/system', async (importActual) => { const checkId = 'MISSING_DEPENDENCY_SECURITY_AUTOMATION' const dependabot = '# Configuration contents are not validated.\n' +// Keep the scan's git calls independent of the developer's global excludes and any enclosing repository. let restoreGitConfig: (() => void) | undefined beforeEach(() => { restoreGitConfig = isolateGitConfig() @@ -139,7 +140,8 @@ describe('dependency automation scanner integration', () => { const submission = buildSubmission(execution.trace, {cliVersion: '3.99.0', submittedAt: '2026-09-15T00:00:00Z'}) expect(JSON.stringify(submission)).not.toContain('local>org/renovate-config') expect(JSON.stringify(execution.trace)).not.toContain('local>org/renovate-config') - // Outside any repository, ignored-path discovery stops at its first probe. + // The scan's own git calls (ignored-path discovery and project metadata) are the only ones allowed. + // The temporary directory is outside any repository, so ignored-path discovery stops at its first probe. expect(vi.mocked(captureOutputWithExitCode).mock.calls.map(([command, args]) => [command, args])).toEqual([ ['git', ['rev-parse', '--is-inside-work-tree', '--show-prefix']], ['git', ['rev-parse', 'HEAD']], @@ -168,14 +170,15 @@ describe('dependency automation scanner integration', () => { }) }) - describe('gitignored configuration', () => { - async function makeRepository(root: string, files: Record, tracked: string[]): Promise { - await makeApp(root, files) - git(root, ['init', '-q', '.']) - git(root, ['add', '-f', '--', 'shopify.app.toml', 'package.json', ...tracked]) - git(root, ['commit', '-qm', 'init']) - } + async function makeRepository(root: string, files: Record, tracked: string[]): Promise { + await makeApp(root, files) + git(root, ['init', '-q', '.']) + git(root, ['add', '-f', '--', 'shopify.app.toml', 'package.json', ...tracked]) + git(root, ['commit', '-qm', 'init']) + } + describe('gitignored configuration', () => { + // Hosted bots read the repository, so a configuration file that never reaches it configures nothing. test('does not read an untracked configuration file that git ignores', async () => { await inTemporaryDirectory(async (root) => { await makeRepository(root, {'.gitignore': '.github/\n', '.github/dependabot.yml': dependabot}, ['.gitignore']) @@ -222,6 +225,44 @@ describe('dependency automation scanner integration', () => { }) }) + describe('--ignore', () => { + test('does not read a committed configuration file a pattern excludes', async () => { + await inTemporaryDirectory(async (root) => { + await makeRepository(root, {'.github/dependabot.yml': dependabot}, ['.github/dependabot.yml']) + const result = await scan(root, undefined, {ignorePatterns: ['.github/']}) + expect(dependencyFindings(result)).toHaveLength(1) + expect(dependencyExecution(result)).toMatchObject({status: 'executed', inspected_files: ['package.json']}) + expect(result.scan.file_hashes).not.toHaveProperty('.github/dependabot.yml') + }) + }) + + test('reads an untracked gitignored configuration file a pattern includes again', async () => { + await inTemporaryDirectory(async (root) => { + // The tracked CODEOWNERS keeps `.github/` from being collapsed into a single ignored directory + // literal, so git reports the configuration file itself and the pattern can include it again. + await makeRepository( + root, + { + '.gitignore': '.github/dependabot.yml\n', + '.github/CODEOWNERS': '* @owners\n', + '.github/dependabot.yml': dependabot, + }, + ['.gitignore', '.github/CODEOWNERS'], + ) + const excluded = await scan(root) + expect(dependencyFindings(excluded)).toHaveLength(1) + + const result = await scan(root, undefined, {ignorePatterns: ['!.github/dependabot.yml']}) + expect(dependencyFindings(result)).toEqual([]) + expect(dependencyExecution(result)).toMatchObject({ + status: 'executed', + inspected_files: ['package.json', '.github/dependabot.yml'], + }) + expect(result.scan.file_hashes?.['.github/dependabot.yml']).toBe(sha256(dependabot)) + }) + }) + }) + test('does not treat package.json as a Renovate configuration filename', async () => { await inTemporaryDirectory(async (root) => { await makeApp(root, {'package.json': '{"dependencies":{"react":"19.0.0"},"renovate":{}}'}) diff --git a/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts b/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts index a0570afc0be..0ad60b3aa8e 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts @@ -67,7 +67,7 @@ function inspectedManifestPaths(result: ScanResult): string[] { } async function scanPathRules(appRoot: string): Promise { - const listing = await listGitIgnoredPaths(appRoot) + const listing = await listGitIgnoredPaths(appRoot, {pruneDefaultDirectories: true}) return buildPathRules({gitIgnoredPaths: listing.status === 'listed' ? listing.paths : []}) } @@ -594,6 +594,116 @@ describe('gitignore-driven exclusions', () => { }) }) +describe('--ignore patterns', () => { + let restoreGitConfig: () => void + beforeEach(() => { + restoreGitConfig = isolateGitConfig() + }) + afterEach(() => { + restoreGitConfig() + }) + + test('excludes a folder that neither the defaults nor .gitignore cover', async () => { + const root = await makeDirectory() + await writeFiles(root, { + 'shopify.app.toml': appConfiguration, + 'src/index.ts': 'export const included = true', + 'generated/client.ts': 'export const excluded = true', + 'web/generated/schema.ts': 'export const excluded = true', + }) + + const paths = hashedPaths(await scan(root, undefined, {ignorePatterns: ['generated/']})) + expect(paths).toContain('src/index.ts') + expect(paths).not.toContain('generated/client.ts') + expect(paths).not.toContain('web/generated/schema.ts') + }) + + test('re-includes a default exclusion at the root only when the pattern is anchored', async () => { + const root = await makeDirectory() + await writeFiles(root, { + 'shopify.app.toml': appConfiguration, + 'build/x.ts': 'export const rootBuild = true', + 'packages/a/build/y.ts': 'export const nestedBuild = true', + }) + + // `/build/` is anchored to the app directory; the nested `build/` stays excluded by the default. + const anchored = hashedPaths(await scan(root, undefined, {ignorePatterns: ['!/build/']})) + expect(anchored).toContain('build/x.ts') + expect(anchored).not.toContain('packages/a/build/y.ts') + + // `build/` without a slash prefix matches at any depth, like the default it overrides. + const unanchored = hashedPaths(await scan(root, undefined, {ignorePatterns: ['!build/']})) + expect(unanchored).toContain('build/x.ts') + expect(unanchored).toContain('packages/a/build/y.ts') + }) + + test('re-includes a gitignored folder', async () => { + const root = await makeRepository({ + 'shopify.app.toml': appConfiguration, + '.gitignore': 'tmp/\n', + 'tmp/scratch.ts': 'export const reincluded = true', + }) + + expect(hashedPaths(await scan(root))).not.toContain('tmp/scratch.ts') + expect(hashedPaths(await scan(root, undefined, {ignorePatterns: ['!tmp/']}))).toContain('tmp/scratch.ts') + }) + + test('cannot re-include a file inside a gitignored folder without re-including the folder', async () => { + const root = await makeRepository({ + 'shopify.app.toml': appConfiguration, + '.gitignore': 'tmp/\n', + 'tmp/keep.ts': 'export const stillExcluded = true', + 'tmp/scratch.ts': 'export const stillExcluded = true', + }) + + const paths = hashedPaths(await scan(root, undefined, {ignorePatterns: ['!tmp/keep.ts']})) + expect(paths).not.toContain('tmp/keep.ts') + expect(paths).not.toContain('tmp/scratch.ts') + }) + + test('still applies .gitignore inside a re-included default folder', async () => { + // Without an include pattern git is told to skip the default directories, so it would never + // report `build/x.local.json`; re-including `build/` must switch that pruning off or the file + // would be scanned despite being gitignored. + const root = await makeRepository({ + 'shopify.app.toml': appConfiguration, + '.gitignore': '*.local.json\n', + 'build/a.ts': 'export const reincluded = true', + 'build/x.local.json': '{"ignored": true}', + }) + + const paths = hashedPaths(await scan(root, undefined, {ignorePatterns: ['!build/']})) + expect(paths).toContain('build/a.ts') + expect(paths).not.toContain('build/x.local.json') + }) + + test('applies later patterns over earlier ones', async () => { + const root = await makeDirectory() + await writeFiles(root, { + 'shopify.app.toml': appConfiguration, + 'generated/client.ts': 'export const decided = true', + }) + + const excludeThenInclude = hashedPaths(await scan(root, undefined, {ignorePatterns: ['generated/', '!generated/']})) + expect(excludeThenInclude).toContain('generated/client.ts') + + const includeThenExclude = hashedPaths(await scan(root, undefined, {ignorePatterns: ['!generated/', 'generated/']})) + expect(includeThenExclude).not.toContain('generated/client.ts') + }) + + test('never stops the selected app configuration from loading', async () => { + const root = await makeDirectory() + await writeFiles(root, { + 'shopify.app.toml': appConfiguration, + 'shopify.app.staging.toml': 'name = "Staging"\napplication_url = "https://staging.example.com"\n', + }) + + const result = await scan(root, 'staging', {ignorePatterns: ['shopify.app*.toml']}) + expect(result.app.name).toBe('Staging') + expect(hashedPaths(result)).toContain('shopify.app.staging.toml') + }) +}) + describe('listRepositoryFiles', () => { test('lists a symlinked directory as an entry without traversing it', async () => { const root = await makeDirectory() diff --git a/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts b/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts index ee0d5de0e93..d6d81e3b480 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts @@ -6,12 +6,15 @@ import { createFilePathMatcher, createPathMatcher, listGitIgnoredPaths, + ignorePatternProblem, + ignorePatternRules, } from '../scanners/path-rules.js' +import {BugError} from '@shopify/cli-kit/node/error' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' -import type {PathRules} from '../scanners/path-rules.js' +import type {PathOverride, PathRules} from '../scanners/path-rules.js' const temporaryDirectories: string[] = [] let restoreGitConfig: (() => void) | undefined @@ -46,12 +49,20 @@ function makeRepository(files: Record): string { return root } -const DEFAULTS_ONLY: PathRules = {defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: []} +/** Only the defaults, as the matcher sees them when git reported nothing. */ +const DEFAULTS_ONLY: PathRules = {defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: [], overrides: []} -const gitIgnoredOnly = (paths: string[]): PathRules => ({defaults: [], gitIgnoredPaths: paths}) +/** Only git literals, so a test can prove literal matching without a default getting in the way. */ +const gitIgnoredOnly = (paths: string[]): PathRules => ({defaults: [], gitIgnoredPaths: paths, overrides: []}) -async function listedPaths(appRoot: string): Promise { - const listing = await listGitIgnoredPaths(appRoot) +const cliExclude = (pattern: string): PathOverride => ({action: 'exclude', pattern, source: 'cli'}) +const cliInclude = (pattern: string): PathOverride => ({action: 'include', pattern, source: 'cli'}) + +/** The listing options exactly as `scan()` uses them when no override can re-include a default directory. */ +const PRUNED = {pruneDefaultDirectories: true} + +async function listedPaths(appRoot: string, options = PRUNED): Promise { + const listing = await listGitIgnoredPaths(appRoot, options) expect(listing.status).toBe('listed') return listing.status === 'listed' ? listing.paths : [] } @@ -60,7 +71,7 @@ describe('listGitIgnoredPaths', () => { test('collapses a fully ignored directory to a single trailing-slash entry', async () => { const root = makeRepository({'.gitignore': 'tmp/\n', 'tmp/a.ts': '', 'tmp/nested/b.ts': '', 'src/index.ts': ''}) - await expect(listGitIgnoredPaths(root)).resolves.toEqual({status: 'listed', paths: ['tmp/']}) + await expect(listGitIgnoredPaths(root, PRUNED)).resolves.toEqual({status: 'listed', paths: ['tmp/']}) }) test('reports an ignored single file', async () => { @@ -70,6 +81,7 @@ describe('listGitIgnoredPaths', () => { }) test('does not report a tracked file that matches .gitignore', async () => { + // The classic leak: commit .env, then gitignore it. It must still be scanned. const root = makeRepository({'.gitignore': '.env\n', '.env': 'SECRET=1\n'}) git(root, ['add', '-f', '.env']) git(root, ['commit', '-qm', 'oops']) @@ -116,6 +128,8 @@ describe('listGitIgnoredPaths', () => { }) test('reports gitignore-significant filenames literally', async () => { + // Names that .gitignore syntax treats specially (glob brackets, comment `#`, negation `!`, + // whitespace) yet remain legal filenames on every platform, including Windows. const root = makeRepository({ '.gitignore': '\\[id\\].ts\n\\#hash.ts\n\\!bang.ts\nsp ace.ts\n', '[id].ts': '', @@ -136,7 +150,9 @@ describe('listGitIgnoredPaths', () => { test.skipIf(process.platform === 'win32')( 'reports an ignored symlinked directory as a file literal, without a trailing slash', async () => { - // Git doesn't follow symlinks, so the link is listed as a file, not a `dir/` entry. + // git never follows a symlink while listing, so the link is a blob to it and is not collapsed + // to a `dir/` entry. `createFilePathMatcher` relies on this when it documents that a + // gitignored symlinked `.github` is not excluded by the `.github/` ancestor check. const root = makeRepository({'.gitignore': '.github\n', 'real/dependabot.yml': ''}) symlinkSync(join(root, 'real'), join(root, '.github'), 'dir') @@ -150,6 +166,7 @@ describe('listGitIgnoredPaths', () => { writeFiles(fakeConfigHome, {'git/ignore': 'notes.txt\n'}) const root = makeRepository({'notes.txt': 'todo', 'src/index.ts': ''}) + // Sanity: with core.excludesFile unset git does read $XDG_CONFIG_HOME/git/ignore. vi.stubEnv('XDG_CONFIG_HOME', fakeConfigHome) await expect(listedPaths(root)).resolves.toEqual(['notes.txt']) @@ -166,11 +183,12 @@ describe('listGitIgnoredPaths', () => { const root = makeDirectory() writeFiles(root, {'.gitignore': 'tmp/\n', 'tmp/a.ts': ''}) - await expect(listGitIgnoredPaths(root)).resolves.toEqual({status: 'not-a-repository'}) + await expect(listGitIgnoredPaths(root, PRUNED)).resolves.toEqual({status: 'not-a-repository'}) }) test('reports an app folder the enclosing repository ignores, even with a force-tracked descendant', async () => { - // A force-tracked file stops git collapsing the app to `./`; it lists each file instead. + // With a force-tracked file inside, git no longer collapses the app to a single `./` entry: + // it lists every untracked file in the app individually, which would empty the scan. const repository = makeRepository({ '.gitignore': 'apps/web/\n', 'apps/web/README.md': 'docs', @@ -180,7 +198,7 @@ describe('listGitIgnoredPaths', () => { git(repository, ['add', '-f', 'apps/web/README.md', '.gitignore']) git(repository, ['commit', '-qm', 'init']) - await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'))).resolves.toEqual({ + await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'), PRUNED)).resolves.toEqual({ status: 'app-root-ignored', }) }) @@ -192,10 +210,10 @@ describe('listGitIgnoredPaths', () => { 'apps/web/src/index.ts': '', }) - // Control: from the repository root, git lists the ignored folder. + // Asked from the repository root the same setup lists the ignored folder, proving git works here. await expect(listedPaths(repository)).resolves.toEqual(['apps/']) - await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'))).resolves.toEqual({ + await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'), PRUNED)).resolves.toEqual({ status: 'app-root-ignored', }) }) @@ -203,17 +221,19 @@ describe('listGitIgnoredPaths', () => { test('does not treat the top level of a repository as ignored', async () => { const root = makeRepository({'.gitignore': 'tmp/\n', 'tmp/a.ts': ''}) - await expect(listGitIgnoredPaths(root)).resolves.toEqual({status: 'listed', paths: ['tmp/']}) + await expect(listGitIgnoredPaths(root, PRUNED)).resolves.toEqual({status: 'listed', paths: ['tmp/']}) }) test('does not treat the top level of a whitelist-style repository as ignored', async () => { + // `git check-ignore .` at the top level normalises `.` to the empty path, which a bare `*` + // matches, so probing there would misreport the app as ignored and drop every git exclusion. const root = makeRepository({ '.gitignore': '*\n!src/\n!src/**\n!.gitignore\n', 'src/index.ts': '', 'tmp.log': '', }) - await expect(listGitIgnoredPaths(root)).resolves.toEqual({status: 'listed', paths: ['tmp.log']}) + await expect(listGitIgnoredPaths(root, PRUNED)).resolves.toEqual({status: 'listed', paths: ['tmp.log']}) }) test('does not treat an app folder a whitelist-style repository re-includes as ignored', async () => { @@ -224,16 +244,17 @@ describe('listGitIgnoredPaths', () => { 'apps/web/.env': 'SECRET=1\n', }) - await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'))).resolves.toEqual({ + await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'), PRUNED)).resolves.toEqual({ status: 'listed', paths: ['.env', 'shopify.app.toml'], }) }) test('reports a directory inside .git as not being in a repository', async () => { + // `git rev-parse --is-inside-work-tree` prints `false` with exit code 0 there. const root = makeRepository({}) - await expect(listGitIgnoredPaths(join(root, '.git'))).resolves.toEqual({status: 'not-a-repository'}) + await expect(listGitIgnoredPaths(join(root, '.git'), PRUNED)).resolves.toEqual({status: 'not-a-repository'}) }) test.each([ @@ -242,25 +263,28 @@ describe('listGitIgnoredPaths', () => { ])( 'reports a failure, not a missing repository, when git refuses a repository with a corrupt %s', async (file, content) => { - // Both make `rev-parse` exit 128, as it does outside any repository. + // Both make `rev-parse` exit 128, as it does outside any repository (verified with git 2.55: a + // corrupt config prints "bad config line", a corrupt HEAD even prints "not a git repository"). + // The .git marker on disk is what tells the two apart. const root = makeRepository({'.gitignore': 'tmp/\n', 'tmp/a.ts': ''}) git(root, ['add', '.gitignore']) git(root, ['commit', '-qm', 'init']) writeFileSync(join(root, '.git', file), content) - await expect(listGitIgnoredPaths(root)).resolves.toEqual({status: 'failed'}) + await expect(listGitIgnoredPaths(root, PRUNED)).resolves.toEqual({status: 'failed'}) }, ) test.skipIf(process.platform === 'win32')( 'reports a failure, not a missing repository, when the .git marker is a dangling symlink', async () => { - // A dangling `.git` link also makes `rev-parse` exit 128. + // git cannot follow the dangling link, so `rev-parse` exits 128 exactly as it does outside any + // repository; the ambiguous marker on disk is what keeps this from being reported as one. const root = makeDirectory() writeFiles(root, {'.gitignore': 'tmp/\n', 'tmp/a.ts': ''}) symlinkSync(join(root, 'missing-git-dir'), join(root, '.git')) - await expect(listGitIgnoredPaths(root)).resolves.toEqual({status: 'failed'}) + await expect(listGitIgnoredPaths(root, PRUNED)).resolves.toEqual({status: 'failed'}) }, ) @@ -268,7 +292,7 @@ describe('listGitIgnoredPaths', () => { const repository = makeRepository({'apps/web/src/index.ts': ''}) writeFileSync(join(repository, '.git', 'config'), '[core\nbogus') - await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'))).resolves.toEqual({status: 'failed'}) + await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'), PRUNED)).resolves.toEqual({status: 'failed'}) }) test('reports a failure when git cannot list the working tree', async () => { @@ -279,7 +303,7 @@ describe('listGitIgnoredPaths', () => { // `ls-files` exit with a fatal error. writeFileSync(join(root, '.git', 'index'), 'not an index') - await expect(listGitIgnoredPaths(root)).resolves.toEqual({status: 'failed'}) + await expect(listGitIgnoredPaths(root, PRUNED)).resolves.toEqual({status: 'failed'}) }) }) @@ -313,6 +337,24 @@ describe('listGitIgnoredPaths', () => { await expect(listedPaths(root)).resolves.toEqual(['scratch/notes.log']) }) + + test('lists paths inside the default directories when pruning is off', async () => { + // `scan()` turns pruning off whenever an include override exists, since a re-included default + // directory is walked and the git-ignored files inside it must still be excluded. + const root = makeRepository({ + '.gitignore': '*.log\n', + 'node_modules/pkg/index.js': '', + 'node_modules/pkg/debug.log': '', + 'src/index.ts': '', + 'src/app.log': '', + }) + + await expect(listedPaths(root)).resolves.toEqual(['src/app.log']) + await expect(listedPaths(root, {pruneDefaultDirectories: false})).resolves.toEqual([ + 'node_modules/pkg/debug.log', + 'src/app.log', + ]) + }) }) }) @@ -447,14 +489,108 @@ describe('DEFAULT_EXCLUDE_PATTERNS', () => { describe('createPathMatcher', () => { test('is case sensitive', () => { - const isExcluded = createPathMatcher({defaults: ['Build/'], gitIgnoredPaths: []}) + const isExcluded = createPathMatcher({defaults: ['Build/'], gitIgnoredPaths: [], overrides: []}) expect(isExcluded('Build/a.ts', {directory: false})).toBe(true) expect(isExcluded('build/a.ts', {directory: false})).toBe(false) }) + describe('with overrides', () => { + test('an include re-includes a gitignored directory and, as it is no longer pruned, its contents', () => { + const isExcluded = createPathMatcher({ + defaults: DEFAULT_EXCLUDE_PATTERNS, + gitIgnoredPaths: ['tmp/'], + overrides: [cliInclude('tmp/')], + }) + + expect(isExcluded('tmp', {directory: true})).toBe(false) + expect(isExcluded('tmp/a.ts', {directory: false})).toBe(false) + }) + + test('an include for one file inside a gitignored directory does not re-include the directory', () => { + // git collapses the ignored directory to a single `tmp/` literal, which the walker prunes + // before ever asking about `tmp/keep.ts`; users must re-include the directory itself. + const isExcluded = createPathMatcher({ + defaults: DEFAULT_EXCLUDE_PATTERNS, + gitIgnoredPaths: ['tmp/'], + overrides: [cliInclude('tmp/keep.ts')], + }) + + expect(isExcluded('tmp', {directory: true})).toBe(true) + }) + + test('an exclude wins over the defaults, the git literals and an earlier include', () => { + const isExcluded = createPathMatcher({ + defaults: DEFAULT_EXCLUDE_PATTERNS, + gitIgnoredPaths: ['notes.txt'], + overrides: [cliInclude('keep.ts'), cliExclude('keep.ts'), cliExclude('*.md'), cliExclude('generated/')], + }) + + expect(isExcluded('keep.ts', {directory: false})).toBe(true) + expect(isExcluded('README.md', {directory: false})).toBe(true) + expect(isExcluded('docs/guide.md', {directory: false})).toBe(true) + expect(isExcluded('generated', {directory: true})).toBe(true) + expect(isExcluded('web/generated', {directory: true})).toBe(true) + expect(isExcluded('notes.txt', {directory: false})).toBe(true) + expect(isExcluded('node_modules', {directory: true})).toBe(true) + expect(isExcluded('src/index.ts', {directory: false})).toBe(false) + }) + + test('an include re-includes a default exclusion without touching other defaults', () => { + const isExcluded = createPathMatcher({ + defaults: DEFAULT_EXCLUDE_PATTERNS, + gitIgnoredPaths: ['notes.txt'], + overrides: [cliInclude('web/build/')], + }) + + expect(isExcluded('web/build', {directory: true})).toBe(false) + expect(isExcluded('web/build/a.ts', {directory: false})).toBe(false) + expect(isExcluded('build/a.ts', {directory: false})).toBe(true) + expect(isExcluded('notes.txt', {directory: false})).toBe(true) + }) + + test('a default that matches a file inside a re-included directory still excludes it', () => { + const isExcluded = createPathMatcher({ + defaults: DEFAULT_EXCLUDE_PATTERNS, + gitIgnoredPaths: ['notes.txt'], + overrides: [cliInclude('web/build/')], + }) + + expect(isExcluded('web/build/a.test.ts', {directory: false})).toBe(true) + }) + + test('the defaults and the git literals decide when no override matches', () => { + const isExcluded = createPathMatcher({ + defaults: ['*.log'], + gitIgnoredPaths: ['notes.txt'], + overrides: [cliInclude('other.ts')], + }) + + expect(isExcluded('debug.log', {directory: false})).toBe(true) + expect(isExcluded('notes.txt', {directory: false})).toBe(true) + expect(isExcluded('other.ts', {directory: false})).toBe(false) + expect(isExcluded('src/index.ts', {directory: false})).toBe(false) + }) + }) + + describe('with --ignore patterns', () => { + // The matcher semantics are covered above with `PathOverride` values and the `!` and escape + // parsing under `ignorePatternRules`; this only pins that command-line order is rule order. + test('later CLI patterns win over earlier ones', () => { + const fromPatterns = (ignorePatterns: string[]) => + createPathMatcher(buildPathRules({gitIgnoredPaths: [], overrides: ignorePatternRules(ignorePatterns)})) + const excludeThenInclude = fromPatterns(['generated/', '!generated/']) + const includeThenExclude = fromPatterns(['!generated/', 'generated/']) + + expect(excludeThenInclude('generated', {directory: true})).toBe(false) + expect(includeThenExclude('generated', {directory: true})).toBe(true) + }) + }) + test('handles many gitignore literals and many lookups', () => { - // Compiling literals into patterns instead of a Set makes this take about a minute. + // 50,000 literals × 50,000 lookups. The literal lookup is a Set membership test, which keeps + // this far inside vitest's timeout; compiling the literals into patterns (as the first version + // of this matcher did) made the same workload take about a minute. const rules = buildPathRules({ gitIgnoredPaths: Array.from({length: 50_000}, (_, index) => `dir${index % 100}/.DS_Store${index}`), }) @@ -479,6 +615,8 @@ describe('createFilePathMatcher', () => { }) test('excludes a file below a collapsed git directory literal', () => { + // `createPathMatcher` alone would answer false for `tmp/a/b.ts`: the literal `tmp/` names only the + // directory. This matcher asks about the ancestors first, as the walker's pruning would have. const isExcluded = createFilePathMatcher(gitIgnoredOnly(['tmp/'])) expect(isExcluded('tmp/a/b.ts')).toBe(true) @@ -496,17 +634,146 @@ describe('createFilePathMatcher', () => { expect(isExcluded('packages/web/node_modules/dep/index.js')).toBe(true) expect(isExcluded('packages/web/src/index.js')).toBe(false) }) + + test('lets an include override re-include a git file literal', () => { + const isExcluded = createFilePathMatcher({ + defaults: [], + gitIgnoredPaths: ['.github/dependabot.yml'], + overrides: [cliInclude('.github/dependabot.yml')], + }) + + expect(isExcluded('.github/dependabot.yml')).toBe(false) + }) + + test('lets an exclude override exclude a file the defaults and git would keep', () => { + const isExcluded = createFilePathMatcher({defaults: [], gitIgnoredPaths: [], overrides: [cliExclude('.github/')]}) + + expect(isExcluded('.github/dependabot.yml')).toBe(true) + expect(isExcluded('renovate.json')).toBe(false) + }) +}) + +describe('ignorePatternRules', () => { + test('turns a plain line into a CLI exclude rule and a `!` line into a CLI include rule', () => { + expect(ignorePatternRules(['generated/', '!build/', '*.log', '/docs'])).toEqual([ + cliExclude('generated/'), + cliInclude('build/'), + cliExclude('*.log'), + cliExclude('/docs'), + ]) + }) + + test('passes gitignore escapes through unchanged so `\\!` excludes a literal `!` name', () => { + const overrides = ignorePatternRules(['\\!bang.ts', '\\#hash.ts']) + expect(overrides).toEqual([cliExclude('\\!bang.ts'), cliExclude('\\#hash.ts')]) + + const isExcluded = createPathMatcher({defaults: [], gitIgnoredPaths: [], overrides}) + expect(isExcluded('!bang.ts', {directory: false})).toBe(true) + expect(isExcluded('bang.ts', {directory: false})).toBe(false) + expect(isExcluded('#hash.ts', {directory: false})).toBe(true) + }) + + test('returns no rules for no patterns', () => { + expect(ignorePatternRules([])).toEqual([]) + }) + + test('rejects a value the flag layer should already have refused as a bug', () => { + // A lone `!` would make `ignore` re-include every path, so it must never reach the matcher. + expect(() => ignorePatternRules(['!'])).toThrow(BugError) + expect(() => ignorePatternRules(['!'])).toThrow(/nothing after/) + expect(() => ignorePatternRules([''])).toThrow(/empty/) + }) +}) + +describe('ignorePatternProblem', () => { + test('accepts ordinary .gitignore lines', () => { + for (const value of [ + 'generated/', + '!build/', + '*.log', + '/docs', + '\\#hash.ts', + '\\!bang.ts', + 'a b/', + '!.env', + 'build\\\\', + 'build\\\\\\\\', + 'trailing\\ ', + 'app/[id]/x.ts', + ]) { + expect(ignorePatternProblem(value), value).toBeUndefined() + } + }) + + test('rejects empty and whitespace-only values', () => { + expect(ignorePatternProblem('')).toMatch(/empty/) + expect(ignorePatternProblem(' ')).toMatch(/empty/) + }) + + test('rejects a .gitignore comment and suggests escaping the #', () => { + const problem = ignorePatternProblem('#hash.ts') + expect(problem).toMatch(/comment/) + expect(problem).toContain('\\#') + }) + + test('rejects a `!` with nothing to re-include', () => { + expect(ignorePatternProblem('!')).toMatch(/nothing after/) + expect(ignorePatternProblem('! ')).toMatch(/nothing after/) + }) + + test('rejects a trailing unescaped backslash, which `ignore` would silently drop or fail to compile', () => { + for (const value of ['build\\', 'src\\lib\\', '!build\\', '\\', 'build\\\\\\', '!build\\\\\\\\\\', '\\\\\\']) { + const problem = ignorePatternProblem(value) + expect(problem, value).toMatch(/ends with a backslash/) + expect(problem, value).toContain('/') + expect(problem, value).toContain('\\\\') + } + }) + + test('rejects values that span more than one line', () => { + for (const value of ['build/\ngenerated/', 'build/\r\n', 'build/\r', '\nbuild/']) { + expect(ignorePatternProblem(value), JSON.stringify(value)).toMatch(/single line/) + } + }) + + test('rejects patterns with `..` as a whole path segment', () => { + for (const value of ['..', '../x', 'x/..', 'a/../b', '**/../x', '!../shared/']) { + expect(ignorePatternProblem(value), value).toBe( + `The --ignore pattern "${value}" contains "..". Patterns are relative to the app directory and can't point outside it.`, + ) + } + }) + + test('rejects patterns the `ignore` matcher cannot compile, instead of crashing the scan', () => { + for (const value of ['src/[id/x.ts', '![/', 'a\\\\[b', 'a\\\\(b']) { + expect(ignorePatternProblem(value), value).toBe( + `The --ignore pattern "${value}" can't be read as a .gitignore pattern. Check for an unclosed "[" or a backslash before a special character.`, + ) + expect(() => ignorePatternRules([value]), value).toThrow(BugError) + } + }) + + test('allows patterns where dots are part of a path segment', () => { + for (const value of ['..cache/', 'a..b', '...', 'x/..y']) { + expect(ignorePatternProblem(value), value).toBeUndefined() + } + }) }) describe('buildPathRules', () => { - test('keeps the defaults and the git literals as separate phases, in input order', () => { - expect(buildPathRules({gitIgnoredPaths: ['notes.txt', 'tmp/']})).toEqual({ + test('keeps the defaults, the git literals and the overrides as separate phases, in input order', () => { + const overrides = [cliExclude('generated/'), cliInclude('build/')] + + expect(buildPathRules({gitIgnoredPaths: ['notes.txt', 'tmp/'], overrides})).toEqual({ defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: ['notes.txt', 'tmp/'], + overrides, }) }) - test('has no git literals when git reported nothing', () => { - expect(buildPathRules({gitIgnoredPaths: []})).toEqual({defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: []}) + test('has no git literals and no overrides when neither was supplied', () => { + const expected = {defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: [], overrides: []} + expect(buildPathRules({gitIgnoredPaths: []})).toEqual(expected) + expect(buildPathRules({gitIgnoredPaths: [], overrides: []})).toEqual(expected) }) }) 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 e907090c444..e09393a5a20 100644 --- a/packages/app/src/cli/services/app-security-engine/types.ts +++ b/packages/app/src/cli/services/app-security-engine/types.ts @@ -72,6 +72,16 @@ export interface ProjectDetection { languages: DetectedLanguage[] } +/** Caller-supplied inputs that change which files a scan discovers. */ +export interface ScanOptions { + /** + * `--ignore` patterns: .gitignore lines relative to the app directory, + * applied in order after the default and gitignore exclusions. They must reach + * every scan of the same review (initial scan and compile) or `input_hash` differs. + */ + ignorePatterns?: ReadonlyArray +} + export interface ScanResult { version: string timestamp: string diff --git a/packages/app/src/cli/services/app-security-instructions.test.ts b/packages/app/src/cli/services/app-security-instructions.test.ts index dd4aa518289..cd53b6760fe 100644 --- a/packages/app/src/cli/services/app-security-instructions.test.ts +++ b/packages/app/src/cli/services/app-security-instructions.test.ts @@ -86,6 +86,24 @@ describe('appSecurityInstructions', () => { }) }) + test('repeats --ignore patterns in the scan, compile, and clean commands', async () => { + await inTemporaryDirectory(async (directory) => { + const appRoot = await createApp(directory) + const instructions = appSecurityInstructions({ + directory: appRoot, + scanComplete: false, + ignorePatterns: ['generated/', '!build/'], + }) + const patterns = `--ignore ${shellQuote('generated/')} --ignore ${shellQuote('!build/')}` + + expect(instructions).toContain(`shopify app security check --path ${shellQuote(appRoot)} ${patterns}\n`) + expect(instructions).toContain( + `shopify app security check --path ${shellQuote(appRoot)} ${patterns} --findings ${shellQuote(joinPath(appRoot, '.shopify', 'app-security', 'findings.json'))}`, + ) + expect(instructions).toContain(`shopify app security check --path ${shellQuote(appRoot)} ${patterns} --clean`) + }) + }) + test('starts from existing results after a scan', async () => { await inTemporaryDirectory(async (directory) => { const appRoot = await createApp(directory) @@ -189,6 +207,17 @@ describe('deliverAppSecurityInstructions', () => { }) }) + test('forwards ignorePatterns into the printed commands', async () => { + await inTemporaryDirectory(async (directory) => { + await createApp(directory) + const dependencies = testDependencies() + + await deliverAppSecurityInstructions({directory, copy: false, ignorePatterns: ['generated/']}, dependencies) + + expect(dependencies.output).toHaveBeenCalledWith(expect.stringContaining(`--ignore ${shellQuote('generated/')}`)) + }) + }) + test('does not infer scan completion from an existing review pack', async () => { await inTemporaryDirectory(async (directory) => { await createApp(directory) diff --git a/packages/app/src/cli/services/app-security-instructions.ts b/packages/app/src/cli/services/app-security-instructions.ts index a355f83cbf6..590667b3c22 100644 --- a/packages/app/src/cli/services/app-security-instructions.ts +++ b/packages/app/src/cli/services/app-security-instructions.ts @@ -44,13 +44,15 @@ function markdownPath(value: string): string { function instructionPaths( directory: string, - commands?: AppSecurityCommands, - configName?: string, + commands: AppSecurityCommands | undefined, + configName: string | undefined, + ignorePatterns: ReadonlyArray, ): AppSecurityInstructionPaths { const appRoot = resolveAppSecurityRoot(resolvePath(directory)) const {artifactDirectory, reviewPath, tracePath, findingsPath} = appSecurityArtifactPaths(appRoot) + // Commands handed over by `security check` already carry its --config values and --ignore patterns. const resolvedCommands = - commands ?? resolveAppSecurityCommands(appRoot, requireSecurityConfigFileName(appRoot, configName)) + commands ?? resolveAppSecurityCommands(appRoot, requireSecurityConfigFileName(appRoot, configName), ignorePatterns) return { appRoot, commands: resolvedCommands, @@ -93,6 +95,8 @@ interface AppSecurityInstructionsOptions { scanComplete?: boolean commands?: AppSecurityCommands configName?: string + /** `--ignore` patterns to repeat in the generated commands when `commands` are not supplied. */ + ignorePatterns?: ReadonlyArray } interface AppSecurityInstructionsDependencies { @@ -116,8 +120,9 @@ export function appSecurityInstructions(options: { scanComplete: boolean commands?: AppSecurityCommands configName?: string + ignorePatterns?: ReadonlyArray }): string { - const paths = instructionPaths(options.directory, options.commands, options.configName) + const paths = instructionPaths(options.directory, options.commands, options.configName, options.ignorePatterns ?? []) const scanContext = options.scanComplete ? completedScanInstructions(paths) : initialScanInstructions(paths) return getAgentInstructions() .replace(SCAN_CONTEXT_PLACEHOLDER, scanContext) @@ -139,6 +144,7 @@ export default async function deliverAppSecurityInstructions( scanComplete: options.scanComplete ?? false, commands: options.commands, configName: options.configName, + ignorePatterns: options.ignorePatterns, }) if (options.copy) { diff --git a/packages/app/src/cli/services/security-check.test.ts b/packages/app/src/cli/services/security-check.test.ts index 4ca20564199..c1ad30e26b8 100644 --- a/packages/app/src/cli/services/security-check.test.ts +++ b/packages/app/src/cli/services/security-check.test.ts @@ -128,6 +128,7 @@ function testOptions() { yes: false, skipInstructions: false, clean: false, + ignorePatterns: [], } } @@ -142,6 +143,7 @@ describe('securityCheck', () => { appRoot: '/tmp/unlinked-app', configName: undefined, findingsPath: undefined, + ignorePatterns: [], }) expect(dependencies.writeArtifacts).toHaveBeenCalledWith(scanExecution, {clean: false}) expect(dependencies.renderReport).toHaveBeenCalledWith({ @@ -167,6 +169,7 @@ describe('securityCheck', () => { appRoot: '/tmp/unlinked-app', configName: 'staging', findingsPath: undefined, + ignorePatterns: [], }) expect(dependencies.renderReport).toHaveBeenCalledWith( expect.objectContaining({ @@ -175,6 +178,45 @@ describe('securityCheck', () => { ) }) + test('forwards ignorePatterns to the scan and repeats them in generated commands and instructions', async () => { + const dependencies = testDependencies() + dependencies.canPrompt.mockReturnValue(true) + dependencies.selectInstructionsDestination.mockResolvedValue('print') + const ignorePatterns = ['generated/', '!build/'] + + await securityCheck({...testOptions(), ignorePatterns}, dependencies) + + const commands = resolveAppSecurityCommands(scanExecution.appRoot, 'shopify.app.toml', ignorePatterns) + expect(commands.scan.args).toContainEqual({flag: '--ignore', value: 'generated/'}) + expect(dependencies.execute).toHaveBeenCalledWith({ + appRoot: '/tmp/unlinked-app', + configName: undefined, + findingsPath: undefined, + ignorePatterns, + }) + expect(dependencies.renderReport).toHaveBeenCalledWith(expect.objectContaining({commands})) + expect(dependencies.deliverInstructions).toHaveBeenCalledWith(expect.objectContaining({commands})) + }) + + test('repeats ignorePatterns in the recovery commands of a refused scan', async () => { + const dependencies = testDependencies() + dependencies.findingsFileExists.mockResolvedValue(true) + const ignorePatterns = ['generated/'] + const commands = resolveAppSecurityCommands(scanExecution.appRoot, 'shopify.app.toml', ignorePatterns) + + const error = await securityCheck({...testOptions(), ignorePatterns}, dependencies).catch((error: unknown) => error) + + expect(error).toBeInstanceOf(AbortError) + expect(formatAppSecurityCommand(commands.compile)).toContain('--ignore') + expect(error).toMatchObject({ + tryMessage: expect.stringContaining(formatAppSecurityCommand(commands.compile)), + }) + expect(error).toMatchObject({ + tryMessage: expect.stringContaining(formatAppSecurityCommand(commands.clean)), + }) + expect(dependencies.execute).not.toHaveBeenCalled() + }) + test('refuses to scan when agent findings exist', async () => { const dependencies = testDependencies() dependencies.findingsFileExists.mockResolvedValue(true) diff --git a/packages/app/src/cli/services/security-check.ts b/packages/app/src/cli/services/security-check.ts index a0ea627b7ef..cdbcd812346 100644 --- a/packages/app/src/cli/services/security-check.ts +++ b/packages/app/src/cli/services/security-check.ts @@ -40,6 +40,8 @@ interface SecurityOptions { skipInstructions: boolean findingsPath?: string clean: boolean + /** `--ignore` patterns in command-line order; forwarded to the scan and repeated in generated commands. */ + ignorePatterns: ReadonlyArray } export type AppSecurityInstructionsDestination = 'copy' | 'print' | 'nothing' @@ -49,7 +51,12 @@ interface SecurityDependencies { artifactPaths(appRoot: string): ResolvedAppSecurityArtifactPaths findingsFileExists(path: string): Promise readTrace(path: string): Promise - execute(options: {appRoot: string; configName?: string; findingsPath?: string}): Promise + execute(options: { + appRoot: string + configName?: string + findingsPath?: string + ignorePatterns: ReadonlyArray + }): Promise writeArtifacts( execution: AppSecurityExecution, options: WriteAppSecurityArtifactsOptions, @@ -82,12 +89,13 @@ const defaultDependencies: SecurityDependencies = { artifactPaths: appSecurityArtifactPaths, findingsFileExists: fileExists, readTrace, - execute: async ({appRoot, configName, findingsPath}) => { + execute: async ({appRoot, configName, findingsPath, ignorePatterns}) => { const findings = findingsPath ? await loadAppSecurityFindings(findingsPath) : undefined return executeAppSecurity({ appRoot, findings, configFileName: requireSecurityConfigFileName(appRoot, configName), + ignorePatterns, }) }, writeArtifacts: writeAppSecurityArtifacts, @@ -156,7 +164,11 @@ export default async function securityCheck( dependencies: SecurityDependencies = defaultDependencies, ): Promise { const appRoot = dependencies.resolveRoot(options.directory) - const commands = resolveAppSecurityCommands(appRoot, resolveSecurityConfigFileName(appRoot, options.configName)) + const commands = resolveAppSecurityCommands( + appRoot, + resolveSecurityConfigFileName(appRoot, options.configName), + options.ignorePatterns, + ) if (!options.findingsPath && !options.clean) { await assertCanStartScan(dependencies.artifactPaths(appRoot), commands, dependencies) } @@ -165,6 +177,7 @@ export default async function securityCheck( appRoot, configName: options.configName, findingsPath: options.findingsPath, + ignorePatterns: options.ignorePatterns, }) const artifacts = await dependencies.writeArtifacts(execution, {clean: options.clean}) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8776b7addba..1f0f3b5f69c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3660,8 +3660,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Runs Shopify App Security locally and creates its review pack and trace.\n\nPass `--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.\n\nIn 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.", - "descriptionWithMarkdown": "Runs Shopify App Security locally and creates its review pack and trace.\n\nPass `--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.\n\nIn 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.", + "description": "Runs Shopify App Security locally and creates its review pack and trace.\n\nPass `--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.\n\nUse `--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.\n\nIn 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.", + "descriptionWithMarkdown": "Runs Shopify App Security locally and creates its review pack and trace.\n\nPass `--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.\n\nUse `--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.\n\nIn 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.", "enableJsonFlag": false, "flags": { "blocking": { @@ -3709,6 +3709,13 @@ "name": "findings", "type": "option" }, + "ignore": { + "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.", + "hasDynamicHelp": false, + "multiple": true, + "name": "ignore", + "type": "option" + }, "json": { "allowNo": false, "char": "j", @@ -3788,8 +3795,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input.", - "descriptionWithMarkdown": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input.", + "description": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input. `--config` values and `--ignore` patterns are included in the generated commands.", + "descriptionWithMarkdown": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input. `--config` values and `--ignore` patterns are included in the generated commands.", "enableJsonFlag": false, "flags": { "config": { @@ -3812,6 +3819,13 @@ "name": "copy", "type": "boolean" }, + "ignore": { + "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.", + "hasDynamicHelp": false, + "multiple": true, + "name": "ignore", + "type": "option" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", From f10ea8c0697f42a190eb5202971d0d0c4e67e918 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Tue, 29 Sep 2026 15:03:21 -0700 Subject: [PATCH 2/7] Trim comments in App Security path filtering --- .../src/cli/commands/app/security/flags.ts | 9 +- .../app/src/cli/services/app-security-api.ts | 1 - .../services/app-security-commands.test.ts | 4 - .../src/cli/services/app-security-commands.ts | 19 +- .../cli/services/app-security-engine/run.ts | 8 +- .../app-security-engine/scanners/discover.ts | 83 +----- .../app-security-engine/scanners/index.ts | 2 - .../scanners/path-rules.ts | 271 +++--------------- .../tests/dependency-automation.test.ts | 8 +- .../tests/discovery-safety.test.ts | 4 +- .../tests/path-rules.test.ts | 39 +-- .../cli/services/app-security-engine/types.ts | 6 - .../app/src/cli/services/security-check.ts | 1 - 13 files changed, 63 insertions(+), 392 deletions(-) diff --git a/packages/app/src/cli/commands/app/security/flags.ts b/packages/app/src/cli/commands/app/security/flags.ts index 3d50440849d..b71e37211e1 100644 --- a/packages/app/src/cli/commands/app/security/flags.ts +++ b/packages/app/src/cli/commands/app/security/flags.ts @@ -2,14 +2,9 @@ import {ignorePatternProblem} from '../../../services/app-security-engine/index. import {Flags} from '@oclif/core' import {AbortError} from '@shopify/cli-kit/node/error' -/** - * Flags shared by `app security check` and `app security instructions`, so - * the commands that `instructions` generates accept exactly what it was given. - */ +/** Shared so the commands `instructions` generates accept exactly what it was given. */ export const appSecurityFlags = { - // Deliberately not bound to an environment variable: oclif reads a repeatable flag's environment variable only - // when the command line has no value for it and passes it as one string. It could carry only one pattern, and any - // --ignore on the command line would silently replace it. + // No environment variable: oclif passes a repeatable flag's variable as one string, so it could hold only one pattern. 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.', diff --git a/packages/app/src/cli/services/app-security-api.ts b/packages/app/src/cli/services/app-security-api.ts index 9cce67bf62c..74d0ff045a2 100644 --- a/packages/app/src/cli/services/app-security-api.ts +++ b/packages/app/src/cli/services/app-security-api.ts @@ -91,7 +91,6 @@ export async function executeAppSecurity(options: { appRoot: string findings?: FindingsDocument configFileName?: string - /** `--ignore` patterns; a compile must repeat the ones its scan used or the findings are rejected. */ ignorePatterns?: ReadonlyArray }): Promise { const startTime = Date.now() diff --git a/packages/app/src/cli/services/app-security-commands.test.ts b/packages/app/src/cli/services/app-security-commands.test.ts index 567d9eeeac7..9bd867c117a 100644 --- a/packages/app/src/cli/services/app-security-commands.test.ts +++ b/packages/app/src/cli/services/app-security-commands.test.ts @@ -222,7 +222,6 @@ describe('formatAppSecurityCommand', () => { '--ignore', 'a b/', ]) - // Every pattern is wrapped in quotes; bare `!` or `*` would be expanded by the shell. expect(formatted).not.toMatch(/ !build\//) expect(formatted).not.toMatch(/ \*\.log/) } @@ -238,9 +237,6 @@ describe('formatAppSecurityCommand', () => { }) test('quotes an --ignore pattern that starts with `-` or repeats a command word', () => { - // `-*.log` and `-tmp/` are legitimate .gitignore lines; left bare, a shell could glob-expand them - // and a parser could read them as flags. A pattern literally named `check` must not blend into - // the command words either. A flag value is quoted whatever it looks like. const ignorePatterns = ['-*.log', '-tmp/', 'check'] const commands = resolveAppSecurityCommands('/tmp/app', undefined, ignorePatterns) diff --git a/packages/app/src/cli/services/app-security-commands.ts b/packages/app/src/cli/services/app-security-commands.ts index 7ab8497590a..83a2375e778 100644 --- a/packages/app/src/cli/services/app-security-commands.ts +++ b/packages/app/src/cli/services/app-security-commands.ts @@ -3,11 +3,7 @@ import {getAppConfigurationShorthand} from '../models/app/config-file-naming.js' export type AppSecurityShell = 'posix' | 'cmd' | 'powershell' -/** - * One command-line argument. A string is command syntax (`app`, `--clean`) and is - * printed bare. A flag with a value is printed with the value quoted, because a - * value is user input: an app path, a configuration name, or an --ignore pattern. - */ +/** Strings are command syntax, printed bare. Flag values are user input, so they're always quoted. */ export type AppSecurityArgument = string | {flag: string; value: string} export interface AppSecurityCommand { @@ -21,11 +17,7 @@ export interface AppSecurityCommands { clean: AppSecurityCommand } -/** - * Build the scan, compile, and clean commands shown to users and coding agents. - * `ignorePatterns` are repeated on every command, in order, because a compile - * must discover the same files as the scan whose findings it validates. - */ +/** `ignorePatterns` are repeated on every command: a compile must discover the same files as its scan. */ export function resolveAppSecurityCommands( appRoot: string, configFileName?: string, @@ -102,13 +94,6 @@ function quoteCmdSegment(part: string): string { return `"${escapedQuotes}${trailingBackslashes}"` } -/** - * Render a command for a shell. Quoting follows the argument's type, not what - * it looks like: every flag value is quoted and all command syntax stays bare. - * An --ignore pattern such as `-*.log`, `-tmp/` or `check` would otherwise be - * left bare, where a shell could glob-expand it or a reader could mistake it - * for a flag or command word. - */ export function formatAppSecurityCommand( action: AppSecurityCommand, shell: AppSecurityShell = shellForPlatform(), 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 4b5182babd6..966478e6a38 100644 --- a/packages/app/src/cli/services/app-security-engine/run.ts +++ b/packages/app/src/cli/services/app-security-engine/run.ts @@ -129,11 +129,6 @@ export async function scanApp( } } -/** - * Compile agent findings against a fresh scan. The findings' `source_scan_id` - * must equal the fresh scan's `input_hash`, so `options` (`--ignore` patterns) - * must repeat whatever the initial scan used. - */ export async function compileFindings( directory: string, document: FindingsDocument, @@ -144,8 +139,7 @@ export async function compileFindings( const result = await scan(appRoot, configFileName, options) const engineVersion = getEngineVersion() const knownFiles = new Set(searchBoundaryFiles(result)) - // Ignore patterns change the input hash but are not recorded in the trace, so a mismatch cannot - // tell a changed file apart from a compile that forgot the scan's flags; the hint covers both. + // Ignore patterns change the input hash but aren't in the trace, so the hint covers both causes. const provenanceRejected = document.source_scan_id === result.scan.input_hash ? [] 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 c72ef8e4329..6d7c41af229 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 @@ -231,17 +231,7 @@ function recordSectionGap(appRoot: string | undefined, path: string, detail: str recordSkippedFile(appRoot, path, {ok: false, reason: 'unreadable', detail}) } -/** - * A directory holding its own `shopify.app*.toml` is an independent Shopify app. - * Nested apps are independent scan roots and never evidence for their parent - * app, so the walker stops at them: a structural boundary, much as git treats - * a nested repository as opaque to the enclosing one. (Nested git repositories - * themselves are NOT a boundary here; only their `.git` entry is pruned.) - * - * Detection looks at the directory's raw entries, not the rule-filtered ones: - * a nested app whose configuration file happens to be gitignored is still a - * nested app. - */ +/** Uses raw entries, so a nested app whose configuration file is gitignored is still a nested app. */ function isNestedAppDirectory(entries: ReadonlyArray): boolean { return entries.some((entry) => !entry.isDirectory() && isValidFormatAppConfigurationFileName(entry.name)) } @@ -262,25 +252,13 @@ function readDirectoryEntries(appRoot: string, absolutePath: string, displayPath } /** - * List every repository file the scan may inspect, as sorted app-root-relative - * POSIX paths. - * - * The walk applies `rules` with .gitignore semantics and prunes excluded - * directories: it never descends into them, so the matcher is only ever asked - * about paths whose ancestors are known to be included (its precondition). - * - * Directory entries use lstat semantics, so a symlink is never a directory - * here. Symlinks (to files or directories), FIFOs and other special entries - * are listed but never traversed; `readRepositoryFile` later enforces - * containment and realpath rules on anything a finder decides to read, and - * records failures. Dot-folders and dotfiles are walked unless a rule excludes - * them (see `DEFAULT_EXCLUDE_PATTERNS`). + * Symlinks and special entries are listed but never traversed; + * `readRepositoryFile` enforces containment on anything a finder reads. */ export function listRepositoryFiles(appRoot: string, rules: PathRules): string[] { const matcher = createPathMatcher(rules) const files: string[] = [] - // Relative directory paths still to be read; '' is the app root itself. Subdirectories are appended - // while iterating, which `for...of` supports: the array iterator re-checks the length on every step. + // Appended to while iterating; '' is the app root. const pendingDirectories = [''] for (const relativeDirectory of pendingDirectories) { @@ -291,14 +269,11 @@ export function listRepositoryFiles(appRoot: string, rules: PathRules): string[] relativeDirectory === '' ? 'app root' : relativeDirectory, ) if (entries === undefined) continue - // A nested app is a scan root in its own right; nothing beneath it belongs to this scan. if (relativeDirectory !== '' && isNestedAppDirectory(entries)) continue for (const entry of entries) { const relative = relativeDirectory === '' ? entry.name : `${relativeDirectory}/${entry.name}` if (entry.isDirectory()) { - // Never descend into an excluded directory: pruning here is what keeps the matcher's - // precondition (every ancestor of a queried path is included) true. if (!matcher(relative, {directory: true})) pendingDirectories.push(relative) } else if (!matcher(relative, {directory: false})) { files.push(relative) @@ -309,13 +284,7 @@ export function listRepositoryFiles(appRoot: string, rules: PathRules): string[] return files.sort() } -/** - * Group source paths by the extension directories that contain them, walking - * each path's ancestors once instead of filtering the whole repository per - * extension. `extensionDirectories` are app-root-relative; `.` is the app root - * and contains every path. A path inside nested extension directories belongs - * to each of them. Paths keep their input order within every group. - */ +/** A path inside nested extension directories belongs to each of them. */ function groupSourcePathsByExtensionDirectory( repositoryFiles: ReadonlyArray, extensionDirectories: ReadonlySet, @@ -326,7 +295,6 @@ function groupSourcePathsByExtensionDirectory( for (const path of repositoryFiles) { if (!hasSupportedSourceExtension(path)) continue - // `dirname` yields `.` for a top-level file and for `.` itself, which ends the climb. let ancestor = dirname(path) while (ancestor !== '.') { pathsByDirectory.get(ancestor)?.push(path) @@ -339,14 +307,13 @@ function groupSourcePathsByExtensionDirectory( } /** - * Find extension-like repository content among the walked repository files. + * Find extension-like repository content under the app root. * * `Project.load()` only considers paths in each app configuration's * `extension_directories`. App Security 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. - * Nested apps, generated output, and test trees are already absent from - * `repositoryFiles` (see `listRepositoryFiles`). + * Nested apps, generated output, and test trees remain excluded. */ export function findExtensions(appRoot: string, repositoryFiles: ReadonlyArray): ExtensionInfo[] { const extensionTomls = repositoryFiles.filter((path) => basename(path) === 'shopify.extension.toml') @@ -620,7 +587,6 @@ const SOURCE_LANGUAGES = { type SourceExtension = keyof typeof SOURCE_LANGUAGES -/** Extension matching is exact and case-sensitive: `.JS` is not treated as source. */ function sourceLanguageFor(path: string): (typeof SOURCE_LANGUAGES)[SourceExtension] | undefined { const extension = extname(path) return extension in SOURCE_LANGUAGES ? SOURCE_LANGUAGES[extension as SourceExtension] : undefined @@ -641,13 +607,8 @@ export function findSourceCandidates(repositoryFiles: ReadonlyArray): So .sort((left, right) => left.path.localeCompare(right.path)) } -/** - * Read the source files (backend routes, extension code, etc.) whose language - * is supported by the non-secret deterministic scanners. Results keep the order - * of `repositoryFiles`, which callers pass in `listRepositoryFiles` order. - */ +/** Find and read only source languages supported by non-secret deterministic scanners. */ export function findAppSourceFiles(appRoot: string, repositoryFiles: ReadonlyArray): SourceFile[] { - // `findExtensions` passes pre-filtered groups, but the filter is this function's own contract for every caller. return repositoryFiles.filter(hasSupportedSourceExtension).map((path) => { const absolutePath = joinPath(appRoot, path) const result = readRepositoryFile(appRoot, absolutePath) @@ -688,7 +649,6 @@ const SECRET_TEXT_EXTENSIONS = new Set([ '.pem', ]) -/** Extensionless files that routinely carry credentials. */ const SENSITIVE_FILE_NAMES = new Set(['Dockerfile', 'Containerfile', 'Gemfile', 'Rakefile', 'Procfile', 'Makefile']) function isSensitiveFile(path: string): boolean { @@ -713,11 +673,7 @@ function isProbablyBinary(content: Buffer): boolean { return sample.length > 0 && suspiciousControlBytes / sample.length > 0.1 } -/** - * Text evidence inspected for secrets regardless of app framework support. - * Results keep the order of `repositoryFiles`, which callers pass in - * `listRepositoryFiles` order. - */ +/** Text evidence inspected for secrets regardless of app framework support. */ export function findSensitiveFiles( appRoot: string, repositoryFiles: ReadonlyArray, @@ -742,12 +698,6 @@ export function findSensitiveFiles( }) } -/** - * Why repository-level configuration cannot be attributed to the app, if it - * cannot. The app owns its configuration when its own `.git` marker is the - * nearest one (or there is no repository at all); a marker found only above - * the app root means the configuration belongs to an enclosing repository. - */ function nestedRepositoryReason(appRoot: string): string | undefined { const marker = findRepositoryMarker(appRoot) if (marker.status === 'none') return undefined @@ -761,14 +711,8 @@ function recordRejectedAllowlistPath(appRoot: string, relative: string, failure: /** * Read local bot configuration only; hosted integrations and CI workflows are - * outside this check's scope. - * - * The allowlisted paths are read directly from disk rather than taken from the - * walked file list, so that a symlinked `.github` (which the walker never - * enters) is reported as unresolved rather than missing. The scan's `rules` - * still apply: hosted bots read the repository, so a configuration file git - * ignores configures nothing and is treated exactly like a missing one. The - * same holds for a path the user excludes with an `--ignore` pattern. + * outside this check's scope. Paths are read from disk rather than the walked + * list, so a symlinked `.github` is reported as unresolved rather than missing. */ export function findDependencyAutomationInputs(appRoot: string, rules: PathRules): DependencyAutomationInputs { let canonicalRoot: string @@ -823,10 +767,7 @@ export function findDependencyAutomationInputs(appRoot: string, rules: PathRules return files.length > 0 ? {files} : {files, ...(unresolvedReason ? {unresolvedReason} : {})} } -/** - * Find JavaScript package manifests. Dependency analysis intentionally supports JavaScript only. - * Results keep the order of `repositoryFiles`, which callers pass in `listRepositoryFiles` order. - */ +/** Find JavaScript package manifests. Dependency analysis intentionally supports JavaScript only. */ export function findManifestPaths(repositoryFiles: ReadonlyArray): string[] { return repositoryFiles.filter((path) => basename(path) === 'package.json') } diff --git a/packages/app/src/cli/services/app-security-engine/scanners/index.ts b/packages/app/src/cli/services/app-security-engine/scanners/index.ts index ebb3de16679..c841e909702 100644 --- a/packages/app/src/cli/services/app-security-engine/scanners/index.ts +++ b/packages/app/src/cli/services/app-security-engine/scanners/index.ts @@ -592,8 +592,6 @@ export async function scan( const selectedFileName = getAppConfigurationFileName(configFileName) const appToml = loadAppToml(joinPath(appRoot, selectedFileName), appRoot) const appTomls = appToml ? [appToml] : [] - // The overrides are parsed once, before asking git: an include override can re-open a default - // directory, and git may only skip the default directories while nothing can re-include one. const overrides = ignorePatternRules(options.ignorePatterns ?? []) const gitIgnoreListing = await listGitIgnoredPaths(appRoot, { pruneDefaultDirectories: !hasIncludeOverride(overrides), diff --git a/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts b/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts index 7cdb0d3078e..e25b0dc52f8 100644 --- a/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts +++ b/packages/app/src/cli/services/app-security-engine/scanners/path-rules.ts @@ -4,79 +4,33 @@ import {outputDebug} from '@shopify/cli-kit/node/output' import {captureOutputWithExitCode} from '@shopify/cli-kit/node/system' import ignore from 'ignore' -/** - * A user-supplied .gitignore pattern relative to the app directory. Include - * rules store the pattern without the leading `!`. `cli` rules come from the - * `--ignore` flag; a future config-file source will join the same phase, - * placed before the CLI rules so the command line keeps the final word. - */ +/** A user `--ignore` pattern. Include rules store the pattern without the leading `!`. */ export interface PathOverride { action: 'exclude' | 'include' pattern: string source: 'cli' } -/** - * The scan's path rules, in three phases. The defaults and the git paths are - * both exclusion-only and independent: either excludes a path. The overrides - * win over both, and within the overrides the last matching rule wins, as in - * .gitignore, so `!pattern` re-includes a default or git-ignored path. - */ +/** Overrides win over the defaults and git paths; among overrides the last match wins, as in .gitignore. */ export interface PathRules { - /** .gitignore exclude patterns applied to every scan. */ defaults: ReadonlyArray - /** Literal paths relative to the app directory that git reports as untracked and ignored; directories end with `/`. */ + /** App-root-relative paths git reports as untracked and ignored; directories end with `/`. */ gitIgnoredPaths: ReadonlyArray - /** User-supplied rules, in precedence order (later wins). */ overrides: ReadonlyArray } /** - * Decide whether a path relative to the app directory is excluded from the scan. - * - * `relativePath` must be POSIX-separated, relative to the app directory, with no - * `./` prefix and no trailing slash. Pass `directory: true` for directories so - * directory-only patterns (`build/`) can match them. - * - * PRECONDITION: the answer is only correct for paths whose ancestor directories - * the caller has already confirmed are NOT excluded. A collapsed git directory - * literal (`tmp/`) matches only the directory itself, never `tmp/a.ts`, so a - * walker must prune the directory rather than ask about its contents. A - * directory walker that never descends into excluded directories satisfies - * this naturally; do not use the matcher to test arbitrary deep paths. + * Only correct for paths whose ancestor directories are not excluded: a git + * directory entry (`tmp/`) never matches `tmp/a.ts`, so callers must prune. */ type PathMatcher = (relativePath: string, options: {directory: boolean}) => boolean -/** - * Decide whether a file path relative to the app directory is excluded from the scan, - * checking its ancestor directories itself. See `createFilePathMatcher`. - */ type FilePathMatcher = (relativePath: string) => boolean /** - * Paths never worth scanning: build output, dependencies, caches, and test - * fixture trees, expressed in .gitignore syntax. A pattern without a slash - * matches at any depth; a trailing `/` matches directories only. - * - * Patterns are generic on purpose. Earlier versions hardcoded the names of - * this project's own fixture directories, which both leaked internal naming - * into a tool that ships to third-party developers and silently skipped any - * directory a developer happened to give the same name. - * - * The scan walks dot-folders, so generated dot-folders must be listed - * explicitly: framework build output (`.next/`, `.nuxt/`, ...), caches - * (`.cache/`, `.turbo/`), Yarn Berry's committed `.yarn/releases`, and the - * CLI-generated `.shopify/`. `.github/`, `.vscode/`, `.devcontainer/` and - * `.circleci/` are deliberately NOT excluded because hardcoded secrets turn up - * in workflow and editor configuration. - * - * `.git` has no trailing slash so it also matches the `.git` FILE that git - * worktrees use in place of a directory. - * - * Sub-apps (a nested directory with its own shopify.app.toml) are not a path - * rule: `listRepositoryFiles` in `discover.ts` treats them as a structural - * walker boundary, since that requires reading the tree rather than matching - * a name. + * `.git` has no trailing slash so it also matches the `.git` file used by + * worktrees. `.github/`, `.vscode/` and similar are deliberately not excluded: + * secrets turn up in workflow and editor configuration. */ export const DEFAULT_EXCLUDE_PATTERNS: ReadonlyArray = [ 'node_modules/', @@ -108,7 +62,6 @@ export const DEFAULT_EXCLUDE_PATTERNS: ReadonlyArray = [ '.svelte-kit/', ] -/** What asking git for the app's ignored paths produced. */ export type GitIgnoreListing = | {status: 'listed'; paths: string[]} | {status: 'not-a-repository'} @@ -116,31 +69,10 @@ export type GitIgnoreListing = | {status: 'failed'} /** - * Ask git which paths under `appRoot` are ignored and untracked. - * - * Listed paths are relative to `appRoot` with POSIX separators. A fully - * ignored directory is collapsed to a single `dir/` entry. Only UNTRACKED - * ignored paths appear: a tracked file that matches .gitignore is never - * reported, so a committed-then-ignored `.env` is still scanned. - * - * Every outcome other than `listed` means "no git-based exclusions": the - * caller scans everything the defaults allow. The outcomes are kept apart so - * later findings can say exactly why a git-ignored file was still scanned. - * - * - `not-a-repository`: `appRoot` is not inside a git working tree: no `.git` - * marker exists at or above it, or `appRoot` lies inside the `.git` - * directory itself. - * - `app-root-ignored`: an enclosing repository ignores the app folder (or an - * ancestor of it). That repository does not own the app, so its rules must - * not empty the scan. - * - `failed`: git is missing, refused to read a repository that does exist - * (dubious ownership, corrupt or invalid configuration), or a command failed - * for any other reason. - * - * `pruneDefaultDirectories` lets git skip the default directories entirely - * (see `defaultDirectoryPathspecExcludes`). Pass `false` whenever an override - * could re-include one of them, so that git-ignored paths inside a re-included - * directory are still reported and excluded. + * Only untracked paths are listed, so tracked files that match .gitignore are + * still scanned. Any status other than `listed` means no git exclusions apply. + * Pass `pruneDefaultDirectories: false` whenever an override could re-include + * a default directory. */ export async function listGitIgnoredPaths( appRoot: string, @@ -155,35 +87,21 @@ async function runGitIgnoreListing( appRoot: string, options: {pruneDefaultDirectories: boolean}, ): Promise { - // One call answers two questions. Verified with git 2.55, the output is two lines: `true` or - // `false`, then the app root's path relative to the repository's top level, which is an empty - // line at the top level itself (`true\n\n`; `true\napps/web/\n` from apps/web). Inside `.git` - // the first line is `false` with exit code 0; outside any repository the exit code is 128. + // Prints `true` or `false`, then the app root's path below the top level (empty at the top level). const location = await runGit(appRoot, ['rev-parse', '--is-inside-work-tree', '--show-prefix']) if (location === undefined) return {status: 'failed'} - // A repository git refuses to read (dubious ownership under `safe.directory`, a corrupt config or - // HEAD) also exits 128, and only git's localized stderr tells the cases apart. The `.git` marker - // on disk does so independently of locale: a marker means git failed on a repository that exists. + // Git exits 128 both outside a repository and when it refuses one; the `.git` marker tells them + // apart without parsing git's localized stderr. if (location.exitCode !== 0) { return findRepositoryMarker(appRoot).status === 'none' ? {status: 'not-a-repository'} : {status: 'failed'} } const [insideWorkTree, prefix] = location.stdout.split(/\r?\n/) if (insideWorkTree === 'false') return {status: 'not-a-repository'} - // A missing git binary resolves with exit code 0 and empty output (captureOutputWithExitCode - // does not reject), so only an explicit `true` counts as being inside a working tree. + // A missing git binary resolves with exit code 0 and empty output. if (insideWorkTree !== 'true' || prefix === undefined) return {status: 'failed'} - // Is the app folder itself ignored by the repository? `--no-index` is essential: without it git - // answers "not ignored" as soon as any descendant is force-tracked, and in exactly that case - // `ls-files` below no longer collapses the app to a single `./` entry but lists every untracked - // file in the app individually, which would exclude almost the entire app from the scan. With - // `--no-index` the answer follows the ignore rules alone, for the app folder and its ancestors. - // - // The probe is skipped at the top level (empty prefix): a repository cannot ignore its own top - // level, and `check-ignore .` normalises `.` there to the empty path, which a bare `*` pattern - // matches, so a whitelist-style root .gitignore (`*` then `!src/` ...) would misreport the app - // as ignored. From a subdirectory `.` is resolved to the prefix path, and the same whitelist - // (`*`, `!*/`) correctly answers `1` (not ignored) for a re-included folder. + // `--no-index` so a force-tracked file doesn't hide that the app folder is ignored. Skipped at + // the top level, where `.` becomes the empty path and a whitelist-style `*` would match it. if (prefix !== '') { const appRootIgnored = await runGit(appRoot, ['check-ignore', '--no-index', '-q', '.']) if (appRootIgnored === undefined) return {status: 'failed'} @@ -207,28 +125,9 @@ async function runGitIgnoreListing( } /** - * Keep git out of the default directories. `ls-files --others --ignored` - * walks every untracked directory that is NOT itself ignored (an unignored - * `node_modules/` with 63,000 files took 0.05 s to list here; 0.02 s with the - * exclusion below). As long as no override can re-include a default directory, the - * walker always prunes every one of them, so git's view of the ignored paths - * inside one can never matter, and skipping them cannot change the scan. - * - * The moment ANY include override exists (`!web/build/`, or even `!keep.ts`) - * the caller must turn the pruning off: a re-included default directory is - * walked, and the git-ignored files inside it (`web/build/x.log` under - * `*.log`) must then be excluded, which requires git to have reported them. - * Pruning is disabled for every include override rather than only those that - * name a default directory, because deciding whether an arbitrary pattern can - * match a directory at some depth would re-implement gitignore matching; the - * cost is only git walking directories it would otherwise skip. - * - * Each pathspec is `:(exclude,glob)` followed by the pattern wrapped in - * leading and trailing `**` segments, which matches the directory at the app - * root and at any depth; a wildcard name such as `*-fixtures/` keeps working. - * Only directory defaults (trailing `/`) become pathspecs; file patterns such - * as `*.test.*` and the `.git` entry do not name a directory git would - * traverse. + * The walker prunes default directories unless an override re-includes one, + * so git needn't walk them. Any include override disables this: telling whether + * a pattern can match a default directory would re-implement gitignore. */ function defaultDirectoryPathspecExcludes(): string[] { return DEFAULT_EXCLUDE_PATTERNS.filter((pattern) => pattern.endsWith('/')).map( @@ -240,33 +139,19 @@ async function runGit(cwd: string, args: string[]): Promise<{exitCode: number; s try { const result = await captureOutputWithExitCode('git', args, {cwd}) return {exitCode: result.exitCode, stdout: result.stdout} - // Defensive only: captureOutputWithExitCode does not reject on a non-zero exit, and a missing git - // binary resolves with empty output rather than throwing. Anything that does throw is treated - // as a failed listing: no git-based exclusions. // eslint-disable-next-line no-catch-all/no-catch-all } catch { return undefined } } -/** - * Whether the value ends in an unescaped backslash. Each pair of backslashes is - * one escaped backslash, so only an odd-length trailing run leaves one - * unescaped. `ignore` silently drops a pattern that ends in a single backslash - * and throws on three or more, because its own check only recognizes one. - */ +/** `ignore` drops a pattern ending in one unescaped backslash and throws on three or more. */ function endsWithUnescapedBackslash(value: string): boolean { const trailingBackslashCount = /\\+$/.exec(value)?.[0].length ?? 0 return trailingBackslashCount % 2 === 1 } -/** - * Whether `ignore` can compile the value. It turns each pattern into a regular - * expression and throws a SyntaxError for some malformed ones, such as an - * unclosed `[` followed by `/` (`src/[id/x.ts`) or an escaped backslash before - * `(` (`a\\(b`). Asking the matcher itself catches every such pattern without - * re-implementing its parser. - */ +/** `ignore` throws a SyntaxError on some malformed patterns, such as `src/[id/x.ts`. */ function compilesAsPattern(value: string): boolean { try { compilePatterns([value]) @@ -278,19 +163,9 @@ function compilesAsPattern(value: string): boolean { } /** - * Explain why a `--ignore` pattern is unusable, or return `undefined` when - * it is a usable .gitignore line. Pure: callers (the flag parser) decide how - * to surface the message. - * - * Each rejected value would otherwise silently do nothing, or worse: a comment - * line (`#x`) and a blank line are no-ops in .gitignore syntax, a lone `!` - * makes the `ignore` matcher re-include every path, a trailing unescaped - * backslash makes `ignore` drop the pattern or throw, and a value with a line - * break is added as one rule (the matcher only splits lines when given a single - * string, not a list) that no path can ever match. A `..` segment can never - * match either: walked paths are built from directory entry names below the app - * directory, so none of them contains `..`. Last, any value `ignore` cannot - * compile is rejected here rather than crashing the scan. + * Rejects values that would silently do nothing or break the scan: comments + * and blank lines are no-ops, a lone `!` re-includes everything, a multi-line + * value never matches, and walked paths never contain `..`. */ export function ignorePatternProblem(value: string): string | undefined { if (value.trim() === '') return "An --ignore pattern can't be empty." @@ -316,15 +191,7 @@ export function ignorePatternProblem(value: string): string | undefined { return undefined } -/** - * Turn `--ignore` patterns into override rules, in command-line order. Each - * value is one .gitignore line relative to the app directory: a leading `!` - * makes an include rule; anything else is passed through unchanged as an - * exclude rule, so gitignore escapes such as `\!` and `\#` keep their meaning. - * - * Values are validated by `ignorePatternProblem` at the flag boundary; a value - * that still fails here is a programming error rather than user input. - */ +/** Values are validated at the flag boundary, so a problem here is a bug. */ export function ignorePatternRules(ignorePatterns: ReadonlyArray): PathOverride[] { return ignorePatterns.map((value) => { const problem = ignorePatternProblem(value) @@ -335,19 +202,10 @@ export function ignorePatternRules(ignorePatterns: ReadonlyArray): PathO }) } -/** Whether any override can re-include a path, which is what makes git's default-directory pruning unsafe. */ export function hasIncludeOverride(overrides: ReadonlyArray): boolean { return overrides.some((override) => override.action === 'include') } -/** - * Assemble the scan's path rules. Precedence, lowest first: the hardcoded - * defaults and the literal paths git reported as ignored (independent, either - * excludes), then the overrides (`ignorePatternRules` for `--ignore`), so - * users can exclude more paths or re-include defaults and git-ignored paths - * with `!pattern`. A future config-file source belongs in the override phase - * before the CLI rules, keeping the command line the final word. - */ export function buildPathRules(input: { gitIgnoredPaths: ReadonlyArray overrides?: ReadonlyArray @@ -364,61 +222,19 @@ function toGitIgnoreLine(override: PathOverride): string { } /** - * `ignorecase: false` matches the case-sensitive fast-glob discovery this - * replaced and makes results identical on every platform. - * - * `allowRelativePaths: true` stops `ignore` from throwing on a name made only - * of dots (`...` is a legal POSIX filename); callers already guarantee there is - * no `./` prefix, which is the case the check exists for. - * - * `ignore` is a CommonJS module whose typings describe an ESM default export, - * so under NodeNext the factory is reached through `.default` (as in - * `dev/app-events/file-watcher.ts`). + * `allowRelativePaths` stops `ignore` throwing on names made only of dots. + * `ignore` is CommonJS, so under NodeNext the factory is on `.default`. */ function compilePatterns(lines: ReadonlyArray) { return ignore.default({ignorecase: false, allowRelativePaths: true}).add([...lines]) } /** - * Compile the rules into a matcher with .gitignore semantics: a path is - * excluded if an ancestor directory is excluded, otherwise the LAST rule that - * matches the path itself decides. - * - * Git literals (possibly thousands of them) are looked up in a Set rather than - * compiled into patterns, both for speed and so that a name containing - * gitignore-significant characters (`[id].ts`, `#hash.ts`) matches literally. - * The pattern phases are compiled into two `ignore` instances: `combined` - * (defaults then overrides, in order) and `overridesOnly`. Each lookup is: - * - * 1. an override matching the path itself decides (the last override wins); - * 2. otherwise a git literal for the path excludes it; - * 3. otherwise `combined` decides. - * - * This is exact under the PathMatcher precondition that every ancestor of the - * queried path is included (the walker prunes excluded directories): - * (a) `overridesOnly.test` then reports only the overrides' own match — had an - * override excluded an ancestor, the full rule set would have excluded it - * too, and the walker would never have asked. A parent's un-ignore does not - * propagate to children in `ignore`, so an `!dir/` override does not claim - * the files beneath it; they fall through to steps 2 and 3. - * (b) Git literals only exclude, so leaving them out of `combined` can only - * include more; ancestors included under the full rule set stay included, - * so `combined.ignores` is the own match of defaults+overrides. When no - * override matched, that is the defaults' own match, which the git literal - * already had its chance to add to in step 2. - * (c) A git directory literal (`tmp/`) matches only the directory. If an - * override re-includes `tmp/`, its files are then judged by the pattern - * phases alone, exactly as git treats a re-included directory. The flip - * side: git collapses a fully ignored directory to that single `logs/` - * literal, so an include override for one file inside it (`!logs/debug.log`) - * cannot re-include the file; the walker prunes the directory literal - * before ever asking about its contents. Users must re-include the - * directory itself (`!logs/`), after which the defaults and the other - * overrides judge everything beneath it. - * - * `combined` still applies git's parent-directory rule among pattern rules: a - * file cannot be re-included once a parent directory is excluded, although a - * directory itself can be re-included by a later `!dir/` override. + * An override matching the path itself decides (the last one wins); otherwise + * a git path excludes; otherwise the defaults and overrides together decide. + * Git paths are a Set so names like `[id].ts` match literally. Git collapses a + * fully ignored folder to `logs/`, so `!logs/debug.log` can't include a file + * inside it; users must include `!logs/` instead. */ export function createPathMatcher(rules: PathRules): PathMatcher { const overrideLines = rules.overrides.map(toGitIgnoreLine) @@ -437,22 +253,9 @@ export function createPathMatcher(rules: PathRules): PathMatcher { } /** - * Compile the rules into a matcher for a FILE path the caller already knows - * rather than reached by walking, such as an allowlisted configuration path. - * - * `createPathMatcher` is only correct once every ancestor directory is known - * to be included, so this matcher establishes that itself: it asks about each - * ancestor directory top-down first (an excluded ancestor excludes the file, - * exactly as the walker would have pruned it), then about the file. The path - * must meet `createPathMatcher`'s format: POSIX-separated, app-root-relative, - * no `./` prefix and no trailing slash. - * - * This deliberately diverges from the walker for a symlinked directory. The - * walker uses lstat semantics, so it tests a symlinked `.github` as a FILE - * (`.github`); this matcher tests every ancestor as a directory (`.github/`). - * A gitignored symlinked folder (reported by git as the file literal `.github`) - * is therefore NOT excluded here, and its configuration surfaces as - * unresolved instead, as documented on `findDependencyAutomationInputs`. + * For a file path not reached by walking: checks each ancestor directory + * first, as the walker would have pruned it. Ancestors are tested as + * directories, so a symlinked folder git lists as a file isn't excluded here. */ export function createFilePathMatcher(rules: PathRules): FilePathMatcher { const matcher = createPathMatcher(rules) diff --git a/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts b/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts index 4d51b613a7f..16bf9e2e1ef 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/dependency-automation.test.ts @@ -26,7 +26,6 @@ vi.mock('@shopify/cli-kit/node/system', async (importActual) => { const checkId = 'MISSING_DEPENDENCY_SECURITY_AUTOMATION' const dependabot = '# Configuration contents are not validated.\n' -// Keep the scan's git calls independent of the developer's global excludes and any enclosing repository. let restoreGitConfig: (() => void) | undefined beforeEach(() => { restoreGitConfig = isolateGitConfig() @@ -140,8 +139,7 @@ describe('dependency automation scanner integration', () => { const submission = buildSubmission(execution.trace, {cliVersion: '3.99.0', submittedAt: '2026-09-15T00:00:00Z'}) expect(JSON.stringify(submission)).not.toContain('local>org/renovate-config') expect(JSON.stringify(execution.trace)).not.toContain('local>org/renovate-config') - // The scan's own git calls (ignored-path discovery and project metadata) are the only ones allowed. - // The temporary directory is outside any repository, so ignored-path discovery stops at its first probe. + // Outside any repository, ignored-path discovery stops at its first probe. expect(vi.mocked(captureOutputWithExitCode).mock.calls.map(([command, args]) => [command, args])).toEqual([ ['git', ['rev-parse', '--is-inside-work-tree', '--show-prefix']], ['git', ['rev-parse', 'HEAD']], @@ -178,7 +176,6 @@ describe('dependency automation scanner integration', () => { } describe('gitignored configuration', () => { - // Hosted bots read the repository, so a configuration file that never reaches it configures nothing. test('does not read an untracked configuration file that git ignores', async () => { await inTemporaryDirectory(async (root) => { await makeRepository(root, {'.gitignore': '.github/\n', '.github/dependabot.yml': dependabot}, ['.gitignore']) @@ -238,8 +235,7 @@ describe('dependency automation scanner integration', () => { test('reads an untracked gitignored configuration file a pattern includes again', async () => { await inTemporaryDirectory(async (root) => { - // The tracked CODEOWNERS keeps `.github/` from being collapsed into a single ignored directory - // literal, so git reports the configuration file itself and the pattern can include it again. + // A tracked CODEOWNERS stops git collapsing `.github/`, so it lists the file itself. await makeRepository( root, { diff --git a/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts b/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts index 0ad60b3aa8e..99a979aaeb2 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts @@ -662,9 +662,7 @@ describe('--ignore patterns', () => { }) test('still applies .gitignore inside a re-included default folder', async () => { - // Without an include pattern git is told to skip the default directories, so it would never - // report `build/x.local.json`; re-including `build/` must switch that pruning off or the file - // would be scanned despite being gitignored. + // Re-including `build/` must turn off git's default-directory pruning, or git never lists this file. const root = await makeRepository({ 'shopify.app.toml': appConfiguration, '.gitignore': '*.local.json\n', diff --git a/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts b/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts index d6d81e3b480..6f875f9522f 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/path-rules.test.ts @@ -49,16 +49,13 @@ function makeRepository(files: Record): string { return root } -/** Only the defaults, as the matcher sees them when git reported nothing. */ const DEFAULTS_ONLY: PathRules = {defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: [], overrides: []} -/** Only git literals, so a test can prove literal matching without a default getting in the way. */ const gitIgnoredOnly = (paths: string[]): PathRules => ({defaults: [], gitIgnoredPaths: paths, overrides: []}) const cliExclude = (pattern: string): PathOverride => ({action: 'exclude', pattern, source: 'cli'}) const cliInclude = (pattern: string): PathOverride => ({action: 'include', pattern, source: 'cli'}) -/** The listing options exactly as `scan()` uses them when no override can re-include a default directory. */ const PRUNED = {pruneDefaultDirectories: true} async function listedPaths(appRoot: string, options = PRUNED): Promise { @@ -81,7 +78,6 @@ describe('listGitIgnoredPaths', () => { }) test('does not report a tracked file that matches .gitignore', async () => { - // The classic leak: commit .env, then gitignore it. It must still be scanned. const root = makeRepository({'.gitignore': '.env\n', '.env': 'SECRET=1\n'}) git(root, ['add', '-f', '.env']) git(root, ['commit', '-qm', 'oops']) @@ -128,8 +124,6 @@ describe('listGitIgnoredPaths', () => { }) test('reports gitignore-significant filenames literally', async () => { - // Names that .gitignore syntax treats specially (glob brackets, comment `#`, negation `!`, - // whitespace) yet remain legal filenames on every platform, including Windows. const root = makeRepository({ '.gitignore': '\\[id\\].ts\n\\#hash.ts\n\\!bang.ts\nsp ace.ts\n', '[id].ts': '', @@ -150,9 +144,7 @@ describe('listGitIgnoredPaths', () => { test.skipIf(process.platform === 'win32')( 'reports an ignored symlinked directory as a file literal, without a trailing slash', async () => { - // git never follows a symlink while listing, so the link is a blob to it and is not collapsed - // to a `dir/` entry. `createFilePathMatcher` relies on this when it documents that a - // gitignored symlinked `.github` is not excluded by the `.github/` ancestor check. + // Git doesn't follow symlinks, so the link is listed as a file, not a `dir/` entry. const root = makeRepository({'.gitignore': '.github\n', 'real/dependabot.yml': ''}) symlinkSync(join(root, 'real'), join(root, '.github'), 'dir') @@ -166,7 +158,6 @@ describe('listGitIgnoredPaths', () => { writeFiles(fakeConfigHome, {'git/ignore': 'notes.txt\n'}) const root = makeRepository({'notes.txt': 'todo', 'src/index.ts': ''}) - // Sanity: with core.excludesFile unset git does read $XDG_CONFIG_HOME/git/ignore. vi.stubEnv('XDG_CONFIG_HOME', fakeConfigHome) await expect(listedPaths(root)).resolves.toEqual(['notes.txt']) @@ -187,8 +178,7 @@ describe('listGitIgnoredPaths', () => { }) test('reports an app folder the enclosing repository ignores, even with a force-tracked descendant', async () => { - // With a force-tracked file inside, git no longer collapses the app to a single `./` entry: - // it lists every untracked file in the app individually, which would empty the scan. + // A force-tracked file stops git collapsing the app to `./`; it lists each file instead. const repository = makeRepository({ '.gitignore': 'apps/web/\n', 'apps/web/README.md': 'docs', @@ -210,7 +200,7 @@ describe('listGitIgnoredPaths', () => { 'apps/web/src/index.ts': '', }) - // Asked from the repository root the same setup lists the ignored folder, proving git works here. + // Control: from the repository root, git lists the ignored folder. await expect(listedPaths(repository)).resolves.toEqual(['apps/']) await expect(listGitIgnoredPaths(join(repository, 'apps', 'web'), PRUNED)).resolves.toEqual({ @@ -225,8 +215,6 @@ describe('listGitIgnoredPaths', () => { }) test('does not treat the top level of a whitelist-style repository as ignored', async () => { - // `git check-ignore .` at the top level normalises `.` to the empty path, which a bare `*` - // matches, so probing there would misreport the app as ignored and drop every git exclusion. const root = makeRepository({ '.gitignore': '*\n!src/\n!src/**\n!.gitignore\n', 'src/index.ts': '', @@ -251,7 +239,6 @@ describe('listGitIgnoredPaths', () => { }) test('reports a directory inside .git as not being in a repository', async () => { - // `git rev-parse --is-inside-work-tree` prints `false` with exit code 0 there. const root = makeRepository({}) await expect(listGitIgnoredPaths(join(root, '.git'), PRUNED)).resolves.toEqual({status: 'not-a-repository'}) @@ -263,9 +250,7 @@ describe('listGitIgnoredPaths', () => { ])( 'reports a failure, not a missing repository, when git refuses a repository with a corrupt %s', async (file, content) => { - // Both make `rev-parse` exit 128, as it does outside any repository (verified with git 2.55: a - // corrupt config prints "bad config line", a corrupt HEAD even prints "not a git repository"). - // The .git marker on disk is what tells the two apart. + // Both make `rev-parse` exit 128, as it does outside any repository. const root = makeRepository({'.gitignore': 'tmp/\n', 'tmp/a.ts': ''}) git(root, ['add', '.gitignore']) git(root, ['commit', '-qm', 'init']) @@ -278,8 +263,7 @@ describe('listGitIgnoredPaths', () => { test.skipIf(process.platform === 'win32')( 'reports a failure, not a missing repository, when the .git marker is a dangling symlink', async () => { - // git cannot follow the dangling link, so `rev-parse` exits 128 exactly as it does outside any - // repository; the ambiguous marker on disk is what keeps this from being reported as one. + // A dangling `.git` link also makes `rev-parse` exit 128. const root = makeDirectory() writeFiles(root, {'.gitignore': 'tmp/\n', 'tmp/a.ts': ''}) symlinkSync(join(root, 'missing-git-dir'), join(root, '.git')) @@ -339,8 +323,6 @@ describe('listGitIgnoredPaths', () => { }) test('lists paths inside the default directories when pruning is off', async () => { - // `scan()` turns pruning off whenever an include override exists, since a re-included default - // directory is walked and the git-ignored files inside it must still be excluded. const root = makeRepository({ '.gitignore': '*.log\n', 'node_modules/pkg/index.js': '', @@ -508,8 +490,6 @@ describe('createPathMatcher', () => { }) test('an include for one file inside a gitignored directory does not re-include the directory', () => { - // git collapses the ignored directory to a single `tmp/` literal, which the walker prunes - // before ever asking about `tmp/keep.ts`; users must re-include the directory itself. const isExcluded = createPathMatcher({ defaults: DEFAULT_EXCLUDE_PATTERNS, gitIgnoredPaths: ['tmp/'], @@ -574,8 +554,6 @@ describe('createPathMatcher', () => { }) describe('with --ignore patterns', () => { - // The matcher semantics are covered above with `PathOverride` values and the `!` and escape - // parsing under `ignorePatternRules`; this only pins that command-line order is rule order. test('later CLI patterns win over earlier ones', () => { const fromPatterns = (ignorePatterns: string[]) => createPathMatcher(buildPathRules({gitIgnoredPaths: [], overrides: ignorePatternRules(ignorePatterns)})) @@ -588,9 +566,7 @@ describe('createPathMatcher', () => { }) test('handles many gitignore literals and many lookups', () => { - // 50,000 literals × 50,000 lookups. The literal lookup is a Set membership test, which keeps - // this far inside vitest's timeout; compiling the literals into patterns (as the first version - // of this matcher did) made the same workload take about a minute. + // Compiling literals into patterns instead of a Set makes this take about a minute. const rules = buildPathRules({ gitIgnoredPaths: Array.from({length: 50_000}, (_, index) => `dir${index % 100}/.DS_Store${index}`), }) @@ -615,8 +591,6 @@ describe('createFilePathMatcher', () => { }) test('excludes a file below a collapsed git directory literal', () => { - // `createPathMatcher` alone would answer false for `tmp/a/b.ts`: the literal `tmp/` names only the - // directory. This matcher asks about the ancestors first, as the walker's pruning would have. const isExcluded = createFilePathMatcher(gitIgnoredOnly(['tmp/'])) expect(isExcluded('tmp/a/b.ts')).toBe(true) @@ -678,7 +652,6 @@ describe('ignorePatternRules', () => { }) test('rejects a value the flag layer should already have refused as a bug', () => { - // A lone `!` would make `ignore` re-include every path, so it must never reach the matcher. expect(() => ignorePatternRules(['!'])).toThrow(BugError) expect(() => ignorePatternRules(['!'])).toThrow(/nothing after/) expect(() => ignorePatternRules([''])).toThrow(/empty/) 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 e09393a5a20..9fba7bc396c 100644 --- a/packages/app/src/cli/services/app-security-engine/types.ts +++ b/packages/app/src/cli/services/app-security-engine/types.ts @@ -72,13 +72,7 @@ export interface ProjectDetection { languages: DetectedLanguage[] } -/** Caller-supplied inputs that change which files a scan discovers. */ export interface ScanOptions { - /** - * `--ignore` patterns: .gitignore lines relative to the app directory, - * applied in order after the default and gitignore exclusions. They must reach - * every scan of the same review (initial scan and compile) or `input_hash` differs. - */ ignorePatterns?: ReadonlyArray } diff --git a/packages/app/src/cli/services/security-check.ts b/packages/app/src/cli/services/security-check.ts index cdbcd812346..4efc86c8dfd 100644 --- a/packages/app/src/cli/services/security-check.ts +++ b/packages/app/src/cli/services/security-check.ts @@ -40,7 +40,6 @@ interface SecurityOptions { skipInstructions: boolean findingsPath?: string clean: boolean - /** `--ignore` patterns in command-line order; forwarded to the scan and repeated in generated commands. */ ignorePatterns: ReadonlyArray } From 5606f93e51c66637bf0583faba05c8253336efb3 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Wed, 30 Sep 2026 12:06:03 -0700 Subject: [PATCH 3/7] Remove App Security path filter changeset App Security stays out of public release notes until it ships (#8698). --- .changeset/app-security-path-filter.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/app-security-path-filter.md diff --git a/.changeset/app-security-path-filter.md b/.changeset/app-security-path-filter.md deleted file mode 100644 index ef486396253..00000000000 --- a/.changeset/app-security-path-filter.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': patch ---- - -Add `--ignore` to `app security check` and `app security instructions` to ignore files, or include them again, with .gitignore patterns. From 91a8753a9c2cf1c64ea5e94b159c4e7d636958c5 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Wed, 30 Sep 2026 14:15:52 -0700 Subject: [PATCH 4/7] Remove --ignore from app security instructions instructions validated patterns with its own flags, so it accepted values such as --clean that the check commands it generates then read as flags. Nothing passes --ignore to instructions. The instructions check offers after a scan are built from check's own parsed flags, so they still repeat its patterns and always parse. --- .../src/cli/commands/app/security/check.ts | 16 +++++++-- .../src/cli/commands/app/security/flags.ts | 18 ---------- .../app/security/instructions.test.ts | 34 ------------------- .../cli/commands/app/security/instructions.ts | 5 +-- .../app-security-instructions.test.ts | 29 ---------------- .../cli/services/app-security-instructions.ts | 14 +++----- packages/cli/oclif.manifest.json | 11 ++---- 7 files changed, 21 insertions(+), 106 deletions(-) delete mode 100644 packages/app/src/cli/commands/app/security/flags.ts diff --git a/packages/app/src/cli/commands/app/security/check.ts b/packages/app/src/cli/commands/app/security/check.ts index 84e165e653e..a7204dfcea8 100644 --- a/packages/app/src/cli/commands/app/security/check.ts +++ b/packages/app/src/cli/commands/app/security/check.ts @@ -1,9 +1,10 @@ -import {appSecurityFlags} from './flags.js' 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' @@ -28,7 +29,18 @@ In interactive terminals, the command offers to copy the coding-agent instructio ...globalFlags, path: appFlags.path, config: appFlags.config, - ...appSecurityFlags, + // 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.', diff --git a/packages/app/src/cli/commands/app/security/flags.ts b/packages/app/src/cli/commands/app/security/flags.ts deleted file mode 100644 index b71e37211e1..00000000000 --- a/packages/app/src/cli/commands/app/security/flags.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ignorePatternProblem} from '../../../services/app-security-engine/index.js' -import {Flags} from '@oclif/core' -import {AbortError} from '@shopify/cli-kit/node/error' - -/** Shared so the commands `instructions` generates accept exactly what it was given. */ -export const appSecurityFlags = { - // No environment variable: oclif passes a repeatable flag's variable as one string, so it could hold only one pattern. - 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 - }, - }), -} diff --git a/packages/app/src/cli/commands/app/security/instructions.test.ts b/packages/app/src/cli/commands/app/security/instructions.test.ts index d0201bc07b2..26073fd54df 100644 --- a/packages/app/src/cli/commands/app/security/instructions.test.ts +++ b/packages/app/src/cli/commands/app/security/instructions.test.ts @@ -1,11 +1,9 @@ import SecurityInstructions from './instructions.js' -import SecurityCheck from './check.js' import {appFlags} from '../../../flags.js' import deliverAppSecurityInstructions from '../../../services/app-security-instructions.js' import AppLinkedCommand from '../../../utilities/app-linked-command.js' import BaseCommand from '@shopify/cli-kit/node/base-command' import {cwd, 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/app-security-instructions.js') @@ -28,39 +26,9 @@ describe('app security instructions command', () => { configName: undefined, copy: false, writePath: undefined, - ignorePatterns: [], }) }) - test('forwards repeated --ignore patterns in command-line order', async () => { - await SecurityInstructions.run(['--ignore', 'generated/', '--ignore', '!build/'], import.meta.url) - - expect(deliverAppSecurityInstructions).toHaveBeenCalledWith( - expect.objectContaining({ignorePatterns: ['generated/', '!build/']}), - ) - }) - - test('rejects an unusable --ignore pattern', async () => { - const outputMock = mockAndCaptureOutput() - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - try { - await expect(SecurityInstructions.run(['--ignore', '#generated/'], import.meta.url)).rejects.toThrow( - 'process.exit unexpectedly called with "1"', - ) - expect(outputMock.error()).toContain('comment') - expect(deliverAppSecurityInstructions).not.toHaveBeenCalled() - } finally { - consoleErrorSpy.mockRestore() - outputMock.clear() - } - }) - - test('shares the --ignore flag definition with app security check', () => { - expect(SecurityInstructions.flags.ignore).toBe(SecurityCheck.flags.ignore) - expect(SecurityInstructions.descriptionWithMarkdown).toContain('`--ignore`') - }) - test('forwards --path and --copy', async () => { await SecurityInstructions.run(['--path', './fixtures/unlinked-app', '--copy'], import.meta.url) @@ -69,7 +37,6 @@ describe('app security instructions command', () => { configName: undefined, copy: true, writePath: undefined, - ignorePatterns: [], }) }) @@ -81,7 +48,6 @@ describe('app security instructions command', () => { configName: undefined, copy: false, writePath: resolvePath('./instructions.md'), - ignorePatterns: [], }) }) diff --git a/packages/app/src/cli/commands/app/security/instructions.ts b/packages/app/src/cli/commands/app/security/instructions.ts index d8664b35ff5..02f63b7423e 100644 --- a/packages/app/src/cli/commands/app/security/instructions.ts +++ b/packages/app/src/cli/commands/app/security/instructions.ts @@ -1,4 +1,3 @@ -import {appSecurityFlags} from './flags.js' import {appFlags} from '../../../flags.js' import deliverAppSecurityInstructions from '../../../services/app-security-instructions.js' import {Flags} from '@oclif/core' @@ -13,7 +12,7 @@ export default class SecurityInstructions extends BaseCommand { static descriptionWithMarkdown = `Prints the complete workflow that a coding agent should follow to review App Security results. -By default, the instructions are printed to stdout. Use \`--copy\` to copy them to the clipboard or \`--write\` to write them to a file. Standalone instructions always start by running \`shopify app security check\`; only that invocation's generated review pack is trusted as workflow input. \`--config\` values and \`--ignore\` patterns are included in the generated commands.` +By default, the instructions are printed to stdout. Use \`--copy\` to copy them to the clipboard or \`--write\` to write them to a file. Standalone instructions always start by running \`shopify app security check\`; only that invocation's generated review pack is trusted as workflow input.` static description = this.descriptionWithoutMarkdown() @@ -21,7 +20,6 @@ By default, the instructions are printed to stdout. Use \`--copy\` to copy them ...globalFlags, path: appFlags.path, config: appFlags.config, - ...appSecurityFlags, copy: Flags.boolean({ description: 'Copy the instructions to the clipboard instead of printing them.', default: false, @@ -44,7 +42,6 @@ By default, the instructions are printed to stdout. Use \`--copy\` to copy them configName: flags.config, copy: flags.copy, writePath: flags.write, - ignorePatterns: flags.ignore ?? [], }) } } diff --git a/packages/app/src/cli/services/app-security-instructions.test.ts b/packages/app/src/cli/services/app-security-instructions.test.ts index cd53b6760fe..dd4aa518289 100644 --- a/packages/app/src/cli/services/app-security-instructions.test.ts +++ b/packages/app/src/cli/services/app-security-instructions.test.ts @@ -86,24 +86,6 @@ describe('appSecurityInstructions', () => { }) }) - test('repeats --ignore patterns in the scan, compile, and clean commands', async () => { - await inTemporaryDirectory(async (directory) => { - const appRoot = await createApp(directory) - const instructions = appSecurityInstructions({ - directory: appRoot, - scanComplete: false, - ignorePatterns: ['generated/', '!build/'], - }) - const patterns = `--ignore ${shellQuote('generated/')} --ignore ${shellQuote('!build/')}` - - expect(instructions).toContain(`shopify app security check --path ${shellQuote(appRoot)} ${patterns}\n`) - expect(instructions).toContain( - `shopify app security check --path ${shellQuote(appRoot)} ${patterns} --findings ${shellQuote(joinPath(appRoot, '.shopify', 'app-security', 'findings.json'))}`, - ) - expect(instructions).toContain(`shopify app security check --path ${shellQuote(appRoot)} ${patterns} --clean`) - }) - }) - test('starts from existing results after a scan', async () => { await inTemporaryDirectory(async (directory) => { const appRoot = await createApp(directory) @@ -207,17 +189,6 @@ describe('deliverAppSecurityInstructions', () => { }) }) - test('forwards ignorePatterns into the printed commands', async () => { - await inTemporaryDirectory(async (directory) => { - await createApp(directory) - const dependencies = testDependencies() - - await deliverAppSecurityInstructions({directory, copy: false, ignorePatterns: ['generated/']}, dependencies) - - expect(dependencies.output).toHaveBeenCalledWith(expect.stringContaining(`--ignore ${shellQuote('generated/')}`)) - }) - }) - test('does not infer scan completion from an existing review pack', async () => { await inTemporaryDirectory(async (directory) => { await createApp(directory) diff --git a/packages/app/src/cli/services/app-security-instructions.ts b/packages/app/src/cli/services/app-security-instructions.ts index 590667b3c22..a355f83cbf6 100644 --- a/packages/app/src/cli/services/app-security-instructions.ts +++ b/packages/app/src/cli/services/app-security-instructions.ts @@ -44,15 +44,13 @@ function markdownPath(value: string): string { function instructionPaths( directory: string, - commands: AppSecurityCommands | undefined, - configName: string | undefined, - ignorePatterns: ReadonlyArray, + commands?: AppSecurityCommands, + configName?: string, ): AppSecurityInstructionPaths { const appRoot = resolveAppSecurityRoot(resolvePath(directory)) const {artifactDirectory, reviewPath, tracePath, findingsPath} = appSecurityArtifactPaths(appRoot) - // Commands handed over by `security check` already carry its --config values and --ignore patterns. const resolvedCommands = - commands ?? resolveAppSecurityCommands(appRoot, requireSecurityConfigFileName(appRoot, configName), ignorePatterns) + commands ?? resolveAppSecurityCommands(appRoot, requireSecurityConfigFileName(appRoot, configName)) return { appRoot, commands: resolvedCommands, @@ -95,8 +93,6 @@ interface AppSecurityInstructionsOptions { scanComplete?: boolean commands?: AppSecurityCommands configName?: string - /** `--ignore` patterns to repeat in the generated commands when `commands` are not supplied. */ - ignorePatterns?: ReadonlyArray } interface AppSecurityInstructionsDependencies { @@ -120,9 +116,8 @@ export function appSecurityInstructions(options: { scanComplete: boolean commands?: AppSecurityCommands configName?: string - ignorePatterns?: ReadonlyArray }): string { - const paths = instructionPaths(options.directory, options.commands, options.configName, options.ignorePatterns ?? []) + const paths = instructionPaths(options.directory, options.commands, options.configName) const scanContext = options.scanComplete ? completedScanInstructions(paths) : initialScanInstructions(paths) return getAgentInstructions() .replace(SCAN_CONTEXT_PLACEHOLDER, scanContext) @@ -144,7 +139,6 @@ export default async function deliverAppSecurityInstructions( scanComplete: options.scanComplete ?? false, commands: options.commands, configName: options.configName, - ignorePatterns: options.ignorePatterns, }) if (options.copy) { diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1f0f3b5f69c..89a92620102 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3795,8 +3795,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input. `--config` values and `--ignore` patterns are included in the generated commands.", - "descriptionWithMarkdown": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input. `--config` values and `--ignore` patterns are included in the generated commands.", + "description": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input.", + "descriptionWithMarkdown": "Prints the complete workflow that a coding agent should follow to review App Security results.\n\nBy default, the instructions are printed to stdout. Use `--copy` to copy them to the clipboard or `--write` to write them to a file. Standalone instructions always start by running `shopify app security check`; only that invocation's generated review pack is trusted as workflow input.", "enableJsonFlag": false, "flags": { "config": { @@ -3819,13 +3819,6 @@ "name": "copy", "type": "boolean" }, - "ignore": { - "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.", - "hasDynamicHelp": false, - "multiple": true, - "name": "ignore", - "type": "option" - }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", From c0422b21bc937abed5da933a131eebfb9553256d Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Wed, 30 Sep 2026 14:16:25 -0700 Subject: [PATCH 5/7] Stop exporting AppSecurityArgument Only app-security-commands.ts uses it, and Knip rejects the unused export. --- packages/app/src/cli/services/app-security-commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/app-security-commands.ts b/packages/app/src/cli/services/app-security-commands.ts index 83a2375e778..2e5cc2d320f 100644 --- a/packages/app/src/cli/services/app-security-commands.ts +++ b/packages/app/src/cli/services/app-security-commands.ts @@ -4,7 +4,7 @@ import {getAppConfigurationShorthand} from '../models/app/config-file-naming.js' export type AppSecurityShell = 'posix' | 'cmd' | 'powershell' /** Strings are command syntax, printed bare. Flag values are user input, so they're always quoted. */ -export type AppSecurityArgument = string | {flag: string; value: string} +type AppSecurityArgument = string | {flag: string; value: string} export interface AppSecurityCommand { command: string From fec5d9235f01154b44335a4cc4a6ce67eea02eb2 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Wed, 30 Sep 2026 14:16:46 -0700 Subject: [PATCH 6/7] Say that findings are rejected on an input hash mismatch Different --ignore patterns change the hash only when they change which files are scanned. --- packages/app/src/cli/services/app-security-engine/run.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 966478e6a38..b4e1699bc36 100644 --- a/packages/app/src/cli/services/app-security-engine/run.ts +++ b/packages/app/src/cli/services/app-security-engine/run.ts @@ -139,7 +139,8 @@ export async function compileFindings( const result = await scan(appRoot, configFileName, options) const engineVersion = getEngineVersion() const knownFiles = new Set(searchBoundaryFiles(result)) - // Ignore patterns change the input hash but aren't in the trace, so the hint covers both causes. + // Rejects findings when the current input hash differs from their source_scan_id. Ignore patterns + // aren't recorded in the trace, so the hint names them as a likely cause. const provenanceRejected = document.source_scan_id === result.scan.input_hash ? [] From 255443bbe9d7ca68dd61770df57139ea2d85a103 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Wed, 30 Sep 2026 14:17:46 -0700 Subject: [PATCH 7/7] Pin --ignore overrides for ignored nested repositories and the selected config --ignore '!inner/' re-includes a nested repository that the app's repository ignores. A pattern that excludes the selected app configuration doesn't stop it from being scanned for secrets. --- .../tests/discovery-safety.test.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts b/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts index 99a979aaeb2..24eeec38110 100644 --- a/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts +++ b/packages/app/src/cli/services/app-security-engine/tests/discovery-safety.test.ts @@ -648,6 +648,22 @@ describe('--ignore patterns', () => { expect(hashedPaths(await scan(root, undefined, {ignorePatterns: ['!tmp/']}))).toContain('tmp/scratch.ts') }) + test('re-includes a nested repository that the app repository ignores', async () => { + const secret = ['shp', `at_${'0123456789abcdef'.repeat(2)}`].join('') + const root = await makeRepository({ + 'shopify.app.toml': appConfiguration, + '.gitignore': 'inner/\n', + 'inner/token.ts': `export const token = '${secret}'\n`, + }) + const inner = join(root, 'inner') + git(inner, ['init', '-q', '.']) + git(inner, ['add', 'token.ts']) + git(inner, ['commit', '-qm', 'Add token']) + + expect(secretFindingFiles(await scan(root))).toEqual([]) + expect(secretFindingFiles(await scan(root, undefined, {ignorePatterns: ['!inner/']}))).toEqual(['inner/token.ts']) + }) + test('cannot re-include a file inside a gitignored folder without re-including the folder', async () => { const root = await makeRepository({ 'shopify.app.toml': appConfiguration, @@ -689,16 +705,18 @@ describe('--ignore patterns', () => { expect(includeThenExclude).not.toContain('generated/client.ts') }) - test('never stops the selected app configuration from loading', async () => { + test('never stops the selected app configuration from loading or being scanned for secrets', async () => { + const secret = ['shp', `at_${'0123456789abcdef'.repeat(2)}`].join('') const root = await makeDirectory() await writeFiles(root, { 'shopify.app.toml': appConfiguration, - 'shopify.app.staging.toml': 'name = "Staging"\napplication_url = "https://staging.example.com"\n', + 'shopify.app.staging.toml': `name = "Staging"\napplication_url = "https://staging.example.com/?token=${secret}"\n`, }) const result = await scan(root, 'staging', {ignorePatterns: ['shopify.app*.toml']}) expect(result.app.name).toBe('Staging') expect(hashedPaths(result)).toContain('shopify.app.staging.toml') + expect(secretFindingFiles(result)).toEqual(['shopify.app.staging.toml']) }) })