From a724a0043b0529d92cc83a7746b83243376fd9cf Mon Sep 17 00:00:00 2001 From: Vik Date: Wed, 25 Mar 2026 04:35:52 +0000 Subject: [PATCH 1/2] feat: add context-action for uploading context data to Nullify New composite GitHub Action at context-action/action.yml. Usage: uses: Nullify-Platform/nullify-cloud-connector/context-action@v1 - Installs Nullify CLI, then delegates to entrypoint.sh - Mode decision matrix: merge-only, pr-only, both - Auto-detects PR number, draft status from GitHub event context - Supports terraform plans, CI logs, and other context types - Inputs: nullify-token, type, name, mode, environment, plan-path, skip-on-draft, dry-run - Outputs: action-taken, upload-path Co-Authored-By: Claude Opus 4.6 (1M context) --- context-action/action.yml | 64 ++++++++++++++ context-action/scripts/entrypoint.sh | 124 +++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 context-action/action.yml create mode 100755 context-action/scripts/entrypoint.sh diff --git a/context-action/action.yml b/context-action/action.yml new file mode 100644 index 0000000..3f6f79e --- /dev/null +++ b/context-action/action.yml @@ -0,0 +1,64 @@ +name: 'Nullify Context' +description: 'Upload context data (Terraform plans, CI logs, etc.) to Nullify for infrastructure-aware security analysis' +branding: + icon: 'upload-cloud' + color: 'blue' + +inputs: + nullify-token: + description: 'Nullify service account JWT' + required: true + type: + description: 'Context type: terraform, ci_logs, config, deploy, api_spec' + required: true + name: + description: 'Logical name for this context (e.g. networking, ecs-api, CI)' + required: true + mode: + description: 'Upload mode: merge-only, pr-only, both' + default: 'both' + environment: + description: 'Deployment environment: development, staging, production, unknown' + default: 'unknown' + plan-path: + description: 'Path to the file(s) to upload (supports glob patterns)' + default: '' + skip-on-draft: + description: 'Skip upload for draft PRs' + default: 'true' + dry-run: + description: 'Log what would be uploaded without uploading' + default: 'false' + +outputs: + action-taken: + description: 'Action taken: uploaded, skipped, cleaned-up' + value: ${{ steps.run.outputs.action_taken }} + upload-path: + description: 'S3 key prefix where data was uploaded' + value: ${{ steps.run.outputs.upload_path }} + +runs: + using: 'composite' + steps: + - name: Install Nullify CLI + shell: bash + run: | + curl -sSL https://cli.nullify.ai/install.sh | bash || { + echo "::error::Failed to install Nullify CLI" + exit 1 + } + + - name: Run context action + id: run + shell: bash + env: + NULLIFY_TOKEN: ${{ inputs.nullify-token }} + INPUT_TYPE: ${{ inputs.type }} + INPUT_NAME: ${{ inputs.name }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_PLAN_PATH: ${{ inputs.plan-path }} + INPUT_SKIP_ON_DRAFT: ${{ inputs.skip-on-draft }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + run: ${{ github.action_path }}/scripts/entrypoint.sh diff --git a/context-action/scripts/entrypoint.sh b/context-action/scripts/entrypoint.sh new file mode 100755 index 0000000..6979ce9 --- /dev/null +++ b/context-action/scripts/entrypoint.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# Nullify Context Action — Entrypoint +# Determines what action to take based on GitHub event context +# and mode, then invokes the Nullify CLI. +# ============================================================ + +ACTION_TAKEN="skipped" + +# --- Detect event context --- +EVENT_NAME="${GITHUB_EVENT_NAME:-}" +REF_NAME="${GITHUB_REF_NAME:-}" +REPOSITORY="${GITHUB_REPOSITORY:-}" +SHA="${GITHUB_SHA:-}" +PR_NUMBER="" +PR_MERGED="false" +PR_DRAFT="false" + +if [[ "$EVENT_NAME" == "pull_request" ]]; then + PR_NUMBER=$(jq -r '.pull_request.number // empty' "$GITHUB_EVENT_PATH") + PR_MERGED=$(jq -r '.pull_request.merged // false' "$GITHUB_EVENT_PATH") + PR_DRAFT=$(jq -r '.pull_request.draft // false' "$GITHUB_EVENT_PATH") + PR_ACTION=$(jq -r '.action // empty' "$GITHUB_EVENT_PATH") +fi + +echo "Event: $EVENT_NAME | Mode: $INPUT_MODE | Ref: $REF_NAME | PR: ${PR_NUMBER:-none}" + +# --- Skip on draft PRs --- +if [[ "$INPUT_SKIP_ON_DRAFT" == "true" && "$PR_DRAFT" == "true" ]]; then + echo "Skipping — draft PR" + echo "action_taken=skipped" >> "$GITHUB_OUTPUT" + exit 0 +fi + +# --- Mode decision matrix --- +should_upload="false" +should_cleanup="false" + +case "$INPUT_MODE" in + merge-only) + if [[ "$EVENT_NAME" == "push" ]]; then + should_upload="true" + fi + ;; + pr-only) + if [[ "$EVENT_NAME" == "pull_request" ]]; then + if [[ "$PR_ACTION" == "closed" ]]; then + should_cleanup="true" + elif [[ "$PR_ACTION" == "opened" || "$PR_ACTION" == "synchronize" ]]; then + should_upload="true" + fi + fi + ;; + both) + if [[ "$EVENT_NAME" == "push" ]]; then + should_upload="true" + elif [[ "$EVENT_NAME" == "pull_request" ]]; then + if [[ "$PR_ACTION" == "closed" && "$PR_MERGED" == "true" ]]; then + # Merged — the push event handles the upload + should_cleanup="true" + elif [[ "$PR_ACTION" == "closed" && "$PR_MERGED" != "true" ]]; then + should_cleanup="true" + elif [[ "$PR_ACTION" == "opened" || "$PR_ACTION" == "synchronize" ]]; then + should_upload="true" + fi + fi + ;; + *) + echo "::error::Invalid mode: $INPUT_MODE (must be merge-only, pr-only, or both)" + exit 1 + ;; +esac + +# --- Execute --- +if [[ "$should_upload" == "true" ]]; then + # Build CLI args + CLI_ARGS="--type $INPUT_TYPE --name $INPUT_NAME" + + if [[ -n "$INPUT_ENVIRONMENT" ]]; then + CLI_ARGS="$CLI_ARGS --environment $INPUT_ENVIRONMENT" + fi + + if [[ -n "$PR_NUMBER" && "$EVENT_NAME" == "pull_request" ]]; then + CLI_ARGS="$CLI_ARGS --pr-number $PR_NUMBER" + fi + + if [[ "$INPUT_DRY_RUN" == "true" ]]; then + CLI_ARGS="$CLI_ARGS --dry-run" + fi + + # Resolve files to upload + FILES="" + if [[ -n "$INPUT_PLAN_PATH" ]]; then + # Use glob expansion + shopt -s nullglob globstar + for f in $INPUT_PLAN_PATH; do + FILES="$FILES $f" + done + shopt -u nullglob globstar + fi + + if [[ -z "$FILES" && -n "$INPUT_PLAN_PATH" ]]; then + echo "::warning::No files matched pattern: $INPUT_PLAN_PATH" + echo "action_taken=skipped" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Uploading context: type=$INPUT_TYPE name=$INPUT_NAME" + # shellcheck disable=SC2086 + nullify api context push $CLI_ARGS $FILES + + ACTION_TAKEN="uploaded" + +elif [[ "$should_cleanup" == "true" ]]; then + echo "PR #${PR_NUMBER} closed — cleanup would happen here (not yet implemented)" + ACTION_TAKEN="cleaned-up" + +else + echo "No action needed for event=$EVENT_NAME mode=$INPUT_MODE" +fi + +echo "action_taken=$ACTION_TAKEN" >> "$GITHUB_OUTPUT" From 1dc67f4092fc51118b4fc68649efe0d3c7fe42fa Mon Sep 17 00:00:00 2001 From: Vik Date: Wed, 25 Mar 2026 04:58:19 +0000 Subject: [PATCH 2/2] fix: address security and logic issues in context action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - Use bash arrays for CLI args (prevents command injection via inputs) - Safe jq parsing with error handling - Check if CLI is already installed before curl|bash Logic fixes: - Default plan-path to **/plan.json when empty - Upload each file separately with name derived from parent directory (e.g. infrastructure/networking/plan.json → name=infrastructure/networking) - Make name optional — auto-derived from file path, overridable - Proper glob expansion into array Co-Authored-By: Claude Opus 4.6 (1M context) --- context-action/action.yml | 19 ++++-- context-action/scripts/entrypoint.sh | 96 +++++++++++++++++----------- 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/context-action/action.yml b/context-action/action.yml index 3f6f79e..3510e5d 100644 --- a/context-action/action.yml +++ b/context-action/action.yml @@ -12,8 +12,9 @@ inputs: description: 'Context type: terraform, ci_logs, config, deploy, api_spec' required: true name: - description: 'Logical name for this context (e.g. networking, ecs-api, CI)' - required: true + description: 'Logical name for this context. Auto-derived from file path if omitted (e.g. infrastructure/networking/plan.json → infrastructure/networking)' + required: false + default: '' mode: description: 'Upload mode: merge-only, pr-only, both' default: 'both' @@ -21,7 +22,7 @@ inputs: description: 'Deployment environment: development, staging, production, unknown' default: 'unknown' plan-path: - description: 'Path to the file(s) to upload (supports glob patterns)' + description: 'Path to the file(s) to upload. Supports glob patterns. Defaults to **/plan.json' default: '' skip-on-draft: description: 'Skip upload for draft PRs' @@ -44,10 +45,14 @@ runs: - name: Install Nullify CLI shell: bash run: | - curl -sSL https://cli.nullify.ai/install.sh | bash || { - echo "::error::Failed to install Nullify CLI" - exit 1 - } + if command -v nullify &>/dev/null; then + echo "Nullify CLI already installed: $(nullify --version 2>/dev/null || echo 'unknown')" + else + curl -sSL https://cli.nullify.ai/install.sh | bash || { + echo "::error::Failed to install Nullify CLI" + exit 1 + } + fi - name: Run context action id: run diff --git a/context-action/scripts/entrypoint.sh b/context-action/scripts/entrypoint.sh index 6979ce9..c608acb 100755 --- a/context-action/scripts/entrypoint.sh +++ b/context-action/scripts/entrypoint.sh @@ -12,17 +12,16 @@ ACTION_TAKEN="skipped" # --- Detect event context --- EVENT_NAME="${GITHUB_EVENT_NAME:-}" REF_NAME="${GITHUB_REF_NAME:-}" -REPOSITORY="${GITHUB_REPOSITORY:-}" -SHA="${GITHUB_SHA:-}" PR_NUMBER="" PR_MERGED="false" PR_DRAFT="false" +PR_ACTION="" if [[ "$EVENT_NAME" == "pull_request" ]]; then - PR_NUMBER=$(jq -r '.pull_request.number // empty' "$GITHUB_EVENT_PATH") - PR_MERGED=$(jq -r '.pull_request.merged // false' "$GITHUB_EVENT_PATH") - PR_DRAFT=$(jq -r '.pull_request.draft // false' "$GITHUB_EVENT_PATH") - PR_ACTION=$(jq -r '.action // empty' "$GITHUB_EVENT_PATH") + PR_NUMBER=$(jq -r '.pull_request.number // empty' "$GITHUB_EVENT_PATH" 2>/dev/null || true) + PR_MERGED=$(jq -r '.pull_request.merged // false' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "false") + PR_DRAFT=$(jq -r '.pull_request.draft // false' "$GITHUB_EVENT_PATH" 2>/dev/null || echo "false") + PR_ACTION=$(jq -r '.action // empty' "$GITHUB_EVENT_PATH" 2>/dev/null || true) fi echo "Event: $EVENT_NAME | Mode: $INPUT_MODE | Ref: $REF_NAME | PR: ${PR_NUMBER:-none}" @@ -57,10 +56,7 @@ case "$INPUT_MODE" in if [[ "$EVENT_NAME" == "push" ]]; then should_upload="true" elif [[ "$EVENT_NAME" == "pull_request" ]]; then - if [[ "$PR_ACTION" == "closed" && "$PR_MERGED" == "true" ]]; then - # Merged — the push event handles the upload - should_cleanup="true" - elif [[ "$PR_ACTION" == "closed" && "$PR_MERGED" != "true" ]]; then + if [[ "$PR_ACTION" == "closed" ]]; then should_cleanup="true" elif [[ "$PR_ACTION" == "opened" || "$PR_ACTION" == "synchronize" ]]; then should_upload="true" @@ -75,41 +71,65 @@ esac # --- Execute --- if [[ "$should_upload" == "true" ]]; then - # Build CLI args - CLI_ARGS="--type $INPUT_TYPE --name $INPUT_NAME" - - if [[ -n "$INPUT_ENVIRONMENT" ]]; then - CLI_ARGS="$CLI_ARGS --environment $INPUT_ENVIRONMENT" - fi - - if [[ -n "$PR_NUMBER" && "$EVENT_NAME" == "pull_request" ]]; then - CLI_ARGS="$CLI_ARGS --pr-number $PR_NUMBER" - fi - - if [[ "$INPUT_DRY_RUN" == "true" ]]; then - CLI_ARGS="$CLI_ARGS --dry-run" - fi - # Resolve files to upload - FILES="" - if [[ -n "$INPUT_PLAN_PATH" ]]; then - # Use glob expansion - shopt -s nullglob globstar - for f in $INPUT_PLAN_PATH; do - FILES="$FILES $f" - done - shopt -u nullglob globstar + PLAN_PATH="${INPUT_PLAN_PATH:-}" + if [[ -z "$PLAN_PATH" ]]; then + # Default: find all plan.json files recursively + PLAN_PATH="**/plan.json" fi - if [[ -z "$FILES" && -n "$INPUT_PLAN_PATH" ]]; then - echo "::warning::No files matched pattern: $INPUT_PLAN_PATH" + # Expand glob into array safely + shopt -s nullglob globstar + FILES=() + for f in $PLAN_PATH; do + FILES+=("$f") + done + shopt -u nullglob globstar + + if [[ ${#FILES[@]} -eq 0 ]]; then + echo "::warning::No files matched pattern: $PLAN_PATH" echo "action_taken=skipped" >> "$GITHUB_OUTPUT" exit 0 fi - echo "Uploading context: type=$INPUT_TYPE name=$INPUT_NAME" - # shellcheck disable=SC2086 - nullify api context push $CLI_ARGS $FILES + echo "Found ${#FILES[@]} file(s) to upload" + + # Upload each file as a separate context entry with name derived from path + for f in "${FILES[@]}"; do + # Derive name from file's parent directory + # e.g. infrastructure/networking/plan.json → infrastructure/networking + # e.g. plan.json → root + FILE_DIR=$(dirname "$f") + if [[ "$FILE_DIR" == "." || "$FILE_DIR" == "/" ]]; then + FILE_NAME="root" + else + FILE_NAME="$FILE_DIR" + fi + + # Use explicit --name if provided, otherwise use derived name + EFFECTIVE_NAME="${INPUT_NAME:-$FILE_NAME}" + + # Build CLI args as array (prevents command injection) + CLI_ARGS=( + "--type" "$INPUT_TYPE" + "--name" "$EFFECTIVE_NAME" + ) + + if [[ -n "${INPUT_ENVIRONMENT:-}" ]]; then + CLI_ARGS+=("--environment" "$INPUT_ENVIRONMENT") + fi + + if [[ -n "$PR_NUMBER" && "$EVENT_NAME" == "pull_request" ]]; then + CLI_ARGS+=("--pr-number" "$PR_NUMBER") + fi + + if [[ "${INPUT_DRY_RUN:-false}" == "true" ]]; then + CLI_ARGS+=("--dry-run") + fi + + echo "Uploading: $f (name=$EFFECTIVE_NAME)" + nullify api context push "${CLI_ARGS[@]}" "$f" + done ACTION_TAKEN="uploaded"