-
Notifications
You must be signed in to change notification settings - Fork 65
Expand file tree
/
Copy pathgithub-scan-manifest.mts
More file actions
265 lines (233 loc) · 7.57 KB
/
Copy pathgithub-scan-manifest.mts
File metadata and controls
265 lines (233 loc) · 7.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
/**
* Manifest file download helpers for `socket scan github`.
*
* Extracted from create-scan-from-github.mts to keep that file under the
* 500-line soft cap. These wrap the GitHub content API and a raw fetch
* stream to pull supported manifest files down into a local scan tmp dir.
*/
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { debug, debugDir } from '@socketsecurity/lib-stable/debug/output'
import { safeMkdirSync } from '@socketsecurity/lib-stable/fs/safe'
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts'
import { strictDelete } from '../../util/fs/strict-delete.mts'
import { formatErrorWithDetail } from '../../util/error/errors.mjs'
import { isReportSupportedFile } from '../../util/fs/glob.mts'
import { socketHttpRequest } from '../../util/socket/api.mjs'
import { getOctokit, withGitHubRetry } from '../../util/git/github.mts'
import type { CResult } from '../../types.mts'
import type { SupportedFiles } from '../../util/fs/glob.mts'
const logger = getDefaultLogger()
// Best-effort cleanup of a partial download. Isolated in its own function so
// its catch handler doesn't shadow the caller's catch binding.
export async function cleanupPartialDownload(localPath: string): Promise<void> {
try {
await strictDelete(localPath)
} catch (e) {
logger.fail(
formatErrorWithDetail(`Error deleting partial file ${localPath}`, e),
)
}
}
export async function downloadManifestFile({
defaultBranch,
file,
orgGithub,
repoSlug,
tmpDir,
}: {
defaultBranch: string
file: string
orgGithub: string
repoSlug: string
tmpDir: string
}): Promise<CResult<undefined>> {
debug('request: file content from GitHub')
const octokit = getOctokit()
const result = await withGitHubRetry(async () => {
const { data } = await octokit.repos.getContent({
owner: orgGithub,
repo: repoSlug,
path: file,
ref: defaultBranch,
})
return data
}, `fetching file content for ${file} in ${orgGithub}/${repoSlug}`)
if (!result.ok) {
logger.fail(`Failed to get file content for: ${file}`)
return result
}
const fileData = result.data as {
type?: string | undefined
size?: number | undefined
download_url?: string | null | undefined
}
debug('complete: request')
debugDir({
fileData: { type: fileData.type, size: fileData.size },
})
// Check if it's a file, not a directory.
if (Array.isArray(fileData) || fileData.type !== 'file') {
return {
ok: false,
message: 'Not a file',
cause: `Path ${file} is not a file in ${orgGithub}/${repoSlug}.`,
}
}
const downloadUrl = fileData.download_url
if (!downloadUrl) {
return {
ok: false,
message: 'Missing download URL',
cause:
`GitHub did not provide a download URL for ${file} in ${orgGithub}/${repoSlug}. ` +
'The file may be too large or in an unsupported format.',
}
}
const localPath = path.join(tmpDir, file)
debug(`download: manifest file started ${downloadUrl} -> ${localPath}`)
// Now stream the file to that file.
const downloadResult = await streamDownloadWithFetch(localPath, downloadUrl)
if (!downloadResult.ok) {
logger.fail(
`Failed to download manifest file, skipping to next file. File: ${file}`,
)
return downloadResult
}
debug('download: manifest file completed')
return { ok: true, data: undefined }
}
// Courtesy of gemini:
export async function streamDownloadWithFetch(
localPath: string,
downloadUrl: string,
): Promise<CResult<string>> {
try {
// Use longer timeout for file downloads (5 minutes).
const response = await socketHttpRequest(downloadUrl, {
timeout: 300_000,
})
if (!response.ok) {
const errorMsg = `Download failed due to bad server response: ${response.status} ${response.statusText} for ${downloadUrl}`
logger.fail(errorMsg)
return { ok: false, message: 'Download Failed', cause: errorMsg }
}
// Make sure the dir exists. It may be nested and we need to construct that
// before starting the download.
const dir = path.dirname(localPath)
if (!existsSync(dir)) {
safeMkdirSync(dir, { recursive: true })
}
await fs.writeFile(localPath, response.body)
return { ok: true, data: localPath }
} catch (e) {
logger.fail(
'An error was thrown while trying to download a manifest file… url:',
downloadUrl,
)
debugDir(e)
// If an error occurs and fileStream was created, attempt to clean up.
await cleanupPartialDownload(localPath)
// Construct a more informative error message
let detailedError = `Error during download of ${downloadUrl}: ${(e as { message: string }).message}`
if ((e as { cause: string }).cause) {
// Include cause if available (e.g., from network errors)
detailedError += `\nCause: ${(e as { cause: string }).cause}`
}
debug(detailedError)
return { ok: false, message: 'Download Failed', cause: detailedError }
}
}
export async function testAndDownloadManifestFile({
defaultBranch,
file,
orgGithub,
repoSlug,
supportedFiles,
tmpDir,
}: {
defaultBranch: string
file: string
orgGithub: string
repoSlug: string
supportedFiles: SupportedFiles | undefined
tmpDir: string
}): Promise<CResult<{ isManifest: boolean }>> {
debug(`testing: file ${file}`)
if (!supportedFiles || !isReportSupportedFile(file, supportedFiles)) {
debug('skip: not a known pattern')
// Not an error.
return { ok: true, data: { isManifest: false } }
}
debug(`found: manifest file, going to attempt to download it; ${file}`)
const result = await downloadManifestFile({
defaultBranch,
file,
orgGithub,
repoSlug,
tmpDir,
})
return result.ok ? { ok: true, data: { isManifest: true } } : result
}
export async function testAndDownloadManifestFiles({
defaultBranch,
files,
orgGithub,
repoSlug,
tmpDir,
}: {
defaultBranch: string
files: string[]
orgGithub: string
repoSlug: string
tmpDir: string
}): Promise<CResult<unknown>> {
logger.info(
`File tree for ${defaultBranch} contains`,
files.length,
'entries. Searching for supported manifest files…',
)
// Fetch supported files once for all file checks (avoid repeated API calls).
const supportedFilesCResult = await fetchSupportedScanFileNames()
const supportedFiles = supportedFilesCResult.ok
? supportedFilesCResult.data
: undefined
logger.group()
let fileCount = 0
let firstFailureResult: CResult<never> | undefined
for (let i = 0, { length } = files; i < length; i += 1) {
const file = files[i]!
const result = await testAndDownloadManifestFile({
defaultBranch,
file,
orgGithub,
repoSlug,
supportedFiles,
tmpDir,
})
if (result.ok) {
if (result.data.isManifest) {
fileCount += 1
}
} else if (!firstFailureResult) {
firstFailureResult = result
}
}
logger.groupEnd()
logger.info('Found and downloaded', fileCount, 'manifest files')
if (!fileCount) {
if (firstFailureResult) {
logger.fail(
'While no supported manifest files were downloaded, at least one error encountered trying to do so. Showing the first error.',
)
return firstFailureResult
}
return {
ok: false,
message: 'No manifest files found',
cause: `No supported manifest files were found in the latest commit on the branch ${defaultBranch} for repo ${orgGithub}/${repoSlug}. Skipping full scan.`,
}
}
return { ok: true, data: undefined }
}