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.
- package/AGENTS.md +199 -0
- package/CONTRIBUTING.md +109 -0
- package/README.md +284 -224
- package/RELEASING.md +338 -0
- package/SECURITY.md +30 -3
- package/docs/AI-AGENTS.md +474 -0
- package/docs/CLI.md +474 -0
- package/docs/CONFIGURATION.md +340 -0
- package/docs/DEVELOPMENT.md +373 -0
- package/docs/GITHUB-ACTION.md +285 -0
- package/docs/JAVASCRIPT-API.md +357 -0
- package/docs/OUTPUTS.md +414 -0
- package/docs/README.md +27 -0
- package/examples/org-scan.yml +42 -0
- package/examples/upgrade-pr.yml +57 -0
- package/llms.txt +38 -0
- package/package.json +32 -10
- package/skills/actions-warden/SKILL.md +151 -40
- package/src/action.js +306 -0
- package/src/cli.js +494 -56
- package/src/commands/audit.js +189 -36
- package/src/commands/org-scan.js +544 -0
- package/src/commands/pin.js +59 -56
- package/src/commands/report.js +122 -10
- package/src/commands/upgrade.js +102 -62
- package/src/commands/verify.js +193 -0
- package/src/index.js +21 -4
- package/src/lib/action-status.js +27 -0
- package/src/lib/agent-mode.js +174 -0
- package/src/lib/annotations.js +250 -0
- package/src/lib/baseline.js +103 -0
- package/src/lib/cache.js +47 -10
- package/src/lib/concurrency.js +27 -0
- package/src/lib/config.js +185 -0
- package/src/lib/execution.js +71 -0
- package/src/lib/formatter.js +127 -8
- package/src/lib/github-org.js +374 -0
- package/src/lib/identity.js +62 -0
- package/src/lib/ignore.js +7 -6
- package/src/lib/org-checkpoint.js +461 -0
- package/src/lib/org-progress.js +60 -0
- package/src/lib/parser.js +326 -52
- package/src/lib/patcher.js +199 -0
- package/src/lib/path-equality.js +30 -0
- package/src/lib/paths.js +35 -12
- package/src/lib/redact.js +65 -4
- package/src/lib/resolver.js +225 -43
- package/src/lib/targets.js +28 -0
- package/src/lib/triggers.js +12 -0
- package/src/lib/writer.js +48 -8
- package/src/rules/excessive-permissions.js +24 -33
- package/src/rules/index.js +19 -1
- package/src/rules/pull-request-target-checkout.js +149 -18
- package/src/rules/reusable-workflow-secrets.js +32 -0
- package/src/rules/script-injection.js +77 -12
- package/src/rules/secrets-in-env.js +101 -18
- package/src/rules/unpinned-action.js +3 -2
- package/src/rules/unpinned-container-image.js +39 -0
- package/src/rules/unpinned-docker-action.js +30 -0
- package/src/rules/untrusted-self-hosted-runner.js +109 -0
- package/src/rules/workflow-run-artifact-execution.js +122 -0
- package/src/rules/workflow-structure.js +396 -0
- package/src/version.js +3 -0
package/src/commands/audit.js
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* { id, ruleId, severity, file, line, fields, explain }
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
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
|
-
* @
|
|
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({
|
|
41
|
-
|
|
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
|
|
133
|
+
doc = await loadWorkflow(file);
|
|
48
134
|
} catch (err) {
|
|
49
135
|
findings.push({
|
|
50
|
-
id:
|
|
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
|
|
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:
|
|
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:
|
|
182
|
+
severity: effectiveSeverity,
|
|
69
183
|
file,
|
|
70
184
|
line: f.line,
|
|
71
|
-
fields
|
|
185
|
+
fields,
|
|
72
186
|
explain: f.explain,
|
|
73
187
|
};
|
|
74
188
|
findings.push(finding);
|
|
75
189
|
}
|
|
76
190
|
}
|
|
77
191
|
}
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
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 =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
}
|