@secureci/cli 0.5.0 → 0.6.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/dist/config.js CHANGED
@@ -38,6 +38,7 @@ exports.getConfigFilePath = getConfigFilePath;
38
38
  exports.loadConfig = loadConfig;
39
39
  exports.saveConfig = saveConfig;
40
40
  exports.getDefaultContainerRuntime = getDefaultContainerRuntime;
41
+ exports.getDefaultParallel = getDefaultParallel;
41
42
  exports.isValidConfigKey = isValidConfigKey;
42
43
  exports.getConfigValue = getConfigValue;
43
44
  exports.setConfigValue = setConfigValue;
@@ -47,6 +48,7 @@ exports.resolveNetworkDisabled = resolveNetworkDisabled;
47
48
  const fs = __importStar(require("fs"));
48
49
  const path = __importStar(require("path"));
49
50
  const YAML = __importStar(require("yaml"));
51
+ const scheduler_1 = require("./runner/scheduler");
50
52
  /** Built-in defaults, used when a key is absent from the config file. */
51
53
  exports.DEFAULT_CONFIG = {
52
54
  'container-runtime': 'docker'
@@ -100,12 +102,22 @@ function getDefaultContainerRuntime(config = loadConfig()) {
100
102
  }
101
103
  return value;
102
104
  }
105
+ /** Resolve the effective `parallel`: config, else the CPU count. */
106
+ function getDefaultParallel(config = loadConfig()) {
107
+ const value = config.parallel;
108
+ return value === undefined ? (0, scheduler_1.defaultParallelism)() : (0, scheduler_1.parseParallel)(value);
109
+ }
103
110
  exports.CONFIG_KEYS = [
104
111
  'container-runtime',
105
112
  'container-image',
106
113
  'container-timeout',
107
114
  'container-memory',
108
- 'allow-network'
115
+ 'allow-network',
116
+ 'parallel',
117
+ 'env-file',
118
+ 'secret-file',
119
+ 'input-file',
120
+ 'var-file'
109
121
  ];
110
122
  /** `runner-images.<label>`: one entry of the runs-on -> image mapping. */
111
123
  const RUNNER_IMAGE_KEY = /^runner-images\.(.+)$/;
@@ -169,9 +181,19 @@ function parseConfigValue(key, rawValue) {
169
181
  throw new Error(`Invalid value "${rawValue}" for allow-network. Must be "true" or "false".`);
170
182
  }
171
183
  return rawValue === 'true';
184
+ case 'parallel':
185
+ return (0, scheduler_1.parseParallel)(rawValue);
172
186
  case 'container-image':
173
187
  case 'container-memory':
174
188
  return rawValue;
189
+ case 'env-file':
190
+ case 'secret-file':
191
+ case 'input-file':
192
+ case 'var-file':
193
+ if (rawValue === '') {
194
+ throw new Error(`Invalid value "" for ${key}. Must be a file path.`);
195
+ }
196
+ return rawValue;
175
197
  }
176
198
  }
177
199
  /**
package/dist/init.js CHANGED
@@ -7,7 +7,7 @@ exports.renderInitWorkflow = renderInitWorkflow;
7
7
  * the release tag matching this CLI's version.
8
8
  *
9
9
  * The output is deterministic for a given set of options, so it can be
10
- * regenerated and diffed. With uploads on (the default) the workflow asks for
10
+ * regenerated and diffed. With uploads on (the default) the job asks for
11
11
  * `security-events: write` and lets the action upload SARIF; with uploads off
12
12
  * it keeps `contents: read` only and passes `upload-sarif: false`.
13
13
  */
@@ -23,11 +23,18 @@ function renderInitWorkflow(options) {
23
23
  ' - pull_request',
24
24
  '',
25
25
  'permissions:',
26
- ' contents: read'
26
+ ' contents: read',
27
+ '',
28
+ 'jobs:',
29
+ ' secureci:',
30
+ ' runs-on: ubuntu-latest'
27
31
  ];
28
- if (upload)
29
- lines.push(' security-events: write');
30
- lines.push('', 'jobs:', ' secureci:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@v4', ` - uses: secure-ci/cli@v${options.version}`);
32
+ // Write scopes belong on the job, not the workflow. Job-level permissions
33
+ // replace the top-level ones, so contents: read is repeated for checkout.
34
+ if (upload) {
35
+ lines.push(' permissions:', ' contents: read', ' security-events: write');
36
+ }
37
+ lines.push(' steps:', ' - uses: actions/checkout@v4', ` - uses: secure-ci/cli@v${options.version}`);
31
38
  const withLines = [];
32
39
  if (contract)
33
40
  withLines.push(` contract: ${yamlScalar(contract)}`);
package/dist/inventory.js CHANGED
@@ -247,6 +247,7 @@ async function addTransitive(direct, deps) {
247
247
  }
248
248
  byUses.set(dep.uses, dep);
249
249
  queue.push({ action: dep, depth: depth + 1 });
250
+ deps.onQueue?.(dep);
250
251
  }
251
252
  }
252
253
  return [...byUses.values()];
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.workflowTriggers = workflowTriggers;
4
+ exports.dangerousTriggerErrors = dangerousTriggerErrors;
5
+ exports.forbiddenTriggerErrors = forbiddenTriggerErrors;
6
+ const RULE = 'secureci/dangerous-trigger';
7
+ /**
8
+ * Triggers whose runs get the base repository's secrets and a read/write
9
+ * `GITHUB_TOKEN` even when the change came from a fork.
10
+ */
11
+ const PRIVILEGED_TRIGGERS = ['pull_request_target', 'workflow_run'];
12
+ /**
13
+ * Expressions (or literal refs) in `actions/checkout`'s `ref:` /
14
+ * `repository:` that select code the pull request's author controls,
15
+ * rather than the base branch.
16
+ */
17
+ const UNTRUSTED_CHECKOUT = [
18
+ /github\.event\.pull_request\.head\.(sha|ref|repo\.full_name)\b/,
19
+ /github\.event\.pull_request\.merge_commit_sha\b/,
20
+ /github\.head_ref\b/,
21
+ /github\.event\.workflow_run\.head_(sha|branch|commit\.id|repository\.full_name)\b/,
22
+ /refs\/pull\//
23
+ ];
24
+ /** A job `if:` that requires a maintainer-applied label to run. */
25
+ const LABEL_GATE = /github\.event\.(pull_request\.labels|label\.name)\b/;
26
+ /** The event names in `on:`, whichever form it takes (string, list or map). */
27
+ function workflowTriggers(on) {
28
+ if (typeof on === 'string')
29
+ return [on];
30
+ if (Array.isArray(on)) {
31
+ return on.filter((t) => typeof t === 'string');
32
+ }
33
+ if (on && typeof on === 'object')
34
+ return Object.keys(on);
35
+ return [];
36
+ }
37
+ function isCheckout(step) {
38
+ return step.uses?.split('@')[0].toLowerCase() === 'actions/checkout';
39
+ }
40
+ /** `with.ref` / `with.repository` of a checkout that selects untrusted code. */
41
+ function untrustedCheckoutInput(step) {
42
+ if (!isCheckout(step))
43
+ return undefined;
44
+ for (const key of ['ref', 'repository']) {
45
+ const value = step.with?.[key];
46
+ if (value === undefined)
47
+ continue;
48
+ if (UNTRUSTED_CHECKOUT.some((re) => re.test(String(value)))) {
49
+ return `${key}: ${String(value)}`;
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+ /** How a step would execute checked-out code, or undefined if it doesn't. */
55
+ function executes(step) {
56
+ if (step.run)
57
+ return 'run:';
58
+ if (step.uses?.startsWith('./'))
59
+ return `uses: ${step.uses}`;
60
+ return undefined;
61
+ }
62
+ /**
63
+ * "Pwn request" detection (#46): a workflow triggered by
64
+ * `pull_request_target` or `workflow_run` runs with secrets and a write
65
+ * token, so a job that checks out the pull request's code and then runs
66
+ * it (a `run:` step or a local `./` action, both of which use the checked
67
+ * out files) lets a fork execute code with those privileges.
68
+ *
69
+ * - error: such a job (reported once, at its untrusted checkout).
70
+ * - warning: the same job when its `if:` requires a label, since a
71
+ * maintainer must then approve each run.
72
+ * - warning: a privileged trigger with no such job, once per trigger, so
73
+ * teams can audit it. Omitted when a job-level finding is reported, so
74
+ * a workflow is never reported twice for the same problem.
75
+ */
76
+ function dangerousTriggerErrors(workflow) {
77
+ const triggers = workflowTriggers(workflow.on).filter((t) => PRIVILEGED_TRIGGERS.includes(t));
78
+ if (triggers.length === 0)
79
+ return [];
80
+ const named = triggers.map((t) => `"${t}"`).join(', ');
81
+ const findings = [];
82
+ for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
83
+ const steps = job.steps ?? [];
84
+ const checkoutIdx = steps.findIndex((s) => untrustedCheckoutInput(s));
85
+ if (checkoutIdx === -1)
86
+ continue;
87
+ const runIdx = steps.findIndex((s, i) => i > checkoutIdx && executes(s) !== undefined);
88
+ if (runIdx === -1)
89
+ continue;
90
+ const gated = LABEL_GATE.test(String(job.if ?? ''));
91
+ findings.push({
92
+ path: `jobs.${jobId}.steps[${checkoutIdx}]`,
93
+ message: `Job "${jobId}" checks out untrusted code (${untrustedCheckoutInput(steps[checkoutIdx])}) ` +
94
+ `in a workflow triggered by ${named}, then executes it at steps[${runIdx}] (${executes(steps[runIdx])}). ` +
95
+ `These triggers run with the repository's secrets and a write token, so a fork can run its own code with them` +
96
+ (gated
97
+ ? `. The job is gated by a label check in its if:, so review the pull request's code before applying the label`
98
+ : `; check out the base ref instead, or move the build to an unprivileged pull_request workflow`),
99
+ severity: gated ? 'warning' : 'error',
100
+ rule: RULE
101
+ });
102
+ }
103
+ if (findings.length > 0)
104
+ return findings;
105
+ return triggers.map((trigger) => ({
106
+ path: `on.${trigger}`,
107
+ message: `Workflow is triggered by "${trigger}", which runs with the repository's secrets and a write token ` +
108
+ `even for ${trigger === 'workflow_run' ? 'runs started from a fork' : 'pull requests from forks'}; ` +
109
+ `make sure it never checks out or runs untrusted code`,
110
+ severity: 'warning',
111
+ rule: RULE
112
+ }));
113
+ }
114
+ /** Contract `forbidden-triggers`: one error per forbidden event in `on:`. */
115
+ function forbiddenTriggerErrors(workflow, forbidden) {
116
+ return workflowTriggers(workflow.on)
117
+ .filter((t) => forbidden.includes(t))
118
+ .map((trigger) => ({
119
+ path: `on.${trigger}`,
120
+ message: `Trigger "${trigger}" is forbidden by the pipeline contract (forbidden-triggers)`,
121
+ severity: 'error',
122
+ rule: 'secureci/contract'
123
+ }));
124
+ }
125
+ //# sourceMappingURL=dangerous-triggers.js.map
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.UNTRUSTED_CONTEXTS = void 0;
37
+ exports.workflowInjectionErrors = workflowInjectionErrors;
38
+ exports.actionInjectionErrors = actionInjectionErrors;
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ const expression_1 = require("../runner/expression");
42
+ const repository_1 = require("../utils/repository");
43
+ const workflow_parser_1 = require("./workflow-parser");
44
+ /**
45
+ * Contexts an outside contributor can set to arbitrary text (`*` matches
46
+ * any key or array index). Based on GitHub Security Lab's list of untrusted
47
+ * input (https://securitylab.github.com/resources/github-actions-untrusted-input/),
48
+ * which GitHub's security hardening guide and zizmor's template-injection
49
+ * audit also use, plus discussion text, PR/issue label names and the
50
+ * fields of `workflow_run` that come from the triggering branch.
51
+ *
52
+ * Inputs of `workflow_dispatch` / `workflow_call` (and the matching
53
+ * `github.event.inputs.*`) are untrusted too, except typed ones that can't
54
+ * hold arbitrary text; those are derived from each workflow's triggers.
55
+ */
56
+ exports.UNTRUSTED_CONTEXTS = [
57
+ 'github.head_ref',
58
+ 'github.event.issue.title',
59
+ 'github.event.issue.body',
60
+ 'github.event.issue.labels.*.name',
61
+ 'github.event.pull_request.title',
62
+ 'github.event.pull_request.body',
63
+ 'github.event.pull_request.labels.*.name',
64
+ 'github.event.pull_request.head.ref',
65
+ 'github.event.pull_request.head.label',
66
+ 'github.event.pull_request.head.repo.default_branch',
67
+ 'github.event.comment.body',
68
+ 'github.event.review.body',
69
+ 'github.event.discussion.title',
70
+ 'github.event.discussion.body',
71
+ 'github.event.pages.*.page_name',
72
+ 'github.event.commits.*.message',
73
+ 'github.event.commits.*.author.email',
74
+ 'github.event.commits.*.author.name',
75
+ 'github.event.commits.*.committer.email',
76
+ 'github.event.commits.*.committer.name',
77
+ 'github.event.head_commit.message',
78
+ 'github.event.head_commit.author.email',
79
+ 'github.event.head_commit.author.name',
80
+ 'github.event.head_commit.committer.email',
81
+ 'github.event.head_commit.committer.name',
82
+ 'github.event.workflow_run.display_title',
83
+ 'github.event.workflow_run.head_branch',
84
+ 'github.event.workflow_run.head_commit.message',
85
+ 'github.event.workflow_run.head_commit.author.email',
86
+ 'github.event.workflow_run.head_commit.author.name',
87
+ 'github.event.workflow_run.pull_requests.*.head.ref'
88
+ ];
89
+ const BASE_PATTERNS = exports.UNTRUSTED_CONTEXTS.map((c) => c.split('.'));
90
+ /** Input types whose values are constrained, so can't carry a payload. */
91
+ const SAFE_INPUT_TYPES = new Set([
92
+ 'boolean',
93
+ 'number',
94
+ 'choice',
95
+ 'environment'
96
+ ]);
97
+ const GITHUB_SCRIPT = /^actions\/github-script(@|$)/i;
98
+ const RULE = 'secureci/injection';
99
+ /** The inner text of every `${{ }}` in a string, as the runner finds them. */
100
+ function expressionsIn(text) {
101
+ return [...text.matchAll(/\$\{\{([\s\S]*?)\}\}/g)].map((m) => m[1].trim());
102
+ }
103
+ /** Whether `prefix` matches the leading segments of `full`. */
104
+ function startsWith(full, prefix) {
105
+ return (prefix.length <= full.length &&
106
+ prefix.every((seg, i) => seg === '*' || full[i] === '*' || seg === full[i]));
107
+ }
108
+ /**
109
+ * The untrusted context paths that reach an expression's result: a value
110
+ * at or below an untrusted path, or (via toJSON) a whole subtree that holds
111
+ * one. An expression GitHub couldn't parse has none.
112
+ */
113
+ function untrustedSources(expr, patterns) {
114
+ const flow = (0, expression_1.expressionDataFlow)(expr);
115
+ if (!flow)
116
+ return [];
117
+ const hits = [
118
+ ...flow.values.filter((v) => patterns.some((p) => startsWith(v, p))),
119
+ ...flow.trees.filter((t) => patterns.some((p) => startsWith(t, p) || startsWith(p, t)))
120
+ ];
121
+ return [...new Set(hits.map((h) => h.join('.')))];
122
+ }
123
+ function isUntrusted(text, patterns) {
124
+ return expressionsIn(String(text)).some((expr) => untrustedSources(expr, patterns).length > 0);
125
+ }
126
+ /** `inputs.<name>` / `github.event.inputs.<name>` of string-typed inputs. */
127
+ function workflowInputPatterns(on) {
128
+ if (!on || typeof on !== 'object' || Array.isArray(on))
129
+ return [];
130
+ const names = new Set();
131
+ for (const trigger of ['workflow_dispatch', 'workflow_call']) {
132
+ const spec = on[trigger];
133
+ for (const [name, input] of Object.entries(spec?.inputs ?? {})) {
134
+ const type = String(input?.type ?? 'string').toLowerCase();
135
+ if (!SAFE_INPUT_TYPES.has(type))
136
+ names.add(name.toLowerCase());
137
+ }
138
+ }
139
+ return [...names].flatMap((name) => [
140
+ ['inputs', name],
141
+ ['github', 'event', 'inputs', name]
142
+ ]);
143
+ }
144
+ /**
145
+ * `inputs.<name>` of a composite action's inputs that its caller fills
146
+ * with untrusted input (or whose default is untrusted, when not given).
147
+ */
148
+ function compositeInputPatterns(action, given, callerPatterns) {
149
+ const patterns = [];
150
+ for (const [name, input] of Object.entries(action.inputs ?? {})) {
151
+ // Input names are case-insensitive.
152
+ const passed = Object.entries(given ?? {}).find(([key]) => key.toLowerCase() === name.toLowerCase());
153
+ const untrusted = passed
154
+ ? isUntrusted(passed[1], callerPatterns)
155
+ : input?.default !== undefined &&
156
+ isUntrusted(input.default, BASE_PATTERNS);
157
+ if (untrusted)
158
+ patterns.push(['inputs', name.toLowerCase()]);
159
+ }
160
+ return patterns;
161
+ }
162
+ /** An environment variable name to suggest for a context path. */
163
+ function envName(source) {
164
+ // The first segment is a context name, so some segment isn't `*`.
165
+ const segment = source
166
+ .split('.')
167
+ .filter((s) => s !== '*')
168
+ .pop();
169
+ return segment.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
170
+ }
171
+ function finding(expr, sources, kind, at) {
172
+ const name = envName(sources[0]);
173
+ const target = kind === 'run'
174
+ ? 'the shell script, so a crafted value can run arbitrary commands'
175
+ : 'the github-script code, so a crafted value can run arbitrary code';
176
+ const use = kind === 'run' ? `"$${name}"` : `process.env.${name}`;
177
+ return {
178
+ path: at,
179
+ message: `\${{ ${expr} }} expands untrusted input (${sources.join(', ')}) into ${target}. Pass it through env: (e.g. env: ${name}: \${{ ${expr} }}) and use ${use} instead.`,
180
+ severity: 'error',
181
+ rule: RULE
182
+ };
183
+ }
184
+ function checkText(text, kind, at, patterns, scan) {
185
+ for (const expr of expressionsIn(String(text))) {
186
+ const sources = untrustedSources(expr, patterns);
187
+ if (sources.length > 0)
188
+ scan.errors.push(finding(expr, sources, kind, at));
189
+ }
190
+ }
191
+ /** Parse a file reached by recursion, or null if it can't be. */
192
+ function tryParse(file) {
193
+ try {
194
+ return (0, workflow_parser_1.parseFile)(file);
195
+ }
196
+ catch {
197
+ // Not this check's concern: validating that file reports it.
198
+ return null;
199
+ }
200
+ }
201
+ /** The action.yml behind a local `uses: ./path`, if there is one. */
202
+ function resolveLocalAction(uses, root) {
203
+ const target = path.resolve(root, uses);
204
+ return [
205
+ target,
206
+ path.join(target, 'action.yml'),
207
+ path.join(target, 'action.yaml')
208
+ ].find((f) => fs.existsSync(f) && fs.statSync(f).isFile());
209
+ }
210
+ const isLocal = (uses) => uses.startsWith('./') || uses.startsWith('../');
211
+ function checkSteps(steps, at, patterns, stack, scan) {
212
+ steps.forEach((step, i) => {
213
+ const stepPath = `${at}steps[${i}]`;
214
+ if (step.run !== undefined) {
215
+ checkText(step.run, 'run', `${stepPath}.run`, patterns, scan);
216
+ }
217
+ if (!step.uses)
218
+ return;
219
+ if (GITHUB_SCRIPT.test(step.uses) && step.with?.script !== undefined) {
220
+ checkText(step.with.script, 'script', `${stepPath}.with.script`, patterns, scan);
221
+ }
222
+ if (!isLocal(step.uses))
223
+ return;
224
+ const file = resolveLocalAction(step.uses, scan.root);
225
+ if (!file || stack.includes(file))
226
+ return;
227
+ const parsed = tryParse(file);
228
+ if (parsed?.type !== 'action')
229
+ return;
230
+ checkSteps(parsed.file.runs.steps ?? [], `${stepPath} > ${step.uses} > `, [
231
+ ...BASE_PATTERNS,
232
+ ...compositeInputPatterns(parsed.file, step.with, patterns)
233
+ ], [...stack, file], scan);
234
+ });
235
+ }
236
+ function checkWorkflow(workflow, file, at, stack, scan) {
237
+ const patterns = [...BASE_PATTERNS, ...workflowInputPatterns(workflow.on)];
238
+ for (const [jobId, job] of Object.entries(workflow.jobs)) {
239
+ if (!job.uses) {
240
+ checkSteps(job.steps ?? [], `${at}jobs.${jobId}.`, patterns, stack, scan);
241
+ continue;
242
+ }
243
+ if (!isLocal(job.uses))
244
+ continue;
245
+ // A local reusable workflow resolves against its caller's repository.
246
+ const called = path.resolve((0, repository_1.repositoryRoot)(file), job.uses);
247
+ if (stack.includes(called) || !fs.existsSync(called))
248
+ continue;
249
+ const parsed = tryParse(called);
250
+ if (parsed?.type !== 'workflow')
251
+ continue;
252
+ checkWorkflow(parsed.file, called, `${at}jobs.${jobId} > ${job.uses} > `, [...stack, called], scan);
253
+ }
254
+ }
255
+ /**
256
+ * Template injection in a workflow: an untrusted `${{ }}` expression (see
257
+ * UNTRUSTED_CONTEXTS) in a `run:` script or `actions/github-script`'s
258
+ * `with.script`. GitHub substitutes expressions into the script text before
259
+ * it runs, so such a value becomes code. `env:` values are not checked:
260
+ * passing data through the environment is the fix. Local composite
261
+ * actions and local reusable workflows are checked recursively.
262
+ */
263
+ function workflowInjectionErrors(workflow, workflowPath) {
264
+ const file = path.resolve(workflowPath);
265
+ const scan = { root: (0, repository_1.repositoryRoot)(file), errors: [] };
266
+ checkWorkflow(workflow, file, '', [file], scan);
267
+ return scan.errors;
268
+ }
269
+ /**
270
+ * Template injection in a composite action's steps (see
271
+ * workflowInjectionErrors). Its own inputs aren't treated as untrusted
272
+ * here, since the callers are unknown; a workflow that passes untrusted
273
+ * input to a local action is checked through that call.
274
+ */
275
+ function actionInjectionErrors(action, actionPath) {
276
+ const file = path.resolve(actionPath);
277
+ const scan = { root: (0, repository_1.repositoryRoot)(file), errors: [] };
278
+ checkSteps(action.runs?.steps ?? [], 'runs.', BASE_PATTERNS, [file], scan);
279
+ return scan.errors;
280
+ }
281
+ //# sourceMappingURL=injection.js.map
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GITHUB_TOKEN_SCOPES = void 0;
4
+ exports.workflowPermissionErrors = workflowPermissionErrors;
5
+ exports.contractPermissionErrors = contractPermissionErrors;
6
+ /** SARIF rule for every GITHUB_TOKEN permissions finding. */
7
+ const RULE = 'secureci/permissions';
8
+ /**
9
+ * Scopes `permissions:` accepts, per GitHub's workflow syntax.
10
+ * `repository-projects` is kept for older workflows (classic Projects).
11
+ */
12
+ exports.GITHUB_TOKEN_SCOPES = [
13
+ 'actions',
14
+ 'artifact-metadata',
15
+ 'attestations',
16
+ 'checks',
17
+ 'contents',
18
+ 'deployments',
19
+ 'discussions',
20
+ 'id-token',
21
+ 'issues',
22
+ 'models',
23
+ 'packages',
24
+ 'pages',
25
+ 'pull-requests',
26
+ 'repository-projects',
27
+ 'security-events',
28
+ 'statuses'
29
+ ];
30
+ const RANK = { none: 0, read: 1, write: 2 };
31
+ const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
32
+ /** Rank of a level, or undefined for anything that isn't one. */
33
+ const rankOf = (level) => typeof level === 'string' && hasOwn(RANK, level)
34
+ ? RANK[level]
35
+ : undefined;
36
+ const declared = (permissions) => permissions !== undefined && permissions !== null;
37
+ const isScopeMap = (permissions) => typeof permissions === 'object' &&
38
+ permissions !== null &&
39
+ !Array.isArray(permissions);
40
+ /**
41
+ * A reusable workflow runs with its caller's token, so a workflow whose only
42
+ * trigger is `workflow_call` doesn't need its own top-level block.
43
+ */
44
+ function onlyWorkflowCall(on) {
45
+ const events = typeof on === 'string'
46
+ ? [on]
47
+ : Array.isArray(on)
48
+ ? on
49
+ : on && typeof on === 'object'
50
+ ? Object.keys(on)
51
+ : [];
52
+ return events.length > 0 && events.every((e) => e === 'workflow_call');
53
+ }
54
+ /**
55
+ * Jobs whose token falls back to the repository/organization default:
56
+ * no top-level `permissions:` and none on the job. Empty when the workflow
57
+ * declares a top-level block or only runs as a reusable workflow.
58
+ */
59
+ function uncoveredJobs(workflow) {
60
+ if (declared(workflow.permissions) || onlyWorkflowCall(workflow.on)) {
61
+ return [];
62
+ }
63
+ return Object.entries(workflow.jobs ?? {})
64
+ .filter(([, job]) => !declared(job?.permissions))
65
+ .map(([id]) => id);
66
+ }
67
+ const quoteList = (ids) => ids.map((id) => `"${id}"`).join(', ');
68
+ const finding = (path, message, severity) => ({ path, message, severity, rule: RULE });
69
+ /**
70
+ * Default GITHUB_TOKEN checks (warnings, so existing workflows keep
71
+ * validating): jobs left on the default token, `write-all` at either level,
72
+ * and write scopes granted to every job at the workflow level.
73
+ */
74
+ function workflowPermissionErrors(workflow) {
75
+ const errors = [];
76
+ const uncovered = uncoveredJobs(workflow);
77
+ if (uncovered.length > 0) {
78
+ errors.push(finding('permissions', `No top-level permissions: and job(s) ${quoteList(uncovered)} declare none, so their GITHUB_TOKEN gets the repository's default permissions (read/write on every scope in many repositories). Add a top-level block such as "permissions: { contents: read }", or declare permissions on each job`, 'warning'));
79
+ }
80
+ const top = workflow.permissions;
81
+ if (top === 'write-all') {
82
+ errors.push(finding('permissions', 'permissions: write-all gives every job write access to every scope. Keep the top level read-only (e.g. "contents: read") and grant write scopes on the jobs that need them', 'warning'));
83
+ }
84
+ else if (isScopeMap(top)) {
85
+ for (const [scope, level] of Object.entries(top)) {
86
+ if (level !== 'write')
87
+ continue;
88
+ errors.push(finding(`permissions.${scope}`, `Workflow-level "${scope}: write" applies to every job. Declare it on the job(s) that need it instead (job-level permissions replace the top-level ones)`, 'warning'));
89
+ }
90
+ }
91
+ for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
92
+ if (job?.permissions === 'write-all') {
93
+ errors.push(finding(`jobs.${jobId}.permissions`, `Job "${jobId}" uses permissions: write-all, giving its GITHUB_TOKEN write access to every scope. List only the scopes it needs`, 'warning'));
94
+ }
95
+ }
96
+ return errors;
97
+ }
98
+ /**
99
+ * Contract `permissions:` policy (errors). `required`: every job must have
100
+ * declared permissions, top-level or its own. `max`: a ceiling on the
101
+ * top-level block (unlisted scopes are capped at `none`); job-level
102
+ * permissions aren't capped, so write scopes go per job. A workflow whose
103
+ * only trigger is `workflow_call` needs no top-level block for either.
104
+ */
105
+ function contractPermissionErrors(workflow, policy) {
106
+ const uncovered = uncoveredJobs(workflow);
107
+ const max = policy.max;
108
+ if (uncovered.length > 0 && (policy.required || max)) {
109
+ return [
110
+ finding('permissions', policy.required
111
+ ? `The contract requires declared GITHUB_TOKEN permissions, but the workflow has no top-level permissions: and job(s) ${quoteList(uncovered)} declare none. Add a top-level permissions: block, or declare permissions on each job`
112
+ : `The contract caps workflow-level permissions at (${formatCeiling(max)}), but the workflow has no top-level permissions:, so job(s) ${quoteList(uncovered)} get the repository's default token permissions. Add a top-level block within the cap, or declare permissions on each job`, 'error')
113
+ ];
114
+ }
115
+ if (!max)
116
+ return [];
117
+ const ceiling = (scope) => (hasOwn(max, scope) ? rankOf(max[scope]) : undefined) ?? RANK.none;
118
+ const top = workflow.permissions;
119
+ if (top === 'read-all' || top === 'write-all') {
120
+ const needed = top === 'read-all' ? RANK.read : RANK.write;
121
+ if (exports.GITHUB_TOKEN_SCOPES.some((scope) => ceiling(scope) < needed)) {
122
+ return [
123
+ finding('permissions', `Workflow-level "${top}" exceeds the contract's cap on workflow-level permissions (${formatCeiling(max)}). List only the scopes the cap allows, and grant the rest on the jobs that need them`, 'error')
124
+ ];
125
+ }
126
+ return [];
127
+ }
128
+ const errors = [];
129
+ if (isScopeMap(top)) {
130
+ for (const [scope, level] of Object.entries(top)) {
131
+ const rank = rankOf(level);
132
+ if (rank !== undefined && rank > ceiling(scope)) {
133
+ errors.push(finding(`permissions.${scope}`, `Workflow-level "${scope}: ${level}" exceeds the contract's cap on workflow-level permissions (${formatCeiling(max)}). Grant it on the job(s) that need it instead`, 'error'));
134
+ }
135
+ }
136
+ }
137
+ return errors;
138
+ }
139
+ function formatCeiling(max) {
140
+ const entries = Object.entries(max);
141
+ return entries.length === 0
142
+ ? 'none'
143
+ : entries.map(([scope, level]) => `${scope}: ${level}`).join(', ');
144
+ }
145
+ //# sourceMappingURL=permissions.js.map