@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 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, matrix builds, reusable
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) and over-broad
24
- `GITHUB_TOKEN` permissions, and **check them against a pipeline contract**
25
- (required/forbidden actions, ordering, minimum versions, SHA-pinning and
26
- permissions rules).
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, and resolves tags to commit SHAs. |
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
- result.errors.forEach((error) => logger_1.logger.warn(` [${error.severity.toUpperCase()}] ${error.path}: ${error.message}`));
801
- }
802
- else {
800
+ else
803
801
  logger_1.logger.error(`Validation failed: ${file}`);
804
- result.errors.forEach((error) => logger_1.logger.error(` [${error.severity.toUpperCase()}] ${error.path}: ${error.message}`));
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: `jobs.${jobId}.steps[${checkoutIdx}]`,
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
- : `; check out the base ref instead, or move the build to an unprivileged pull_request workflow`),
99
- severity: gated ? 'warning' : 'error',
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
  }
@@ -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
@@ -28,7 +28,8 @@ function jsonForFile(entry) {
28
28
  errors: entry.result.errors.map((e) => ({
29
29
  path: e.path,
30
30
  message: e.message,
31
- severity: e.severity
31
+ severity: e.severity,
32
+ rule: ruleIdFor(e)
32
33
  }))
33
34
  };
34
35
  }
@@ -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.6.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"