Skip to content

fix: validate target input before YAML/CLI interpolation#34

Merged
danielmeppiel merged 2 commits into
mainfrom
harden/target-input-validation
May 7, 2026
Merged

fix: validate target input before YAML/CLI interpolation#34
danielmeppiel merged 2 commits into
mainfrom
harden/target-input-validation

Conversation

@danielmeppiel

Copy link
Copy Markdown
Collaborator

Why

Follow-up to #33. The target action input flowed verbatim into two unsafe surfaces:

  1. The generated apm.yml scalar (isolated mode) — target: ${value}\n
  2. The apm pack --target <value> CLI call

A value containing \n, \r, #, :, quotes, or stray whitespace could:

  • break YAML parsing of the generated manifest,
  • inject additional top-level YAML keys into the manifest (e.g. copilot\nname: hijacked),
  • smuggle extra CLI flags into the pack invocation.

No template engine, no escaping, no allowlist — raw interpolation. Worth fixing even though the input is set by trusted workflow authors, because action inputs in workflow_call / reusable workflow chains can come from less-trusted callers, and defence-in-depth on the YAML boundary is cheap.

What

Add a parseTargetInput(raw) helper applied at both call sites:

  • trims and returns undefined for empty input (preserves current behaviour),
  • splits on , to support APM's CSV multi-target form,
  • requires every token to match ^[a-z][a-z0-9-]{0,31}$ — the shape of every shipped APM harness name (agent-skills, claude, codex, copilot, cursor, gemini, opencode, windsurf) and any plausible future addition,
  • throws a descriptive error on the first invalid token so the action fails fast before writing a malformed manifest or invoking pack.

Why a regex allowlist over a hardcoded list of names: APM ships new harnesses periodically. The pattern is tight enough to block every injection vector (no :, no \n/\r, no #, no whitespace, no quotes, no ..) while not pinning the action to a specific APM version's harness inventory.

Tests

10 new unit tests in runner.test.ts:

Case Input
newline injection "copilot\ninjected: true"
carriage return "copilot\rfoo"
comment char "copilot # nope"
colon adds key "copilot: extra"
quoting '"copilot"'
leading dash "-copilot"
empty token in CSV "copilot,,claude"
uppercase "Copilot"
whitespace token "copilot, "
valid CSV with whitespace " copilot , claude " → normalises to "copilot,claude"

All assert setFailed is called with Invalid 'target' input and that no apm.yml is written for rejected inputs. The valid CSV case asserts the normalised value lands in the manifest.

109/109 unit tests pass locally.

Compat

  • No public input shape change. Callers passing single names (copilot) or CSV (copilot,claude) keep working.
  • Whitespace around CSV separators is now tolerated and normalised away (was previously passed through, which APM may or may not have accepted depending on parser leniency).
  • Invalid input now fails fast with a clear error instead of producing a corrupt manifest. This is a strict improvement for any caller that was already broken silently.

The `target` action input flowed verbatim into the generated apm.yml
(isolated mode) and into `apm pack --target <value>`. A value
containing newlines, `#`, `:`, quotes, or stray whitespace could
break YAML parsing, inject extra top-level keys into the manifest,
or smuggle CLI flags into the pack invocation.

Add a `parseTargetInput` helper applied at both call sites that:

- trims and rejects empty input (returns undefined as before),
- splits on `,` to support APM's CSV multi-target form,
- requires every token to match `^[a-z][a-z0-9-]{0,31}$` -- the
  shape of every shipped APM harness name (agent-skills, claude,
  codex, copilot, cursor, gemini, opencode, windsurf) and any
  plausible future addition,
- throws a descriptive error on the first invalid token so the
  action fails fast before writing a malformed manifest.

10 new unit tests cover the rejection matrix (newline, CR, `#`,
`:`, quoting, leading dash, empty CSV token, uppercase, stray
whitespace) and the normalisation of well-formed CSV input. All
109 existing + new tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 7, 2026 10:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the target action input by validating and normalizing it before it’s interpolated into the generated apm.yml (isolated mode) and before it’s passed to apm pack --target, preventing YAML breakage/injection and rejecting malformed values early.

Changes:

  • Add parseTargetInput(raw) to trim, normalize CSV targets, and enforce a strict allowlist regex.
  • Apply parseTargetInput at both target call sites (isolated manifest generation and pack invocation).
  • Add unit tests covering unsafe target inputs and valid CSV normalization; regenerate dist/index.js.
Show a summary per file
File Description
src/runner.ts Introduces parseTargetInput and uses it to validate/normalize target before manifest/pack usage.
src/tests/runner.test.ts Adds tests for rejecting unsafe target values and accepting normalized CSV targets in isolated mode.
dist/index.js Rebuilds compiled output to include the new validation logic.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/3 changed files
  • Comments generated: 2

Comment thread src/runner.ts
Comment on lines 349 to 352
// 8. Pack mode: produce bundle after install
if (packInput) {
const target = core.getInput('target').trim() || undefined;
const target = parseTargetInput(core.getInput('target'));
const archive = core.getInput('archive') !== 'false';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — adopted in 713355d. Hoisted parseTargetInput to the top of run() (alongside packInput/isolated parsing), so an invalid target now fails before install/audit/compile run. Both downstream call sites consume the same validatedTarget constant.

Comment on lines +239 to +243
it.each([
['newline injection', 'copilot\ninjected: true'],
['carriage return', 'copilot\rfoo'],
['comment char', 'copilot # nope'],
['colon adds key', 'copilot: extra'],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 713355d. Added a pack-mode test (pack: true, isolated: false, target: "copilot --evil-flag") that asserts setFailed fires, mockRunPackStep is never called, and mockEnsureApmInstalled is never reached either — locking in both the CLI-side guard and the early-fail guarantee.

Address Copilot review on #34:

- Move `parseTargetInput` to the top of `run()` (alongside packInput
  and isolated parsing), so an invalid `target` fails before any
  install/audit/compile/script side-effects. Both call sites now
  consume the validated `validatedTarget` constant rather than
  re-parsing core.getInput('target').
- Add regression test asserting that in pack mode (`pack: true,
  isolated: false`) an unsafe target rejects up front: setFailed
  fires, runPackStep is never called, and ensureApmInstalled is
  never even reached. This locks in the CLI-side guard against
  flag smuggling via `apm pack --target`.

110/110 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel merged commit a01486d into main May 7, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants