@secureci/cli 1.0.0 → 1.1.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.
@@ -6,6 +6,7 @@ exports.substituteExpressions = substituteExpressions;
6
6
  exports.substituteRecord = substituteRecord;
7
7
  exports.withImplicitSuccess = withImplicitSuccess;
8
8
  exports.evaluateCondition = evaluateCondition;
9
+ exports.staticCondition = staticCondition;
9
10
  exports.ifEvaluationError = ifEvaluationError;
10
11
  exports.validateExpression = validateExpression;
11
12
  exports.expressionDataFlow = expressionDataFlow;
@@ -388,16 +389,46 @@ function callFunction(name, rawArgs, ctx) {
388
389
  }
389
390
  /** The `*` filter in `x.*` / `x[*]`, as opposed to a `'*'` key. */
390
391
  const STAR = Symbol('*');
392
+ /**
393
+ * A value static evaluation (`staticCondition`) doesn't know, with its
394
+ * truthiness when that is known anyway (`unknown || true` is truthy).
395
+ */
396
+ class Unknown {
397
+ constructor(truthy) {
398
+ this.truthy = truthy;
399
+ }
400
+ }
401
+ /** Contexts an expression may name; static evaluation knows only some. */
402
+ const NAMED_CONTEXTS = new Set([
403
+ 'env',
404
+ 'inputs',
405
+ 'secrets',
406
+ 'steps',
407
+ 'github',
408
+ 'matrix',
409
+ 'needs',
410
+ 'job',
411
+ 'runner',
412
+ 'vars',
413
+ 'strategy'
414
+ ]);
415
+ /** Truthiness of a (possibly Unknown) value; undefined when unknown. */
416
+ function truthOf(value) {
417
+ return value instanceof Unknown ? value.truthy : toBoolean(unwrap(value));
418
+ }
391
419
  class Parser {
392
420
  /**
393
421
  * With a scope, only check names and syntax; with `flow`, compute data
394
422
  * flow (Flow values) instead. Either way nothing is evaluated.
395
423
  */
396
- constructor(tokens, ctx, scope, flow = false) {
424
+ constructor(tokens, ctx, scope, flow = false,
425
+ /** Static evaluation: only these contexts are known (see Unknown). */
426
+ known) {
397
427
  this.tokens = tokens;
398
428
  this.ctx = ctx;
399
429
  this.scope = scope;
400
430
  this.flow = flow;
431
+ this.known = known;
401
432
  this.pos = 0;
402
433
  }
403
434
  peek() {
@@ -429,6 +460,16 @@ class Parser {
429
460
  while (this.isOp('||')) {
430
461
  this.next();
431
462
  const right = this.parseAnd();
463
+ if (this.known) {
464
+ const l = truthOf(left);
465
+ left =
466
+ l === true
467
+ ? left
468
+ : l === false
469
+ ? right
470
+ : new Unknown(truthOf(right) === true ? true : undefined);
471
+ continue;
472
+ }
432
473
  left = this.flow
433
474
  ? Flow.union(left, right)
434
475
  : toBoolean(unwrap(left))
@@ -442,6 +483,16 @@ class Parser {
442
483
  while (this.isOp('&&')) {
443
484
  this.next();
444
485
  const right = this.parseEquality();
486
+ if (this.known) {
487
+ const l = truthOf(left);
488
+ left =
489
+ l === false
490
+ ? left
491
+ : l === true
492
+ ? right
493
+ : new Unknown(truthOf(right) === false ? false : undefined);
494
+ continue;
495
+ }
445
496
  left = this.flow
446
497
  ? Flow.union(left, right)
447
498
  : toBoolean(unwrap(left))
@@ -455,6 +506,10 @@ class Parser {
455
506
  while (this.isOp('==', '!=')) {
456
507
  const op = this.next().value;
457
508
  const right = this.parseComparison();
509
+ if (left instanceof Unknown || right instanceof Unknown) {
510
+ left = new Unknown();
511
+ continue;
512
+ }
458
513
  const eq = looseEquals(unwrap(left), unwrap(right));
459
514
  left = op === '==' ? eq : !eq;
460
515
  }
@@ -465,6 +520,10 @@ class Parser {
465
520
  while (this.isOp('<', '<=', '>', '>=')) {
466
521
  const op = this.next().value;
467
522
  const right = this.parseUnary();
523
+ if (left instanceof Unknown || right instanceof Unknown) {
524
+ left = new Unknown();
525
+ continue;
526
+ }
468
527
  const c = compare(unwrap(left), unwrap(right));
469
528
  left =
470
529
  !Number.isNaN(c) &&
@@ -481,7 +540,11 @@ class Parser {
481
540
  parseUnary() {
482
541
  if (this.isOp('!')) {
483
542
  this.next();
484
- return !toBoolean(unwrap(this.parseUnary()));
543
+ const operand = this.parseUnary();
544
+ if (operand instanceof Unknown) {
545
+ return new Unknown(operand.truthy === undefined ? undefined : !operand.truthy);
546
+ }
547
+ return !toBoolean(unwrap(operand));
485
548
  }
486
549
  return this.parsePostfix(this.parsePrimary());
487
550
  }
@@ -509,7 +572,12 @@ class Parser {
509
572
  }
510
573
  else {
511
574
  const key = unwrap(this.parseOr());
512
- value = this.access(value, typeof key === 'number' || key instanceof Flow ? key : toText(key));
575
+ value =
576
+ key instanceof Unknown
577
+ ? new Unknown()
578
+ : this.access(value, typeof key === 'number' || key instanceof Flow
579
+ ? key
580
+ : toText(key));
513
581
  }
514
582
  this.expect('rbracket');
515
583
  }
@@ -526,6 +594,12 @@ class Parser {
526
594
  // `.*` and a computed key (`x[matrix.field]`) may be any key.
527
595
  return value.member(key === STAR || key instanceof Flow ? '*' : String(key).toLowerCase());
528
596
  }
597
+ if (this.known) {
598
+ if (value instanceof Unknown)
599
+ return new Unknown();
600
+ const result = key === STAR ? star(value) : member(value, key);
601
+ return result === undefined ? new Unknown() : result;
602
+ }
529
603
  if (key === STAR)
530
604
  return star(value);
531
605
  return member(value, key);
@@ -566,6 +640,8 @@ class Parser {
566
640
  }
567
641
  if (this.flow)
568
642
  return flowCall(tok.value, args);
643
+ if (this.known)
644
+ return this.staticCall(tok.value, args);
569
645
  return callFunction(tok.value, args, this.ctx);
570
646
  }
571
647
  if (this.scope) {
@@ -576,12 +652,34 @@ class Parser {
576
652
  }
577
653
  if (this.flow)
578
654
  return new Flow([[lower]]);
655
+ if (this.known) {
656
+ if (!NAMED_CONTEXTS.has(lower)) {
657
+ throw new Error(`Unrecognized named-value: '${tok.value}'`);
658
+ }
659
+ return lower in this.known ? this.known[lower] : new Unknown();
660
+ }
579
661
  return rootContext(lower, this.ctx);
580
662
  }
581
663
  default:
582
664
  throw new Error(`Unexpected token: ${tok.type}`);
583
665
  }
584
666
  }
667
+ /**
668
+ * A function call in static evaluation: `success()` and `always()` hold
669
+ * for a step that runs at all; other status functions, `hashFiles` and
670
+ * calls with an unknown argument are unknown.
671
+ */
672
+ staticCall(name, args) {
673
+ checkCall(name, args.length);
674
+ const lower = name.toLowerCase();
675
+ if (lower === 'success' || lower === 'always')
676
+ return true;
677
+ if (['failure', 'cancelled', 'hashfiles'].includes(lower) ||
678
+ args.some((arg) => arg instanceof Unknown)) {
679
+ return new Unknown();
680
+ }
681
+ return callFunction(name, args, this.ctx);
682
+ }
585
683
  }
586
684
  function evaluate(expr, ctx) {
587
685
  return new Parser(tokenize(expr), ctx).parse();
@@ -670,6 +768,30 @@ function evaluateCondition(expr, ctx) {
670
768
  // it fails the step or job), never a condition that passes.
671
769
  return toBoolean(evaluate(trimmed, ctx));
672
770
  }
771
+ /**
772
+ * Whether an `if:` holds when only the `known` contexts are known, e.g.
773
+ * `{ github: { event_name: 'push' } }`: true or false when that settles
774
+ * it, undefined when it depends on anything else (or can't be parsed).
775
+ * Used to check a contract without running the workflow.
776
+ */
777
+ function staticCondition(expr, known) {
778
+ if (expr === undefined || typeof expr === 'boolean')
779
+ return expr ?? true;
780
+ let trimmed = expr.trim();
781
+ const wrapped = trimmed.match(/^\$\{\{([\s\S]*)\}\}$/);
782
+ if (wrapped)
783
+ trimmed = wrapped[1].trim();
784
+ if (trimmed === '')
785
+ return true;
786
+ try {
787
+ return truthOf(new Parser(tokenize(trimmed), createContext(), undefined, false, {
788
+ ...known
789
+ }).parse());
790
+ }
791
+ catch {
792
+ return undefined;
793
+ }
794
+ }
673
795
  /** The error for an `if:` that couldn't be evaluated, worded like GitHub's. */
674
796
  function ifEvaluationError(target, error) {
675
797
  return `Error when evaluating 'if' for ${target}. ${error instanceof Error ? error.message : String(error)}`;
@@ -50,6 +50,13 @@ const expression_1 = require("./expression");
50
50
  const container_runtime_1 = require("./container-runtime");
51
51
  const shell_1 = require("./shell");
52
52
  const container_policy_1 = require("./container-policy");
53
+ /**
54
+ * `jobs.<id>.container.options` the CLI sets on the job container itself:
55
+ * it idles with its own entrypoint and steps run in the mounted workspace.
56
+ */
57
+ const JOB_CONTAINER_RESERVED = new Set(['--entrypoint', '--workdir', '-w']);
58
+ /** Also reserved when `--container-memory-limit` caps the job container. */
59
+ const MEMORY_OPTIONS = ['--memory', '-m', '--memory-swap'];
53
60
  /**
54
61
  * Image for jobs whose `runs-on` has no image mapping. Runner-like (Node.js,
55
62
  * git, curl and common tools), as used by nektos/act.
@@ -123,14 +130,6 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
123
130
  logger_1.logger.error(noRuntimeMessage(preference));
124
131
  return false;
125
132
  }
126
- if (spec &&
127
- 'image' in spec &&
128
- (('options' in spec && spec.options) ||
129
- ('volumes' in spec && spec.volumes) ||
130
- ('ports' in spec && spec.ports) ||
131
- ('credentials' in spec && spec.credentials))) {
132
- logger_1.logger.warn('jobs.<id>.container: only image and env are supported; ignoring options/volumes/ports/credentials.');
133
- }
134
133
  // `container: ${{ inputs.image || null }}` that comes out empty means no
135
134
  // container image, as on GitHub: use the image for runs-on.
136
135
  const specImage = spec?.image
@@ -140,19 +139,25 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
140
139
  options.containerImage ||
141
140
  runnerImage(job?.['runs-on'], ctx, options);
142
141
  const containerEnv = (0, expression_1.substituteRecord)(specImage && spec && 'env' in spec ? spec.env : undefined, ctx);
142
+ const extras = specImage && spec
143
+ ? jobContainerExtras(spec, ctx, options, runtime.workspace)
144
+ : {};
145
+ if (!extras)
146
+ return false;
143
147
  const exchangeDir = fs.mkdtempSync(path.join(runDir, 'container-'));
144
148
  fs.chmodSync(exchangeDir, 0o777);
145
149
  fs.copyFileSync(runtime.eventPath, path.join(exchangeDir, 'event.json'));
146
150
  logger_1.logger.info(`Starting job container: ${image} (${containerRuntime.name})`);
147
151
  const started = await containerRuntime.start({
148
152
  image,
149
- workingDir: process.cwd(),
153
+ workingDir: runtime.workspace,
150
154
  readOnlyPaths: protectedWorkspacePaths(options),
151
155
  exchangeDir,
152
156
  env: containerEnv,
153
157
  memoryLimit: options.containerMemoryLimit,
154
158
  networkDisabled: options.networkDisabled === true,
155
- network: runtime.services?.network
159
+ network: runtime.services?.network,
160
+ ...extras
156
161
  });
157
162
  if (!started.success || !started.id) {
158
163
  logger_1.logger.error(`Failed to start job container (${image}): ${started.error}`);
@@ -173,6 +178,47 @@ async function startJobContainer(job, ctx, options, runtime, runDir) {
173
178
  activeContainers.add(runtime.container);
174
179
  return true;
175
180
  }
181
+ /**
182
+ * The `options`, `ports` and `volumes` of `jobs.<id>.container`, checked
183
+ * with the same policy as service containers: options and host mounts that
184
+ * would reach this machine are refused (undefined: the job fails), options
185
+ * the CLI sets itself are dropped with a warning.
186
+ */
187
+ function jobContainerExtras(spec, ctx, options, workspace) {
188
+ const label = `jobs.${ctx.github.job ?? '<id>'}.container`;
189
+ const args = spec.options
190
+ ? (0, shell_1.splitArgs)((0, expression_1.substituteExpressions)(spec.options, ctx))
191
+ : [];
192
+ const unsafe = (0, container_policy_1.unsafeServiceOptions)(args);
193
+ if (unsafe.length > 0) {
194
+ logger_1.logger.error(`${label}: refusing unsafe options ${[...new Set(unsafe)].join(', ')}: they would give the container access to this machine.`);
195
+ return undefined;
196
+ }
197
+ const reserved = new Set([
198
+ ...JOB_CONTAINER_RESERVED,
199
+ ...(options.containerMemoryLimit ? MEMORY_OPTIONS : [])
200
+ ]);
201
+ const { kept, dropped } = (0, container_policy_1.splitReservedOptions)(args, reserved);
202
+ if (dropped.length > 0) {
203
+ logger_1.logger.warn(`${label}: ignoring options the CLI sets itself: ${[...new Set(dropped)].join(', ')}.`);
204
+ }
205
+ const volumes = (spec.volumes ?? []).map((v) => (0, expression_1.substituteExpressions)(v, ctx));
206
+ for (const volume of volumes) {
207
+ const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, workspace);
208
+ if (reason) {
209
+ logger_1.logger.error(`${label}: ${reason}.`);
210
+ return undefined;
211
+ }
212
+ }
213
+ if (spec.credentials) {
214
+ logger_1.logger.warn(`${label}: credentials are ignored; log in with \`docker login\` instead.`);
215
+ }
216
+ return {
217
+ extraArgs: kept,
218
+ ports: (spec.ports ?? []).map((port) => (0, expression_1.substituteExpressions)(String(port), ctx)),
219
+ volumes
220
+ };
221
+ }
176
222
  /**
177
223
  * The image for a job's `runs-on`: the first label with a mapping in
178
224
  * `runner-images` (or the built-in one), else the default image, with a
@@ -267,7 +313,7 @@ async function startJobServices(job, ctx, options, runtime) {
267
313
  return false;
268
314
  }
269
315
  for (const volume of volumes) {
270
- const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, process.cwd());
316
+ const reason = (0, container_policy_1.unsafeServiceVolumeReason)(volume, runtime.workspace);
271
317
  if (reason) {
272
318
  logger_1.logger.error(`services.${serviceId}: ${reason}.`);
273
319
  return false;
@@ -107,7 +107,7 @@ function runnerContext(runtime) {
107
107
  return {
108
108
  name: 'secureci',
109
109
  os: runtime.runnerEnv.RUNNER_OS,
110
- arch: process.arch === 'arm64' ? 'ARM64' : 'X64',
110
+ arch: runtime.runnerEnv.RUNNER_ARCH,
111
111
  temp: runtime.runnerEnv.RUNNER_TEMP,
112
112
  tool_cache: runtime.runnerEnv.RUNNER_TOOL_CACHE
113
113
  };
@@ -126,12 +126,13 @@ function hostHasBash() {
126
126
  }
127
127
  return hostBash;
128
128
  }
129
- function createJobRuntime(github, runDir, defaults = {}) {
129
+ function createJobRuntime(github, runDir, defaults = {}, workspace = process.cwd()) {
130
130
  const homeDir = process.env.HOME || process.env.USERPROFILE || os.tmpdir();
131
131
  const eventPath = path.join(runDir, 'event.json');
132
132
  return {
133
+ workspace,
133
134
  runnerEnv: (0, runner_env_1.buildRunnerEnv)(github, {
134
- workspace: process.cwd(),
135
+ workspace,
135
136
  eventPath,
136
137
  // Per job, so concurrent jobs don't share $RUNNER_TEMP.
137
138
  tempDir: fs.mkdtempSync(path.join(runDir, 'tmp-')),
@@ -144,7 +144,7 @@ async function executeNodeAction(action, actionPath, step, actionInputs, callerC
144
144
  }
145
145
  try {
146
146
  const { stdout, stderr } = await execFileAsync(node.path, [path.join(actionDir, script)], {
147
- cwd: process.cwd(),
147
+ cwd: runtime.workspace,
148
148
  maxBuffer: 64 * 1024 * 1024,
149
149
  timeout: limit.ms,
150
150
  killSignal: 'SIGKILL',
@@ -38,6 +38,7 @@ exports.createStepFiles = createStepFiles;
38
38
  exports.readStepFiles = readStepFiles;
39
39
  exports.cleanupStepFiles = cleanupStepFiles;
40
40
  exports.processWorkflowCommands = processWorkflowCommands;
41
+ exports.runnerArch = runnerArch;
41
42
  exports.buildRunnerEnv = buildRunnerEnv;
42
43
  exports.prependPath = prependPath;
43
44
  exports.hostBaseEnv = hostBaseEnv;
@@ -248,6 +249,24 @@ function runnerOs() {
248
249
  return 'Linux';
249
250
  }
250
251
  }
252
+ /**
253
+ * `RUNNER_ARCH` / `runner.arch` for a Node.js architecture, using GitHub's
254
+ * values (X86, X64, ARM, ARM64). Jobs run natively (a job container has the
255
+ * host's architecture), so the host's is the runner's. Anything else reports
256
+ * X64, as before this was mapped.
257
+ */
258
+ function runnerArch(arch = process.arch) {
259
+ switch (arch) {
260
+ case 'arm64':
261
+ return 'ARM64';
262
+ case 'arm':
263
+ return 'ARM';
264
+ case 'ia32':
265
+ return 'X86';
266
+ default:
267
+ return 'X64';
268
+ }
269
+ }
251
270
  /**
252
271
  * The default environment variables GitHub's runner exports to every step,
253
272
  * derived from the (local, best-effort) `github` context.
@@ -274,6 +293,7 @@ function buildRunnerEnv(github, options) {
274
293
  GITHUB_WORKSPACE: options.workspace,
275
294
  GITHUB_EVENT_PATH: options.eventPath,
276
295
  RUNNER_OS: runnerOs(),
296
+ RUNNER_ARCH: runnerArch(),
277
297
  RUNNER_TEMP: options.tempDir,
278
298
  RUNNER_TOOL_CACHE: options.toolCache
279
299
  };
@@ -143,8 +143,8 @@ async function executeStep(step, options, runtime, ctx) {
143
143
  timeout: limit.ms,
144
144
  killSignal: 'SIGKILL',
145
145
  cwd: workingDirectory
146
- ? path.resolve(process.cwd(), workingDirectory)
147
- : process.cwd(),
146
+ ? path.resolve(runtime.workspace, workingDirectory)
147
+ : runtime.workspace,
148
148
  env: {
149
149
  ...(0, runner_env_1.hostBaseEnv)(),
150
150
  ...runtime.runnerEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.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": {