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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Audit command - scan workflows for security findings.
3
+ *
4
+ * Programmatic API:
5
+ * const result = await audit({ cwd, workflows, severity, explain });
6
+ * result.findings: Finding[]
7
+ * result.summary: { files, findings, critical, high, medium, low }
8
+ * result.status: 'OK' | 'FAIL'
9
+ *
10
+ * A finding looks like:
11
+ * { id, ruleId, severity, file, line, fields, explain }
12
+ */
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import { parseWorkflowFile } from '../lib/parser.js';
16
+ import { discoverWorkflows, resolveWorkflowArg } from '../lib/paths.js';
17
+ import { format, summarize, SEVERITY_ORDER } from '../lib/formatter.js';
18
+ import { parseIgnoreDirectives, isIgnored } from '../lib/ignore.js';
19
+ import { RULES } from '../rules/index.js';
20
+
21
+ /**
22
+ * @typedef {object} Finding
23
+ * @property {string} id - unique id (sha1 short)
24
+ * @property {string} ruleId
25
+ * @property {string} severity
26
+ * @property {string} file
27
+ * @property {number} line
28
+ * @property {Record<string, unknown>} fields
29
+ * @property {string} explain
30
+ */
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {string} [opts.cwd]
35
+ * @param {string[]} [opts.workflows] - explicit file/glob args
36
+ * @param {'low'|'medium'|'high'|'critical'} [opts.severity] - minimum severity
37
+ * @param {boolean} [opts.explain]
38
+ * @returns {Promise<{findings: Finding[], summary: object, status: 'OK'|'FAIL', files: string[]}>}
39
+ */
40
+ export async function audit({ cwd = process.cwd(), workflows, severity, explain = false } = {}) {
41
+ const files = await resolveTargets(workflows, cwd);
42
+ /** @type {Finding[]} */
43
+ const findings = [];
44
+ for (const file of files) {
45
+ let doc;
46
+ try {
47
+ doc = await parseWorkflowFile(file);
48
+ } catch (err) {
49
+ findings.push({
50
+ id: shortId(`parse:${file}`),
51
+ ruleId: 'parse-error',
52
+ severity: 'high',
53
+ file,
54
+ line: 0,
55
+ fields: { type: 'parse-error', sev: 'high', file },
56
+ explain: String(err.message ?? err),
57
+ });
58
+ continue;
59
+ }
60
+ const ignore = parseIgnoreDirectives(doc.source);
61
+ for (const rule of RULES) {
62
+ const ruleFindings = rule.check(doc);
63
+ for (const f of ruleFindings) {
64
+ if (isIgnored(ignore, f.line, rule.id)) continue;
65
+ const finding = {
66
+ id: shortId(`${rule.id}:${file}:${f.line}:${JSON.stringify(f.fields)}`),
67
+ ruleId: rule.id,
68
+ severity: f.severity,
69
+ file,
70
+ line: f.line,
71
+ fields: { ...f.fields, file: relPath(file, cwd) },
72
+ explain: f.explain,
73
+ };
74
+ findings.push(finding);
75
+ }
76
+ }
77
+ }
78
+ const filtered = filterBySeverity(findings, severity);
79
+ const counts = summarize(filtered);
80
+ const status = filtered.length === 0 ? 'OK' : 'FAIL';
81
+ return {
82
+ files,
83
+ findings: explain ? filtered : filtered.map(stripExplain),
84
+ summary: { files: files.length, findings: filtered.length, ...counts },
85
+ status,
86
+ };
87
+ }
88
+
89
+ function stripExplain(f) {
90
+ const { explain: _unused, ...rest } = f;
91
+ return rest;
92
+ }
93
+
94
+ /**
95
+ * @param {string[]|undefined} workflows
96
+ * @param {string} cwd
97
+ */
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
+ /**
111
+ * @param {Finding[]} findings
112
+ * @param {string|undefined} min
113
+ */
114
+ function filterBySeverity(findings, min) {
115
+ if (!min) return findings;
116
+ const minIdx = SEVERITY_ORDER.indexOf(min);
117
+ 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);
123
+ }
124
+
125
+ function relPath(p, cwd) {
126
+ if (p.startsWith(cwd)) return p.slice(cwd.length + 1);
127
+ return p;
128
+ }
129
+
130
+ /**
131
+ * Render an audit result to the chosen format.
132
+ *
133
+ * @param {Awaited<ReturnType<typeof audit>>} result
134
+ * @param {{format: 'toon'|'json'|'text', explain?: boolean, cwd?: string}} opts
135
+ */
136
+ export function renderAudit(result, opts) {
137
+ const cwd = opts.cwd ?? process.cwd();
138
+ if (opts.format === 'json') {
139
+ return format('json', [], {
140
+ status: result.status,
141
+ json: {
142
+ files: result.files.map(f => relPath(f, cwd)),
143
+ findings: result.findings.map(f => ({ ...f, file: relPath(f.file, cwd) })),
144
+ summary: result.summary,
145
+ status: result.status,
146
+ },
147
+ });
148
+ }
149
+ /** @type {Array<{label: string, fields: Record<string, unknown>}>} */
150
+ const records = [];
151
+ for (const f of result.files) {
152
+ records.push({ label: 'SCAN', fields: { file: relPath(f, opts.cwd ?? process.cwd()) } });
153
+ }
154
+ for (const finding of result.findings) {
155
+ const fields = { id: finding.id, ...finding.fields, line: finding.line };
156
+ if (opts.explain) fields.explain = finding.explain;
157
+ records.push({ label: 'FINDING', fields });
158
+ }
159
+ records.push({ label: 'SUMMARY', fields: result.summary });
160
+ return format(opts.format, records, { status: result.status });
161
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Pin command - rewrite tag-based `uses:` refs to immutable commit SHAs.
3
+ *
4
+ * Format: `uses: owner/repo@<sha> # <original-ref>`
5
+ *
6
+ * The original tag is preserved as an inline comment so upgrades can find
7
+ * the human-readable version later.
8
+ */
9
+
10
+ import { readFile } from 'node:fs/promises';
11
+ import { createHash } from 'node:crypto';
12
+ import { parseWorkflowSource, collectUses } from '../lib/parser.js';
13
+ import { discoverWorkflows, resolveWorkflowArg } from '../lib/paths.js';
14
+ import { resolveRefToSha, resolveToken } from '../lib/resolver.js';
15
+ import { writeFileGuarded } from '../lib/writer.js';
16
+ import { parseIgnoreDirectives, isIgnored } from '../lib/ignore.js';
17
+ import { format } from '../lib/formatter.js';
18
+
19
+ const SHA_RE = /^[0-9a-f]{40}$/i;
20
+
21
+ /**
22
+ * @typedef {object} PinChange
23
+ * @property {string} id
24
+ * @property {string} file
25
+ * @property {string} action - owner/repo
26
+ * @property {string} fromRef
27
+ * @property {string} toSha
28
+ * @property {number} line
29
+ * @property {'tag'|'branch'|'commit'} refType
30
+ */
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {string} [opts.cwd]
35
+ * @param {string[]} [opts.workflows]
36
+ * @param {boolean} [opts.dryRun]
37
+ * @param {string} [opts.token]
38
+ * @param {string} [opts.fix] - finding/change id to apply (skip others)
39
+ * @returns {Promise<{changes: PinChange[], errors: object[], status: 'OK'|'FAIL'}>}
40
+ */
41
+ export async function pin({ cwd = process.cwd(), workflows, dryRun = true, token, fix } = {}) {
42
+ const files = await resolveTargets(workflows, cwd);
43
+ const tok = resolveToken(token);
44
+ /** @type {PinChange[]} */
45
+ const changes = [];
46
+ const errors = [];
47
+
48
+ for (const file of files) {
49
+ let source;
50
+ try {
51
+ source = await readFile(file, 'utf8');
52
+ } catch (err) {
53
+ errors.push({ file, error: String(err.message ?? err) });
54
+ continue;
55
+ }
56
+ let doc;
57
+ try {
58
+ doc = parseWorkflowSource(source, file);
59
+ } catch (err) {
60
+ errors.push({ file, error: String(err.message ?? err) });
61
+ continue;
62
+ }
63
+ /** @type {Array<{ref: import('../lib/parser.js').ActionRef, sha: string, type: string}>} */
64
+ const planned = [];
65
+ const ignore = parseIgnoreDirectives(source);
66
+ for (const { ref } of collectUses(doc)) {
67
+ if (ref.kind !== 'external' && ref.kind !== 'reusable-workflow') continue;
68
+ if (!ref.ref || SHA_RE.test(ref.ref)) continue;
69
+ if (isIgnored(ignore, ref.line, 'unpinned-action')) continue;
70
+ try {
71
+ const resolved = await resolveRefToSha({
72
+ owner: ref.owner,
73
+ repo: ref.repo,
74
+ ref: ref.ref,
75
+ token: tok,
76
+ cwd,
77
+ });
78
+ planned.push({ ref, sha: resolved.sha, type: resolved.type });
79
+ } catch (err) {
80
+ errors.push({ file, action: ref.raw, error: String(err.message ?? err) });
81
+ }
82
+ }
83
+ if (planned.length === 0) continue;
84
+
85
+ let newSource = source;
86
+ for (const { ref, sha, type } of planned) {
87
+ const change = {
88
+ id: changeId(file, ref.raw),
89
+ file,
90
+ action: `${ref.owner}/${ref.repo}${ref.subpath ? `/${ref.subpath}` : ''}`,
91
+ fromRef: ref.ref,
92
+ toSha: sha,
93
+ line: ref.line,
94
+ refType: type,
95
+ };
96
+ if (fix && fix !== change.id) continue;
97
+ newSource = rewriteUses(newSource, ref, sha);
98
+ changes.push(change);
99
+ }
100
+ if (newSource !== source) {
101
+ await writeFileGuarded({ path: file, content: newSource, dryRun, cwd });
102
+ }
103
+ }
104
+ return { changes, errors, status: errors.length === 0 ? 'OK' : 'FAIL' };
105
+ }
106
+
107
+ /**
108
+ * Replace `uses: owner/repo[/sub]@<ref>` with the pinned SHA + comment.
109
+ *
110
+ * Operates on the source string; preserves quoting and whitespace.
111
+ *
112
+ * @param {string} source
113
+ * @param {import('../lib/parser.js').ActionRef} ref
114
+ * @param {string} sha
115
+ * @returns {string}
116
+ */
117
+ export function rewriteUses(source, ref, sha) {
118
+ const left = ref.subpath ? `${ref.owner}/${ref.repo}/${ref.subpath}` : `${ref.owner}/${ref.repo}`;
119
+ const escLeft = escapeRegExp(left);
120
+ const escRef = escapeRegExp(ref.ref);
121
+ // Match: optional quote, owner/repo[/sub]@ref, optional quote, optional comment.
122
+ const re = new RegExp(
123
+ `(uses\\s*:\\s*['"]?)${escLeft}@${escRef}(['"]?)([^\\n]*)`,
124
+ 'g',
125
+ );
126
+ return source.replace(re, (_, prefix, closingQuote, trailing) => {
127
+ const tail = stripExistingVersionComment(trailing);
128
+ return `${prefix}${left}@${sha}${closingQuote}${tail} # ${ref.ref}`;
129
+ });
130
+ }
131
+
132
+ function stripExistingVersionComment(trailing) {
133
+ // Remove any existing inline comment so we don't stack `# v3 # v3`.
134
+ const idx = trailing.indexOf('#');
135
+ if (idx === -1) return trailing;
136
+ return trailing.slice(0, idx).replace(/\s+$/, '');
137
+ }
138
+
139
+ function escapeRegExp(s) {
140
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
141
+ }
142
+
143
+ function changeId(file, raw) {
144
+ return createHash('sha1').update(`pin:${file}:${raw}`).digest('hex').slice(0, 10);
145
+ }
146
+
147
+ async function resolveTargets(workflows, cwd) {
148
+ if (!workflows || workflows.length === 0) return discoverWorkflows({ cwd });
149
+ const out = new Set();
150
+ for (const w of workflows) {
151
+ for (const f of await resolveWorkflowArg(w, cwd)) out.add(f);
152
+ }
153
+ return [...out].sort();
154
+ }
155
+
156
+ /**
157
+ * @param {Awaited<ReturnType<typeof pin>>} result
158
+ * @param {{format: 'toon'|'json'|'text', dryRun: boolean, cwd?: string}} opts
159
+ */
160
+ export function renderPin(result, opts) {
161
+ const cwd = opts.cwd ?? process.cwd();
162
+ if (opts.format === 'json') {
163
+ return format('json', [], {
164
+ status: result.status,
165
+ json: {
166
+ dryRun: opts.dryRun,
167
+ changes: result.changes.map(c => ({ ...c, file: rel(c.file, cwd) })),
168
+ errors: result.errors,
169
+ status: result.status,
170
+ },
171
+ });
172
+ }
173
+ const records = [];
174
+ for (const c of result.changes) {
175
+ records.push({
176
+ label: 'PIN',
177
+ fields: {
178
+ id: c.id,
179
+ file: rel(c.file, cwd),
180
+ line: c.line,
181
+ action: c.action,
182
+ from: c.fromRef,
183
+ to: c.toSha,
184
+ kind: c.refType,
185
+ applied: !opts.dryRun,
186
+ },
187
+ });
188
+ }
189
+ for (const e of result.errors) {
190
+ records.push({ label: 'ERROR', fields: { file: rel(e.file ?? '', cwd), action: e.action ?? '', msg: e.error } });
191
+ }
192
+ records.push({ label: 'SUMMARY', fields: { changes: result.changes.length, errors: result.errors.length, dry_run: opts.dryRun } });
193
+ return format(opts.format, records, { status: result.status });
194
+ }
195
+
196
+ function rel(p, cwd) {
197
+ if (p && p.startsWith(cwd)) return p.slice(cwd.length + 1);
198
+ return p;
199
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Report command - runs audit + dry-run pin + dry-run upgrade and produces
3
+ * a combined view. Useful for "what would change?" review and LLM prompting.
4
+ */
5
+
6
+ import { audit } from './audit.js';
7
+ import { pin } from './pin.js';
8
+ import { upgrade } from './upgrade.js';
9
+ import { format } from '../lib/formatter.js';
10
+
11
+ /**
12
+ * @param {object} opts
13
+ * @param {string} [opts.cwd]
14
+ * @param {string[]} [opts.workflows]
15
+ * @param {string} [opts.token]
16
+ * @param {'major'|'minor'|'patch'} [opts.mode]
17
+ * @param {boolean} [opts.skipResolve] - when true, skip pin/upgrade (offline mode)
18
+ */
19
+ export async function report({
20
+ cwd = process.cwd(),
21
+ workflows,
22
+ token,
23
+ mode = 'minor',
24
+ skipResolve = false,
25
+ minAgeDays = 7,
26
+ } = {}) {
27
+ const auditResult = await audit({ cwd, workflows, explain: true });
28
+ let pinResult = { changes: [], errors: [], status: 'OK' };
29
+ let upgradeResult = { changes: [], errors: [], skipped: [], status: 'OK' };
30
+ if (!skipResolve) {
31
+ pinResult = await pin({ cwd, workflows, dryRun: true, token });
32
+ upgradeResult = await upgrade({ cwd, workflows, dryRun: true, token, mode, minAgeDays });
33
+ }
34
+ const status = [auditResult.status, pinResult.status, upgradeResult.status].includes('FAIL')
35
+ ? 'FAIL' : 'OK';
36
+ return { audit: auditResult, pin: pinResult, upgrade: upgradeResult, status };
37
+ }
38
+
39
+ /**
40
+ * @param {Awaited<ReturnType<typeof report>>} result
41
+ * @param {{format: 'toon'|'json'|'text', mode: string, cwd?: string}} opts
42
+ */
43
+ export function renderReport(result, opts) {
44
+ const cwd = opts.cwd ?? process.cwd();
45
+ if (opts.format === 'json') {
46
+ return format('json', [], {
47
+ status: result.status,
48
+ json: {
49
+ audit: { files: result.audit.files.map(f => rel(f, cwd)), findings: result.audit.findings, summary: result.audit.summary },
50
+ pin: { changes: result.pin.changes.map(c => ({ ...c, file: rel(c.file, cwd) })) },
51
+ upgrade: { changes: result.upgrade.changes.map(c => ({ ...c, file: rel(c.file, cwd) })), mode: opts.mode },
52
+ status: result.status,
53
+ },
54
+ });
55
+ }
56
+ const records = [];
57
+ for (const finding of result.audit.findings) {
58
+ records.push({
59
+ label: 'FINDING',
60
+ fields: {
61
+ id: finding.id,
62
+ ...finding.fields,
63
+ line: finding.line,
64
+ explain: finding.explain,
65
+ },
66
+ });
67
+ }
68
+ for (const c of result.pin.changes) {
69
+ records.push({ label: 'PIN', fields: { id: c.id, file: rel(c.file, cwd), action: c.action, from: c.fromRef, to: c.toSha } });
70
+ }
71
+ for (const c of result.upgrade.changes) {
72
+ records.push({ label: 'UPGRADE', fields: { id: c.id, file: rel(c.file, cwd), action: c.action, from: c.fromVersion ?? c.fromRef, to: c.toTag, level: c.level } });
73
+ }
74
+ records.push({
75
+ label: 'SUMMARY',
76
+ fields: {
77
+ files: result.audit.summary.files,
78
+ findings: result.audit.summary.findings,
79
+ critical: result.audit.summary.critical,
80
+ high: result.audit.summary.high,
81
+ medium: result.audit.summary.medium,
82
+ low: result.audit.summary.low,
83
+ pins: result.pin.changes.length,
84
+ upgrades: result.upgrade.changes.length,
85
+ },
86
+ });
87
+ return format(opts.format, records, { status: result.status });
88
+ }
89
+
90
+ function rel(p, cwd) {
91
+ if (p && p.startsWith(cwd)) return p.slice(cwd.length + 1);
92
+ return p;
93
+ }