ci: Clean up Template Injection surface in Actions (#29354)

This commit is contained in:
Matsu 2026-04-28 10:44:44 +03:00 committed by GitHub
parent ffef9c9c48
commit 4cf26bb70b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 111 additions and 46 deletions

View File

@ -59,8 +59,10 @@ runs:
- name: Install Dependencies
if: ${{ inputs.install-command != '' }}
env:
INSTALL_COMMAND: ${{ inputs.install-command }}
run: |
${{ inputs.install-command }}
$INSTALL_COMMAND
shell: bash
- name: Disable safe-chain
@ -81,8 +83,10 @@ runs:
- name: Build Project
if: ${{ inputs.build-command != '' }}
env:
BUILD_COMMAND: ${{ inputs.build-command }}
run: |
${{ inputs.build-command }} --summarize
$BUILD_COMMAND --summarize
node .github/scripts/send-build-stats.mjs || true
node .github/scripts/send-docker-stats.mjs || true
shell: bash

View File

@ -2,16 +2,18 @@
/**
* Retry a shell command with configurable attempts and delay.
*
* Usage: node retry.mjs [--attempts N] [--delay N] '<command>'
* Usage (safe): node retry.mjs [--attempts N] [--delay N] -- <cmd> [args...]
* Usage (legacy): node retry.mjs [--attempts N] [--delay N] '<shell command>'
*
* Options:
* --attempts N Maximum number of attempts (default: 4)
* --delay N Seconds to wait between retries (default: 15)
*
* The command is executed via shell, so pipes and env-var expansion work.
* The -- form passes args directly to the process (no shell, safe for untrusted input).
* The legacy form executes via shell, so pipes and env-var expansion work but injection is possible.
* Exits 0 on first success, 1 if all attempts fail.
*/
import { execSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';
const args = process.argv.slice(2);
@ -29,23 +31,40 @@ function getFlag(name, defaultValue) {
const attempts = getFlag('attempts', 4);
const delay = getFlag('delay', 15);
// Command is the last positional arg (skip flags and their values)
const command = args
.filter((a, i) => {
if (a.startsWith('--')) return false;
if (i > 0 && args[i - 1].startsWith('--')) return false;
return true;
})
.pop();
// Preferred form: -- cmd arg1 arg2 ... (no shell, safe for untrusted input)
// Legacy form: '<shell command string>' (uses shell; kept for backwards compat)
const separatorIndex = args.indexOf('--');
let command;
let commandArgs = [];
const isSafeRetry = separatorIndex !== -1;
if (isSafeRetry) {
[command, ...commandArgs] = args.slice(separatorIndex + 1);
} else {
command = args
.filter((a, i) => {
if (a.startsWith('--')) return false;
if (i > 0 && args[i - 1].startsWith('--')) return false;
return true;
})
.pop();
}
if (!command) {
console.error("Usage: node retry.mjs [--attempts N] [--delay N] '<command>'");
console.error('Usage: node retry.mjs [--attempts N] [--delay N] -- <cmd> [args...]');
process.exit(1);
}
for (let i = 1; i <= attempts; i++) {
try {
execSync(command, { shell: true, stdio: 'inherit' });
if (isSafeRetry) {
const result = spawnSync(command, commandArgs, { stdio: 'inherit' });
if (result.status !== 0) throw new Error(`Exit code ${result.status}`);
} else {
execSync(command, { shell: true, stdio: 'inherit' });
}
process.exit(0);
} catch {
if (i < attempts) {

View File

@ -58,14 +58,18 @@ jobs:
- name: Determine build context
id: context
env:
N8N_VERSION: ${{ inputs.n8n_version }}
RELEASE_TYPE: ${{ inputs.release_type }}
PUSH_ENABLED: ${{ inputs.push_enabled }}
run: |
node .github/scripts/docker/docker-config.mjs \
--event "${{ github.event_name }}" \
--pr "${{ github.event.pull_request.number }}" \
--branch "${{ github.ref_name }}" \
--version "${{ inputs.n8n_version }}" \
--release-type "${{ inputs.release_type }}" \
--push-enabled "${{ inputs.push_enabled }}"
--version "$N8N_VERSION" \
--release-type "$RELEASE_TYPE" \
--push-enabled "$PUSH_ENABLED"
build-and-push-docker:
name: Build App, then Build and Push Docker Image (${{ matrix.platform }})

View File

@ -64,9 +64,10 @@ jobs:
- name: Attach SBOM and VEX files to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG_REF: ${{ inputs.release_tag_ref }}
run: |
# Upload SBOM and VEX files to the existing release
gh release upload "${{ inputs.release_tag_ref }}" \
gh release upload "$RELEASE_TAG_REF" \
sbom-source.cdx.json \
security/vex.openvex.json \
--clobber

View File

@ -40,6 +40,9 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
- name: Sync master from public
env:
EVENT_NAME: ${{ github.event_name }}
FORCE: ${{ inputs.force }}
run: |
git fetch https://github.com/n8n-io/n8n.git master:public-master
@ -47,10 +50,10 @@ jobs:
AHEAD_COUNT=$(git rev-list public-master..HEAD --pretty=oneline --grep="chore: Bundle" --invert-grep --count)
if [ "$AHEAD_COUNT" -gt 0 ]; then
if [ "${{ github.event_name }}" = "schedule" ]; then
if [ "$EVENT_NAME" = "schedule" ]; then
echo "Private is $AHEAD_COUNT commit(s) ahead of public, skipping scheduled sync"
exit 0
elif [ "${{ inputs.force }}" != "true" ]; then
elif [ "$FORCE" != "true" ]; then
echo "Private is $AHEAD_COUNT commit(s) ahead of public, skipping (force not enabled)"
exit 0
else
@ -62,6 +65,9 @@ jobs:
git push origin master --force-with-lease
- name: Sync 1.x from public
env:
EVENT_NAME: ${{ github.event_name }}
FORCE: ${{ inputs.force }}
run: |
git fetch https://github.com/n8n-io/n8n.git 1.x:public-1.x
git checkout 1.x
@ -70,10 +76,10 @@ jobs:
AHEAD_COUNT=$(git rev-list public-1.x..HEAD --pretty=oneline --grep="chore: Bundle" --invert-grep --count)
if [ "$AHEAD_COUNT" -gt 0 ]; then
if [ "${{ github.event_name }}" = "schedule" ]; then
if [ "$EVENT_NAME" = "schedule" ]; then
echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, skipping scheduled sync"
exit 0
elif [ "${{ inputs.force }}" != "true" ]; then
elif [ "$FORCE" != "true" ]; then
echo "Private 1.x is $AHEAD_COUNT commit(s) ahead of public, skipping (force not enabled)"
exit 0
else

View File

@ -40,7 +40,9 @@ jobs:
sparse-checkout-cone-mode: false
- name: Pull Docker image with retry
run: node .github/scripts/retry.mjs --attempts 4 --delay 15 'docker pull "${{ inputs.image_ref }}"'
env:
IMAGE_REF: ${{ inputs.image_ref }}
run: node .github/scripts/retry.mjs --attempts 4 --delay 15 -- docker pull "$IMAGE_REF"
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # v0.34.1
@ -90,11 +92,13 @@ jobs:
- name: Generate GitHub Job Summary
if: always()
env:
IMAGE_REF: ${{ inputs.image_ref }}
run: |
{
echo "# 🛡️ Trivy Security Scan Results"
echo ""
echo "**Image:** \`${{ inputs.image_ref }}\`"
echo "**Image:** \`$IMAGE_REF\`"
echo "**Scan Date:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
@ -125,7 +129,7 @@ jobs:
{
# Generate detailed vulnerability table
jq -r --arg image_ref "${{ inputs.image_ref }}" '
jq -r --arg image_ref "$IMAGE_REF" '
# Collect all vulnerabilities
[.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[]] |
# Group by CVE ID to avoid duplicates
@ -165,8 +169,10 @@ jobs:
- name: Generate Slack Blocks JSON
if: steps.process_results.outputs.vulnerabilities_found == 'true'
id: generate_blocks
env:
IMAGE_REF: ${{ inputs.image_ref }}
run: |
BLOCKS_JSON=$(jq -c --arg image_ref "${{ inputs.image_ref }}" \
BLOCKS_JSON=$(jq -c --arg image_ref "$IMAGE_REF" \
--arg repo_url "${{ github.server_url }}/${{ github.repository }}" \
--arg repo_name "${{ github.repository }}" \
--arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \

View File

@ -102,9 +102,16 @@ jobs:
run: npx tsx packages/testing/containers/pull-test-images.ts ${{ matrix.images }} || true
- name: Run Tests
# Uses pre-distributed specs if orchestration enabled, otherwise falls back to Playwright sharding
run: ${{ inputs.test-command }} --workers=${{ env.PLAYWRIGHT_WORKERS }} ${{ matrix.specs || format('--shard={0}/{1}', matrix.shard, strategy.job-total) }}
run: |
# shellcheck disable=SC2086
$TEST_COMMAND --workers="$WORKERS" $SHARD_ARGS
env:
# Protect args from template injections
TEST_COMMAND: ${{ inputs.test-command }}
# Uses pre-distributed specs if orchestration enabled, otherwise falls back to Playwright sharding
WORKERS: ${{ env.PLAYWRIGHT_WORKERS }}
SHARD_ARGS: ${{ matrix.specs || format('--shard={0}/{1}', matrix.shard, strategy.job-total) }}
# Args for actual test command runner
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
QA_METRICS_WEBHOOK_URL: ${{ secrets.QA_METRICS_WEBHOOK_URL }}
QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}

View File

@ -48,15 +48,16 @@ jobs:
- name: Generate experiment name
id: experiment
env:
PREFIX: ${{ inputs.experiment_name_prefix }}
BRANCH: ${{ inputs.branch }}
run: |
DATE=$(date +%Y_%m_%d)
PREFIX="${{ inputs.experiment_name_prefix }}"
if [ -n "$PREFIX" ]; then
NAME="${PREFIX}_${DATE}"
else
# Extract ticket ID from branch name (e.g., AI-1234 from ai-1234-feature-name)
BRANCH="${{ inputs.branch }}"
TICKET=$(echo "$BRANCH" | grep -oE '^[Aa][Ii]-[0-9]+' | tr '[:lower:]' '[:upper:]' || true)
if [ -n "$TICKET" ]; then
NAME="${TICKET}_${DATE}"
@ -95,14 +96,23 @@ jobs:
- name: Run Evaluations
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations
env:
SUITE: ${{ inputs.suite }}
DATASET: ${{ inputs.dataset }}
REPETITIONS: ${{ inputs.repetitions }}
JUDGES: ${{ inputs.judges }}
CONCURRENCY: ${{ inputs.concurrency }}
EXPERIMENT_NAME: ${{ steps.experiment.outputs.name }}
WEBHOOK_URL_ARG: ${{ secrets.EVALS_WEBHOOK_URL && format('--webhook-url={0}', secrets.EVALS_WEBHOOK_URL) || '' }}
WEBHOOK_SECRET_ARG: ${{ secrets.EVALS_WEBHOOK_SECRET && format('--webhook-secret={0}', secrets.EVALS_WEBHOOK_SECRET) || '' }}
run: |
pnpm eval \
--suite "${{ inputs.suite }}" \
--suite "$SUITE" \
--backend langsmith \
--dataset "${{ inputs.dataset }}" \
--repetitions ${{ inputs.repetitions }} \
--judges ${{ inputs.judges }} \
--concurrency ${{ inputs.concurrency }} \
--name "${{ steps.experiment.outputs.name }}" \
${{ secrets.EVALS_WEBHOOK_URL && format('--webhook-url "{0}"', secrets.EVALS_WEBHOOK_URL) || '' }} \
${{ secrets.EVALS_WEBHOOK_SECRET && format('--webhook-secret "{0}"', secrets.EVALS_WEBHOOK_SECRET) || '' }}
--dataset "$DATASET" \
--repetitions "$REPETITIONS" \
--judges "$JUDGES" \
--concurrency "$CONCURRENCY" \
--name "$EXPERIMENT_NAME" \
"$WEBHOOK_URL_ARG" \
"$WEBHOOK_SECRET_ARG"

View File

@ -30,16 +30,20 @@ jobs:
- name: Approve PR (as the App)
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
PR_NUMBER: ${{ inputs.pull-request-number }}
REPOSITORY: ${{ github.repository }}
run: |
gh pr review "${{ inputs.pull-request-number }}" \
gh pr review "$PR_NUMBER" \
--approve \
--repo "${{ github.repository }}"
--repo "$REPOSITORY"
- name: Enable auto-merge (merge when checks pass)
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
PR_NUMBER: ${{ inputs.pull-request-number }}
REPOSITORY: ${{ github.repository }}
run: |
gh pr merge "${{ inputs.pull-request-number }}" \
gh pr merge "$PR_NUMBER" \
--auto \
--squash \
--repo "${{ github.repository }}"
--repo "$REPOSITORY"

View File

@ -169,8 +169,10 @@ jobs:
- name: Push branch
if: always()
env:
REF: ${{ inputs.ref }}
run: |
if ! git diff --quiet "${{ inputs.ref }}" 2>/dev/null; then
if ! git diff --quiet "$REF" 2>/dev/null; then
git push -u origin "$BRANCH_NAME"
echo "::notice::Changes pushed to branch $BRANCH_NAME"
else

View File

@ -35,7 +35,9 @@ jobs:
QA_METRICS_WEBHOOK_USER: ${{ secrets.QA_METRICS_WEBHOOK_USER }}
QA_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_METRICS_WEBHOOK_PASSWORD }}
GITHUB_TOKEN: ${{ github.token }}
METRICS: ${{ inputs.metrics }}
BASELINE_DAYS: ${{ inputs.baseline-days }}
run: |
node .github/scripts/post-qa-metrics-comment.mjs \
--metrics "${{ inputs.metrics }}" \
--baseline-days "${{ inputs.baseline-days }}"
--metrics "$METRICS" \
--baseline-days "$BASELINE_DAYS"