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.
Files changed (63) hide show
  1. package/AGENTS.md +199 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +284 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +30 -3
  6. package/docs/AI-AGENTS.md +474 -0
  7. package/docs/CLI.md +474 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +373 -0
  10. package/docs/GITHUB-ACTION.md +285 -0
  11. package/docs/JAVASCRIPT-API.md +357 -0
  12. package/docs/OUTPUTS.md +414 -0
  13. package/docs/README.md +27 -0
  14. package/examples/org-scan.yml +42 -0
  15. package/examples/upgrade-pr.yml +57 -0
  16. package/llms.txt +38 -0
  17. package/package.json +32 -10
  18. package/skills/actions-warden/SKILL.md +151 -40
  19. package/src/action.js +306 -0
  20. package/src/cli.js +494 -56
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +544 -0
  23. package/src/commands/pin.js +59 -56
  24. package/src/commands/report.js +122 -10
  25. package/src/commands/upgrade.js +102 -62
  26. package/src/commands/verify.js +193 -0
  27. package/src/index.js +21 -4
  28. package/src/lib/action-status.js +27 -0
  29. package/src/lib/agent-mode.js +174 -0
  30. package/src/lib/annotations.js +250 -0
  31. package/src/lib/baseline.js +103 -0
  32. package/src/lib/cache.js +47 -10
  33. package/src/lib/concurrency.js +27 -0
  34. package/src/lib/config.js +185 -0
  35. package/src/lib/execution.js +71 -0
  36. package/src/lib/formatter.js +127 -8
  37. package/src/lib/github-org.js +374 -0
  38. package/src/lib/identity.js +62 -0
  39. package/src/lib/ignore.js +7 -6
  40. package/src/lib/org-checkpoint.js +461 -0
  41. package/src/lib/org-progress.js +60 -0
  42. package/src/lib/parser.js +326 -52
  43. package/src/lib/patcher.js +199 -0
  44. package/src/lib/path-equality.js +30 -0
  45. package/src/lib/paths.js +35 -12
  46. package/src/lib/redact.js +65 -4
  47. package/src/lib/resolver.js +225 -43
  48. package/src/lib/targets.js +28 -0
  49. package/src/lib/triggers.js +12 -0
  50. package/src/lib/writer.js +48 -8
  51. package/src/rules/excessive-permissions.js +24 -33
  52. package/src/rules/index.js +19 -1
  53. package/src/rules/pull-request-target-checkout.js +149 -18
  54. package/src/rules/reusable-workflow-secrets.js +32 -0
  55. package/src/rules/script-injection.js +77 -12
  56. package/src/rules/secrets-in-env.js +101 -18
  57. package/src/rules/unpinned-action.js +3 -2
  58. package/src/rules/unpinned-container-image.js +39 -0
  59. package/src/rules/unpinned-docker-action.js +30 -0
  60. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  61. package/src/rules/workflow-run-artifact-execution.js +122 -0
  62. package/src/rules/workflow-structure.js +396 -0
  63. package/src/version.js +3 -0
@@ -7,10 +7,14 @@
7
7
  */
8
8
 
9
9
  import semver from 'semver';
10
- import { readCache, writeCache } from './cache.js';
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 async function ghFetch({ url, token, retries = 3, cwd = process.cwd(), useCache = true }) {
38
- if (useCache) {
39
- const cached = await readCache({ key: url, cwd });
40
- if (cached !== undefined) return { status: 200, body: cached };
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, { headers });
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
- await sleep(backoff(attempt));
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
- throw new Error('github rate limit exhausted');
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
- await sleep(Math.min(wait, 30_000));
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
- await sleep(backoff(attempt));
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({ key: url, value: body, cwd });
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 (/^[0-9a-f]{40}$/i.test(ref)) {
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
- // Annotated tag - dereference to commit.
119
- const tagObjUrl = `${API}/repos/${owner}/${repo}/git/tags/${obj.sha}`;
120
- const tagObj = await ghFetch({ url: tagObjUrl, token, cwd });
121
- if (tagObj.status === 200 && tagObj.body && tagObj.body.object) {
122
- return { sha: tagObj.body.object.sha, type: 'tag' };
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
- return { sha: obj.sha, type: 'tag' };
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 (branchRes.status === 200 && branchRes.body?.object?.sha) {
132
- return { sha: branchRes.body.object.sha, type: 'branch' };
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
- * List all tags for a repo (paginated, up to 200).
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 <= 2; page += 1) {
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 || !Array.isArray(res.body)) break;
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
- * Fetch the committer date of a commit (ms epoch).
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 getCommitDate({ owner, repo, sha, token, cwd }) {
180
- const url = `${API}/repos/${owner}/${repo}/commits/${encodeURIComponent(sha)}`;
181
- const res = await ghFetch({ url, token, cwd });
182
- if (res.status !== 200 || !res.body) {
183
- throw new Error(`could not fetch commit date for ${owner}/${repo}@${sha}`);
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 dateStr = res.body.commit?.committer?.date ?? res.body.commit?.author?.date;
186
- if (!dateStr) throw new Error('commit response missing date');
187
- const ms = Date.parse(dateStr);
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: semver.coerce(t.name) }))
366
+ .map(t => ({ tag: t, parsed: parseActionVersion(t.name) }))
204
367
  .filter(x => x.parsed)
205
- .map(x => ({ tag: x.tag, version: x.parsed.version }))
206
- .sort((a, b) => semver.rcompare(a.version, b.version));
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 ? semver.coerce(currentRef) : null;
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 { writeFile } from 'node:fs/promises';
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
- const abs = resolve(cwd, path);
29
- const rel = relative(cwd, abs);
30
- if (rel.startsWith('..') || rel.includes('\0')) {
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
- await writeFile(abs, content, 'utf8');
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
- if (path.includes('..')) {
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 resolve(cwd, path);
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 overly broad GITHUB_TOKEN permissions.';
8
+ export const description = 'Workflow or job grants broadly writable GITHUB_TOKEN permissions.';
9
9
 
10
- const BROAD_VALUES = new Set(['write-all', '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
- * @returns {string|null} - returns offending scope label, or null
14
+ * @param {boolean} declared
15
+ * @returns {string|null} - returns an offending scope label, or null
25
16
  */
26
- function inspect(permissions) {
27
- if (permissions === undefined || permissions === null) {
28
- return 'unset-default'; // GitHub default is permissive when not declared
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
- for (const [scope, val] of Object.entries(permissions)) {
35
- if (val === 'write' && WRITE_SCOPES.has(scope)) {
36
- return `${scope}=write`;
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
- // Composite actions inherit the calling workflow's token permissions and do
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:` at workflow root with least-privilege scopes',
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 grants ${topScope} - narrow to specific scopes`,
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}" grants ${jobScope}`,
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
  }
@@ -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 = [unpinned, perms, secrets, injection, prTarget];
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}>}