diff --git a/.changeset/add-app-doctor-circleci-dependency-auditing.md b/.changeset/add-app-doctor-circleci-dependency-auditing.md new file mode 100644 index 00000000000..a03540e1cb0 --- /dev/null +++ b/.changeset/add-app-doctor-circleci-dependency-auditing.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Recognize CircleCI dependency vulnerability auditing configuration in App Doctor. diff --git a/packages/app/src/cli/services/app-doctor-engine/rules/dependency-auditing-circle-schema.ts b/packages/app/src/cli/services/app-doctor-engine/rules/dependency-auditing-circle-schema.ts new file mode 100644 index 00000000000..1024f624bd3 --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/rules/dependency-auditing-circle-schema.ts @@ -0,0 +1,50 @@ +import {zod} from '@shopify/cli-kit/node/schema' + +export const CircleConfigurationHeaderSchema = zod.object({workflows: zod.unknown()}) +export const CircleConfigurationSchema = zod.object({ + jobs: zod + .record(zod.unknown()) + .nullish() + .transform((jobs) => jobs ?? {}), + workflows: zod.record(zod.unknown()), + commands: zod.record(zod.unknown()).optional(), + // Unsupported orb declarations are not evidence; invoked unknown aliases are unresolved. + orbs: zod.record(zod.unknown()).catch({}), +}) + +export const CircleWorkflowHeaderSchema = zod.object({when: zod.unknown()}) +export const CircleWorkflowSchema = zod.object({jobs: zod.array(zod.unknown())}) + +const NamedInvocationSchema = zod.record(zod.unknown()).refine((value) => Object.keys(value).length === 1) +export const CircleJobInvocationSchema = zod.union([ + zod.string(), + NamedInvocationSchema.transform((invocation) => Object.keys(invocation)[0]!), +]) +export const CircleStepSchema = NamedInvocationSchema.transform((step) => { + const [name, settings] = Object.entries(step)[0]! + return {name, settings} +}) + +export const CircleJobSchema = zod.object({ + working_directory: zod.unknown(), + // Only workflow-invoked jobs and their executed steps are inspected. + steps: zod.array(zod.unknown()), +}) +export const CircleCheckoutSchema = zod.object({checkout: zod.object({path: zod.unknown()})}) +export const CircleWhenStepHeaderSchema = zod.object({when: zod.unknown()}) +export const CircleWhenStepSchema = zod.object({condition: zod.unknown(), steps: zod.array(zod.unknown())}) +export const CircleRunHeaderSchema = zod.object({when: zod.unknown()}) +export const CircleRunCommandSchema = zod.object({command: zod.string(), working_directory: zod.unknown()}) + +export const CircleSnykSettingsSchema = zod + .object({ + 'package-manager': zod.string().optional(), + // Select the target only after establishing that the package manager is supported. + 'target-file': zod.unknown(), + file: zod.unknown(), + }) + .nullish() + .transform((settings) => settings ?? {}) +export const CircleSnykTargetSchema = zod.string().optional() + +export type CircleJob = zod.infer diff --git a/packages/app/src/cli/services/app-doctor-engine/rules/dependency-auditing-rules.ts b/packages/app/src/cli/services/app-doctor-engine/rules/dependency-auditing-rules.ts index 4b6699269d5..896247e9161 100644 --- a/packages/app/src/cli/services/app-doctor-engine/rules/dependency-auditing-rules.ts +++ b/packages/app/src/cli/services/app-doctor-engine/rules/dependency-auditing-rules.ts @@ -23,7 +23,24 @@ import { GitLabScriptsSchema, GitLabVariablesSchema, } from './dependency-auditing-gitlab-schema.js' +import { + CircleCheckoutSchema, + CircleConfigurationHeaderSchema, + CircleConfigurationSchema, + CircleJobInvocationSchema, + CircleJobSchema, + CircleRunCommandSchema, + CircleRunHeaderSchema, + CircleSnykSettingsSchema, + CircleSnykTargetSchema, + CircleStepSchema, + CircleWhenStepHeaderSchema, + CircleWhenStepSchema, + CircleWorkflowHeaderSchema, + CircleWorkflowSchema, +} from './dependency-auditing-circle-schema.js' import {parseDocument} from 'yaml' +import type {CircleJob} from './dependency-auditing-circle-schema.js' import type {GitHubActionStep, GitHubDefaults, GitHubRunStep} from './dependency-auditing-github-schema.js' import type {Issue} from '../types.js' import type {ManifestFile, ScanContext, SourceFile} from './types.js' @@ -81,6 +98,17 @@ const SHELL_CONTROL_WORDS = new Set([ 'until', 'while', ]) +const CIRCLE_BUILT_IN_STEPS = new Set([ + 'add_ssh_keys', + 'attach_workspace', + 'checkout', + 'persist_to_workspace', + 'restore_cache', + 'save_cache', + 'setup_remote_docker', + 'store_artifacts', + 'store_test_results', +]) const MAX_YAML_ALIASES = 20 /** @@ -149,6 +177,9 @@ function analyzeConfiguration( if (/(?:^|\/)\.gitlab-ci\.ya?ml$/i.test(path)) { return combine(warnings, analyzeGitLabConfiguration(parsed.value, path, manifests)) } + if (/^\.circleci\/config\.ya?ml$/i.test(path)) { + return combine(warnings, analyzeCircleConfiguration(parsed.value, path, manifests)) + } return warnings } @@ -400,6 +431,166 @@ function analyzeGitLabIncludes(value: unknown, path: string, dependencyScanningD return analysis } +function analyzeCircleConfiguration(value: unknown, path: string, manifests: ManifestFile[]): Analysis { + const header = CircleConfigurationHeaderSchema.safeParse(value) + if (!header.success) return obstacle(`CircleCI configuration has an unsupported structure: ${path}`) + if (header.data.workflows === undefined) return noEvidence() + const configuration = CircleConfigurationSchema.safeParse(value) + if (!configuration.success) return obstacle(`CircleCI configuration has unsupported jobs or workflows: ${path}`) + const {jobs, workflows, commands, orbs} = configuration.data + + const snykAliases = new Set( + Object.entries(orbs) + .filter(([, orb]) => typeof orb === 'string' && /^snyk\/snyk@[^\s]+$/i.test(orb)) + .map(([alias]) => alias), + ) + const customCommands = new Set(Object.keys(commands ?? {})) + let analysis = noEvidence() + for (const [workflowName, workflowValue] of Object.entries(workflows)) { + if (workflowName === 'version') continue + const workflowHeader = CircleWorkflowHeaderSchema.safeParse(workflowValue) + if (!workflowHeader.success) { + analysis = combine(analysis, obstacle(`CircleCI workflow has an unsupported structure in ${path}`)) + continue + } + if (isDisabled(workflowHeader.data.when)) continue + const workflow = CircleWorkflowSchema.safeParse(workflowValue) + if (!workflow.success) { + analysis = combine(analysis, obstacle(`CircleCI workflow has unsupported jobs in ${path}`)) + continue + } + for (const invocation of workflow.data.jobs) { + const parsedInvocation = CircleJobInvocationSchema.safeParse(invocation) + if (!parsedInvocation.success) { + analysis = combine(analysis, obstacle(`CircleCI workflow has an unsupported job invocation in ${path}`)) + continue + } + const jobName = parsedInvocation.data + if (!jobName) continue + if (jobName.includes('/')) { + analysis = combine(analysis, obstacle(`Unsupported CircleCI orb job referenced by ${path}`)) + continue + } + const job = CircleJobSchema.safeParse(jobs[jobName]) + if (!job.success) { + analysis = combine(analysis, obstacle(`Unknown CircleCI job referenced by ${path}`)) + continue + } + analysis = combine(analysis, analyzeCircleJob(job.data, snykAliases, customCommands, path, manifests)) + } + } + return analysis +} + +function analyzeCircleJob( + job: CircleJob, + snykAliases: Set, + customCommands: Set, + path: string, + manifests: ManifestFile[], +): Analysis { + const directory = resolveCircleDirectory(job.working_directory) + if (!directory.ok) return obstacle(`Unsupported CircleCI working_directory in ${path}`) + if ( + job.steps.some((step) => { + const checkout = CircleCheckoutSchema.safeParse(step) + return checkout.success && checkout.data.checkout.path !== undefined + }) + ) { + return obstacle(`Unsupported CircleCI checkout path in ${path}`) + } + + let analysis = noEvidence() + for (const stepValue of job.steps) { + const header = CircleWhenStepHeaderSchema.safeParse(stepValue) + if (header.success && header.data.when !== undefined) { + const when = CircleWhenStepSchema.safeParse(header.data.when) + if (!when.success) { + analysis = combine(analysis, obstacle(`CircleCI when step has an unsupported structure in ${path}`)) + continue + } + if (isDisabled(when.data.condition)) continue + for (const nestedStep of when.data.steps) { + analysis = combine( + analysis, + analyzeCircleStep(nestedStep, directory.value, snykAliases, customCommands, path, manifests), + ) + } + continue + } + analysis = combine( + analysis, + analyzeCircleStep(stepValue, directory.value, snykAliases, customCommands, path, manifests), + ) + } + return analysis +} + +function analyzeCircleStep( + stepValue: unknown, + directory: string, + snykAliases: Set, + customCommands: Set, + path: string, + manifests: ManifestFile[], +): Analysis { + if (typeof stepValue === 'string') { + if (CIRCLE_BUILT_IN_STEPS.has(stepValue)) return noEvidence() + const [alias, command] = stepValue.split('/') + if (alias && command) { + if (command === 'scan' && snykAliases.has(alias)) { + return targetCoversManifest(directory, undefined, manifests) ? evidence() : noEvidence() + } + return obstacle(`Unsupported CircleCI orb command referenced by ${path}`) + } + return obstacle(`Unsupported CircleCI custom command referenced by ${path}`) + } + const step = CircleStepSchema.safeParse(stepValue) + if (!step.success) return obstacle(`CircleCI job has an unsupported step in ${path}`) + const {name, settings: settingsValue} = step.data + if (name === 'run' && settingsValue !== undefined) { + const runValue = typeof settingsValue === 'string' ? {command: settingsValue} : settingsValue + const header = CircleRunHeaderSchema.safeParse(runValue) + if (!header.success) return obstacle(`CircleCI run step has an unsupported structure in ${path}`) + if (header.data.when === 'never') return noEvidence() + const run = CircleRunCommandSchema.safeParse(runValue) + if (!run.success) return obstacle(`CircleCI run command has an unsupported structure in ${path}`) + const runDirectory = + run.data.working_directory === undefined + ? {ok: true as const, value: directory} + : resolveCircleDirectory(run.data.working_directory) + if (!runDirectory.ok) return obstacle(`Unsupported CircleCI run working_directory in ${path}`) + return analyzeCommands(run.data.command, {directory: runDirectory.value, manifests, allowPackageScript: true}, path) + } + + if (CIRCLE_BUILT_IN_STEPS.has(name)) return noEvidence() + const [alias, command] = name.split('/') + if (!alias || !command) { + return customCommands.has(name) + ? obstacle(`Unsupported CircleCI custom command referenced by ${path}`) + : obstacle(`Unknown CircleCI command referenced by ${path}`) + } + if (!snykAliases.has(alias) || command !== 'scan') { + return obstacle(`Unsupported CircleCI orb command referenced by ${path}`) + } + const settings = CircleSnykSettingsSchema.safeParse(settingsValue) + if (!settings.success) return obstacle(`CircleCI Snyk settings have an unsupported structure in ${path}`) + const packageManagerValue = settings.data['package-manager'] + if (packageManagerValue !== undefined) { + if (packageManagerValue.length === 0 || isDynamic(packageManagerValue)) { + return obstacle(`CircleCI Snyk package manager has an unsupported value in ${path}`) + } + if (!SUPPORTED_PACKAGE_MANAGERS.has(packageManagerValue.toLowerCase())) return noEvidence() + } + const target = CircleSnykTargetSchema.safeParse(settings.data['target-file'] ?? settings.data.file) + if (!target.success) return obstacle(`CircleCI Snyk target has an unsupported structure in ${path}`) + const targetValue = target.data + if (targetValue !== undefined && (!targetValue || !isSafeRelativePath(targetValue))) { + return obstacle(`Unsupported CircleCI Snyk target prevents auditing analysis in ${path}`) + } + return targetCoversManifest(directory, targetValue, manifests) ? evidence() : noEvidence() +} + function analyzeCommands(command: string, context: CommandContext, configurationPath: string): Analysis { const parsedShell = splitShell(command) if (!parsedShell.ok) return obstacle(`Unsupported shell syntax referenced by ${configurationPath}`) @@ -685,6 +876,15 @@ function resolveSelectedDirectory(value: unknown): ResolvedValue { return {ok: true, value: normalizeDirectory(value)} } +function resolveCircleDirectory(value: unknown): ResolvedValue { + if (value === undefined) return {ok: true, value: ''} + if (typeof value !== 'string' || isDynamic(value)) return {ok: false} + const normalized = normalizePath(value) + if (normalized !== '~/project' && !normalized.startsWith('~/project/')) return {ok: false} + const relative = normalized === '~/project' ? '' : normalized.slice('~/project/'.length) + return isSafeRelativePath(relative) ? {ok: true, value: normalizeDirectory(relative)} : {ok: false} +} + function normalizeDirectory(value: string): string { return normalizePath(value) .split('/') diff --git a/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts b/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts index 2cbfe1b80fb..c4f1ad5b28f 100644 --- a/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts +++ b/packages/app/src/cli/services/app-doctor-engine/scanners/discover.ts @@ -749,7 +749,7 @@ function nestedRepositoryReason(appRoot: string): string | undefined { } } -const DEPENDENCY_AUDITING_FILES = ['.gitlab-ci.yml'] +const DEPENDENCY_AUDITING_FILES = ['.gitlab-ci.yml', '.circleci/config.yml', '.circleci/config.yaml'] /** Discover only repository CI files that can explicitly invoke dependency auditing. */ export function findDependencyAuditingInputs(appRoot: string): DependencyAuditingInputs { diff --git a/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts b/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts index 3f8527488b7..d7c4b27e2f9 100644 --- a/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts +++ b/packages/app/src/cli/services/app-doctor-engine/scanners/index.ts @@ -549,7 +549,8 @@ function skippedInputsForCheck( const isDependencyAuditingInput = (path: string) => /(^|\/)package\.json$/.test(path) || /^\.github\/workflows\/[^/]+\.ya?ml$/i.test(path) || - /^\.gitlab-ci\.ya?ml$/i.test(path) + /^\.gitlab-ci\.ya?ml$/i.test(path) || + /^\.circleci\/config\.ya?ml$/i.test(path) const isSecretInput = (file: SkippedFile) => !file.detail?.includes('could not be parsed') && (context.sourceCandidates.some((candidate) => candidate.path === file.path) || diff --git a/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-discovery.test.ts b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-discovery.test.ts index 16a3f242271..a155361c729 100644 --- a/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-discovery.test.ts +++ b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-discovery.test.ts @@ -45,6 +45,8 @@ describe('dependency auditing input discovery', () => { '.github/workflows/release.yaml': 'name: release\n', '.github/workflows/ignored.json': '{}', '.gitlab-ci.yml': 'audit:\n script: npm audit\n', + '.circleci/config.yml': 'version: 2.1\n', + '.circleci/other.yml': 'not: allowlisted\n', '.hidden/config.yml': 'not: allowlisted\n', }) @@ -52,6 +54,7 @@ describe('dependency auditing input discovery', () => { expect(result.unresolvedReason).toBeUndefined() expect(result.files.map(({path}) => path)).toEqual([ + '.circleci/config.yml', '.github/workflows/dependencies.yml', '.github/workflows/release.yaml', '.gitlab-ci.yml', diff --git a/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-rules.test.ts b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-rules.test.ts index 0c8a75d03e3..4f3d33cd27e 100644 --- a/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-rules.test.ts +++ b/packages/app/src/cli/services/app-doctor-engine/tests/dependency-auditing-rules.test.ts @@ -28,6 +28,7 @@ ${body}`, ) const gitlab = (content: string): SourceFile => configuration('.gitlab-ci.yml', content) +const circle = (content: string): SourceFile => configuration('.circleci/config.yml', content) function scan(files: SourceFile[], manifests: ManifestFile[] = [manifest()]) { return scanDependencyAuditing({manifests, dependencyAuditing: {files}}) @@ -485,6 +486,163 @@ describe('GitLab dependency scanning', () => { }) }) +describe('CircleCI dependency scanning', () => { + const config = (workflowJobs: string, jobs: string, orbs = ' security: snyk/snyk@2.1.0') => + circle(`version: 2.1 +orbs: +${orbs} +jobs: +${jobs} +workflows: + checks: + jobs: +${workflowJobs}`) + + test('resolves an arbitrary Snyk orb alias in a workflow-invoked job', () => { + expectRecognized([ + config( + ' - audit', + ' audit:\n steps:\n - security/scan:\n package-manager: npm\n target-file: package.json', + ), + ]) + }) + + test('recognizes a scalar Snyk scan in an invoked job', () => { + expectRecognized([config(' - audit', ' audit:\n steps:\n - security/scan')]) + }) + + test('recognizes an explicit run command in an invoked job', () => { + expectRecognized([config(' - audit', ' audit:\n steps:\n - run: yarn npm audit')]) + }) + + test('does not use an unused orb, alias, or job as evidence', () => { + expectMissing([ + config( + ' - build', + ' build:\n steps:\n - run: npm test\n audit:\n steps:\n - security/scan', + ), + ]) + }) + + test('matches CircleCI target files and package managers to an actual manifest', () => { + const file = config( + ' - audit', + ' audit:\n working_directory: ~/project/packages/web\n steps:\n - security/scan:\n package-manager: pnpm\n target-file: package.json', + ) + expectRecognized([file], [manifest('packages/web/package.json')]) + expectMissing([file], [manifest('packages/admin/package.json')]) + + const unrelated = config( + ' - audit', + ' audit:\n steps:\n - security/scan:\n package-manager: pip\n target-file: requirements.txt', + ) + expectMissing([unrelated]) + }) + + test('leaves invoked custom and non-Snyk orb commands unresolved', () => { + expectUnresolved([ + circle( + 'version: 2.1\ncommands:\n security:\n steps:\n - run: npm audit\njobs:\n build:\n steps:\n - security\nworkflows:\n checks:\n jobs:\n - build', + ), + ]) + expectUnresolved([ + config(' - audit', ' audit:\n steps:\n - other/scan', ' other: acme/security@1.0.0'), + ]) + }) + + test('ignores unused custom commands and non-audit tools', () => { + expectMissing([ + circle( + 'version: 2.1\ncommands:\n security:\n steps:\n - run: npm audit\njobs:\n build:\n steps:\n - run: gitleaks detect\nworkflows:\n checks:\n jobs:\n - build', + ), + ]) + }) + + test('does not recognize disabled workflows or run steps', () => { + expectMissing([ + circle( + 'version: 2.1\njobs:\n audit:\n steps:\n - run: npm audit\nworkflows:\n checks:\n when: false\n jobs:\n - audit', + ), + ]) + expectMissing([ + config( + ' - audit', + ' audit:\n steps:\n - run:\n when: never\n command: npm audit', + ), + ]) + }) + + test.each([ + ' working_directory: $PROJECT_DIR', + ' working_directory: /tmp', + ' working_directory: ~/other/project', + ])('leaves unsupported CircleCI job directory unresolved', (workingDirectory) => { + expectUnresolved([config(' - audit', ` audit:\n${workingDirectory}\n steps:\n - run: npm audit`)]) + }) + + test('leaves unsupported CircleCI run directory unresolved', () => { + expectUnresolved([ + config( + ' - audit', + ' audit:\n steps:\n - run:\n working_directory: ../\n command: npm audit', + ), + ]) + }) + + test.each([ + 'jobs: invalid\ncommands: invalid', + 'workflows:\n checks:\n when: false\n jobs: invalid', + 'jobs:\n build:\n steps:\n - run: npm test\n unused: invalid\nworkflows:\n checks:\n jobs: [build]', + ])('does not validate configuration outside invoked workflows: %s', (content) => { + expectMissing([circle(content)]) + }) + + test.each([ + ' - run:\n when: never\n command: [invalid]\n working_directory: [invalid]', + ' - when:\n condition: false\n steps:\n - run: [invalid]', + ' - security/scan:\n package-manager: pip\n target-file: [invalid]', + ])('does not validate disabled execution or unsupported ecosystems: %s', (steps) => { + expectMissing([config(' - audit', ` audit:\n steps:\n${steps}`)]) + }) + + test.each([ + ' - security/scan:\n target-file: package.json\n file: [invalid]\n monitor-on-build: false', + ' - run: {command: npm audit, name: audit dependencies}', + ' - when:\n condition: true\n steps:\n - run: npm audit', + ' - run: [invalid]\n - run: npm audit', + ])('retains selected targets, nested execution, and independent evidence: %s', (steps) => { + expectRecognized([config(' - audit', ` audit:\n steps:\n${steps}`)]) + }) + + test.each([' - {audit: {}, build: {}}', ' - {}', ' - null'])( + 'rejects malformed job invocations without stripping keys: %s', + (invocation) => { + expectUnresolved([config(invocation, ' audit:\n steps:\n - run: npm audit')]) + }, + ) + + test.each([ + ' - run: npm audit\n unexpected: true', + ' - when:\n condition: false\n steps: invalid', + ])('retains structural obstacles in steps: %s', (steps) => { + expectUnresolved([config(' - audit', ` audit:\n steps:\n${steps}`)]) + }) + + test('does not treat unsupported orb declarations as a reason to discard independent evidence', () => { + expectRecognized([ + circle( + 'orbs: invalid\njobs:\n audit:\n steps:\n - run: npm audit\nworkflows:\n checks:\n jobs:\n - audit: {name: dependency audit}', + ), + ]) + }) + + test('treats malformed jobs, steps, and run commands as unresolved', () => { + expectUnresolved([config(' - audit', ' audit: invalid')]) + expectUnresolved([config(' - audit', ' audit:\n steps: invalid')]) + expectUnresolved([config(' - audit', ' audit:\n steps:\n - run:\n command: [npm, audit]')]) + }) +}) + describe('execution boundaries', () => { test.each(['repository: other/project', 'path: other'])('leaves a scoped checkout unresolved (%s)', (input) => { const file = github( @@ -506,6 +664,19 @@ describe('execution boundaries', () => { expectMissing([file]) }) + test('does not lose unknown CircleCI orb jobs when there are no local jobs', () => { + const file = circle( + 'version: 2.1\norbs:\n security: example/security@1\nworkflows:\n audit:\n jobs:\n - security/audit', + ) + expect(scan([file])).toMatchObject({issues: [], unresolvedReason: expect.any(String)}) + }) + + test('does not attribute a scoped CircleCI checkout to the app root', () => { + const file = circle( + 'version: 2.1\njobs:\n audit:\n steps:\n - checkout:\n path: other\n - run: npm audit\nworkflows:\n checks:\n jobs:\n - audit', + ) + expect(scan([file])).toMatchObject({issues: [], unresolvedReason: expect.any(String)}) + }) test.each([ ['quoted heredoc', "cat <<'EOF'\nnpm audit\nEOF"], ['function definition', 'audit() {\n npm audit\n}'], @@ -543,6 +714,7 @@ describe('configuration obstacles', () => { test.each([ ['malformed GitHub workflow', configuration('.github/workflows/ci.yml', 'jobs: [')], ['malformed GitLab configuration', gitlab('include: [')], + ['malformed CircleCI configuration', circle('jobs: {')], [ 'excessive YAML aliases', github( @@ -561,6 +733,10 @@ describe('configuration obstacles', () => { ), ], ['unknown local GitHub action', github(' steps:\n - uses: ./.github/actions/security')], + [ + 'unknown CircleCI invoked job', + circle('version: 2.1\njobs: {}\nworkflows:\n checks:\n jobs:\n - delegated'), + ], ])('returns no finding and an unresolved reason for %s', (_name, file) => { const result = scan([file]) expect(result.issues).toEqual([])