@secureci/cli 0.6.0 → 0.8.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 +10 -8
- package/dist/cli.js +8 -5
- package/dist/parser/allow-comments.js +113 -0
- package/dist/parser/dangerous-triggers.js +43 -5
- package/dist/parser/validator.js +36 -1
- package/dist/report.js +2 -1
- package/dist/runner/action-fetcher.js +43 -6
- package/dist/runner/builtin-actions.js +98 -9
- package/dist/runner/checkout.js +18 -1
- package/dist/runner/container-policy.js +166 -0
- package/dist/runner/container-runtime.js +32 -1
- package/dist/runner/docker-action.js +8 -0
- package/dist/runner/job-container.js +29 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -16,14 +16,16 @@ of [SecureCI](https://secure-ci.dev).
|
|
|
16
16
|
|
|
17
17
|
A single CLI that understands GitHub Actions the way the runner does:
|
|
18
18
|
|
|
19
|
-
- **Run workflows locally** — jobs in `needs:` order
|
|
20
|
-
workflows, composite / JavaScript / Docker actions, and the full
|
|
19
|
+
- **Run workflows locally** — jobs in `needs:` order (independent jobs and
|
|
20
|
+
matrix combinations in parallel), matrix builds, reusable workflows, composite / JavaScript / Docker actions, and the full
|
|
21
21
|
`${{ }}` / `if:` expression language.
|
|
22
22
|
- **Validate** workflow and action files, flag **template injection**
|
|
23
|
-
(untrusted `${{ }}` input expanded into `run:` scripts)
|
|
24
|
-
`GITHUB_TOKEN` permissions
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
(untrusted `${{ }}` input expanded into `run:` scripts), over-broad
|
|
24
|
+
`GITHUB_TOKEN` permissions and `pull_request_target` / `workflow_run` jobs
|
|
25
|
+
that run untrusted code (noting whether `actions/checkout`'s safe default
|
|
26
|
+
covers the checkout), and **check them against a pipeline contract**
|
|
27
|
+
(required/forbidden actions, ordering, minimum versions, SHA-pinning,
|
|
28
|
+
permissions, forbidden triggers and advisory severity rules).
|
|
27
29
|
- **Inventory** every action a workflow pulls in, resolve each tag to a commit
|
|
28
30
|
SHA, and **catch supply-chain drift** when a tag quietly starts pointing
|
|
29
31
|
somewhere new.
|
|
@@ -235,9 +237,9 @@ the feature named, and none runs at install time.
|
|
|
235
237
|
| Capability | Where | Why |
|
|
236
238
|
| ------------------------ | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
237
239
|
| Shell / process spawning | `run:` steps, JavaScript actions, `docker`/`podman`/`nerdctl` | Runs workflow steps in a container (or on your host with `--container-runtime host`). `commander` also loads `child_process`, used only for executable subcommands this CLI does not use. |
|
|
238
|
-
| Network | `github.com`, `api.github.com` (or the GHES host you set) | Downloads actions and workflows,
|
|
240
|
+
| Network | `github.com`, `api.github.com` (or the GHES host you set) | Downloads actions and workflows, resolves tags to commit SHAs, and looks up security advisories for the actions you use (skipped with `--offline`). |
|
|
239
241
|
| Environment variables | `HOME`, `PATH`, `GITHUB_*`, `GITHUB_TOKEN`, `GH_TOKEN` | Builds the runner environment and authenticates GitHub downloads. |
|
|
240
|
-
| Filesystem | `~/.secureci/`, the working directory, temp dirs | Caches actions, stores artifacts and config, and reads your workflow files.
|
|
242
|
+
| Filesystem | `~/.secureci/`, the working directory, temp dirs | Caches actions and advisories, stores artifacts and config, and reads your workflow files. |
|
|
241
243
|
| Hard-coded URLs | GitHub documentation and JSON Schema URLs in messages and editor hints | Informational only; the CLI does not fetch them. |
|
|
242
244
|
|
|
243
245
|
No install scripts run for consumers. The published tarball contains only
|
package/dist/cli.js
CHANGED
|
@@ -795,13 +795,16 @@ async function validateFiles(files, contractFile, recurse, format, offline) {
|
|
|
795
795
|
return;
|
|
796
796
|
}
|
|
797
797
|
for (const { file, result } of entries) {
|
|
798
|
-
if (result.valid)
|
|
798
|
+
if (result.valid)
|
|
799
799
|
logger_1.logger.success(`Validation passed: ${file}`);
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
else {
|
|
800
|
+
else
|
|
803
801
|
logger_1.logger.error(`Validation failed: ${file}`);
|
|
804
|
-
|
|
802
|
+
for (const error of result.errors) {
|
|
803
|
+
const line = ` [${error.severity.toUpperCase()}] ${error.path}: ${error.message}`;
|
|
804
|
+
if (error.severity === 'warning')
|
|
805
|
+
logger_1.logger.warn(line);
|
|
806
|
+
else
|
|
807
|
+
logger_1.logger.error(line);
|
|
805
808
|
}
|
|
806
809
|
}
|
|
807
810
|
if (!allValid)
|
|
@@ -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
|
|
@@ -4,6 +4,8 @@ exports.workflowTriggers = workflowTriggers;
|
|
|
4
4
|
exports.dangerousTriggerErrors = dangerousTriggerErrors;
|
|
5
5
|
exports.forbiddenTriggerErrors = forbiddenTriggerErrors;
|
|
6
6
|
const RULE = 'secureci/dangerous-trigger';
|
|
7
|
+
/** The comment that downgrades a finding on a checkout step. */
|
|
8
|
+
const ALLOW = '# secureci-allow dangerous-trigger';
|
|
7
9
|
/**
|
|
8
10
|
* Triggers whose runs get the base repository's secrets and a read/write
|
|
9
11
|
* `GITHUB_TOKEN` even when the change came from a fork.
|
|
@@ -51,6 +53,30 @@ function untrustedCheckoutInput(step) {
|
|
|
51
53
|
}
|
|
52
54
|
return undefined;
|
|
53
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
|
+
}
|
|
54
80
|
/** How a step would execute checked-out code, or undefined if it doesn't. */
|
|
55
81
|
function executes(step) {
|
|
56
82
|
if (step.run)
|
|
@@ -72,8 +98,12 @@ function executes(step) {
|
|
|
72
98
|
* - warning: a privileged trigger with no such job, once per trigger, so
|
|
73
99
|
* teams can audit it. Omitted when a job-level finding is reported, so
|
|
74
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.
|
|
75
105
|
*/
|
|
76
|
-
function dangerousTriggerErrors(workflow) {
|
|
106
|
+
function dangerousTriggerErrors(workflow, allowed = new Map()) {
|
|
77
107
|
const triggers = workflowTriggers(workflow.on).filter((t) => PRIVILEGED_TRIGGERS.includes(t));
|
|
78
108
|
if (triggers.length === 0)
|
|
79
109
|
return [];
|
|
@@ -87,16 +117,24 @@ function dangerousTriggerErrors(workflow) {
|
|
|
87
117
|
const runIdx = steps.findIndex((s, i) => i > checkoutIdx && executes(s) !== undefined);
|
|
88
118
|
if (runIdx === -1)
|
|
89
119
|
continue;
|
|
120
|
+
const path = `jobs.${jobId}.steps[${checkoutIdx}]`;
|
|
90
121
|
const gated = LABEL_GATE.test(String(job.if ?? ''));
|
|
122
|
+
const reason = allowed.get(path);
|
|
91
123
|
findings.push({
|
|
92
|
-
path
|
|
124
|
+
path,
|
|
93
125
|
message: `Job "${jobId}" checks out untrusted code (${untrustedCheckoutInput(steps[checkoutIdx])}) ` +
|
|
94
126
|
`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` +
|
|
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])}` +
|
|
96
129
|
(gated
|
|
97
130
|
? `. The job is gated by a label check in its if:, so review the pull request's code before applying the label`
|
|
98
|
-
:
|
|
99
|
-
|
|
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',
|
|
100
138
|
rule: RULE
|
|
101
139
|
});
|
|
102
140
|
}
|
package/dist/parser/validator.js
CHANGED
|
@@ -1,15 +1,50 @@
|
|
|
1
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
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.workflowExpressionErrors = workflowExpressionErrors;
|
|
4
37
|
exports.actionExpressionErrors = actionExpressionErrors;
|
|
5
38
|
exports.validateWorkflow = validateWorkflow;
|
|
6
39
|
exports.mergeValidationResults = mergeValidationResults;
|
|
7
40
|
exports.validateAction = validateAction;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
8
42
|
const workflow_parser_1 = require("./workflow-parser");
|
|
9
43
|
const expression_1 = require("../runner/expression");
|
|
10
44
|
const injection_1 = require("./injection");
|
|
11
45
|
const permissions_1 = require("./permissions");
|
|
12
46
|
const dangerous_triggers_1 = require("./dangerous-triggers");
|
|
47
|
+
const allow_comments_1 = require("./allow-comments");
|
|
13
48
|
/**
|
|
14
49
|
* Invalid `if:` expressions in a workflow: syntax errors, or contexts and
|
|
15
50
|
* functions not available where they're used. GitHub rejects such a
|
|
@@ -126,7 +161,7 @@ function validateWorkflow(filePath) {
|
|
|
126
161
|
errors.push(...workflowExpressionErrors(workflow));
|
|
127
162
|
errors.push(...(0, injection_1.workflowInjectionErrors)(workflow, filePath));
|
|
128
163
|
errors.push(...(0, permissions_1.workflowPermissionErrors)(workflow));
|
|
129
|
-
errors.push(...(0, dangerous_triggers_1.dangerousTriggerErrors)(workflow));
|
|
164
|
+
errors.push(...(0, dangerous_triggers_1.dangerousTriggerErrors)(workflow, (0, allow_comments_1.stepAllowComments)(fs.readFileSync(filePath, 'utf-8'), 'dangerous-trigger')));
|
|
130
165
|
}
|
|
131
166
|
catch (error) {
|
|
132
167
|
errors.push({
|
package/dist/report.js
CHANGED
|
@@ -43,6 +43,7 @@ exports.lsRemoteSha = lsRemoteSha;
|
|
|
43
43
|
exports.parseLsRemoteTags = parseLsRemoteTags;
|
|
44
44
|
exports.lsRemoteTags = lsRemoteTags;
|
|
45
45
|
exports.getText = getText;
|
|
46
|
+
exports.followRedirect = followRedirect;
|
|
46
47
|
exports.fetchAction = fetchAction;
|
|
47
48
|
exports.fetchRepoFile = fetchRepoFile;
|
|
48
49
|
exports.clearActionCache = clearActionCache;
|
|
@@ -62,6 +63,11 @@ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
|
62
63
|
/** Default connect/idle timeout for action downloads. */
|
|
63
64
|
const DOWNLOAD_TIMEOUT_MS = 30000;
|
|
64
65
|
const MAX_REDIRECTS = 5;
|
|
66
|
+
// GitHub owner and repository names; never `.`/`..` (they'd be cache paths).
|
|
67
|
+
const NAME = /^[A-Za-z0-9_.][A-Za-z0-9_.-]*$/;
|
|
68
|
+
const validName = (name) => NAME.test(name) && name !== '.' && name !== '..';
|
|
69
|
+
/** API lookups are small; refuse anything bigger than this. */
|
|
70
|
+
const MAX_TEXT_BYTES = 16 * 1024 * 1024;
|
|
65
71
|
/**
|
|
66
72
|
* Parse an action reference like "actions/checkout@v3" or "owner/repo/path@ref"
|
|
67
73
|
*/
|
|
@@ -73,6 +79,10 @@ function parseActionReference(uses) {
|
|
|
73
79
|
return null;
|
|
74
80
|
}
|
|
75
81
|
const [, owner, repo, actionPath, ref] = match;
|
|
82
|
+
if (!validName(owner) || !validName(repo)) {
|
|
83
|
+
logger_1.logger.warn(`Invalid action reference format: ${uses}`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
76
86
|
return {
|
|
77
87
|
owner,
|
|
78
88
|
repo,
|
|
@@ -216,8 +226,16 @@ function getText(url, headers, timeoutMs) {
|
|
|
216
226
|
const client = url.startsWith('http://') ? http : https;
|
|
217
227
|
const request = client.get(url, { headers }, (response) => {
|
|
218
228
|
let body = '';
|
|
229
|
+
let size = 0;
|
|
219
230
|
response.setEncoding('utf8');
|
|
220
|
-
response.on('data', (chunk) =>
|
|
231
|
+
response.on('data', (chunk) => {
|
|
232
|
+
size += Buffer.byteLength(chunk);
|
|
233
|
+
if (size > MAX_TEXT_BYTES) {
|
|
234
|
+
request.destroy(new Error(`Response too large: ${url}`));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
body += chunk;
|
|
238
|
+
});
|
|
221
239
|
response.on('end', () => response.statusCode === 200
|
|
222
240
|
? resolve(body)
|
|
223
241
|
: reject(Object.assign(new Error(`HTTP ${response.statusCode}`), {
|
|
@@ -252,6 +270,22 @@ async function resolveSha(ref, token, timeoutMs) {
|
|
|
252
270
|
sha: await lsRemoteSha(`${serverUrl()}/${ref.owner}/${ref.repo}.git`, ref.ref)
|
|
253
271
|
};
|
|
254
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Where a redirect from `url` to `location` goes, and with which headers:
|
|
275
|
+
* the Authorization header only stays on the same host, and an https URL
|
|
276
|
+
* never redirects to http.
|
|
277
|
+
*/
|
|
278
|
+
function followRedirect(url, location, headers) {
|
|
279
|
+
const from = new URL(url);
|
|
280
|
+
const next = new URL(location, url);
|
|
281
|
+
if (from.protocol === 'https:' && next.protocol !== 'https:') {
|
|
282
|
+
throw new Error(`Refusing insecure redirect from ${url} to ${next}`);
|
|
283
|
+
}
|
|
284
|
+
const nextHeaders = { ...headers };
|
|
285
|
+
if (next.host !== from.host)
|
|
286
|
+
delete nextHeaders.Authorization;
|
|
287
|
+
return { url: next.toString(), headers: nextHeaders };
|
|
288
|
+
}
|
|
255
289
|
/**
|
|
256
290
|
* Download a URL to a file, following up to MAX_REDIRECTS redirects. The
|
|
257
291
|
* Authorization header is only sent to the original host (GitHub's API
|
|
@@ -274,12 +308,15 @@ function downloadFile(url, dest, timeoutMs, headers, redirectsLeft = MAX_REDIREC
|
|
|
274
308
|
reject(new Error(`Too many redirects downloading ${url}`));
|
|
275
309
|
}
|
|
276
310
|
else {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
311
|
+
let next;
|
|
312
|
+
try {
|
|
313
|
+
next = followRedirect(url, location, headers);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
reject(error);
|
|
317
|
+
return;
|
|
281
318
|
}
|
|
282
|
-
downloadFile(next.
|
|
319
|
+
downloadFile(next.url, dest, timeoutMs, next.headers, redirectsLeft - 1).then(resolve, reject);
|
|
283
320
|
}
|
|
284
321
|
return;
|
|
285
322
|
}
|
|
@@ -58,7 +58,7 @@ function hostFileSystem(workspace) {
|
|
|
58
58
|
workspace,
|
|
59
59
|
home: os.homedir(),
|
|
60
60
|
path: path,
|
|
61
|
-
readable: async (logical) =>
|
|
61
|
+
readable: async (logical) => fs.existsSync(logical) ? fs.realpathSync(logical) : null,
|
|
62
62
|
writeInto: async (logical, fill) => {
|
|
63
63
|
fs.mkdirSync(logical, { recursive: true });
|
|
64
64
|
fill(logical);
|
|
@@ -76,24 +76,58 @@ function containerFileSystem(access) {
|
|
|
76
76
|
return dir;
|
|
77
77
|
};
|
|
78
78
|
const inWorkspace = (logical) => logical === access.workspace || logical.startsWith(access.workspace + '/');
|
|
79
|
-
const
|
|
79
|
+
const segments = (logical) => p.relative(access.workspace, logical).split('/').filter(Boolean);
|
|
80
|
+
// The container can put symlinks in the mounted workspace; on the host
|
|
81
|
+
// they resolve against the host's filesystem. Every workspace path is
|
|
82
|
+
// resolved and must stay inside the workspace.
|
|
83
|
+
const hostRoot = () => fs.realpathSync(access.hostWorkspace);
|
|
84
|
+
const contained = (root, real) => real === root || real.startsWith(root + path.sep);
|
|
80
85
|
return {
|
|
81
86
|
workspace: access.workspace,
|
|
82
87
|
home: access.home,
|
|
83
88
|
path: p,
|
|
84
89
|
readable: async (logical) => {
|
|
85
90
|
if (inWorkspace(logical)) {
|
|
86
|
-
const
|
|
87
|
-
|
|
91
|
+
const root = hostRoot();
|
|
92
|
+
let real;
|
|
93
|
+
try {
|
|
94
|
+
real = fs.realpathSync(path.join(root, ...segments(logical)));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return contained(root, real) ? real : null;
|
|
88
100
|
}
|
|
89
101
|
const target = path.join(tempDir(), 'copy');
|
|
90
102
|
return (await access.copyFrom(logical, target)) ? target : null;
|
|
91
103
|
},
|
|
92
104
|
writeInto: async (logical, fill) => {
|
|
93
105
|
if (inWorkspace(logical)) {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
const root = hostRoot();
|
|
107
|
+
let dir = root;
|
|
108
|
+
for (const part of segments(logical)) {
|
|
109
|
+
const next = path.join(dir, part);
|
|
110
|
+
const stat = lstatOrNull(next);
|
|
111
|
+
if (!stat) {
|
|
112
|
+
fs.mkdirSync(next);
|
|
113
|
+
dir = next;
|
|
114
|
+
}
|
|
115
|
+
else if (stat.isDirectory()) {
|
|
116
|
+
dir = next;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const real = stat.isSymbolicLink() ? realpathOrNull(next) : null;
|
|
120
|
+
if (!real || !contained(root, real)) {
|
|
121
|
+
throw new Error(`Refusing to write to ${logical}: it is outside the workspace or not a directory`);
|
|
122
|
+
}
|
|
123
|
+
dir = real;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Fill a private staging directory, then merge it in without
|
|
127
|
+
// following any symlink the container left in the destination.
|
|
128
|
+
const stage = tempDir();
|
|
129
|
+
fill(stage);
|
|
130
|
+
mergeNoFollow(stage, dir);
|
|
97
131
|
return;
|
|
98
132
|
}
|
|
99
133
|
const stage = tempDir();
|
|
@@ -110,8 +144,63 @@ function containerFileSystem(access) {
|
|
|
110
144
|
}
|
|
111
145
|
};
|
|
112
146
|
}
|
|
147
|
+
function lstatOrNull(file) {
|
|
148
|
+
try {
|
|
149
|
+
return fs.lstatSync(file);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function realpathOrNull(file) {
|
|
156
|
+
try {
|
|
157
|
+
return fs.realpathSync(file);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Copy the tree `from` into `to`, replacing (never following) symlinks in
|
|
165
|
+
* `to`, so the copy can't land outside it.
|
|
166
|
+
*/
|
|
167
|
+
function mergeNoFollow(from, to) {
|
|
168
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
169
|
+
const src = path.join(from, entry.name);
|
|
170
|
+
const dst = path.join(to, entry.name);
|
|
171
|
+
let stat = lstatOrNull(dst);
|
|
172
|
+
if (stat?.isSymbolicLink()) {
|
|
173
|
+
fs.unlinkSync(dst);
|
|
174
|
+
stat = null;
|
|
175
|
+
}
|
|
176
|
+
if (entry.isDirectory()) {
|
|
177
|
+
if (!stat)
|
|
178
|
+
fs.mkdirSync(dst);
|
|
179
|
+
else if (!stat.isDirectory()) {
|
|
180
|
+
throw new Error(`Cannot overwrite non-directory ${dst} with a directory`);
|
|
181
|
+
}
|
|
182
|
+
mergeNoFollow(src, dst);
|
|
183
|
+
}
|
|
184
|
+
else if (entry.isFile()) {
|
|
185
|
+
const fd = fs.openSync(dst, fs.constants.O_WRONLY |
|
|
186
|
+
fs.constants.O_CREAT |
|
|
187
|
+
fs.constants.O_TRUNC |
|
|
188
|
+
(fs.constants.O_NOFOLLOW ?? 0));
|
|
189
|
+
try {
|
|
190
|
+
fs.writeFileSync(fd, fs.readFileSync(src));
|
|
191
|
+
fs.fchmodSync(fd, fs.statSync(src).mode & 0o7777);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
fs.closeSync(fd);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
113
199
|
function listFiles(hostRoot, base = '') {
|
|
114
|
-
|
|
200
|
+
// Never follow a symlink (e.g. one copied out of a container).
|
|
201
|
+
const stat = fs.lstatSync(hostRoot);
|
|
202
|
+
if (stat.isSymbolicLink())
|
|
203
|
+
return [];
|
|
115
204
|
if (stat.isFile())
|
|
116
205
|
return [base];
|
|
117
206
|
const files = [];
|
|
@@ -170,7 +259,7 @@ async function collectFiles(fsys, patterns, options) {
|
|
|
170
259
|
const host = await fsys.readable(searchPath);
|
|
171
260
|
if (!host)
|
|
172
261
|
continue;
|
|
173
|
-
const isFile = fs.
|
|
262
|
+
const isFile = fs.lstatSync(host).isFile();
|
|
174
263
|
searchPaths.push(isFile ? p.dirname(searchPath) : searchPath);
|
|
175
264
|
const matcher = globAt === -1 ? undefined : (0, hash_files_1.globToRegExp)(pattern);
|
|
176
265
|
for (const rel of listFiles(host)) {
|
package/dist/runner/checkout.js
CHANGED
|
@@ -86,6 +86,21 @@ function isLocalCheckout(uses, withValues, ctx) {
|
|
|
86
86
|
* cloned and `.git` is never touched. With `path:`, the working copy is
|
|
87
87
|
* copied into that subdirectory and the copy is removed at job end.
|
|
88
88
|
*/
|
|
89
|
+
/**
|
|
90
|
+
* Whether `dest` (or the closest part of it that exists) resolves outside
|
|
91
|
+
* `workspace` through a symlink, e.g. one a container step planted.
|
|
92
|
+
*/
|
|
93
|
+
function escapesViaSymlink(workspace, dest) {
|
|
94
|
+
const root = fs.realpathSync(workspace);
|
|
95
|
+
let existing = dest;
|
|
96
|
+
while (!fs.existsSync(existing) && existing !== path.dirname(existing)) {
|
|
97
|
+
if (fs.lstatSync(existing, { throwIfNoEntry: false }))
|
|
98
|
+
return true; // dangling link
|
|
99
|
+
existing = path.dirname(existing);
|
|
100
|
+
}
|
|
101
|
+
const real = fs.realpathSync(existing);
|
|
102
|
+
return real !== root && !real.startsWith(root + path.sep);
|
|
103
|
+
}
|
|
89
104
|
function localCheckout(step, ctx, runtime, startTime) {
|
|
90
105
|
const withValues = (0, expression_1.substituteRecord)(step.with, ctx);
|
|
91
106
|
const outputs = {
|
|
@@ -104,7 +119,9 @@ function localCheckout(step, ctx, runtime, startTime) {
|
|
|
104
119
|
const workspace = process.cwd();
|
|
105
120
|
const dest = path.resolve(workspace, withValues.path || '.');
|
|
106
121
|
const relative = path.relative(workspace, dest);
|
|
107
|
-
if (relative.startsWith('..') ||
|
|
122
|
+
if (relative.startsWith('..') ||
|
|
123
|
+
path.isAbsolute(relative) ||
|
|
124
|
+
escapesViaSymlink(workspace, dest)) {
|
|
108
125
|
return fail(`actions/checkout: path "${withValues.path}" is outside the workspace`);
|
|
109
126
|
}
|
|
110
127
|
let output = 'Using local working copy';
|
|
@@ -0,0 +1,166 @@
|
|
|
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.invalidImageReason = invalidImageReason;
|
|
37
|
+
exports.unsafeServiceOptions = unsafeServiceOptions;
|
|
38
|
+
exports.unsafeServiceVolumeReason = unsafeServiceVolumeReason;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
/**
|
|
42
|
+
* What the CLI lets workflow and action content pass to the container
|
|
43
|
+
* engine. Container isolation is the boundary between third-party code
|
|
44
|
+
* (remote actions, remote reusable workflows) and this machine, so anything
|
|
45
|
+
* that would let a container reach the host is refused.
|
|
46
|
+
*/
|
|
47
|
+
// A Docker image reference: never an option, never whitespace.
|
|
48
|
+
const IMAGE = /^[A-Za-z0-9][A-Za-z0-9._\-/:@]*$/;
|
|
49
|
+
/** Why `image` can't be passed to the container engine, if it can't. */
|
|
50
|
+
function invalidImageReason(image) {
|
|
51
|
+
return IMAGE.test(image)
|
|
52
|
+
? undefined
|
|
53
|
+
: `Invalid image reference ${JSON.stringify(image)}: an image name can't be empty, start with "-" or contain whitespace`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `docker run` options that give a container the host's privileges,
|
|
57
|
+
* namespaces, devices or filesystem.
|
|
58
|
+
*/
|
|
59
|
+
const UNSAFE_LONG_OPTIONS = new Set([
|
|
60
|
+
'--privileged',
|
|
61
|
+
'--cap-add',
|
|
62
|
+
'--security-opt',
|
|
63
|
+
'--pid',
|
|
64
|
+
'--network',
|
|
65
|
+
'--net',
|
|
66
|
+
'--ipc',
|
|
67
|
+
'--userns',
|
|
68
|
+
'--uts',
|
|
69
|
+
'--cgroupns',
|
|
70
|
+
'--device',
|
|
71
|
+
'--device-cgroup-rule',
|
|
72
|
+
'--volume',
|
|
73
|
+
'--mount',
|
|
74
|
+
'--volumes-from',
|
|
75
|
+
'--runtime',
|
|
76
|
+
'--cgroup-parent'
|
|
77
|
+
]);
|
|
78
|
+
const UNSAFE_SHORT_OPTIONS = new Set(['-v']);
|
|
79
|
+
/** Long options that take a value as the next argument (when no `=`). */
|
|
80
|
+
const VALUE_OPTIONS = new Set([
|
|
81
|
+
...[...UNSAFE_LONG_OPTIONS].filter((o) => o !== '--privileged'),
|
|
82
|
+
'--health-cmd',
|
|
83
|
+
'--health-interval',
|
|
84
|
+
'--health-timeout',
|
|
85
|
+
'--health-retries',
|
|
86
|
+
'--health-start-period',
|
|
87
|
+
'--entrypoint',
|
|
88
|
+
'--env',
|
|
89
|
+
'--shm-size',
|
|
90
|
+
'--tmpfs',
|
|
91
|
+
'--user',
|
|
92
|
+
'--hostname',
|
|
93
|
+
'--workdir',
|
|
94
|
+
'--name',
|
|
95
|
+
'--memory',
|
|
96
|
+
'--cpus',
|
|
97
|
+
'--label',
|
|
98
|
+
'--add-host',
|
|
99
|
+
'--ulimit'
|
|
100
|
+
]);
|
|
101
|
+
const SHORT_VALUE_OPTIONS = new Set(['-v', '-e', '-u', '-w', '-h', '-l', '-m']);
|
|
102
|
+
/**
|
|
103
|
+
* The unsafe options in `args` (`services.<id>.options`), by flag name, in
|
|
104
|
+
* order. Values of options are skipped, so `--health-cmd --privileged` is
|
|
105
|
+
* fine.
|
|
106
|
+
*/
|
|
107
|
+
function unsafeServiceOptions(args) {
|
|
108
|
+
const found = [];
|
|
109
|
+
for (let i = 0; i < args.length; i++) {
|
|
110
|
+
const arg = args[i];
|
|
111
|
+
if (arg.startsWith('--')) {
|
|
112
|
+
const [name, value] = arg.split(/=(.*)/s);
|
|
113
|
+
if (UNSAFE_LONG_OPTIONS.has(name))
|
|
114
|
+
found.push(name);
|
|
115
|
+
if (value === undefined && VALUE_OPTIONS.has(name))
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
else if (arg.startsWith('-') && arg.length > 1) {
|
|
119
|
+
const name = arg.slice(0, 2);
|
|
120
|
+
if (UNSAFE_SHORT_OPTIONS.has(name))
|
|
121
|
+
found.push(name);
|
|
122
|
+
if (arg.length === 2 && SHORT_VALUE_OPTIONS.has(name))
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
const VOLUME_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
129
|
+
function realpathOrSelf(p) {
|
|
130
|
+
try {
|
|
131
|
+
return fs.realpathSync(p);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return p;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Why a `services.<id>.volumes` entry is refused, if it is. Named and
|
|
139
|
+
* anonymous volumes are fine; a host bind mount must stay inside the
|
|
140
|
+
* workspace (after following symlinks) and away from its `.git`.
|
|
141
|
+
*/
|
|
142
|
+
function unsafeServiceVolumeReason(spec, workspace) {
|
|
143
|
+
const parts = spec.split(':');
|
|
144
|
+
if (parts.length < 2)
|
|
145
|
+
return undefined; // anonymous volume: `/data`
|
|
146
|
+
const source = parts[0];
|
|
147
|
+
if (VOLUME_NAME.test(source))
|
|
148
|
+
return undefined; // named volume
|
|
149
|
+
if (!path.isAbsolute(source)) {
|
|
150
|
+
return `Volume ${JSON.stringify(spec)} uses a relative or unsupported host path; use an absolute path inside the workspace or a named volume`;
|
|
151
|
+
}
|
|
152
|
+
const root = realpathOrSelf(workspace);
|
|
153
|
+
const real = realpathOrSelf(source);
|
|
154
|
+
const rel = path.relative(root, real);
|
|
155
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
156
|
+
return `Volume ${JSON.stringify(spec)} mounts a host path outside the workspace`;
|
|
157
|
+
}
|
|
158
|
+
const git = path.join(root, '.git');
|
|
159
|
+
if (real === git ||
|
|
160
|
+
real.startsWith(git + path.sep) ||
|
|
161
|
+
(rel === '' && fs.existsSync(git))) {
|
|
162
|
+
return `Volume ${JSON.stringify(spec)} would expose the repository's .git to the container`;
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=container-policy.js.map
|
|
@@ -41,6 +41,7 @@ const fs = __importStar(require("fs"));
|
|
|
41
41
|
const os = __importStar(require("os"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
43
|
const util_1 = require("util");
|
|
44
|
+
const container_policy_1 = require("./container-policy");
|
|
44
45
|
const execAsync = (0, util_1.promisify)(child_process_1.exec);
|
|
45
46
|
/** The CLI binary invoked for each runtime. `containerd` has no Docker-CLI-
|
|
46
47
|
* compatible binary of its own, so `nerdctl` is used as its front-end. */
|
|
@@ -98,6 +99,21 @@ function envTransport(env = {}) {
|
|
|
98
99
|
}
|
|
99
100
|
};
|
|
100
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* `-v` mounts that put existing `paths` under `workingDir` back read-only
|
|
104
|
+
* over the workspace mount, so a container can't rewrite them (a step
|
|
105
|
+
* writing `.git/hooks` would otherwise run code on the host later).
|
|
106
|
+
*/
|
|
107
|
+
function readOnlyMounts(workingDir, paths = []) {
|
|
108
|
+
const args = [];
|
|
109
|
+
for (const rel of paths) {
|
|
110
|
+
const host = path.join(workingDir, rel);
|
|
111
|
+
if (!fs.existsSync(host))
|
|
112
|
+
continue;
|
|
113
|
+
args.push('-v', `${host}:${path.posix.join(exports.CONTAINER_WORKDIR, ...rel.split(path.sep))}:ro`);
|
|
114
|
+
}
|
|
115
|
+
return args;
|
|
116
|
+
}
|
|
101
117
|
/** Probe timeout for `<bin> version` availability checks. */
|
|
102
118
|
const PROBE_TIMEOUT_MS = 3000;
|
|
103
119
|
class CliContainerRuntime {
|
|
@@ -123,6 +139,17 @@ class CliContainerRuntime {
|
|
|
123
139
|
return this.availableCache;
|
|
124
140
|
}
|
|
125
141
|
execute(options) {
|
|
142
|
+
const invalid = (0, container_policy_1.invalidImageReason)(options.image);
|
|
143
|
+
if (invalid) {
|
|
144
|
+
return Promise.resolve({
|
|
145
|
+
success: false,
|
|
146
|
+
stdout: '',
|
|
147
|
+
stderr: invalid,
|
|
148
|
+
exitCode: null,
|
|
149
|
+
timedOut: false,
|
|
150
|
+
durationMs: 0
|
|
151
|
+
});
|
|
152
|
+
}
|
|
126
153
|
const args = [
|
|
127
154
|
'run',
|
|
128
155
|
'--rm',
|
|
@@ -130,6 +157,7 @@ class CliContainerRuntime {
|
|
|
130
157
|
exports.CONTAINER_WORKDIR,
|
|
131
158
|
'-v',
|
|
132
159
|
`${options.workingDir}:${exports.CONTAINER_WORKDIR}`,
|
|
160
|
+
...readOnlyMounts(options.workingDir, options.readOnlyPaths),
|
|
133
161
|
'-v',
|
|
134
162
|
`${options.exchangeDir}:${exports.CONTAINER_EXCHANGE_DIR}`
|
|
135
163
|
];
|
|
@@ -154,9 +182,12 @@ class CliContainerRuntime {
|
|
|
154
182
|
return this.runCli(args, options.timeoutMs, env);
|
|
155
183
|
}
|
|
156
184
|
start(options) {
|
|
185
|
+
const invalid = (0, container_policy_1.invalidImageReason)(options.image);
|
|
186
|
+
if (invalid)
|
|
187
|
+
return Promise.resolve({ success: false, error: invalid });
|
|
157
188
|
const args = ['run', '-d'];
|
|
158
189
|
if (options.workingDir) {
|
|
159
|
-
args.push('-w', exports.CONTAINER_WORKDIR, '-v', `${options.workingDir}:${exports.CONTAINER_WORKDIR}
|
|
190
|
+
args.push('-w', exports.CONTAINER_WORKDIR, '-v', `${options.workingDir}:${exports.CONTAINER_WORKDIR}`, ...readOnlyMounts(options.workingDir, options.readOnlyPaths));
|
|
160
191
|
}
|
|
161
192
|
if (options.exchangeDir) {
|
|
162
193
|
args.push('-v', `${options.exchangeDir}:${exports.CONTAINER_EXCHANGE_DIR}`);
|
|
@@ -43,6 +43,7 @@ const expression_1 = require("./expression");
|
|
|
43
43
|
const container_runtime_1 = require("./container-runtime");
|
|
44
44
|
const runner_env_1 = require("./runner-env");
|
|
45
45
|
const job_runtime_1 = require("./job-runtime");
|
|
46
|
+
const job_container_1 = require("./job-container");
|
|
46
47
|
const node_action_1 = require("./node-action");
|
|
47
48
|
/**
|
|
48
49
|
* Execute a Docker-type action (`runs.using: docker`). Unlike `run:` steps,
|
|
@@ -114,6 +115,12 @@ async function executeDockerAction(action, actionPath, step, actionInputs, calle
|
|
|
114
115
|
// `runs.args` may reference ${{ inputs.* }} / ${{ secrets.* }}.
|
|
115
116
|
const actionCtx = (0, expression_1.createContext)({}, actionInputs, options.secrets);
|
|
116
117
|
const args = (action.runs.args || []).map((arg) => (0, expression_1.substituteExpressions)(arg, actionCtx));
|
|
118
|
+
// Args go on the engine client's command line, which other local users
|
|
119
|
+
// can read (`ps`); env values don't, so say so when a secret lands here.
|
|
120
|
+
const secretValues = Object.values(options.secrets ?? {}).filter((value) => value && value.length >= 3);
|
|
121
|
+
if (args.some((arg) => secretValues.some((value) => arg.includes(value)))) {
|
|
122
|
+
logger_1.logger.warn(`Docker action "${step.uses}": a secret is passed in its args, so it is on the container engine's command line, visible to other users of this machine. Pass secrets through env: instead.`);
|
|
123
|
+
}
|
|
117
124
|
logger_1.logger.info(`Executing Docker action: ${action.name}`);
|
|
118
125
|
const exchangeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'secureci-exchange-'));
|
|
119
126
|
const files = (0, runner_env_1.createStepFiles)(exchangeDir);
|
|
@@ -125,6 +132,7 @@ async function executeDockerAction(action, actionPath, step, actionInputs, calle
|
|
|
125
132
|
args,
|
|
126
133
|
entrypoint: action.runs.entrypoint,
|
|
127
134
|
workingDir: process.cwd(),
|
|
135
|
+
readOnlyPaths: (0, job_container_1.protectedWorkspacePaths)(options),
|
|
128
136
|
exchangeDir,
|
|
129
137
|
env: {
|
|
130
138
|
...(0, job_runtime_1.containerRunnerEnv)(runtime),
|
|
@@ -41,6 +41,7 @@ exports.runnerImage = runnerImage;
|
|
|
41
41
|
exports.stopJobContainer = stopJobContainer;
|
|
42
42
|
exports.startJobServices = startJobServices;
|
|
43
43
|
exports.stopJobServices = stopJobServices;
|
|
44
|
+
exports.protectedWorkspacePaths = protectedWorkspacePaths;
|
|
44
45
|
const fs = __importStar(require("fs"));
|
|
45
46
|
const path = __importStar(require("path"));
|
|
46
47
|
const crypto = __importStar(require("crypto"));
|
|
@@ -48,6 +49,7 @@ const logger_1 = require("../utils/logger");
|
|
|
48
49
|
const expression_1 = require("./expression");
|
|
49
50
|
const container_runtime_1 = require("./container-runtime");
|
|
50
51
|
const shell_1 = require("./shell");
|
|
52
|
+
const container_policy_1 = require("./container-policy");
|
|
51
53
|
/**
|
|
52
54
|
* Image for jobs whose `runs-on` has no image mapping. Runner-like (Node.js,
|
|
53
55
|
* git, curl and common tools), as used by nektos/act.
|
|
@@ -145,6 +147,7 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
|
|
|
145
147
|
const started = await containerRuntime.start({
|
|
146
148
|
image,
|
|
147
149
|
workingDir: process.cwd(),
|
|
150
|
+
readOnlyPaths: protectedWorkspacePaths(options),
|
|
148
151
|
exchangeDir,
|
|
149
152
|
env: containerEnv,
|
|
150
153
|
memoryLimit: options.containerMemoryLimit,
|
|
@@ -254,6 +257,22 @@ async function startJobServices(job, ctx, options, runtime) {
|
|
|
254
257
|
const contexts = {};
|
|
255
258
|
for (const [serviceId, spec] of specs) {
|
|
256
259
|
const service = typeof spec === 'string' ? { image: spec } : spec;
|
|
260
|
+
const extraArgs = service.options
|
|
261
|
+
? (0, shell_1.splitArgs)((0, expression_1.substituteExpressions)(service.options, ctx))
|
|
262
|
+
: [];
|
|
263
|
+
const volumes = (service.volumes ?? []).map((v) => (0, expression_1.substituteExpressions)(v, ctx));
|
|
264
|
+
const unsafe = (0, container_policy_1.unsafeServiceOptions)(extraArgs);
|
|
265
|
+
if (unsafe.length > 0) {
|
|
266
|
+
logger_1.logger.error(`services.${serviceId}: refusing unsafe options ${[...new Set(unsafe)].join(', ')}: they would give the container access to this machine.`);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
for (const volume of volumes) {
|
|
270
|
+
const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, process.cwd());
|
|
271
|
+
if (reason) {
|
|
272
|
+
logger_1.logger.error(`services.${serviceId}: ${reason}.`);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
257
276
|
if (service.credentials) {
|
|
258
277
|
logger_1.logger.warn(`services.${serviceId}: credentials are ignored; log in with \`docker login\` instead.`);
|
|
259
278
|
}
|
|
@@ -266,10 +285,8 @@ async function startJobServices(job, ctx, options, runtime) {
|
|
|
266
285
|
? { network: services.network, networkAlias: serviceId }
|
|
267
286
|
: {}),
|
|
268
287
|
ports: (service.ports ?? []).map((port) => (0, expression_1.substituteExpressions)(String(port), ctx)),
|
|
269
|
-
volumes
|
|
270
|
-
extraArgs
|
|
271
|
-
? (0, shell_1.splitArgs)((0, expression_1.substituteExpressions)(service.options, ctx))
|
|
272
|
-
: [],
|
|
288
|
+
volumes,
|
|
289
|
+
extraArgs,
|
|
273
290
|
env: (0, expression_1.substituteRecord)(service.env, ctx)
|
|
274
291
|
});
|
|
275
292
|
if (!started.success || !started.id) {
|
|
@@ -319,4 +336,12 @@ async function stopJobServices(runtime) {
|
|
|
319
336
|
await services.runtime.removeNetwork(services.network);
|
|
320
337
|
}
|
|
321
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* Workspace paths mounted read-only into containers: the working copy's
|
|
341
|
+
* `.git`, so a step can't plant hooks or config that run on the host. Not
|
|
342
|
+
* with --no-local-checkout, where the real actions/checkout manages it.
|
|
343
|
+
*/
|
|
344
|
+
function protectedWorkspacePaths(options) {
|
|
345
|
+
return options.localCheckout === false ? [] : ['.git'];
|
|
346
|
+
}
|
|
322
347
|
//# sourceMappingURL=job-container.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secureci/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "SecureCI: validate, check against pipeline standards, and run GitHub Actions workflows locally",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -68,11 +68,11 @@
|
|
|
68
68
|
"@types/node": "^20.10.6",
|
|
69
69
|
"@typescript-eslint/eslint-plugin": "^8.71.1",
|
|
70
70
|
"@typescript-eslint/parser": "^8.71.1",
|
|
71
|
-
"@vitest/coverage-v8": "^4.1.
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.12",
|
|
72
72
|
"eslint": "^8.56.0",
|
|
73
73
|
"postject": "1.0.0-alpha.6",
|
|
74
74
|
"typescript": "^5.3.3",
|
|
75
|
-
"vitest": "^4.1.
|
|
75
|
+
"vitest": "^4.1.12"
|
|
76
76
|
},
|
|
77
77
|
"volta": {
|
|
78
78
|
"node": "24.0.0"
|