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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade command - bump pinned (or tagged) actions to the newest version
|
|
3
|
+
* permitted by the chosen policy.
|
|
4
|
+
*
|
|
5
|
+
* For SHA-pinned refs, the human-readable version is read from the inline
|
|
6
|
+
* comment (e.g. `# v3.1.0`). If absent, the action is reported as `unknown`
|
|
7
|
+
* and skipped.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import semver from 'semver';
|
|
13
|
+
import { parseWorkflowSource, collectUses } from '../lib/parser.js';
|
|
14
|
+
import { discoverWorkflows, resolveWorkflowArg } from '../lib/paths.js';
|
|
15
|
+
import { listTags, pickLatestTag, resolveRefToSha, resolveToken, getCommitDate } from '../lib/resolver.js';
|
|
16
|
+
import { writeFileGuarded } from '../lib/writer.js';
|
|
17
|
+
import { parseIgnoreDirectives, isIgnored } from '../lib/ignore.js';
|
|
18
|
+
import { rewriteUses } from './pin.js';
|
|
19
|
+
import { format } from '../lib/formatter.js';
|
|
20
|
+
|
|
21
|
+
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
22
|
+
const INLINE_COMMENT_RE = /uses\s*:[^\n#]*#\s*([^\s]+)/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} UpgradeChange
|
|
26
|
+
* @property {string} id
|
|
27
|
+
* @property {string} file
|
|
28
|
+
* @property {string} action
|
|
29
|
+
* @property {string} fromRef
|
|
30
|
+
* @property {string|null} fromVersion
|
|
31
|
+
* @property {string} toTag
|
|
32
|
+
* @property {string} toSha
|
|
33
|
+
* @property {'major'|'minor'|'patch'|'unknown'} level
|
|
34
|
+
* @property {number} line
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} [opts.cwd]
|
|
40
|
+
* @param {string[]} [opts.workflows]
|
|
41
|
+
* @param {boolean} [opts.dryRun]
|
|
42
|
+
* @param {string} [opts.token]
|
|
43
|
+
* @param {'major'|'minor'|'patch'} [opts.mode]
|
|
44
|
+
* @param {string} [opts.fix]
|
|
45
|
+
* @param {number} [opts.minAgeDays] - skip tags newer than this many days
|
|
46
|
+
* @returns {Promise<{changes: UpgradeChange[], errors: object[], skipped: object[], status: 'OK'|'FAIL'}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function upgrade({
|
|
49
|
+
cwd = process.cwd(),
|
|
50
|
+
workflows,
|
|
51
|
+
dryRun = true,
|
|
52
|
+
token,
|
|
53
|
+
mode = 'minor',
|
|
54
|
+
fix,
|
|
55
|
+
minAgeDays = 7,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const files = await resolveTargets(workflows, cwd);
|
|
58
|
+
const tok = resolveToken(token);
|
|
59
|
+
/** @type {UpgradeChange[]} */
|
|
60
|
+
const changes = [];
|
|
61
|
+
const errors = [];
|
|
62
|
+
/** @type {object[]} */
|
|
63
|
+
const skipped = [];
|
|
64
|
+
const cooldownMs = Math.max(minAgeDays, 0) * 86_400_000;
|
|
65
|
+
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
let source;
|
|
68
|
+
try {
|
|
69
|
+
source = await readFile(file, 'utf8');
|
|
70
|
+
} catch (err) {
|
|
71
|
+
errors.push({ file, error: String(err.message ?? err) });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const lines = source.split('\n');
|
|
75
|
+
let doc;
|
|
76
|
+
try {
|
|
77
|
+
doc = parseWorkflowSource(source, file);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
errors.push({ file, error: String(err.message ?? err) });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ignore = parseIgnoreDirectives(source);
|
|
84
|
+
const planned = [];
|
|
85
|
+
for (const { ref } of collectUses(doc)) {
|
|
86
|
+
if (ref.kind !== 'external' && ref.kind !== 'reusable-workflow') continue;
|
|
87
|
+
if (isIgnored(ignore, ref.line, 'unpinned-action')) continue;
|
|
88
|
+
const usesLine = lines[ref.line - 1] ?? '';
|
|
89
|
+
const inlineVersion = readInlineVersion(usesLine);
|
|
90
|
+
const currentVersion = ref.ref && SHA_RE.test(ref.ref) ? inlineVersion : ref.ref;
|
|
91
|
+
if (!currentVersion) continue;
|
|
92
|
+
|
|
93
|
+
let tags;
|
|
94
|
+
try {
|
|
95
|
+
tags = await listTags({ owner: ref.owner, repo: ref.repo, token: tok, cwd });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
errors.push({ file, action: ref.raw, error: String(err.message ?? err) });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const latest = await pickAgedTag({
|
|
101
|
+
tags,
|
|
102
|
+
currentRef: currentVersion,
|
|
103
|
+
mode,
|
|
104
|
+
cooldownMs,
|
|
105
|
+
owner: ref.owner,
|
|
106
|
+
repo: ref.repo,
|
|
107
|
+
token: tok,
|
|
108
|
+
cwd,
|
|
109
|
+
skipped,
|
|
110
|
+
file,
|
|
111
|
+
ref,
|
|
112
|
+
});
|
|
113
|
+
if (!latest) continue;
|
|
114
|
+
if (latest.name === currentVersion) continue;
|
|
115
|
+
const level = bumpLevel(currentVersion, latest.name);
|
|
116
|
+
let resolved;
|
|
117
|
+
try {
|
|
118
|
+
resolved = await resolveRefToSha({
|
|
119
|
+
owner: ref.owner,
|
|
120
|
+
repo: ref.repo,
|
|
121
|
+
ref: latest.name,
|
|
122
|
+
token: tok,
|
|
123
|
+
cwd,
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
errors.push({ file, action: ref.raw, error: String(err.message ?? err) });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
planned.push({ ref, latest, sha: resolved.sha, level, currentVersion });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let newSource = source;
|
|
133
|
+
for (const { ref, latest, sha, level, currentVersion } of planned) {
|
|
134
|
+
const change = {
|
|
135
|
+
id: changeId(file, ref.raw, latest.name),
|
|
136
|
+
file,
|
|
137
|
+
action: `${ref.owner}/${ref.repo}`,
|
|
138
|
+
fromRef: ref.ref,
|
|
139
|
+
fromVersion: currentVersion,
|
|
140
|
+
toTag: latest.name,
|
|
141
|
+
toSha: sha,
|
|
142
|
+
level,
|
|
143
|
+
line: ref.line,
|
|
144
|
+
};
|
|
145
|
+
if (fix && fix !== change.id) continue;
|
|
146
|
+
newSource = rewriteUses(newSource, ref, sha);
|
|
147
|
+
newSource = fixInlineComment(newSource, ref, latest.name, sha);
|
|
148
|
+
changes.push(change);
|
|
149
|
+
}
|
|
150
|
+
if (newSource !== source) {
|
|
151
|
+
await writeFileGuarded({ path: file, content: newSource, dryRun, cwd });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { changes, errors, skipped, status: errors.length === 0 ? 'OK' : 'FAIL' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Walk candidate tags newest-first and return the first whose commit is older
|
|
159
|
+
* than the cooldown threshold. Skipped candidates are recorded.
|
|
160
|
+
*/
|
|
161
|
+
async function pickAgedTag({ tags, currentRef, mode, cooldownMs, owner, repo, token, cwd, skipped, file, ref }) {
|
|
162
|
+
if (cooldownMs <= 0) {
|
|
163
|
+
return pickLatestTag({ tags, currentRef, mode });
|
|
164
|
+
}
|
|
165
|
+
const remaining = [...tags];
|
|
166
|
+
const cutoff = Date.now() - cooldownMs;
|
|
167
|
+
for (;;) {
|
|
168
|
+
const candidate = pickLatestTag({ tags: remaining, currentRef, mode });
|
|
169
|
+
if (!candidate) return null;
|
|
170
|
+
let dateMs;
|
|
171
|
+
try {
|
|
172
|
+
dateMs = await getCommitDate({ owner, repo, sha: candidate.sha, token, cwd });
|
|
173
|
+
} catch {
|
|
174
|
+
return candidate;
|
|
175
|
+
}
|
|
176
|
+
if (dateMs <= cutoff) return candidate;
|
|
177
|
+
skipped.push({
|
|
178
|
+
file,
|
|
179
|
+
action: `${ref.owner}/${ref.repo}`,
|
|
180
|
+
tag: candidate.name,
|
|
181
|
+
reason: 'cooldown',
|
|
182
|
+
ageDays: Math.round((Date.now() - dateMs) / 86_400_000),
|
|
183
|
+
});
|
|
184
|
+
const idx = remaining.findIndex(t => t.name === candidate.name);
|
|
185
|
+
if (idx === -1) return null;
|
|
186
|
+
remaining.splice(idx, 1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readInlineVersion(line) {
|
|
191
|
+
const m = INLINE_COMMENT_RE.exec(line);
|
|
192
|
+
return m ? m[1] : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function bumpLevel(from, to) {
|
|
196
|
+
const a = semver.coerce(from);
|
|
197
|
+
const b = semver.coerce(to);
|
|
198
|
+
if (!a || !b) return 'unknown';
|
|
199
|
+
if (a.major !== b.major) return 'major';
|
|
200
|
+
if (a.minor !== b.minor) return 'minor';
|
|
201
|
+
return 'patch';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fixInlineComment(source, ref, newTag, sha) {
|
|
205
|
+
const left = ref.subpath ? `${ref.owner}/${ref.repo}/${ref.subpath}` : `${ref.owner}/${ref.repo}`;
|
|
206
|
+
const escLeft = escRe(left);
|
|
207
|
+
const escSha = escRe(sha);
|
|
208
|
+
const re = new RegExp(
|
|
209
|
+
`(uses\\s*:\\s*['"]?)${escLeft}@${escSha}(['"]?)\\s*#\\s*[^\\n]+`,
|
|
210
|
+
'g',
|
|
211
|
+
);
|
|
212
|
+
return source.replace(re, (_, prefix, closingQuote) => `${prefix}${left}@${sha}${closingQuote} # ${newTag}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function escRe(s) {
|
|
216
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function changeId(file, raw, target) {
|
|
220
|
+
return createHash('sha1').update(`up:${file}:${raw}->${target}`).digest('hex').slice(0, 10);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function resolveTargets(workflows, cwd) {
|
|
224
|
+
if (!workflows || workflows.length === 0) return discoverWorkflows({ cwd });
|
|
225
|
+
const out = new Set();
|
|
226
|
+
for (const w of workflows) {
|
|
227
|
+
for (const f of await resolveWorkflowArg(w, cwd)) out.add(f);
|
|
228
|
+
}
|
|
229
|
+
return [...out].sort();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {Awaited<ReturnType<typeof upgrade>>} result
|
|
234
|
+
* @param {{format: 'toon'|'json'|'text', dryRun: boolean, mode: string, cwd?: string}} opts
|
|
235
|
+
*/
|
|
236
|
+
export function renderUpgrade(result, opts) {
|
|
237
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
238
|
+
if (opts.format === 'json') {
|
|
239
|
+
return format('json', [], {
|
|
240
|
+
status: result.status,
|
|
241
|
+
json: {
|
|
242
|
+
dryRun: opts.dryRun,
|
|
243
|
+
mode: opts.mode,
|
|
244
|
+
changes: result.changes.map(c => ({ ...c, file: rel(c.file, cwd) })),
|
|
245
|
+
skipped: (result.skipped ?? []).map(s => ({ ...s, file: rel(s.file ?? '', cwd) })),
|
|
246
|
+
errors: result.errors,
|
|
247
|
+
status: result.status,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const records = [];
|
|
252
|
+
for (const c of result.changes) {
|
|
253
|
+
records.push({
|
|
254
|
+
label: 'UPGRADE',
|
|
255
|
+
fields: {
|
|
256
|
+
id: c.id,
|
|
257
|
+
file: rel(c.file, cwd),
|
|
258
|
+
line: c.line,
|
|
259
|
+
action: c.action,
|
|
260
|
+
from: c.fromVersion ?? c.fromRef,
|
|
261
|
+
to: c.toTag,
|
|
262
|
+
sha: c.toSha,
|
|
263
|
+
level: c.level,
|
|
264
|
+
applied: !opts.dryRun,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
for (const s of result.skipped ?? []) {
|
|
269
|
+
records.push({ label: 'SKIP', fields: { file: rel(s.file ?? '', cwd), action: s.action, tag: s.tag, reason: s.reason, age_days: s.ageDays } });
|
|
270
|
+
}
|
|
271
|
+
for (const e of result.errors) {
|
|
272
|
+
records.push({ label: 'ERROR', fields: { file: rel(e.file ?? '', cwd), action: e.action ?? '', msg: e.error } });
|
|
273
|
+
}
|
|
274
|
+
records.push({ label: 'SUMMARY', fields: { changes: result.changes.length, skipped: (result.skipped ?? []).length, errors: result.errors.length, mode: opts.mode, dry_run: opts.dryRun } });
|
|
275
|
+
return format(opts.format, records, { status: result.status });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function rel(p, cwd) {
|
|
279
|
+
if (p && p.startsWith(cwd)) return p.slice(cwd.length + 1);
|
|
280
|
+
return p;
|
|
281
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public programmatic API.
|
|
3
|
+
*
|
|
4
|
+
* Each command can be invoked without spawning a subprocess:
|
|
5
|
+
*
|
|
6
|
+
* import { audit, pin, upgrade, report } from 'actions-warden';
|
|
7
|
+
* const result = await audit({ cwd: '/repo' });
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { audit, renderAudit } from './commands/audit.js';
|
|
11
|
+
export { pin, renderPin, rewriteUses } from './commands/pin.js';
|
|
12
|
+
export { upgrade, renderUpgrade } from './commands/upgrade.js';
|
|
13
|
+
export { report, renderReport } from './commands/report.js';
|
|
14
|
+
export { listRules } from './rules/index.js';
|
|
15
|
+
export { parseWorkflowFile, parseWorkflowSource, collectUses, parseActionRef } from './lib/parser.js';
|
|
16
|
+
export { format, renderToon, renderJson, renderText, summarize, SEVERITY_ORDER } from './lib/formatter.js';
|
|
17
|
+
export { discoverWorkflows } from './lib/paths.js';
|
|
18
|
+
export { redact } from './lib/redact.js';
|
|
19
|
+
export { parseIgnoreDirectives, isIgnored } from './lib/ignore.js';
|
package/src/lib/cache.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk cache for GitHub API responses.
|
|
3
|
+
*
|
|
4
|
+
* Keyed by sha1 of the request URL. TTL stored in the cache entry. Files live
|
|
5
|
+
* in `.actions-warden-cache/` inside the working directory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve, join } from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} CacheEntry
|
|
14
|
+
* @property {number} savedAt - ms epoch
|
|
15
|
+
* @property {number} ttlMs
|
|
16
|
+
* @property {unknown} value
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} cwd
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function cacheDir(cwd = process.cwd()) {
|
|
24
|
+
return resolve(cwd, '.actions-warden-cache');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} key
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function digest(key) {
|
|
32
|
+
return createHash('sha1').update(key).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {string} opts.key
|
|
38
|
+
* @param {number} [opts.ttlMs] - default 1h
|
|
39
|
+
* @param {string} [opts.cwd]
|
|
40
|
+
* @returns {Promise<unknown|undefined>}
|
|
41
|
+
*/
|
|
42
|
+
export async function readCache({ key, ttlMs = 3600 * 1000, cwd = process.cwd() }) {
|
|
43
|
+
const path = join(cacheDir(cwd), `${digest(key)}.json`);
|
|
44
|
+
try {
|
|
45
|
+
const raw = await readFile(path, 'utf8');
|
|
46
|
+
/** @type {CacheEntry} */
|
|
47
|
+
const entry = JSON.parse(raw);
|
|
48
|
+
if (Date.now() - entry.savedAt > Math.min(entry.ttlMs, ttlMs)) return undefined;
|
|
49
|
+
return entry.value;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} opts
|
|
57
|
+
* @param {string} opts.key
|
|
58
|
+
* @param {unknown} opts.value
|
|
59
|
+
* @param {number} [opts.ttlMs]
|
|
60
|
+
* @param {string} [opts.cwd]
|
|
61
|
+
*/
|
|
62
|
+
export async function writeCache({ key, value, ttlMs = 3600 * 1000, cwd = process.cwd() }) {
|
|
63
|
+
const dir = cacheDir(cwd);
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
/** @type {CacheEntry} */
|
|
66
|
+
const entry = { savedAt: Date.now(), ttlMs, value };
|
|
67
|
+
await writeFile(join(dir, `${digest(key)}.json`), JSON.stringify(entry), 'utf8');
|
|
68
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatter supporting TOON (Token-Oriented Object Notation), JSON, and
|
|
3
|
+
* plain text.
|
|
4
|
+
*
|
|
5
|
+
* TOON output rules:
|
|
6
|
+
* - One record per line: `LABEL: key=value key=value`
|
|
7
|
+
* - Values containing spaces or `=` are quoted: `msg="hello world"`
|
|
8
|
+
* - Empty/null values are omitted
|
|
9
|
+
* - Trailing `STATUS: OK` or `STATUS: FAIL` signal for machine consumers
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { redact } from './redact.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Severity ordering, lowest-to-highest.
|
|
16
|
+
*/
|
|
17
|
+
export const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} value
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
function quoteIfNeeded(value) {
|
|
24
|
+
if (value === '') return '""';
|
|
25
|
+
if (/[\s="]/.test(value)) {
|
|
26
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Serialize a record to a single TOON line.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} label
|
|
35
|
+
* @param {Record<string, unknown>} fields
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function toonLine(label, fields) {
|
|
39
|
+
const parts = [];
|
|
40
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
41
|
+
if (v === null || v === undefined || v === '') continue;
|
|
42
|
+
const value = typeof v === 'string' ? v : String(v);
|
|
43
|
+
parts.push(`${k}=${quoteIfNeeded(redact(value))}`);
|
|
44
|
+
}
|
|
45
|
+
return parts.length === 0 ? `${label}:` : `${label}: ${parts.join(' ')}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render a TOON document.
|
|
50
|
+
*
|
|
51
|
+
* @param {Array<{label: string, fields: Record<string, unknown>}>} records
|
|
52
|
+
* @param {{status?: 'OK'|'FAIL'}} [options]
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function renderToon(records, options = {}) {
|
|
56
|
+
const lines = records.map(r => toonLine(r.label, r.fields));
|
|
57
|
+
if (options.status) lines.push(`STATUS: ${options.status}`);
|
|
58
|
+
return lines.join('\n') + '\n';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render JSON with stable key ordering and 2-space indent.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} payload
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function renderJson(payload) {
|
|
68
|
+
return JSON.stringify(payload, null, 2) + '\n';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render plain text (human-readable summary).
|
|
73
|
+
*
|
|
74
|
+
* @param {Array<{label: string, fields: Record<string, unknown>}>} records
|
|
75
|
+
* @param {{status?: 'OK'|'FAIL'}} [options]
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function renderText(records, options = {}) {
|
|
79
|
+
const lines = records.map(r => {
|
|
80
|
+
const kv = Object.entries(r.fields)
|
|
81
|
+
.filter(([, v]) => v !== null && v !== undefined && v !== '')
|
|
82
|
+
.map(([k, v]) => `${k}=${redact(String(v))}`)
|
|
83
|
+
.join(' ');
|
|
84
|
+
return `[${r.label}] ${kv}`;
|
|
85
|
+
});
|
|
86
|
+
if (options.status) lines.push(`==> ${options.status}`);
|
|
87
|
+
return lines.join('\n') + '\n';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format records into the requested output mode.
|
|
92
|
+
*
|
|
93
|
+
* @param {'toon'|'json'|'text'} format
|
|
94
|
+
* @param {Array<{label: string, fields: Record<string, unknown>}>} records
|
|
95
|
+
* @param {{status?: 'OK'|'FAIL', json?: unknown}} [options]
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
export function format(format_, records, options = {}) {
|
|
99
|
+
switch (format_) {
|
|
100
|
+
case 'json':
|
|
101
|
+
return renderJson(options.json ?? recordsToJson(records, options));
|
|
102
|
+
case 'text':
|
|
103
|
+
return renderText(records, options);
|
|
104
|
+
case 'toon':
|
|
105
|
+
default:
|
|
106
|
+
return renderToon(records, options);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Convert records to a generic JSON payload when no custom JSON is supplied.
|
|
112
|
+
*
|
|
113
|
+
* @param {Array<{label: string, fields: Record<string, unknown>}>} records
|
|
114
|
+
* @param {{status?: string}} options
|
|
115
|
+
* @returns {object}
|
|
116
|
+
*/
|
|
117
|
+
function recordsToJson(records, options) {
|
|
118
|
+
return {
|
|
119
|
+
records: records.map(r => ({ label: r.label, ...r.fields })),
|
|
120
|
+
status: options.status ?? null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Aggregate findings into a summary record.
|
|
126
|
+
*
|
|
127
|
+
* @param {Array<{severity: string}>} findings
|
|
128
|
+
* @returns {Record<string, number>}
|
|
129
|
+
*/
|
|
130
|
+
export function summarize(findings) {
|
|
131
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
132
|
+
for (const f of findings) {
|
|
133
|
+
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
|
|
134
|
+
}
|
|
135
|
+
return counts;
|
|
136
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline ignore directives in workflow sources.
|
|
3
|
+
*
|
|
4
|
+
* Directives (accept either `actions-warden-` or `aw-` prefix):
|
|
5
|
+
*
|
|
6
|
+
* # actions-warden-ignore-file - silence the entire file
|
|
7
|
+
* # actions-warden-ignore-start - start a block (until -end)
|
|
8
|
+
* # actions-warden-ignore-end - end the current block
|
|
9
|
+
* # actions-warden-ignore-next-line - silence the next non-comment line
|
|
10
|
+
* # actions-warden-ignore - silence the same line (inline)
|
|
11
|
+
*
|
|
12
|
+
* Optional rule filter: append rule ids after a colon, e.g.
|
|
13
|
+
* # actions-warden-ignore: unpinned-action,secrets-in-env
|
|
14
|
+
*
|
|
15
|
+
* Without a filter the directive silences every rule.
|
|
16
|
+
*/
|
|
17
|
+
|
|
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}`);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} IgnoreScope
|
|
28
|
+
* @property {boolean} wholeFile
|
|
29
|
+
* @property {Set<string>|null} fileRules - null means "all rules"
|
|
30
|
+
* @property {Array<{start: number, end: number, rules: Set<string>|null}>} ranges
|
|
31
|
+
* @property {Map<number, Set<string>|null>} lines - line -> ignored rule set (or null = all)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string|null|undefined} list comma- or whitespace-separated rule ids
|
|
36
|
+
* @returns {Set<string>|null} null = match every rule
|
|
37
|
+
*/
|
|
38
|
+
function parseRuleList(list) {
|
|
39
|
+
if (!list) return null;
|
|
40
|
+
const ids = list.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
|
|
41
|
+
if (ids.length === 0) return null;
|
|
42
|
+
return new Set(ids);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} source
|
|
47
|
+
* @returns {IgnoreScope}
|
|
48
|
+
*/
|
|
49
|
+
export function parseIgnoreDirectives(source) {
|
|
50
|
+
/** @type {IgnoreScope} */
|
|
51
|
+
const scope = {
|
|
52
|
+
wholeFile: false,
|
|
53
|
+
fileRules: null,
|
|
54
|
+
ranges: [],
|
|
55
|
+
lines: new Map(),
|
|
56
|
+
};
|
|
57
|
+
const lines = source.split('\n');
|
|
58
|
+
/** @type {{start: number, rules: Set<string>|null}|null} */
|
|
59
|
+
let openBlock = null;
|
|
60
|
+
|
|
61
|
+
lines.forEach((text, i) => {
|
|
62
|
+
const lineNo = i + 1;
|
|
63
|
+
const fileMatch = text.match(RE_FILE);
|
|
64
|
+
if (fileMatch) {
|
|
65
|
+
scope.wholeFile = true;
|
|
66
|
+
scope.fileRules = parseRuleList(fileMatch[1]);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const startMatch = text.match(RE_START);
|
|
70
|
+
if (startMatch) {
|
|
71
|
+
openBlock = { start: lineNo, rules: parseRuleList(startMatch[1]) };
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (RE_END.test(text)) {
|
|
75
|
+
if (openBlock) {
|
|
76
|
+
scope.ranges.push({ start: openBlock.start, end: lineNo, rules: openBlock.rules });
|
|
77
|
+
openBlock = null;
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const nextMatch = text.match(RE_NEXT);
|
|
82
|
+
if (nextMatch) {
|
|
83
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
84
|
+
const next = lines[j].trim();
|
|
85
|
+
if (next === '' || next.startsWith('#')) continue;
|
|
86
|
+
mergeLine(scope.lines, j + 1, parseRuleList(nextMatch[1]));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const inlineMatch = text.match(RE_INLINE);
|
|
92
|
+
if (inlineMatch) {
|
|
93
|
+
mergeLine(scope.lines, lineNo, parseRuleList(inlineMatch[1]));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (openBlock) {
|
|
98
|
+
scope.ranges.push({ start: openBlock.start, end: lines.length, rules: openBlock.rules });
|
|
99
|
+
}
|
|
100
|
+
return scope;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mergeLine(map, line, rules) {
|
|
104
|
+
const existing = map.get(line);
|
|
105
|
+
if (existing === undefined) {
|
|
106
|
+
map.set(line, rules);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (existing === null || rules === null) {
|
|
110
|
+
map.set(line, null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const r of rules) existing.add(r);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {IgnoreScope} scope
|
|
118
|
+
* @param {number} line
|
|
119
|
+
* @param {string} ruleId
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isIgnored(scope, line, ruleId) {
|
|
123
|
+
if (scope.wholeFile && matches(scope.fileRules, ruleId)) return true;
|
|
124
|
+
for (const range of scope.ranges) {
|
|
125
|
+
if (line >= range.start && line <= range.end && matches(range.rules, ruleId)) return true;
|
|
126
|
+
}
|
|
127
|
+
const lineRules = scope.lines.get(line);
|
|
128
|
+
if (lineRules !== undefined && matches(lineRules, ruleId)) return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function matches(rules, ruleId) {
|
|
133
|
+
return rules === null ? true : rules.has(ruleId);
|
|
134
|
+
}
|