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.
Files changed (63) hide show
  1. package/AGENTS.md +199 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +284 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +30 -3
  6. package/docs/AI-AGENTS.md +474 -0
  7. package/docs/CLI.md +474 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +373 -0
  10. package/docs/GITHUB-ACTION.md +285 -0
  11. package/docs/JAVASCRIPT-API.md +357 -0
  12. package/docs/OUTPUTS.md +414 -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 +151 -40
  19. package/src/action.js +306 -0
  20. package/src/cli.js +494 -56
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +544 -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 +174 -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 +461 -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/path-equality.js +30 -0
  45. package/src/lib/paths.js +35 -12
  46. package/src/lib/redact.js +65 -4
  47. package/src/lib/resolver.js +225 -43
  48. package/src/lib/targets.js +28 -0
  49. package/src/lib/triggers.js +12 -0
  50. package/src/lib/writer.js +48 -8
  51. package/src/rules/excessive-permissions.js +24 -33
  52. package/src/rules/index.js +19 -1
  53. package/src/rules/pull-request-target-checkout.js +149 -18
  54. package/src/rules/reusable-workflow-secrets.js +32 -0
  55. package/src/rules/script-injection.js +77 -12
  56. package/src/rules/secrets-in-env.js +101 -18
  57. package/src/rules/unpinned-action.js +3 -2
  58. package/src/rules/unpinned-container-image.js +39 -0
  59. package/src/rules/unpinned-docker-action.js +30 -0
  60. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  61. package/src/rules/workflow-run-artifact-execution.js +122 -0
  62. package/src/rules/workflow-structure.js +396 -0
  63. package/src/version.js +3 -0
@@ -0,0 +1,109 @@
1
+ import { hasTrigger } from '../lib/triggers.js';
2
+ import picomatch from 'picomatch';
3
+ import {
4
+ checkoutOfUntrustedPr,
5
+ fetchesUntrustedPrCode,
6
+ } from './pull-request-target-checkout.js';
7
+
8
+ export const id = 'untrusted-self-hosted-runner';
9
+ export const severity = 'high';
10
+ export const description = 'Untrusted pull-request code can reach a self-hosted runner.';
11
+
12
+ function runnerRisk(value, policy) {
13
+ const labels = collectLabels(value);
14
+ const configuredLabel = labels.find(label => (
15
+ policy.selfHostedLabels.some(pattern => picomatch.isMatch(label, pattern))
16
+ ));
17
+ if (labels.includes('self-hosted')) return { selector: 'self-hosted' };
18
+ if (configuredLabel) return { selector: configuredLabel };
19
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
20
+ const group = typeof value.group === 'string' ? value.group : null;
21
+ if (
22
+ group
23
+ && policy.flagUnknownGroups
24
+ && !policy.trustedGroups.some(pattern => picomatch.isMatch(group, pattern))
25
+ ) {
26
+ return { selector: `group:${group}` };
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function collectLabels(value) {
33
+ if (typeof value === 'string') return [value];
34
+ if (Array.isArray(value)) return value.flatMap(collectLabels);
35
+ if (value && typeof value === 'object' && 'labels' in value) {
36
+ return collectLabels(value.labels);
37
+ }
38
+ return [];
39
+ }
40
+
41
+ function matrixRunnerRisk(job, rawJob, policy) {
42
+ const matrix = rawJob?.strategy?.matrix;
43
+ if (!matrix || typeof matrix !== 'object' || Array.isArray(matrix)) return null;
44
+ for (const key of matrixRunnerKeys(job.runsOn)) {
45
+ const values = [];
46
+ if (Array.isArray(matrix[key])) values.push(...matrix[key]);
47
+ if (Array.isArray(matrix.include)) {
48
+ for (const entry of matrix.include) {
49
+ if (entry && typeof entry === 'object' && key in entry) values.push(entry[key]);
50
+ }
51
+ }
52
+ for (const value of values) {
53
+ const risk = runnerRisk(value, policy);
54
+ if (risk) return { selector: `matrix.${key}:${risk.selector}` };
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function matrixRunnerKeys(value) {
61
+ if (Array.isArray(value)) return new Set(value.flatMap(item => [...matrixRunnerKeys(item)]));
62
+ if (value && typeof value === 'object') {
63
+ return new Set(Object.values(value).flatMap(item => [...matrixRunnerKeys(item)]));
64
+ }
65
+ if (typeof value !== 'string') return new Set();
66
+ const match = value.trim().match(
67
+ /^\$\{\{\s*matrix(?:\.([A-Za-z_][A-Za-z0-9_-]*)|\[['"]([^'"]+)['"]\])\s*\}\}$/i,
68
+ );
69
+ return new Set(match ? [match[1] ?? match[2]] : []);
70
+ }
71
+
72
+ /**
73
+ * @param {import('../lib/parser.js').WorkflowDoc} workflow
74
+ */
75
+ export function check(workflow, context = {}) {
76
+ const pullRequest = hasTrigger(workflow.on, 'pull_request');
77
+ const pullRequestTarget = hasTrigger(workflow.on, 'pull_request_target');
78
+ if (!pullRequest && !pullRequestTarget) return [];
79
+ const policy = {
80
+ trustedGroups: [],
81
+ selfHostedLabels: [],
82
+ flagUnknownGroups: false,
83
+ ...(context.runnerPolicy ?? {}),
84
+ };
85
+ return workflow.jobs.flatMap(job => {
86
+ if (
87
+ pullRequestTarget
88
+ && !pullRequest
89
+ && !job.steps.some(step => (
90
+ checkoutOfUntrustedPr(step) || fetchesUntrustedPrCode(step)
91
+ ))
92
+ ) return [];
93
+ const risk = runnerRisk(job.runsOn, policy)
94
+ ?? matrixRunnerRisk(job, workflow.raw?.jobs?.[job.name], policy);
95
+ if (!risk) return [];
96
+ return [{
97
+ id,
98
+ severity,
99
+ line: job.line,
100
+ fields: {
101
+ type: id,
102
+ sev: severity,
103
+ job: job.name,
104
+ selector: risk.selector,
105
+ },
106
+ explain: 'use a GitHub-hosted runner or a clean one-job ephemeral runner isolated from credentials and sensitive networks; treat approval as an additional gate, not runner cleanup',
107
+ }];
108
+ });
109
+ }
@@ -0,0 +1,122 @@
1
+ import { hasTrigger } from '../lib/triggers.js';
2
+ import { executesWorkspace, normalizeSourcePath } from '../lib/execution.js';
3
+
4
+ export const id = 'workflow-run-artifact-execution';
5
+ export const severity = 'critical';
6
+ export const description = 'Privileged workflow_run job executes files from a cross-run artifact.';
7
+
8
+ function downloadsArtifact(step) {
9
+ const officialDownload = step.uses?.owner?.toLowerCase() === 'actions'
10
+ && step.uses?.repo?.toLowerCase() === 'download-artifact';
11
+ const thirdPartyDownload = step.uses?.kind === 'external'
12
+ && step.uses?.repo?.toLowerCase().includes('download-artifact') === true
13
+ && !officialDownload;
14
+ if (officialDownload) {
15
+ const runId = step.with_?.['run-id'];
16
+ if (!selectsAnotherRun(runId, step.with_?.repository)) return null;
17
+ return {
18
+ line: step.uses.line,
19
+ path: normalizeSourcePath(step.with_?.path),
20
+ retrieval: 'actions/download-artifact-cross-run',
21
+ };
22
+ }
23
+ if (thirdPartyDownload) {
24
+ return {
25
+ line: step.uses.line,
26
+ path: normalizeSourcePath(step.with_?.path),
27
+ retrieval: 'third-party-download-action',
28
+ };
29
+ }
30
+ if (typeof step.run !== 'string') return null;
31
+ const command = step.run;
32
+ const ghDownload = command.match(
33
+ /\bgh\s+run\s+download\s+(?:"([^"]+)"|'([^']+)'|(\$\{\{[\s\S]*?\}\})|([^\s;&|]+))/i,
34
+ );
35
+ const ghRunId = ghDownload?.[1] ?? ghDownload?.[2] ?? ghDownload?.[3] ?? ghDownload?.[4];
36
+ const repository = shellOption(command, /(?:--repo|-R)/i);
37
+ if (ghRunId && !ghRunId.startsWith('-') && selectsAnotherRun(ghRunId, repository)) {
38
+ const directory = command.match(/(?:^|\s)(?:--dir|-D)(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\S+))/i);
39
+ return {
40
+ line: step.runLine || step.line,
41
+ path: normalizeSourcePath(directory?.[1] ?? directory?.[2] ?? directory?.[3]),
42
+ retrieval: 'gh-run-download-cross-run',
43
+ };
44
+ }
45
+ if (
46
+ /\b(?:gh\s+api|curl|wget)\b[\s\S]*\/actions\/artifacts\/[^/\s"']+\/(?:zip|tar)\b/i
47
+ .test(command)
48
+ || /\b(?:curl|wget)\b[\s\S]*archive_download_url\b/i.test(command)
49
+ ) {
50
+ return {
51
+ line: step.runLine || step.line,
52
+ path: '.',
53
+ retrieval: 'actions-api-artifact-archive',
54
+ };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function selectsAnotherRun(value, repository) {
60
+ if (value === undefined || value === null || value === '') return false;
61
+ if (typeof value === 'number') return true;
62
+ if (typeof value !== 'string') return true;
63
+ const normalized = value.trim();
64
+ if (!normalized) return false;
65
+ if (
66
+ /^\$\{\{\s*github\.run_id\s*\}\}$/i.test(normalized)
67
+ && isCurrentRepository(repository)
68
+ ) return false;
69
+ return true;
70
+ }
71
+
72
+ function isCurrentRepository(value) {
73
+ if (value === undefined || value === null || value === '') return true;
74
+ if (typeof value !== 'string') return false;
75
+ return /^\$\{\{\s*(?:github\.repository|github\.event\.workflow_run\.repository\.full_name)\s*\}\}$/i
76
+ .test(value.trim());
77
+ }
78
+
79
+ function shellOption(command, option) {
80
+ const match = command.match(
81
+ new RegExp(`(?:^|\\s)${option.source}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|(\\S+))`, 'i'),
82
+ );
83
+ return match?.[1] ?? match?.[2] ?? match?.[3];
84
+ }
85
+
86
+ /**
87
+ * @param {import('../lib/parser.js').WorkflowDoc} workflow
88
+ */
89
+ export function check(workflow) {
90
+ if (!hasTrigger(workflow.on, 'workflow_run')) return [];
91
+ const findings = [];
92
+ for (const job of workflow.jobs) {
93
+ const artifactSources = [];
94
+ for (const step of job.steps) {
95
+ const artifact = downloadsArtifact(step);
96
+ if (artifact) {
97
+ artifactSources.push(artifact);
98
+ continue;
99
+ }
100
+ const artifactSource = artifactSources.find(source => (
101
+ executesWorkspace(step, { sourcePaths: [source.path] })
102
+ ));
103
+ if (artifactSource) {
104
+ findings.push({
105
+ id,
106
+ severity,
107
+ line: step.runLine || step.line,
108
+ fields: {
109
+ type: id,
110
+ sev: severity,
111
+ job: job.name,
112
+ source_line: artifactSource.line,
113
+ retrieval: artifactSource.retrieval,
114
+ },
115
+ explain: 'download cross-run artifacts into a dedicated temporary directory, validate the expected producer, integrity, and data schema, then consume them only as inert data; never execute, source, or import artifact-controlled files in this privileged job',
116
+ });
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ return findings;
122
+ }
@@ -0,0 +1,396 @@
1
+ export const id = 'workflow-structure';
2
+ export const severity = 'medium';
3
+ export const description = 'Workflow or composite action contains invalid or ambiguous GitHub Actions syntax.';
4
+
5
+ const PERMISSION_SHORTHANDS = new Set(['read-all', 'write-all']);
6
+ const PERMISSION_LEVELS = new Set(['read', 'write', 'none']);
7
+
8
+ /**
9
+ * Detect high-value structural mistakes separately from security policy.
10
+ *
11
+ * @param {import('../lib/parser.js').WorkflowDoc} workflow
12
+ */
13
+ export function check(workflow) {
14
+ if (workflow.kind === 'composite-action') return checkCompositeAction(workflow);
15
+ if (workflow.kind !== 'workflow') {
16
+ return isWorkflowPath(workflow.path)
17
+ ? [finding(1, 'missing-jobs', 'workflow files must declare a `jobs:` mapping')]
18
+ : [];
19
+ }
20
+
21
+ const findings = [];
22
+ if (!workflow.onDeclared) {
23
+ findings.push(finding(1, 'missing-trigger', 'the workflow must declare an `on:` trigger'));
24
+ } else if (workflow.on === null) {
25
+ findings.push(finding(1, 'trigger', 'the workflow declares `on:` without a trigger value'));
26
+ } else if (!validTriggers(workflow.on)) {
27
+ findings.push(finding(1, 'trigger-shape', '`on` must name at least one workflow event'));
28
+ }
29
+ findings.push(...permissionFindings(
30
+ workflow.permissions,
31
+ workflow.permissionsDeclared,
32
+ workflow.permissionsLine || 1,
33
+ 'permissions',
34
+ ));
35
+
36
+ if (!isRecord(workflow.raw?.jobs)) {
37
+ findings.push(finding(1, 'jobs-mapping', '`jobs:` must be a mapping of job IDs to job definitions'));
38
+ return findings;
39
+ }
40
+ if (Object.keys(workflow.raw.jobs).length === 0) {
41
+ findings.push(finding(1, 'empty-jobs', 'the workflow must declare at least one job'));
42
+ }
43
+
44
+ for (const job of workflow.jobs) {
45
+ if (!job.validMapping) {
46
+ findings.push(finding(
47
+ job.line,
48
+ 'job-definition',
49
+ `job "${job.name}" must be a mapping`,
50
+ job.name,
51
+ ));
52
+ continue;
53
+ }
54
+ findings.push(...permissionFindings(
55
+ job.permissions,
56
+ job.permissionsDeclared,
57
+ job.permissionsLine || job.line,
58
+ 'job-permissions',
59
+ job.name,
60
+ ));
61
+
62
+ if (job.usesDeclared) {
63
+ if (!isReusableWorkflowRef(job.uses)) {
64
+ findings.push(finding(
65
+ job.line,
66
+ 'reusable-job-uses',
67
+ `job "${job.name}" must reference a reusable workflow from its uses key`,
68
+ job.name,
69
+ ));
70
+ }
71
+ if (job.runsOnDeclared) {
72
+ findings.push(finding(
73
+ job.line,
74
+ 'reusable-job-runs-on',
75
+ `job "${job.name}" cannot contain both a reusable workflow call and runs-on`,
76
+ job.name,
77
+ ));
78
+ }
79
+ if (job.stepsDeclared) {
80
+ findings.push(finding(
81
+ job.line,
82
+ 'reusable-job-steps',
83
+ `job "${job.name}" cannot contain both a reusable workflow call and steps`,
84
+ job.name,
85
+ ));
86
+ }
87
+ } else {
88
+ if (!job.runsOnDeclared || !validRunnerSelector(job.runsOn)) {
89
+ findings.push(finding(
90
+ job.line,
91
+ 'missing-runs-on',
92
+ `job "${job.name}" must declare a non-empty runs-on value`,
93
+ job.name,
94
+ ));
95
+ }
96
+ if (!job.stepsDeclared) {
97
+ findings.push(finding(
98
+ job.line,
99
+ 'missing-steps',
100
+ `job "${job.name}" must declare steps or call a reusable workflow`,
101
+ job.name,
102
+ ));
103
+ } else if (!job.stepsValid) {
104
+ findings.push(finding(
105
+ job.line,
106
+ 'steps-sequence',
107
+ `job "${job.name}" must declare steps as a sequence of mappings`,
108
+ job.name,
109
+ ));
110
+ } else if (job.steps.length === 0) {
111
+ findings.push(finding(
112
+ job.line,
113
+ 'empty-steps',
114
+ `job "${job.name}" must declare at least one step or call a reusable workflow`,
115
+ job.name,
116
+ ));
117
+ }
118
+ }
119
+ findings.push(...checkSteps(job.steps, job.name, false));
120
+ }
121
+ return findings;
122
+ }
123
+
124
+ function checkCompositeAction(workflow) {
125
+ const job = workflow.jobs[0];
126
+ if (!job?.stepsDeclared) {
127
+ return [finding(job?.line || 1, 'composite-steps', 'a composite action must declare `runs.steps`')];
128
+ }
129
+ if (!job.stepsValid) {
130
+ return [finding(job.line, 'composite-steps', 'a composite action must declare `runs.steps` as a sequence of mappings')];
131
+ }
132
+ if (job.steps.length === 0) {
133
+ return [finding(job.line, 'composite-steps', 'a composite action must declare at least one step')];
134
+ }
135
+ return checkSteps(job.steps, job.name, true);
136
+ }
137
+
138
+ function checkSteps(steps, jobName, composite) {
139
+ const findings = [];
140
+ for (const step of steps) {
141
+ if (step.primaryCount === 0) {
142
+ findings.push(finding(
143
+ step.line,
144
+ 'step-action',
145
+ 'a step must contain run, uses, wait, wait-all, cancel, or parallel',
146
+ jobName,
147
+ ));
148
+ continue;
149
+ }
150
+ if (step.primaryCount > 1) {
151
+ const runAndUsesOnly = step.usesDeclared
152
+ && step.runDeclared
153
+ && step.primaryCount === 2;
154
+ findings.push(finding(
155
+ step.line,
156
+ runAndUsesOnly ? 'step-run-uses' : 'step-primary',
157
+ runAndUsesOnly
158
+ ? 'a step cannot contain both run and uses'
159
+ : 'a step can contain only one of run, uses, wait, wait-all, cancel, or parallel',
160
+ jobName,
161
+ ));
162
+ continue;
163
+ }
164
+ if (step.backgroundDeclared && step.control) {
165
+ findings.push(finding(
166
+ step.line,
167
+ 'background-step-type',
168
+ 'background is supported only on run or uses steps',
169
+ jobName,
170
+ ));
171
+ } else if (step.backgroundDeclared && typeof step.background !== 'boolean') {
172
+ findings.push(finding(
173
+ step.line,
174
+ 'background-value',
175
+ 'background must be a boolean value',
176
+ jobName,
177
+ ));
178
+ }
179
+ if (step.control) {
180
+ findings.push(...controlStepFindings(step, jobName, composite));
181
+ continue;
182
+ }
183
+ if (step.usesDeclared && !isStepActionRef(step.uses)) {
184
+ findings.push(finding(
185
+ step.line,
186
+ 'step-uses',
187
+ 'a step uses value must reference an action, Docker image, or local/self action',
188
+ jobName,
189
+ ));
190
+ }
191
+ if (step.runDeclared && step.run === null) {
192
+ findings.push(finding(step.line, 'step-run', 'a run step must contain a script', jobName));
193
+ }
194
+ if (composite && step.runDeclared && (!step.shellDeclared || step.shell === null)) {
195
+ findings.push(finding(
196
+ step.line,
197
+ 'composite-step-shell',
198
+ 'run steps in a composite action must declare a non-empty shell',
199
+ jobName,
200
+ ));
201
+ }
202
+ if (composite && step.backgroundDeclared) {
203
+ findings.push(finding(
204
+ step.line,
205
+ 'composite-step-background',
206
+ 'background steps are not supported inside a composite action',
207
+ jobName,
208
+ ));
209
+ }
210
+ }
211
+ return findings;
212
+ }
213
+
214
+ function controlStepFindings(step, jobName, composite) {
215
+ if (composite) {
216
+ return [finding(
217
+ step.line,
218
+ 'composite-step-control',
219
+ `${step.control} control steps are not supported inside a composite action`,
220
+ jobName,
221
+ )];
222
+ }
223
+ const findings = [];
224
+ if (step.control === 'parallel') {
225
+ if (!step.parallelValid) {
226
+ findings.push(finding(
227
+ step.line,
228
+ 'parallel-steps',
229
+ 'parallel must contain a non-empty sequence of step mappings',
230
+ jobName,
231
+ ));
232
+ }
233
+ return findings;
234
+ }
235
+ if (step.ifDeclared) {
236
+ findings.push(finding(
237
+ step.line,
238
+ 'control-step-if',
239
+ `${step.control} control steps always run and do not support if`,
240
+ jobName,
241
+ ));
242
+ }
243
+ if (step.control === 'wait-all') {
244
+ if (step.controlValue !== null) {
245
+ findings.push(finding(step.line, 'wait-all-value', 'wait-all takes no value', jobName));
246
+ }
247
+ return findings;
248
+ }
249
+ if (step.control === 'wait') {
250
+ const valid = (typeof step.controlValue === 'string' && step.controlValue.length > 0)
251
+ || (
252
+ Array.isArray(step.controlValue)
253
+ && step.controlValue.length > 0
254
+ && step.controlValue.every(value => typeof value === 'string' && value.length > 0)
255
+ );
256
+ if (!valid) {
257
+ findings.push(finding(
258
+ step.line,
259
+ 'wait-value',
260
+ 'wait must name one or more background step IDs',
261
+ jobName,
262
+ ));
263
+ }
264
+ return findings;
265
+ }
266
+ if (typeof step.controlValue !== 'string' || step.controlValue.length === 0) {
267
+ findings.push(finding(
268
+ step.line,
269
+ 'cancel-value',
270
+ 'cancel must name one background step ID',
271
+ jobName,
272
+ ));
273
+ }
274
+ return findings;
275
+ }
276
+
277
+ function permissionFindings(value, declared, line, issue, job) {
278
+ if (!declared) return [];
279
+ if (value === null) {
280
+ return [finding(
281
+ line,
282
+ issue,
283
+ job
284
+ ? `job "${job}" has a bare permissions key; use an empty map or explicit scopes`
285
+ : 'bare `permissions:` is invalid; use `permissions: {}` to disable all token permissions',
286
+ job,
287
+ )];
288
+ }
289
+ if (typeof value === 'string') {
290
+ if (PERMISSION_SHORTHANDS.has(value)) return [];
291
+ return [finding(
292
+ line,
293
+ issue,
294
+ '`permissions` scalar values must be `read-all` or `write-all`',
295
+ job,
296
+ )];
297
+ }
298
+ if (!isRecord(value)) {
299
+ return [finding(line, issue, '`permissions` must be a mapping or a supported shorthand', job)];
300
+ }
301
+ const invalid = Object.entries(value)
302
+ .filter(([, level]) => !PERMISSION_LEVELS.has(level))
303
+ .map(([scope]) => scope);
304
+ return invalid.length === 0
305
+ ? []
306
+ : [finding(
307
+ line,
308
+ issue,
309
+ `permission scopes must use read, write, or none; invalid: ${invalid.join(', ')}`,
310
+ job,
311
+ )];
312
+ }
313
+
314
+ function isReusableWorkflowRef(ref) {
315
+ if (!ref) return false;
316
+ if (ref.kind === 'reusable-workflow') {
317
+ return Boolean(
318
+ ref.owner
319
+ && ref.repo
320
+ && ref.ref
321
+ && !/\s|@/.test(ref.ref)
322
+ && /^\.github\/workflows\/[^/]+\.ya?ml$/i.test(ref.subpath ?? ''),
323
+ );
324
+ }
325
+ if (ref.kind === 'local') {
326
+ return /^\.\/\.github\/workflows\/[^/]+\.ya?ml$/i.test(ref.raw);
327
+ }
328
+ return ref.kind === 'self'
329
+ && /^\$\/\.github\/workflows\/[^/]+\.ya?ml$/i.test(ref.raw);
330
+ }
331
+
332
+ function isStepActionRef(ref) {
333
+ if (!ref) return false;
334
+ if (ref.kind === 'self') return /^\$\/[^@]+$/.test(ref.raw);
335
+ if (ref.kind === 'external') {
336
+ return Boolean(
337
+ ref.owner
338
+ && ref.repo
339
+ && ref.ref
340
+ && !/\s|@/.test(ref.ref)
341
+ && /^[^/@\s]+\/[^/@\s]+(?:\/[^/@\s]+)*@[^@\s]+$/.test(ref.raw),
342
+ );
343
+ }
344
+ if (ref.kind === 'local') {
345
+ return ref.raw === './' || /^(?:\.\/|\.\.\/).+$/s.test(ref.raw);
346
+ }
347
+ if (ref.kind === 'docker') return /^docker:\/\/\S+$/.test(ref.raw);
348
+ return false;
349
+ }
350
+
351
+ function isWorkflowPath(path) {
352
+ return /(?:^|[\\/])\.github[\\/]workflows[\\/][^\\/]+\.ya?ml$/i.test(path);
353
+ }
354
+
355
+ function validTriggers(value) {
356
+ if (typeof value === 'string') return value.length > 0;
357
+ if (Array.isArray(value)) {
358
+ return value.length > 0 && value.every(event => typeof event === 'string' && event.length > 0);
359
+ }
360
+ return isRecord(value) && Object.keys(value).length > 0;
361
+ }
362
+
363
+ function validRunnerSelector(value) {
364
+ if (typeof value === 'string') return value.trim().length > 0;
365
+ if (Array.isArray(value)) {
366
+ return value.length > 0
367
+ && value.every(label => typeof label === 'string' && label.trim().length > 0);
368
+ }
369
+ if (!isRecord(value)) return false;
370
+ const groupValid = typeof value.group === 'string' && value.group.trim().length > 0;
371
+ const labelsValid = typeof value.labels === 'string'
372
+ ? value.labels.trim().length > 0
373
+ : Array.isArray(value.labels)
374
+ && value.labels.length > 0
375
+ && value.labels.every(label => typeof label === 'string' && label.trim().length > 0);
376
+ return groupValid || labelsValid;
377
+ }
378
+
379
+ function isRecord(value) {
380
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
381
+ }
382
+
383
+ function finding(line, issue, explain, job) {
384
+ return {
385
+ id,
386
+ severity,
387
+ line,
388
+ fields: {
389
+ type: id,
390
+ sev: severity,
391
+ issue,
392
+ ...(job ? { job } : {}),
393
+ },
394
+ explain,
395
+ };
396
+ }
package/src/version.js ADDED
@@ -0,0 +1,3 @@
1
+ // Runtime version embedded in the GitHub Action bundle. The version-sync check
2
+ // keeps this value aligned with package.json, the lockfile, and plugin metadata.
3
+ export const VERSION = '0.4.0';