actions-warden 0.2.0 → 0.3.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 (62) hide show
  1. package/AGENTS.md +189 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +272 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +25 -3
  6. package/docs/AI-AGENTS.md +458 -0
  7. package/docs/CLI.md +421 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +350 -0
  10. package/docs/GITHUB-ACTION.md +281 -0
  11. package/docs/JAVASCRIPT-API.md +355 -0
  12. package/docs/OUTPUTS.md +409 -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 +140 -38
  19. package/src/action.js +317 -0
  20. package/src/cli.js +267 -22
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +549 -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 +175 -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 +378 -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/paths.js +35 -12
  45. package/src/lib/redact.js +65 -4
  46. package/src/lib/resolver.js +225 -43
  47. package/src/lib/targets.js +28 -0
  48. package/src/lib/triggers.js +12 -0
  49. package/src/lib/writer.js +45 -8
  50. package/src/rules/excessive-permissions.js +24 -33
  51. package/src/rules/index.js +19 -1
  52. package/src/rules/pull-request-target-checkout.js +149 -18
  53. package/src/rules/reusable-workflow-secrets.js +32 -0
  54. package/src/rules/script-injection.js +77 -12
  55. package/src/rules/secrets-in-env.js +101 -18
  56. package/src/rules/unpinned-action.js +3 -2
  57. package/src/rules/unpinned-container-image.js +39 -0
  58. package/src/rules/unpinned-docker-action.js +30 -0
  59. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  60. package/src/rules/workflow-run-artifact-execution.js +122 -0
  61. package/src/rules/workflow-structure.js +396 -0
  62. package/src/version.js +3 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Read-only GitHub organization/repository discovery.
3
+ *
4
+ * Repository code is fetched as Git objects and parsed in memory. Nothing is
5
+ * cloned, checked out, or executed by the organization scanner.
6
+ */
7
+
8
+ import { TextDecoder } from 'node:util';
9
+ import { ghFetch } from './resolver.js';
10
+
11
+ const API = 'https://api.github.com';
12
+ const NAME_RE = /^[A-Za-z0-9_.-]+$/;
13
+ const SHA_RE = /^[0-9a-f]{40}$/i;
14
+ export const MAX_WORKFLOW_BYTES = 2 * 1024 * 1024;
15
+ export const MAX_WORKFLOW_FILES = 1000;
16
+ export const MAX_REPOSITORY_WORKFLOW_BYTES = 32 * 1024 * 1024;
17
+
18
+ /**
19
+ * List every repository visible to the supplied token for an organization.
20
+ */
21
+ export async function listOrganizationRepositories({ organization, token, cwd, onRetry } = {}) {
22
+ const org = validateName(organization, 'organization');
23
+ const repositories = [];
24
+ const seen = new Set();
25
+
26
+ for (let page = 1; page <= 1000; page += 1) {
27
+ const query = new URLSearchParams({
28
+ type: 'all',
29
+ sort: 'full_name',
30
+ direction: 'asc',
31
+ per_page: '100',
32
+ page: String(page),
33
+ });
34
+ const response = await ghFetch({
35
+ url: `${API}/orgs/${encodeURIComponent(org)}/repos?${query}`,
36
+ token,
37
+ cwd,
38
+ useCache: false,
39
+ onRetry,
40
+ });
41
+ if (response.status !== 200) {
42
+ throw new Error(`could not list repositories for ${org} (HTTP ${response.status})`);
43
+ }
44
+ if (!Array.isArray(response.body)) {
45
+ throw new Error(`invalid repository response for ${org}`);
46
+ }
47
+
48
+ for (const raw of response.body) {
49
+ const repository = normalizeRepository(raw, org);
50
+ const key = repository.fullName.toLowerCase();
51
+ if (seen.has(key)) continue;
52
+ seen.add(key);
53
+ repositories.push(repository);
54
+ }
55
+ if (response.body.length < 100) {
56
+ return repositories.sort((left, right) => left.fullName.localeCompare(right.fullName));
57
+ }
58
+ }
59
+ throw new Error(`repository listing for ${org} exceeded 100000 entries`);
60
+ }
61
+
62
+ /**
63
+ * Fetch and validate the workflow-bearing portion of a repository tree.
64
+ * The returned snapshot can be compared with a checkpoint before any blobs
65
+ * are downloaded.
66
+ */
67
+ export async function fetchRepositoryWorkflowTree({
68
+ repository,
69
+ token,
70
+ cwd,
71
+ includePath,
72
+ onRetry,
73
+ } = {}) {
74
+ const normalized = normalizeRepository(repository, repository?.owner);
75
+ if (includePath !== undefined && typeof includePath !== 'function') {
76
+ throw new Error('includePath must be a function');
77
+ }
78
+ if (!normalized.defaultBranch) {
79
+ return workflowTree(normalized, { entries: [], empty: true, treeSha: null });
80
+ }
81
+ const treeUrl = `${API}/repos/${encodeURIComponent(normalized.owner)}`
82
+ + `/${encodeURIComponent(normalized.name)}/git/trees/`
83
+ + `${encodeURIComponent(normalized.defaultBranch)}?recursive=1`;
84
+ const treeResponse = await ghFetch({
85
+ url: treeUrl,
86
+ token,
87
+ cwd,
88
+ useCache: false,
89
+ onRetry,
90
+ });
91
+ if (treeResponse.status === 409) {
92
+ return workflowTree(normalized, { entries: [], empty: true, treeSha: null });
93
+ }
94
+ if (treeResponse.status !== 200) {
95
+ throw new Error(`could not read ${normalized.fullName}@${normalized.defaultBranch} tree (HTTP ${treeResponse.status})`);
96
+ }
97
+ if (!treeResponse.body || typeof treeResponse.body !== 'object' || !Array.isArray(treeResponse.body.tree)) {
98
+ throw new Error(`invalid tree response for ${normalized.fullName}`);
99
+ }
100
+ if (typeof treeResponse.body.truncated !== 'boolean') {
101
+ throw new Error(`invalid tree truncation state for ${normalized.fullName}`);
102
+ }
103
+ if (!SHA_RE.test(String(treeResponse.body.sha ?? ''))) {
104
+ throw new Error(`invalid tree SHA for ${normalized.fullName}`);
105
+ }
106
+ if (treeResponse.body.truncated === true) {
107
+ throw new Error(`tree response for ${normalized.fullName} was truncated; refusing an incomplete scan`);
108
+ }
109
+
110
+ const entries = treeResponse.body.tree
111
+ .filter(entry => (
112
+ entry?.type === 'blob'
113
+ && isWorkflowPath(entry.path)
114
+ && (includePath === undefined || includePath(entry.path))
115
+ ))
116
+ .sort((left, right) => left.path.localeCompare(right.path));
117
+ if (entries.length > MAX_WORKFLOW_FILES) {
118
+ throw new Error(
119
+ `${normalized.fullName} has more than ${MAX_WORKFLOW_FILES} workflow files; refusing an unbounded scan`,
120
+ );
121
+ }
122
+ const declaredBytes = entries.reduce(
123
+ (total, entry) => total + (
124
+ Number.isSafeInteger(entry.size) && entry.size >= 0 ? entry.size : 0
125
+ ),
126
+ 0,
127
+ );
128
+ if (declaredBytes > MAX_REPOSITORY_WORKFLOW_BYTES) {
129
+ throw new Error(
130
+ `${normalized.fullName} workflow sources exceed the ${MAX_REPOSITORY_WORKFLOW_BYTES}-byte repository limit`,
131
+ );
132
+ }
133
+ return workflowTree(normalized, {
134
+ entries,
135
+ empty: false,
136
+ treeSha: String(treeResponse.body.sha).toLowerCase(),
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Fetch workflow and composite-action YAML from a repository's default branch.
142
+ */
143
+ export async function fetchRepositoryWorkflows({
144
+ repository,
145
+ token,
146
+ cwd,
147
+ includePath,
148
+ workflowTree: suppliedWorkflowTree,
149
+ onRetry,
150
+ } = {}) {
151
+ const normalized = normalizeRepository(repository, repository?.owner);
152
+ if (suppliedWorkflowTree !== undefined && includePath !== undefined) {
153
+ throw new Error('includePath cannot be combined with a supplied workflow tree');
154
+ }
155
+ const tree = suppliedWorkflowTree ?? await fetchRepositoryWorkflowTree({
156
+ repository: normalized,
157
+ token,
158
+ cwd,
159
+ includePath,
160
+ onRetry,
161
+ });
162
+ validateWorkflowTree(tree, normalized);
163
+ if (tree.empty) {
164
+ return { sources: [], errors: [], empty: true, treeSha: tree.treeSha };
165
+ }
166
+
167
+ const sources = [];
168
+ const errors = [];
169
+ let sourceBytes = 0;
170
+ for (const entry of tree.entries) {
171
+ if (!SHA_RE.test(String(entry.sha ?? ''))) {
172
+ errors.push({ path: entry.path, error: 'workflow tree entry has an invalid blob SHA' });
173
+ continue;
174
+ }
175
+ if (
176
+ entry.size !== undefined
177
+ && (!Number.isSafeInteger(entry.size) || entry.size < 0)
178
+ ) {
179
+ errors.push({ path: entry.path, error: 'workflow tree entry has an invalid size' });
180
+ continue;
181
+ }
182
+ if (Number.isFinite(entry.size) && entry.size > MAX_WORKFLOW_BYTES) {
183
+ errors.push({
184
+ path: entry.path,
185
+ error: `workflow exceeds the ${MAX_WORKFLOW_BYTES}-byte scan limit`,
186
+ });
187
+ continue;
188
+ }
189
+ let source;
190
+ try {
191
+ source = await fetchBlobText({
192
+ owner: normalized.owner,
193
+ repo: normalized.name,
194
+ sha: entry.sha,
195
+ token,
196
+ cwd,
197
+ onRetry,
198
+ });
199
+ } catch (error) {
200
+ errors.push({ path: entry.path, error: String(error.message ?? error) });
201
+ continue;
202
+ }
203
+ sourceBytes += Buffer.byteLength(source);
204
+ if (sourceBytes > MAX_REPOSITORY_WORKFLOW_BYTES) {
205
+ throw new Error(
206
+ `${normalized.fullName} workflow sources exceed the ${MAX_REPOSITORY_WORKFLOW_BYTES}-byte repository limit`,
207
+ );
208
+ }
209
+ sources.push({ path: entry.path, source, sha: String(entry.sha).toLowerCase() });
210
+ }
211
+ return {
212
+ sources,
213
+ errors,
214
+ empty: false,
215
+ treeSha: tree.treeSha,
216
+ };
217
+ }
218
+
219
+ export function isWorkflowPath(path) {
220
+ if (
221
+ typeof path !== 'string'
222
+ || path.includes('\0')
223
+ || path.includes('\\')
224
+ || path.split('/').some(segment => !segment || segment === '.' || segment === '..')
225
+ ) return false;
226
+ return (
227
+ /^\.github\/workflows\/[^/]+\.ya?ml$/.test(path)
228
+ || /^action\.ya?ml$/.test(path)
229
+ || /^\.github\/actions\/(?:.*\/)?action\.ya?ml$/.test(path)
230
+ );
231
+ }
232
+
233
+ async function fetchBlobText({ owner, repo, sha, token, cwd, onRetry }) {
234
+ const response = await ghFetch({
235
+ url: `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`
236
+ + `/git/blobs/${encodeURIComponent(sha)}`,
237
+ token,
238
+ cwd,
239
+ useCache: false,
240
+ onRetry,
241
+ });
242
+ if (response.status !== 200) {
243
+ throw new Error(`could not read workflow blob ${sha} (HTTP ${response.status})`);
244
+ }
245
+ const body = response.body;
246
+ if (!body || typeof body !== 'object' || body.encoding !== 'base64' || typeof body.content !== 'string') {
247
+ throw new Error(`invalid workflow blob response for ${sha}`);
248
+ }
249
+ if (String(body.sha ?? '').toLowerCase() !== sha.toLowerCase()) {
250
+ throw new Error(`workflow blob response did not match ${sha}`);
251
+ }
252
+ if (body.size !== undefined && (!Number.isSafeInteger(body.size) || body.size < 0)) {
253
+ throw new Error(`workflow blob ${sha} has an invalid size`);
254
+ }
255
+ if (Number.isFinite(body.size) && body.size > MAX_WORKFLOW_BYTES) {
256
+ throw new Error(`workflow exceeds the ${MAX_WORKFLOW_BYTES}-byte scan limit`);
257
+ }
258
+ const compact = body.content.replace(/\s/g, '');
259
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(compact)) {
260
+ throw new Error(`workflow blob ${sha} is not valid base64`);
261
+ }
262
+ const bytes = Buffer.from(compact, 'base64');
263
+ if (bytes.length > MAX_WORKFLOW_BYTES) {
264
+ throw new Error(`workflow exceeds the ${MAX_WORKFLOW_BYTES}-byte scan limit`);
265
+ }
266
+ if (Number.isFinite(body.size) && bytes.length !== body.size) {
267
+ throw new Error(`workflow blob ${sha} size did not match its metadata`);
268
+ }
269
+ try {
270
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
271
+ } catch {
272
+ throw new Error(`workflow blob ${sha} is not valid UTF-8`);
273
+ }
274
+ }
275
+
276
+ function workflowTree(repository, { entries, empty, treeSha }) {
277
+ return {
278
+ repository: repository.fullName,
279
+ branch: repository.defaultBranch,
280
+ entries: entries.map(entry => ({
281
+ path: entry.path,
282
+ type: entry.type,
283
+ sha: entry.sha,
284
+ ...(entry.size === undefined ? {} : { size: entry.size }),
285
+ })),
286
+ empty,
287
+ treeSha,
288
+ };
289
+ }
290
+
291
+ function validateWorkflowTree(tree, repository) {
292
+ if (
293
+ !tree
294
+ || typeof tree !== 'object'
295
+ || tree.repository !== repository.fullName
296
+ || tree.branch !== repository.defaultBranch
297
+ || typeof tree.empty !== 'boolean'
298
+ || !Array.isArray(tree.entries)
299
+ || (tree.treeSha !== null && !SHA_RE.test(String(tree.treeSha)))
300
+ ) {
301
+ throw new Error(`invalid workflow tree snapshot for ${repository.fullName}`);
302
+ }
303
+ if (tree.empty && tree.entries.length > 0) {
304
+ throw new Error(`invalid workflow tree snapshot for ${repository.fullName}`);
305
+ }
306
+ if (!tree.empty && tree.treeSha === null) {
307
+ throw new Error(`invalid workflow tree snapshot for ${repository.fullName}`);
308
+ }
309
+ if (tree.entries.length > MAX_WORKFLOW_FILES) {
310
+ throw new Error(`invalid workflow tree snapshot for ${repository.fullName}`);
311
+ }
312
+ for (const entry of tree.entries) {
313
+ if (
314
+ !entry
315
+ || typeof entry !== 'object'
316
+ || entry.type !== 'blob'
317
+ || !isWorkflowPath(entry.path)
318
+ ) {
319
+ throw new Error(`invalid workflow tree snapshot for ${repository.fullName}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ function normalizeRepository(raw, expectedOwner) {
325
+ if (!raw || typeof raw !== 'object') throw new Error('invalid repository record');
326
+ const owner = validateName(
327
+ raw.owner?.login ?? (typeof raw.owner === 'string' ? raw.owner : expectedOwner),
328
+ 'repository owner',
329
+ );
330
+ const name = validateName(raw.name, 'repository name');
331
+ const fullName = `${owner}/${name}`;
332
+ const suppliedFullName = raw.full_name ?? raw.fullName;
333
+ if (
334
+ suppliedFullName !== undefined
335
+ && String(suppliedFullName).toLowerCase() !== fullName.toLowerCase()
336
+ ) {
337
+ throw new Error(`invalid repository identity: ${suppliedFullName}`);
338
+ }
339
+ const suppliedDefaultBranch = raw.default_branch ?? raw.defaultBranch;
340
+ const defaultBranch = suppliedDefaultBranch == null
341
+ ? null
342
+ : validateRef(String(suppliedDefaultBranch));
343
+ const visibility = ['public', 'private', 'internal'].includes(raw.visibility)
344
+ ? raw.visibility
345
+ : raw.private === true ? 'private' : 'public';
346
+ return {
347
+ owner,
348
+ name,
349
+ fullName,
350
+ defaultBranch,
351
+ visibility,
352
+ private: raw.private === true,
353
+ fork: raw.fork === true,
354
+ archived: raw.archived === true,
355
+ disabled: raw.disabled === true,
356
+ htmlUrl: typeof (raw.html_url ?? raw.htmlUrl) === 'string'
357
+ ? (raw.html_url ?? raw.htmlUrl)
358
+ : `https://github.com/${owner}/${name}`,
359
+ };
360
+ }
361
+
362
+ function validateName(value, label) {
363
+ if (typeof value !== 'string' || !NAME_RE.test(value) || value === '.' || value === '..') {
364
+ throw new Error(`invalid ${label}`);
365
+ }
366
+ return value;
367
+ }
368
+
369
+ function validateRef(value) {
370
+ if (!value || value.includes('\0') || value.startsWith('/') || value.endsWith('/')) {
371
+ throw new Error('invalid default branch');
372
+ }
373
+ return value;
374
+ }
@@ -0,0 +1,62 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { relative, sep } from 'node:path';
3
+
4
+ /**
5
+ * Return a stable repository-relative path using POSIX separators.
6
+ *
7
+ * @param {string} file
8
+ * @param {string} cwd
9
+ */
10
+ export function canonicalPath(file, cwd = process.cwd()) {
11
+ return relative(cwd, file).split(sep).join('/');
12
+ }
13
+
14
+ /**
15
+ * Create a stable, collision-resistant identifier for a source occurrence.
16
+ *
17
+ * IDs deliberately exclude absolute paths so they remain stable across clones.
18
+ *
19
+ * @param {object} input
20
+ * @param {string} input.kind
21
+ * @param {string} input.file
22
+ * @param {string} input.cwd
23
+ * @param {number} [input.line]
24
+ * @param {number} [input.start]
25
+ * @param {string} [input.subject]
26
+ */
27
+ export function occurrenceId({
28
+ kind,
29
+ file,
30
+ cwd = process.cwd(),
31
+ line = 0,
32
+ start = 0,
33
+ subject = '',
34
+ }) {
35
+ const identity = JSON.stringify({
36
+ kind,
37
+ file: canonicalPath(file, cwd),
38
+ line,
39
+ start,
40
+ subject,
41
+ });
42
+ return createHash('sha256').update(identity).digest('hex').slice(0, 16);
43
+ }
44
+
45
+ /**
46
+ * The ID shared by an unpinned-action finding and its pin change.
47
+ *
48
+ * @param {object} input
49
+ * @param {string} input.file
50
+ * @param {string} input.cwd
51
+ * @param {{line: number, start?: number, raw: string}} input.ref
52
+ */
53
+ export function pinOccurrenceId({ file, cwd, ref }) {
54
+ return occurrenceId({
55
+ kind: 'pin',
56
+ file,
57
+ cwd,
58
+ line: ref.line,
59
+ start: ref.start ?? 0,
60
+ subject: ref.raw,
61
+ });
62
+ }
package/src/lib/ignore.js CHANGED
@@ -16,12 +16,13 @@
16
16
  */
17
17
 
18
18
  const PREFIX = '(?:actions-warden|aw)';
19
- const TAIL = '(?::\\s*([\\w,\\s-]+))?\\s*$';
20
- const RE_FILE = new RegExp(`#\\s*${PREFIX}-ignore-file${TAIL}`);
21
- const RE_START = new RegExp(`#\\s*${PREFIX}-ignore-start${TAIL}`);
22
- const RE_END = new RegExp(`#\\s*${PREFIX}-ignore-end\\s*$`);
23
- const RE_NEXT = new RegExp(`#\\s*${PREFIX}-ignore-next-line${TAIL}`);
24
- const RE_INLINE = new RegExp(`#\\s*${PREFIX}-ignore(?!-)${TAIL}`);
19
+ const LEAD = '(?:#|;)\\s*';
20
+ const TAIL = '(?::\\s*([\\w,\\s-]+?))?\\s*(?=;|$)';
21
+ const RE_FILE = new RegExp(`${LEAD}${PREFIX}-ignore-file${TAIL}`);
22
+ const RE_START = new RegExp(`${LEAD}${PREFIX}-ignore-start${TAIL}`);
23
+ const RE_END = new RegExp(`${LEAD}${PREFIX}-ignore-end\\s*(?=;|$)`);
24
+ const RE_NEXT = new RegExp(`${LEAD}${PREFIX}-ignore-next-line${TAIL}`);
25
+ const RE_INLINE = new RegExp(`${LEAD}${PREFIX}-ignore(?!-)${TAIL}`);
25
26
 
26
27
  /**
27
28
  * @typedef {object} IgnoreScope