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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/app/src/cli/services/app-doctor-engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* Public App Doctor engine API.
*
* CLI code outside this directory should import only these operations and result
* types: locate an app, scan, parse/compile findings, parse a stored trace, and
* build a submission. Keep scanners, registries, merge helpers, and redaction
* 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.
*/
export {
Expand All @@ -27,4 +27,6 @@ export type {
export {buildSubmission, SUBMISSION_SCHEMA_VERSION} from './submission/index.js'
export type {AppDoctorSubmission, AppDoctorSubmissionReport, BuildSubmissionOptions} from './submission/index.js'
export type {ReviewPack} from './checks/index.js'
export {groupIssues} from './output/group-issues.js'
export type {IssueGroup} from './output/group-issues.js'
export type {Capabilities, Issue, ScanResult, Severity, TraceV2} from './types.js'
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {groupIssues} from './group-issues.js'
import {describe, expect, test} from 'vitest'
import type {Issue} from '../types.js'

function issue(overrides: Partial<Issue> = {}): Issue {
return {
id: 'COMMITTED_SECRET',
pattern_id: 'Shopify token',
severity: 'high',
title: 'Hardcoded Shopify token detected',
message: 'A token was found.',
points: -50,
location: {file: 'app/a.ts', line: 1},
fix: {automated: false, description: 'Rotate the token.'},
...overrides,
}
}

describe('groupIssues', () => {
test('uses rule, pattern, and severity, not messages, titles, or locations', () => {
const issues = [issue(), issue({title: 'Updated wording', message: 'More detail', location: {file: 'app/b.ts'}})]
const before = structuredClone(issues)
const groups = groupIssues(issues)

expect(groups).toHaveLength(1)
expect(groups[0]!.issues).toHaveLength(2)
expect(groups[0]!.files).toEqual(['app/a.ts', 'app/b.ts'])
expect(issues).toEqual(before)
})

test('keeps different rules, patterns, sources, and severities separate', () => {
expect(
groupIssues([
issue(),
issue({id: 'ANOTHER_RULE'}),
issue({pattern_id: 'Private key'}),
issue({severity: 'low'}),
issue({found_by: 'agent'}),
issue({found_by: 'external'}),
]),
).toHaveLength(6)
})

test('preserves exactly repeated occurrences and handles empty input', () => {
expect(groupIssues([])).toEqual([])
const repeated = issue()
expect(groupIssues([repeated, repeated])[0]!.issues).toHaveLength(2)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {Issue, Severity} from '../types.js'

export interface IssueGroup {
severity: Severity
issues: Issue[]
files: string[]
}

const SEVERITY_ORDER: Record<Severity, number> = {high: 0, medium: 1, low: 2}

function groupKey(issue: Issue): string {
return [issue.found_by ?? 'static', issue.id, issue.pattern_id ?? '', issue.severity].join('|')
Comment thread
jplhomer marked this conversation as resolved.
}

/** A presentation view only: never replace scan issues or trace findings with these groups. */
export function groupIssues(issues: Issue[]): IssueGroup[] {
const groups = new Map<string, IssueGroup>()
const sorted = [...issues].sort(
(left, right) =>
SEVERITY_ORDER[left.severity] - SEVERITY_ORDER[right.severity] ||
left.location.file.localeCompare(right.location.file) ||
(left.location.line ?? 0) - (right.location.line ?? 0) ||
(left.location.column ?? 0) - (right.location.column ?? 0) ||
left.id.localeCompare(right.id) ||
left.title.localeCompare(right.title),
)
for (const issue of sorted) {
const key = groupKey(issue)
const group = groups.get(key)
if (group) {
group.issues.push(issue)
if (!group.files.includes(issue.location.file)) group.files.push(issue.location.file)
} else {
groups.set(key, {severity: issue.severity, issues: [issue], files: [issue.location.file]})
}
}
return [...groups.values()]
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function liquidUnsafeRenderVisitor(file: SourceFile) {
unsafeRenderFix(context),
'medium',
-10,
context,
),
]
},
Expand Down Expand Up @@ -81,6 +82,7 @@ function liquidExecutableContextVisitor(file: SourceFile) {
: 'Keep dynamic values out of executable attributes; use a fixed, versioned script asset and event listeners.',
'high',
-25,
context,
),
]
},
Expand Down Expand Up @@ -144,10 +146,12 @@ function makeLiquidIssue(
fix: string,
severity: Issue['severity'],
points: number,
context: LiquidOutputContext,
): Issue {
const lineStart = file.content!.lastIndexOf('\n', Math.max(0, offset - 1)) + 1
return {
id,
pattern_id: `liquid-${context}`,
severity,
points,
title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ function committedSecretFileIssue(file: SourceFile, status: GitFileStatus, envir

return {
id: 'COMMITTED_SECRET',
pattern_id: `${environmentFile ? 'environment-file' : 'secret-file'}:${tracked ? 'tracked' : 'unconfirmed'}`,
severity: 'high',
points: -50,
title,
Expand Down Expand Up @@ -192,6 +193,7 @@ export async function scanCommittedSecrets(secretEvidenceFiles: SourceFile[], ap
if (!pattern.regex.test(line)) continue
issues.push({
id: 'COMMITTED_SECRET',
pattern_id: pattern.name,
severity: 'high',
points: -50,
title: `Hardcoded ${pattern.name} detected`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ describe('git status drives severity, not .gitignore text', () => {
expect(finding!.severity).toBe('high')
expect(finding!.points).toBe(-50)
expect(finding!.detection_evidence?.join(' ')).toContain('TRACKED')
expect(finding!.pattern_id).toBe('environment-file:tracked')
rmSync(dir, {recursive: true, force: true})
})

Expand Down Expand Up @@ -239,6 +240,7 @@ describe('git status drives severity, not .gitignore text', () => {
const finding = result.issues.find((i) => i.id === 'COMMITTED_SECRET')
expect(finding).toBeDefined()
expect(finding!.severity).toBe('high')
expect(finding!.pattern_id).toBe('environment-file:unconfirmed')
rmSync(dir, {recursive: true, force: true})
})

Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/cli/services/app-doctor-engine/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export interface Issue {
id: string
/** Detector-defined variant within a rule, used to group similar findings in the report. */
pattern_id?: string
severity: Severity
points: number
title: string
Expand Down
79 changes: 79 additions & 0 deletions packages/app/src/cli/services/doctor-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ describe('buildDoctorAlert', () => {
[
{bold: 'Request input selects Admin API shop context'},
{subdued: 'REQUEST_CONTROLLED_ADMIN_CONTEXT'},
'1 occurrence across 1 file',
{filePath: 'app/routes/action.ts:42'},
],
[
{bold: 'Configured API version is no longer supported'},
{subdued: 'EOL_API_VERSION'},
'1 occurrence across 1 file',
{filePath: 'shopify.app.toml'},
],
],
Expand All @@ -137,6 +139,83 @@ describe('buildDoctorAlert', () => {
])
})

test('collapses hundreds of occurrences without hiding their reach or changing severity', () => {
const issues = Array.from({length: 200}, (_, index) => ({
...scanWithIssues.issues[0]!,
location: {file: `app/routes/route-${String(index).padStart(3, '0')}.ts`, line: 42},
}))
const input = reportInput({scan: {...scanWithIssues, issues}})
const before = structuredClone(input.scan)
const alert = buildDoctorAlert(input)
const high = section(input, 'High')!

expect(alert.type).toBe('error')
expect(alert.options.headline).toBe('1 security issue group found (200 occurrences).')
expect(high.body).toEqual({
list: {
items: [
[
{bold: issues[0]!.title},
{subdued: issues[0]!.id},
'200 occurrences across 200 files',
{filePath: 'app/routes/route-000.ts:42'},
{filePath: 'app/routes/route-001.ts:42'},
{filePath: 'app/routes/route-002.ts:42'},
{subdued: '+197 more files'},
],
],
},
})
expect(JSON.stringify(alert)).toContain('Use --verbose')
expect(input.scan).toEqual(before)
expect(buildDoctorAlert({...input, scan: {...input.scan, issues: [...issues].reverse()}})).toEqual(alert)
})

test('samples distinct files, not just the first three occurrences', () => {
const issues = [
{file: 'app/a.ts', line: 1},
{file: 'app/a.ts', line: 2},
{file: 'app/a.ts', line: 3},
{file: 'app/b.ts', line: 4},
{file: 'app/c.ts', line: 5},
].map((location) => ({...scanWithIssues.issues[0]!, location}))
const input = reportInput({scan: {...scanWithIssues, issues}})
const serialized = JSON.stringify(section(input, 'High'))
expect(serialized).toContain('5 occurrences across 3 files')
expect(serialized).toContain('app/a.ts:1')
expect(serialized).toContain('app/b.ts:4')
expect(serialized).toContain('app/c.ts:5')
expect(serialized).not.toContain('app/a.ts:2')
})

test('verbose output expands every occurrence, including distinct messages and fixes', () => {
const issues = Array.from({length: 4}, (_, index) => ({
...scanWithIssues.issues[0]!,
location: {file: 'app/a.ts', line: index + 1},
message: `Evidence ${index}`,
fix: {automated: false, description: `Remediation ${index}`},
}))
const input = reportInput({verbose: true, scan: {...scanWithIssues, issues}})
const serialized = JSON.stringify(section(input, 'High'))
expect(serialized).toContain('4 occurrences across 1 file')
for (const [index, issue] of issues.entries()) {
expect(serialized).toContain(`app/a.ts:${index + 1}`)
expect(serialized).toContain(issue.message)
expect(serialized).toContain(`Fix: ${issue.fix.description}`)
}
})

test.each(['low', 'medium'] as const)('does not promote widespread %s findings', (severity) => {
const issues = Array.from({length: 200}, (_, index) => ({
...scanWithIssues.issues[0]!,
severity,
location: {file: `app/${index}.ts`},
}))
const input = reportInput({scan: {...scanWithIssues, issues}})
expect(buildDoctorAlert(input).type).toBe('warning')
expect(section(input, 'High')).toBeUndefined()
})

test('quotes compile commands for Windows paths with spaces and percents', () => {
const commands = resolveAppDoctorCommands('C:/Users/50%/my app')
const alert = buildDoctorAlert(reportInput({commands}))
Expand Down
Loading
Loading