actions-warden 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +199 -0
- package/CONTRIBUTING.md +109 -0
- package/README.md +284 -224
- package/RELEASING.md +338 -0
- package/SECURITY.md +30 -3
- package/docs/AI-AGENTS.md +474 -0
- package/docs/CLI.md +474 -0
- package/docs/CONFIGURATION.md +340 -0
- package/docs/DEVELOPMENT.md +373 -0
- package/docs/GITHUB-ACTION.md +285 -0
- package/docs/JAVASCRIPT-API.md +357 -0
- package/docs/OUTPUTS.md +414 -0
- package/docs/README.md +27 -0
- package/examples/org-scan.yml +42 -0
- package/examples/upgrade-pr.yml +57 -0
- package/llms.txt +38 -0
- package/package.json +32 -10
- package/skills/actions-warden/SKILL.md +151 -40
- package/src/action.js +306 -0
- package/src/cli.js +494 -56
- package/src/commands/audit.js +189 -36
- package/src/commands/org-scan.js +544 -0
- package/src/commands/pin.js +59 -56
- package/src/commands/report.js +122 -10
- package/src/commands/upgrade.js +102 -62
- package/src/commands/verify.js +193 -0
- package/src/index.js +21 -4
- package/src/lib/action-status.js +27 -0
- package/src/lib/agent-mode.js +174 -0
- package/src/lib/annotations.js +250 -0
- package/src/lib/baseline.js +103 -0
- package/src/lib/cache.js +47 -10
- package/src/lib/concurrency.js +27 -0
- package/src/lib/config.js +185 -0
- package/src/lib/execution.js +71 -0
- package/src/lib/formatter.js +127 -8
- package/src/lib/github-org.js +374 -0
- package/src/lib/identity.js +62 -0
- package/src/lib/ignore.js +7 -6
- package/src/lib/org-checkpoint.js +461 -0
- package/src/lib/org-progress.js +60 -0
- package/src/lib/parser.js +326 -52
- package/src/lib/patcher.js +199 -0
- package/src/lib/path-equality.js +30 -0
- package/src/lib/paths.js +35 -12
- package/src/lib/redact.js +65 -4
- package/src/lib/resolver.js +225 -43
- package/src/lib/targets.js +28 -0
- package/src/lib/triggers.js +12 -0
- package/src/lib/writer.js +48 -8
- package/src/rules/excessive-permissions.js +24 -33
- package/src/rules/index.js +19 -1
- package/src/rules/pull-request-target-checkout.js +149 -18
- package/src/rules/reusable-workflow-secrets.js +32 -0
- package/src/rules/script-injection.js +77 -12
- package/src/rules/secrets-in-env.js +101 -18
- package/src/rules/unpinned-action.js +3 -2
- package/src/rules/unpinned-container-image.js +39 -0
- package/src/rules/unpinned-docker-action.js +30 -0
- package/src/rules/untrusted-self-hosted-runner.js +109 -0
- package/src/rules/workflow-run-artifact-execution.js +122 -0
- package/src/rules/workflow-structure.js +396 -0
- package/src/version.js +3 -0
package/src/lib/resolver.js
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import semver from 'semver';
|
|
10
|
-
import {
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { readCache, readCacheEntry, writeCache } from './cache.js';
|
|
11
13
|
import { redact } from './redact.js';
|
|
12
14
|
|
|
13
15
|
const API = 'https://api.github.com';
|
|
16
|
+
const IN_FLIGHT = new Map();
|
|
17
|
+
const COMMIT_SHA_RE = /^[0-9a-f]{40}$/i;
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* Resolve the API token. Precedence: explicit param > GITHUB_TOKEN > GH_TOKEN.
|
|
@@ -32,13 +36,42 @@ export function resolveToken(explicit) {
|
|
|
32
36
|
* @param {number} [opts.retries]
|
|
33
37
|
* @param {string} [opts.cwd]
|
|
34
38
|
* @param {boolean} [opts.useCache]
|
|
39
|
+
* @param {(event: {attempt: number, maxRetries: number, reason: 'network'|'rate-limit'|'server-error', delayMs: number, status?: number}) => void|Promise<void>} [opts.onRetry]
|
|
35
40
|
* @returns {Promise<{status: number, body: unknown}>}
|
|
36
41
|
*/
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
export function ghFetch(options) {
|
|
43
|
+
const {
|
|
44
|
+
url,
|
|
45
|
+
token,
|
|
46
|
+
cwd = process.cwd(),
|
|
47
|
+
useCache = true,
|
|
48
|
+
} = options;
|
|
49
|
+
const inFlightKey = `${requestCacheKey(url, token)}|cwd=${resolve(cwd)}|cache=${useCache}`;
|
|
50
|
+
const existing = IN_FLIGHT.get(inFlightKey);
|
|
51
|
+
if (existing) return existing;
|
|
52
|
+
const request = ghFetchInternal(options).finally(() => {
|
|
53
|
+
if (IN_FLIGHT.get(inFlightKey) === request) IN_FLIGHT.delete(inFlightKey);
|
|
54
|
+
});
|
|
55
|
+
IN_FLIGHT.set(inFlightKey, request);
|
|
56
|
+
return request;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function ghFetchInternal({
|
|
60
|
+
url,
|
|
61
|
+
token,
|
|
62
|
+
retries = 3,
|
|
63
|
+
cwd = process.cwd(),
|
|
64
|
+
useCache = true,
|
|
65
|
+
onRetry,
|
|
66
|
+
}) {
|
|
67
|
+
if (onRetry !== undefined && typeof onRetry !== 'function') {
|
|
68
|
+
throw new Error('onRetry must be a function');
|
|
41
69
|
}
|
|
70
|
+
const cacheKey = requestCacheKey(url, token);
|
|
71
|
+
const cached = useCache
|
|
72
|
+
? await readCacheEntry({ key: cacheKey, cwd, allowExpired: true })
|
|
73
|
+
: undefined;
|
|
74
|
+
if (cached?.fresh) return { status: 200, body: cached.value };
|
|
42
75
|
|
|
43
76
|
const headers = {
|
|
44
77
|
accept: 'application/vnd.github+json',
|
|
@@ -46,31 +79,70 @@ export async function ghFetch({ url, token, retries = 3, cwd = process.cwd(), us
|
|
|
46
79
|
'x-github-api-version': '2022-11-28',
|
|
47
80
|
};
|
|
48
81
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
82
|
+
if (cached?.etag) headers['if-none-match'] = cached.etag;
|
|
49
83
|
|
|
50
84
|
let attempt = 0;
|
|
51
85
|
for (;;) {
|
|
52
86
|
let response;
|
|
53
87
|
try {
|
|
54
|
-
response = await fetch(url, {
|
|
88
|
+
response = await fetch(url, {
|
|
89
|
+
headers,
|
|
90
|
+
signal: AbortSignal.timeout(15_000),
|
|
91
|
+
});
|
|
55
92
|
} catch (err) {
|
|
56
93
|
if (attempt >= retries) throw new Error(`github fetch failed: ${redact(String(err))}`);
|
|
57
|
-
|
|
94
|
+
const delayMs = backoff(attempt);
|
|
95
|
+
await notifyRetry(onRetry, {
|
|
96
|
+
attempt: attempt + 1,
|
|
97
|
+
maxRetries: retries,
|
|
98
|
+
reason: 'network',
|
|
99
|
+
delayMs,
|
|
100
|
+
});
|
|
101
|
+
await sleep(delayMs);
|
|
58
102
|
attempt += 1;
|
|
59
103
|
continue;
|
|
60
104
|
}
|
|
61
105
|
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
106
|
+
if (response.status === 304 && cached) {
|
|
107
|
+
await writeCache({
|
|
108
|
+
key: cacheKey,
|
|
109
|
+
value: cached.value,
|
|
110
|
+
etag: cached.etag,
|
|
111
|
+
cwd,
|
|
112
|
+
});
|
|
113
|
+
return { status: 200, body: cached.value };
|
|
114
|
+
}
|
|
62
115
|
if ((response.status === 403 && remaining === '0') || response.status === 429) {
|
|
63
116
|
const reset = Number(response.headers.get('x-ratelimit-reset') ?? 0) * 1000;
|
|
64
117
|
const wait = Math.max(reset - Date.now(), backoff(attempt));
|
|
65
118
|
if (attempt >= retries) {
|
|
66
|
-
|
|
119
|
+
const resetMessage = Number.isFinite(reset) && reset > Date.now()
|
|
120
|
+
? `; resets at ${new Date(reset).toISOString()}`
|
|
121
|
+
: '';
|
|
122
|
+
throw new Error(`github rate limit exhausted${resetMessage}`);
|
|
67
123
|
}
|
|
68
|
-
|
|
124
|
+
const delayMs = Math.min(wait, 30_000);
|
|
125
|
+
await notifyRetry(onRetry, {
|
|
126
|
+
attempt: attempt + 1,
|
|
127
|
+
maxRetries: retries,
|
|
128
|
+
reason: 'rate-limit',
|
|
129
|
+
delayMs,
|
|
130
|
+
status: response.status,
|
|
131
|
+
});
|
|
132
|
+
await sleep(delayMs);
|
|
69
133
|
attempt += 1;
|
|
70
134
|
continue;
|
|
71
135
|
}
|
|
72
136
|
if (response.status >= 500 && attempt < retries) {
|
|
73
|
-
|
|
137
|
+
const delayMs = backoff(attempt);
|
|
138
|
+
await notifyRetry(onRetry, {
|
|
139
|
+
attempt: attempt + 1,
|
|
140
|
+
maxRetries: retries,
|
|
141
|
+
reason: 'server-error',
|
|
142
|
+
delayMs,
|
|
143
|
+
status: response.status,
|
|
144
|
+
});
|
|
145
|
+
await sleep(delayMs);
|
|
74
146
|
attempt += 1;
|
|
75
147
|
continue;
|
|
76
148
|
}
|
|
@@ -79,12 +151,23 @@ export async function ghFetch({ url, token, retries = 3, cwd = process.cwd(), us
|
|
|
79
151
|
let body;
|
|
80
152
|
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
|
81
153
|
if (response.status === 200 && useCache) {
|
|
82
|
-
await writeCache({
|
|
154
|
+
await writeCache({
|
|
155
|
+
key: cacheKey,
|
|
156
|
+
value: body,
|
|
157
|
+
etag: response.headers.get('etag') ?? undefined,
|
|
158
|
+
cwd,
|
|
159
|
+
});
|
|
83
160
|
}
|
|
84
161
|
return { status: response.status, body };
|
|
85
162
|
}
|
|
86
163
|
}
|
|
87
164
|
|
|
165
|
+
function requestCacheKey(url, token) {
|
|
166
|
+
if (!token) return `${url}|auth=anonymous`;
|
|
167
|
+
const identity = createHash('sha256').update(token).digest('hex').slice(0, 16);
|
|
168
|
+
return `${url}|auth=${identity}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
88
171
|
function backoff(attempt) {
|
|
89
172
|
return Math.min(1000 * 2 ** attempt, 8000);
|
|
90
173
|
}
|
|
@@ -92,6 +175,10 @@ function sleep(ms) {
|
|
|
92
175
|
return new Promise(res => setTimeout(res, ms));
|
|
93
176
|
}
|
|
94
177
|
|
|
178
|
+
async function notifyRetry(onRetry, event) {
|
|
179
|
+
if (onRetry) await onRetry(event);
|
|
180
|
+
}
|
|
181
|
+
|
|
95
182
|
/**
|
|
96
183
|
* Resolve a ref (tag, branch, or commit-ish) to an immutable commit SHA.
|
|
97
184
|
*
|
|
@@ -105,7 +192,7 @@ function sleep(ms) {
|
|
|
105
192
|
*/
|
|
106
193
|
export async function resolveRefToSha({ owner, repo, ref, token, cwd }) {
|
|
107
194
|
// Already a full SHA?
|
|
108
|
-
if (
|
|
195
|
+
if (COMMIT_SHA_RE.test(ref)) {
|
|
109
196
|
return { sha: ref.toLowerCase(), type: 'commit' };
|
|
110
197
|
}
|
|
111
198
|
// Try as tag.
|
|
@@ -113,35 +200,87 @@ export async function resolveRefToSha({ owner, repo, ref, token, cwd }) {
|
|
|
113
200
|
const tagRes = await ghFetch({ url: tagUrl, token, cwd });
|
|
114
201
|
if (tagRes.status === 200 && tagRes.body && typeof tagRes.body === 'object') {
|
|
115
202
|
const obj = tagRes.body.object;
|
|
116
|
-
if (obj && obj.sha) {
|
|
203
|
+
if (obj && COMMIT_SHA_RE.test(String(obj.sha))) {
|
|
117
204
|
if (obj.type === 'tag') {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
205
|
+
const sha = await dereferenceTagToCommit({
|
|
206
|
+
owner,
|
|
207
|
+
repo,
|
|
208
|
+
sha: String(obj.sha),
|
|
209
|
+
token,
|
|
210
|
+
cwd,
|
|
211
|
+
});
|
|
212
|
+
return { sha, type: 'tag' };
|
|
124
213
|
}
|
|
125
|
-
|
|
214
|
+
if (obj.type !== 'commit') {
|
|
215
|
+
throw new Error(`tag ${owner}/${repo}@${ref} does not resolve to a commit`);
|
|
216
|
+
}
|
|
217
|
+
return { sha: String(obj.sha).toLowerCase(), type: 'tag' };
|
|
126
218
|
}
|
|
127
219
|
}
|
|
128
220
|
// Try as branch.
|
|
129
221
|
const branchUrl = `${API}/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(ref)}`;
|
|
130
222
|
const branchRes = await ghFetch({ url: branchUrl, token, cwd });
|
|
131
|
-
if (
|
|
132
|
-
|
|
223
|
+
if (
|
|
224
|
+
branchRes.status === 200
|
|
225
|
+
&& branchRes.body?.object?.type === 'commit'
|
|
226
|
+
&& COMMIT_SHA_RE.test(String(branchRes.body.object.sha))
|
|
227
|
+
) {
|
|
228
|
+
return { sha: String(branchRes.body.object.sha).toLowerCase(), type: 'branch' };
|
|
133
229
|
}
|
|
134
230
|
// Try as commit.
|
|
135
231
|
const commitUrl = `${API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`;
|
|
136
232
|
const commitRes = await ghFetch({ url: commitUrl, token, cwd });
|
|
137
|
-
if (commitRes.status === 200 && commitRes.body?.sha) {
|
|
138
|
-
return { sha: commitRes.body.sha, type: 'commit' };
|
|
233
|
+
if (commitRes.status === 200 && COMMIT_SHA_RE.test(String(commitRes.body?.sha))) {
|
|
234
|
+
return { sha: String(commitRes.body.sha).toLowerCase(), type: 'commit' };
|
|
139
235
|
}
|
|
140
236
|
throw new Error(`could not resolve ${owner}/${repo}@${ref}`);
|
|
141
237
|
}
|
|
142
238
|
|
|
239
|
+
async function dereferenceTagToCommit({ owner, repo, sha, token, cwd }) {
|
|
240
|
+
let currentSha = sha;
|
|
241
|
+
for (let depth = 0; depth < 10; depth += 1) {
|
|
242
|
+
const url = `${API}/repos/${owner}/${repo}/git/tags/${currentSha}`;
|
|
243
|
+
const response = await ghFetch({ url, token, cwd });
|
|
244
|
+
const target = response.body?.object;
|
|
245
|
+
if (
|
|
246
|
+
response.status !== 200
|
|
247
|
+
|| !target
|
|
248
|
+
|| !COMMIT_SHA_RE.test(String(target.sha))
|
|
249
|
+
) {
|
|
250
|
+
throw new Error(`could not dereference annotated tag ${owner}/${repo}@${sha}`);
|
|
251
|
+
}
|
|
252
|
+
if (target.type === 'commit') return String(target.sha).toLowerCase();
|
|
253
|
+
if (target.type !== 'tag') {
|
|
254
|
+
throw new Error(`annotated tag ${owner}/${repo}@${sha} targets ${target.type ?? 'an unknown object'}, not a commit`);
|
|
255
|
+
}
|
|
256
|
+
currentSha = String(target.sha);
|
|
257
|
+
}
|
|
258
|
+
throw new Error(`annotated tag chain is too deep for ${owner}/${repo}@${sha}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
143
261
|
/**
|
|
144
|
-
*
|
|
262
|
+
* Confirm that a commit SHA is reachable through the requested repository's
|
|
263
|
+
* commits API rather than merely looking like a SHA.
|
|
264
|
+
*/
|
|
265
|
+
export async function verifyCommitInRepo({ owner, repo, sha, token, cwd }) {
|
|
266
|
+
if (!COMMIT_SHA_RE.test(sha)) {
|
|
267
|
+
throw new Error(`invalid commit SHA for ${owner}/${repo}`);
|
|
268
|
+
}
|
|
269
|
+
const url = `${API}/repos/${owner}/${repo}/commits/${encodeURIComponent(sha)}`;
|
|
270
|
+
const response = await ghFetch({ url, token, cwd });
|
|
271
|
+
if (
|
|
272
|
+
response.status !== 200
|
|
273
|
+
|| !response.body
|
|
274
|
+
|| typeof response.body !== 'object'
|
|
275
|
+
|| String(response.body.sha).toLowerCase() !== sha.toLowerCase()
|
|
276
|
+
) {
|
|
277
|
+
throw new Error(`commit ${sha} is not verifiable in ${owner}/${repo} (HTTP ${response.status})`);
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* List all tags for a repo.
|
|
145
284
|
*
|
|
146
285
|
* @param {object} opts
|
|
147
286
|
* @param {string} opts.owner
|
|
@@ -153,10 +292,15 @@ export async function resolveRefToSha({ owner, repo, ref, token, cwd }) {
|
|
|
153
292
|
export async function listTags({ owner, repo, token, cwd }) {
|
|
154
293
|
/** @type {Array<{name: string, sha: string}>} */
|
|
155
294
|
const out = [];
|
|
156
|
-
for (let page = 1; page <=
|
|
295
|
+
for (let page = 1; page <= 100; page += 1) {
|
|
157
296
|
const url = `${API}/repos/${owner}/${repo}/tags?per_page=100&page=${page}`;
|
|
158
297
|
const res = await ghFetch({ url, token, cwd });
|
|
159
|
-
if (res.status !== 200
|
|
298
|
+
if (res.status !== 200) {
|
|
299
|
+
throw new Error(`could not list tags for ${owner}/${repo} (HTTP ${res.status})`);
|
|
300
|
+
}
|
|
301
|
+
if (!Array.isArray(res.body)) {
|
|
302
|
+
throw new Error(`invalid tag response for ${owner}/${repo}`);
|
|
303
|
+
}
|
|
160
304
|
for (const t of res.body) {
|
|
161
305
|
if (t && t.name && t.commit?.sha) out.push({ name: t.name, sha: t.commit.sha });
|
|
162
306
|
}
|
|
@@ -166,27 +310,46 @@ export async function listTags({ owner, repo, token, cwd }) {
|
|
|
166
310
|
}
|
|
167
311
|
|
|
168
312
|
/**
|
|
169
|
-
*
|
|
313
|
+
* Get conservative age evidence for a tag.
|
|
314
|
+
*
|
|
315
|
+
* Git commit and tagger timestamps are author-controlled, so they are not
|
|
316
|
+
* suitable for a security cooldown. Prefer GitHub's release publication time.
|
|
317
|
+
* For tags without releases, persist when this exact tag-to-SHA mapping was
|
|
318
|
+
* first observed and age it from that point.
|
|
170
319
|
*
|
|
171
320
|
* @param {object} opts
|
|
172
321
|
* @param {string} opts.owner
|
|
173
322
|
* @param {string} opts.repo
|
|
323
|
+
* @param {string} opts.tag
|
|
174
324
|
* @param {string} opts.sha
|
|
175
325
|
* @param {string} [opts.token]
|
|
176
326
|
* @param {string} [opts.cwd]
|
|
177
|
-
* @returns {Promise<number>}
|
|
327
|
+
* @returns {Promise<{dateMs: number, source: 'release'|'first-seen'}>}
|
|
178
328
|
*/
|
|
179
|
-
export async function
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
329
|
+
export async function getTagAgeEvidence({ owner, repo, tag, sha, token, cwd = process.cwd() }) {
|
|
330
|
+
const releaseUrl = `${API}/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
|
331
|
+
const release = await ghFetch({ url: releaseUrl, token, cwd });
|
|
332
|
+
if (release.status === 200 && release.body && typeof release.body === 'object') {
|
|
333
|
+
const dateText = release.body.published_at;
|
|
334
|
+
const dateMs = Date.parse(dateText);
|
|
335
|
+
if (Number.isFinite(dateMs)) return { dateMs, source: 'release' };
|
|
336
|
+
}
|
|
337
|
+
// A release object without a trustworthy timestamp provides no stronger age
|
|
338
|
+
// evidence than a tag without a release. Start the conservative first-seen
|
|
339
|
+
// cooldown instead of failing the entire upgrade.
|
|
340
|
+
if (release.status !== 404 && release.status !== 200) {
|
|
341
|
+
throw new Error(`could not verify tag age for ${owner}/${repo}@${tag} (HTTP ${release.status})`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const observationKey = `first-seen:${owner.toLowerCase()}/${repo.toLowerCase()}@${tag}:${sha}`;
|
|
345
|
+
const ttlMs = 10 * 365 * 86_400_000;
|
|
346
|
+
const existing = await readCache({ key: observationKey, ttlMs, cwd });
|
|
347
|
+
if (existing && typeof existing === 'object' && Number.isFinite(existing.dateMs)) {
|
|
348
|
+
return { dateMs: existing.dateMs, source: 'first-seen' };
|
|
184
349
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (!Number.isFinite(ms)) throw new Error(`invalid commit date: ${dateStr}`);
|
|
189
|
-
return ms;
|
|
350
|
+
const evidence = { dateMs: Date.now(), source: 'first-seen' };
|
|
351
|
+
await writeCache({ key: observationKey, value: evidence, ttlMs, cwd });
|
|
352
|
+
return evidence;
|
|
190
353
|
}
|
|
191
354
|
|
|
192
355
|
/**
|
|
@@ -200,15 +363,21 @@ export async function getCommitDate({ owner, repo, sha, token, cwd }) {
|
|
|
200
363
|
*/
|
|
201
364
|
export function pickLatestTag({ tags, currentRef, mode = 'major' }) {
|
|
202
365
|
const semverTags = tags
|
|
203
|
-
.map(t => ({ tag: t, parsed:
|
|
366
|
+
.map(t => ({ tag: t, parsed: parseActionVersion(t.name) }))
|
|
204
367
|
.filter(x => x.parsed)
|
|
205
|
-
.
|
|
206
|
-
.
|
|
368
|
+
.filter(x => x.parsed.prerelease.length === 0)
|
|
369
|
+
.map(x => ({
|
|
370
|
+
tag: x.tag,
|
|
371
|
+
version: x.parsed.version,
|
|
372
|
+
specificity: versionSpecificity(x.tag.name),
|
|
373
|
+
}))
|
|
374
|
+
.sort((a, b) => semver.rcompare(a.version, b.version) || b.specificity - a.specificity);
|
|
207
375
|
if (semverTags.length === 0) return null;
|
|
208
376
|
|
|
209
|
-
const current = currentRef ?
|
|
377
|
+
const current = currentRef ? parseActionVersion(currentRef) : null;
|
|
210
378
|
if (!current || mode === 'major') return semverTags[0].tag;
|
|
211
379
|
for (const candidate of semverTags) {
|
|
380
|
+
if (semver.lt(candidate.version, current.version)) continue;
|
|
212
381
|
if (mode === 'minor' && semver.major(candidate.version) === current.major) {
|
|
213
382
|
return candidate.tag;
|
|
214
383
|
}
|
|
@@ -222,3 +391,16 @@ export function pickLatestTag({ tags, currentRef, mode = 'major' }) {
|
|
|
222
391
|
}
|
|
223
392
|
return null;
|
|
224
393
|
}
|
|
394
|
+
|
|
395
|
+
function parseActionVersion(input) {
|
|
396
|
+
if (typeof input !== 'string') return null;
|
|
397
|
+
const match = input.match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(-[0-9A-Za-z.-]+)?$/);
|
|
398
|
+
if (!match) return null;
|
|
399
|
+
const normalized = `${match[1]}.${match[2] ?? '0'}.${match[3] ?? '0'}${match[4] ?? ''}`;
|
|
400
|
+
return semver.parse(normalized);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function versionSpecificity(input) {
|
|
404
|
+
const core = input.replace(/^v/, '').split('-')[0];
|
|
405
|
+
return core.split('.').length;
|
|
406
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { discoverWorkflows, resolveWorkflowArg } from './paths.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve command workflow targets and reject empty scopes.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} input
|
|
7
|
+
* @param {string[]|undefined} input.workflows
|
|
8
|
+
* @param {string} input.cwd
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveTargets({ workflows, cwd }) {
|
|
11
|
+
if (!workflows || workflows.length === 0) {
|
|
12
|
+
const discovered = await discoverWorkflows({ cwd });
|
|
13
|
+
if (discovered.length === 0) {
|
|
14
|
+
throw new Error('no workflow or composite action files found');
|
|
15
|
+
}
|
|
16
|
+
return discovered;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const out = new Set();
|
|
20
|
+
for (const workflow of workflows) {
|
|
21
|
+
const files = await resolveWorkflowArg(workflow, cwd);
|
|
22
|
+
if (files.length === 0) {
|
|
23
|
+
throw new Error(`no workflows matched: ${workflow}`);
|
|
24
|
+
}
|
|
25
|
+
for (const file of files) out.add(file);
|
|
26
|
+
}
|
|
27
|
+
return [...out].sort();
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test whether a parsed workflow subscribes to an event.
|
|
3
|
+
*
|
|
4
|
+
* @param {unknown} on
|
|
5
|
+
* @param {string} name
|
|
6
|
+
*/
|
|
7
|
+
export function hasTrigger(on, name) {
|
|
8
|
+
if (!on) return false;
|
|
9
|
+
if (typeof on === 'string') return on === name;
|
|
10
|
+
if (Array.isArray(on)) return on.includes(name);
|
|
11
|
+
return typeof on === 'object' && Object.prototype.hasOwnProperty.call(on, name);
|
|
12
|
+
}
|
package/src/lib/writer.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* (the default), no bytes are written - the change is recorded and reported.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { resolve, relative } from 'node:path';
|
|
8
|
+
import { open, rename, unlink, lstat, realpath } from 'node:fs/promises';
|
|
9
|
+
import { resolve, relative, dirname, basename, isAbsolute, sep, join } from 'node:path';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* @typedef {object} WriteResult
|
|
@@ -25,16 +26,49 @@ import { resolve, relative } from 'node:path';
|
|
|
25
26
|
* @returns {Promise<WriteResult>}
|
|
26
27
|
*/
|
|
27
28
|
export async function writeFileGuarded({ path, content, dryRun = true, cwd = process.cwd() }) {
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
if (typeof path !== 'string' || path.includes('\0')) throw new Error('invalid path');
|
|
30
|
+
const requestedRoot = resolve(cwd);
|
|
31
|
+
const requestedPath = resolve(requestedRoot, path);
|
|
32
|
+
if (!isAbsolute(path) && isOutside(relative(requestedRoot, requestedPath))) {
|
|
31
33
|
throw new Error(`refusing to write outside working directory: ${path}`);
|
|
32
34
|
}
|
|
35
|
+
const root = await realpath(requestedRoot);
|
|
36
|
+
const parent = await realpath(dirname(requestedPath));
|
|
37
|
+
if (isOutside(relative(root, parent))) {
|
|
38
|
+
throw new Error(`refusing to write outside working directory: ${path}`);
|
|
39
|
+
}
|
|
40
|
+
const abs = join(parent, basename(requestedPath));
|
|
41
|
+
let existingMode;
|
|
42
|
+
try {
|
|
43
|
+
const entry = await lstat(requestedPath);
|
|
44
|
+
if (entry.isSymbolicLink()) {
|
|
45
|
+
throw new Error(`refusing to write through a symlink: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
if (!entry.isFile()) {
|
|
48
|
+
throw new Error(`refusing to replace a non-file path: ${path}`);
|
|
49
|
+
}
|
|
50
|
+
existingMode = entry.mode & 0o777;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code !== 'ENOENT') throw error;
|
|
53
|
+
}
|
|
33
54
|
const bytes = Buffer.byteLength(content, 'utf8');
|
|
34
55
|
if (dryRun) {
|
|
35
56
|
return { path: abs, written: false, dryRun: true, bytes };
|
|
36
57
|
}
|
|
37
|
-
|
|
58
|
+
const tempPath = join(parent, `.${basename(abs)}.actions-warden-${process.pid}-${randomUUID()}`);
|
|
59
|
+
let handle;
|
|
60
|
+
try {
|
|
61
|
+
handle = await open(tempPath, 'wx', existingMode ?? 0o600);
|
|
62
|
+
await handle.writeFile(content, 'utf8');
|
|
63
|
+
await handle.sync();
|
|
64
|
+
await handle.close();
|
|
65
|
+
handle = null;
|
|
66
|
+
await rename(tempPath, abs);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (handle) await handle.close().catch(() => {});
|
|
69
|
+
await unlink(tempPath).catch(() => {});
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
38
72
|
return { path: abs, written: true, dryRun: false, bytes };
|
|
39
73
|
}
|
|
40
74
|
|
|
@@ -49,8 +83,14 @@ export function assertSafePath(path, cwd = process.cwd()) {
|
|
|
49
83
|
if (typeof path !== 'string' || path.includes('\0')) {
|
|
50
84
|
throw new Error('invalid path');
|
|
51
85
|
}
|
|
52
|
-
|
|
86
|
+
const root = resolve(cwd);
|
|
87
|
+
const abs = resolve(root, path);
|
|
88
|
+
if (isOutside(relative(root, abs))) {
|
|
53
89
|
throw new Error(`path traversal rejected: ${path}`);
|
|
54
90
|
}
|
|
55
|
-
return
|
|
91
|
+
return abs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isOutside(rel) {
|
|
95
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
56
96
|
}
|
|
@@ -5,37 +5,30 @@
|
|
|
5
5
|
|
|
6
6
|
export const id = 'excessive-permissions';
|
|
7
7
|
export const severity = 'medium';
|
|
8
|
-
export const description = 'Workflow or job grants
|
|
8
|
+
export const description = 'Workflow or job grants broadly writable GITHUB_TOKEN permissions.';
|
|
9
9
|
|
|
10
|
-
const BROAD_VALUES = new Set(['write-all'
|
|
11
|
-
const WRITE_SCOPES = new Set([
|
|
12
|
-
'contents',
|
|
13
|
-
'actions',
|
|
14
|
-
'packages',
|
|
15
|
-
'deployments',
|
|
16
|
-
'id-token',
|
|
17
|
-
'issues',
|
|
18
|
-
'pull-requests',
|
|
19
|
-
'security-events',
|
|
20
|
-
]);
|
|
10
|
+
const BROAD_VALUES = new Set(['write-all']);
|
|
21
11
|
|
|
22
12
|
/**
|
|
23
13
|
* @param {unknown} permissions
|
|
24
|
-
* @
|
|
14
|
+
* @param {boolean} declared
|
|
15
|
+
* @returns {string|null} - returns an offending scope label, or null
|
|
25
16
|
*/
|
|
26
|
-
function inspect(permissions) {
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
}
|
|
17
|
+
function inspect(permissions, declared) {
|
|
18
|
+
if (!declared) return 'unset-default';
|
|
19
|
+
if (permissions === undefined || permissions === null) return null;
|
|
30
20
|
if (typeof permissions === 'string') {
|
|
31
21
|
return BROAD_VALUES.has(permissions) ? permissions : null;
|
|
32
22
|
}
|
|
33
23
|
if (typeof permissions === 'object') {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
const writable = Object.entries(permissions)
|
|
25
|
+
.filter(([scope, value]) => value === 'write' && scope !== 'id-token')
|
|
26
|
+
.map(([scope]) => scope);
|
|
27
|
+
// Specific job capabilities such as contents:write or security-events:write
|
|
28
|
+
// are often the least privilege needed. Flag only maps broad enough to
|
|
29
|
+
// approximate write-all. Other rules independently report untrusted code
|
|
30
|
+
// flowing into privileged trigger contexts.
|
|
31
|
+
if (writable.length >= 3) return writable.map(scope => `${scope}=write`).join(',');
|
|
39
32
|
}
|
|
40
33
|
return null;
|
|
41
34
|
}
|
|
@@ -44,37 +37,35 @@ function inspect(permissions) {
|
|
|
44
37
|
* @param {import('../lib/parser.js').WorkflowDoc} workflow
|
|
45
38
|
*/
|
|
46
39
|
export function check(workflow) {
|
|
47
|
-
|
|
48
|
-
// not support a top-level permissions block of their own.
|
|
49
|
-
if (workflow.kind === 'composite-action') return [];
|
|
40
|
+
if (workflow.kind !== 'workflow') return [];
|
|
50
41
|
const findings = [];
|
|
51
|
-
const topScope = inspect(workflow.permissions);
|
|
42
|
+
const topScope = inspect(workflow.permissions, workflow.permissionsDeclared);
|
|
52
43
|
if (topScope === 'unset-default') {
|
|
53
44
|
findings.push({
|
|
54
45
|
id,
|
|
55
46
|
severity: 'low',
|
|
56
47
|
line: 1,
|
|
57
|
-
fields: { type: id, sev: 'low', scope: 'workflow-default' },
|
|
58
|
-
explain: 'declare `permissions
|
|
48
|
+
fields: { type: id, sev: 'low', scope: 'workflow-default-unspecified' },
|
|
49
|
+
explain: 'declare workflow-level `permissions: {}` and grant only the read or write scopes each job requires, so configurable repository defaults cannot broaden the token',
|
|
59
50
|
});
|
|
60
51
|
} else if (topScope) {
|
|
61
52
|
findings.push({
|
|
62
53
|
id,
|
|
63
54
|
severity,
|
|
64
|
-
line: 1,
|
|
55
|
+
line: workflow.permissionsLine || 1,
|
|
65
56
|
fields: { type: id, sev: severity, scope: topScope, target: 'workflow' },
|
|
66
|
-
explain: `workflow
|
|
57
|
+
explain: `replace workflow ${topScope} with an empty permissions map, then grant only the scopes required by each job`,
|
|
67
58
|
});
|
|
68
59
|
}
|
|
69
60
|
for (const job of workflow.jobs) {
|
|
70
|
-
const jobScope = inspect(job.permissions);
|
|
61
|
+
const jobScope = inspect(job.permissions, job.permissionsDeclared);
|
|
71
62
|
if (jobScope && jobScope !== 'unset-default') {
|
|
72
63
|
findings.push({
|
|
73
64
|
id,
|
|
74
65
|
severity,
|
|
75
|
-
line: job.line,
|
|
66
|
+
line: job.permissionsLine || job.line,
|
|
76
67
|
fields: { type: id, sev: severity, scope: jobScope, job: job.name },
|
|
77
|
-
explain: `job "${job.name}"
|
|
68
|
+
explain: `replace ${jobScope} in job "${job.name}" with only the specific read or write scopes its steps require`,
|
|
78
69
|
});
|
|
79
70
|
}
|
|
80
71
|
}
|
package/src/rules/index.js
CHANGED
|
@@ -3,12 +3,30 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as unpinned from './unpinned-action.js';
|
|
6
|
+
import * as unpinnedDocker from './unpinned-docker-action.js';
|
|
7
|
+
import * as unpinnedContainer from './unpinned-container-image.js';
|
|
6
8
|
import * as perms from './excessive-permissions.js';
|
|
7
9
|
import * as secrets from './secrets-in-env.js';
|
|
8
10
|
import * as injection from './script-injection.js';
|
|
9
11
|
import * as prTarget from './pull-request-target-checkout.js';
|
|
12
|
+
import * as reusableSecrets from './reusable-workflow-secrets.js';
|
|
13
|
+
import * as selfHosted from './untrusted-self-hosted-runner.js';
|
|
14
|
+
import * as workflowRunArtifact from './workflow-run-artifact-execution.js';
|
|
15
|
+
import * as workflowStructure from './workflow-structure.js';
|
|
10
16
|
|
|
11
|
-
export const RULES = [
|
|
17
|
+
export const RULES = [
|
|
18
|
+
unpinned,
|
|
19
|
+
unpinnedDocker,
|
|
20
|
+
unpinnedContainer,
|
|
21
|
+
perms,
|
|
22
|
+
secrets,
|
|
23
|
+
injection,
|
|
24
|
+
prTarget,
|
|
25
|
+
reusableSecrets,
|
|
26
|
+
selfHosted,
|
|
27
|
+
workflowRunArtifact,
|
|
28
|
+
workflowStructure,
|
|
29
|
+
];
|
|
12
30
|
|
|
13
31
|
/**
|
|
14
32
|
* @returns {Array<{id: string, severity: string, description: string}>}
|