@secureci/cli 0.7.0 → 0.9.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/cli.js +0 -2
- package/dist/parser/pipeline-contract-parser.js +1 -1
- package/dist/parser/workflow-parser.js +6 -2
- package/dist/runner/action-fetcher.js +43 -6
- package/dist/runner/builtin-actions.js +99 -10
- 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/github-context.js +1 -1
- package/dist/runner/job-container.js +29 -4
- package/dist/runner/node-action.js +1 -1
- package/package.json +10 -8
package/dist/cli.js
CHANGED
|
@@ -455,7 +455,6 @@ async function runInventory(files, options) {
|
|
|
455
455
|
}
|
|
456
456
|
// --dry-run: print the resolved inventory as JSON, touching nothing.
|
|
457
457
|
if (options.dryRun) {
|
|
458
|
-
// eslint-disable-next-line no-console
|
|
459
458
|
console.log(JSON.stringify({
|
|
460
459
|
actions,
|
|
461
460
|
advisories: advisoryReport.findings,
|
|
@@ -788,7 +787,6 @@ async function validateFiles(files, contractFile, recurse, format, offline) {
|
|
|
788
787
|
say(() => logger_1.logger.info(line.text));
|
|
789
788
|
}
|
|
790
789
|
if (quiet) {
|
|
791
|
-
// eslint-disable-next-line no-console
|
|
792
790
|
console.log((0, report_1.formatReport)(entries, format, { contract }));
|
|
793
791
|
if (!allValid)
|
|
794
792
|
process.exit(1);
|
|
@@ -66,7 +66,7 @@ function parsePipelineContract(filePath) {
|
|
|
66
66
|
parsed = YAML.parse(content);
|
|
67
67
|
}
|
|
68
68
|
catch (error) {
|
|
69
|
-
throw new Error(`Failed to parse contract file: ${error instanceof Error ? error.message : 'Unknown error'}
|
|
69
|
+
throw new Error(`Failed to parse contract file: ${error instanceof Error ? error.message : 'Unknown error'}`, { cause: error });
|
|
70
70
|
}
|
|
71
71
|
if (parsed === null || parsed === undefined) {
|
|
72
72
|
return {};
|
|
@@ -62,7 +62,9 @@ function parseWorkflow(filePath) {
|
|
|
62
62
|
}
|
|
63
63
|
catch (error) {
|
|
64
64
|
if (error instanceof Error) {
|
|
65
|
-
throw new Error(`Failed to parse workflow: ${error.message}
|
|
65
|
+
throw new Error(`Failed to parse workflow: ${error.message}`, {
|
|
66
|
+
cause: error
|
|
67
|
+
});
|
|
66
68
|
}
|
|
67
69
|
throw error;
|
|
68
70
|
}
|
|
@@ -94,7 +96,9 @@ function parseAction(filePath) {
|
|
|
94
96
|
}
|
|
95
97
|
catch (error) {
|
|
96
98
|
if (error instanceof Error) {
|
|
97
|
-
throw new Error(`Failed to parse action: ${error.message}
|
|
99
|
+
throw new Error(`Failed to parse action: ${error.message}`, {
|
|
100
|
+
cause: error
|
|
101
|
+
});
|
|
98
102
|
}
|
|
99
103
|
throw error;
|
|
100
104
|
}
|
|
@@ -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)) {
|
|
@@ -363,7 +452,7 @@ function findCache(scope, version, key, restoreKeys) {
|
|
|
363
452
|
if (fs.existsSync(path.join(exact, 'meta.json'))) {
|
|
364
453
|
return { dir: exact, key, created: 0 };
|
|
365
454
|
}
|
|
366
|
-
let entries
|
|
455
|
+
let entries;
|
|
367
456
|
try {
|
|
368
457
|
entries = fs
|
|
369
458
|
.readdirSync(scope)
|
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),
|
|
@@ -225,7 +225,7 @@ function loadEvent(options, inputs, on) {
|
|
|
225
225
|
payload = JSON.parse(fs.readFileSync(options.eventPath, 'utf-8'));
|
|
226
226
|
}
|
|
227
227
|
catch (error) {
|
|
228
|
-
throw new Error(`Invalid event payload ${options.eventPath}: ${error instanceof Error ? error.message : String(error)}
|
|
228
|
+
throw new Error(`Invalid event payload ${options.eventPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
229
229
|
}
|
|
230
230
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
231
231
|
throw new Error(`Invalid event payload ${options.eventPath}: expected a JSON object`);
|
|
@@ -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
|
|
@@ -280,7 +280,7 @@ function runPreIf(action, step, ctx) {
|
|
|
280
280
|
return (0, expression_1.evaluateCondition)(action.runs['pre-if'] ?? 'always()', ctx);
|
|
281
281
|
}
|
|
282
282
|
catch (error) {
|
|
283
|
-
throw new Error((0, expression_1.ifEvaluationError)(`step 'Pre ${step.name || step.uses}'`, error));
|
|
283
|
+
throw new Error((0, expression_1.ifEvaluationError)(`step 'Pre ${step.name || step.uses}'`, error), { cause: error });
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
//# sourceMappingURL=node-action.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secureci/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"test:watch": "vitest",
|
|
20
20
|
"test:coverage": "vitest run --coverage",
|
|
21
21
|
"dev": "tsc --watch",
|
|
22
|
-
"lint": "eslint src
|
|
22
|
+
"lint": "eslint src",
|
|
23
23
|
"format": "prettier --write .",
|
|
24
24
|
"prepare": "npm run build"
|
|
25
25
|
},
|
|
@@ -63,18 +63,20 @@
|
|
|
63
63
|
"yaml": "^2.3.4"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@
|
|
66
|
+
"@eslint/js": "^10.0.1",
|
|
67
67
|
"@jabraf/prettier": "^3.0.1",
|
|
68
68
|
"@types/node": "^20.10.6",
|
|
69
|
-
"@
|
|
70
|
-
"
|
|
71
|
-
"@vitest/coverage-v8": "^4.1.11",
|
|
72
|
-
"eslint": "^8.56.0",
|
|
69
|
+
"@vitest/coverage-v8": "^4.1.12",
|
|
70
|
+
"eslint": "^10.14.0",
|
|
73
71
|
"postject": "1.0.0-alpha.6",
|
|
74
72
|
"typescript": "^5.3.3",
|
|
75
|
-
"
|
|
73
|
+
"typescript-eslint": "^8.72.1",
|
|
74
|
+
"vitest": "^4.1.12"
|
|
76
75
|
},
|
|
77
76
|
"volta": {
|
|
78
77
|
"node": "24.0.0"
|
|
78
|
+
},
|
|
79
|
+
"overrides": {
|
|
80
|
+
"minimatch": "^11.0.4"
|
|
79
81
|
}
|
|
80
82
|
}
|