@secureci/cli 0.5.1 → 0.7.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/README.md +74 -15
- package/dist/advisories.js +603 -0
- package/dist/cli.js +263 -38
- package/dist/config.js +23 -1
- package/dist/init.js +12 -5
- package/dist/parser/allow-comments.js +113 -0
- package/dist/parser/dangerous-triggers.js +163 -0
- package/dist/parser/injection.js +281 -0
- package/dist/parser/permissions.js +145 -0
- package/dist/parser/pipeline-contract-parser.js +70 -0
- package/dist/parser/pipeline-contract-validator.js +18 -4
- package/dist/parser/validator.js +61 -1
- package/dist/pin.js +311 -0
- package/dist/report.js +9 -2
- package/dist/runner/action-fetcher.js +100 -4
- package/dist/runner/builtin-actions.js +13 -3
- package/dist/runner/dry-run.js +57 -12
- package/dist/runner/executor.js +368 -254
- package/dist/runner/expression.js +106 -8
- package/dist/runner/job-plan.js +58 -1
- package/dist/runner/job-runtime.js +3 -3
- package/dist/runner/node-action.js +11 -3
- package/dist/runner/node-binary.js +108 -0
- package/dist/runner/scheduler.js +207 -0
- package/dist/runner/steps.js +54 -2
- package/dist/utils/env-file.js +185 -0
- package/dist/utils/logger.js +36 -7
- package/dist/version.js +56 -0
- package/package.json +3 -1
|
@@ -0,0 +1,113 @@
|
|
|
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.stepAllowComments = stepAllowComments;
|
|
37
|
+
const YAML = __importStar(require("yaml"));
|
|
38
|
+
/** `# secureci-allow <rule>` with an optional `: <reason>`. */
|
|
39
|
+
function directive(rule) {
|
|
40
|
+
const name = rule.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
return new RegExp(`^#\\s*secureci-allow\\s+${name}(?=\\s|:|$)(?::(.*))?$`);
|
|
42
|
+
}
|
|
43
|
+
/** Source offset of every comment, mapped to its text (`# ...`). */
|
|
44
|
+
function commentOffsets(content) {
|
|
45
|
+
const comments = new Map();
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const token of new YAML.Lexer().lex(content)) {
|
|
48
|
+
const type = YAML.CST.tokenType(token);
|
|
49
|
+
if (type === 'comment')
|
|
50
|
+
comments.set(offset, token);
|
|
51
|
+
// Control tokens mark the parser's state; they aren't in the source.
|
|
52
|
+
if (type !== 'doc-mode' && type !== 'flow-error-end' && type !== 'scalar') {
|
|
53
|
+
offset += token.length;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return comments;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Steps carrying a `# secureci-allow <rule>: <reason>` comment, as
|
|
60
|
+
* `jobs.<id>.steps[<i>]` paths mapped to the reason ('' when it's missing).
|
|
61
|
+
* The comment goes at the end of the step's first line (`- uses: ...`) or
|
|
62
|
+
* on a line of its own directly above the step; comments separated by a
|
|
63
|
+
* blank line, or inside the step, don't count.
|
|
64
|
+
*/
|
|
65
|
+
function stepAllowComments(content, rule) {
|
|
66
|
+
const allowed = new Map();
|
|
67
|
+
const doc = YAML.parseDocument(content);
|
|
68
|
+
if (doc.errors.length > 0)
|
|
69
|
+
return allowed;
|
|
70
|
+
const jobs = doc.get('jobs');
|
|
71
|
+
if (!YAML.isMap(jobs))
|
|
72
|
+
return allowed;
|
|
73
|
+
const comments = commentOffsets(content);
|
|
74
|
+
const pattern = directive(rule);
|
|
75
|
+
const lineStart = (offset) => content.lastIndexOf('\n', offset - 1) + 1;
|
|
76
|
+
for (const pair of jobs.items) {
|
|
77
|
+
const steps = YAML.isMap(pair.value) ? pair.value.get('steps') : undefined;
|
|
78
|
+
if (!YAML.isSeq(steps))
|
|
79
|
+
continue;
|
|
80
|
+
steps.items.forEach((step, i) => {
|
|
81
|
+
if (!YAML.isNode(step) || !step.range)
|
|
82
|
+
return;
|
|
83
|
+
const start = step.range[0];
|
|
84
|
+
const lineEnd = content.indexOf('\n', start);
|
|
85
|
+
const texts = [];
|
|
86
|
+
for (const [offset, text] of comments) {
|
|
87
|
+
if (offset >= start && (lineEnd === -1 || offset < lineEnd)) {
|
|
88
|
+
texts.push(text);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Whole-line comments directly above the step.
|
|
92
|
+
let line = lineStart(start);
|
|
93
|
+
while (line > 0) {
|
|
94
|
+
const above = lineStart(line - 1);
|
|
95
|
+
const indent = content.slice(above, line).search(/\S/);
|
|
96
|
+
const text = indent === -1 ? undefined : comments.get(above + indent);
|
|
97
|
+
if (text === undefined)
|
|
98
|
+
break;
|
|
99
|
+
texts.push(text);
|
|
100
|
+
line = above;
|
|
101
|
+
}
|
|
102
|
+
for (const text of texts) {
|
|
103
|
+
const match = pattern.exec(text.trimEnd());
|
|
104
|
+
if (match) {
|
|
105
|
+
allowed.set(`jobs.${String(pair.key)}.steps[${i}]`, (match[1] ?? '').trim());
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return allowed;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=allow-comments.js.map
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
/** The comment that downgrades a finding on a checkout step. */
|
|
8
|
+
const ALLOW = '# secureci-allow dangerous-trigger';
|
|
9
|
+
/**
|
|
10
|
+
* Triggers whose runs get the base repository's secrets and a read/write
|
|
11
|
+
* `GITHUB_TOKEN` even when the change came from a fork.
|
|
12
|
+
*/
|
|
13
|
+
const PRIVILEGED_TRIGGERS = ['pull_request_target', 'workflow_run'];
|
|
14
|
+
/**
|
|
15
|
+
* Expressions (or literal refs) in `actions/checkout`'s `ref:` /
|
|
16
|
+
* `repository:` that select code the pull request's author controls,
|
|
17
|
+
* rather than the base branch.
|
|
18
|
+
*/
|
|
19
|
+
const UNTRUSTED_CHECKOUT = [
|
|
20
|
+
/github\.event\.pull_request\.head\.(sha|ref|repo\.full_name)\b/,
|
|
21
|
+
/github\.event\.pull_request\.merge_commit_sha\b/,
|
|
22
|
+
/github\.head_ref\b/,
|
|
23
|
+
/github\.event\.workflow_run\.head_(sha|branch|commit\.id|repository\.full_name)\b/,
|
|
24
|
+
/refs\/pull\//
|
|
25
|
+
];
|
|
26
|
+
/** A job `if:` that requires a maintainer-applied label to run. */
|
|
27
|
+
const LABEL_GATE = /github\.event\.(pull_request\.labels|label\.name)\b/;
|
|
28
|
+
/** The event names in `on:`, whichever form it takes (string, list or map). */
|
|
29
|
+
function workflowTriggers(on) {
|
|
30
|
+
if (typeof on === 'string')
|
|
31
|
+
return [on];
|
|
32
|
+
if (Array.isArray(on)) {
|
|
33
|
+
return on.filter((t) => typeof t === 'string');
|
|
34
|
+
}
|
|
35
|
+
if (on && typeof on === 'object')
|
|
36
|
+
return Object.keys(on);
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
function isCheckout(step) {
|
|
40
|
+
return step.uses?.split('@')[0].toLowerCase() === 'actions/checkout';
|
|
41
|
+
}
|
|
42
|
+
/** `with.ref` / `with.repository` of a checkout that selects untrusted code. */
|
|
43
|
+
function untrustedCheckoutInput(step) {
|
|
44
|
+
if (!isCheckout(step))
|
|
45
|
+
return undefined;
|
|
46
|
+
for (const key of ['ref', 'repository']) {
|
|
47
|
+
const value = step.with?.[key];
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
if (UNTRUSTED_CHECKOUT.some((re) => re.test(String(value)))) {
|
|
51
|
+
return `${key}: ${String(value)}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether `actions/checkout` itself refuses the untrusted checkout: since
|
|
58
|
+
* June 2026 it won't check out a fork's code under these triggers unless
|
|
59
|
+
* `allow-unsafe-pr-checkout` is set. v7 has it, and floating major tags
|
|
60
|
+
* (`@v4`) and branches got it backported; SHA, minor or patch pins and v1
|
|
61
|
+
* did not.
|
|
62
|
+
*/
|
|
63
|
+
function checkoutProtection(step) {
|
|
64
|
+
const optOut = step.with?.['allow-unsafe-pr-checkout'];
|
|
65
|
+
if (String(optOut).toLowerCase() === 'true') {
|
|
66
|
+
return (`allow-unsafe-pr-checkout: true explicitly turns off actions/checkout's refusal to ` +
|
|
67
|
+
`check out a fork's code under these triggers`);
|
|
68
|
+
}
|
|
69
|
+
const version = step.uses?.split('@')[1];
|
|
70
|
+
if (version !== undefined &&
|
|
71
|
+
(/^[0-9a-f]{40}$/i.test(version) ||
|
|
72
|
+
/^v\d+\.\d+/.test(version) ||
|
|
73
|
+
/^v1$/.test(version))) {
|
|
74
|
+
return (`the safe default of actions/checkout does not apply to @${version} ` +
|
|
75
|
+
`(only v7, floating major tags like @v4 and branches have it), so the checkout succeeds`);
|
|
76
|
+
}
|
|
77
|
+
return (`actions/checkout refuses this checkout by default, so the step fails until someone ` +
|
|
78
|
+
`sets allow-unsafe-pr-checkout: true, which would run the fork's code`);
|
|
79
|
+
}
|
|
80
|
+
/** How a step would execute checked-out code, or undefined if it doesn't. */
|
|
81
|
+
function executes(step) {
|
|
82
|
+
if (step.run)
|
|
83
|
+
return 'run:';
|
|
84
|
+
if (step.uses?.startsWith('./'))
|
|
85
|
+
return `uses: ${step.uses}`;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* "Pwn request" detection (#46): a workflow triggered by
|
|
90
|
+
* `pull_request_target` or `workflow_run` runs with secrets and a write
|
|
91
|
+
* token, so a job that checks out the pull request's code and then runs
|
|
92
|
+
* it (a `run:` step or a local `./` action, both of which use the checked
|
|
93
|
+
* out files) lets a fork execute code with those privileges.
|
|
94
|
+
*
|
|
95
|
+
* - error: such a job (reported once, at its untrusted checkout).
|
|
96
|
+
* - warning: the same job when its `if:` requires a label, since a
|
|
97
|
+
* maintainer must then approve each run.
|
|
98
|
+
* - warning: a privileged trigger with no such job, once per trigger, so
|
|
99
|
+
* teams can audit it. Omitted when a job-level finding is reported, so
|
|
100
|
+
* a workflow is never reported twice for the same problem.
|
|
101
|
+
*
|
|
102
|
+
* `allowed` maps checkout step paths with a `# secureci-allow
|
|
103
|
+
* dangerous-trigger: <reason>` comment to the reason (see
|
|
104
|
+
* stepAllowComments); a reason downgrades that job's error to a warning.
|
|
105
|
+
*/
|
|
106
|
+
function dangerousTriggerErrors(workflow, allowed = new Map()) {
|
|
107
|
+
const triggers = workflowTriggers(workflow.on).filter((t) => PRIVILEGED_TRIGGERS.includes(t));
|
|
108
|
+
if (triggers.length === 0)
|
|
109
|
+
return [];
|
|
110
|
+
const named = triggers.map((t) => `"${t}"`).join(', ');
|
|
111
|
+
const findings = [];
|
|
112
|
+
for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
|
|
113
|
+
const steps = job.steps ?? [];
|
|
114
|
+
const checkoutIdx = steps.findIndex((s) => untrustedCheckoutInput(s));
|
|
115
|
+
if (checkoutIdx === -1)
|
|
116
|
+
continue;
|
|
117
|
+
const runIdx = steps.findIndex((s, i) => i > checkoutIdx && executes(s) !== undefined);
|
|
118
|
+
if (runIdx === -1)
|
|
119
|
+
continue;
|
|
120
|
+
const path = `jobs.${jobId}.steps[${checkoutIdx}]`;
|
|
121
|
+
const gated = LABEL_GATE.test(String(job.if ?? ''));
|
|
122
|
+
const reason = allowed.get(path);
|
|
123
|
+
findings.push({
|
|
124
|
+
path,
|
|
125
|
+
message: `Job "${jobId}" checks out untrusted code (${untrustedCheckoutInput(steps[checkoutIdx])}) ` +
|
|
126
|
+
`in a workflow triggered by ${named}, then executes it at steps[${runIdx}] (${executes(steps[runIdx])}). ` +
|
|
127
|
+
`These triggers run with the repository's secrets and a write token, so a fork can run its own code with them; ` +
|
|
128
|
+
`${checkoutProtection(steps[checkoutIdx])}` +
|
|
129
|
+
(gated
|
|
130
|
+
? `. The job is gated by a label check in its if:, so review the pull request's code before applying the label`
|
|
131
|
+
: `. Check out the base ref instead, or move the build to an unprivileged pull_request workflow`) +
|
|
132
|
+
(reason
|
|
133
|
+
? `. Downgraded to a warning by "${ALLOW}" on this step: ${reason}`
|
|
134
|
+
: reason === ''
|
|
135
|
+
? `. The "${ALLOW}" comment on this step needs a reason (${ALLOW}: <reason>) to downgrade this to a warning`
|
|
136
|
+
: ''),
|
|
137
|
+
severity: gated || reason ? 'warning' : 'error',
|
|
138
|
+
rule: RULE
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (findings.length > 0)
|
|
142
|
+
return findings;
|
|
143
|
+
return triggers.map((trigger) => ({
|
|
144
|
+
path: `on.${trigger}`,
|
|
145
|
+
message: `Workflow is triggered by "${trigger}", which runs with the repository's secrets and a write token ` +
|
|
146
|
+
`even for ${trigger === 'workflow_run' ? 'runs started from a fork' : 'pull requests from forks'}; ` +
|
|
147
|
+
`make sure it never checks out or runs untrusted code`,
|
|
148
|
+
severity: 'warning',
|
|
149
|
+
rule: RULE
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
/** Contract `forbidden-triggers`: one error per forbidden event in `on:`. */
|
|
153
|
+
function forbiddenTriggerErrors(workflow, forbidden) {
|
|
154
|
+
return workflowTriggers(workflow.on)
|
|
155
|
+
.filter((t) => forbidden.includes(t))
|
|
156
|
+
.map((trigger) => ({
|
|
157
|
+
path: `on.${trigger}`,
|
|
158
|
+
message: `Trigger "${trigger}" is forbidden by the pipeline contract (forbidden-triggers)`,
|
|
159
|
+
severity: 'error',
|
|
160
|
+
rule: 'secureci/contract'
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
//# 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
|