actions-warden 0.1.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.
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/SECURITY.md +52 -0
- package/package.json +68 -0
- package/src/cli.js +155 -0
- package/src/commands/audit.js +161 -0
- package/src/commands/pin.js +199 -0
- package/src/commands/report.js +93 -0
- package/src/commands/upgrade.js +281 -0
- package/src/index.js +19 -0
- package/src/lib/cache.js +68 -0
- package/src/lib/formatter.js +136 -0
- package/src/lib/ignore.js +134 -0
- package/src/lib/parser.js +279 -0
- package/src/lib/paths.js +99 -0
- package/src/lib/redact.js +44 -0
- package/src/lib/resolver.js +224 -0
- package/src/lib/writer.js +56 -0
- package/src/rules/excessive-permissions.js +79 -0
- package/src/rules/index.js +18 -0
- package/src/rules/pull-request-target-checkout.js +46 -0
- package/src/rules/script-injection.js +59 -0
- package/src/rules/secrets-in-env.js +51 -0
- package/src/rules/unpinned-action.js +39 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML workflow parser.
|
|
3
|
+
*
|
|
4
|
+
* Parses GitHub Actions workflow files into a structure annotated with line
|
|
5
|
+
* numbers for every job, step, and `uses:` reference. Line numbers refer to
|
|
6
|
+
* 1-indexed positions in the source.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { parseDocument, isMap, isSeq, isPair, isScalar } from 'yaml';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} ActionRef
|
|
14
|
+
* @property {string} raw - e.g. `actions/checkout@v3` or `./local`
|
|
15
|
+
* @property {string|null} owner
|
|
16
|
+
* @property {string|null} repo
|
|
17
|
+
* @property {string|null} subpath - reusable-workflow sub-path
|
|
18
|
+
* @property {string|null} ref - tag, branch, or SHA after `@`
|
|
19
|
+
* @property {'external'|'reusable-workflow'|'local'|'docker'|'unknown'} kind
|
|
20
|
+
* @property {number} line
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} StepNode
|
|
25
|
+
* @property {string|null} name
|
|
26
|
+
* @property {string|null} id
|
|
27
|
+
* @property {ActionRef|null} uses
|
|
28
|
+
* @property {string|null} run
|
|
29
|
+
* @property {object|null} env
|
|
30
|
+
* @property {object|null} with_
|
|
31
|
+
* @property {number} line
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} JobNode
|
|
36
|
+
* @property {string} name
|
|
37
|
+
* @property {object|null} permissions
|
|
38
|
+
* @property {string|null} runsOn
|
|
39
|
+
* @property {object|null} env
|
|
40
|
+
* @property {StepNode[]} steps
|
|
41
|
+
* @property {number} line
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} WorkflowDoc
|
|
46
|
+
* @property {string} path
|
|
47
|
+
* @property {string} source
|
|
48
|
+
* @property {string|null} name
|
|
49
|
+
* @property {unknown} on
|
|
50
|
+
* @property {object|null} permissions
|
|
51
|
+
* @property {object|null} env
|
|
52
|
+
* @property {JobNode[]} jobs
|
|
53
|
+
* @property {object} raw - the parsed plain object (for rules to query)
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} raw e.g. `actions/checkout@v3` or `./local`
|
|
58
|
+
* @param {number} line
|
|
59
|
+
* @returns {ActionRef}
|
|
60
|
+
*/
|
|
61
|
+
export function parseActionRef(raw, line) {
|
|
62
|
+
/** @type {ActionRef} */
|
|
63
|
+
const base = {
|
|
64
|
+
raw,
|
|
65
|
+
owner: null,
|
|
66
|
+
repo: null,
|
|
67
|
+
subpath: null,
|
|
68
|
+
ref: null,
|
|
69
|
+
kind: 'unknown',
|
|
70
|
+
line,
|
|
71
|
+
};
|
|
72
|
+
if (typeof raw !== 'string' || raw.length === 0) return base;
|
|
73
|
+
if (raw.startsWith('./') || raw.startsWith('../')) {
|
|
74
|
+
return { ...base, kind: 'local' };
|
|
75
|
+
}
|
|
76
|
+
if (raw.startsWith('docker://')) {
|
|
77
|
+
return { ...base, kind: 'docker' };
|
|
78
|
+
}
|
|
79
|
+
const atIndex = raw.lastIndexOf('@');
|
|
80
|
+
if (atIndex <= 0) return base;
|
|
81
|
+
const left = raw.slice(0, atIndex);
|
|
82
|
+
const ref = raw.slice(atIndex + 1);
|
|
83
|
+
const parts = left.split('/');
|
|
84
|
+
if (parts.length < 2) return base;
|
|
85
|
+
const owner = parts[0];
|
|
86
|
+
const repo = parts[1];
|
|
87
|
+
const rest = parts.slice(2).join('/');
|
|
88
|
+
const kind = /\.(yml|yaml)$/.test(rest) ? 'reusable-workflow' : 'external';
|
|
89
|
+
return { ...base, owner, repo, subpath: rest || null, ref, kind };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {unknown} node
|
|
94
|
+
* @returns {number}
|
|
95
|
+
*/
|
|
96
|
+
function lineOf(node) {
|
|
97
|
+
if (node && typeof node === 'object' && 'range' in node && Array.isArray(node.range)) {
|
|
98
|
+
// We won't use this path; line is computed externally from the document.
|
|
99
|
+
}
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Find the 1-based line of a Pair/Scalar node within the YAML document.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} source
|
|
107
|
+
* @param {{range?: number[]}} node
|
|
108
|
+
* @returns {number}
|
|
109
|
+
*/
|
|
110
|
+
function lineFromRange(source, node) {
|
|
111
|
+
if (!node || !node.range || node.range.length === 0) return 0;
|
|
112
|
+
const offset = node.range[0];
|
|
113
|
+
let line = 1;
|
|
114
|
+
for (let i = 0; i < offset && i < source.length; i += 1) {
|
|
115
|
+
if (source.charCodeAt(i) === 10) line += 1;
|
|
116
|
+
}
|
|
117
|
+
return line;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read a Pair value into a JS scalar where possible.
|
|
122
|
+
*
|
|
123
|
+
* @param {unknown} node
|
|
124
|
+
*/
|
|
125
|
+
function toJs(node) {
|
|
126
|
+
if (node == null) return null;
|
|
127
|
+
if (typeof node !== 'object') return node;
|
|
128
|
+
if ('toJSON' in node && typeof node.toJSON === 'function') return node.toJSON();
|
|
129
|
+
return node;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Locate a child Pair by key within a YAMLMap.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} map - yaml YAMLMap node
|
|
136
|
+
* @param {string} key
|
|
137
|
+
* @returns {object|null}
|
|
138
|
+
*/
|
|
139
|
+
function findPair(map, key) {
|
|
140
|
+
if (!map || !isMap(map)) return null;
|
|
141
|
+
for (const item of map.items) {
|
|
142
|
+
if (isPair(item) && isScalar(item.key) && item.key.value === key) {
|
|
143
|
+
return item;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} source
|
|
151
|
+
* @param {object} doc - yaml Document
|
|
152
|
+
* @returns {JobNode[]}
|
|
153
|
+
*/
|
|
154
|
+
function extractJobs(source, doc) {
|
|
155
|
+
/** @type {JobNode[]} */
|
|
156
|
+
const jobs = [];
|
|
157
|
+
const jobsPair = findPair(doc.contents, 'jobs');
|
|
158
|
+
if (!jobsPair || !isMap(jobsPair.value)) return jobs;
|
|
159
|
+
|
|
160
|
+
for (const jobPair of jobsPair.value.items) {
|
|
161
|
+
if (!isPair(jobPair) || !isScalar(jobPair.key)) continue;
|
|
162
|
+
const jobName = String(jobPair.key.value);
|
|
163
|
+
const jobNode = jobPair.value;
|
|
164
|
+
const jobLine = lineFromRange(source, jobPair.key);
|
|
165
|
+
|
|
166
|
+
/** @type {JobNode} */
|
|
167
|
+
const job = {
|
|
168
|
+
name: jobName,
|
|
169
|
+
permissions: null,
|
|
170
|
+
runsOn: null,
|
|
171
|
+
env: null,
|
|
172
|
+
steps: [],
|
|
173
|
+
line: jobLine,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
if (isMap(jobNode)) {
|
|
177
|
+
const perms = findPair(jobNode, 'permissions');
|
|
178
|
+
if (perms) job.permissions = toJs(perms.value);
|
|
179
|
+
const runsOn = findPair(jobNode, 'runs-on');
|
|
180
|
+
if (runsOn) job.runsOn = toJs(runsOn.value);
|
|
181
|
+
const env = findPair(jobNode, 'env');
|
|
182
|
+
if (env) job.env = toJs(env.value);
|
|
183
|
+
|
|
184
|
+
const stepsPair = findPair(jobNode, 'steps');
|
|
185
|
+
if (stepsPair && isSeq(stepsPair.value)) {
|
|
186
|
+
for (const stepNode of stepsPair.value.items) {
|
|
187
|
+
if (!isMap(stepNode)) continue;
|
|
188
|
+
const usesPair = findPair(stepNode, 'uses');
|
|
189
|
+
const runPair = findPair(stepNode, 'run');
|
|
190
|
+
const namePair = findPair(stepNode, 'name');
|
|
191
|
+
const idPair = findPair(stepNode, 'id');
|
|
192
|
+
const envPair = findPair(stepNode, 'env');
|
|
193
|
+
const withPair = findPair(stepNode, 'with');
|
|
194
|
+
|
|
195
|
+
const stepLine = lineFromRange(source, stepNode);
|
|
196
|
+
/** @type {StepNode} */
|
|
197
|
+
const step = {
|
|
198
|
+
name: namePair && isScalar(namePair.value) ? String(namePair.value.value) : null,
|
|
199
|
+
id: idPair && isScalar(idPair.value) ? String(idPair.value.value) : null,
|
|
200
|
+
uses: null,
|
|
201
|
+
run: runPair && isScalar(runPair.value) ? String(runPair.value.value) : null,
|
|
202
|
+
env: envPair ? toJs(envPair.value) : null,
|
|
203
|
+
with_: withPair ? toJs(withPair.value) : null,
|
|
204
|
+
line: stepLine,
|
|
205
|
+
};
|
|
206
|
+
if (usesPair && isScalar(usesPair.value)) {
|
|
207
|
+
const usesLine = lineFromRange(source, usesPair.value);
|
|
208
|
+
step.uses = parseActionRef(String(usesPair.value.value), usesLine);
|
|
209
|
+
}
|
|
210
|
+
job.steps.push(step);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
jobs.push(job);
|
|
215
|
+
}
|
|
216
|
+
return jobs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Parse a workflow YAML source into a {@link WorkflowDoc}.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} source
|
|
223
|
+
* @param {string} path
|
|
224
|
+
* @returns {WorkflowDoc}
|
|
225
|
+
*/
|
|
226
|
+
export function parseWorkflowSource(source, path) {
|
|
227
|
+
const doc = parseDocument(source, { keepSourceTokens: true });
|
|
228
|
+
if (doc.errors && doc.errors.length > 0) {
|
|
229
|
+
const first = doc.errors[0];
|
|
230
|
+
throw new Error(`yaml parse error in ${path}: ${first.message}`);
|
|
231
|
+
}
|
|
232
|
+
/** @type {WorkflowDoc} */
|
|
233
|
+
const result = {
|
|
234
|
+
path,
|
|
235
|
+
source,
|
|
236
|
+
name: null,
|
|
237
|
+
on: null,
|
|
238
|
+
permissions: null,
|
|
239
|
+
env: null,
|
|
240
|
+
jobs: [],
|
|
241
|
+
raw: doc.toJS() ?? {},
|
|
242
|
+
};
|
|
243
|
+
if (!doc.contents || !isMap(doc.contents)) return result;
|
|
244
|
+
const namePair = findPair(doc.contents, 'name');
|
|
245
|
+
if (namePair && isScalar(namePair.value)) result.name = String(namePair.value.value);
|
|
246
|
+
const onPair = findPair(doc.contents, 'on');
|
|
247
|
+
if (onPair) result.on = toJs(onPair.value);
|
|
248
|
+
const permPair = findPair(doc.contents, 'permissions');
|
|
249
|
+
if (permPair) result.permissions = toJs(permPair.value);
|
|
250
|
+
const envPair = findPair(doc.contents, 'env');
|
|
251
|
+
if (envPair) result.env = toJs(envPair.value);
|
|
252
|
+
result.jobs = extractJobs(source, doc);
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {string} path
|
|
258
|
+
* @returns {Promise<WorkflowDoc>}
|
|
259
|
+
*/
|
|
260
|
+
export async function parseWorkflowFile(path) {
|
|
261
|
+
const source = await readFile(path, 'utf8');
|
|
262
|
+
return parseWorkflowSource(source, path);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Iterate every action reference in a workflow.
|
|
267
|
+
*
|
|
268
|
+
* @param {WorkflowDoc} workflow
|
|
269
|
+
* @returns {Array<{ref: ActionRef, jobName: string, stepIndex: number}>}
|
|
270
|
+
*/
|
|
271
|
+
export function collectUses(workflow) {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const job of workflow.jobs) {
|
|
274
|
+
job.steps.forEach((step, i) => {
|
|
275
|
+
if (step.uses) out.push({ ref: step.uses, jobName: job.name, stepIndex: i });
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
package/src/lib/paths.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow file discovery with safe path handling.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import { resolve, join, relative } from 'node:path';
|
|
7
|
+
import picomatch from 'picomatch';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default workflow directory globs.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_WORKFLOW_PATTERNS = [
|
|
13
|
+
'.github/workflows/*.yml',
|
|
14
|
+
'.github/workflows/*.yaml',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reject path-traversal and absolute-escape attempts.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} p
|
|
21
|
+
* @param {string} cwd
|
|
22
|
+
*/
|
|
23
|
+
function assertInside(p, cwd) {
|
|
24
|
+
const abs = resolve(cwd, p);
|
|
25
|
+
const rel = relative(cwd, abs);
|
|
26
|
+
if (rel.startsWith('..') || rel.includes('\0')) {
|
|
27
|
+
throw new Error(`path traversal rejected: ${p}`);
|
|
28
|
+
}
|
|
29
|
+
return abs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recursively list files under a directory.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} dir
|
|
36
|
+
* @param {string[]} acc
|
|
37
|
+
* @returns {Promise<string[]>}
|
|
38
|
+
*/
|
|
39
|
+
async function walk(dir, acc = []) {
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return acc;
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.name.startsWith('.git') && entry.name !== '.github') continue;
|
|
48
|
+
if (entry.name === 'node_modules') continue;
|
|
49
|
+
const full = join(dir, entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
await walk(full, acc);
|
|
52
|
+
} else if (entry.isFile()) {
|
|
53
|
+
acc.push(full);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return acc;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Discover workflow files matching the given patterns (relative to cwd).
|
|
61
|
+
*
|
|
62
|
+
* @param {object} opts
|
|
63
|
+
* @param {string[]} [opts.patterns]
|
|
64
|
+
* @param {string} [opts.cwd]
|
|
65
|
+
* @returns {Promise<string[]>}
|
|
66
|
+
*/
|
|
67
|
+
export async function discoverWorkflows({ patterns = DEFAULT_WORKFLOW_PATTERNS, cwd = process.cwd() } = {}) {
|
|
68
|
+
for (const p of patterns) assertInside(p, cwd);
|
|
69
|
+
const matchers = patterns.map(p => picomatch(p, { dot: true }));
|
|
70
|
+
const all = await walk(cwd);
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const file of all) {
|
|
73
|
+
const rel = relative(cwd, file);
|
|
74
|
+
if (matchers.some(m => m(rel))) out.push(file);
|
|
75
|
+
}
|
|
76
|
+
return out.sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a single workflow path argument. If it's a directory or glob,
|
|
81
|
+
* expand it; if a file, validate it exists.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} input
|
|
84
|
+
* @param {string} [cwd]
|
|
85
|
+
* @returns {Promise<string[]>}
|
|
86
|
+
*/
|
|
87
|
+
export async function resolveWorkflowArg(input, cwd = process.cwd()) {
|
|
88
|
+
const abs = assertInside(input, cwd);
|
|
89
|
+
try {
|
|
90
|
+
const st = await stat(abs);
|
|
91
|
+
if (st.isDirectory()) {
|
|
92
|
+
const files = await walk(abs);
|
|
93
|
+
return files.filter(f => /\.ya?ml$/.test(f));
|
|
94
|
+
}
|
|
95
|
+
return [abs];
|
|
96
|
+
} catch {
|
|
97
|
+
return discoverWorkflows({ patterns: [input], cwd });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction utility.
|
|
3
|
+
*
|
|
4
|
+
* Replaces values that look like tokens, credentials, or high-entropy strings
|
|
5
|
+
* with `<redacted>`. Conservative by design: prefers false positives over leaks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TOKEN_PATTERNS = [
|
|
9
|
+
/ghp_[A-Za-z0-9]{30,}/g,
|
|
10
|
+
/ghs_[A-Za-z0-9]{30,}/g,
|
|
11
|
+
/gho_[A-Za-z0-9]{30,}/g,
|
|
12
|
+
/ghu_[A-Za-z0-9]{30,}/g,
|
|
13
|
+
/github_pat_[A-Za-z0-9_]{30,}/g,
|
|
14
|
+
/xox[abprs]-[A-Za-z0-9-]{10,}/g,
|
|
15
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
16
|
+
/sk-[A-Za-z0-9]{20,}/g,
|
|
17
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const KV_TOKEN_KEYS = /\b(token|secret|password|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key)\s*[:=]\s*["']?([^"'\s,]+)/gi;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Redacts sensitive substrings from a value.
|
|
24
|
+
*
|
|
25
|
+
* @param {unknown} input
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function redact(input) {
|
|
29
|
+
if (input == null) return '';
|
|
30
|
+
let s = typeof input === 'string' ? input : String(input);
|
|
31
|
+
for (const re of TOKEN_PATTERNS) s = s.replace(re, '<redacted>');
|
|
32
|
+
s = s.replace(KV_TOKEN_KEYS, (_, key) => `${key}=<redacted>`);
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wraps console.error/log so any string-coerced argument is redacted first.
|
|
38
|
+
*
|
|
39
|
+
* @param {(...args: unknown[]) => void} fn
|
|
40
|
+
* @returns {(...args: unknown[]) => void}
|
|
41
|
+
*/
|
|
42
|
+
export function safeLogger(fn) {
|
|
43
|
+
return (...args) => fn(...args.map(a => (typeof a === 'string' ? redact(a) : a)));
|
|
44
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub API version resolver.
|
|
3
|
+
*
|
|
4
|
+
* Resolves tags/branches to commit SHAs and looks up latest releases. Uses
|
|
5
|
+
* native fetch, with exponential backoff on rate-limit (HTTP 403 + ratelimit
|
|
6
|
+
* remaining 0) and 5xx responses. Caches successful responses to disk.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import semver from 'semver';
|
|
10
|
+
import { readCache, writeCache } from './cache.js';
|
|
11
|
+
import { redact } from './redact.js';
|
|
12
|
+
|
|
13
|
+
const API = 'https://api.github.com';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the API token. Precedence: explicit param > GITHUB_TOKEN > GH_TOKEN.
|
|
17
|
+
*
|
|
18
|
+
* @param {string|undefined} explicit
|
|
19
|
+
* @returns {string|undefined}
|
|
20
|
+
*/
|
|
21
|
+
export function resolveToken(explicit) {
|
|
22
|
+
if (explicit && explicit.length > 0) return explicit;
|
|
23
|
+
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
|
24
|
+
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.url
|
|
31
|
+
* @param {string} [opts.token]
|
|
32
|
+
* @param {number} [opts.retries]
|
|
33
|
+
* @param {string} [opts.cwd]
|
|
34
|
+
* @param {boolean} [opts.useCache]
|
|
35
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
36
|
+
*/
|
|
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 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const headers = {
|
|
44
|
+
accept: 'application/vnd.github+json',
|
|
45
|
+
'user-agent': 'actions-warden',
|
|
46
|
+
'x-github-api-version': '2022-11-28',
|
|
47
|
+
};
|
|
48
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
49
|
+
|
|
50
|
+
let attempt = 0;
|
|
51
|
+
for (;;) {
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(url, { headers });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (attempt >= retries) throw new Error(`github fetch failed: ${redact(String(err))}`);
|
|
57
|
+
await sleep(backoff(attempt));
|
|
58
|
+
attempt += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
62
|
+
if ((response.status === 403 && remaining === '0') || response.status === 429) {
|
|
63
|
+
const reset = Number(response.headers.get('x-ratelimit-reset') ?? 0) * 1000;
|
|
64
|
+
const wait = Math.max(reset - Date.now(), backoff(attempt));
|
|
65
|
+
if (attempt >= retries) {
|
|
66
|
+
throw new Error('github rate limit exhausted');
|
|
67
|
+
}
|
|
68
|
+
await sleep(Math.min(wait, 30_000));
|
|
69
|
+
attempt += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (response.status >= 500 && attempt < retries) {
|
|
73
|
+
await sleep(backoff(attempt));
|
|
74
|
+
attempt += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const text = await response.text();
|
|
78
|
+
/** @type {unknown} */
|
|
79
|
+
let body;
|
|
80
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
|
81
|
+
if (response.status === 200 && useCache) {
|
|
82
|
+
await writeCache({ key: url, value: body, cwd });
|
|
83
|
+
}
|
|
84
|
+
return { status: response.status, body };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function backoff(attempt) {
|
|
89
|
+
return Math.min(1000 * 2 ** attempt, 8000);
|
|
90
|
+
}
|
|
91
|
+
function sleep(ms) {
|
|
92
|
+
return new Promise(res => setTimeout(res, ms));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a ref (tag, branch, or commit-ish) to an immutable commit SHA.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} opts
|
|
99
|
+
* @param {string} opts.owner
|
|
100
|
+
* @param {string} opts.repo
|
|
101
|
+
* @param {string} opts.ref
|
|
102
|
+
* @param {string} [opts.token]
|
|
103
|
+
* @param {string} [opts.cwd]
|
|
104
|
+
* @returns {Promise<{sha: string, type: 'tag'|'branch'|'commit'}>}
|
|
105
|
+
*/
|
|
106
|
+
export async function resolveRefToSha({ owner, repo, ref, token, cwd }) {
|
|
107
|
+
// Already a full SHA?
|
|
108
|
+
if (/^[0-9a-f]{40}$/i.test(ref)) {
|
|
109
|
+
return { sha: ref.toLowerCase(), type: 'commit' };
|
|
110
|
+
}
|
|
111
|
+
// Try as tag.
|
|
112
|
+
const tagUrl = `${API}/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(ref)}`;
|
|
113
|
+
const tagRes = await ghFetch({ url: tagUrl, token, cwd });
|
|
114
|
+
if (tagRes.status === 200 && tagRes.body && typeof tagRes.body === 'object') {
|
|
115
|
+
const obj = tagRes.body.object;
|
|
116
|
+
if (obj && obj.sha) {
|
|
117
|
+
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
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { sha: obj.sha, type: 'tag' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Try as branch.
|
|
129
|
+
const branchUrl = `${API}/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(ref)}`;
|
|
130
|
+
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' };
|
|
133
|
+
}
|
|
134
|
+
// Try as commit.
|
|
135
|
+
const commitUrl = `${API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`;
|
|
136
|
+
const commitRes = await ghFetch({ url: commitUrl, token, cwd });
|
|
137
|
+
if (commitRes.status === 200 && commitRes.body?.sha) {
|
|
138
|
+
return { sha: commitRes.body.sha, type: 'commit' };
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`could not resolve ${owner}/${repo}@${ref}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* List all tags for a repo (paginated, up to 200).
|
|
145
|
+
*
|
|
146
|
+
* @param {object} opts
|
|
147
|
+
* @param {string} opts.owner
|
|
148
|
+
* @param {string} opts.repo
|
|
149
|
+
* @param {string} [opts.token]
|
|
150
|
+
* @param {string} [opts.cwd]
|
|
151
|
+
* @returns {Promise<Array<{name: string, sha: string}>>}
|
|
152
|
+
*/
|
|
153
|
+
export async function listTags({ owner, repo, token, cwd }) {
|
|
154
|
+
/** @type {Array<{name: string, sha: string}>} */
|
|
155
|
+
const out = [];
|
|
156
|
+
for (let page = 1; page <= 2; page += 1) {
|
|
157
|
+
const url = `${API}/repos/${owner}/${repo}/tags?per_page=100&page=${page}`;
|
|
158
|
+
const res = await ghFetch({ url, token, cwd });
|
|
159
|
+
if (res.status !== 200 || !Array.isArray(res.body)) break;
|
|
160
|
+
for (const t of res.body) {
|
|
161
|
+
if (t && t.name && t.commit?.sha) out.push({ name: t.name, sha: t.commit.sha });
|
|
162
|
+
}
|
|
163
|
+
if (res.body.length < 100) break;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fetch the committer date of a commit (ms epoch).
|
|
170
|
+
*
|
|
171
|
+
* @param {object} opts
|
|
172
|
+
* @param {string} opts.owner
|
|
173
|
+
* @param {string} opts.repo
|
|
174
|
+
* @param {string} opts.sha
|
|
175
|
+
* @param {string} [opts.token]
|
|
176
|
+
* @param {string} [opts.cwd]
|
|
177
|
+
* @returns {Promise<number>}
|
|
178
|
+
*/
|
|
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}`);
|
|
184
|
+
}
|
|
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;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Pick the highest semver tag matching the given policy.
|
|
194
|
+
*
|
|
195
|
+
* @param {object} opts
|
|
196
|
+
* @param {Array<{name: string}>} opts.tags
|
|
197
|
+
* @param {string|null} opts.currentRef - current ref ("v3", "v3.1.0", branch)
|
|
198
|
+
* @param {'major'|'minor'|'patch'} [opts.mode]
|
|
199
|
+
* @returns {{name: string}|null}
|
|
200
|
+
*/
|
|
201
|
+
export function pickLatestTag({ tags, currentRef, mode = 'major' }) {
|
|
202
|
+
const semverTags = tags
|
|
203
|
+
.map(t => ({ tag: t, parsed: semver.coerce(t.name) }))
|
|
204
|
+
.filter(x => x.parsed)
|
|
205
|
+
.map(x => ({ tag: x.tag, version: x.parsed.version }))
|
|
206
|
+
.sort((a, b) => semver.rcompare(a.version, b.version));
|
|
207
|
+
if (semverTags.length === 0) return null;
|
|
208
|
+
|
|
209
|
+
const current = currentRef ? semver.coerce(currentRef) : null;
|
|
210
|
+
if (!current || mode === 'major') return semverTags[0].tag;
|
|
211
|
+
for (const candidate of semverTags) {
|
|
212
|
+
if (mode === 'minor' && semver.major(candidate.version) === current.major) {
|
|
213
|
+
return candidate.tag;
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
mode === 'patch' &&
|
|
217
|
+
semver.major(candidate.version) === current.major &&
|
|
218
|
+
semver.minor(candidate.version) === current.minor
|
|
219
|
+
) {
|
|
220
|
+
return candidate.tag;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe file writer with dry-run guard.
|
|
3
|
+
*
|
|
4
|
+
* Every mutation flows through {@link writeFileGuarded}. When `dryRun` is true
|
|
5
|
+
* (the default), no bytes are written - the change is recorded and reported.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve, relative } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} WriteResult
|
|
13
|
+
* @property {string} path
|
|
14
|
+
* @property {boolean} written
|
|
15
|
+
* @property {boolean} dryRun
|
|
16
|
+
* @property {number} bytes
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} args
|
|
21
|
+
* @param {string} args.path
|
|
22
|
+
* @param {string} args.content
|
|
23
|
+
* @param {boolean} [args.dryRun]
|
|
24
|
+
* @param {string} [args.cwd]
|
|
25
|
+
* @returns {Promise<WriteResult>}
|
|
26
|
+
*/
|
|
27
|
+
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')) {
|
|
31
|
+
throw new Error(`refusing to write outside working directory: ${path}`);
|
|
32
|
+
}
|
|
33
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
34
|
+
if (dryRun) {
|
|
35
|
+
return { path: abs, written: false, dryRun: true, bytes };
|
|
36
|
+
}
|
|
37
|
+
await writeFile(abs, content, 'utf8');
|
|
38
|
+
return { path: abs, written: true, dryRun: false, bytes };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Throws on path traversal attempts.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} path
|
|
45
|
+
* @param {string} [cwd]
|
|
46
|
+
* @returns {string} absolute path
|
|
47
|
+
*/
|
|
48
|
+
export function assertSafePath(path, cwd = process.cwd()) {
|
|
49
|
+
if (typeof path !== 'string' || path.includes('\0')) {
|
|
50
|
+
throw new Error('invalid path');
|
|
51
|
+
}
|
|
52
|
+
if (path.includes('..')) {
|
|
53
|
+
throw new Error(`path traversal rejected: ${path}`);
|
|
54
|
+
}
|
|
55
|
+
return resolve(cwd, path);
|
|
56
|
+
}
|