From 1f0915adade3bbd73ecc95a48c57eba2ec97e3b0 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Tue, 20 Oct 2026 10:17:02 -0700 Subject: [PATCH 1/2] Move the app security record presenter to security-record/result.ts The record result's JSON codec and presenter will live in security-record/, beside its schema, as for clean. The text output doesn't change. --- .../cli/commands/app/security/record.test.ts | 4 +- .../src/cli/commands/app/security/record.ts | 3 +- .../src/cli/services/security-record.test.ts | 22 +--------- .../app/src/cli/services/security-record.ts | 21 +--------- .../services/security-record/result.test.ts | 41 +++++++++++++++++++ .../cli/services/security-record/result.ts | 22 ++++++++++ 6 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 packages/app/src/cli/services/security-record/result.test.ts create mode 100644 packages/app/src/cli/services/security-record/result.ts diff --git a/packages/app/src/cli/commands/app/security/record.test.ts b/packages/app/src/cli/commands/app/security/record.test.ts index a153216ab2c..c8d7111c243 100644 --- a/packages/app/src/cli/commands/app/security/record.test.ts +++ b/packages/app/src/cli/commands/app/security/record.test.ts @@ -4,7 +4,8 @@ import {appFlags} from '../../../flags.js' import {appSecurityArtifactPaths} from '../../../services/app-security-artifacts.js' import {resolveAppSecuritySelection} from '../../../services/app-security-selection.js' import {validAppConfiguration} from '../../../services/app-security-selection.test-data.js' -import securityRecord, {renderSecurityRecordResult} from '../../../services/security-record.js' +import securityRecord from '../../../services/security-record.js' +import {renderSecurityRecordResult} from '../../../services/security-record/result.js' import AppLinkedCommand from '../../../utilities/app-linked-command.js' import BaseCommand from '@shopify/cli-kit/node/base-command' import {AbortError} from '@shopify/cli-kit/node/error' @@ -14,6 +15,7 @@ import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {describe, expect, test, vi} from 'vitest' vi.mock('../../../services/security-record.js') +vi.mock('../../../services/security-record/result.js') vi.mock('../../../services/app-security-selection.js', async (importOriginal) => ({ ...(await importOriginal()), resolveAppSecuritySelection: vi.fn(), diff --git a/packages/app/src/cli/commands/app/security/record.ts b/packages/app/src/cli/commands/app/security/record.ts index e5dbc6d8c9b..1719478bdf7 100644 --- a/packages/app/src/cli/commands/app/security/record.ts +++ b/packages/app/src/cli/commands/app/security/record.ts @@ -1,7 +1,8 @@ import {appSecuritySelectionFlags} from './selection-flags.js' import {requireResultsDirectory} from '../../../services/app-security-results.js' import {resolveAppSecuritySelection} from '../../../services/app-security-selection.js' -import securityRecord, {renderSecurityRecordResult} from '../../../services/security-record.js' +import securityRecord from '../../../services/security-record.js' +import {renderSecurityRecordResult} from '../../../services/security-record/result.js' import BaseCommand from '@shopify/cli-kit/node/base-command' import {globalFlags} from '@shopify/cli-kit/node/cli' diff --git a/packages/app/src/cli/services/security-record.test.ts b/packages/app/src/cli/services/security-record.test.ts index 4da84dd3f2f..8d4cd965fce 100644 --- a/packages/app/src/cli/services/security-record.test.ts +++ b/packages/app/src/cli/services/security-record.test.ts @@ -1,4 +1,4 @@ -import securityRecord, {renderSecurityRecordResult} from './security-record.js' +import securityRecord from './security-record.js' import {appSecurityArtifactPaths, readFindingsDocument, writeAgentFindings} from './app-security-artifacts.js' import {formatAppSecurityCommand, resolveAppSecurityCommands} from './app-security-commands.js' import { @@ -442,23 +442,3 @@ describe('securityRecord', () => { await expectRejected('{"schema_version": 1,', [expect.stringContaining('The findings document is not valid JSON')]) }) }) - -describe('renderSecurityRecordResult', () => { - test('shows the counts, the recorded path, and how to review the results', async () => { - await inTemporaryDirectory(async (directory) => { - const appRoot = await createApp(directory) - const path = artifactPaths(appRoot).agentFindingsPath - const output = mockAndCaptureOutput() - output.clear() - - renderSecurityRecordResult({path, checks: 1, findings: 2}, selectionFor(appRoot), appRoot) - - const rendered = output.info() - expect(rendered).toContain('Agent findings recorded.') - expect(rendered).toContain('Recorded 1 check and 2 findings in') - expect(rendered).toContain('agent-findings.json') - expect(rendered).toContain('shopify app security review --path') - output.clear() - }) - }) -}) diff --git a/packages/app/src/cli/services/security-record.ts b/packages/app/src/cli/services/security-record.ts index adfb76f6686..dad64742910 100644 --- a/packages/app/src/cli/services/security-record.ts +++ b/packages/app/src/cli/services/security-record.ts @@ -1,18 +1,16 @@ import {encodedArtifactSize, MAX_ARTIFACT_FILE_SIZE_BYTES, writeAgentFindings} from './app-security-artifacts.js' import {formatAppSecurityCommand, resolveAppSecurityCommands} from './app-security-commands.js' -import {countLabel} from './app-security-format.js' import {recordAppSecurityMetadata, type AppSecurityMetadata} from './app-security-metadata.js' import {getEngineVersion, recordAgentFindings, type AgentFindingsDocument} from './app-security-engine/index.js' import {resultsKey, type AppSecuritySelection} from './app-security-selection.js' import {AbortError} from '@shopify/cli-kit/node/error' import {readStdinString} from '@shopify/cli-kit/node/system' -import {renderSuccess} from '@shopify/cli-kit/node/ui' import type {AppSecurityCommands} from './app-security-commands.js' const MAX_FINDINGS_DOCUMENT_BYTES = 5_000_000 /** A recorded findings document: where it was written and what it holds. */ -interface SecurityRecordResult { +export interface SecurityRecordResult { path: string checks: number findings: number @@ -125,20 +123,3 @@ export default async function securityRecord( await dependencies.recordMetadata({num_security_findings: findings}) return {path, checks: recorded.document.checks.length, findings} } - -/** Presents a recorded document in the terminal. */ -export function renderSecurityRecordResult( - result: SecurityRecordResult, - selection: AppSecuritySelection, - path: string, -): void { - const commands = resolveAppSecurityCommands(selection, path) - renderSuccess({ - headline: 'Agent findings recorded.', - body: [ - `Recorded ${countLabel(result.checks, 'check')} and ${countLabel(result.findings, 'finding')} in`, - {filePath: result.path}, - ], - nextSteps: [['Run', {command: formatAppSecurityCommand(commands.review)}, 'to see the results.']], - }) -} diff --git a/packages/app/src/cli/services/security-record/result.test.ts b/packages/app/src/cli/services/security-record/result.test.ts new file mode 100644 index 00000000000..fa960bea9d7 --- /dev/null +++ b/packages/app/src/cli/services/security-record/result.test.ts @@ -0,0 +1,41 @@ +import {renderSecurityRecordResult} from './result.js' +import {appSecurityArtifactPaths} from '../app-security-artifacts.js' +import {fileRealPath, inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test} from 'vitest' +import type {AppSecuritySelection} from '../app-security-selection.js' + +async function createApp(directory: string): Promise { + await writeFile(joinPath(directory, 'shopify.app.toml'), 'client_id = "test"\n') + return fileRealPath(directory) +} + +/** The selection that `createApp`'s `shopify.app.toml` gives. Its results key is `shopify.app`. */ +function selectionFor(appRoot: string): AppSecuritySelection { + return {kind: 'config', appDirectory: appRoot, appConfigFilePath: joinPath(appRoot, 'shopify.app.toml')} +} + +function artifactPaths(appRoot: string) { + return appSecurityArtifactPaths(appRoot, 'shopify.app') +} + +describe('renderSecurityRecordResult', () => { + test('shows the counts, the recorded path, and how to review the results', async () => { + await inTemporaryDirectory(async (directory) => { + const appRoot = await createApp(directory) + const path = artifactPaths(appRoot).agentFindingsPath + const output = mockAndCaptureOutput() + output.clear() + + renderSecurityRecordResult({path, checks: 1, findings: 2}, selectionFor(appRoot), appRoot) + + const rendered = output.info() + expect(rendered).toContain('Agent findings recorded.') + expect(rendered).toContain('Recorded 1 check and 2 findings in') + expect(rendered).toContain('agent-findings.json') + expect(rendered).toContain('shopify app security review --path') + output.clear() + }) + }) +}) diff --git a/packages/app/src/cli/services/security-record/result.ts b/packages/app/src/cli/services/security-record/result.ts new file mode 100644 index 00000000000..aec32ef9a01 --- /dev/null +++ b/packages/app/src/cli/services/security-record/result.ts @@ -0,0 +1,22 @@ +import {formatAppSecurityCommand, resolveAppSecurityCommands} from '../app-security-commands.js' +import {countLabel} from '../app-security-format.js' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import type {AppSecuritySelection} from '../app-security-selection.js' +import type {SecurityRecordResult} from '../security-record.js' + +/** Presents a recorded document in the terminal. */ +export function renderSecurityRecordResult( + result: SecurityRecordResult, + selection: AppSecuritySelection, + path: string, +): void { + const commands = resolveAppSecurityCommands(selection, path) + renderSuccess({ + headline: 'Agent findings recorded.', + body: [ + `Recorded ${countLabel(result.checks, 'check')} and ${countLabel(result.findings, 'finding')} in`, + {filePath: result.path}, + ], + nextSteps: [['Run', {command: formatAppSecurityCommand(commands.review)}, 'to see the results.']], + }) +} From afae2993fbf29ea098921f57540d65b3d819b4b2 Mon Sep 17 00:00:00 2001 From: Jason Kirtland Date: Tue, 20 Oct 2026 10:28:20 -0700 Subject: [PATCH 2/2] Add typed JSON output to app security record `--json` prints the recorded agent-findings.json, its results directory, the app it belongs to, and the number of checks and findings recorded. Without a findings document on stdin, the command aborts with the `findings-document-missing` code. A rejected document aborts with `findings-document-rejected` and lists every error in `details.errors`, so callers can fix them all without parsing the message. --- .../generated/generated_docs_data_v2.json | 11 +- .../app/security/record.integration.test.ts | 240 ++++++++++++++++++ .../cli/commands/app/security/record.test.ts | 31 ++- .../src/cli/commands/app/security/record.ts | 13 +- .../src/cli/services/security-record.test.ts | 19 ++ .../app/src/cli/services/security-record.ts | 11 +- .../services/security-record/result.test.ts | 62 ++++- .../cli/services/security-record/result.ts | 36 ++- .../services/security-record/types.test.ts | 43 ++++ .../src/cli/services/security-record/types.ts | 20 ++ packages/cli/README.md | 94 ++++++- packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - 13 files changed, 569 insertions(+), 23 deletions(-) create mode 100644 packages/app/src/cli/commands/app/security/record.integration.test.ts create mode 100644 packages/app/src/cli/services/security-record/types.test.ts create mode 100644 packages/app/src/cli/services/security-record/types.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 50b5391161e..80090e3f5b2 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -3732,9 +3732,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-security-record.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface appsecurityrecord {\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Scan --path as an app with no app configuration file. Config checks are skipped. Requires --client-id.\n * @environment SHOPIFY_FLAG_WITHOUT_APP_CONFIG\n */\n '--without-app-config'?: ''\n}" + "value": "export interface appsecurityrecord {\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Scan --path as an app with no app configuration file. Config checks are skipped. Requires --client-id.\n * @environment SHOPIFY_FLAG_WITHOUT_APP_CONFIG\n */\n '--without-app-config'?: ''\n}" } }, "appsecurityreview": { diff --git a/packages/app/src/cli/commands/app/security/record.integration.test.ts b/packages/app/src/cli/commands/app/security/record.integration.test.ts new file mode 100644 index 00000000000..c743268ec33 --- /dev/null +++ b/packages/app/src/cli/commands/app/security/record.integration.test.ts @@ -0,0 +1,240 @@ +import SecurityRecord from './record.js' +import {appSecurityArtifactPaths} from '../../../services/app-security-artifacts.js' +import {validAppConfiguration} from '../../../services/app-security-selection.test-data.js' +import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, fileRealPath, inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {unstyled} from '@shopify/cli-kit/node/output' +import {joinPath} from '@shopify/cli-kit/node/path' +import {readStdinString} from '@shopify/cli-kit/node/system' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test, vi} from 'vitest' +// eslint-disable-next-line no-restricted-imports -- Verify native filesystem paths in JSON output. +import {resolve} from 'node:path' + +// Skip the installed-CLI error reporting setup, as `check`'s command boundary test does. +vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({ + ...(await importOriginal()), + isDevelopment: () => true, +})) +// Command lifecycle telemetry is unrelated to recording. Keep the real error handler and renderer. +vi.mock('@shopify/cli-kit/node/analytics', async (importOriginal) => ({ + ...(await importOriginal()), + reportAnalyticsEvent: vi.fn(), +})) +// A test can't pipe into the worker's stdin, so each test supplies what the command reads from it. +vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({ + ...(await importOriginal()), + readStdinString: vi.fn(), +})) + +const REJECTED_MESSAGE = 'The findings document was rejected. Nothing was recorded.' + +const validDocument = { + schema_version: 1, + scope: {include_dirs: [], excludes: [], no_git_ignore: false}, + checks_executed: [ + {check_id: 'MISSING_TENANT_ISOLATION', check_version: 1, status: 'executed'}, + { + check_id: 'OPEN_REDIRECT', + check_version: 1, + status: 'not_applicable', + reason: {code: 'no_redirects', message: 'The app never redirects'}, + }, + ], + findings: [ + { + check_id: 'MISSING_TENANT_ISOLATION', + check_version: 1, + file: 'app/routes/orders.tsx', + line: 12, + message: 'Orders are loaded without a shop filter', + evidence: [{file: 'app/routes/orders.tsx', line: 12, quote: 'prisma.order.findMany()'}], + }, + ], +} + +/** Creates an app linked to `test-client-id` with the `shopify.app` results directory, and returns its real path. */ +async function createApp(directory: string, {withResults = true} = {}): Promise { + await writeFile(joinPath(directory, 'shopify.app.toml'), validAppConfiguration('test-client-id')) + const appDirectory = await fileRealPath(directory) + if (withResults) await mkdir(appSecurityArtifactPaths(appDirectory, 'shopify.app').resultsDirectory) + return appDirectory +} + +function agentFindingsPath(appDirectory: string): string { + return appSecurityArtifactPaths(appDirectory, 'shopify.app').agentFindingsPath +} + +// Boxes wrap text across lines and draw borders, so compare their text with the borders and line breaks removed. +function boxText(output: string): string { + return unstyled(output).replaceAll('│', '').replace(/\s+/g, ' ') +} + +async function runCommand(argv: string[]) { + const previousExitCode = process.exitCode + process.exitCode = 0 + // Observe the real Oclif error handler's requested exit without terminating the test worker. + const exit = vi.spyOn(process, 'exit').mockImplementation((code) => { + process.exitCode = code ?? 0 + return undefined as never + }) + try { + const output = await withCapturedStandardStreams(async ({stdout, stderr}) => { + const config = await Config.load(import.meta.url) + // This test invokes the app command directly, not as a separately installed CLI plugin. + config.plugins.clear() + await SecurityRecord.run(argv, config) + return {stdout: stdout(), stderr: stderr()} + }) + return {...output, exitCode: process.exitCode} + } finally { + exit.mockRestore() + process.exitCode = previousExitCode + } +} + +/** + * Runs the command with --json for a failure. The error handler reads --json from the process arguments, not from the + * arguments given to `run`, so the environment sets it as well. + */ +async function runJsonCommand(argv: string[]) { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + try { + return await runCommand([...argv, '--json']) + } finally { + vi.unstubAllEnvs() + } +} + +describe('app security record command boundary', () => { + test('--json records the document and writes one result with the selected app and the recorded file', async () => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = await createApp(directory) + vi.mocked(readStdinString).mockResolvedValue(JSON.stringify(validDocument)) + + const result = await runCommand(['--path', directory, '--json']) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toStrictEqual({ + status: 'success', + app: { + directory: resolve(appDirectory), + configurationPath: resolve(appDirectory, 'shopify.app.toml'), + clientId: 'test-client-id', + }, + resultsDirectory: resolve(appDirectory, '.shopify', 'app-security', 'shopify.app'), + path: resolve(appDirectory, '.shopify', 'app-security', 'shopify.app', 'agent-findings.json'), + checkCount: 2, + findingCount: 1, + }) + expect(result.stderr).toBe('') + await expect(fileExists(agentFindingsPath(appDirectory))).resolves.toBe(true) + }) + }) + + test('--json without a document on stdin writes one error document with its code and exits 1', async () => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = await createApp(directory) + vi.mocked(readStdinString).mockResolvedValue(undefined) + + const result = await runJsonCommand(['--path', directory]) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toStrictEqual({ + error: { + type: 'abort', + message: 'No findings document was piped on stdin.', + code: 'findings-document-missing', + nextSteps: [ + expect.stringMatching(/^Pipe the findings document on stdin: shopify app security record --path .+$/), + ], + }, + }) + expect(result.stderr).toBe('') + await expect(fileExists(agentFindingsPath(appDirectory))).resolves.toBe(false) + }) + }) + + test.each([ + ['invalid JSON', '{"schema_version": 1,', [expect.stringMatching(/^The findings document is not valid JSON: /)]], + [ + 'a document that fails validation', + JSON.stringify({...validDocument, schema_version: 2, findings: [{...validDocument.findings[0], line: 0}]}), + ['schema_version must be 1', 'findings[0] (MISSING_TENANT_ISOLATION): invalid line number: 0'], + ], + [ + 'a document over 5 MB', + JSON.stringify({...validDocument, padding: 'x'.repeat(5_000_000)}), + [expect.stringMatching(/^The findings document is \d+ bytes; the limit is 5 MB\.$/)], + ], + ])('--json rejects %s with one error document listing every error in its details', async (_case, stdin, errors) => { + await inTemporaryDirectory(async (directory) => { + const appDirectory = await createApp(directory) + vi.mocked(readStdinString).mockResolvedValue(stdin) + + const result = await runJsonCommand(['--path', directory]) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toStrictEqual({ + error: { + type: 'abort', + message: REJECTED_MESSAGE, + code: 'findings-document-rejected', + nextSteps: [ + expect.stringMatching(/^Fix every error, then run shopify app security record --path .+ again\.$/), + ], + customSections: [{title: 'Errors', body: expect.any(String)}], + details: {errors}, + }, + }) + expect(result.stderr).toBe('') + await expect(fileExists(agentFindingsPath(appDirectory))).resolves.toBe(false) + }) + }) + + test('--json rejects stdin over the read limit with the read error in the details', async () => { + await inTemporaryDirectory(async (directory) => { + await createApp(directory) + vi.mocked(readStdinString).mockRejectedValue(new AbortError('Stdin input exceeded the maximum allowed size.')) + + const result = await runJsonCommand(['--path', directory]) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + error: { + message: REJECTED_MESSAGE, + code: 'findings-document-rejected', + details: {errors: ['Stdin input exceeded the maximum allowed size.']}, + }, + }) + expect(result.stderr).toBe('') + }) + }) + + test('--json without a results directory writes one error document with its code before reading stdin', async () => { + await inTemporaryDirectory(async (directory) => { + await createApp(directory, {withResults: false}) + + const result = await runJsonCommand(['--path', directory]) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({error: {code: 'results-directory-not-found'}}) + expect(result.stderr).toBe('') + expect(readStdinString).not.toHaveBeenCalled() + }) + }) + + test('without --json, prints the recorded path on stderr and nothing on stdout', async () => { + await inTemporaryDirectory(async (directory) => { + await createApp(directory) + vi.mocked(readStdinString).mockResolvedValue(JSON.stringify(validDocument)) + + const result = await runCommand(['--path', directory]) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('') + expect(boxText(result.stderr)).toContain('Agent findings recorded. Recorded 2 checks and 1 finding in') + }) + }) +}) diff --git a/packages/app/src/cli/commands/app/security/record.test.ts b/packages/app/src/cli/commands/app/security/record.test.ts index c8d7111c243..ba124de1ec1 100644 --- a/packages/app/src/cli/commands/app/security/record.test.ts +++ b/packages/app/src/cli/commands/app/security/record.test.ts @@ -6,8 +6,10 @@ import {resolveAppSecuritySelection} from '../../../services/app-security-select import {validAppConfiguration} from '../../../services/app-security-selection.test-data.js' import securityRecord from '../../../services/security-record.js' import {renderSecurityRecordResult} from '../../../services/security-record/result.js' +import {appSecurityRecordJsonOutputSchema} from '../../../services/security-record/types.js' import AppLinkedCommand from '../../../utilities/app-linked-command.js' import BaseCommand from '@shopify/cli-kit/node/base-command' +import {jsonFlag} from '@shopify/cli-kit/node/cli' import {AbortError} from '@shopify/cli-kit/node/error' import {fileExists, fileRealPath, inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' import {cwd, joinPath} from '@shopify/cli-kit/node/path' @@ -82,8 +84,11 @@ describe('app security record command', () => { test('does not require linked app context', () => { expect(SecurityRecord.prototype).toBeInstanceOf(BaseCommand) expect(SecurityRecord.prototype).not.toBeInstanceOf(AppLinkedCommand) - expect(SecurityRecord.flags).not.toHaveProperty('json') - expect(SecurityRecord.jsonOutputSchema).toBeUndefined() + }) + + test('defines --json and exposes the result schema', () => { + expect(SecurityRecord.flags.json).toBe(jsonFlag.json) + expect(SecurityRecord.jsonOutputSchema).toBe(appSecurityRecordJsonOutputSchema) }) test('defines the selection flags as check does', () => { @@ -113,9 +118,9 @@ describe('app security record command', () => { allowPrompts: false, validateClientIdFlag: true, }) - const selection = await vi.mocked(resolveAppSecuritySelection).mock.results[0]!.value - expect(securityRecord).toHaveBeenCalledWith({selection, path: cwd()}) - expect(renderSecurityRecordResult).toHaveBeenCalledWith(result, selection, cwd()) + const options = {selection: await vi.mocked(resolveAppSecuritySelection).mock.results[0]!.value, path: cwd()} + expect(securityRecord).toHaveBeenCalledWith(options) + expect(renderSecurityRecordResult).toHaveBeenCalledWith(result, options, 'text') expect(output.info()).toBe('') } finally { vi.unstubAllEnvs() @@ -124,6 +129,22 @@ describe('app security record command', () => { }) }) + test.each([['--json'], ['-j']])('presents the result as JSON with %s', async (flag) => { + await inTemporaryDirectory(async (directory) => { + const appRoot = await createApp(directory) + const result = recordedResult(appRoot) + vi.mocked(securityRecord).mockResolvedValue(result) + + await SecurityRecord.run(['--path', directory, flag], import.meta.url) + + expect(renderSecurityRecordResult).toHaveBeenCalledWith( + result, + {selection: expect.objectContaining({appDirectory: appRoot}), path: directory}, + 'json', + ) + }) + }) + test('forwards --config, --client-id and --without-app-config to the resolver without prompting', async () => { await inTemporaryDirectory(async (directory) => { const appRoot = await createApp(directory) diff --git a/packages/app/src/cli/commands/app/security/record.ts b/packages/app/src/cli/commands/app/security/record.ts index 1719478bdf7..5fa0268888e 100644 --- a/packages/app/src/cli/commands/app/security/record.ts +++ b/packages/app/src/cli/commands/app/security/record.ts @@ -3,8 +3,9 @@ import {requireResultsDirectory} from '../../../services/app-security-results.js import {resolveAppSecuritySelection} from '../../../services/app-security-selection.js' import securityRecord from '../../../services/security-record.js' import {renderSecurityRecordResult} from '../../../services/security-record/result.js' +import {appSecurityRecordJsonOutputSchema} from '../../../services/security-record/types.js' import BaseCommand from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' export default class SecurityRecord extends BaseCommand { static summary = 'Record agent findings from an app security check.' @@ -17,11 +18,16 @@ The document is recorded all or nothing: if anything is invalid, the command fai Reports the number of findings to Shopify as usage telemetry, but not finding details. See [usage reporting](https://shopify.dev/docs/api/shopify-cli#usage-reporting) for details.` + static get jsonOutputSchema() { + return appSecurityRecordJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, ...appSecuritySelectionFlags, + ...jsonFlag, } public async run(): Promise { @@ -36,7 +42,8 @@ Reports the number of findings to Shopify as usage telemetry, but not finding de validateClientIdFlag: true, }) await requireResultsDirectory(selection, flags.path) - const result = await securityRecord({selection, path: flags.path}) - renderSecurityRecordResult(result, selection, flags.path) + const options = {selection, path: flags.path} + const result = await securityRecord(options) + renderSecurityRecordResult(result, options, flags.json ? 'json' : 'text') } } diff --git a/packages/app/src/cli/services/security-record.test.ts b/packages/app/src/cli/services/security-record.test.ts index 8d4cd965fce..5800af2f6cc 100644 --- a/packages/app/src/cli/services/security-record.test.ts +++ b/packages/app/src/cli/services/security-record.test.ts @@ -110,6 +110,8 @@ async function expectRejected(stdin: string | undefined, expectedErrors: unknown expect(error.message).toBe(REJECTED_MESSAGE) expect(error.customSections).toEqual(errorsSection(expectedErrors)) + expect(error.code).toBe('findings-document-rejected') + expect(error.details).toEqual({errors: expectedErrors}) expect(dependencies.writeAgentFindings).not.toHaveBeenCalled() expect(dependencies.recordMetadata).not.toHaveBeenCalled() await expect(fileExists(artifactPaths(appRoot).agentFindingsPath)).resolves.toBe(false) @@ -223,6 +225,8 @@ describe('securityRecord', () => { ] expect(error.message).toBe(REJECTED_MESSAGE) expect(error.customSections).toStrictEqual(errorsSection(errors)) + expect(error.code).toBe('findings-document-rejected') + expect(error.details).toStrictEqual({errors}) expect(error.nextSteps).toStrictEqual([ ['Fix every error, then run', {command: recordCommand(appRoot)}, 'again.'], ]) @@ -361,6 +365,17 @@ describe('securityRecord', () => { } }) }) + + test('in the error details', async () => { + await inTemporaryDirectory(async (directory) => { + const appRoot = await createApp(directory) + + const error = await recordError(appRoot, testDependencies(documentWithSecrets)) + + expect(error.details).toStrictEqual({errors: redactedErrors}) + expect(JSON.stringify(error.details)).not.toContain(FAKE_SHOPIFY_TOKEN) + }) + }) }) test('fails when nothing is piped on stdin', async () => { @@ -371,6 +386,8 @@ describe('securityRecord', () => { const error = await recordError(appRoot, dependencies) expect(error.message).toBe('No findings document was piped on stdin.') + expect(error.code).toBe('findings-document-missing') + expect(error.details).toBeUndefined() expect(error.nextSteps).toStrictEqual([ ['Pipe the findings document on stdin:', {command: recordCommand(appRoot)}], ]) @@ -392,6 +409,8 @@ describe('securityRecord', () => { expect(error.message).toBe(REJECTED_MESSAGE) expect(error.customSections).toStrictEqual(errorsSection(['Stdin input exceeded the maximum allowed size.'])) + expect(error.code).toBe('findings-document-rejected') + expect(error.details).toStrictEqual({errors: ['Stdin input exceeded the maximum allowed size.']}) await expect(fileExists(artifactPaths(appRoot).agentFindingsPath)).resolves.toBe(false) }) }) diff --git a/packages/app/src/cli/services/security-record.ts b/packages/app/src/cli/services/security-record.ts index dad64742910..1a2b113b34f 100644 --- a/packages/app/src/cli/services/security-record.ts +++ b/packages/app/src/cli/services/security-record.ts @@ -16,7 +16,7 @@ export interface SecurityRecordResult { findings: number } -interface SecurityRecordOptions { +export interface SecurityRecordOptions { selection: AppSecuritySelection /** The `--path` value that was typed. */ path: string @@ -42,12 +42,15 @@ function recordCommand(commands: AppSecurityCommands): string { /** Rejects a document that fails validation, listing every error so an agent can fix them all at once. */ function abortRejectedDocument(errors: string[], commands: AppSecurityCommands): never { - throw new AbortError( + const error = new AbortError( 'The findings document was rejected. Nothing was recorded.', null, [['Fix every error, then run', {command: recordCommand(commands)}, 'again.']], [{title: 'Errors', body: {list: {items: errors}}}], ) + error.code = 'findings-document-rejected' + error.details = {errors} + throw error } async function readStdinDocument( @@ -70,9 +73,11 @@ async function readInputDocument( ): Promise { const input = await readStdinDocument(commands, dependencies) if (input === undefined) { - throw new AbortError('No findings document was piped on stdin.', null, [ + const error = new AbortError('No findings document was piped on stdin.', null, [ ['Pipe the findings document on stdin:', {command: recordCommand(commands)}], ]) + error.code = 'findings-document-missing' + throw error } const size = Buffer.byteLength(input, 'utf8') diff --git a/packages/app/src/cli/services/security-record/result.test.ts b/packages/app/src/cli/services/security-record/result.test.ts index fa960bea9d7..9213051149a 100644 --- a/packages/app/src/cli/services/security-record/result.test.ts +++ b/packages/app/src/cli/services/security-record/result.test.ts @@ -2,8 +2,10 @@ import {renderSecurityRecordResult} from './result.js' import {appSecurityArtifactPaths} from '../app-security-artifacts.js' import {fileRealPath, inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {describe, expect, test} from 'vitest' +// eslint-disable-next-line no-restricted-imports -- Verify native filesystem paths in JSON output. +import {resolve} from 'node:path' import type {AppSecuritySelection} from '../app-security-selection.js' async function createApp(directory: string): Promise { @@ -28,7 +30,11 @@ describe('renderSecurityRecordResult', () => { const output = mockAndCaptureOutput() output.clear() - renderSecurityRecordResult({path, checks: 1, findings: 2}, selectionFor(appRoot), appRoot) + renderSecurityRecordResult( + {path, checks: 1, findings: 2}, + {selection: selectionFor(appRoot), path: appRoot}, + 'text', + ) const rendered = output.info() expect(rendered).toContain('Agent findings recorded.') @@ -38,4 +44,56 @@ describe('renderSecurityRecordResult', () => { output.clear() }) }) + + test('writes one JSON result with the selected app, the results directory and the recorded file', async () => { + await inTemporaryDirectory(async (directory) => { + const appRoot = await createApp(directory) + const selection = {...selectionFor(appRoot), configClientId: 'test'} + const path = artifactPaths(appRoot).agentFindingsPath + + await withCapturedStandardStreams(({stdout, stderr}) => { + renderSecurityRecordResult({path, checks: 1, findings: 2}, {selection, path: appRoot}, 'json') + + expect(JSON.parse(stdout())).toStrictEqual({ + status: 'success', + app: { + directory: resolve(appRoot), + configurationPath: resolve(appRoot, 'shopify.app.toml'), + clientId: 'test', + }, + resultsDirectory: resolve(appRoot, '.shopify', 'app-security', 'shopify.app'), + path: resolve(appRoot, '.shopify', 'app-security', 'shopify.app', 'agent-findings.json'), + checkCount: 1, + findingCount: 2, + }) + expect(stderr()).toBe('') + }) + }) + }) + + test('writes a JSON result under the --client-id results key without app configuration', async () => { + await inTemporaryDirectory(async (directory) => { + const appRoot = await fileRealPath(directory) + const selection: AppSecuritySelection = { + kind: 'no-config', + appDirectory: appRoot, + clientId: 'flag-client-id', + clientIdSource: 'flag', + } + const path = appSecurityArtifactPaths(appRoot, 'flag-client-id').agentFindingsPath + + await withCapturedStandardStreams(({stdout}) => { + renderSecurityRecordResult({path, checks: 0, findings: 0}, {selection, path: appRoot}, 'json') + + expect(JSON.parse(stdout())).toStrictEqual({ + status: 'success', + app: {directory: resolve(appRoot), configurationPath: null, clientId: 'flag-client-id'}, + resultsDirectory: resolve(appRoot, '.shopify', 'app-security', 'flag-client-id'), + path: resolve(appRoot, '.shopify', 'app-security', 'flag-client-id', 'agent-findings.json'), + checkCount: 0, + findingCount: 0, + }) + }) + }) + }) }) diff --git a/packages/app/src/cli/services/security-record/result.ts b/packages/app/src/cli/services/security-record/result.ts index aec32ef9a01..463ea5cdb8c 100644 --- a/packages/app/src/cli/services/security-record/result.ts +++ b/packages/app/src/cli/services/security-record/result.ts @@ -1,16 +1,25 @@ +import {appSecurityRecordJsonOutputSchema, type AppSecurityRecordResult} from './types.js' +import {appSecurityArtifactPaths} from '../app-security-artifacts.js' import {formatAppSecurityCommand, resolveAppSecurityCommands} from '../app-security-commands.js' import {countLabel} from '../app-security-format.js' +import {projectAppSecurityApp, projectAppSecurityPath} from '../app-security-json-output.js' +import {resultsKey} from '../app-security-selection.js' +import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess} from '@shopify/cli-kit/node/ui' -import type {AppSecuritySelection} from '../app-security-selection.js' -import type {SecurityRecordResult} from '../security-record.js' +import type {SecurityRecordOptions, SecurityRecordResult} from '../security-record.js' -/** Presents a recorded document in the terminal. */ +/** Presents a recorded document as JSON, or in the terminal. */ export function renderSecurityRecordResult( result: SecurityRecordResult, - selection: AppSecuritySelection, - path: string, + options: SecurityRecordOptions, + format: 'json' | 'text', ): void { - const commands = resolveAppSecurityCommands(selection, path) + if (format === 'json') { + outputResult(appSecurityRecordJsonOutputSchema.encode(appSecurityRecordResult(result, options))) + return + } + + const commands = resolveAppSecurityCommands(options.selection, options.path) renderSuccess({ headline: 'Agent findings recorded.', body: [ @@ -20,3 +29,18 @@ export function renderSecurityRecordResult( nextSteps: [['Run', {command: formatAppSecurityCommand(commands.review)}, 'to see the results.']], }) } + +function appSecurityRecordResult( + result: SecurityRecordResult, + {selection}: SecurityRecordOptions, +): AppSecurityRecordResult { + const {resultsDirectory} = appSecurityArtifactPaths(selection.appDirectory, resultsKey(selection)) + return { + status: 'success', + app: projectAppSecurityApp(selection), + resultsDirectory: projectAppSecurityPath(resultsDirectory), + path: projectAppSecurityPath(result.path), + checkCount: result.checks, + findingCount: result.findings, + } +} diff --git a/packages/app/src/cli/services/security-record/types.test.ts b/packages/app/src/cli/services/security-record/types.test.ts new file mode 100644 index 00000000000..7d7f4ecaab9 --- /dev/null +++ b/packages/app/src/cli/services/security-record/types.test.ts @@ -0,0 +1,43 @@ +import {appSecurityRecordJsonOutputSchema} from './types.js' +import {describe, expect, test} from 'vitest' + +const onWindows = process.platform === 'win32' +const appDirectory = onWindows ? 'C:\\apps\\my-app' : '/apps/my-app' +const resultsDirectory = onWindows + ? 'C:\\apps\\my-app\\.shopify\\app-security\\shopify.app' + : '/apps/my-app/.shopify/app-security/shopify.app' +const result = { + status: 'success', + app: { + directory: appDirectory, + configurationPath: onWindows ? 'C:\\apps\\my-app\\shopify.app.toml' : '/apps/my-app/shopify.app.toml', + clientId: 'toml-client-id', + }, + resultsDirectory, + path: onWindows ? `${resultsDirectory}\\agent-findings.json` : `${resultsDirectory}/agent-findings.json`, + checkCount: 2, + findingCount: 3, +} + +describe('appSecurityRecordJsonOutputSchema', () => { + test.each([ + ['the selected app', result], + ['no checks or findings', {...result, checkCount: 0, findingCount: 0}], + ['no app configuration', {...result, app: {directory: appDirectory, configurationPath: null, clientId: 'abc'}}], + ])('accepts a result with %s', (_description, value) => { + expect(appSecurityRecordJsonOutputSchema.validate(value)).toStrictEqual(value) + }) + + test.each([ + ['an extra field', {...result, checks: 2}], + ['a missing field', {...result, path: undefined}], + ['an extra app field', {...result, app: {...result.app, name: 'My app'}}], + ['a relative path', {...result, path: 'agent-findings.json'}], + ['a relative results directory', {...result, resultsDirectory: '.shopify/app-security/shopify.app'}], + ['a negative count', {...result, findingCount: -1}], + ['a fractional count', {...result, checkCount: 1.5}], + ['another status', {...result, status: 'cancelled'}], + ])('rejects %s', (_description, value) => { + expect(() => appSecurityRecordJsonOutputSchema.validate(value)).toThrow() + }) +}) diff --git a/packages/app/src/cli/services/security-record/types.ts b/packages/app/src/cli/services/security-record/types.ts new file mode 100644 index 00000000000..9646ddd5ac7 --- /dev/null +++ b/packages/app/src/cli/services/security-record/types.ts @@ -0,0 +1,20 @@ +import {appSecurityAppSchema, createAbsolutePathSchema} from '../app-security-json-output.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const appSecurityRecordJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppSecurityRecordResult', + schema: zod + .object({ + status: zod.literal('success'), + app: appSecurityAppSchema, + resultsDirectory: createAbsolutePathSchema().describe('The absolute path of the results directory.'), + path: createAbsolutePathSchema().describe('The absolute path of the agent-findings.json file that was written.'), + checkCount: zod.number().int().nonnegative().describe('The number of checks recorded.'), + findingCount: zod.number().int().nonnegative().describe('The number of findings recorded, across every check.'), + }) + .strict(), + definitions: {AppSecurityApp: appSecurityAppSchema}, +}) + +export type AppSecurityRecordResult = InferJsonOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index 34d421f941a..9718026baab 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5883,7 +5883,7 @@ Record agent findings from an app security check. ``` USAGE - $ shopify app security record [--json-schema] [--no-color] [--no-input] [--path ] [--verbose] + $ shopify app security record [-j] [--json-schema] [--no-color] [--no-input] [--path ] [--verbose] [--without-app-config [--client-id | -c ]] FLAGS @@ -5891,6 +5891,10 @@ FLAGS The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --client-id= The Client ID of your app. [env: SHOPIFY_FLAG_CLIENT_ID] @@ -5935,6 +5939,94 @@ DESCRIPTION Reports the number of findings to Shopify as usage telemetry, but not finding details. See "usage reporting" (https://shopify.dev/docs/api/shopify-cli#usage-reporting) for details. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppSecurityRecordResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "app": { + "$ref": "#/definitions/AppSecurityApp" + }, + "resultsDirectory": { + "type": "string", + "description": "The absolute path of the results directory." + }, + "path": { + "type": "string", + "description": "The absolute path of the agent-findings.json file that was written." + }, + "checkCount": { + "type": "integer", + "minimum": 0, + "description": "The number of checks recorded." + }, + "findingCount": { + "type": "integer", + "minimum": 0, + "description": "The number of findings recorded, across every check." + } + }, + "required": [ + "status", + "app", + "resultsDirectory", + "path", + "checkCount", + "findingCount" + ], + "additionalProperties": false, + "title": "AppSecurityRecordResult", + "definitions": { + "AppSecurityApp": { + "type": "object", + "properties": { + "directory": { + "type": "string", + "description": "The absolute app directory." + }, + "configurationPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The absolute path of the selected app configuration file, or null with --without-app-config." + }, + "clientId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "The --client-id value when passed, otherwise the app configuration's client_id. Null when the app configuration isn't linked." + } + }, + "required": [ + "directory", + "configurationPath", + "clientId" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app security review` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 83b309a6489..79a64c2dfb4 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4429,7 +4429,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Reads a coding agent's complete findings document from stdin, validates it, and replaces `agent-findings.json` in the results directory, `.shopify/app-security//`. The results key is `--client-id` when you pass it, and otherwise the name of the app configuration file without `.toml`. `--client-id` is checked against your Shopify account before anything is read, so it needs you to be logged in.\n\nThe document must include a `scope` with the `include_dirs`, `excludes` and `no_git_ignore` values of the `check` run it describes, exactly as typed. It's recorded as reported and never compared with the scan's files.\n\nThe document is recorded all or nothing: if anything is invalid, the command fails with every error, writes nothing, and exits with a non-zero code. It needs the results directory that `shopify app security check` creates.\n\nReports the number of findings to Shopify as usage telemetry, but not finding details. See \"usage reporting\" (https://shopify.dev/docs/api/shopify-cli#usage-reporting) for details.", + "description": "Reads a coding agent's complete findings document from stdin, validates it, and replaces `agent-findings.json` in the results directory, `.shopify/app-security//`. The results key is `--client-id` when you pass it, and otherwise the name of the app configuration file without `.toml`. `--client-id` is checked against your Shopify account before anything is read, so it needs you to be logged in.\n\nThe document must include a `scope` with the `include_dirs`, `excludes` and `no_git_ignore` values of the `check` run it describes, exactly as typed. It's recorded as reported and never compared with the scan's files.\n\nThe document is recorded all or nothing: if anything is invalid, the command fails with every error, writes nothing, and exits with a non-zero code. It needs the results directory that `shopify app security check` creates.\n\nReports the number of findings to Shopify as usage telemetry, but not finding details. See \"usage reporting\" (https://shopify.dev/docs/api/shopify-cli#usage-reporting) for details.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppSecurityRecordResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"app\": {\n \"$ref\": \"#/definitions/AppSecurityApp\"\n },\n \"resultsDirectory\": {\n \"type\": \"string\",\n \"description\": \"The absolute path of the results directory.\"\n },\n \"path\": {\n \"type\": \"string\",\n \"description\": \"The absolute path of the agent-findings.json file that was written.\"\n },\n \"checkCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of checks recorded.\"\n },\n \"findingCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of findings recorded, across every check.\"\n }\n },\n \"required\": [\n \"status\",\n \"app\",\n \"resultsDirectory\",\n \"path\",\n \"checkCount\",\n \"findingCount\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppSecurityRecordResult\",\n \"definitions\": {\n \"AppSecurityApp\": {\n \"type\": \"object\",\n \"properties\": {\n \"directory\": {\n \"type\": \"string\",\n \"description\": \"The absolute app directory.\"\n },\n \"configurationPath\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The absolute path of the selected app configuration file, or null with --without-app-config.\"\n },\n \"clientId\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"minLength\": 1\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The --client-id value when passed, otherwise the app configuration's client_id. Null when the app configuration isn't linked.\"\n }\n },\n \"required\": [\n \"directory\",\n \"configurationPath\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Reads a coding agent's complete findings document from stdin, validates it, and replaces `agent-findings.json` in the results directory, `.shopify/app-security//`. The results key is `--client-id` when you pass it, and otherwise the name of the app configuration file without `.toml`. `--client-id` is checked against your Shopify account before anything is read, so it needs you to be logged in.\n\nThe document must include a `scope` with the `include_dirs`, `excludes` and `no_git_ignore` values of the `check` run it describes, exactly as typed. It's recorded as reported and never compared with the scan's files.\n\nThe document is recorded all or nothing: if anything is invalid, the command fails with every error, writes nothing, and exits with a non-zero code. It needs the results directory that `shopify app security check` creates.\n\nReports the number of findings to Shopify as usage telemetry, but not finding details. See [usage reporting](https://shopify.dev/docs/api/shopify-cli#usage-reporting) for details.", "enableJsonFlag": false, "flags": { @@ -4455,6 +4455,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 4db3840a539..34f2d470a1e 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -6,7 +6,6 @@ const commandExceptions = [ // App Security commands, launched before adopting typed JSON output. Remove entries as they adopt it. 'packages/app/src/cli/commands/app/security/check.ts', 'packages/app/src/cli/commands/app/security/instructions.ts', - 'packages/app/src/cli/commands/app/security/record.ts', 'packages/app/src/cli/commands/app/security/review.ts', // Streaming commands without a single finite result.