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,199 @@
1
+ /**
2
+ * Apply non-overlapping source patches from right to left.
3
+ *
4
+ * @param {string} source
5
+ * @param {Array<{start: number, end: number, text: string, expected?: string}>} patches
6
+ */
7
+ export function applyPatches(source, patches) {
8
+ const ordered = [...patches].sort((a, b) => b.start - a.start || b.end - a.end);
9
+ let previousStart = source.length + 1;
10
+ let output = source;
11
+
12
+ for (const patch of ordered) {
13
+ if (
14
+ !Number.isInteger(patch.start)
15
+ || !Number.isInteger(patch.end)
16
+ || patch.start < 0
17
+ || patch.end < patch.start
18
+ || patch.end > source.length
19
+ ) {
20
+ throw new Error('invalid source patch range');
21
+ }
22
+ if (patch.end > previousStart) {
23
+ throw new Error('overlapping source patches');
24
+ }
25
+ if (
26
+ patch.expected !== undefined
27
+ && source.slice(patch.start, patch.end) !== patch.expected
28
+ ) {
29
+ throw new Error('workflow changed while planning; refusing to apply stale patch');
30
+ }
31
+ output = output.slice(0, patch.start) + patch.text + output.slice(patch.end);
32
+ previousStart = patch.start;
33
+ }
34
+ return output;
35
+ }
36
+
37
+ /**
38
+ * Locate a parsed action reference when callers constructed it without ranges.
39
+ *
40
+ * @param {string} source
41
+ * @param {{raw: string, line: number, start?: number, end?: number, lineStart?: number, lineEnd?: number}} ref
42
+ */
43
+ export function locateActionRef(source, ref) {
44
+ if (
45
+ Number.isInteger(ref.start)
46
+ && Number.isInteger(ref.end)
47
+ && ref.end > ref.start
48
+ && (
49
+ source.slice(ref.start, ref.end).includes(ref.raw)
50
+ || ref.alias === true
51
+ )
52
+ ) {
53
+ const lineStart = (
54
+ Number.isInteger(ref.lineStart)
55
+ && ref.lineStart <= ref.start
56
+ && !source.slice(ref.lineStart, ref.start).includes('\n')
57
+ )
58
+ ? ref.lineStart
59
+ : source.lastIndexOf('\n', Math.max(ref.start - 1, 0)) + 1;
60
+ const newline = source.indexOf('\n', ref.end);
61
+ const computedLineEnd = newline === -1 ? source.length : newline;
62
+ const lineEnd = Number.isInteger(ref.lineEnd) && ref.lineEnd >= ref.end
63
+ ? ref.lineEnd
64
+ : computedLineEnd;
65
+ return {
66
+ ...ref,
67
+ lineStart,
68
+ lineEnd,
69
+ column: Number.isInteger(ref.column) && ref.column > 0
70
+ ? ref.column
71
+ : ref.start - lineStart + 1,
72
+ };
73
+ }
74
+
75
+ const lines = source.split('\n');
76
+ const lineIndex = Math.max((ref.line || 1) - 1, 0);
77
+ let lineStart = 0;
78
+ for (let i = 0; i < lineIndex; i += 1) lineStart += lines[i].length + 1;
79
+ const lineText = lines[lineIndex] ?? '';
80
+ const relativeStart = lineText.indexOf(ref.raw);
81
+ if (relativeStart === -1) {
82
+ throw new Error(`could not locate uses reference on line ${ref.line}`);
83
+ }
84
+ let start = lineStart + relativeStart;
85
+ let end = start + ref.raw.length;
86
+ const before = source[start - 1];
87
+ const after = source[end];
88
+ if ((before === '"' || before === "'") && after === before) {
89
+ start -= 1;
90
+ end += 1;
91
+ }
92
+ return {
93
+ ...ref,
94
+ start,
95
+ end,
96
+ lineStart,
97
+ lineEnd: lineStart + lineText.length,
98
+ column: start - lineStart + 1,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Build exact patches for one `uses:` scalar and its version metadata.
104
+ *
105
+ * @param {string} source
106
+ * @param {object} ref
107
+ * @param {string} ref.raw
108
+ * @param {string} ref.owner
109
+ * @param {string} ref.repo
110
+ * @param {string|null} ref.subpath
111
+ * @param {number} ref.start
112
+ * @param {number} ref.end
113
+ * @param {number} ref.lineEnd
114
+ * @param {string} newRef
115
+ * @param {string} versionLabel
116
+ */
117
+ export function planUsesPatches(source, ref, newRef, versionLabel) {
118
+ const located = locateActionRef(source, ref);
119
+ const left = located.subpath
120
+ ? `${located.owner}/${located.repo}/${located.subpath}`
121
+ : `${located.owner}/${located.repo}`;
122
+ const nextValue = `${left}@${newRef}`;
123
+ const originalScalar = source.slice(located.start, located.end);
124
+ const quote = originalScalar[0] === '"' || originalScalar[0] === "'"
125
+ ? originalScalar[0]
126
+ : '';
127
+ const scalarText = quote ? `${quote}${nextValue}${quote}` : nextValue;
128
+ const patches = [{
129
+ start: located.start,
130
+ end: located.end,
131
+ text: scalarText,
132
+ expected: originalScalar,
133
+ }];
134
+
135
+ if (versionLabel) {
136
+ const commentPatch = planVersionCommentPatch(source, located, versionLabel);
137
+ if (commentPatch) patches.push(commentPatch);
138
+ }
139
+ return patches;
140
+ }
141
+
142
+ /**
143
+ * Read version metadata from either the new marker or the legacy `# v1.2.3`
144
+ * comment format.
145
+ *
146
+ * @param {string} source
147
+ * @param {object} ref
148
+ */
149
+ export function readVersionComment(source, ref) {
150
+ const located = locateActionRef(source, ref);
151
+ const trailing = source.slice(located.end, located.lineEnd);
152
+ const marker = trailing.match(/\bactions-warden-ref:\s*([^\s;#]+)/i);
153
+ if (marker) return marker[1];
154
+ const legacy = trailing.match(/^\s*#\s*(v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)\s*$/);
155
+ return legacy ? legacy[1] : null;
156
+ }
157
+
158
+ function planVersionCommentPatch(source, ref, versionLabel) {
159
+ const trailing = source.slice(ref.end, ref.lineEnd);
160
+ const marker = /\bactions-warden-ref:\s*([^\s;#]+)/i.exec(trailing);
161
+ if (marker) {
162
+ const valueOffset = marker.index + marker[0].lastIndexOf(marker[1]);
163
+ return {
164
+ start: ref.end + valueOffset,
165
+ end: ref.end + valueOffset + marker[1].length,
166
+ text: versionLabel,
167
+ expected: marker[1],
168
+ };
169
+ }
170
+
171
+ const legacy = /^(\s*#\s*)(v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)(\s*)$/.exec(trailing);
172
+ if (legacy) {
173
+ const valueOffset = legacy[1].length;
174
+ return {
175
+ start: ref.end + valueOffset,
176
+ end: ref.end + valueOffset + legacy[2].length,
177
+ text: versionLabel,
178
+ expected: legacy[2],
179
+ };
180
+ }
181
+
182
+ const hashOffset = trailing.indexOf('#');
183
+ if (hashOffset === -1) {
184
+ return {
185
+ start: ref.end,
186
+ end: ref.end,
187
+ text: ` # actions-warden-ref: ${versionLabel}`,
188
+ expected: '',
189
+ };
190
+ }
191
+
192
+ const insertAt = ref.end + hashOffset + 1;
193
+ return {
194
+ start: insertAt,
195
+ end: insertAt,
196
+ text: ` actions-warden-ref: ${versionLabel};`,
197
+ expected: '',
198
+ };
199
+ }
package/src/lib/paths.js CHANGED
@@ -2,12 +2,12 @@
2
2
  * Workflow file discovery with safe path handling.
3
3
  */
4
4
 
5
- import { readdir, stat } from 'node:fs/promises';
6
- import { resolve, join, relative } from 'node:path';
5
+ import { readdir, stat, realpath } from 'node:fs/promises';
6
+ import { resolve, join, relative, isAbsolute, sep } from 'node:path';
7
7
  import picomatch from 'picomatch';
8
8
 
9
9
  /**
10
- * Default workflow and composite-action globs.
10
+ * Default workflow directory globs.
11
11
  */
12
12
  export const DEFAULT_WORKFLOW_PATTERNS = [
13
13
  '.github/workflows/*.yml',
@@ -25,14 +25,30 @@ export const DEFAULT_WORKFLOW_PATTERNS = [
25
25
  * @param {string} cwd
26
26
  */
27
27
  function assertInside(p, cwd) {
28
+ if (typeof p !== 'string' || p.includes('\0')) {
29
+ throw new Error('invalid workflow path');
30
+ }
31
+ const root = resolve(cwd);
28
32
  const abs = resolve(cwd, p);
29
- const rel = relative(cwd, abs);
30
- if (rel.startsWith('..') || rel.includes('\0')) {
33
+ const rel = relative(root, abs);
34
+ if (isOutside(rel)) {
31
35
  throw new Error(`path traversal rejected: ${p}`);
32
36
  }
33
37
  return abs;
34
38
  }
35
39
 
40
+ function isOutside(rel) {
41
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
42
+ }
43
+
44
+ async function assertRealInside(path, cwd) {
45
+ const [root, target] = await Promise.all([realpath(resolve(cwd)), realpath(path)]);
46
+ if (isOutside(relative(root, target))) {
47
+ throw new Error(`symlink escape rejected: ${path}`);
48
+ }
49
+ return target;
50
+ }
51
+
36
52
  /**
37
53
  * Recursively list files under a directory.
38
54
  *
@@ -69,12 +85,17 @@ async function walk(dir, acc = []) {
69
85
  * @returns {Promise<string[]>}
70
86
  */
71
87
  export async function discoverWorkflows({ patterns = DEFAULT_WORKFLOW_PATTERNS, cwd = process.cwd() } = {}) {
72
- for (const p of patterns) assertInside(p, cwd);
73
- const matchers = patterns.map(p => picomatch(p, { dot: true }));
74
- const all = await walk(cwd);
88
+ const root = resolve(cwd);
89
+ for (const p of patterns) assertInside(p, root);
90
+ const normalizedPatterns = patterns.map(p => {
91
+ const pattern = isAbsolute(p) ? relative(root, p) : p;
92
+ return pattern.split(sep).join('/');
93
+ });
94
+ const matchers = normalizedPatterns.map(p => picomatch(p, { dot: true }));
95
+ const all = await walk(root);
75
96
  const out = [];
76
97
  for (const file of all) {
77
- const rel = relative(cwd, file);
98
+ const rel = relative(root, file).split(sep).join('/');
78
99
  if (matchers.some(m => m(rel))) out.push(file);
79
100
  }
80
101
  return out.sort();
@@ -92,11 +113,13 @@ export async function resolveWorkflowArg(input, cwd = process.cwd()) {
92
113
  const abs = assertInside(input, cwd);
93
114
  try {
94
115
  const st = await stat(abs);
116
+ const safePath = await assertRealInside(abs, cwd);
95
117
  if (st.isDirectory()) {
96
- const files = await walk(abs);
97
- return files.filter(f => /\.ya?ml$/.test(f));
118
+ const files = await walk(safePath);
119
+ return files.filter(f => /\.ya?ml$/i.test(f)).sort();
98
120
  }
99
- return [abs];
121
+ if (!st.isFile()) return [];
122
+ return [safePath];
100
123
  } catch {
101
124
  return discoverWorkflows({ patterns: [input], cwd });
102
125
  }
package/src/lib/redact.js CHANGED
@@ -12,12 +12,25 @@ const TOKEN_PATTERNS = [
12
12
  /ghu_[A-Za-z0-9]{30,}/g,
13
13
  /github_pat_[A-Za-z0-9_]{30,}/g,
14
14
  /xox[abprs]-[A-Za-z0-9-]{10,}/g,
15
- /AKIA[0-9A-Z]{16}/g,
15
+ /(?:AKIA|ASIA)[0-9A-Z]{16}/g,
16
16
  /sk-[A-Za-z0-9]{20,}/g,
17
+ /(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
18
+ /AIza[0-9A-Za-z_-]{30,}/g,
19
+ /ya29\.[0-9A-Za-z_-]{20,}/g,
20
+ /npm_[0-9A-Za-z]{20,}/g,
21
+ /eyJ[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}/g,
17
22
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
18
23
  ];
19
24
 
20
- const KV_TOKEN_KEYS = /\b(token|secret|password|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key)\s*[:=]\s*["']?([^"'\s,]+)/gi;
25
+ const SENSITIVE_KEY = /(?:token|secret|password|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key)$/i;
26
+ const KV_TOKEN_KEYS = /([\w-]*(?:token|secret|password|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key))(\s*[:=]\s*)["']?([^"'\s,]+)/gi;
27
+ const HIGH_ENTROPY = /[A-Za-z0-9_+/-]{32,}={0,2}/g;
28
+ // This public rule ID is long enough to trip the generic entropy heuristic.
29
+ // Keep the exception exact instead of weakening detection for arbitrary
30
+ // kebab-case values, which may still be credentials or passphrases.
31
+ const SAFE_PUBLIC_VALUES = new Set([
32
+ 'reusable-workflow-secrets-inherit',
33
+ ]);
21
34
 
22
35
  /**
23
36
  * Redacts sensitive substrings from a value.
@@ -29,10 +42,58 @@ export function redact(input) {
29
42
  if (input == null) return '';
30
43
  let s = typeof input === 'string' ? input : String(input);
31
44
  for (const re of TOKEN_PATTERNS) s = s.replace(re, '<redacted>');
32
- s = s.replace(KV_TOKEN_KEYS, (_, key) => `${key}=<redacted>`);
45
+ s = s.replace(KV_TOKEN_KEYS, (_, key, separator) => `${key}${separator}<redacted>`);
46
+ s = s.replace(HIGH_ENTROPY, value => (
47
+ shouldRedactHighEntropy(value) ? '<redacted>' : value
48
+ ));
33
49
  return s;
34
50
  }
35
51
 
52
+ /**
53
+ * Recursively redact strings in a JSON-compatible value.
54
+ *
55
+ * @param {unknown} input
56
+ * @param {WeakSet<object>} [seen]
57
+ * @returns {unknown}
58
+ */
59
+ export function redactDeep(input, seen = new WeakSet()) {
60
+ if (typeof input === 'string') return redact(input);
61
+ if (input === null || typeof input !== 'object') return input;
62
+ if (seen.has(input)) return '<circular>';
63
+ seen.add(input);
64
+ let output;
65
+ if (Array.isArray(input)) {
66
+ output = input.map(item => redactDeep(item, seen));
67
+ } else {
68
+ output = {};
69
+ for (const [key, value] of Object.entries(input)) {
70
+ output[key] = SENSITIVE_KEY.test(key) ? '<redacted>' : redactDeep(value, seen);
71
+ }
72
+ }
73
+ seen.delete(input);
74
+ return output;
75
+ }
76
+
77
+ function shouldRedactHighEntropy(value) {
78
+ if (SAFE_PUBLIC_VALUES.has(value)) return false;
79
+ if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)) return false;
80
+ const categories = [
81
+ /[a-z]/.test(value),
82
+ /[A-Z]/.test(value),
83
+ /\d/.test(value),
84
+ /[_+/=-]/.test(value),
85
+ ].filter(Boolean).length;
86
+ if (categories < 2) return false;
87
+ const frequencies = new Map();
88
+ for (const char of value) frequencies.set(char, (frequencies.get(char) ?? 0) + 1);
89
+ let entropy = 0;
90
+ for (const count of frequencies.values()) {
91
+ const probability = count / value.length;
92
+ entropy -= probability * Math.log2(probability);
93
+ }
94
+ return entropy >= 3.5;
95
+ }
96
+
36
97
  /**
37
98
  * Wraps console.error/log so any string-coerced argument is redacted first.
38
99
  *
@@ -40,5 +101,5 @@ export function redact(input) {
40
101
  * @returns {(...args: unknown[]) => void}
41
102
  */
42
103
  export function safeLogger(fn) {
43
- return (...args) => fn(...args.map(a => (typeof a === 'string' ? redact(a) : a)));
104
+ return (...args) => fn(...args.map(a => redactDeep(a)));
44
105
  }