@secureci/cli 0.7.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.
@@ -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) => (body += 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
- const next = new URL(location, url);
278
- const nextHeaders = { ...headers };
279
- if (next.host !== new URL(url).host) {
280
- delete nextHeaders.Authorization;
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.toString(), dest, timeoutMs, nextHeaders, redirectsLeft - 1).then(resolve, reject);
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) => (fs.existsSync(logical) ? logical : null),
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 toHost = (logical) => path.join(access.hostWorkspace, ...p.relative(access.workspace, logical).split('/'));
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 host = toHost(logical);
87
- return fs.existsSync(host) ? host : null;
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 host = toHost(logical);
95
- fs.mkdirSync(host, { recursive: true });
96
- fill(host);
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
- const stat = fs.statSync(hostRoot);
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.statSync(host).isFile();
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)) {
@@ -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('..') || path.isAbsolute(relative)) {
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: (service.volumes ?? []).map((v) => (0, expression_1.substituteExpressions)(v, ctx)),
270
- extraArgs: service.options
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.7.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.11",
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.11"
75
+ "vitest": "^4.1.12"
76
76
  },
77
77
  "volta": {
78
78
  "node": "24.0.0"