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
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Organization scan command - enumerate repositories through the GitHub API,
3
+ * fetch workflow YAML from each default branch, and aggregate audit findings.
4
+ */
5
+
6
+ import { resolve } from 'node:path';
7
+ import picomatch from 'picomatch';
8
+ import { auditSources } from './audit.js';
9
+ import { loadBaseline } from '../lib/baseline.js';
10
+ import { filterIgnoredPaths, loadConfig } from '../lib/config.js';
11
+ import { mapLimit } from '../lib/concurrency.js';
12
+ import { format, summarize } from '../lib/formatter.js';
13
+ import { canonicalPath } from '../lib/identity.js';
14
+ import {
15
+ fetchRepositoryWorkflowTree,
16
+ fetchRepositoryWorkflows,
17
+ listOrganizationRepositories,
18
+ } from '../lib/github-org.js';
19
+ import {
20
+ canReuseCheckpointResult,
21
+ createOrganizationCheckpointIdentity,
22
+ loadOrganizationCheckpoint,
23
+ restoreCheckpointResult,
24
+ validateOrganizationCheckpointPath,
25
+ writeOrganizationCheckpoint,
26
+ } from '../lib/org-checkpoint.js';
27
+ import { resolveToken } from '../lib/resolver.js';
28
+ import { listRules, RULES } from '../rules/index.js';
29
+ import { sameFilePath } from '../lib/path-equality.js';
30
+
31
+ const VISIBILITIES = new Set(['all', 'public', 'private', 'internal']);
32
+ const ORGANIZATION_RE = /^[A-Za-z0-9_.-]+$/;
33
+
34
+ /**
35
+ * @param {object} opts
36
+ * @param {string} opts.organization
37
+ * @param {string} [opts.cwd]
38
+ * @param {string} [opts.token]
39
+ * @param {string[]} [opts.repositories]
40
+ * @param {'all'|'public'|'private'|'internal'} [opts.visibility]
41
+ * @param {boolean} [opts.includeArchived]
42
+ * @param {boolean} [opts.includeDisabled]
43
+ * @param {boolean} [opts.includeForks]
44
+ * @param {number} [opts.maxRepositories]
45
+ * @param {number} [opts.concurrency]
46
+ * @param {'low'|'medium'|'high'|'critical'} [opts.severity]
47
+ * @param {boolean} [opts.explain]
48
+ * @param {string|false} [opts.configPath]
49
+ * @param {string} [opts.baseline]
50
+ * @param {string} [opts.checkpointPath]
51
+ * @param {boolean} [opts.resume]
52
+ * @param {(event: object) => void|Promise<void>} [opts.onProgress]
53
+ */
54
+ export async function scanOrganization({
55
+ organization,
56
+ cwd = process.cwd(),
57
+ token,
58
+ repositories,
59
+ visibility = 'all',
60
+ includeArchived = false,
61
+ includeDisabled = false,
62
+ includeForks = false,
63
+ maxRepositories,
64
+ concurrency = 4,
65
+ severity,
66
+ explain = false,
67
+ configPath,
68
+ baseline,
69
+ checkpointPath,
70
+ resume = false,
71
+ onProgress,
72
+ } = {}) {
73
+ if (
74
+ typeof organization !== 'string'
75
+ || !ORGANIZATION_RE.test(organization)
76
+ || organization === '.'
77
+ || organization === '..'
78
+ ) throw new Error('invalid organization');
79
+ if (!VISIBILITIES.has(visibility)) throw new Error(`invalid visibility: ${visibility}`);
80
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) {
81
+ throw new Error('concurrency must be an integer from 1 to 16');
82
+ }
83
+ if (maxRepositories !== undefined && (!Number.isInteger(maxRepositories) || maxRepositories < 1)) {
84
+ throw new Error('max repositories must be a positive integer');
85
+ }
86
+ if (checkpointPath !== undefined && (typeof checkpointPath !== 'string' || !checkpointPath)) {
87
+ throw new Error('checkpoint path must be a non-empty string');
88
+ }
89
+ if (typeof resume !== 'boolean') throw new Error('resume must be a boolean');
90
+ if (resume && !checkpointPath) throw new Error('resume requires a checkpoint path');
91
+ if (onProgress !== undefined && typeof onProgress !== 'function') {
92
+ throw new Error('onProgress must be a function');
93
+ }
94
+ const startedAt = Date.now();
95
+ await emitProgress(onProgress, { type: 'scan-started', organization });
96
+ const patterns = normalizePatterns(repositories);
97
+ const resolvedToken = resolveToken(token);
98
+ const config = await loadConfig({
99
+ cwd,
100
+ path: configPath,
101
+ ruleIds: RULES.map(rule => rule.id),
102
+ });
103
+ const baselinePath = baseline ?? config.baseline;
104
+ const baselineData = baselinePath
105
+ ? await loadBaseline({ path: baselinePath, cwd })
106
+ : { path: null, ids: new Set(), fingerprints: new Set() };
107
+ if (checkpointPath) {
108
+ await validateOrganizationCheckpointPath({ path: checkpointPath, cwd });
109
+ await assertCheckpointDoesNotReplaceControlFile(checkpointPath, cwd, [
110
+ config.path,
111
+ baselineData.path,
112
+ ]);
113
+ }
114
+ const checkpointIdentity = createOrganizationCheckpointIdentity({
115
+ organization,
116
+ repositories: patterns,
117
+ visibility,
118
+ includeArchived,
119
+ includeDisabled,
120
+ includeForks,
121
+ maxRepositories,
122
+ severity,
123
+ explain,
124
+ config,
125
+ baselineData,
126
+ });
127
+ const loadedCheckpoint = resume
128
+ ? await loadOrganizationCheckpoint({ path: checkpointPath, cwd, identity: checkpointIdentity })
129
+ : { results: new Map(), migrationRequired: false };
130
+ if (resume) {
131
+ await emitProgress(onProgress, {
132
+ type: 'checkpoint-loaded',
133
+ repositories: loadedCheckpoint.results.size,
134
+ });
135
+ }
136
+ await emitProgress(onProgress, { type: 'discovery-started', organization });
137
+ const discovered = await listOrganizationRepositories({
138
+ organization,
139
+ token: resolvedToken,
140
+ cwd,
141
+ onRetry: retry => emitRetryProgress(onProgress, retry),
142
+ });
143
+ const eligible = selectRepositories(discovered, {
144
+ patterns,
145
+ visibility,
146
+ includeArchived,
147
+ includeDisabled,
148
+ includeForks,
149
+ });
150
+ if (patterns.length > 0 && eligible.length === 0) {
151
+ throw new Error(`no repositories matched: ${patterns.join(', ')}`);
152
+ }
153
+ const selected = maxRepositories === undefined
154
+ ? eligible
155
+ : eligible.slice(0, maxRepositories);
156
+ await emitProgress(onProgress, {
157
+ type: 'discovery-completed',
158
+ organization,
159
+ discovered: discovered.length,
160
+ eligible: eligible.length,
161
+ selected: selected.length,
162
+ });
163
+
164
+ const checkpointResults = new Map();
165
+ for (const repository of selected) {
166
+ const stored = loadedCheckpoint.results.get(repository.fullName.toLowerCase());
167
+ if (!stored) continue;
168
+ checkpointResults.set(repository.fullName.toLowerCase(), restoreCheckpointResult(stored, {
169
+ repository,
170
+ cwd,
171
+ sourceUrl,
172
+ }));
173
+ }
174
+ const persistCheckpoint = createCheckpointWriter({
175
+ path: checkpointPath,
176
+ cwd,
177
+ identity: checkpointIdentity,
178
+ results: checkpointResults,
179
+ });
180
+ if (checkpointPath && (!resume || loadedCheckpoint.migrationRequired)) {
181
+ await persistCheckpoint();
182
+ if (!resume) await emitProgress(onProgress, { type: 'checkpoint-created' });
183
+ }
184
+
185
+ let completed = 0;
186
+ let reused = 0;
187
+ const repositoryResults = await mapLimit(selected, concurrency, async (repository, index) => {
188
+ await emitProgress(onProgress, {
189
+ type: 'repository-started',
190
+ repository: repository.fullName,
191
+ position: index + 1,
192
+ total: selected.length,
193
+ });
194
+ let repositoryResult;
195
+ let wasReused = false;
196
+ try {
197
+ const workflowTree = await fetchRepositoryWorkflowTree({
198
+ repository,
199
+ token: resolvedToken,
200
+ cwd,
201
+ includePath: path => filterIgnoredPaths([
202
+ virtualPath(cwd, repository, path),
203
+ ], config, cwd).length > 0,
204
+ onRetry: retry => emitRetryProgress(onProgress, retry, repository.fullName),
205
+ });
206
+ const stored = loadedCheckpoint.results.get(repository.fullName.toLowerCase());
207
+ if (stored && canReuseCheckpointResult(stored, repository, workflowTree.treeSha)) {
208
+ repositoryResult = restoreCheckpointResult(stored, { repository, cwd, sourceUrl });
209
+ wasReused = true;
210
+ } else {
211
+ const fetched = await fetchRepositoryWorkflows({
212
+ repository,
213
+ token: resolvedToken,
214
+ cwd,
215
+ workflowTree,
216
+ onRetry: retry => emitRetryProgress(onProgress, retry, repository.fullName),
217
+ });
218
+ const sources = fetched.sources.map(item => ({
219
+ file: virtualPath(cwd, repository, item.path),
220
+ source: item.source,
221
+ }));
222
+ const auditResult = await auditSources({
223
+ cwd,
224
+ sources,
225
+ severity,
226
+ explain,
227
+ config,
228
+ baselineData,
229
+ });
230
+ const errors = fetched.errors.map(error => ({
231
+ repository: repository.fullName,
232
+ path: error.path,
233
+ error: error.error,
234
+ }));
235
+ const findings = auditResult.findings.map(finding => ({
236
+ ...finding,
237
+ repository: repository.fullName,
238
+ branch: repository.defaultBranch,
239
+ url: sourceUrl(
240
+ repository,
241
+ canonicalPath(finding.file, resolve(cwd, repository.owner, repository.name)),
242
+ finding.line,
243
+ ),
244
+ }));
245
+ repositoryResult = {
246
+ repository,
247
+ revision: {
248
+ branch: repository.defaultBranch,
249
+ treeSha: fetched.treeSha,
250
+ },
251
+ files: auditResult.files.map(file => canonicalPath(
252
+ file,
253
+ resolve(cwd, repository.owner, repository.name),
254
+ )),
255
+ findings,
256
+ errors,
257
+ summary: auditResult.summary,
258
+ status: auditResult.status === 'FAIL' || errors.length > 0 ? 'FAIL' : 'OK',
259
+ };
260
+ }
261
+ } catch (error) {
262
+ if (error instanceof ProgressCallbackError) throw error;
263
+ repositoryResult = {
264
+ repository,
265
+ revision: { branch: repository.defaultBranch, treeSha: null },
266
+ files: [],
267
+ findings: [],
268
+ errors: [{
269
+ repository: repository.fullName,
270
+ error: String(error.message ?? error),
271
+ }],
272
+ summary: emptySummary(),
273
+ status: 'FAIL',
274
+ };
275
+ }
276
+ if (checkpointPath) {
277
+ checkpointResults.set(repository.fullName.toLowerCase(), repositoryResult);
278
+ await persistCheckpoint();
279
+ }
280
+ completed += 1;
281
+ if (wasReused) reused += 1;
282
+ await emitProgress(onProgress, {
283
+ type: 'repository-completed',
284
+ repository: repository.fullName,
285
+ position: index + 1,
286
+ total: selected.length,
287
+ completed,
288
+ reused: wasReused,
289
+ status: repositoryResult.status,
290
+ files: repositoryResult.files.length,
291
+ findings: repositoryResult.findings.length,
292
+ errors: repositoryResult.errors.length,
293
+ });
294
+ return repositoryResult;
295
+ });
296
+
297
+ const findings = repositoryResults.flatMap(result => result.findings);
298
+ const errors = repositoryResults.flatMap(result => result.errors);
299
+ const counts = summarize(findings);
300
+ const summary = {
301
+ repositoriesDiscovered: discovered.length,
302
+ repositoriesEligible: eligible.length,
303
+ repositoriesSelected: selected.length,
304
+ repositoriesScanned: repositoryResults.length,
305
+ repositoriesWithWorkflows: repositoryResults.filter(result => result.files.length > 0).length,
306
+ repositoriesWithFindings: repositoryResults.filter(result => result.findings.length > 0).length,
307
+ repositoriesFailed: repositoryResults.filter(result => result.errors.length > 0).length,
308
+ repositoriesSkipped: discovered.length - selected.length,
309
+ files: repositoryResults.reduce((total, result) => total + result.files.length, 0),
310
+ findings: findings.length,
311
+ totalFindings: repositoryResults.reduce(
312
+ (total, result) => total + result.summary.totalFindings,
313
+ 0,
314
+ ),
315
+ suppressed: repositoryResults.reduce((total, result) => total + result.summary.suppressed, 0),
316
+ errors: errors.length,
317
+ ...counts,
318
+ };
319
+ const result = {
320
+ organization,
321
+ scope: {
322
+ repositories: patterns,
323
+ visibility,
324
+ includeArchived,
325
+ includeDisabled,
326
+ includeForks,
327
+ maxRepositories: maxRepositories ?? null,
328
+ concurrency,
329
+ severity: severity ?? null,
330
+ },
331
+ repositories: repositoryResults,
332
+ findings,
333
+ errors,
334
+ summary,
335
+ baseline: {
336
+ path: baselineData.path,
337
+ suppressed: summary.suppressed,
338
+ },
339
+ configPath: config.path,
340
+ status: findings.length === 0 && errors.length === 0 ? 'OK' : 'FAIL',
341
+ };
342
+ await emitProgress(onProgress, {
343
+ type: 'scan-completed',
344
+ organization,
345
+ status: result.status,
346
+ total: selected.length,
347
+ completed,
348
+ reused,
349
+ findings: summary.findings,
350
+ errors: summary.errors,
351
+ elapsedMs: Date.now() - startedAt,
352
+ });
353
+ return result;
354
+ }
355
+
356
+ export function renderOrganizationScan(result, { format: outputFormat, cwd = process.cwd() }) {
357
+ if (outputFormat === 'json') {
358
+ return format('json', [], {
359
+ status: result.status,
360
+ json: {
361
+ schemaVersion: '1.0',
362
+ organization: result.organization,
363
+ scope: result.scope,
364
+ repositories: result.repositories.map(repositoryResult => ({
365
+ repository: repositoryResult.repository,
366
+ revision: repositoryResult.revision,
367
+ files: repositoryResult.files,
368
+ findings: repositoryResult.findings.map(finding => normalizeFinding(finding, cwd)),
369
+ errors: repositoryResult.errors,
370
+ summary: repositoryResult.summary,
371
+ status: repositoryResult.status,
372
+ })),
373
+ findings: result.findings.map(finding => normalizeFinding(finding, cwd)),
374
+ errors: result.errors,
375
+ summary: result.summary,
376
+ baseline: {
377
+ ...result.baseline,
378
+ path: result.baseline.path ? canonicalPath(result.baseline.path, cwd) : null,
379
+ },
380
+ configPath: result.configPath ? canonicalPath(result.configPath, cwd) : null,
381
+ status: result.status,
382
+ },
383
+ });
384
+ }
385
+
386
+ const records = [];
387
+ if (outputFormat === 'sarif') {
388
+ for (const rule of listRules()) records.push({ label: 'RULE', fields: rule });
389
+ }
390
+ for (const repositoryResult of result.repositories) {
391
+ records.push({
392
+ label: 'REPOSITORY',
393
+ fields: {
394
+ repo: repositoryResult.repository.fullName,
395
+ visibility: repositoryResult.repository.visibility,
396
+ branch: repositoryResult.revision.branch,
397
+ files: repositoryResult.files.length,
398
+ findings: repositoryResult.findings.length,
399
+ errors: repositoryResult.errors.length,
400
+ status: repositoryResult.status,
401
+ },
402
+ });
403
+ for (const finding of repositoryResult.findings) {
404
+ records.push({
405
+ label: 'FINDING',
406
+ fields: {
407
+ id: finding.id,
408
+ ...finding.fields,
409
+ repo: finding.repository,
410
+ file: canonicalPath(finding.file, cwd),
411
+ line: finding.line,
412
+ url: finding.url,
413
+ ...(finding.explain ? { explain: finding.explain } : {}),
414
+ ...(outputFormat === 'sarif' && finding.fingerprint
415
+ ? { fingerprint: finding.fingerprint }
416
+ : {}),
417
+ },
418
+ });
419
+ }
420
+ for (const error of repositoryResult.errors) {
421
+ records.push({
422
+ label: 'ERROR',
423
+ fields: {
424
+ repo: error.repository,
425
+ file: error.path ? `${error.repository}/${error.path}` : undefined,
426
+ msg: error.error,
427
+ },
428
+ });
429
+ }
430
+ }
431
+ records.push({
432
+ label: 'SUMMARY',
433
+ fields: { organization: result.organization, ...result.summary },
434
+ });
435
+ return format(outputFormat, records, { status: result.status });
436
+ }
437
+
438
+ function selectRepositories(repositories, options) {
439
+ const matchers = options.patterns.map(pattern => picomatch(pattern, { nocase: true }));
440
+ return repositories.filter(repository => {
441
+ if (!options.includeArchived && repository.archived) return false;
442
+ if (!options.includeDisabled && repository.disabled) return false;
443
+ if (!options.includeForks && repository.fork) return false;
444
+ if (options.visibility !== 'all' && repository.visibility !== options.visibility) return false;
445
+ if (
446
+ matchers.length > 0
447
+ && !matchers.some(matches => matches(repository.name) || matches(repository.fullName))
448
+ ) return false;
449
+ return true;
450
+ });
451
+ }
452
+
453
+ function normalizePatterns(patterns) {
454
+ if (patterns === undefined) return [];
455
+ if (!Array.isArray(patterns) || patterns.some(pattern => typeof pattern !== 'string' || !pattern)) {
456
+ throw new Error('repository filters must be non-empty strings');
457
+ }
458
+ return [...new Set(patterns)];
459
+ }
460
+
461
+ function normalizeFinding(finding, cwd) {
462
+ return {
463
+ ...finding,
464
+ file: canonicalPath(finding.file, cwd),
465
+ };
466
+ }
467
+
468
+ function sourceUrl(repository, path, line) {
469
+ if (!repository.defaultBranch) return repository.htmlUrl;
470
+ const encodedPath = path.split('/').map(encodeURIComponent).join('/');
471
+ const encodedBranch = encodeURIComponent(repository.defaultBranch);
472
+ const suffix = Number.isInteger(line) && line > 0 ? `#L${line}` : '';
473
+ return `${repository.htmlUrl}/blob/${encodedBranch}/${encodedPath}${suffix}`;
474
+ }
475
+
476
+ function virtualPath(cwd, repository, path) {
477
+ return resolve(cwd, repository.owner, repository.name, ...path.split('/'));
478
+ }
479
+
480
+ function createCheckpointWriter({ path, cwd, identity, results }) {
481
+ let pending = Promise.resolve();
482
+ return async function persistCheckpoint() {
483
+ if (!path) return;
484
+ const snapshot = [...results.values()];
485
+ const write = pending.then(() => writeOrganizationCheckpoint({
486
+ path,
487
+ cwd,
488
+ identity,
489
+ repositoryResults: snapshot,
490
+ }));
491
+ pending = write;
492
+ await write;
493
+ };
494
+ }
495
+
496
+ async function assertCheckpointDoesNotReplaceControlFile(path, cwd, controlFiles) {
497
+ const requested = resolve(cwd, path);
498
+ for (const controlFile of controlFiles) {
499
+ if (controlFile && await sameFilePath(requested, resolve(controlFile))) {
500
+ throw new Error('checkpoint path cannot replace the active config or baseline');
501
+ }
502
+ }
503
+ }
504
+
505
+ async function emitRetryProgress(onProgress, retry, repository) {
506
+ await emitProgress(onProgress, {
507
+ type: 'request-retry',
508
+ ...(repository ? { repository } : {}),
509
+ attempt: retry.attempt,
510
+ maxRetries: retry.maxRetries,
511
+ reason: retry.reason,
512
+ delayMs: retry.delayMs,
513
+ ...(retry.status === undefined ? {} : { status: retry.status }),
514
+ });
515
+ }
516
+
517
+ async function emitProgress(onProgress, event) {
518
+ if (!onProgress) return;
519
+ try {
520
+ await onProgress(Object.freeze({ ...event }));
521
+ } catch (error) {
522
+ throw new ProgressCallbackError(error);
523
+ }
524
+ }
525
+
526
+ class ProgressCallbackError extends Error {
527
+ constructor(cause) {
528
+ super(`organization progress callback failed: ${String(cause?.message ?? cause)}`, { cause });
529
+ this.name = 'ProgressCallbackError';
530
+ }
531
+ }
532
+
533
+ function emptySummary() {
534
+ return {
535
+ files: 0,
536
+ findings: 0,
537
+ totalFindings: 0,
538
+ suppressed: 0,
539
+ critical: 0,
540
+ high: 0,
541
+ medium: 0,
542
+ low: 0,
543
+ };
544
+ }