-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathaudit_workflow_runs.js
More file actions
301 lines (258 loc) · 8.23 KB
/
audit_workflow_runs.js
File metadata and controls
301 lines (258 loc) · 8.23 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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import { Octokit } from "@octokit/rest";
import { throttling } from "@octokit/plugin-throttling";
import fs from "fs";
import AdmZip from "adm-zip";
import {
parseFromInputFile,
matchActionsToAuditTargets,
searchForSetUpJob,
searchForTopLevelLog,
} from "./audit_workflow_runs_utils.js";
const OctokitWithThrottling = Octokit.plugin(throttling);
// Initialize Octokit with a personal access token
const octokit = new OctokitWithThrottling({
auth: process.env.GITHUB_TOKEN,
baseUrl: process.env.GITHUB_BASE_URL,
throttle: {
onRateLimit: (retryAfter, options, octokit, retryCount) => {
octokit.log.warn(
`Request quota exhausted for request ${options.method} ${options.url}`
);
if (retryCount < 1) {
// only retries once
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
return true;
}
},
onSecondaryRateLimit: (retryAfter, options, octokit) => {
// does not retry, only logs a warning
octokit.log.warn(
`SecondaryRateLimit detected for request ${options.method} ${options.url}`
);
},
},
});
octokit.log.warn = () => {};
octokit.log.error = () => {};
// Helper function to extract Actions used from workflow logs
async function extractActionsFromLogs(logUrl) {
let retries = 3;
while (retries > 0) {
try {
const response = await octokit.request(`GET ${logUrl}`, {
headers: { Accept: "application/vnd.github+json" },
});
// get the zip file content
const zipBuffer = Buffer.from(response.data);
// Unzip the file
const zip = new AdmZip(zipBuffer);
const logEntries = zip.getEntries(); // Get all entries in the zip file
const [success, actions] = searchForSetUpJob(logEntries);
if (!success) {
actions.push(...searchForTopLevelLog(logEntries));
}
return actions;
} catch (error) {
if (error.status == 404) {
console.error(
`Failed to fetch logs from ${logUrl}: 404. This may be due to the logs being too old, or the workflow having not run due to an error.`
);
return [];
} else if (error.message.startsWith("Connect Timeout Error ") || error.message === "read ECONNRESET" || error.message === "read ETIMEDOUT") {
console.error(
`Connection timeout/reset. Retrying, attempt ${4 - retries}/3. Waiting 30 seconds...`
);
retries--;
// sleep 30 seconds
await new Promise((resolve) => setTimeout(resolve, 30000));
continue;
}
console.error(`Failed to fetch logs from ${logUrl}:`, error.message);
return [];
}
}
}
async function createActionsRunResults(owner, repo, run, actions) {
const action_run_results = [];
for (const action of actions) {
const workflow = await octokit.request(`GET ${run.workflow_url}`);
if (workflow.status != 200) {
console.error(
`Error fetching workflow ${run.workflow_url}: `,
workflow.status
);
continue;
}
const workflow_path = workflow.data.path;
action_run_results.push({
org: owner,
repo: repo,
workflow: workflow_path,
run_id: run.id,
created_at: run.created_at,
name: action[0],
version: action[1],
sha: action[2],
immutable_version: action[3],
digest: action[4],
});
}
return action_run_results;
}
// Main function to query an organization and its repositories without using the audit log
async function* auditOrganizationWithoutAuditLog(orgName, startDate, endDate) {
try {
// Step 1: Get all repositories in the organization
const repos = await octokit.repos.listForOrg({
org: orgName,
per_page: 100,
});
if (repos.status != 200) {
console.error(`Error listing repos for org ${orgName}: `, repos.status);
return;
}
for (const repo of repos.data) {
// Step 2: Get all workflow runs in the repository within the date range
for await (const result of _auditRepo(
orgName,
repo.name,
startDate,
endDate
)) {
yield result;
}
}
} catch (error) {
console.error(`Error auditing organization ${orgName}: `, error.message);
}
}
// audit a single repository
async function* auditRepo(repoName, startDate, endDate) {
const [org, repo] = repoName.split("/");
for await (const result of _auditRepo(org, repo, startDate, endDate)) {
yield result;
}
}
// audit a single repository, using the orgname and repo name
async function* _auditRepo(org, repo, startDate, endDate) {
try {
const workflowRuns = await octokit.actions.listWorkflowRunsForRepo({
owner: org,
repo: repo,
per_page: 100,
created: `${startDate}..${endDate}`,
});
for (const run of workflowRuns.data.workflow_runs) {
const actions = await extractActionsFromLogs(run.logs_url);
const action_run_results = await createActionsRunResults(
org,
repo,
run,
actions
);
for (const result of action_run_results) {
yield result;
}
}
} catch (error) {
console.error(`Error auditing repo ${org}/${repo}: `, error.message);
}
}
// use the Enterprise/Organization audit log to list all workflow runs in that period
// for each workflow run, extract the actions used
// get the audit log, searching for `worklows` category, workflows.prepared_workflow_job being created
async function* auditEnterpriseOrOrg(
entOrOrgName,
entOrOrg,
startDate,
endDate
) {
try {
const phrase = `actions:workflows.prepared_workflow_job+created:${startDate}..${endDate}`;
const workflow_jobs = await octokit.paginate(
`GET /${
entOrOrg.startsWith("ent") ? "enterprises" : "orgs"
}/${entOrOrgName}/audit-log`,
{
phrase,
per_page: 100,
}
);
for (const job of workflow_jobs) {
if (job.action == "workflows.created_workflow_run") {
const run_id = job.workflow_run_id;
const [owner, repo] = job.repo.split("/");
try {
// get the workflow run log with the REST API
const run = await octokit.actions.getWorkflowRun({
owner: owner,
repo: repo,
run_id,
});
const actions = await extractActionsFromLogs(run.data.logs_url);
const action_run_results = await createActionsRunResults(
owner,
repo,
run.data,
actions
);
for (const result of action_run_results) {
yield result;
}
} catch (error) {
console.error(
`Error fetching workflow run ${owner}/${repo}#${run_id}:`,
error.status
);
continue;
}
}
}
} catch (error) {
console.error(
`Error auditing ${entOrOrg.startsWith("ent") ? "enterprise" : "org"}:`,
error.message
);
}
}
async function main() {
// Parse CLI arguments
const args = process.argv.slice(2);
if (args.length < 4) {
const script_name = process.argv[1].split("/").pop();
console.error(
`Usage: node ${script_name} <org-or-ent-name> <org|ent|repo> <start-date> <end-date> [<actions-to-audit-file>] [<output-filename>]`
);
return;
}
const [
orgOrEntName,
orgOrEnt,
startDate,
endDate,
argsOutputFilename,
actionsToAuditFilename,
] = args;
if (!["ent", "org", "repo"].includes(orgOrEnt)) {
console.error("<org|ent|repo> must be 'ent', 'org', 'repo'");
return;
}
const actionsToAudit = parseFromInputFile(actionsToAuditFilename);
const outputFilename = argsOutputFilename || "workflow_audit_results.sljson";
const action_run_results =
orgOrEnt != "repo"
? auditEnterpriseOrOrg(orgOrEntName, orgOrEnt, startDate, endDate)
: auditRepo(orgOrEntName, startDate, endDate);
console.log("org,repo,workflow,run_id,created_at,name,version,sha,immutable_version,digest");
const checkActions = Object.keys(actionsToAudit).length > 0;
for await (const result of action_run_results) {
if (checkActions) {
if (!matchActionsToAuditTargets(result, actionsToAudit)) {
continue;
}
}
console.log(Object.values(result).join(","));
fs.appendFileSync(outputFilename, JSON.stringify(result) + "\n");
}
}
await main();