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
@@ -11,11 +11,13 @@
11
11
  * { id, ruleId, severity, file, line, fields, explain }
12
12
  */
13
13
 
14
- import { createHash } from 'node:crypto';
15
- import { parseWorkflowFile } from '../lib/parser.js';
16
- import { discoverWorkflows, resolveWorkflowArg } from '../lib/paths.js';
14
+ import { parseWorkflowFile, parseWorkflowSource } from '../lib/parser.js';
17
15
  import { format, summarize, SEVERITY_ORDER } from '../lib/formatter.js';
18
16
  import { parseIgnoreDirectives, isIgnored } from '../lib/ignore.js';
17
+ import { canonicalPath, occurrenceId, pinOccurrenceId } from '../lib/identity.js';
18
+ import { resolveTargets } from '../lib/targets.js';
19
+ import { assignBaselineFingerprints, loadBaseline } from '../lib/baseline.js';
20
+ import { DEFAULT_CONFIG, filterIgnoredPaths, loadConfig } from '../lib/config.js';
19
21
  import { RULES } from '../rules/index.js';
20
22
 
21
23
  /**
@@ -35,78 +37,210 @@ import { RULES } from '../rules/index.js';
35
37
  * @param {string[]} [opts.workflows] - explicit file/glob args
36
38
  * @param {'low'|'medium'|'high'|'critical'} [opts.severity] - minimum severity
37
39
  * @param {boolean} [opts.explain]
38
- * @returns {Promise<{findings: Finding[], summary: object, status: 'OK'|'FAIL', files: string[]}>}
40
+ * @param {string|false} [opts.configPath]
41
+ * @param {string} [opts.baseline]
42
+ * @param {boolean} [opts.ignoreBaseline]
43
+ * @returns {Promise<{findings: Finding[], allFindings: Finding[], summary: object, status: 'OK'|'FAIL', files: string[], baseline: object}>}
39
44
  */
40
- export async function audit({ cwd = process.cwd(), workflows, severity, explain = false } = {}) {
41
- const files = await resolveTargets(workflows, cwd);
45
+ export async function audit({
46
+ cwd = process.cwd(),
47
+ workflows,
48
+ severity,
49
+ explain = false,
50
+ configPath,
51
+ baseline,
52
+ ignoreBaseline = false,
53
+ } = {}) {
54
+ const config = await loadConfig({
55
+ cwd,
56
+ path: configPath,
57
+ ruleIds: RULES.map(rule => rule.id),
58
+ });
59
+ const resolvedFiles = await resolveTargets({ workflows, cwd });
60
+ const files = filterIgnoredPaths(resolvedFiles, config, cwd);
61
+ const baselineData = await resolveBaselineData({
62
+ baseline: ignoreBaseline ? null : (baseline ?? config.baseline),
63
+ cwd,
64
+ });
65
+ return runAudit({
66
+ cwd,
67
+ files,
68
+ severity,
69
+ explain,
70
+ config,
71
+ baselineData,
72
+ loadWorkflow: parseWorkflowFile,
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Audit workflow sources supplied by a caller without writing them to disk.
78
+ * This is the shared boundary used by remote scanners; rules receive the same
79
+ * parsed model as a local audit and therefore cannot tell local and remote
80
+ * inputs apart.
81
+ *
82
+ * @param {object} opts
83
+ * @param {string} [opts.cwd] - identity root for stable paths and finding IDs
84
+ * @param {Array<{file: string, source: string}>} opts.sources
85
+ * @param {'low'|'medium'|'high'|'critical'} [opts.severity]
86
+ * @param {boolean} [opts.explain]
87
+ * @param {typeof DEFAULT_CONFIG} [opts.config]
88
+ * @param {{path: string|null, ids: Set<string>, fingerprints: Set<string>}} [opts.baselineData]
89
+ */
90
+ export async function auditSources({
91
+ cwd = process.cwd(),
92
+ sources,
93
+ severity,
94
+ explain = false,
95
+ config = DEFAULT_CONFIG,
96
+ baselineData = emptyBaseline(),
97
+ } = {}) {
98
+ if (!Array.isArray(sources)) throw new Error('sources must be an array');
99
+ const sourceByFile = new Map();
100
+ for (const item of sources) {
101
+ if (!item || typeof item.file !== 'string' || typeof item.source !== 'string') {
102
+ throw new Error('every source must contain string file and source values');
103
+ }
104
+ if (sourceByFile.has(item.file)) throw new Error(`duplicate workflow source: ${item.file}`);
105
+ sourceByFile.set(item.file, item.source);
106
+ }
107
+ const files = filterIgnoredPaths([...sourceByFile.keys()].sort(), config, cwd);
108
+ return runAudit({
109
+ cwd,
110
+ files,
111
+ severity,
112
+ explain,
113
+ config,
114
+ baselineData,
115
+ loadWorkflow: async file => parseWorkflowSource(sourceByFile.get(file), file),
116
+ });
117
+ }
118
+
119
+ async function runAudit({
120
+ cwd,
121
+ files,
122
+ severity,
123
+ explain,
124
+ config,
125
+ baselineData,
126
+ loadWorkflow,
127
+ }) {
42
128
  /** @type {Finding[]} */
43
129
  const findings = [];
44
130
  for (const file of files) {
45
131
  let doc;
46
132
  try {
47
- doc = await parseWorkflowFile(file);
133
+ doc = await loadWorkflow(file);
48
134
  } catch (err) {
49
135
  findings.push({
50
- id: shortId(`parse:${file}`),
136
+ id: occurrenceId({ kind: 'parse-error', file, cwd }),
51
137
  ruleId: 'parse-error',
52
138
  severity: 'high',
53
139
  file,
54
140
  line: 0,
55
- fields: { type: 'parse-error', sev: 'high', file },
141
+ fields: { type: 'parse-error', sev: 'high', file: canonicalPath(file, cwd) },
56
142
  explain: String(err.message ?? err),
57
143
  });
58
144
  continue;
59
145
  }
60
146
  const ignore = parseIgnoreDirectives(doc.source);
61
147
  for (const rule of RULES) {
62
- const ruleFindings = rule.check(doc);
148
+ const rulePolicy = config.rules[rule.id];
149
+ if (rulePolicy?.enabled === false) continue;
150
+ const ruleFindings = rule.check(doc, {
151
+ config,
152
+ runnerPolicy: config.runnerPolicy,
153
+ });
63
154
  for (const f of ruleFindings) {
64
155
  if (isIgnored(ignore, f.line, rule.id)) continue;
156
+ const effectiveSeverity = rulePolicy?.severity ?? f.severity;
157
+ const fields = {
158
+ ...f.fields,
159
+ sev: effectiveSeverity,
160
+ file: canonicalPath(file, cwd),
161
+ };
65
162
  const finding = {
66
- id: shortId(`${rule.id}:${file}:${f.line}:${JSON.stringify(f.fields)}`),
163
+ id: rule.id === 'unpinned-action'
164
+ ? pinOccurrenceId({
165
+ file,
166
+ cwd,
167
+ ref: {
168
+ line: f.line,
169
+ start: f.start ?? 0,
170
+ raw: String(f.fields.action ?? ''),
171
+ },
172
+ })
173
+ : occurrenceId({
174
+ kind: rule.id,
175
+ file,
176
+ cwd,
177
+ line: f.line,
178
+ start: f.start ?? 0,
179
+ subject: JSON.stringify(f.fields),
180
+ }),
67
181
  ruleId: rule.id,
68
- severity: f.severity,
182
+ severity: effectiveSeverity,
69
183
  file,
70
184
  line: f.line,
71
- fields: { ...f.fields, file: relPath(file, cwd) },
185
+ fields,
72
186
  explain: f.explain,
73
187
  };
74
188
  findings.push(finding);
75
189
  }
76
190
  }
77
191
  }
78
- const filtered = filterBySeverity(findings, severity);
192
+ assignBaselineFingerprints(findings, cwd);
193
+ const severityFiltered = filterBySeverity(findings, severity);
194
+ const filtered = severityFiltered.filter(finding => (
195
+ finding.ruleId === 'parse-error'
196
+ || (
197
+ !baselineData.ids.has(finding.id)
198
+ && !baselineData.fingerprints.has(finding.fingerprint)
199
+ )
200
+ ));
201
+ const suppressed = severityFiltered.length - filtered.length;
79
202
  const counts = summarize(filtered);
80
203
  const status = filtered.length === 0 ? 'OK' : 'FAIL';
81
204
  return {
82
205
  files,
83
206
  findings: explain ? filtered : filtered.map(stripExplain),
84
- summary: { files: files.length, findings: filtered.length, ...counts },
207
+ allFindings: findings,
208
+ summary: {
209
+ files: files.length,
210
+ findings: filtered.length,
211
+ totalFindings: severityFiltered.length,
212
+ suppressed,
213
+ ...counts,
214
+ },
215
+ baseline: {
216
+ path: baselineData.path,
217
+ suppressed,
218
+ },
219
+ configPath: config.path,
85
220
  status,
86
221
  };
87
222
  }
88
223
 
224
+ async function resolveBaselineData({ baseline, cwd }) {
225
+ return baseline
226
+ ? loadBaseline({ path: baseline, cwd })
227
+ : emptyBaseline();
228
+ }
229
+
230
+ function emptyBaseline() {
231
+ return { path: null, ids: new Set(), fingerprints: new Set() };
232
+ }
233
+
89
234
  function stripExplain(f) {
90
- const { explain: _unused, ...rest } = f;
91
- return rest;
235
+ const copy = { ...f };
236
+ delete copy.explain;
237
+ return copy;
92
238
  }
93
239
 
94
240
  /**
95
241
  * @param {string[]|undefined} workflows
96
242
  * @param {string} cwd
97
243
  */
98
- async function resolveTargets(workflows, cwd) {
99
- if (!workflows || workflows.length === 0) {
100
- return discoverWorkflows({ cwd });
101
- }
102
- const out = new Set();
103
- for (const w of workflows) {
104
- const files = await resolveWorkflowArg(w, cwd);
105
- for (const f of files) out.add(f);
106
- }
107
- return [...out].sort();
108
- }
109
-
110
244
  /**
111
245
  * @param {Finding[]} findings
112
246
  * @param {string|undefined} min
@@ -115,16 +249,14 @@ function filterBySeverity(findings, min) {
115
249
  if (!min) return findings;
116
250
  const minIdx = SEVERITY_ORDER.indexOf(min);
117
251
  if (minIdx === -1) return findings;
118
- return findings.filter(f => SEVERITY_ORDER.indexOf(f.severity) >= minIdx);
119
- }
120
-
121
- function shortId(input) {
122
- return createHash('sha1').update(input).digest('hex').slice(0, 10);
252
+ return findings.filter(f => (
253
+ f.ruleId === 'parse-error'
254
+ || SEVERITY_ORDER.indexOf(f.severity) >= minIdx
255
+ ));
123
256
  }
124
257
 
125
258
  function relPath(p, cwd) {
126
- if (p.startsWith(cwd)) return p.slice(cwd.length + 1);
127
- return p;
259
+ return canonicalPath(p, cwd);
128
260
  }
129
261
 
130
262
  /**
@@ -139,20 +271,41 @@ export function renderAudit(result, opts) {
139
271
  return format('json', [], {
140
272
  status: result.status,
141
273
  json: {
274
+ schemaVersion: '1.0',
142
275
  files: result.files.map(f => relPath(f, cwd)),
143
276
  findings: result.findings.map(f => ({ ...f, file: relPath(f.file, cwd) })),
144
277
  summary: result.summary,
278
+ baseline: {
279
+ ...result.baseline,
280
+ path: result.baseline.path ? relPath(result.baseline.path, cwd) : null,
281
+ },
282
+ configPath: result.configPath ? relPath(result.configPath, cwd) : null,
145
283
  status: result.status,
146
284
  },
147
285
  });
148
286
  }
149
287
  /** @type {Array<{label: string, fields: Record<string, unknown>}>} */
150
288
  const records = [];
289
+ if (opts.format === 'sarif') {
290
+ for (const rule of RULES) {
291
+ records.push({
292
+ label: 'RULE',
293
+ fields: {
294
+ id: rule.id,
295
+ severity: rule.severity,
296
+ description: rule.description,
297
+ },
298
+ });
299
+ }
300
+ }
151
301
  for (const f of result.files) {
152
302
  records.push({ label: 'SCAN', fields: { file: relPath(f, opts.cwd ?? process.cwd()) } });
153
303
  }
154
304
  for (const finding of result.findings) {
155
305
  const fields = { id: finding.id, ...finding.fields, line: finding.line };
306
+ if (opts.format === 'sarif' && finding.fingerprint) {
307
+ fields.fingerprint = finding.fingerprint;
308
+ }
156
309
  if (opts.explain) fields.explain = finding.explain;
157
310
  records.push({ label: 'FINDING', fields });
158
311
  }