Skip to content

Commit 992ca04

Browse files
committed
Working version
0 parents  commit 992ca04

5 files changed

Lines changed: 456 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
.DS_Store
3+
workflow_audit_results.json

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Audit GitHub workflow runs for an org or Enterprise, using the audit log
2+
3+
Check the audit log for a GitHub Enterprise or organization for workflow runs, listing the Actions and specific versions and commits used in them.
4+
5+
Optionally, filter by a particular action, possibly including a commit SHA of interest.
6+
7+
> [!NOTE]
8+
> Not supported by GitHub
9+
10+
(C) Copyright GitHub, Inc.
11+
12+
## Usage
13+
14+
Set a `GITHUB_TOKEN` in the environment with appropriate access to the audit log on your org or Enterprise.
15+
16+
For Enterprise Server or Data Residency users, please set `GITHUB_BASE_URL` in your environment, e.g. `https://github.acme-inc.com/api/v3`
17+
18+
```bash
19+
node audit_workflow_runs.js <org or enterprise name> <"ent" or "org"> <start date> <end date> [<action>] [<commit SHA>]
20+
```
21+
22+
For example:
23+
24+
```bash
25+
node audit_workflow_runs.js github org 2025-03-13 2025-03-15 tj-actions/changed-files 0e58ed8671d6b60d0890c21b07f8835ace038e67
26+
```

audit_workflow_runs.js

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { Octokit } from "@octokit/rest";
2+
import fs from "fs";
3+
import AdmZip from "adm-zip";
4+
5+
// Initialize Octokit with a personal access token
6+
const octokit = new Octokit({
7+
auth: process.env.GITHUB_TOKEN, // Set your GitHub token in an environment variable
8+
baseUrl: process.env.GITHUB_BASE_URL // Set the GitHub base URL, e.g. for Enterprise Server, in an env var
9+
});
10+
11+
// Helper function to extract Actions used from workflow logs
12+
async function extractActionsFromLogs(logUrl) {
13+
try {
14+
const response = await octokit.request(`GET ${logUrl}`, {
15+
headers: { Accept: "application/vnd.github+json" },
16+
});
17+
18+
// get the zip file content
19+
const zipBuffer = Buffer.from(response.data);
20+
21+
// Unzip the file
22+
const zip = new AdmZip(zipBuffer);
23+
const logEntries = zip.getEntries(); // Get all entries in the zip file
24+
25+
// Download action repository 'actions/checkout@v4' (SHA:11bd71901bbe5b1630ceea73d27597364c9af683)
26+
const actionRegex = /Download action repository '(.+?)' \(SHA:(.+?)\)/g;
27+
const actions = [];
28+
29+
// Iterate through each file in the zip
30+
for (const entry of logEntries) {
31+
if (!entry.isDirectory) {
32+
const fileName = entry.entryName; // Get the file name
33+
// get the base name of the file
34+
const baseName = fileName.split("/").pop();
35+
if (baseName == "1_Set up job.txt") {
36+
const logContent = entry.getData().toString("utf8"); // Extract file content as a string
37+
let match;
38+
// Extract actions from the log content
39+
while (match = actionRegex.exec(logContent)) {
40+
const action = match[1];
41+
const sha = match[2];
42+
43+
const [repo, version] = action.split("@");
44+
actions.push([repo, version, sha]);
45+
}
46+
}
47+
}
48+
}
49+
return actions;
50+
51+
} catch (error) {
52+
console.error(`Failed to fetch logs from ${logUrl}:`, error.message);
53+
return [];
54+
}
55+
}
56+
57+
async function createActionsRunResults(owner, repo, run, actions) {
58+
const action_run_results = [];
59+
60+
for (const action of actions) {
61+
const workflow = await octokit.request(`GET ${run.workflow_url}`)
62+
63+
if (workflow.status != 200) {
64+
console.error("Error fetching workflow:", workflow.status);
65+
continue;
66+
}
67+
68+
const workflow_path = workflow.data.path;
69+
70+
action_run_results.push({
71+
org: owner,
72+
repo: repo,
73+
workflow: workflow_path,
74+
run_id: run.id,
75+
created_at: run.created_at,
76+
name: action[0],
77+
version: action[1],
78+
sha: action[2],
79+
});
80+
}
81+
return action_run_results;
82+
}
83+
84+
// Main function to query an organization and its repositories without using the audit log
85+
async function* auditOrganizationWithoutAuditLog(orgName, startDate, endDate) {
86+
try {
87+
// Step 1: Get all repositories in the organization
88+
const repos = await octokit.repos.listForOrg({
89+
org: orgName,
90+
per_page: 100
91+
});
92+
93+
if (repos.status != 200) {
94+
console.error("Error listing repos:", repos.status);
95+
return;
96+
}
97+
98+
for (const repo of repos.data) {
99+
// Step 2: Get all workflow runs in the repository within the date range
100+
try {
101+
const workflowRuns = await octokit.actions.listWorkflowRunsForRepo({
102+
owner: orgName,
103+
repo: repo.name,
104+
per_page: 100,
105+
created: `${startDate}..${endDate}`,
106+
});
107+
108+
for (const run of workflowRuns.data.workflow_runs) {
109+
// Step 3: Get the logs for the workflow run
110+
const actions = await extractActionsFromLogs(run.logs_url);
111+
112+
const action_run_results = await createActionsRunResults(
113+
orgName,
114+
repo.name,
115+
run,
116+
actions
117+
);
118+
119+
for (const result of action_run_results) {
120+
yield result;
121+
}
122+
}
123+
} catch (error) {
124+
continue;
125+
}
126+
}
127+
} catch (error) {
128+
console.error("Error auditing organization:", error.message);
129+
}
130+
}
131+
132+
// use the Enterprise/Organization audit log to list all workflow runs in that period
133+
// for each workflow run, extract the actions used
134+
// 1. get the audit log, searching for `worklows` category, workflows.prepared_workflow_job
135+
async function* auditEnterpriseOrOrg(entOrOrgName, entOrOrg, startDate, endDate) {
136+
try {
137+
const phrase = `actions:workflows.prepared_workflow_job+created:${startDate}..${endDate}`;
138+
const workflow_jobs = await octokit.paginate(`GET /${entOrOrg.startsWith('ent') ? 'enterprises' : 'orgs'}/${entOrOrgName}/audit-log`, {
139+
phrase,
140+
per_page: 100
141+
});
142+
143+
for (const job of workflow_jobs) {
144+
if (job.action == "workflows.created_workflow_run") {
145+
const run_id = job.workflow_run_id;
146+
const [owner, repo] = job.repo.split("/");
147+
148+
try {
149+
// get the workflow run log with the REST API
150+
const run = await octokit.actions.getWorkflowRun({
151+
owner: owner,
152+
repo: repo,
153+
run_id,
154+
});
155+
156+
const actions = await extractActionsFromLogs(run.data.logs_url);
157+
158+
const action_run_results = await createActionsRunResults(
159+
owner,
160+
repo,
161+
run.data,
162+
actions
163+
);
164+
165+
for (const result of action_run_results) {
166+
yield result;
167+
}
168+
} catch (error) {
169+
console.error("Error fetching workflow run:", error.message);
170+
continue;
171+
}
172+
}
173+
}
174+
} catch (error) {
175+
console.error(`Error auditing ${entOrOrg.startsWith('ent') ? 'enterprise' : 'org'}:`, error.message);
176+
}
177+
}
178+
179+
async function main() {
180+
// Parse CLI arguments
181+
const args = process.argv.slice(2);
182+
183+
if (args.length < 4) {
184+
console.error("Usage: node main.js <org-or-ent-name> <org|ent> <start-date> <end-date> [<action-name>] [<action-commit-sha>]");
185+
return;
186+
}
187+
188+
const [orgOrEntName, orgOrEnt, startDate, endDate] = args;
189+
190+
if (!['ent', 'org'].includes(orgOrEnt)) {
191+
console.error("<org|ent|repo> must be 'ent', 'org'");
192+
return;
193+
}
194+
195+
const action_run_results = auditEnterpriseOrOrg(orgOrEntName, orgOrEnt, startDate, endDate);
196+
197+
for await (const result of action_run_results) {
198+
if (args.length >= 5) {
199+
const [actionName, actionSha] = args.slice(4);
200+
if (result.name != actionName) {
201+
continue;
202+
}
203+
if (actionSha && result.sha != actionSha) {
204+
continue;
205+
}
206+
}
207+
208+
console.log(Object.values(result).join(","));
209+
fs.appendFileSync(
210+
"workflow_audit_results.json",
211+
JSON.stringify(result) + "\n"
212+
);
213+
}
214+
}
215+
216+
await main();

0 commit comments

Comments
 (0)