diff --git a/.github/workflows/example-fix-pr-review.yaml b/.github/workflows/example-fix-pr-review.yaml index cab993b..3fe0101 100644 --- a/.github/workflows/example-fix-pr-review.yaml +++ b/.github/workflows/example-fix-pr-review.yaml @@ -1,32 +1,52 @@ name: Suggest autofixes with Kubescape for PR by reviews on: - pull_request_target: + pull_request: + +permissions: + contents: read jobs: kubescape-fix-pr-reviews: runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v35 + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + # Scan the workspace rather than interpolating changed filenames into inputs. + # Fork contents are analyzed without repository secrets or write permissions. - uses: kubescape/github-action@main with: - account: ${{secrets.KUBESCAPE_ACCOUNT}} - accessKey: ${{secrets.KUBESCAPE_ACCESS_KEY}} - server: ${{ vars.KUBESCAPE_SERVER }} - files: ${{ steps.changed-files.outputs.all_changed_files }} + files: . fixFiles: true format: "sarif" + - name: Save scan results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: kubescape-pr-results + path: | + results.sarif + results.json + if-no-files-found: error + + publish-reviews: + needs: kubescape-fix-pr-reviews + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: kubescape-pr-results - name: PR Suggester according to SARIF file - if: github.event_name == 'pull_request_target' uses: HollowMan6/sarif4reviewdog@v1.0.0 with: file: 'results.sarif' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..d4017e9 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,21 @@ +name: Entrypoint tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + entrypoint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - name: Check entrypoint syntax + run: bash -n entrypoint.sh + - name: Run entrypoint tests + run: bash tests/entrypoint_test.sh + - name: Run action command-construction tests + run: bash tests/action_test.sh diff --git a/README.md b/README.md index 21eff84..0698a09 100644 --- a/README.md +++ b/README.md @@ -42,45 +42,65 @@ You can then see the results in the Pull Request that triggered the scan and the ### Automatically Suggest Fixes -To make Kubescape automatically suggest fixes to your pull requests by code review, use the following workflow: +To scan pull requests and suggest fixes on branches in the same repository, use the following workflow: ```yaml name: Suggest autofixes with Kubescape for PR by reviews on: - pull_request_target: + pull_request: + +permissions: + contents: read jobs: kubescape-fix-pr-reviews: runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v35 + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + # Scan the workspace rather than interpolating changed filenames into inputs. + # Fork contents are analyzed without repository secrets or write permissions. - uses: kubescape/github-action@main with: - account: ${{secrets.KUBESCAPE_ACCOUNT}} - accessKey: ${{secrets.KUBESCAPE_ACCESS_KEY}} - server: ${{ vars.KUBESCAPE_SERVER }} - files: ${{ steps.changed-files.outputs.all_changed_files }} + files: . fixFiles: true format: "sarif" + - name: Save scan results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: kubescape-pr-results + path: | + results.sarif + results.json + if-no-files-found: error + + publish-reviews: + needs: kubescape-fix-pr-reviews + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: kubescape-pr-results - name: PR Suggester according to SARIF file - if: github.event_name == 'pull_request_target' uses: HollowMan6/sarif4reviewdog@v1.0.0 with: file: 'results.sarif' level: warning ``` -The above workflow works by collecting the [SARIF (Static Analysis Results Interchange Format)](https://www.oasis-open.org/committees/tc_home.php?wg_abbrev=sarif) file that kubescape generates. Then, with the help of [HollowMan6/sarif4reviewdog](https://github.com/marketplace/actions/sarif-support-for-reviewdog), convert the SARIF file into [RDFormat (Reviewdog Diagnostic Format)](https://github.com/reviewdog/reviewdog/tree/master/proto/rdf) and generate reviews using [Reviewdog](https://github.com/reviewdog/reviewdog). +The scan job runs on `pull_request` with read-only permissions and no repository secrets. It scans the workspace and saves SARIF and JSON results as a downloadable artifact. A separate job uses Reviewdog to post reviews only for pull requests from branches in the same repository. Fork pull requests receive scan artifacts; they do not run the posting job. Do not enable unsafe fork checkout under `pull_request_target`. You can also make Kubescape automatically suggest fixes for the pushes to your main branch by opening new PRs with the following workflow: @@ -182,7 +202,7 @@ jobs: | Name | Description | Required | | --- | --- | ---| -| files | YAML files or Helm charts to scan for misconfigurations. The files need to be provided with the complete path from the root of the repository. | No (default is `.` which scans the whole repository) | +| files | YAML files or Helm charts to scan, using paths or glob patterns relative to the repository root, separated by whitespace. A single existing path may contain spaces. Shell expressions and quoted shell-style path lists are not supported. | No (default is `.` which scans the whole repository) | | outputFile | Name of the output file where the scan result will be stored without the extension. | No (default is `results`) | | frameworks | Security framework(s) to scan the files against. Multiple frameworks can be specified separated by a comma with no spaces. Example - `nsa,devopsbest`. Run `kubescape list frameworks` in the [Kubescape CLI](https://hub.armo.cloud/docs/installing-kubescape) to get a list of all frameworks. Either frameworks have to be specified or controls. | No | | controls | Security control(s) to scan the files against. Multiple controls can be specified separated by a comma with no spaces. Example - `Configured liveness probe,Pods in default namespace`. Run `kubescape list controls` in the [Kubescape CLI](https://hub.armo.cloud/docs/installing-kubescape) to get a list of all controls. You can use either the complete control name or the control ID such as `C-0001` to specify the control you want use. You must specify either the control(s) or the framework(s) you want used in the scan. | No | @@ -194,6 +214,7 @@ jobs: | verbose | Display all of the input resources and not only failed resources. Default is off | No | | exceptions | The JSON file containing at least one resource and one policy. Refer [exceptions](https://hub.armo.cloud/docs/exceptions) docs for more info. Objects with exceptions will be presented as exclude and not fail. | No | | controlsConfig | The file containing controls configuration. Use `kubescape download controls-inputs` to download the configured controls-inputs. | No | +| artifacts | Workspace-relative path to a vendored Kubescape artifacts directory. The directory must resolve inside the workspace and cannot be used with `image`. | No | | image | The image you wish to scan. Launches an image scan, which cannot run together with configuration scans. | No | | registryUsername | Username to a private registry that hosts the scanned image. | No | | registryPassword | Password to a private registry that hosts the scanned image. | No | @@ -201,7 +222,33 @@ jobs: ## Examples -> **Note:** The `version` input defaults to `latest`, so it is omitted from the examples below. For reproducible scans, pin a specific Kubescape release with e.g. `version: v3.0.21`. +> **Note:** The `version` input defaults to `latest`, but pinning a Kubescape version alone does not pin the policy library used by a scan. Use a reviewed artifact bundle as described below when policy stability is required. + +### Reproducible policy evaluation + +Create the artifacts outside the CI run, review them, and commit the directory alongside the manifests that will be scanned: + +```bash +kubescape download artifacts --output kubescape-artifacts +``` + +Then pin the action commit and Kubescape version, and scan a path that does not contain the artifact JSON files: + +```yaml +- uses: actions/checkout@v3 +- uses: kubescape/github-action@ + with: + version: v4.0.13 + frameworks: nsa + files: manifests/ + artifacts: kubescape-artifacts/ +``` + +The `artifacts` path must be relative to the checked-out workspace and must resolve inside it. Downloading the bundle during every CI run would fetch the current policy library again and defeat policy reproducibility. + +The bundle includes `exceptions.json` and `controls-inputs.json`. Kubescape v4.0.13 prefers explicit `exceptions` and `controlsConfig` inputs when they are supplied together with `artifacts`; older versions such as v3.0.21 prefer the files in the artifact bundle. When `account`, `accessKey`, or `server` are also supplied, they are still forwarded, but the vendored artifacts remain the policy source. Ensure that any required custom policies are present in the bundle. + +Pinning the action commit, Kubescape version, scanned manifests, and reviewed artifact bundle makes policy and rule evaluation reproducible. It does not make the entire container build reproducible because the action currently retrieves Kubescape's installer separately. #### Scan and submit results to the [Kubescape Cloud](https://cloud.armosec.io/) @@ -344,4 +391,3 @@ jobs: with: sarif_file: results.sarif ``` - diff --git a/action.yml b/action.yml index e02875c..4231eb8 100644 --- a/action.yml +++ b/action.yml @@ -20,7 +20,9 @@ inputs: default: high files: description: | - Path to the configuration yaml to scan + Configuration paths or glob patterns to scan, separated by whitespace. + A single existing path may contain spaces. Shell expressions are not + evaluated; quoted shell-style path lists are not supported. required: false outputFile: description: | @@ -51,6 +53,11 @@ inputs: description: | Path to the file containing controls configuration. required: false + artifacts: + description: | + Workspace-relative path to a vendored Kubescape artifacts directory. + The directory is used as the policy source for configuration scans. + required: false account: description: | Kubescape Portal client id. @@ -122,45 +129,81 @@ runs: steps: - id: resolve_version shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} run: | - VERSION="${{ inputs.version }}" + VERSION="$INPUT_VERSION" + if [[ ! "$VERSION" =~ ^(latest|v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?)$ ]]; then + echo "Invalid Kubescape version" >&2 + exit 1 + fi if [ "$VERSION" = "latest" ]; then - VERSION=$(curl -s -H "Authorization: Bearer ${{ github.token }}" https://api.github.com/repos/kubescape/kubescape/releases/latest | jq -r .tag_name) + VERSION=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/kubescape/kubescape/releases/latest | jq -r .tag_name) + fi + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]]; then + echo "Invalid resolved Kubescape version" >&2 + exit 1 fi - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Build Kubescape container shell: bash + env: + KUBESCAPE_VERSION: ${{ steps.resolve_version.outputs.version }} + ACTION_PATH: ${{ github.action_path }} run: | - docker build -t kubescape-action:${{ steps.resolve_version.outputs.version }} \ - --build-arg KUBESCAPE_VERSION=${{ steps.resolve_version.outputs.version }} \ - ${{ github.action_path }} + docker build -t "kubescape-action:$KUBESCAPE_VERSION" \ + --build-arg "KUBESCAPE_VERSION=$KUBESCAPE_VERSION" \ + "$ACTION_PATH" - name: Run Kubescape scan shell: bash + env: + INPUT_FAILEDTHRESHOLD: ${{ inputs.failedThreshold }} + INPUT_COMPLIANCETHRESHOLD: ${{ inputs.complianceThreshold }} + INPUT_SEVERITYTHRESHOLD: ${{ inputs.severityThreshold }} + INPUT_FILES: ${{ inputs.files }} + INPUT_OUTPUTFILE: ${{ inputs.outputFile }} + INPUT_VERBOSE: ${{ inputs.verbose }} + INPUT_FRAMEWORKS: ${{ inputs.frameworks }} + INPUT_CONTROLS: ${{ inputs.controls }} + INPUT_CONTROLSCONFIG: ${{ inputs.controlsConfig }} + INPUT_ACCOUNT: ${{ inputs.account }} + INPUT_ACCESSKEY: ${{ inputs.accessKey }} + INPUT_SERVER: ${{ inputs.server }} + INPUT_EXCEPTIONS: ${{ inputs.exceptions }} + INPUT_FORMAT: ${{ inputs.format }} + INPUT_FIXFILES: ${{ inputs.fixFiles }} + INPUT_IMAGE: ${{ inputs.image }} + INPUT_REGISTRYUSERNAME: ${{ inputs.registryUsername }} + INPUT_REGISTRYPASSWORD: ${{ inputs.registryPassword }} + INPUT_ARTIFACTS: ${{ inputs.artifacts }} + KUBESCAPE_VERSION: ${{ steps.resolve_version.outputs.version }} run: | docker run --rm \ -e GITHUB_ACTIONS=true \ -e GITHUB_WORKSPACE=/github/workspace \ - -e GITHUB_REPOSITORY=${{ github.repository }} \ - -e GITHUB_REF=${{ github.ref }} \ - -e GITHUB_SHA=${{ github.sha }} \ - -v ${{ github.workspace }}:/github/workspace \ + -e GITHUB_REPOSITORY \ + -e GITHUB_REF \ + -e GITHUB_SHA \ + -v "$GITHUB_WORKSPACE:/github/workspace" \ -w /github/workspace \ - -e INPUT_FAILEDTHRESHOLD="${{ inputs.failedThreshold }}" \ - -e INPUT_COMPLIANCETHRESHOLD="${{ inputs.complianceThreshold }}" \ - -e INPUT_SEVERITYTHRESHOLD="${{ inputs.severityThreshold }}" \ - -e INPUT_FILES="${{ inputs.files }}" \ - -e INPUT_OUTPUTFILE="${{ inputs.outputFile }}" \ - -e INPUT_VERBOSE="${{ inputs.verbose }}" \ - -e INPUT_FRAMEWORKS="${{ inputs.frameworks }}" \ - -e INPUT_CONTROLS="${{ inputs.controls }}" \ - -e INPUT_CONTROLSCONFIG="${{ inputs.controlsConfig }}" \ - -e INPUT_ACCOUNT="${{ inputs.account }}" \ - -e INPUT_ACCESSKEY="${{ inputs.accessKey }}" \ - -e INPUT_SERVER="${{ inputs.server }}" \ - -e INPUT_EXCEPTIONS="${{ inputs.exceptions }}" \ - -e INPUT_FORMAT="${{ inputs.format }}" \ - -e INPUT_FIXFILES="${{ inputs.fixFiles }}" \ - -e INPUT_IMAGE="${{ inputs.image }}" \ - -e INPUT_REGISTRYUSERNAME="${{ inputs.registryUsername }}" \ - -e INPUT_REGISTRYPASSWORD="${{ inputs.registryPassword }}" \ - kubescape-action:${{ steps.resolve_version.outputs.version }} + -e INPUT_FAILEDTHRESHOLD="$INPUT_FAILEDTHRESHOLD" \ + -e INPUT_COMPLIANCETHRESHOLD="$INPUT_COMPLIANCETHRESHOLD" \ + -e INPUT_SEVERITYTHRESHOLD="$INPUT_SEVERITYTHRESHOLD" \ + -e INPUT_FILES="$INPUT_FILES" \ + -e INPUT_OUTPUTFILE="$INPUT_OUTPUTFILE" \ + -e INPUT_VERBOSE="$INPUT_VERBOSE" \ + -e INPUT_FRAMEWORKS="$INPUT_FRAMEWORKS" \ + -e INPUT_CONTROLS="$INPUT_CONTROLS" \ + -e INPUT_CONTROLSCONFIG="$INPUT_CONTROLSCONFIG" \ + -e INPUT_ARTIFACTS="$INPUT_ARTIFACTS" \ + -e INPUT_ACCOUNT="$INPUT_ACCOUNT" \ + -e INPUT_ACCESSKEY="$INPUT_ACCESSKEY" \ + -e INPUT_SERVER="$INPUT_SERVER" \ + -e INPUT_EXCEPTIONS="$INPUT_EXCEPTIONS" \ + -e INPUT_FORMAT="$INPUT_FORMAT" \ + -e INPUT_FIXFILES="$INPUT_FIXFILES" \ + -e INPUT_IMAGE="$INPUT_IMAGE" \ + -e INPUT_REGISTRYUSERNAME="$INPUT_REGISTRYUSERNAME" \ + -e INPUT_REGISTRYPASSWORD="$INPUT_REGISTRYPASSWORD" \ + "kubescape-action:$KUBESCAPE_VERSION" diff --git a/entrypoint.sh b/entrypoint.sh index b6b758c..665032b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,6 +10,14 @@ contains() { set -e +# Expand filename patterns as data, with word splitting disabled. Bash does not +# execute shell syntax introduced by expanding a variable. +append_paths() { + local IFS= + # shellcheck disable=SC2206 + scan_command+=( $1 ) +} + # Kubescape uses the client name to make a request for checking for updates export KS_CLIENT="github_actions" @@ -29,39 +37,65 @@ if [ -z "${INPUT_FRAMEWORKS}" ] && [ -z "${INPUT_CONTROLS}" ] && [ -z "${INPUT_I INPUT_FRAMEWORKS="all" fi -if [ -n "${INPUT_CONTROLS}" ]; then - controls="" - set -f - IFS=',' - set -- "${INPUT_CONTROLS}" - set +f - unset IFS - for control in "$@"; do - control=$(echo "${control}" | xargs) - controls="${controls}\"${control}\"," - done - controls=$(echo "${controls%?}") +# Split legacy whitespace-separated scopes without evaluating shell syntax. +scan_command=(kubescape scan) +if [ -n "${INPUT_IMAGE}" ]; then + scan_command+=(image) + if [ -n "${INPUT_REGISTRYUSERNAME}" ] && [ -n "${INPUT_REGISTRYPASSWORD}" ]; then + scan_command+=("--username=${INPUT_REGISTRYUSERNAME}" "--password=${INPUT_REGISTRYPASSWORD}") + fi + scan_command+=("${INPUT_IMAGE}") +else + scope=() + if [ -n "${INPUT_FRAMEWORKS}" ]; then + read -r -a scope <<< "${INPUT_FRAMEWORKS//$'\n'/ }" + scan_command+=(framework "${scope[@]}") + elif [ -n "${INPUT_CONTROLS}" ]; then + scan_command+=(control "${INPUT_CONTROLS}") + fi + if [ -n "${INPUT_FILES}" ]; then + if [ -e "${INPUT_FILES}" ]; then + scan_command+=("${INPUT_FILES}") + else + read -r -a scope <<< "${INPUT_FILES//$'\n'/ }" + for path in "${scope[@]}"; do + append_paths "$path" + done + fi + else + scan_command+=(.) + fi fi - -frameworks_cmd=$([ -n "${INPUT_FRAMEWORKS}" ] && echo "framework ${INPUT_FRAMEWORKS}" || echo "") -controls_cmd=$([ -n "${INPUT_CONTROLS}" ] && echo control "${controls}" || echo "") -scan_input=$([ -n "${INPUT_FILES}" ] && echo "${INPUT_FILES}" || echo .) output_formats="${INPUT_FORMAT:-pretty-printer}" -output_file=$([ -n "${INPUT_OUTPUTFILE}" ] && echo "${INPUT_OUTPUTFILE}" || echo "results") +output_file="${INPUT_OUTPUTFILE:-results}" + +if [ -n "${INPUT_ARTIFACTS}" ]; then + case "${INPUT_ARTIFACTS}" in + /*) + echo "Artifacts path must be relative to the GitHub workspace" + exit 1 + ;; + esac + if [ -n "${INPUT_IMAGE}" ]; then + echo "Artifacts cannot be used with image scans" + exit 1 + fi + if [ ! -d "${INPUT_ARTIFACTS}" ]; then + echo "Artifacts directory '${INPUT_ARTIFACTS}' does not exist" + exit 1 + fi -verbose="" -if [ -n "${INPUT_VERBOSE}" ] && [ "${INPUT_VERBOSE}" != "false" ]; then - verbose="--verbose" + workspace_path=$(pwd -P) + resolved_artifacts_path=$(cd -- "${INPUT_ARTIFACTS}" && pwd -P) + case "${resolved_artifacts_path}" in + "${workspace_path}"|"${workspace_path}"/*) ;; + *) + echo "Artifacts directory must resolve inside the GitHub workspace" + exit 1 + ;; + esac + scan_command+=(--use-artifacts-from "${resolved_artifacts_path}") fi - -exceptions=$([ -n "$INPUT_EXCEPTIONS" ] && echo "--exceptions ${INPUT_EXCEPTIONS}" || echo "") -controls_config=$([ -n "$INPUT_CONTROLSCONFIG" ] && echo "--controls-config ${INPUT_CONTROLSCONFIG}" || echo "") -account_opt=$([ -n "${INPUT_ACCOUNT}" ] && echo --account "${INPUT_ACCOUNT}" || echo "") -access_key_opt=$([ -n "${INPUT_ACCESSKEY}" ] && echo --access-key "${INPUT_ACCESSKEY}" || echo "") -server_opt=$([ -n "${INPUT_SERVER}" ] && echo --server "${INPUT_SERVER}" || echo "") -fail_threshold_opt=$([ -n "${INPUT_FAILEDTHRESHOLD}" ] && echo --fail-threshold "${INPUT_FAILEDTHRESHOLD}" || echo "") -compliance_threshold_opt=$([ -n "${INPUT_COMPLIANCETHRESHOLD}" ] && echo --compliance-threshold "${INPUT_COMPLIANCETHRESHOLD}" || echo "") - should_fix_files="false" if [ "${INPUT_FIXFILES}" = "true" ]; then should_fix_files="true" @@ -70,26 +104,32 @@ if [ "${INPUT_FIXFILES}" = "true" ]; then fi fi -severity_threshold_opt="" if [ -n "${INPUT_SEVERITYTHRESHOLD}" ] && [ "${should_fix_files}" = "false" ]; then - severity_threshold_opt="--severity-threshold ${INPUT_SEVERITYTHRESHOLD}" + scan_command+=(--severity-threshold "${INPUT_SEVERITYTHRESHOLD}") fi - -image_subcmd="" -if [ -n "${INPUT_IMAGE}" ]; then - image_arg="${INPUT_IMAGE}" - auth_opts="" - if [ -n "${INPUT_REGISTRYUSERNAME}" ] && [ -n "${INPUT_REGISTRYPASSWORD}" ]; then - auth_opts="--username=${INPUT_REGISTRYUSERNAME} --password=${INPUT_REGISTRYPASSWORD}" +for option in ACCOUNT ACCESSKEY SERVER FAILEDTHRESHOLD COMPLIANCETHRESHOLD EXCEPTIONS CONTROLSCONFIG; do + input="INPUT_${option}" + if [ -n "${!input}" ]; then + case "$option" in + ACCOUNT) flag=--account ;; + ACCESSKEY) flag=--access-key ;; + SERVER) flag=--server ;; + FAILEDTHRESHOLD) flag=--fail-threshold ;; + COMPLIANCETHRESHOLD) flag=--compliance-threshold ;; + EXCEPTIONS) flag=--exceptions ;; + CONTROLSCONFIG) flag=--controls-config ;; + esac + scan_command+=("$flag" "${!input}") fi - image_subcmd="image ${auth_opts}" - scan_input="${image_arg}" +done +scan_command+=(--format "${output_formats}" --output "${output_file}") +if [ -n "${INPUT_VERBOSE}" ] && [ "${INPUT_VERBOSE}" != "false" ]; then + scan_command+=(--verbose) fi -scan_command="kubescape scan ${image_subcmd} ${frameworks_cmd} ${controls_cmd} ${scan_input} ${account_opt} ${access_key_opt} ${server_opt} ${fail_threshold_opt} ${compliance_threshold_opt} ${severity_threshold_opt} --format ${output_formats} --output ${output_file} ${verbose} ${exceptions} ${controls_config}" - -echo "Running: ${scan_command}" -eval "${scan_command}" +# Do not log arguments containing account or registry credentials. +echo "Running Kubescape scan" +"${scan_command[@]}" # Post-processing for SARIF to ensure relative paths and remove results with empty URIs if contains "${output_formats}" "sarif"; then diff --git a/tests/action_test.sh b/tests/action_test.sh new file mode 100644 index 0000000..40ebf50 --- /dev/null +++ b/tests/action_test.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +set -u + +# Verify the executed runner scripts contain no input expression interpolation. +if sed -n '/^runs:/,$p' "$(dirname "$0")/../action.yml" | + sed '/^[[:space:]]*[A-Z_]*:.*\${{/d' | grep -q '\${{ inputs\.'; then + echo "Action inputs must enter scripts through env" >&2 + exit 1 +fi + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +test_root=$(mktemp -d) +trap 'rm -rf "${test_root}"' EXIT + +passed=0 +failed=0 + +pass() { + passed=$((passed + 1)) + printf 'ok - %s\n' "$1" +} + +fail() { + failed=$((failed + 1)) + printf 'not ok - %s\n' "$1" +} + +run_script="${test_root}/run.sh" +bin_dir="${test_root}/bin" +args_file="${test_root}/docker-args" +mkdir -p "${bin_dir}" + +# Extract the composite step that constructs the docker command. Replacing +# GitHub expressions with inert values lets this test execute that boundary +# without needing a GitHub runner or Docker daemon. +awk ' + /^ - name: Run Kubescape scan$/ { in_step=1; next } + in_step && /^ run: \|$/ { in_run=1; next } + in_run && /^ / { sub(/^ /, ""); print; next } + in_run { exit } +' "${repo_root}/action.yml" | + sed -E 's/\$\{\{[^}]+\}\}/test/g' > "${run_script}" + +cat > "${bin_dir}/docker" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" > "${DOCKER_ARGS_FILE}" +STUB +chmod +x "${bin_dir}/docker" + +# Exercise every action input with shell punctuation at the runner boundary. +input_value='"; touch PWNED; # $(touch PWNED)' +env_args=() +while read -r name expression; do + name="${name%:}" + if [[ "$name" == INPUT_* ]]; then + if [[ "$expression" != '${{ inputs.'*' }}' ]]; then + fail "$name is wired to an action input" + fi + env_args+=("$name=$input_value") + fi +done < <(awk ' + /^ - name: Run Kubescape scan$/ { in_step=1; next } + in_step && /^ env:$/ { in_env=1; next } + in_env && /^ / { print; next } + in_env { exit } +' "${repo_root}/action.yml") + +if [ "${#env_args[@]}" -eq 0 ]; then + fail "scan input environment was extracted" + exit 1 +fi + +if ( + cd "${test_root}" && + env PATH="${bin_dir}:${PATH}" \ + DOCKER_ARGS_FILE="${args_file}" \ + "${env_args[@]}" bash "${run_script}" +) && [ ! -e "${test_root}/PWNED" ]; then + for argument in "${env_args[@]}"; do + if grep -Fxq -- "$argument" "${args_file}"; then + pass "${argument%%=*} remains literal in the Docker command" + else + fail "${argument%%=*} remains literal in the Docker command" + fi + done +else + fail "composite action keeps inputs data-only" +fi + +# Version input must not introduce shell code or container tag syntax. +version_script="${test_root}/version.sh" +awk ' + /^ - id: resolve_version$/ { in_step=1; next } + in_step && /^ run: \|$/ { in_run=1; next } + in_run && /^ / { sub(/^ /, ""); print; next } + in_run { exit } +' "${repo_root}/action.yml" > "${version_script}" +for version in v4.0.13 v4.0.13-rc.1; do + if INPUT_VERSION="$version" GITHUB_OUTPUT="${test_root}/version-output" \ + bash -e "${version_script}" && + grep -Fxq -- "version=$version" "${test_root}/version-output"; then + pass "version $version resolves successfully" + else + fail "version $version resolves successfully" + fi +done +if ( + cd "${test_root}" && + INPUT_VERSION='"; touch PWNED; #' GITHUB_OUTPUT="${test_root}/version-output" \ + bash -e "${version_script}" >/dev/null 2>&1 +); then + fail "invalid version is rejected" +elif [ ! -e "${test_root}/PWNED" ]; then + pass "invalid version is rejected without executing shell syntax" +else + fail "invalid version executed shell syntax" +fi + +printf '%s passed, %s failed\n' "${passed}" "${failed}" +[ "${failed}" -eq 0 ] diff --git a/tests/entrypoint_test.sh b/tests/entrypoint_test.sh new file mode 100644 index 0000000..0386342 --- /dev/null +++ b/tests/entrypoint_test.sh @@ -0,0 +1,342 @@ +#!/bin/bash + +set -u + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +test_root=$(mktemp -d) +trap 'rm -rf "${test_root}"' EXIT + +passed=0 +failed=0 + +pass() { + passed=$((passed + 1)) + printf 'ok - %s\n' "$1" +} + +fail() { + failed=$((failed + 1)) + printf 'not ok - %s\n' "$1" +} + +new_case() { + case_root="${test_root}/$1" + workspace="${case_root}/workspace" + bin_dir="${case_root}/bin" + args_file="${case_root}/args" + output_file="${case_root}/output" + + mkdir -p "${workspace}/manifests" "${bin_dir}" + cat > "${bin_dir}/kubescape" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" > "${KUBESCAPE_ARGS_FILE}" +STUB + chmod +x "${bin_dir}/kubescape" + + unset INPUT_ACCESSKEY INPUT_ACCOUNT INPUT_ARTIFACTS INPUT_CONTROLSCONFIG + unset INPUT_EXCEPTIONS INPUT_IMAGE INPUT_SERVER INPUT_FILES + unset INPUT_FRAMEWORKS INPUT_CONTROLS INPUT_REGISTRYUSERNAME INPUT_REGISTRYPASSWORD +} + +run_entrypoint() { + ( + cd "${workspace}" || exit 1 + PATH="${bin_dir}:${PATH}" \ + KUBESCAPE_ARGS_FILE="${args_file}" \ + INPUT_ACCESSKEY="${INPUT_ACCESSKEY:-}" \ + INPUT_ACCOUNT="${INPUT_ACCOUNT:-}" \ + INPUT_ARTIFACTS="${INPUT_ARTIFACTS:-}" \ + INPUT_COMPLIANCETHRESHOLD="" \ + INPUT_CONTROLS="${INPUT_CONTROLS:-}" \ + INPUT_CONTROLSCONFIG="${INPUT_CONTROLSCONFIG:-}" \ + INPUT_EXCEPTIONS="${INPUT_EXCEPTIONS:-}" \ + INPUT_FAILEDTHRESHOLD="" \ + INPUT_FILES="${INPUT_FILES:-manifests}" \ + INPUT_FIXFILES="false" \ + INPUT_FORMAT="pretty-printer" \ + INPUT_FRAMEWORKS="${INPUT_FRAMEWORKS-nsa}" \ + INPUT_IMAGE="${INPUT_IMAGE:-}" \ + INPUT_OUTPUTFILE="results" \ + INPUT_REGISTRYPASSWORD="${INPUT_REGISTRYPASSWORD:-}" \ + INPUT_REGISTRYUSERNAME="${INPUT_REGISTRYUSERNAME:-}" \ + INPUT_SERVER="${INPUT_SERVER:-}" \ + INPUT_SEVERITYTHRESHOLD="" \ + INPUT_VERBOSE="false" \ + bash "${repo_root}/entrypoint.sh" + ) > "${output_file}" 2>&1 +} + +test_default_command_is_unchanged() { + new_case default + + if run_entrypoint && ! grep -Fq -- '--use-artifacts-from' "${args_file}"; then + pass "default command does not use local artifacts" + else + fail "default command does not use local artifacts" + fi +} + +test_artifacts_are_forwarded_once() { + new_case artifacts + mkdir -p "${workspace}/kubescape-artifacts" + INPUT_ARTIFACTS="kubescape-artifacts" + + if run_entrypoint && + [ "$(grep -Fxc -- '--use-artifacts-from' "${args_file}")" -eq 1 ] && + grep -Fxq -- "${workspace}/kubescape-artifacts" "${args_file}"; then + pass "artifact directory is forwarded exactly once" + else + fail "artifact directory is forwarded exactly once" + fi +} + +test_artifact_path_with_spaces_is_one_argument() { + new_case spaces + mkdir -p "${workspace}/artifacts with spaces" + INPUT_ARTIFACTS="artifacts with spaces" + + if run_entrypoint && + [ "$(grep -Fxc -- '--use-artifacts-from' "${args_file}")" -eq 1 ] && + [ "$(grep -Fxc -- "${workspace}/artifacts with spaces" "${args_file}")" -eq 1 ]; then + pass "artifact path with spaces remains one argument" + else + fail "artifact path with spaces remains one argument" + fi +} + +test_missing_artifact_directory_fails() { + new_case missing + INPUT_ARTIFACTS="missing-artifacts" + + if ! run_entrypoint && + grep -Fq -- "Artifacts directory 'missing-artifacts' does not exist" "${output_file}" && + [ ! -e "${args_file}" ]; then + pass "missing artifact directory fails before Kubescape" + else + fail "missing artifact directory fails before Kubescape" + fi +} + +test_absolute_artifact_path_fails() { + new_case absolute + mkdir -p "${workspace}/kubescape-artifacts" + INPUT_ARTIFACTS="${workspace}/kubescape-artifacts" + + if ! run_entrypoint && + grep -Fq -- "Artifacts path must be relative to the GitHub workspace" "${output_file}" && + [ ! -e "${args_file}" ]; then + pass "absolute artifact path is rejected" + else + fail "absolute artifact path is rejected" + fi +} + +test_artifact_symlink_escape_fails() { + new_case symlink + mkdir -p "${case_root}/outside" + ln -s "${case_root}/outside" "${workspace}/escaped-artifacts" + INPUT_ARTIFACTS="escaped-artifacts" + + if ! run_entrypoint && + grep -Fq -- "Artifacts directory must resolve inside the GitHub workspace" "${output_file}" && + [ ! -e "${args_file}" ]; then + pass "artifact symlink outside the workspace is rejected" + else + fail "artifact symlink outside the workspace is rejected" + fi +} + +test_relative_artifact_escape_fails() { + new_case relative_escape + mkdir -p "${case_root}/outside" + INPUT_ARTIFACTS="../outside" + + if ! run_entrypoint && + grep -Fq -- "Artifacts directory must resolve inside the GitHub workspace" "${output_file}" && + [ ! -e "${args_file}" ]; then + pass "relative artifact path outside the workspace is rejected" + else + fail "relative artifact path outside the workspace is rejected" + fi +} + +test_artifacts_cannot_be_used_for_image_scans() { + new_case image + mkdir -p "${workspace}/kubescape-artifacts" + INPUT_ARTIFACTS="kubescape-artifacts" + INPUT_IMAGE="nginx:latest" + + if ! run_entrypoint && + grep -Fq -- "Artifacts cannot be used with image scans" "${output_file}" && + [ ! -e "${args_file}" ]; then + pass "artifacts are rejected for image scans" + else + fail "artifacts are rejected for image scans" + fi +} + +test_exceptions_are_forwarded_with_artifacts() { + new_case exceptions + mkdir -p "${workspace}/kubescape-artifacts" + INPUT_ARTIFACTS="kubescape-artifacts" + INPUT_EXCEPTIONS="custom-exceptions.json" + + if run_entrypoint && + grep -Fxq -- '--exceptions' "${args_file}" && + grep -Fxq -- 'custom-exceptions.json' "${args_file}" && + grep -Fxq -- '--use-artifacts-from' "${args_file}"; then + pass "explicit exceptions are forwarded with artifacts" + else + fail "explicit exceptions are forwarded with artifacts" + fi +} + +test_controls_config_is_forwarded_with_artifacts() { + new_case controls_config + mkdir -p "${workspace}/kubescape-artifacts" + INPUT_ARTIFACTS="kubescape-artifacts" + INPUT_CONTROLSCONFIG="custom-controls.json" + + if run_entrypoint && + grep -Fxq -- '--controls-config' "${args_file}" && + grep -Fxq -- 'custom-controls.json' "${args_file}" && + grep -Fxq -- '--use-artifacts-from' "${args_file}"; then + pass "explicit controls config is forwarded with artifacts" + else + fail "explicit controls config is forwarded with artifacts" + fi +} + +test_account_credentials_are_forwarded_with_artifacts() { + new_case account + mkdir -p "${workspace}/kubescape-artifacts" + INPUT_ARTIFACTS="kubescape-artifacts" + INPUT_ACCOUNT="account-id" + INPUT_ACCESSKEY="access-key" + INPUT_SERVER="https://example.invalid" + + if run_entrypoint && + grep -Fxq -- '--account' "${args_file}" && + grep -Fxq -- 'account-id' "${args_file}" && + grep -Fxq -- '--access-key' "${args_file}" && + grep -Fxq -- 'access-key' "${args_file}" && + grep -Fxq -- '--server' "${args_file}" && + grep -Fxq -- 'https://example.invalid' "${args_file}" && + grep -Fxq -- '--use-artifacts-from' "${args_file}"; then + pass "account credentials are forwarded with artifacts" + else + fail "account credentials are forwarded with artifacts" + fi +} + +test_artifact_path_cannot_inject_commands() { + new_case injection + mkdir -p "${workspace}/artifacts;touch PWNED" + INPUT_ARTIFACTS="artifacts;touch PWNED" + + if run_entrypoint && + grep -Fxq -- "${workspace}/artifacts;touch PWNED" "${args_file}" && + [ ! -e "${workspace}/PWNED" ]; then + pass "artifact path cannot inject a command" + else + fail "artifact path cannot inject a command" + fi +} + +# Shell punctuation and substitutions must remain literal arguments. +test_files_cannot_inject_commands() { + new_case files_injection + INPUT_FILES='evil\"; touch PWNED; #.yaml $(touch PWNED)' + if run_entrypoint && [ ! -e "${workspace}/PWNED" ] && + grep -Fxq -- 'evil\";' "${args_file}" && + grep -Fxq -- '$(touch' "${args_file}"; then + pass "file input cannot execute shell syntax" + else + fail "file input cannot execute shell syntax" + fi +} + +# Preserve ordinary multi-file scopes and comma-separated control names. +test_scan_scopes() { + new_case scopes + INPUT_FILES='manifests/one.yaml manifests/two.yaml' + INPUT_FRAMEWORKS='nsa mitre' + if run_entrypoint && grep -Fxq -- 'nsa' "${args_file}" && + grep -Fxq -- 'mitre' "${args_file}" && + grep -Fxq -- 'manifests/one.yaml' "${args_file}" && + grep -Fxq -- 'manifests/two.yaml' "${args_file}"; then + pass "frameworks and file lists retain their arguments" + else + fail "frameworks and file lists retain their arguments" + fi + INPUT_FRAMEWORKS='' + INPUT_CONTROLS='Control one,Control two' + if run_entrypoint && grep -Fxq -- 'control' "${args_file}" && + grep -Fxq -- "$INPUT_CONTROLS" "${args_file}"; then + pass "control names remain one comma-separated argument" + else + fail "control names remain one comma-separated argument" + fi +} + +# Credentials must stay literal and must not be printed in the scan log. +test_credentials_cannot_inject_commands() { + new_case credential_injection + INPUT_IMAGE='nginx:latest' + INPUT_REGISTRYUSERNAME='user name' + INPUT_REGISTRYPASSWORD='$(touch PWNED); secret' + INPUT_ACCESSKEY='$(touch PWNED); key' + if run_entrypoint && [ ! -e "${workspace}/PWNED" ] && + grep -Fxq -- "--password=$INPUT_REGISTRYPASSWORD" "${args_file}" && + grep -Fxq -- "$INPUT_ACCESSKEY" "${args_file}" && + ! grep -Fq -- "$INPUT_REGISTRYPASSWORD" "${output_file}" && + ! grep -Fq -- "$INPUT_ACCESSKEY" "${output_file}"; then + pass "image credentials and access keys remain literal and private" + else + fail "image credentials and access keys remain literal and private" + fi +} + +# Patterns must match files while treating matched filenames as literal data. +test_globs_and_multiline_scopes() { + new_case glob + evil_name='evil"; touch PWNED; #.yaml' + touch "${workspace}/manifests/${evil_name}" "${workspace}/manifests/normal.yaml" + INPUT_FILES='manifests/*.yaml' + INPUT_FRAMEWORKS=$'nsa\nmitre' + if run_entrypoint && [ ! -e "${workspace}/PWNED" ] && + grep -Fxq -- "manifests/${evil_name}" "${args_file}" && + grep -Fxq -- 'manifests/normal.yaml' "${args_file}" && + grep -Fxq -- 'mitre' "${args_file}"; then + pass "glob matches and multiline frameworks remain literal arguments" + else + fail "glob matches and multiline frameworks remain literal arguments" + fi + mkdir -p "${workspace}/manifests with spaces" + INPUT_FILES='manifests with spaces' + if run_entrypoint && grep -Fxq -- "$INPUT_FILES" "${args_file}"; then + pass "a single existing file path can contain spaces" + else + fail "a single existing file path can contain spaces" + fi +} + +test_globs_and_multiline_scopes +test_scan_scopes +test_credentials_cannot_inject_commands +test_files_cannot_inject_commands +test_default_command_is_unchanged +test_artifacts_are_forwarded_once +test_artifact_path_with_spaces_is_one_argument +test_missing_artifact_directory_fails +test_absolute_artifact_path_fails +test_artifact_symlink_escape_fails +test_relative_artifact_escape_fails +test_artifacts_cannot_be_used_for_image_scans +test_exceptions_are_forwarded_with_artifacts +test_controls_config_is_forwarded_with_artifacts +test_account_credentials_are_forwarded_with_artifacts +test_artifact_path_cannot_inject_commands + +printf '%s passed, %s failed\n' "${passed}" "${failed}" +[ "${failed}" -eq 0 ]