Skip to content

Commit d5783b5

Browse files
committed
Add initial coana fix pr open logic
1 parent 3019f7f commit d5783b5

2 files changed

Lines changed: 344 additions & 75 deletions

File tree

src/commands/fix/coana-fix.mts

Lines changed: 265 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
1-
import { debugDir } from '@socketsecurity/registry/lib/debug'
1+
import path from 'node:path'
22

3+
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
4+
import { logger } from '@socketsecurity/registry/lib/logger'
5+
6+
import { getFixEnv } from './fix-env-helpers.mts'
7+
import {
8+
enablePrAutoMerge,
9+
openCoanaPr,
10+
setGitRemoteGithubRepoUrl,
11+
} from './pull-request.mts'
312
import { handleApiCall } from '../../utils/api.mts'
13+
import { cmdFlagValueToArray } from '../../utils/cmd.mts'
414
import { spawnCoana } from '../../utils/coana.mts'
15+
import {
16+
gitCheckoutBranch,
17+
gitCommit,
18+
gitCreateBranch,
19+
gitDeleteBranch,
20+
gitPushBranch,
21+
gitRemoteBranchExists,
22+
gitResetAndClean,
23+
gitUnstagedModifiedFiles,
24+
} from '../../utils/git.mts'
525
import { getPackageFilesForScan } from '../../utils/path-resolve.mts'
626
import { setupSdk } from '../../utils/sdk.mts'
727
import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-names.mts'
@@ -12,90 +32,278 @@ import type { CResult } from '../../types.mts'
1232
export async function coanaFix(
1333
fixConfig: FixConfig,
1434
): Promise<CResult<{ fixed: boolean }>> {
15-
const { ghsas } = fixConfig
16-
17-
if (!ghsas.length) {
18-
return { ok: true, data: { fixed: false } }
19-
}
35+
const { autoMerge, cwd, ghsas, limit, orgSlug, spinner } = fixConfig
2036

21-
const { cwd, orgSlug, spinner } = fixConfig
37+
const fixEnv = await getFixEnv()
38+
debugDir('inspect', { fixEnv })
2239

2340
spinner?.start()
2441

2542
const sockSdkCResult = await setupSdk()
43+
if (!sockSdkCResult.ok) {
44+
return sockSdkCResult
45+
}
46+
47+
const sockSdk = sockSdkCResult.data
2648

27-
let lastCResult: CResult<any> = sockSdkCResult
49+
const supportedFilesCResult = await fetchSupportedScanFileNames()
50+
if (!supportedFilesCResult.ok) {
51+
return supportedFilesCResult
52+
}
2853

29-
const sockSdk = sockSdkCResult.ok ? sockSdkCResult.data : undefined
54+
const supportedFiles = supportedFilesCResult.data
55+
const scanFilepaths = await getPackageFilesForScan(['.'], supportedFiles, {
56+
cwd,
57+
})
58+
const uploadCResult = await handleApiCall(
59+
sockSdk.uploadManifestFiles(orgSlug, scanFilepaths),
60+
{
61+
desc: 'upload manifests',
62+
},
63+
)
3064

31-
const supportedFilesCResult = sockSdk
32-
? await fetchSupportedScanFileNames()
33-
: undefined
65+
if (!uploadCResult.ok) {
66+
return uploadCResult
67+
}
3468

35-
if (supportedFilesCResult) {
36-
lastCResult = supportedFilesCResult
69+
const tarHash: string = (uploadCResult as any).data.tarHash
70+
if (!tarHash) {
71+
spinner?.stop()
72+
return {
73+
ok: false,
74+
message:
75+
'No tar hash returned from Socket API upload-manifest-files endpoint',
76+
data: uploadCResult.data,
77+
}
3778
}
3879

39-
const supportedFiles = supportedFilesCResult?.ok
40-
? supportedFilesCResult.data
41-
: undefined
80+
const isAll =
81+
ghsas.length === 1 && (ghsas[0] === 'all' || ghsas[0] === 'auto')
82+
83+
const shouldOpenPrs = fixEnv.isCi && fixEnv.repoInfo
4284

43-
const packagePaths = supportedFiles
44-
? await getPackageFilesForScan(['.'], supportedFiles!, {
85+
if (!shouldOpenPrs) {
86+
const ids = isAll ? ['all'] : ghsas.slice(0, limit)
87+
if (!ids.length) {
88+
spinner?.stop()
89+
return { ok: true, data: { fixed: false } }
90+
}
91+
const fixCResult = await spawnCoana(
92+
[
93+
'compute-fixes-and-upgrade-purls',
4594
cwd,
46-
})
47-
: []
95+
'--manifests-tar-hash',
96+
tarHash,
97+
'--apply-fixes-to',
98+
...(isAll ? ['all'] : ghsas),
99+
...fixConfig.unknownFlags,
100+
],
101+
fixConfig.orgSlug,
102+
{ cwd, spinner },
103+
)
104+
spinner?.stop()
105+
return fixCResult.ok ? { ok: true, data: { fixed: true } } : fixCResult
106+
}
48107

49-
const uploadCResult = sockSdk
50-
? await handleApiCall(sockSdk?.uploadManifestFiles(orgSlug, packagePaths), {
51-
desc: 'upload manifests',
52-
})
53-
: undefined
108+
let ids: string[] | undefined
109+
if (isAll) {
110+
const foundCResult = await spawnCoana(
111+
[
112+
'compute-fixes-and-upgrade-purls',
113+
cwd,
114+
'--manifests-tar-hash',
115+
tarHash,
116+
...fixConfig.unknownFlags,
117+
],
118+
fixConfig.orgSlug,
119+
{ cwd, spinner },
120+
)
121+
if (foundCResult.ok) {
122+
const foundIds = cmdFlagValueToArray(
123+
/(?<=Vulnerabilities found:).*/.exec(foundCResult.data),
124+
)
125+
ids = foundIds.slice(0, limit)
126+
}
127+
} else {
128+
ids = ghsas.slice(0, limit)
129+
}
54130

55-
if (uploadCResult) {
56-
lastCResult = uploadCResult
131+
if (!ids?.length) {
132+
debugFn('notice', 'miss: no GHSA IDs to process')
57133
}
58134

59-
const tarHash = uploadCResult?.ok ? (uploadCResult as any).data.tarHash : ''
135+
if (!fixEnv.repoInfo) {
136+
debugFn('notice', 'miss: no repo info detected')
137+
}
60138

61-
if (!tarHash) {
139+
if (!ids?.length || !fixEnv.repoInfo) {
62140
spinner?.stop()
63-
return lastCResult as CResult<any>
141+
return { ok: true, data: { fixed: false } }
64142
}
65143

66-
const isAllOrAuto =
67-
ghsas.length === 1 && (ghsas[0] === 'all' || ghsas[0] === 'auto')
144+
const scanBaseNames = new Set(scanFilepaths.map(p => path.basename(p)))
68145

69-
const ids = isAllOrAuto ? ['all'] : ghsas
146+
let count = 0
147+
let overallFixed = false
148+
149+
// Process each GHSA ID individually, similar to npm-fix/pnpm-fix.
150+
ghsaLoop: for (let i = 0, { length } = ids; i < length; i += 1) {
151+
const id = ids[i]!
152+
debugFn('notice', `Processing GHSA ID: ${id}`)
153+
154+
// Apply fix for single GHSA ID.
155+
// eslint-disable-next-line no-await-in-loop
156+
const fixCResult = await spawnCoana(
157+
[
158+
'compute-fixes-and-upgrade-purls',
159+
cwd,
160+
'--manifests-tar-hash',
161+
tarHash,
162+
'--apply-fixes-to',
163+
id,
164+
...fixConfig.unknownFlags,
165+
],
166+
fixConfig.orgSlug,
167+
{ cwd, spinner },
168+
)
70169

71-
const fixCResult = ids.length
72-
? await spawnCoana(
73-
[
74-
'compute-fixes-and-upgrade-purls',
170+
if (!fixCResult.ok) {
171+
logger.error(
172+
`Update failed for ${id}: ${fixCResult.message || 'Unknown error'}`,
173+
)
174+
continue ghsaLoop
175+
}
176+
177+
// Check for modified files after applying the fix.
178+
// eslint-disable-next-line no-await-in-loop
179+
const unstagedCResult = await gitUnstagedModifiedFiles(cwd)
180+
const modifiedFiles = unstagedCResult.ok
181+
? unstagedCResult.data.filter(relPath =>
182+
scanBaseNames.has(path.basename(relPath)),
183+
)
184+
: []
185+
186+
if (!modifiedFiles.length) {
187+
debugFn('notice', `skip: no changes for ${id}`)
188+
continue ghsaLoop
189+
}
190+
191+
overallFixed = true
192+
193+
// Create PR if in CI environment
194+
try {
195+
const branch = `socket/coana-fix/${id}`
196+
197+
// Check if branch already exists
198+
// eslint-disable-next-line no-await-in-loop
199+
if (await gitRemoteBranchExists(branch, cwd)) {
200+
debugFn('notice', `skip: remote branch "${branch}" exists`)
201+
continue ghsaLoop
202+
}
203+
204+
debugFn('notice', `pr: creating for ${id}`)
205+
206+
const pushed =
207+
// eslint-disable-next-line no-await-in-loop
208+
(await gitCreateBranch(branch, cwd)) &&
209+
// eslint-disable-next-line no-await-in-loop
210+
(await gitCheckoutBranch(branch, cwd)) &&
211+
// eslint-disable-next-line no-await-in-loop
212+
(await gitCommit(
213+
`fix: Apply Coana security fix for ${id}`,
214+
modifiedFiles,
215+
{
216+
cwd,
217+
email: fixEnv.gitEmail,
218+
user: fixEnv.gitUser,
219+
},
220+
)) &&
221+
// eslint-disable-next-line no-await-in-loop
222+
(await gitPushBranch(branch, cwd))
223+
224+
if (!pushed) {
225+
logger.warn(`Push failed for ${id}, skipping PR creation.`)
226+
// eslint-disable-next-line no-await-in-loop
227+
await gitResetAndClean(fixEnv.baseBranch, cwd)
228+
// eslint-disable-next-line no-await-in-loop
229+
await gitCheckoutBranch(fixEnv.baseBranch, cwd)
230+
// eslint-disable-next-line no-await-in-loop
231+
await gitDeleteBranch(branch, cwd)
232+
continue ghsaLoop
233+
}
234+
235+
// Set up git remote.
236+
// eslint-disable-next-line no-await-in-loop
237+
await setGitRemoteGithubRepoUrl(
238+
fixEnv.repoInfo.owner,
239+
fixEnv.repoInfo.repo,
240+
fixEnv.githubToken!,
241+
cwd,
242+
)
243+
244+
// eslint-disable-next-line no-await-in-loop
245+
const prResponse = await openCoanaPr(
246+
fixEnv.repoInfo.owner,
247+
fixEnv.repoInfo.repo,
248+
branch,
249+
// Single GHSA ID.
250+
[id],
251+
{
252+
baseBranch: fixEnv.baseBranch,
75253
cwd,
76-
'--manifests-tar-hash',
77-
tarHash,
78-
'--apply-fixes-to',
79-
...ids,
80-
...fixConfig.unknownFlags,
81-
],
82-
fixConfig.orgSlug,
83-
{ cwd, spinner },
254+
},
84255
)
85-
: undefined
86256

87-
if (fixCResult) {
88-
lastCResult = fixCResult
257+
if (prResponse) {
258+
const { data } = prResponse
259+
const prRef = `PR #${data.number}`
260+
logger.success(`Opened ${prRef} for ${id}.`)
261+
262+
if (autoMerge) {
263+
logger.indent()
264+
spinner?.indent()
265+
// eslint-disable-next-line no-await-in-loop
266+
const { details, enabled } = await enablePrAutoMerge(data)
267+
if (enabled) {
268+
logger.info(`Auto-merge enabled for ${prRef}.`)
269+
} else {
270+
const message = `Failed to enable auto-merge for ${prRef}${
271+
details ? `:\n${details.map(d => ` - ${d}`).join('\n')}` : '.'
272+
}`
273+
logger.error(message)
274+
}
275+
logger.dedent()
276+
spinner?.dedent()
277+
}
278+
}
279+
280+
// Reset back to base branch for next iteration.
281+
// eslint-disable-next-line no-await-in-loop
282+
await gitResetAndClean(branch, cwd)
283+
// eslint-disable-next-line no-await-in-loop
284+
await gitCheckoutBranch(fixEnv.baseBranch, cwd)
285+
} catch (e) {
286+
logger.warn(
287+
`Unexpected condition: Push failed for ${id}, skipping PR creation.`,
288+
)
289+
debugDir('inspect', { error: e })
290+
// eslint-disable-next-line no-await-in-loop
291+
await gitResetAndClean(fixEnv.baseBranch, cwd)
292+
// eslint-disable-next-line no-await-in-loop
293+
await gitCheckoutBranch(fixEnv.baseBranch, cwd)
294+
}
295+
296+
count += 1
297+
debugFn('notice', `Processed ${count}/${Math.min(limit, ids.length)} fixes`)
298+
if (count >= limit) {
299+
break ghsaLoop
300+
}
89301
}
90302

91303
spinner?.stop()
92304

93-
debugDir('inspect', { lastCResult })
94-
95-
return lastCResult.ok
96-
? {
97-
ok: true,
98-
data: { fixed: true },
99-
}
100-
: (lastCResult as CResult<any>)
305+
return {
306+
ok: true,
307+
data: { fixed: overallFixed },
308+
}
101309
}

0 commit comments

Comments
 (0)