diff --git a/.github/workflows/Trivy Scan.yml b/.github/workflows/Trivy Scan.yml index df0a25200..138d25c2b 100644 --- a/.github/workflows/Trivy Scan.yml +++ b/.github/workflows/Trivy Scan.yml @@ -4,6 +4,10 @@ on: pull_request: workflow_call: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -19,7 +23,9 @@ jobs: - name: Build the project run: go build -o ./cx ./cmd - name: Build Docker image - run: docker build -t ast-cli:${{ github.sha }} . + env: + IMAGE_TAG: ast-cli:${{ github.sha }} + run: docker build -t "$IMAGE_TAG" . - name: Run Trivy scanner without downloading DBs uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 #v0.35.0 with: diff --git a/.github/workflows/checkmarx-one-scan.yml b/.github/workflows/checkmarx-one-scan.yml index 238d51295..669819e8e 100644 --- a/.github/workflows/checkmarx-one-scan.yml +++ b/.github/workflows/checkmarx-one-scan.yml @@ -12,6 +12,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: cx-scan: name: Checkmarx One Scan diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index ac76bbd42..19b441f9f 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -4,6 +4,10 @@ on: pull_request: workflow_call: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -255,8 +259,10 @@ jobs: - name: Install pre-commit if: matrix.needs_precommit == 'true' + env: + ECHO_LIBRARIES_ACCESS_KEY: ${{ secrets.ECHO_LIBRARIES_ACCESS_KEY }} run: | - pip config set global.index-url https://:${{ secrets.ECHO_LIBRARIES_ACCESS_KEY }}@pypi.echohq.com/simple + pip config set global.index-url "https://:${ECHO_LIBRARIES_ACCESS_KEY}@pypi.echohq.com/simple" pip index versions pre-commit pip install pre-commit==4.6.0 @@ -282,17 +288,23 @@ jobs: - name: Run integration tests (${{ matrix.label }}) if: matrix.name != 'uncovered' || needs.validate-test-coverage.outputs.has_uncovered == 'true' + env: + MATRIX_NAME: ${{ matrix.name }} + MATRIX_LABEL: ${{ matrix.label }} + MATRIX_RUN_PATTERN: ${{ matrix.run_pattern }} + MATRIX_TIMEOUT: ${{ matrix.timeout }} + UNCOVERED_TESTS: ${{ needs.validate-test-coverage.outputs.uncovered_tests }} run: | set -euo pipefail # Resolve the -run pattern: catch-all uses Job A output; named groups use matrix field. - if [ "${{ matrix.name }}" = "uncovered" ]; then - RUN_PATTERN="${{ needs.validate-test-coverage.outputs.uncovered_tests }}" + if [ "$MATRIX_NAME" = "uncovered" ]; then + RUN_PATTERN="$UNCOVERED_TESTS" else - RUN_PATTERN="${{ matrix.run_pattern }}" + RUN_PATTERN="$MATRIX_RUN_PATTERN" fi - COVER_FILE="cover-${{ matrix.name }}.out" + COVER_FILE="cover-${MATRIX_NAME}.out" run_tests() { local pattern="$1" outfile="$2" logfile="$3" timeout_val="$4" @@ -300,23 +312,23 @@ jobs: -tags integration \ -v \ -timeout "${timeout_val}" \ - -coverpkg "${{ env.GO_COVERAGE_PKGS }}" \ + -coverpkg "$GO_COVERAGE_PKGS" \ -coverprofile "${outfile}" \ -run "${pattern}" \ github.com/checkmarx/ast-cli/test/integration 2>&1 | tee "${logfile}" || true } - echo "::group::Attempt 1 — ${{ matrix.label }}" - run_tests "$RUN_PATTERN" "$COVER_FILE" "test_output.log" "${{ matrix.timeout }}" + echo "::group::Attempt 1 — ${MATRIX_LABEL}" + run_tests "$RUN_PATTERN" "$COVER_FILE" "test_output.log" "$MATRIX_TIMEOUT" echo "::endgroup::" FAILED=$(grep -E "^--- FAIL: " test_output.log | awk '{print $3}' | paste -sd '|' - || true) # ── Retry 1 ────────────────────────────────────────────────────────── if [ -n "$FAILED" ]; then - echo "::warning::Retry 1 for ${{ matrix.label }}: $FAILED" - COVER_R1="cover-${{ matrix.name }}-r1.out" - echo "::group::Attempt 2 — ${{ matrix.label }}" + echo "::warning::Retry 1 for ${MATRIX_LABEL}: $FAILED" + COVER_R1="cover-${MATRIX_NAME}-r1.out" + echo "::group::Attempt 2 — ${MATRIX_LABEL}" run_tests "$FAILED" "$COVER_R1" "retry1_output.log" "30m" echo "::endgroup::" @@ -330,9 +342,9 @@ jobs: # ── Retry 2 ──────────────────────────────────────────────────────── if [ -n "$FAILED2" ]; then - echo "::warning::Retry 2 for ${{ matrix.label }}: $FAILED2" - COVER_R2="cover-${{ matrix.name }}-r2.out" - echo "::group::Attempt 3 — ${{ matrix.label }}" + echo "::warning::Retry 2 for ${MATRIX_LABEL}: $FAILED2" + COVER_R2="cover-${MATRIX_NAME}-r2.out" + echo "::group::Attempt 3 — ${MATRIX_LABEL}" run_tests "$FAILED2" "$COVER_R2" "retry2_output.log" "30m" echo "::endgroup::" @@ -344,13 +356,13 @@ jobs: FINAL_FAILED=$(grep -E "^--- FAIL: " retry2_output.log | awk '{print $3}' || true) if [ -n "$FINAL_FAILED" ]; then - echo "::error::Tests still failing after 2 retries in ${{ matrix.label }}: $FINAL_FAILED" + echo "::error::Tests still failing after 2 retries in ${MATRIX_LABEL}: $FINAL_FAILED" exit 1 fi fi fi - echo "All ${{ matrix.label }} tests passed." + echo "All ${MATRIX_LABEL} tests passed." - name: Upload coverage artifact if: always() && (matrix.name != 'uncovered' || needs.validate-test-coverage.outputs.has_uncovered == 'true') @@ -392,68 +404,68 @@ jobs: echo "failed_list=" >> "$GITHUB_OUTPUT" fi - - name: Send failure notification to Teams - if: always() && steps.failed_tests.outputs.has_failures == 'true' - uses: Skitionek/notify-microsoft-teams@9c67757f64d610fb6748d8ff3c11f284355ed7ec #v1.0.8 - with: - webhook_url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_INTEGRATION_TESTS }} - raw: > - { - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "msteams": { - "width": "Full" - }, - "body": [ - { - "type": "TextBlock", - "text": "Integration Tests Failed — ${{ matrix.label }}", - "weight": "Bolder", - "size": "Large", - "color": "Attention" - }, - { - "type": "FactSet", - "facts": [ - { "title": "Repository:", "value": "${{ github.repository }}" }, - { "title": "Author:", "value": "${{ github.actor }}" }, - { "title": "Branch:", "value": "${{ github.ref_name }}" }, - { "title": "Run ID:", "value": "${{ github.run_id }}" }, - { "title": "Group:", "value": "${{ matrix.label }} (${{ matrix.name }})" }, - { "title": "Failed Tests:", "value": "${{ steps.failed_tests.outputs.fail_count }}" } - ] - }, - { - "type": "TextBlock", - "text": "**Failed Test Cases:**", - "weight": "Bolder", - "spacing": "Medium" - }, - { - "type": "TextBlock", - "text": "${{ steps.failed_tests.outputs.failed_list }}", - "wrap": true, - "fontType": "Monospace", - "spacing": "Small" - } - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "View Workflow Run", - "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - } - ] - } - } - ] - } + # - name: Send failure notification to Teams + # if: always() && steps.failed_tests.outputs.has_failures == 'true' + # uses: Skitionek/notify-microsoft-teams@9c67757f64d610fb6748d8ff3c11f284355ed7ec #v1.0.8 + # with: + # webhook_url: ${{ secrets.MS_TEAMS_WEBHOOK_URL_INTEGRATION_TESTS }} + # raw: > + # { + # "type": "message", + # "attachments": [ + # { + # "contentType": "application/vnd.microsoft.card.adaptive", + # "content": { + # "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + # "type": "AdaptiveCard", + # "version": "1.4", + # "msteams": { + # "width": "Full" + # }, + # "body": [ + # { + # "type": "TextBlock", + # "text": "Integration Tests Failed — ${{ matrix.label }}", + # "weight": "Bolder", + # "size": "Large", + # "color": "Attention" + # }, + # { + # "type": "FactSet", + # "facts": [ + # { "title": "Repository:", "value": "${{ github.repository }}" }, + # { "title": "Author:", "value": "${{ github.actor }}" }, + # { "title": "Branch:", "value": "${{ github.ref_name }}" }, + # { "title": "Run ID:", "value": "${{ github.run_id }}" }, + # { "title": "Group:", "value": "${{ matrix.label }} (${{ matrix.name }})" }, + # { "title": "Failed Tests:", "value": "${{ steps.failed_tests.outputs.fail_count }}" } + # ] + # }, + # { + # "type": "TextBlock", + # "text": "**Failed Test Cases:**", + # "weight": "Bolder", + # "spacing": "Medium" + # }, + # { + # "type": "TextBlock", + # "text": "${{ steps.failed_tests.outputs.failed_list }}", + # "wrap": true, + # "fontType": "Monospace", + # "spacing": "Small" + # } + # ], + # "actions": [ + # { + # "type": "Action.OpenUrl", + # "title": "View Workflow Run", + # "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + # } + # ] + # } + # } + # ] + # } - name: Stop Squid proxy if: always() @@ -537,38 +549,38 @@ jobs: # Job D: Write a GitHub Actions Job Summary when any job in the chain fails. # No external service required — everything goes to GITHUB_STEP_SUMMARY. # ───────────────────────────────────────────────────────────────────────────── - notify-on-failure: - name: Notify on Failure - needs: [integration-tests, merge-coverage] - runs-on: cx-public-ubuntu-x64 - if: failure() - steps: - - name: Write failure summary - env: - INTEGRATION_RESULT: ${{ toJson(needs.integration-tests) }} - MERGE_RESULT: ${{ toJson(needs.merge-coverage) }} - run: | - cat >> "$GITHUB_STEP_SUMMARY" << 'SUMMARY' - ## AST-CLI Integration Tests — FAILED - - | Field | Value | - |-------|-------| - | Run | ${{ github.run_id }} | - | Triggered by | ${{ github.event_name }} | - | Branch | ${{ github.ref_name }} | - | Commit | ${{ github.sha }} | - SUMMARY - - printf '\n### integration-tests result\n```json\n%s\n```\n' "$INTEGRATION_RESULT" \ - >> "$GITHUB_STEP_SUMMARY" - printf '\n### merge-coverage result\n```json\n%s\n```\n' "$MERGE_RESULT" \ - >> "$GITHUB_STEP_SUMMARY" - - cat >> "$GITHUB_STEP_SUMMARY" << 'SUMMARY' - - ### Next Steps - 1. Click each failed matrix group in the job list above to inspect its logs. - 2. Download the `test-logs-` artifact for the full `go test` output. - 3. Retry a specific group manually via **Run workflow** (`workflow_dispatch`). - 4. If the failure is consistent, open an issue referencing this run. - SUMMARY + # notify-on-failure: + # name: Notify on Failure + # needs: [integration-tests, merge-coverage] + # runs-on: cx-public-ubuntu-x64 + # if: failure() + # steps: + # - name: Write failure summary + # env: + # INTEGRATION_RESULT: ${{ toJson(needs.integration-tests) }} + # MERGE_RESULT: ${{ toJson(needs.merge-coverage) }} + # run: | + # cat >> "$GITHUB_STEP_SUMMARY" << 'SUMMARY' + # ## AST-CLI Integration Tests — FAILED + + # | Field | Value | + # |-------|-------| + # | Run | ${{ github.run_id }} | + # | Triggered by | ${{ github.event_name }} | + # | Branch | ${{ github.ref_name }} | + # | Commit | ${{ github.sha }} | + # SUMMARY + + # printf '\n### integration-tests result\n```json\n%s\n```\n' "$INTEGRATION_RESULT" \ + # >> "$GITHUB_STEP_SUMMARY" + # printf '\n### merge-coverage result\n```json\n%s\n```\n' "$MERGE_RESULT" \ + # >> "$GITHUB_STEP_SUMMARY" + + # cat >> "$GITHUB_STEP_SUMMARY" << 'SUMMARY' + + # ### Next Steps + # 1. Click each failed matrix group in the job list above to inspect its logs. + # 2. Download the `test-logs-` artifact for the full `go test` output. + # 3. Retry a specific group manually via **Run workflow** (`workflow_dispatch`). + # 4. If the failure is consistent, open an issue referencing this run. + # SUMMARY diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 9ff4e3074..4a71a26fc 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -3,13 +3,16 @@ name: Govulncheck on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: - govulncheck: + govulncheck: # zizmor: ignore[anonymous-definition] runs-on: cx-public-ubuntu-x64 - name: Vulnerability Scan (govulncheck) steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 135121fbd..6d75923d6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,10 @@ name: Lint on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.github/workflows/nightly-parallel.yml b/.github/workflows/nightly-parallel.yml index f5fe32ce7..77bc9eea9 100644 --- a/.github/workflows/nightly-parallel.yml +++ b/.github/workflows/nightly-parallel.yml @@ -17,7 +17,7 @@ permissions: contents: read jobs: - integration-tests: - name: Run Full Integration Test Suite + integration-tests: # zizmor: ignore[anonymous-definition] uses: ./.github/workflows/ci-tests.yml - secrets: inherit + # ci-tests.yml requires ~20 CX/SCM secrets; it's a same-repo reusable workflow, not a fork/third-party target. + secrets: inherit # zizmor: ignore[secrets-inherit] diff --git a/.github/workflows/pr-linter.yml b/.github/workflows/pr-linter.yml index 003974db9..a1cb5b6b9 100644 --- a/.github/workflows/pr-linter.yml +++ b/.github/workflows/pr-linter.yml @@ -4,6 +4,10 @@ on: pull_request: types: [opened, edited] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -13,9 +17,10 @@ jobs: runs-on: cx-public-ubuntu-x64 steps: - name: Check PR Title and Branch + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BRANCH: ${{ github.head_ref }} run: | - PR_TITLE="${{ github.event.pull_request.title }}" - PR_BRANCH="${{ github.head_ref }}" if ! [[ "$PR_TITLE" =~ ^[A-Z][a-zA-Z0-9]* ]]; then echo "::error::PR title must be in CamelCase. Please update the title." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 608341293..0d512844c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,14 +61,19 @@ on: default: true type: boolean -permissions: - id-token: write - contents: write +concurrency: + group: ${{ github.workflow }}-${{ inputs.tag }} + cancel-in-progress: false + +permissions: {} jobs: build: name: Build, Sign & Publish Release runs-on: cx-public-macos-15-x64 + permissions: + id-token: write # required for AWS OIDC federation via configure-aws-credentials (HSM signing) + contents: write # required for `gh release create`/`gh release delete` to manage GitHub Releases env: AC_PASSWORD: ${{ secrets.AC_PASSWORD }} AC_USER: ${{ secrets.AC_USER }} @@ -141,24 +146,30 @@ jobs: role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} aws-region: ${{ secrets.AWS_ASSUME_ROLE_REGION }} - name: Tag + env: + TAG: ${{ inputs.tag }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} run: | - echo ${{ inputs.tag }} - echo "NEXT_VERSION=${{ inputs.tag }}" >> $GITHUB_ENV - tag=${{ inputs.tag }} - message='${{ inputs.tag }}: PR #${{ github.event.pull_request.number }} ${{ github.event.pull_request.title }}' + echo "$TAG" + echo "NEXT_VERSION=$TAG" >> "$GITHUB_ENV" + tag="$TAG" + message="${TAG}: PR #${PR_NUMBER} ${PR_TITLE}" git config user.name "${GITHUB_ACTOR}" git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" git tag -a "${tag}" -m "${message}" # tag stays local — pushed at the end of the job, after the release is fully built - name: Build GoReleaser Args + env: + DEV: ${{ inputs.dev }} run: | args='release --clean --debug --timeout 90m' - if [ ${{ inputs.dev }} = true ]; then + if [ "$DEV" = true ]; then args=${args}' --config=".goreleaser-dev.yml"' fi - echo "GR_ARGS=${args}" >> $GITHUB_ENV + echo "GR_ARGS=${args}" >> "$GITHUB_ENV" - name: Echo GoReleaser Args - run: echo ${{ env.GR_ARGS }} + run: echo "$GR_ARGS" - name: Run GoReleaser uses: step-security/goreleaser-action@1472c46ac6e641f2b929f1a893354288b3a6b6b6 # v7.2.1 with: @@ -175,8 +186,10 @@ jobs: SIGNING_HSM_CREDS: ${{ secrets.SIGNING_HSM_CREDS }} - name: Sign Docker Image with Cosign if: inputs.dev == false + env: + TAG: ${{ inputs.tag }} run: | - cosign sign --yes --key env://COSIGN_PRIVATE_KEY checkmarx/ast-cli:${{ inputs.tag }} + cosign sign --yes --key env://COSIGN_PRIVATE_KEY "checkmarx/ast-cli:${TAG}" #- name: Verify Docker image signature # if: inputs.dev == false @@ -189,20 +202,23 @@ jobs: - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} + RELEASE_SHA: ${{ github.sha }} + DEV: ${{ inputs.dev }} run: | set -euo pipefail shopt -s failglob common=( - "${{ inputs.tag }}" + "$TAG" dist/*.tar.gz dist/*.zip dist/*checksums* - --target "${{ github.sha }}" - --title "Checkmarx One CLI ${{ inputs.tag }}" + --target "$RELEASE_SHA" + --title "Checkmarx One CLI ${TAG}" --generate-notes --draft ) - if [ "${{ inputs.dev }}" = "true" ]; then + if [ "$DEV" = "true" ]; then gh release create "${common[@]}" --prerelease else gh release create "${common[@]}" @@ -212,7 +228,8 @@ jobs: if: failure() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release delete "${{ inputs.tag }}" --cleanup-tag --yes || true + TAG: ${{ inputs.tag }} + run: gh release delete "$TAG" --cleanup-tag --yes || true #notify: # name: Update Teams & JIRA About New Release @@ -228,11 +245,14 @@ jobs: # jira_product_name: ASTCLI # secrets: inherit - dispatch_auto_release: - name: Update Plugins With new Cli Version - if: inputs.dev == false && 1 == 0 - #needs: notify - uses: Checkmarx/plugins-release-workflow/.github/workflows/dispatch-workflow.yml@main - with: - cli_version: ${{ inputs.tag }} - secrets: inherit + # dispatch_auto_release: + # name: Update Plugins With new Cli Version + # if: inputs.dev == false && 1 == 0 + # #needs: notify + # permissions: + # contents: read # dispatch-workflow.yml itself declares `permissions: contents: read` + # uses: Checkmarx/plugins-release-workflow/.github/workflows/dispatch-workflow.yml@d0ad5e1c4cf1edacf4d91c250c2b7a4a5478baab # main + # with: + # cli_version: ${{ inputs.tag }} + # # dispatch-workflow.yml is a trusted first-party Checkmarx org workflow, not a fork/third-party target. + # secrets: inherit # zizmor: ignore[secrets-inherit] diff --git a/.github/workflows/scan-github-action.yml b/.github/workflows/scan-github-action.yml index 578b35be7..3330f7ed3 100644 --- a/.github/workflows/scan-github-action.yml +++ b/.github/workflows/scan-github-action.yml @@ -12,7 +12,7 @@ permissions: {} jobs: zizmor: name: Scan repository contents - runs-on: cx-private-ubuntu-x64 + runs-on: cx-public-ubuntu-x64 permissions: contents: read steps: diff --git a/.github/workflows/trivy-cache.yml b/.github/workflows/trivy-cache.yml index 80b6e80fe..734028f53 100644 --- a/.github/workflows/trivy-cache.yml +++ b/.github/workflows/trivy-cache.yml @@ -7,6 +7,13 @@ on: - cron: '0 0 * * *' # Run daily at midnight UTC workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + jobs: update-trivy-db: name: Update Trivy Vulnerability DB Cache diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 37a81bb46..915c07be9 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -15,8 +15,7 @@ env: MIN_COVERAGE: "85" jobs: - unit-tests: - name: Unit Tests + unit-tests: # zizmor: ignore[anonymous-definition] runs-on: cx-public-ubuntu-x64 steps: - name: Checkout the repository @@ -58,12 +57,15 @@ jobs: - name: Publish job summary if: always() shell: bash + env: + COVERAGE: ${{ steps.coverage.outputs.coverage }} + TEST_EXIT_CODE: ${{ steps.run_tests.outputs.test_exit_code }} run: | python3 ./internal/commands/.scripts/print_test_summary.py \ junit.xml \ - "${{ steps.coverage.outputs.coverage }}" \ - "${{ env.MIN_COVERAGE }}" \ - "${{ steps.run_tests.outputs.test_exit_code }}" \ + "$COVERAGE" \ + "$MIN_COVERAGE" \ + "$TEST_EXIT_CODE" \ >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage report @@ -87,16 +89,18 @@ jobs: - name: Enforce test and coverage gate if: always() shell: bash + env: + TEST_EXIT_CODE: ${{ steps.run_tests.outputs.test_exit_code }} + CODE_COV: ${{ steps.coverage.outputs.coverage }} run: | FAILED=0 - if [ "${{ steps.run_tests.outputs.test_exit_code }}" != "0" ]; then + if [ "$TEST_EXIT_CODE" != "0" ]; then echo "::error::Unit tests failed. See the job summary above for the failing test cases." FAILED=1 fi - CODE_COV="${{ steps.coverage.outputs.coverage }}" - EXPECTED_CODE_COV="${{ env.MIN_COVERAGE }}" + EXPECTED_CODE_COV="$MIN_COVERAGE" if awk -v cov="$CODE_COV" -v min="$EXPECTED_CODE_COV" 'BEGIN{ exit !(cov < min) }'; then echo "::error::Code coverage is too low. Coverage percentage is: ${CODE_COV}% (required >= ${EXPECTED_CODE_COV}%)" FAILED=1 diff --git a/cmd/main.go b/cmd/main.go index 0e2d1dc03..aae6c2c2c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,7 +30,6 @@ func main() { bindKeysToEnvAndDefault() err = configuration.LoadConfiguration() exitIfError(err) - wrappers.LoadActiveCredential() scans := viper.GetString(params.ScansPathKey) groups := viper.GetString(params.GroupsPathKey) logs := viper.GetString(params.LogsPathKey) diff --git a/go.mod b/go.mod index 2b257eccd..8e20c14ac 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkmarx/ast-cli go 1.26.5 require ( - github.com/Checkmarx/ast-cx-hooks v1.0.4 + github.com/Checkmarx/ast-cx-hooks v1.0.5 github.com/Checkmarx/containers-resolver v1.0.34 github.com/Checkmarx/containers-types v1.0.9 github.com/Checkmarx/gen-ai-prompts v0.0.0-20240807143411-708ceec12b63 @@ -32,7 +32,7 @@ require ( golang.org/x/crypto v0.53.0 golang.org/x/sync v0.21.0 golang.org/x/text v0.39.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 gotest.tools v2.2.0+incompatible @@ -301,7 +301,7 @@ require ( golang.org/x/tools v0.47.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 9e8514478..63662e935 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Checkmarx/ast-cx-hooks v1.0.4 h1:rxzUea+wT4kxGbwzlgSJmVswod+HuWOALgxQtjY6P0c= -github.com/Checkmarx/ast-cx-hooks v1.0.4/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= +github.com/Checkmarx/ast-cx-hooks v1.0.5 h1:4Og5JeBBg3SynAErAP76oGKrjoWrlduWRgg1V9IXjWo= +github.com/Checkmarx/ast-cx-hooks v1.0.5/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= github.com/Checkmarx/containers-images-extractor v1.0.22 h1:kJZgwk28LwJZ7Xky+kzwL+JSZOlpwrGsZQhhz4L2t6s= github.com/Checkmarx/containers-images-extractor v1.0.22/go.mod h1:HyzVb8TtTDf56hGlSakalPXtzjJ6VhTYe9fmAcOS+V8= github.com/Checkmarx/containers-resolver v1.0.34 h1:KULN8s8xb1tQtdH4yzHVdwN8GyLqtPCAkFWra10k7V0= @@ -1504,10 +1504,10 @@ google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1535,8 +1535,8 @@ google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/internal/commands/agenthooks/agentprofile/agentprofile.go b/internal/commands/agenthooks/agentprofile/agentprofile.go new file mode 100644 index 000000000..edfd3c294 --- /dev/null +++ b/internal/commands/agenthooks/agentprofile/agentprofile.go @@ -0,0 +1,35 @@ +// Package agentprofile centralizes the small agent-specific fragments used in Checkmarx remediation +// guidance. The remediation message itself is the same for every CLI agent this cx binary gates +// (Claude Code, Copilot, Cursor, Gemini, …); only the "how to reconnect the MCP" hint differs, since +// each client re-registers the MCP its own way. No agent is ever told to run a registration script. +package agentprofile + +// McpReconnect returns the agent-specific "how" fragment for reconnecting the Checkmarx MCP after it +// becomes unavailable. Callers embed it inside +// +// reconnect the Checkmarx MCP (), then retry +// +// so each fragment is a bare method phrase that must NOT repeat the subject ("the Checkmarx MCP") — +// otherwise the sentence reads "reconnect the Checkmarx MCP (reconnect the Checkmarx MCP …)". CLI +// clients that expose a live MCP command lead with it (e.g. /mcp) and keep a restart fallback; none +// needs a registration script. +func McpReconnect(agent string) string { + switch agent { + case "Claude": + return "run /mcp to reconnect it; if it isn't listed, run /reload-plugins first, then /mcp again, or restart Claude Code" + case "Copilot": + return "restart the Copilot CLI, or reconnect it via /mcp" + case "Cursor": + return "run /mcp to reconnect it, or restart cursor-agent" + case "Codex": + return "restart Codex so it reloads MCP servers from ~/.codex/config.toml" + case "Gemini": + return "restart the Gemini CLI" + case "Windsurf": + return "via Windsurf's MCP settings, or restart Windsurf" + case "Droid": + return "restart Droid" + default: + return "via your client's MCP settings, or restart the client" + } +} diff --git a/internal/commands/agenthooks/agentprofile/agentprofile_test.go b/internal/commands/agenthooks/agentprofile/agentprofile_test.go new file mode 100644 index 000000000..6144d673f --- /dev/null +++ b/internal/commands/agenthooks/agentprofile/agentprofile_test.go @@ -0,0 +1,54 @@ +package agentprofile + +import ( + "strings" + "testing" +) + +func TestMcpReconnect_PerAgentPhrase(t *testing.T) { + // Each recognized agent's fragment must contain a distinctive, correct token. + cases := map[string]string{ + "Claude": "restart Claude Code", + "Copilot": "Copilot CLI", + "Cursor": "cursor-agent", + "Codex": "Codex", + "Gemini": "Gemini", + "Windsurf": "Windsurf", + "Droid": "Droid", + } + for agent, want := range cases { + if got := McpReconnect(agent); !strings.Contains(got, want) { + t.Errorf("McpReconnect(%q) = %q, want it to contain %q", agent, got, want) + } + } + + // An unrecognized agent gets a neutral, client-agnostic phrase. + if got := McpReconnect("Aider"); !strings.Contains(got, "your client") { + t.Errorf("McpReconnect(unknown) = %q, want a neutral phrase", got) + } + + // The Claude hint must expose the full recovery path: the /mcp reconnect, the /reload-plugins + // fallback when the server isn't listed, and a restart as last resort. + claude := McpReconnect("Claude") + for _, want := range []string{"/mcp", "/reload-plugins", "restart Claude Code"} { + if !strings.Contains(claude, want) { + t.Errorf("McpReconnect(%q) = %q, want it to contain %q", "Claude", claude, want) + } + } + + // No variant may reference the removed registration script or plugin-root env. + for _, agent := range []string{"Claude", "Copilot", "Cursor", "Codex"} { + got := McpReconnect(agent) + if strings.Contains(got, "cx_mcp_register") || strings.Contains(got, "CLAUDE_PLUGIN_ROOT") { + t.Errorf("McpReconnect(%q) = %q must not mention the removed script/env", agent, got) + } + } + + // Fragments embed as "reconnect the Checkmarx MCP ()", so no fragment may repeat the + // subject "the Checkmarx MCP" (which would read "... (reconnect the Checkmarx MCP …)"). + for _, agent := range []string{"Claude", "Copilot", "Cursor", "Codex", "Gemini", "Windsurf", "Droid", "Aider"} { + if got := McpReconnect(agent); strings.Contains(strings.ToLower(got), "the checkmarx mcp") { + t.Errorf("McpReconnect(%q) = %q must not repeat the subject 'the Checkmarx MCP'", agent, got) + } + } +} diff --git a/internal/commands/agenthooks/commands.lnk b/internal/commands/agenthooks/commands.lnk new file mode 100644 index 000000000..e5cc3f475 Binary files /dev/null and b/internal/commands/agenthooks/commands.lnk differ diff --git a/internal/commands/agenthooks/cx/hooks.go b/internal/commands/agenthooks/cx/hooks.go index c2f0ac03b..0bacd8b39 100644 --- a/internal/commands/agenthooks/cx/hooks.go +++ b/internal/commands/agenthooks/cx/hooks.go @@ -3,7 +3,6 @@ package cx import ( "log" "os" - "path/filepath" "strings" agenthooks "github.com/Checkmarx/ast-cx-hooks" @@ -25,39 +24,6 @@ const ( engineKics = "Kics" ) -// markerDirPerm / markerFilePerm are the permissions used when creating the session findings -// marker file's directory and the file itself. -const ( - markerDirPerm = 0o700 - markerFilePerm = 0o600 -) - -// sessionFindingsMarker is the path of the per-session marker file that records -// whether at least one real Checkmarx finding was blocked this session. -// cxBeforeFileEdit / cxBeforeToolCall create it on any scanner denial; -// cxWhenAgentIdle consumes it to decide whether to force a summary turn. -func sessionFindingsMarkerPath() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".checkmarx", ".session-cx-findings") -} - -// touchSessionFindingsMarker creates the marker file best-effort. -// A write failure must never affect the scan decision that called it. -func touchSessionFindingsMarker() { - path := sessionFindingsMarkerPath() - if path == "" { - return - } - _ = os.MkdirAll(filepath.Dir(path), markerDirPerm) - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, markerFilePerm) - if err == nil { - _ = f.Close() - } -} - // scaScanner is the package-level SCA scanner used by the guardrail handlers. // It is set by RegisterGuardrails so the handlers (free functions registered // with the agenthooks library) can reach it without an injection mechanism. @@ -98,12 +64,11 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { return agenthooks.Deny(reason) } if scaScanner != nil { - if finding, remediation := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir); finding != "" { - touchSessionFindingsMarker() - agent := agentToString(ev.Agent) - sid := sessionIDFromToolCall(&ev) + agent := agentToString(ev.Agent) + sid := sessionIDFromToolCall(&ev) + if finding, remediation, severity := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir, agent, sid); finding != "" { sessiontally.Add(sid, engineSca, 1, 1) - logRemediationTelemetry(agent, "SCA", finding, remediation) + logRemediationTelemetry(agent, "SCA", severity, sid) return agenthooks.DenyWithContext(finding, remediation) } } @@ -146,25 +111,22 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict { return agenthooks.RejectEdit(reason) } agent := agentToString(ev.Agent) - if blocked, reason, context := asca.ScanFileEdit(ev, telemetryWrapper, agent); blocked { - touchSessionFindingsMarker() + if blocked, reason, context, severity := asca.ScanFileEdit(&ev, telemetryWrapper, agent); blocked { sessiontally.Add(ev.SessionID, engineAsca, 1, 1) - logRemediationTelemetry(agent, "Asca", reason, context) + logRemediationTelemetry(agent, "Asca", severity, ev.SessionID) return agenthooks.RejectEditWithContext(reason, context) } if kicsScanner != nil { if blocked, reason, context := kics.ScanFileEdit(ev, kicsScanner); blocked { - touchSessionFindingsMarker() sessiontally.Add(ev.SessionID, engineKics, 1, 1) return agenthooks.RejectEditWithContext(reason, context) } } if scaScanner != nil { for _, diff := range ev.Changes { - if finding, remediation := scaScanner.CheckManifestEdit(ev.FilePath, fullAfterContent(ev.FilePath, diff), ev.WorkDir); finding != "" { - touchSessionFindingsMarker() + if finding, remediation, severity := scaScanner.CheckManifestEdit(ev.FilePath, fullAfterContent(ev.FilePath, diff), ev.WorkDir, agent, ev.SessionID); finding != "" { sessiontally.Add(ev.SessionID, engineSca, 1, 1) - logRemediationTelemetry(agent, "Oss", finding, remediation) + logRemediationTelemetry(agent, "Oss", severity, ev.SessionID) return agenthooks.RejectEditWithContext(finding, remediation) } } @@ -275,25 +237,21 @@ func RegisterPassThrough() { } // logRemediationTelemetry sends telemetry when remediation context is delivered to the agent. -func logRemediationTelemetry(agent, engine, finding, remediationContext string) { +func logRemediationTelemetry(agent, engine, severity, sessionID string) { if telemetryWrapper == nil { return } telemetryData := &wrappers.DataForAITelemetry{ - //agent = aiProvider - //hooks-detect for detection - //subtype = scan - // hooks-remeditae - //subType = fixWithAIchet - - AIProvider: agent, - Agent: agent + "-cli", - Engine: engine, - ScanType: strings.ToLower(engine), - UniqueID: wrappers.GetUniqueID(), - Type: "hooks-remediate", - SubType: "fixWithAIAssist", + AIProvider: agent, + Agent: agent + "-cli", + Engine: engine, + ScanType: strings.ToLower(engine), + UniqueID: wrappers.GetUniqueID(), + Type: "hooks-remediate", + SubType: "fixWithAIAssist", + ProblemSeverity: severity, + AiAgentSessionId: sessionID, } if err := telemetryWrapper.SendAIDataToLog(telemetryData); err != nil { @@ -308,6 +266,8 @@ func agentToString(agent agenthooks.AgentID) string { return "Claude" case agenthooks.AgentCopilot: return "Copilot" + case agenthooks.AgentCopilotCLI: + return "Copilot" case agenthooks.AgentCursor: return "Cursor" case agenthooks.AgentGemini: diff --git a/internal/commands/agenthooks/guardrails/asca/asca.go b/internal/commands/agenthooks/guardrails/asca/asca.go index e8f4d123d..eed2bc7eb 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca.go +++ b/internal/commands/agenthooks/guardrails/asca/asca.go @@ -29,12 +29,12 @@ func isSupportedByASCA(filePath string) bool { } // ScanFileEdit runs ASCA on the proposed post-edit content. -// Returns blocked=true with a formatted reason and remediation context when ASCA +// Returns blocked=true with a formatted reason, remediation context, and highest severity when ASCA // finds *new* vulnerabilities introduced by ev.Changes (delta-detection for edits; // any-vuln for new writes). Findings the user already suppressed via // `cx ignore-vulnerability` (the realtime ignore file) are filtered out before the // verdict. Fail-open on infrastructure errors (ASCA install fail, engine unavailable, panic). -func ScanFileEdit(ev agenthooks.FileEditEvent, telemetryWrapper wrappers.TelemetryWrapper, agent string) (blocked bool, reason, context string) { +func ScanFileEdit(ev *agenthooks.FileEditEvent, telemetryWrapper wrappers.TelemetryWrapper, agent string) (blocked bool, reason, context, severity string) { findingCount := 0 defer func() { @@ -43,16 +43,16 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, telemetryWrapper wrappers.Telemet reason = "" context = "" } - logASCATelemetry(telemetryWrapper, agent, findingCount) + logASCATelemetry(telemetryWrapper, agent, ev.SessionID, findingCount) }() if !isSupportedByASCA(ev.FilePath) { - return false, "", "" + return false, "", "", "" } - newContent, originalContent, err := ProposedContent(ev.FilePath, ev.Changes) + newContent, originalContent, err := ProposedContent(ev.FilePath, ev.Changes, ev.Agent) if err != nil || newContent == "" { - return false, "", "" + return false, "", "", "" } wrapperParams := services.AscaWrappersParam{ @@ -71,57 +71,78 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, telemetryWrapper wrappers.Telemet } // Stage and scan the proposed (new) content - stagedNew, cleanupNew, err := stageForScan(ev.FilePath, newContent, ev.SessionID) + stagedNew, cleanupNew, err := stageForScan(ev.FilePath, newContent, ev.SessionID, ev.Agent) if err != nil { - return false, "", "" + return false, "", "", "" } defer cleanupNew() ascaParams.FilePath = stagedNew newResult, err := services.CreateASCAScanRequest(ascaParams, wrapperParams) if err != nil || newResult == nil { - return false, "", "" + return false, "", "", "" } if newResult.Error != nil { - return false, "", "" + return false, "", "", "" } if len(newResult.ScanDetails) == 0 { - return false, "", "" + return false, "", "", "" } // For new files (no original content), every finding is new if originalContent == "" { - r, c := formatFindings(ev.FilePath, newResult.ScanDetails, ev.WorkDir) + r, c := formatFindings(ev.FilePath, newResult.ScanDetails, ev.WorkDir, agent, ev.SessionID) findingCount = len(newResult.ScanDetails) - return true, r, c + return true, r, c, highestSeverity(newResult.ScanDetails) } - // Delta: scan original content and find only newly introduced findings - stagedOrig, cleanupOrig, err := stageForScan(ev.FilePath, originalContent, ev.SessionID) + newFindings, err := deltaFindings(ev, originalContent, ascaParams, wrapperParams, newResult.ScanDetails) + if err != nil || len(newFindings) == 0 { + findingCount = 0 + return false, "", "", "" + } + + r, c := formatFindings(ev.FilePath, newFindings, ev.WorkDir, agent, ev.SessionID) + findingCount = len(newFindings) + return true, r, c, highestSeverity(newFindings) +} + +// deltaFindings scans the original (pre-edit) content and returns only the findings +// newly introduced by ev.Changes relative to newDetails. +func deltaFindings(ev *agenthooks.FileEditEvent, originalContent string, ascaParams services.AscaScanParams, + wrapperParams services.AscaWrappersParam, newDetails []grpcs.ScanDetail) ([]grpcs.ScanDetail, error) { + stagedOrig, cleanupOrig, err := stageForScan(ev.FilePath, originalContent, ev.SessionID, ev.Agent) if err != nil { - return false, "", "" + return nil, err } defer cleanupOrig() ascaParams.FilePath = stagedOrig origResult, err := services.CreateASCAScanRequest(ascaParams, wrapperParams) if err != nil || origResult == nil { - return false, "", "" + return nil, err } var origDetails []grpcs.ScanDetail if origResult.Error == nil { origDetails = origResult.ScanDetails } - newFindings := NewFindings(origDetails, newResult.ScanDetails) - if len(newFindings) == 0 { - findingCount = 0 - return false, "", "" - } + return NewFindings(origDetails, newDetails), nil +} - r, c := formatFindings(ev.FilePath, newFindings, ev.WorkDir) - findingCount = len(newFindings) - return true, r, c +// highestSeverity returns the highest severity level across the given ASCA findings. +// Order: Critical > High > Medium > Low > (anything else). +func highestSeverity(findings []grpcs.ScanDetail) string { + rank := map[string]int{"Critical": 4, "High": 3, "Medium": 2, "Low": 1} + best := "" + bestRank := -1 + for i := range findings { + if r, ok := rank[findings[i].Severity]; ok && r > bestRank { + bestRank = r + best = findings[i].Severity + } + } + return best } // existingIgnoreFilePath returns the default realtime ignore-file path only when it @@ -144,27 +165,21 @@ func shouldUpdateVersion() bool { // logASCATelemetry sends a telemetry event for ASCA scan results. // Called once after ASCA scan is performed with the actual finding count. -func logASCATelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent string, totalCount int) { +func logASCATelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, sessionID string, totalCount int) { if telemetryWrapper == nil || totalCount == 0 { return } telemetryData := &wrappers.DataForAITelemetry{ - - //agent = aiProvider - //hooks-detect for detection - //subtype = scan - // hooks-remeditae - //subType = fixWithAIchet - - Agent: agent + "-cli", - AIProvider: agent, - Engine: "Asca", - TotalCount: totalCount, - UniqueID: wrappers.GetUniqueID(), - Type: "hooks-detect", - SubType: "scan", - ScanType: "asca", + Agent: agent + "-cli", + AIProvider: agent, + Engine: "Asca", + TotalCount: totalCount, + UniqueID: wrappers.GetUniqueID(), + Type: "hooks-detect", + SubType: "scan", + ScanType: "asca", + AiAgentSessionId: sessionID, } if err := telemetryWrapper.SendAIDataToLog(telemetryData); err != nil { diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 4d96ad4ec..3c452cf68 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -19,7 +19,7 @@ import ( func TestProposedContent_FullFileWrite(t *testing.T) { newContent, _, err := ProposedContent("/nonexistent/auth.py", []agenthooks.FileDiff{ {Before: "", After: "print('hello')"}, - }) + }, "") if err != nil { t.Fatal(err) } @@ -31,7 +31,7 @@ func TestProposedContent_FullFileWrite(t *testing.T) { func TestProposedContent_FullFileWrite_OriginalEmpty_WhenFileAbsent(t *testing.T) { _, orig, err := ProposedContent("/nonexistent/auth.py", []agenthooks.FileDiff{ {Before: "", After: "new content"}, - }) + }, "") if err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func TestProposedContent_StringReplaceEdit(t *testing.T) { newContent, origContent, err := ProposedContent(path, []agenthooks.FileDiff{ {Before: "y = 2", After: "y = 99"}, - }) + }, "") if err != nil { t.Fatal(err) } @@ -71,7 +71,7 @@ func TestProposedContent_MissingBeforeFailsOpen(t *testing.T) { // Before string not present → returns original unchanged newContent, origContent, err := ProposedContent(path, []agenthooks.FileDiff{ {Before: "NOTHERE", After: "replacement"}, - }) + }, "") if err != nil { t.Fatal(err) } @@ -80,6 +80,30 @@ func TestProposedContent_MissingBeforeFailsOpen(t *testing.T) { } } +func TestProposedContent_CopilotCLI_NormalizesLF(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.py") + // Disk file has CRLF (Windows) + if err := os.WriteFile(path, []byte("x = 1\r\ny = 2\r\n"), 0o600); err != nil { + t.Fatal(err) + } + // Copilot CLI sends LF-only old_str/new_str + newContent, origContent, err := ProposedContent(path, []agenthooks.FileDiff{ + {Before: "y = 2\n", After: "y = 99\n"}, + }, agenthooks.AgentCopilotCLI) + if err != nil { + t.Fatal(err) + } + // originalContent should be normalised to LF + if strings.Contains(origContent, "\r") { + t.Fatalf("originalContent should be LF-normalised, got %q", origContent) + } + // newContent should have the replacement applied + if !strings.Contains(newContent, "y = 99") { + t.Fatalf("expected y = 99 in newContent, got %q", newContent) + } +} + func TestProposedContent_MultiEdit(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "app.py") @@ -90,7 +114,7 @@ func TestProposedContent_MultiEdit(t *testing.T) { newContent, _, err := ProposedContent(path, []agenthooks.FileDiff{ {Before: "a", After: "A"}, {Before: "b", After: "B"}, - }) + }, "") if err != nil { t.Fatal(err) } @@ -99,6 +123,42 @@ func TestProposedContent_MultiEdit(t *testing.T) { } } +// ── asciiSafe ──────────────────────────────────────────────────────────────── + +func TestASCIISafe_PureASCII(t *testing.T) { + in := "hello world\nfoo = 'bar';" + if got := asciiSafe(in); got != in { + t.Fatalf("pure-ASCII input should pass through unchanged, got %q", got) + } +} + +func TestASCIISafe_ReplacesNonASCII(t *testing.T) { + in := "// comment with em-dash — here\ncode = 1;" + got := asciiSafe(in) + if strings.ContainsRune(got, '—') { + t.Fatal("em-dash should have been replaced") + } + // Line structure preserved + if !strings.Contains(got, "\ncode = 1;") { + t.Fatalf("newlines and code should be intact, got %q", got) + } +} + +func TestStageForScan_StripsNonASCII(t *testing.T) { + content := "class A {\n// — em dash in comment\nint x = 1;\n}" + staged, cleanup, err := stageForScan("/some/path/A.java", content, "sess1", agenthooks.AgentCopilotCLI) + if err != nil { + t.Fatal(err) + } + defer cleanup() + data, _ := os.ReadFile(staged) + for _, b := range data { + if b > 127 { + t.Fatalf("staged file should contain only ASCII, found byte %d", b) + } + } +} + // ── stageForScan / safeSessionTag ─────────────────────────────────────────── func TestSafeSessionTag_Empty(t *testing.T) { @@ -127,7 +187,7 @@ func TestSafeSessionTag_UUID(t *testing.T) { } func TestStageForScan_CreatesFileWithOriginalBasename(t *testing.T) { - staged, cleanup, err := stageForScan("/some/path/auth.py", "content", "sess123") + staged, cleanup, err := stageForScan("/some/path/auth.py", "content", "sess123", "") if err != nil { t.Fatal(err) } @@ -146,7 +206,7 @@ func TestStageForScan_CreatesFileWithOriginalBasename(t *testing.T) { } func TestStageForScan_DirNameContainsSessionTag(t *testing.T) { - staged, cleanup, err := stageForScan("/tmp/foo.py", "x", "abc123") + staged, cleanup, err := stageForScan("/tmp/foo.py", "x", "abc123", "") if err != nil { t.Fatal(err) } @@ -163,7 +223,7 @@ func TestStageForScan_DirNameContainsSessionTag(t *testing.T) { } func TestStageForScan_CleanupRemovesDir(t *testing.T) { - staged, cleanup, err := stageForScan("/tmp/foo.py", "x", "sess") + staged, cleanup, err := stageForScan("/tmp/foo.py", "x", "sess", "") if err != nil { t.Fatal(err) } @@ -178,7 +238,7 @@ func TestStageForScan_FileMode(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Unix permission bits (0600) are not enforced on Windows; validated on Linux/macOS CI") } - staged, cleanup, err := stageForScan("/tmp/secret.py", "secret", "s1") + staged, cleanup, err := stageForScan("/tmp/secret.py", "secret", "s1", "") if err != nil { t.Fatal(err) } @@ -265,7 +325,7 @@ func TestAdditionalContext_SingleFinding_PreFilledCommand(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, } - ctx := additionalContext("billing.py", "cx", findings, "") + ctx := additionalContext("billing.py", "cx", findings, "", "", "") if !strings.Contains(ctx, "ignore-vulnerability") { t.Errorf("expected ignore-vulnerability command, got %q", ctx) } @@ -280,12 +340,40 @@ func TestAdditionalContext_SingleFinding_PreFilledCommand(t *testing.T) { } } +func TestAdditionalContext_EmitsProvenanceOptionalFlags(t *testing.T) { + findings := []grpcs.ScanDetail{ + {FileName: "billing.py", Line: 5, RuleID: 4059}, + } + ctx := additionalContext("billing.py", "cx", findings, "", "Claude", "sess-123") + want := ` --optional-flags "aiProvider=Claude;agent=Claude-cli;aiAgentSessionId=sess-123"` + if !strings.Contains(ctx, want) { + t.Errorf("expected provenance flags %q in ignore command, got %q", want, ctx) + } + // Empty agent → no provenance fragment (backward-compatible default). + if noAgent := additionalContext("billing.py", "cx", findings, "", "", ""); strings.Contains(noAgent, "--optional-flags") { + t.Errorf("expected no --optional-flags when agent is empty, got %q", noAgent) + } +} + +func TestAdditionalContext_FileNameWithPercent_NotMisformatted(t *testing.T) { + findings := []grpcs.ScanDetail{ + {FileName: "a%s.py", Line: 5, RuleID: 4059}, + } + ctx := additionalContext("a%s.py", "cx", findings, "", "Claude", "sess-1") + if strings.Contains(ctx, "%!s") || strings.Contains(ctx, "MISSING") { + t.Errorf("a %%-containing filename leaked a format verb into the output: %q", ctx) + } + if !strings.Contains(ctx, `"FileName":"a%s.py"`) { + t.Errorf("expected the literal filename in the ignore command, got %q", ctx) + } +} + func TestAdditionalContext_MultipleFindings_EachGetsCommand(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, {FileName: "billing.py", Line: 12, RuleID: 4027}, } - ctx := additionalContext("billing.py", "cx", findings, "") + ctx := additionalContext("billing.py", "cx", findings, "", "", "") if strings.Count(ctx, "ignore-vulnerability") != 2 { t.Errorf("expected 2 ignore commands for 2 findings, got: %q", ctx) } @@ -298,7 +386,7 @@ func TestAdditionalContext_MultipleFindings_EachGetsCommand(t *testing.T) { } func TestAdditionalContext_EmptyFindings_StillContainsRemediationInstruction(t *testing.T) { - ctx := additionalContext("main.py", "cx", nil, "") + ctx := additionalContext("main.py", "cx", nil, "", "", "") if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") { t.Errorf("expected codeRemediation instruction even with no findings, got %q", ctx) } @@ -309,7 +397,7 @@ func TestAdditionalContext_PinsIgnoredFilePathToWorkDir(t *testing.T) { {FileName: "billing.py", Line: 5, RuleID: 4059}, } workDir := filepath.Join("repo", "ws") - ctx := additionalContext("billing.py", "cx", findings, workDir) + ctx := additionalContext("billing.py", "cx", findings, workDir, "", "") want := "--ignored-file-path '" + ignore.PathFor(workDir) + "'" if !strings.Contains(ctx, want) { t.Errorf("expected context to pin %q, got %q", want, ctx) @@ -320,7 +408,7 @@ func TestAdditionalContext_EmptyWorkDirOmitsIgnoredFilePath(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, } - ctx := additionalContext("billing.py", "cx", findings, "") + ctx := additionalContext("billing.py", "cx", findings, "", "", "") if strings.Contains(ctx, "--ignored-file-path") { t.Errorf("expected no ignored-file-path flag for empty workDir, got %q", ctx) } diff --git a/internal/commands/agenthooks/guardrails/asca/content.go b/internal/commands/agenthooks/guardrails/asca/content.go index ef65576a0..2aa9dde6e 100644 --- a/internal/commands/agenthooks/guardrails/asca/content.go +++ b/internal/commands/agenthooks/guardrails/asca/content.go @@ -7,12 +7,21 @@ import ( agenthooks "github.com/Checkmarx/ast-cx-hooks" ) +// normLF normalises any line-ending variant (CRLF, bare CR, LF) to LF. +// Applied only for agents that send LF-only payloads against CRLF disk files +// (e.g. Copilot CLI on Windows) to ensure strings.Index finds a match. +func normLF(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return s +} + // ProposedContent returns the file content that would exist after ev.Changes are applied. // Returns (newContent, originalContent, err). // - Full-file write: Changes = [{Before:"", After:X}] → newContent=X, originalContent= // - String-replace edit: read disk, apply each FileDiff.Before→After in order // - File doesn't exist on disk: originalContent="" -func ProposedContent(filePath string, changes []agenthooks.FileDiff) (newContent, originalContent string, err error) { +func ProposedContent(filePath string, changes []agenthooks.FileDiff, agent agenthooks.AgentID) (newContent, originalContent string, err error) { diskBytes, readErr := os.ReadFile(filePath) if readErr == nil { originalContent = string(diskBytes) @@ -24,15 +33,33 @@ func ProposedContent(filePath string, changes []agenthooks.FileDiff) (newContent return changes[0].After, originalContent, nil } - // String-replace: apply each diff in order against current content + // Copilot CLI sends LF-only old_str/new_str regardless of the OS, while the + // file on disk may have CRLF (Windows) or bare CR (classic macOS). Normalise + // both the disk content and the diff strings to LF before matching so + // strings.Index reliably finds the substring on all three OSes. + normalize := agent == agenthooks.AgentCopilotCLI current := originalContent + if normalize { + current = normLF(current) + } + for _, diff := range changes { - idx := strings.Index(current, diff.Before) + before := diff.Before + after := diff.After + if normalize { + before = normLF(before) + after = normLF(after) + } + idx := strings.Index(current, before) if idx < 0 { // Before not found — malformed edit; fail-open, let agent's tool surface it continue } - current = current[:idx] + diff.After + current[idx+len(diff.Before):] + current = current[:idx] + after + current[idx+len(before):] + } + + if normalize { + originalContent = normLF(originalContent) } return current, originalContent, nil } diff --git a/internal/commands/agenthooks/guardrails/asca/delta.go b/internal/commands/agenthooks/guardrails/asca/delta.go index 9032ff2a7..afcf92ab7 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -64,14 +64,14 @@ func findingsSummary(findings []grpcs.ScanDetail) string { // human-readable deny reason (rendered as permissionDecisionReason) and the // remediation guidance injected into the agent's context (additionalContext). // ast-cx-hooks v1.0.3 carries these as distinct fields via RejectEditWithContext. -func formatFindings(filePath string, findings []grpcs.ScanDetail, workDir string) (reason, context string) { +func formatFindings(filePath string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) (reason, context string) { summary := findingsSummary(findings) cxExe, err := os.Executable() cxBinary := "cx" if err == nil { cxBinary = cxExe } - return permissionDecisionReason(filePath, summary), additionalContext(filePath, cxBinary, findings, workDir) + return permissionDecisionReason(filePath, summary), additionalContext(filePath, cxBinary, findings, workDir, agent, sessionID) } // ignoredFilePathFlag returns the " --ignored-file-path ''" fragment that pins @@ -88,6 +88,21 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the +// child `cx ignore-vulnerability` process via --optional-flags, which reads them through +// utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. +// Empty agent → no fragment (nothing to attribute). +func optionalFlagsFragment(agent, sessionID string) string { + if agent == "" { + return "" + } + pairs := "aiProvider=" + agent + ";agent=" + agent + "-cli" + if sessionID != "" { + pairs += ";aiAgentSessionId=" + sessionID + } + return fmt.Sprintf(" --optional-flags %q", pairs) +} + // permissionDecisionReason is the human-readable deny message shown to the user. // Contains only the findings — no agent instructions. func permissionDecisionReason(filePath, summary string) string { @@ -99,8 +114,9 @@ func permissionDecisionReason(filePath, summary string) string { // additionalContext is injected into the agent's context window to drive remediation. // Contains all action instructions — not shown directly to the user. -func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir string) string { +func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string { ignoreFlag := ignoredFilePathFlag(workDir) + provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, f := range findings { data, _ := json.Marshal(grpcs.AscaIgnoreFinding{ @@ -108,7 +124,7 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w Line: f.Line, RuleID: f.RuleID, }) - fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s\n", cxBinary, string(data), ignoreFlag) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) } return fmt.Sprintf( "ASCA detected vulnerabilities in %s. "+ @@ -129,8 +145,7 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w " \"type\": \"sast\"\n"+ " }\n"+ "Use the remediation guidance returned by the tool to fix the vulnerability, then retry the write. "+ - "If a finding is a confirmed false positive, suppress it by running the corresponding command below, then retry the write:\n"+ - suppressCmds.String(), - filePath, + "If a finding is a confirmed false positive, suppress it by running the corresponding command below, then retry the write:\n%s", + filePath, suppressCmds.String(), ) } diff --git a/internal/commands/agenthooks/guardrails/asca/stage.go b/internal/commands/agenthooks/guardrails/asca/stage.go index 711207ae6..366aa1616 100644 --- a/internal/commands/agenthooks/guardrails/asca/stage.go +++ b/internal/commands/agenthooks/guardrails/asca/stage.go @@ -5,19 +5,61 @@ import ( "os" "path/filepath" "strings" + "unicode/utf8" + + agenthooks "github.com/Checkmarx/ast-cx-hooks" ) // noop is a no-op cleanup func returned on error paths so callers can always defer cleanup(). var noop = func() {} +// maxASCIICodePoint is the highest code point representable in 7-bit ASCII. +const maxASCIICodePoint = 127 + +// stagedFileMode restricts staged scan files to owner read/write only. +const stagedFileMode = 0o600 + +// asciiSafe replaces every non-ASCII rune with a space so ASCA's language +// parsers can tokenise the file. Non-ASCII chars appear in comments or string +// literals but never in code constructs that ASCA analyses for vulnerabilities; +// substituting them with spaces preserves line numbers and code structure. +func asciiSafe(s string) string { + if utf8.ValidString(s) { + allASCII := true + for _, r := range s { + if r > maxASCIICodePoint { + allASCII = false + break + } + } + if allASCII { + return s + } + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r > maxASCIICodePoint { + b.WriteByte(' ') + } else { + b.WriteRune(r) + } + } + return b.String() +} + // stageForScan writes content to a fresh temp directory under os.TempDir(), // preserving the original basename so ASCA's language detection works correctly // and findings report a sensible file_name. The dir name includes a short, // sanitized prefix of sessionID so concurrent agent sessions are visibly // separated and orphaned dirs can be traced back to the session that created them. // +// For AgentCopilotCLI, non-ASCII characters are replaced with spaces before +// writing — Copilot CLI may add non-ASCII chars (e.g. EM dashes in comments) +// that cause ASCA's parser to silently return 0 findings. +// // Returns the staged path and a cleanup func. Caller must defer cleanup(). -func stageForScan(originalPath, content, sessionID string) (stagedPath string, cleanup func(), err error) { +func stageForScan(originalPath, content, sessionID string, agent agenthooks.AgentID) (stagedPath string, cleanup func(), err error) { pattern := fmt.Sprintf("asca-hook-%s-*", safeSessionTag(sessionID)) tempDir, err := os.MkdirTemp("", pattern) if err != nil { @@ -37,7 +79,11 @@ func stageForScan(originalPath, content, sessionID string) (stagedPath string, c return "", noop, fmt.Errorf("path traversal in %q", base) } - if err := os.WriteFile(staged, []byte(content), 0o600); err != nil { + toWrite := content + if agent == agenthooks.AgentCopilotCLI { + toWrite = asciiSafe(content) + } + if err := os.WriteFile(staged, []byte(toWrite), stagedFileMode); err != nil { _ = os.RemoveAll(tempDir) return "", noop, err } diff --git a/internal/commands/agenthooks/mcp/bridge.go b/internal/commands/agenthooks/mcp/bridge.go index b6ebe39e8..1e6ee214b 100644 --- a/internal/commands/agenthooks/mcp/bridge.go +++ b/internal/commands/agenthooks/mcp/bridge.go @@ -124,7 +124,7 @@ var ( configMu.Lock() defer configMu.Unlock() _ = configuration.LoadConfiguration() - wrappers.LoadActiveCredential() + } invalidateTokenCache = wrappers.InvalidateAccessTokenCache credentialPollInterval = 3 * time.Second @@ -224,8 +224,7 @@ func runBridgeIO(in io.Reader, out io.Writer, client *http.Client, version, urlO sess.state = stateUnauth // Degraded notice goes to STDERR only (stdout is the protocol channel). fmt.Fprintln(os.Stderr, "cx mcp bridge: no usable Checkmarx credential yet — serving in a degraded state. "+ - "Log in with 'cx auth login' (or /cx-cli-setup) using the default (yaml) or '--session global' mode "+ - "(NOT '--session local', which this process can't see); Checkmarx tools appear automatically once authenticated. "+ + "Log in with 'cx auth login' (or /cx-cli-setup); Checkmarx tools appear automatically once authenticated. "+ "For on-prem/custom domains, set CX_MCP_URL or pass --mcp-url.") stop := make(chan struct{}) var wg sync.WaitGroup diff --git a/internal/commands/agenthooks/mcp/bridge_test.go b/internal/commands/agenthooks/mcp/bridge_test.go index 62b7a11b0..76701bff7 100644 --- a/internal/commands/agenthooks/mcp/bridge_test.go +++ b/internal/commands/agenthooks/mcp/bridge_test.go @@ -585,7 +585,7 @@ func TestAuthedSelfHeal_ReReadsDisk(t *testing.T) { setupBridgeTest(t) const oldKey = "old-key" const newKey = "new-key" - viper.Set(commonParams.AstAPIKey, oldKey) // stale in-memory value + viper.Set(commonParams.AstAPIKey, oldKey) // stale in-memory value reloadConfig = func() { viper.Set(commonParams.AstAPIKey, newKey) } // disk re-read brings the new token var seenAuth []string diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index b128cfad0..4be68391b 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -6,38 +6,40 @@ import ( "os" "strings" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/agentprofile" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime" ) // DenyMalicious returns the finding and remediation strings for one or more // packages classified as Malicious. -func DenyMalicious(pkgs []ossrealtime.OssPackage) (finding, remediation string) { +func DenyMalicious(pkgs []ossrealtime.OssPackage, agent string) (finding, remediation string) { var b strings.Builder b.WriteString("Checkmarx SCA scan detected MALICIOUS package(s):\n") for _, p := range pkgs { fmt.Fprintf(&b, " - %s: Known supply chain attack.\n", pkgLabel(p)) } b.WriteString("\nDo NOT proceed with the installation.") - return b.String(), remediationNote("malicious package", "safest available version") + return b.String(), remediationNote("malicious package", "safest available version", agent) } // DenyVulnerable returns the finding and remediation strings for one or more // packages with known vulnerabilities. workDir anchors the suppression command's // --ignored-file-path to the workspace ignore file (see vulnerableRemediationNote). -func DenyVulnerable(pkgs []ossrealtime.OssPackage, workDir string) (finding, remediation string) { +func DenyVulnerable(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID string) (finding, remediation string) { var b strings.Builder b.WriteString("Checkmarx SCA scan found vulnerabilities:\n") for _, p := range pkgs { fmt.Fprintf(&b, " - %s: %s\n", pkgLabel(p), vulnDetail(p)) } - return b.String(), vulnerableRemediationNote(pkgs, workDir) + return b.String(), vulnerableRemediationNote(pkgs, workDir, agent, sessionID) } // remediationNote returns the action steps to include as additionalContext for malicious packages. -// Remediation must go through the cx-devassist-sca skill (or MCP fallback); if MCP is unavailable -// Claude must self-recover by running the registration helper rather than asking the user. -func remediationNote(subject, goal string) string { +// Remediation goes through the cx-devassist skill (or the Checkmarx MCP tool directly when the skill +// is unavailable); if the MCP tool itself is unavailable the user reconnects it via the client — the +// reconnect phrasing is per-agent, from agentprofile.McpReconnect. +func remediationNote(subject, goal, agent string) string { return fmt.Sprintf( "Action required:\n"+ " 1. For each %s, invoke the cx-devassist:cx-devassist-sca skill — "+ @@ -45,21 +47,20 @@ func remediationNote(subject, goal string) string { "MCP-driven remediation to find the %s; the skill also handles MCP unavailability and self-recovery.\n"+ " 2. If that skill is not available in this session, use mcp__Checkmarx__packageRemediation for each %s.\n"+ " This is the only supported remediation path — do not attempt manual version selection.\n"+ - " 3. If mcp__Checkmarx__packageRemediation is unavailable, recover it yourself — do NOT ask\n"+ - " the user. Run: bash \"${CLAUDE_PLUGIN_ROOT}/mcp/cx_mcp_register.sh\"\n"+ - " Then tell the user: 'I have registered the Checkmarx MCP. Please run /mcp (or restart\n"+ - " Claude Code) to load it, then retry.' Do not proceed until the MCP is available.\n"+ + " 3. If mcp__Checkmarx__packageRemediation is unavailable, tell the user to reconnect the\n"+ + " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ " 4. If no safe version exists, the user can acknowledge the finding via\n"+ " the Checkmarx Dev Assist interface.", - subject, goal, subject) + subject, goal, subject, agentprofile.McpReconnect(agent)) } // vulnerableRemediationNote returns the action steps for vulnerable packages. // When no safe version is found, the agent runs the per-package ignore command // and informs the user. -func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir string) string { +func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID string) string { cxBinary := cxExecutable() ignoreFlag := ignoredFilePathFlag(workDir) + provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, p := range pkgs { data, _ := json.Marshal([]map[string]string{{ @@ -67,7 +68,7 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir string) st "PackageName": p.PackageName, "PackageVersion": p.PackageVersion, }}) - fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s\n", cxBinary, string(data), ignoreFlag) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) } return fmt.Sprintf( "Action required:\n"+ @@ -76,12 +77,11 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir string) st "MCP-driven remediation to find non-vulnerable versions; the skill also handles MCP unavailability and self-recovery.\n"+ " 2. If that skill is not available in this session, use mcp__Checkmarx__packageRemediation for each affected package.\n"+ " This is the only supported remediation path — do not attempt manual version selection.\n"+ - " 3. If mcp__Checkmarx__packageRemediation is unavailable, recover it yourself — do NOT ask\n"+ - " the user. Run: bash \"${CLAUDE_PLUGIN_ROOT}/mcp/cx_mcp_register.sh\"\n"+ - " Then tell the user: 'I have registered the Checkmarx MCP. Please run /mcp (or restart\n"+ - " Claude Code) to load it, then retry.' Do not proceed until the MCP is available.\n"+ + " 3. If mcp__Checkmarx__packageRemediation is unavailable, tell the user to reconnect the\n"+ + " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ " 4. If no safe version exists for a package, suppress it by running the corresponding command\n"+ " and inform the user that no safer version is available:\n%s", + agentprofile.McpReconnect(agent), suppressCmds.String()) } @@ -99,6 +99,21 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the +// child `cx ignore-vulnerability` process via --optional-flags, which reads them through +// utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. +// Empty agent → no fragment (nothing to attribute). +func optionalFlagsFragment(agent, sessionID string) string { + if agent == "" { + return "" + } + pairs := "aiProvider=" + agent + ";agent=" + agent + "-cli" + if sessionID != "" { + pairs += ";aiAgentSessionId=" + sessionID + } + return fmt.Sprintf(" --optional-flags %q", pairs) +} + func cxExecutable() string { cxExe, err := os.Executable() if err != nil { diff --git a/internal/commands/agenthooks/sca/sca.go b/internal/commands/agenthooks/sca/sca.go index 0564a20a9..261156ca1 100644 --- a/internal/commands/agenthooks/sca/sca.go +++ b/internal/commands/agenthooks/sca/sca.go @@ -7,23 +7,23 @@ import ( ) // CheckBashInstall is the entry point for the pre-Bash-tool guardrail. It -// returns ("", "") to allow the command, or (finding, remediation) to block. +// returns ("", "", "") to allow the command, or (finding, remediation, severity) to block. // Errors fail open — we never block on infrastructure failures. // // Compound commands produce multiple install requests; we scan each and // return on the first finding (malicious takes precedence over vulnerable). -func (s *Scanner) CheckBashInstall(command, workDir string) (finding, remediation string) { +func (s *Scanner) CheckBashInstall(command, workDir, agent, sessionID string) (finding, remediation, severity string) { s.workDir = workDir for _, req := range ParseInstall(command) { mal, vuln, err := s.scanRequest(req, workDir) if err != nil { continue } - if f, r := denyFrom(mal, vuln, workDir); f != "" { - return f, r + if f, r, sev := denyFrom(mal, vuln, workDir, agent, sessionID); f != "" { + return f, r, sev } } - return "", "" + return "", "", "" } func (s *Scanner) scanRequest(req InstallRequest, workDir string) (malicious, vulnerable []ossrealtime.OssPackage, err error) { @@ -41,38 +41,56 @@ func (s *Scanner) scanRequest(req InstallRequest, workDir string) (malicious, vu } // CheckManifestEdit is the entry point for the pre-file-edit guardrail. It -// returns ("", "") to accept the edit, or (finding, remediation) to reject. +// returns ("", "", "") to accept the edit, or (finding, remediation, severity) to reject. // // Non-manifest paths are a no-op. For manifest paths we diff before/after, // scan only the newly-added packages, and reject if any are malicious or // vulnerable. -func (s *Scanner) CheckManifestEdit(filePath string, afterContent []byte, workDir string) (finding, remediation string) { +func (s *Scanner) CheckManifestEdit(filePath string, afterContent []byte, workDir, agent, sessionID string) (finding, remediation, severity string) { s.workDir = workDir format, ok := IsManifest(filePath) if !ok { - return "", "" + return "", "", "" } before, _ := os.ReadFile(filePath) // missing → empty before added, err := AddedPackages(filePath, before, afterContent) if err != nil || len(added) == 0 { - return "", "" + return "", "", "" } mal, vuln, err := s.ScanPackages(format, added) if err != nil { - return "", "" + return "", "", "" } - return denyFrom(mal, vuln, workDir) + return denyFrom(mal, vuln, workDir, agent, sessionID) } -// denyFrom builds the (finding, remediation) pair. workDir anchors the +// denyFrom builds the (finding, remediation, severity) triple. workDir anchors the // `cx ignore-vulnerability` suppression command emitted for vulnerable packages // to the workspace ignore file so the agent writes where the hook later reads. -func denyFrom(malicious, vulnerable []ossrealtime.OssPackage, workDir string) (finding, remediation string) { +func denyFrom(malicious, vulnerable []ossrealtime.OssPackage, workDir, agent, sessionID string) (finding, remediation, severity string) { if len(malicious) > 0 { - return DenyMalicious(malicious) + f, r := DenyMalicious(malicious, agent) + return f, r, statusMalicious } if len(vulnerable) > 0 { - return DenyVulnerable(vulnerable, workDir) + f, r := DenyVulnerable(vulnerable, workDir, agent, sessionID) + return f, r, highestVulnSeverity(vulnerable) } - return "", "" + return "", "", "" +} + +// highestVulnSeverity returns the highest CVE severity across all vulnerable packages. +func highestVulnSeverity(pkgs []ossrealtime.OssPackage) string { + rank := map[string]int{"Critical": 4, "High": 3, "Medium": 2, "Low": 1} + best := "" + bestRank := -1 + for i := range pkgs { + for _, v := range pkgs[i].Vulnerabilities { + if r, ok := rank[v.Severity]; ok && r > bestRank { + bestRank = r + best = v.Severity + } + } + } + return best } diff --git a/internal/commands/agenthooks/sca/sca_test.go b/internal/commands/agenthooks/sca/sca_test.go index ce41affe0..20c80cc7f 100644 --- a/internal/commands/agenthooks/sca/sca_test.go +++ b/internal/commands/agenthooks/sca/sca_test.go @@ -20,7 +20,7 @@ func scannerWith(pkgs ...ossrealtime.OssPackage) *Scanner { func TestCheckBashInstall_NoInstallCommand(t *testing.T) { s := scannerWith(ossrealtime.OssPackage{PackageName: "anything", Status: "Malicious"}) - finding, _ := s.CheckBashInstall("ls -la", "") + finding, _, _ := s.CheckBashInstall("ls -la", "", "", "") if finding != "" { t.Errorf("expected empty for non-install command, got %q", finding) } @@ -28,7 +28,7 @@ func TestCheckBashInstall_NoInstallCommand(t *testing.T) { func TestCheckBashInstall_CleanInstall(t *testing.T) { s := scannerWith(ossrealtime.OssPackage{PackageName: "lodash", Status: "OK"}) - finding, _ := s.CheckBashInstall("npm install lodash", "") + finding, _, _ := s.CheckBashInstall("npm install lodash", "", "", "") if finding != "" { t.Errorf("expected empty for clean install, got %q", finding) } @@ -36,7 +36,7 @@ func TestCheckBashInstall_CleanInstall(t *testing.T) { func TestCheckBashInstall_MaliciousMentionsMCP(t *testing.T) { s := scannerWith(ossrealtime.OssPackage{PackageName: "lodash", PackageVersion: "4.17.21", Status: "Malicious"}) - finding, remediation := s.CheckBashInstall("npm install lodash@4.17.21", "") + finding, remediation, severity := s.CheckBashInstall("npm install lodash@4.17.21", "", "Claude", "") if !strings.Contains(finding, "MALICIOUS") { t.Errorf("expected finding to mention MALICIOUS, got %q", finding) } @@ -46,17 +46,23 @@ func TestCheckBashInstall_MaliciousMentionsMCP(t *testing.T) { if !strings.Contains(remediation, "cx-devassist:cx-devassist-sca") { t.Errorf("expected remediation to reference cx-devassist-sca skill, got %q", remediation) } - if !strings.Contains(remediation, "cx_mcp_register.sh") { - t.Errorf("expected remediation to mention MCP registration script, got %q", remediation) + if !strings.Contains(remediation, "restart Claude Code") { + t.Errorf("expected the per-agent reconnect phrase for Claude, got %q", remediation) + } + if strings.Contains(remediation, "cx_mcp_register.sh") { + t.Errorf("cx_mcp_register.sh must not appear (removed), got %q", remediation) } if !strings.Contains(remediation, "Dev Assist") { t.Errorf("expected remediation to mention Dev Assist fallback, got %q", remediation) } + if severity != statusMalicious { + t.Errorf("expected severity Malicious, got %q", severity) + } } func TestCheckBashInstall_Vulnerable(t *testing.T) { s := scannerWith(ossrealtime.OssPackage{PackageName: "axios", PackageVersion: "0.21.0", Status: "Vulnerable"}) - finding, remediation := s.CheckBashInstall("npm install axios@0.21.0", "") + finding, remediation, _ := s.CheckBashInstall("npm install axios@0.21.0", "", "Claude", "") if !strings.Contains(finding, "vulnerabilities") { t.Errorf("expected vulnerable finding, got %q", finding) } @@ -74,12 +80,33 @@ func TestCheckBashInstall_Vulnerable(t *testing.T) { } } +func TestRemediation_PerAgentReconnectPhrase(t *testing.T) { + s := scannerWith(ossrealtime.OssPackage{PackageName: "axios", PackageVersion: "0.21.0", Status: "Vulnerable"}) + _, remediation, _ := s.CheckBashInstall("npm install axios@0.21.0", "", "Copilot", "") + // The remediation message is the same for every agent — the skill line and MCP fallback are always present. + if !strings.Contains(remediation, "cx-devassist:cx-devassist-sca") { + t.Errorf("expected the shared skill line for all agents, got %q", remediation) + } + if !strings.Contains(remediation, "mcp__Checkmarx__packageRemediation") { + t.Errorf("expected the MCP-tool fallback for all agents, got %q", remediation) + } + // Only the reconnect hint is agent-specific, and the removed script/env/Claude-restart must be gone. + if !strings.Contains(remediation, "Copilot CLI") { + t.Errorf("expected the Copilot reconnect phrase, got %q", remediation) + } + for _, forbidden := range []string{"cx_mcp_register.sh", "CLAUDE_PLUGIN_ROOT", "restart Claude Code"} { + if strings.Contains(remediation, forbidden) { + t.Errorf("remediation must not contain %q, got %q", forbidden, remediation) + } + } +} + func TestCheckBashInstall_MaliciousTakesPrecedence(t *testing.T) { s := scannerWith( ossrealtime.OssPackage{PackageName: "vuln", Status: "Vulnerable"}, ossrealtime.OssPackage{PackageName: "bad", Status: "Malicious"}, ) - finding, _ := s.CheckBashInstall("npm install vuln bad", "") + finding, _, _ := s.CheckBashInstall("npm install vuln bad", "", "", "") if !strings.Contains(finding, "MALICIOUS") { t.Errorf("expected MALICIOUS message when both present, got %q", finding) } @@ -99,7 +126,7 @@ func TestCheckBashInstall_CompoundWithCleanThenBad(t *testing.T) { {PackageName: "evil", Status: "Malicious"}, }}, nil }) - finding, _ := s.CheckBashInstall("npm install lodash && pip install evil", "") + finding, _, _ := s.CheckBashInstall("npm install lodash && pip install evil", "", "", "") if !strings.Contains(finding, "MALICIOUS") { t.Errorf("expected deny on second segment, got %q", finding) } @@ -112,7 +139,7 @@ func TestCheckBashInstall_FailOpenOnScannerError(t *testing.T) { s := NewScannerWithFunc(func(string) (*ossrealtime.OssPackageResults, error) { return nil, errBoom }) - finding, _ := s.CheckBashInstall("npm install lodash", "") + finding, _, _ := s.CheckBashInstall("npm install lodash", "", "", "") if finding != "" { t.Errorf("expected fail-open empty on scanner error, got %q", finding) } @@ -120,7 +147,7 @@ func TestCheckBashInstall_FailOpenOnScannerError(t *testing.T) { func TestCheckManifestEdit_NonManifestNoop(t *testing.T) { s := scannerWith(ossrealtime.OssPackage{PackageName: "x", Status: "Malicious"}) - finding, _ := s.CheckManifestEdit("/repo/main.go", []byte("anything"), "") + finding, _, _ := s.CheckManifestEdit("/repo/main.go", []byte("anything"), "", "", "") if finding != "" { t.Errorf("non-manifest: expected empty, got %q", finding) } @@ -142,13 +169,16 @@ func TestCheckManifestEdit_NewMaliciousAddition(t *testing.T) { after := []byte(`{"name":"x","dependencies":{"lodash":"4.17.21","evil-pkg":"1.0.0"}}`) s := scannerWith(ossrealtime.OssPackage{PackageName: "evil-pkg", PackageVersion: "1.0.0", Status: "Malicious"}) - finding, remediation := s.CheckManifestEdit(pkgJSON, after, "") + finding, remediation, severity := s.CheckManifestEdit(pkgJSON, after, "", "", "") if !strings.Contains(finding, "MALICIOUS") { t.Errorf("expected MALICIOUS finding, got %q", finding) } if !strings.Contains(remediation, "mcp__Checkmarx__packageRemediation") { t.Errorf("expected remediation to reference MCP tool, got %q", remediation) } + if severity != statusMalicious { + t.Errorf("expected severity Malicious, got %q", severity) + } } func TestCheckManifestEdit_OnlyVersionBumpOfCleanPkg(t *testing.T) { @@ -166,7 +196,7 @@ func TestCheckManifestEdit_OnlyVersionBumpOfCleanPkg(t *testing.T) { // Even though it's only a bump, the new version is "new" and gets scanned. // If the scanner returns OK, the edit is accepted. s := scannerWith(ossrealtime.OssPackage{PackageName: "lodash", PackageVersion: "4.17.21", Status: "OK"}) - finding, _ := s.CheckManifestEdit(pkgJSON, after, "") + finding, _, _ := s.CheckManifestEdit(pkgJSON, after, "", "", "") if finding != "" { t.Errorf("expected accept for clean version bump, got %q", finding) } @@ -176,7 +206,7 @@ func TestDenyVulnerable_IgnoreCommandIncludesPackageData(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageManager: "pip", PackageName: "requests", PackageVersion: "2.19.0"}, } - _, remediation := DenyVulnerable(pkgs, "") + _, remediation := DenyVulnerable(pkgs, "", "", "") if !strings.Contains(remediation, "ignore-vulnerability") { t.Errorf("expected ignore-vulnerability in remediation, got %q", remediation) } @@ -191,12 +221,27 @@ func TestDenyVulnerable_IgnoreCommandIncludesPackageData(t *testing.T) { } } +func TestDenyVulnerable_EmitsProvenanceOptionalFlags(t *testing.T) { + pkgs := []ossrealtime.OssPackage{ + {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, + } + _, remediation := DenyVulnerable(pkgs, "", "Cursor", "sess-9") + want := ` --optional-flags "aiProvider=Cursor;agent=Cursor-cli;aiAgentSessionId=sess-9"` + if !strings.Contains(remediation, want) { + t.Errorf("expected provenance flags %q in ignore command, got %q", want, remediation) + } + // Empty agent → no provenance fragment (backward-compatible default). + if _, noAgent := DenyVulnerable(pkgs, "", "", ""); strings.Contains(noAgent, "--optional-flags") { + t.Errorf("expected no --optional-flags when agent is empty, got %q", noAgent) + } +} + func TestDenyVulnerable_MultiplePackages_EachGetsIgnoreCommand(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageManager: "npm", PackageName: "lodash", PackageVersion: "4.17.0"}, {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, } - _, remediation := DenyVulnerable(pkgs, "") + _, remediation := DenyVulnerable(pkgs, "", "", "") if strings.Count(remediation, "ignore-vulnerability") != 2 { t.Errorf("expected 2 ignore commands for 2 packages, got %q", remediation) } @@ -213,7 +258,7 @@ func TestDenyVulnerable_PinsIgnoredFilePathToWorkDir(t *testing.T) { {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, } workDir := filepath.Join("repo", "ws") - _, remediation := DenyVulnerable(pkgs, workDir) + _, remediation := DenyVulnerable(pkgs, workDir, "", "") want := "--ignored-file-path '" + ignore.PathFor(workDir) + "'" if !strings.Contains(remediation, want) { t.Errorf("expected remediation to pin %q, got %q", want, remediation) @@ -224,7 +269,7 @@ func TestDenyVulnerable_EmptyWorkDirOmitsIgnoredFilePath(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, } - _, remediation := DenyVulnerable(pkgs, "") + _, remediation := DenyVulnerable(pkgs, "", "", "") if strings.Contains(remediation, "--ignored-file-path") { t.Errorf("expected no ignored-file-path flag for empty workDir, got %q", remediation) } @@ -234,7 +279,7 @@ func TestDenyMalicious_StillMentionsDevAssist(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageName: "evil-pkg", PackageVersion: "1.0.0"}, } - _, remediation := DenyMalicious(pkgs) + _, remediation := DenyMalicious(pkgs, "Claude") if !strings.Contains(remediation, "Dev Assist") { t.Errorf("malicious remediation should still mention Dev Assist, got %q", remediation) } @@ -255,7 +300,7 @@ func TestCheckManifestEdit_VulnerableContainsIgnoreCommand(t *testing.T) { s := scannerWith(ossrealtime.OssPackage{ PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0", Status: "Vulnerable", }) - finding, remediation := s.CheckManifestEdit(pkgJSON, after, "") + finding, remediation, _ := s.CheckManifestEdit(pkgJSON, after, "", "", "") if !strings.Contains(finding, "vulnerabilities") { t.Errorf("expected vulnerable finding, got %q", finding) } diff --git a/internal/commands/agenthooks/sessiontally/sessiontally.go b/internal/commands/agenthooks/sessiontally/sessiontally.go index 9d2196d84..38d28631b 100644 --- a/internal/commands/agenthooks/sessiontally/sessiontally.go +++ b/internal/commands/agenthooks/sessiontally/sessiontally.go @@ -89,7 +89,16 @@ func tallyPath(sessionID string) (string, bool) { } // Add appends one best-effort NDJSON record for (sessionID, engine). Never returns or panics. +// +// Disabled: writes are short-circuited so no .session-cx-tallies-* files are created under +// ~/.checkmarx. The session-end summary that would consume these tallies (via Load) is not wired up +// anywhere in this codebase yet, so there is no functional loss in keeping this a no-op. func Add(sessionID, engine string, foundDelta, remOfferedDelta int) { + + const disabled = true + if disabled { + return + } defer func() { _ = recover() }() path, ok := tallyPath(sessionID) if !ok { diff --git a/internal/commands/agenthooks/sessiontally/sessiontally_test.go b/internal/commands/agenthooks/sessiontally/sessiontally_test.go index dbe153c76..c2c13fea3 100644 --- a/internal/commands/agenthooks/sessiontally/sessiontally_test.go +++ b/internal/commands/agenthooks/sessiontally/sessiontally_test.go @@ -4,6 +4,7 @@ package sessiontally import ( "os" + "path/filepath" "sync" "testing" ) @@ -17,27 +18,32 @@ func sandbox(t *testing.T) { t.Setenv("USERPROFILE", home) } -func TestAddLoadClearRoundTrip(t *testing.T) { +// Add is currently disabled (see its doc comment): these tests assert that no matter what is passed +// in, Add never writes a tally file under ~/.checkmarx and Load/Clear stay safe no-ops as a result. + +func TestAddIsNoOpAndWritesNoFile(t *testing.T) { sandbox(t) Add("S1", "Asca", 2, 2) - Add("S1", "Asca", 1, 1) Add("S1", "Sca", 3, 1) + Add("", "Sca", 1, 1) - got := Load("S1") - if got["Asca"].VulnerabilitiesFound != 3 || got["Asca"].RemediationsOffered != 3 { - t.Errorf("Asca fold wrong: %+v", got["Asca"]) - } - if got["Sca"].VulnerabilitiesFound != 3 || got["Sca"].RemediationsOffered != 1 { - t.Errorf("Sca fold wrong: %+v", got["Sca"]) + if got := Load("S1"); len(got) != 0 { + t.Errorf("expected no tallies recorded, got %+v", got) } - Clear("S1") - if n := len(Load("S1")); n != 0 { - t.Errorf("expected empty after Clear, got %d engines", n) + dir, ok := baseDir() + if !ok { + t.Fatal("baseDir unavailable") + } + if _, err := os.Stat(dir); err == nil { + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Errorf("expected no files created under %s, got %v", dir, entries) + } } } -func TestConcurrentAddDoesNotLoseRecords(t *testing.T) { +func TestConcurrentAddStillNoOp(t *testing.T) { sandbox(t) var wg sync.WaitGroup for i := 0; i < 50; i++ { @@ -45,20 +51,8 @@ func TestConcurrentAddDoesNotLoseRecords(t *testing.T) { go func() { defer wg.Done(); Add("S1", "Asca", 1, 0) }() } wg.Wait() - if got := Load("S1")["Asca"].VulnerabilitiesFound; got != 50 { - t.Errorf("concurrent append lost records: got %d want 50", got) - } -} - -func TestEmptyIDUsesDefaultBucketAndIsMergedAndCleared(t *testing.T) { - sandbox(t) - Add("", "Sca", 1, 1) // empty id → shared default bucket - if got := Load("S1")["Sca"].VulnerabilitiesFound; got != 1 { - t.Errorf("Load should merge the default bucket: got %d", got) - } - Clear("S1") // Clear removes the default bucket too - if n := len(Load("other")); n != 0 { - t.Errorf("default bucket not cleared: got %d engines", n) + if got := Load("S1")["Asca"].VulnerabilitiesFound; got != 0 { + t.Errorf("Add is disabled, expected 0 got %d", got) } } @@ -77,16 +71,8 @@ func TestHostileIDStaysInsideCheckmarxDir(t *testing.T) { sandbox(t) id := "../../etc/passwd weird/id" Add(id, "Asca", 1, 1) - if Load(id)["Asca"].VulnerabilitiesFound != 1 { - t.Errorf("sanitized id round-trip failed") - } - dir, ok := baseDir() - if !ok { - t.Fatal("baseDir unavailable") - } - entries, err := os.ReadDir(dir) - if err != nil || len(entries) == 0 { - t.Fatalf("expected a tally file inside %s (err=%v)", dir, err) + if got := Load(id)["Asca"].VulnerabilitiesFound; got != 0 { + t.Errorf("Add is disabled, expected 0 got %d", got) } // The traversal must not have created a file outside ~/.checkmarx. if _, err := os.Stat("etc/passwd weird"); err == nil { @@ -96,16 +82,19 @@ func TestHostileIDStaysInsideCheckmarxDir(t *testing.T) { func TestMalformedLinesSkipped(t *testing.T) { sandbox(t) - Add("S1", "Asca", 1, 1) + // Add is disabled, so write the tally file directly to exercise Load's fold/skip logic. path, ok := tallyPath("S1") if !ok { t.Fatal("no tally path") } - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, filePerm) if err != nil { t.Fatal(err) } - _, _ = f.WriteString("not json\n{}\n{\"engine\":\"\",\"found\":9}\n") + _, _ = f.WriteString("not json\n{}\n{\"engine\":\"\",\"found\":9}\n{\"engine\":\"Asca\",\"found\":1,\"remOffered\":1}\n") _ = f.Close() if got := Load("S1")["Asca"].VulnerabilitiesFound; got != 1 { diff --git a/internal/commands/asca/asca_test.go b/internal/commands/asca/asca_test.go index 9fc1b2d24..363ea71a3 100644 --- a/internal/commands/asca/asca_test.go +++ b/internal/commands/asca/asca_test.go @@ -21,14 +21,14 @@ func TestInstallOrUpgrade_firstInstallation_Success(t *testing.T) { func firstInstallation() error { os.RemoveAll(ascaconfig.Params.WorkingDir()) - _, err := osinstaller.InstallOrUpgrade(&ascaconfig.Params) + _, err := osinstaller.InstallOrUpgrade(&ascaconfig.Params, nil) return err } func TestInstallOrUpgrade_installationIsUpToDate_Success(t *testing.T) { err := firstInstallation() assert.NilError(t, err, "Error on first installation of asca") - _, err = osinstaller.InstallOrUpgrade(&ascaconfig.Params) + _, err = osinstaller.InstallOrUpgrade(&ascaconfig.Params, nil) assert.NilError(t, err, "Error when not need to upgrade") } @@ -36,7 +36,7 @@ func TestInstallOrUpgrade_installationIsNotUpToDate_Success(t *testing.T) { err := firstInstallation() assert.NilError(t, err, "Error on first installation of asca") changeHashFile() - _, err = osinstaller.InstallOrUpgrade(&ascaconfig.Params) + _, err = osinstaller.InstallOrUpgrade(&ascaconfig.Params, nil) assert.NilError(t, err, "Error when need to upgrade") fileExists, _ := osinstaller.FileExists(ascaconfig.Params.ExecutableFilePath()) assert.Assert(t, fileExists, "Executable file not found") diff --git a/internal/commands/auth_login.go b/internal/commands/auth_login.go index 2c74e6aca..92daf3b83 100644 --- a/internal/commands/auth_login.go +++ b/internal/commands/auth_login.go @@ -15,15 +15,11 @@ import ( "github.com/spf13/viper" ) -// defaultLoginClientID matches the Keycloak client used by the Checkmarx One -// VS Code extension's OAuth flow. Confirmed via the official extension source -// (Checkmarx/ast-vscode-extension, packages/core/src/services/authService.ts). -// This client has localhost callbacks whitelisted across production tenants. +// defaultLoginClientID is the public PKCE client the IDE plugins use; its +// localhost callbacks are whitelisted and it needs no client secret. const defaultLoginClientID = "ide-integration" -// configFilePerm restricts the yaml config file to owner read/write only, since -// after login it holds a long-lived refresh token. Mirrors the 0o600 used for -// the global-session and active-mode files in the wrappers package. +// configFilePerm: owner-only, since the file holds a long-lived refresh token. const configFilePerm = 0o600 func newAuthLoginCommand() *cobra.Command { @@ -31,24 +27,20 @@ func newAuthLoginCommand() *cobra.Command { Use: "login", Short: "Authenticate to Checkmarx One via browser-based OAuth", Long: "Opens the default browser, walks the user through the Checkmarx One IAM login " + - "(including MFA), and persists the resulting refresh token. The --session flag picks " + - "the storage mode: default (yaml) for backward-compatible cross-shell persistence, " + - "'local' for current-shell env-only via Invoke-Expression / eval, or 'global' for a " + - "dedicated disk file shared across shells. Every login revokes any existing token " + - "server-side and clears file storage before issuing the new credential.", + "(including MFA), and saves the resulting refresh token to the config file's cx_apikey " + + "field — the same credential slot cx configure writes to, so every other command picks " + + "it up automatically.\n\n" + + "Requires --tenant and --base-uri (or --base-auth-uri). Pass them as flags, or run " + + "cx auth login with none and it prompts for the missing ones like cx configure.", Example: heredoc.Doc(` - # Default (yaml) — saves refresh token to ~/.checkmarx/checkmarxcli.yaml - $ cx auth login --tenant my-tenant + # With flags — saves the refresh token to ~/.checkmarx/checkmarxcli.yaml + $ cx auth login --base-uri https://.ast.checkmarx.net --tenant my-tenant - # Local session mode — refresh token lives in current shell's env var only - # PowerShell: - $ Invoke-Expression (cx auth login --tenant my-tenant --session local) - # bash / zsh: - $ eval "$(cx auth login --tenant my-tenant --session local)" + # No flags — prompts for base URI / tenant, then opens the browser + $ cx auth login - # Global session mode — refresh token persists in ~/.checkmarx/session_global, - # accessible to every shell, until explicit logout - $ cx auth login --tenant my-tenant --session global + # Print the authorization URL instead of opening a browser + $ cx auth login --base-uri https://.ast.checkmarx.net --tenant my-tenant --no-browser `), Annotations: map[string]string{ "command:doc": heredoc.Doc(` @@ -59,49 +51,28 @@ func newAuthLoginCommand() *cobra.Command { } cmd.Flags().Int(params.LoginPortFlag, 0, params.LoginPortFlagUsage) cmd.Flags().Bool(params.LoginNoBrowserFlag, false, params.LoginNoBrowserFlagUsage) - cmd.Flags().String(params.SessionFlag, "", params.SessionLoginFlagUsage) return cmd } func runAuthLogin(cmd *cobra.Command, _ []string) error { - // cx auth login starts a new login session. The user's explicit --tenant / - // --base-auth-uri flags must win over the realm URL embedded in any existing - // API key's JWT claims — they may be logging into a different tenant than - // their current credential is for. - viper.Set(params.ApikeyOverrideFlag, true) - - sessionMode, _ := cmd.Flags().GetString(params.SessionFlag) - if err := validateSessionFlag(sessionMode); err != nil { - return err + // Prompt for connection details like cx configure when none were passed as + // flags, so a re-login can still review/change base-uri / tenant. + if !connectionFlagsProvided(cmd) { + configuration.PromptAuthConnection() } + // Force explicit --tenant/--base-* to win over the realm in any stored key. + // Set after the prompt so the prompt's write isn't persisted here. + viper.Set(params.ApikeyOverrideFlag, true) + realmURL, err := wrappers.GetRealmURL() if err != nil { return errors.Wrap(err, "failed to resolve IAM realm URL") } - // revokeClientID is used ONLY for the best-effort revocation of any - // PRE-EXISTING stored tokens during the nuke phase. It intentionally keeps - // the CX_CLIENT_ID fallback so that a credential originally issued to that - // client can still be revoked. It is NOT used for the interactive login - // below (see the ClientID note on LoginWithPKCE). - revokeClientID := viper.GetString(params.AccessKeyIDConfigKey) - if revokeClientID == "" { - revokeClientID = defaultLoginClientID - } - port, _ := cmd.Flags().GetInt(params.LoginPortFlag) noBrowser, _ := cmd.Flags().GetBool(params.LoginNoBrowserFlag) - // Authenticate FIRST and only touch existing credentials once we hold a - // fresh refresh token. If the browser flow fails or is cancelled (closed - // tab, timeout, port clash, network blip), the user's existing credential - // is left completely intact instead of being wiped before login even runs. - // - // The interactive PKCE flow MUST use the public 'ide-integration' client - // (its localhost callbacks are whitelisted and it needs no client secret). - // CX_CLIENT_ID is a confidential service-account client and cannot complete - // an Authorization Code + PKCE flow, so it is deliberately NOT used here. tokens, err := wrappers.LoginWithPKCE(context.Background(), wrappers.PKCELoginOptions{ RealmURL: realmURL, ClientID: defaultLoginClientID, @@ -112,93 +83,17 @@ func runAuthLogin(cmd *cobra.Command, _ []string) error { return err } - // Nuke phase: now that a new credential exists, revoke every prior refresh - // token server-side and clear the file storages. Combined with the persist - // step below this leaves exactly one active credential in the storage - // matching --session. - nukeAllStorages(revokeClientID) - - switch sessionMode { - case params.SessionLocalValue: - return persistLocalLogin(cmd, tokens.RefreshToken) - case params.SessionGlobalValue: - return persistGlobalLogin(cmd, tokens.RefreshToken) - default: - return persistYamlLogin(cmd, tokens.RefreshToken) - } -} - -// validateSessionFlag enforces that --session is either unset, "local", or -// "global". Any other value gets a clear error instead of silently falling -// through to default-mode behavior. -func validateSessionFlag(sessionMode string) error { - switch sessionMode { - case "", params.SessionLocalValue, params.SessionGlobalValue: - return nil - default: - return errors.Errorf("invalid --session value %q: must be %q or %q", - sessionMode, params.SessionLocalValue, params.SessionGlobalValue) - } -} - -// nukeAllStorages revokes the tokens the CLI actually owns — the yaml config -// file and the global session file — at IAM (best-effort, via the OAuth 2.0 -// revocation endpoint) and clears those file storages. -// -// The CX_APIKEY environment variable is deliberately left untouched: a child -// process cannot clear a parent shell's env var, and that env value is most -// often a deliberately-provided CI / long-lived credential. Silently revoking -// it server-side would break the caller's pipeline, so we never revoke env. -// -// This is called as the first step of every login (regardless of mode) and -// of every logout, ensuring that the CLI's own file storages hold at most one -// active credential after the operation completes. -func nukeAllStorages(clientID string) { - // Revoke yaml's token first — read the yaml file directly to bypass any - // stale env shadowing in viper's normal lookup. - if yamlRT := wrappers.ReadYamlAPIKey(); yamlRT != "" { - revokeOldRefreshToken(yamlRT, clientID, "yaml") - } - if globalRT, err := wrappers.ReadSessionGlobal(); err == nil && globalRT != "" { - revokeOldRefreshToken(globalRT, clientID, "global") - } - clearFileStorages() -} - -// revokeOldRefreshToken POSTs the given refresh token to the realm extracted -// from its own JWT "aud" claim. Best-effort — failures are logged at verbose -// level so a missing realm claim or a non-2xx response doesn't block the new -// login. -func revokeOldRefreshToken(refreshToken, clientID, sourceLabel string) { - realmURL, err := wrappers.ExtractFromTokenClaims(refreshToken, audClaim) - if err != nil || realmURL == "" { - logger.PrintIfVerbose(fmt.Sprintf("could not extract realm from %s refresh token (skipping revoke): %v", sourceLabel, err)) - return - } - if err := wrappers.RevokeRefreshToken(context.Background(), realmURL, clientID, refreshToken); err != nil { - logger.PrintIfVerbose(fmt.Sprintf("revoke of %s refresh token failed (continuing): %v", sourceLabel, err)) - } + return persistYamlLogin(cmd, tokens.RefreshToken) } -// clearFileStorages empties the yaml cx_apikey field and deletes the global -// session file. Best-effort — failures are logged at verbose level. Env is -// not touched here; that's done via shell-eval emission for local-mode -// logins or by the user closing their shell. -func clearFileStorages() { - if configPath, err := configuration.GetConfigFilePath(); err == nil { - if writeErr := configuration.SafeWriteSingleConfigKeyString(configPath, params.AstAPIKey, ""); writeErr != nil { - logger.PrintIfVerbose(fmt.Sprintf("failed to clear yaml cx_apikey: %v", writeErr)) - } - } - if err := wrappers.ClearSessionGlobal(); err != nil { - logger.PrintIfVerbose(fmt.Sprintf("failed to clear global session file: %v", err)) - } +// connectionFlagsProvided reports whether any connection detail was passed as a flag. +func connectionFlagsProvided(cmd *cobra.Command) bool { + return cmd.Flags().Changed(params.BaseURIFlag) || + cmd.Flags().Changed(params.BaseAuthURIFlag) || + cmd.Flags().Changed(params.TenantFlag) } -// persistYamlLogin writes the new refresh token to the yaml config file and -// records yaml as the active mode. The token is NOT echoed to stdout — it is -// already persisted to the config file, and printing it would leak the -// credential into shell history / CI logs. +// persistYamlLogin saves the refresh token to cx_apikey; never echoes it to stdout. func persistYamlLogin(cmd *cobra.Command, refreshToken string) error { configPath, err := configuration.GetConfigFilePath() if err != nil { @@ -207,50 +102,10 @@ func persistYamlLogin(cmd *cobra.Command, refreshToken string) error { if err := configuration.SafeWriteSingleConfigKeyString(configPath, params.AstAPIKey, refreshToken); err != nil { return errors.Wrap(err, "failed to save refresh token to config file") } - // The config file now holds a long-lived refresh token; restrict it to - // owner read/write only (matching the 0o600 used for the global session - // and active-mode files). On Windows this is a best-effort no-op. + // Restrict to owner-only; best-effort no-op on Windows. if chErr := os.Chmod(configPath, configFilePerm); chErr != nil { logger.PrintIfVerbose(fmt.Sprintf("failed to restrict config file permissions: %v", chErr)) } - if err := wrappers.WriteActiveMode(params.SessionYamlValue); err != nil { - logger.PrintIfVerbose(fmt.Sprintf("failed to write active-mode file: %v", err)) - } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Authenticated. Token saved to %s\n", configPath) - return nil -} - -// persistGlobalLogin writes the new refresh token to the dedicated global -// session file and records global as the active mode. No env-var emission — -// global mode is a plain command (no Invoke-Expression wrapper). -func persistGlobalLogin(cmd *cobra.Command, refreshToken string) error { - if err := wrappers.WriteSessionGlobal(refreshToken); err != nil { - return errors.Wrap(err, "failed to write global session file") - } - if err := wrappers.WriteActiveMode(params.SessionGlobalValue); err != nil { - logger.PrintIfVerbose(fmt.Sprintf("failed to write active-mode file: %v", err)) - } - path, _ := wrappers.SessionGlobalFilePath() - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Authenticated. Token saved to %s (global session — persists across shells until explicit logout).\n", path) - return nil -} - -// persistLocalLogin records local as the active mode and emits a single -// shell-evaluable line to stdout: a defensive reset of CX_APIKEY followed by -// the new refresh-token assignment, separated by `;` so the whole emission -// stays on one line. PowerShell's Invoke-Expression accepts only a single -// string argument, so multi-line stdout would be captured as a string array -// and rejected. Bash's `eval` and fish's `;` statement separator handle the -// same single-line form correctly. Informational text goes to stderr to -// keep stdout strictly evaluable. -func persistLocalLogin(cmd *cobra.Command, refreshToken string) error { - if err := wrappers.WriteActiveMode(params.SessionLocalValue); err != nil { - logger.PrintIfVerbose(fmt.Sprintf("failed to write active-mode file: %v", err)) - } - shell := detectShell() - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s; %s\n", - formatEnvAssignment(shell, params.AstAPIKeyEnv, ""), - formatEnvAssignment(shell, params.AstAPIKeyEnv, refreshToken)) - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Authenticated. Wrap with Invoke-Expression (PowerShell) or eval (bash) to apply.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Successfully authenticated to Checkmarx One server!") return nil } diff --git a/internal/commands/auth_login_test.go b/internal/commands/auth_login_test.go index 1c160ef07..4b2eb7c11 100644 --- a/internal/commands/auth_login_test.go +++ b/internal/commands/auth_login_test.go @@ -4,27 +4,20 @@ package commands import ( "bytes" - "os" "path/filepath" "strings" "testing" "github.com/checkmarx/ast-cli/internal/params" - "github.com/checkmarx/ast-cli/internal/wrappers" "github.com/checkmarx/ast-cli/internal/wrappers/configuration" "github.com/spf13/cobra" "github.com/spf13/viper" ) -// Full runAuthLogin coverage is out of scope for unit tests: it opens a browser -// and runs the PKCE network exchange (wrappers.LoginWithPKCE). These tests cover -// the deterministic, network-free pieces — the persist* writers, clearFileStorages, -// nukeAllStorages' env-safety, and the universal logout — which is also where the -// security fixes (#4 no token to stdout, #5 env token never revoked) live. +// The full runAuthLogin (browser + network) is out of scope; these cover the +// deterministic pieces: persistYamlLogin and runAuthLogout. -// withTempConfigDir points viper at a temp config file for one test so the auth -// storage helpers operate on a sandbox instead of the real ~/.checkmarx. Also -// clears CX_APIKEY so env never shadows the sandbox. +// withTempConfigDir sandboxes viper at a temp config file and clears CX_APIKEY. func withTempConfigDir(t *testing.T) string { t.Helper() dir := t.TempDir() @@ -44,9 +37,24 @@ func newBufferedCmd() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { return cmd, &out, &errOut } -// TestPersistYamlLogin_DoesNotPrintToken locks in fix #4: the refresh token must -// be saved to the yaml file but never echoed to stdout (it would leak into shell -// history / CI logs). +// readYamlAPIKey reads cx_apikey directly from the sandbox yaml file. +func readYamlAPIKey(t *testing.T) string { + t.Helper() + configPath, err := configuration.GetConfigFilePath() + if err != nil { + t.Fatalf("GetConfigFilePath failed: %v", err) + } + yamlConfig, err := configuration.LoadConfig(configPath) + if err != nil { + return "" + } + if v, ok := yamlConfig[params.AstAPIKey].(string); ok { + return v + } + return "" +} + +// Token must be saved to yaml but never echoed to stdout. func TestPersistYamlLogin_DoesNotPrintToken(t *testing.T) { withTempConfigDir(t) const token = "super-secret-refresh-token" @@ -60,127 +68,65 @@ func TestPersistYamlLogin_DoesNotPrintToken(t *testing.T) { if strings.Contains(stdout, token) { t.Errorf("refresh token leaked to stdout: %q", stdout) } - if !strings.Contains(stdout, "Authenticated. Token saved to") { + if !strings.Contains(stdout, "Successfully authenticated to Checkmarx One server!") { t.Errorf("expected confirmation line, got: %q", stdout) } - // Token must actually be persisted to the yaml file. - if got := wrappers.ReadYamlAPIKey(); got != token { + if got := readYamlAPIKey(t); got != token { t.Errorf("expected token persisted to yaml, got %q", got) } - if mode, _ := wrappers.ReadActiveMode(); mode != params.SessionYamlValue { - t.Errorf("expected active mode %q, got %q", params.SessionYamlValue, mode) - } } -// TestPersistGlobalLogin_WritesFileAndNoToken: global mode persists to the global -// session file and prints only the path — never the token. -func TestPersistGlobalLogin_WritesFileAndNoToken(t *testing.T) { - withTempConfigDir(t) - const token = "global-refresh-token" - - cmd, out, _ := newBufferedCmd() - if err := persistGlobalLogin(cmd, token); err != nil { - t.Fatalf("persistGlobalLogin failed: %v", err) - } - - if strings.Contains(out.String(), token) { - t.Errorf("refresh token leaked to stdout: %q", out.String()) - } - if got, err := wrappers.ReadSessionGlobal(); err != nil || got != token { - t.Errorf("expected token in global session file, got %q (err=%v)", got, err) - } - if mode, _ := wrappers.ReadActiveMode(); mode != params.SessionGlobalValue { - t.Errorf("expected active mode %q, got %q", params.SessionGlobalValue, mode) - } -} - -// TestPersistLocalLogin_EmitsShellEval: local mode intentionally emits a single -// shell-evaluable line (reset + assignment) to stdout — the token IS present -// there by design (it lives only in the shell env). -func TestPersistLocalLogin_EmitsShellEval(t *testing.T) { - withTempConfigDir(t) - const token = "local-refresh-token" - - cmd, out, errOut := newBufferedCmd() - if err := persistLocalLogin(cmd, token); err != nil { - t.Fatalf("persistLocalLogin failed: %v", err) - } - - stdout := out.String() - if !strings.Contains(stdout, params.AstAPIKeyEnv) { - t.Errorf("expected env-var name in stdout, got: %q", stdout) - } - if !strings.Contains(stdout, token) { - t.Errorf("local mode must emit the token for eval, got: %q", stdout) - } - if !strings.Contains(errOut.String(), "Authenticated") { - t.Errorf("expected info line on stderr, got: %q", errOut.String()) - } - if mode, _ := wrappers.ReadActiveMode(); mode != params.SessionLocalValue { - t.Errorf("expected active mode %q, got %q", params.SessionLocalValue, mode) +// Prompt is skipped only when a connection detail is passed as a flag; with no +// flags login always prompts (parity with cx configure, incl. re-login after logout). +func TestConnectionFlagsProvided(t *testing.T) { + cases := []struct { + name string + args []string + want bool + }{ + {"no flags", []string{}, false}, + {"base-uri", []string{"--base-uri", "https://x"}, true}, + {"base-auth-uri", []string{"--base-auth-uri", "https://x"}, true}, + {"tenant", []string{"--tenant", "t"}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "login", RunE: func(*cobra.Command, []string) error { return nil }} + cmd.Flags().String(params.BaseURIFlag, "", "") + cmd.Flags().String(params.BaseAuthURIFlag, "", "") + cmd.Flags().String(params.TenantFlag, "", "") + cmd.SetArgs(tc.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if got := connectionFlagsProvided(cmd); got != tc.want { + t.Errorf("connectionFlagsProvided() = %v, want %v", got, tc.want) + } + }) } } -// TestClearFileStorages_ClearsYamlAndGlobal: clearing empties the yaml cx_apikey -// and removes the global session file. -func TestClearFileStorages_ClearsYamlAndGlobal(t *testing.T) { +// Logout clears cx_apikey and is idempotent. +func TestRunAuthLogout_ClearsYaml(t *testing.T) { dir := withTempConfigDir(t) configPath := filepath.Join(dir, "checkmarxcli.yaml") - if err := configuration.SafeWriteSingleConfigKeyString(configPath, params.AstAPIKey, "yaml-token"); err != nil { + if err := configuration.SafeWriteSingleConfigKeyString(configPath, params.AstAPIKey, "stored-token"); err != nil { t.Fatalf("setup yaml write failed: %v", err) } - if err := wrappers.WriteSessionGlobal("global-token"); err != nil { - t.Fatalf("setup global write failed: %v", err) - } - - clearFileStorages() - - if got := wrappers.ReadYamlAPIKey(); got != "" { - t.Errorf("expected yaml cx_apikey cleared, got %q", got) - } - if got, _ := wrappers.ReadSessionGlobal(); got != "" { - t.Errorf("expected global session cleared, got %q", got) - } -} - -// TestNukeAllStorages_DoesNotRevokeOrClearEnv locks in fix #5: an env-var token is -// neither cleared nor touched by the nuke (the CLI can't clear a parent shell's -// env, and it is often a deliberate CI credential). With empty file storages this -// makes no network call. -func TestNukeAllStorages_DoesNotRevokeOrClearEnv(t *testing.T) { - withTempConfigDir(t) - const envToken = "ci-env-refresh-token" - t.Setenv(params.AstAPIKeyEnv, envToken) - - // No yaml or global token present → no revocation network call is attempted. - nukeAllStorages(defaultLoginClientID) - // The env var must remain exactly as the caller set it. - if got := os.Getenv(params.AstAPIKeyEnv); got != envToken { - t.Errorf("nukeAllStorages must not alter the env token: got %q, want %q", got, envToken) - } -} - -// TestRunAuthLogout_EmptyStorage emits a shell-clear of CX_APIKEY, clears the -// active mode, and makes no network call when no token is stored. -func TestRunAuthLogout_EmptyStorage(t *testing.T) { - withTempConfigDir(t) - if err := wrappers.WriteActiveMode(params.SessionYamlValue); err != nil { - t.Fatalf("setup active mode failed: %v", err) - } - - cmd, out, errOut := newBufferedCmd() + cmd, out, _ := newBufferedCmd() if err := runAuthLogout(cmd, nil); err != nil { t.Fatalf("runAuthLogout failed: %v", err) } - - if !strings.Contains(out.String(), params.AstAPIKeyEnv) { - t.Errorf("expected shell-clear of %s on stdout, got: %q", params.AstAPIKeyEnv, out.String()) + if got := readYamlAPIKey(t); got != "" { + t.Errorf("expected yaml cx_apikey cleared, got %q", got) } - if !strings.Contains(errOut.String(), "Logged out") { - t.Errorf("expected logout info on stderr, got: %q", errOut.String()) + if !strings.Contains(out.String(), "Successfully logged out of Checkmarx One server!") { + t.Errorf("expected logout confirmation, got: %q", out.String()) } - if mode, _ := wrappers.ReadActiveMode(); mode != "" { - t.Errorf("expected active mode cleared after logout, got %q", mode) + + // Idempotent: running again on empty storage must not error. + if err := runAuthLogout(cmd, nil); err != nil { + t.Fatalf("second runAuthLogout failed: %v", err) } } diff --git a/internal/commands/auth_logout.go b/internal/commands/auth_logout.go index 441decda8..b32a4d1e8 100644 --- a/internal/commands/auth_logout.go +++ b/internal/commands/auth_logout.go @@ -4,62 +4,36 @@ import ( "fmt" "github.com/MakeNowJust/heredoc" - "github.com/checkmarx/ast-cli/internal/logger" "github.com/checkmarx/ast-cli/internal/params" - "github.com/checkmarx/ast-cli/internal/wrappers" + "github.com/checkmarx/ast-cli/internal/wrappers/configuration" + "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/spf13/viper" ) -// audClaim is the OIDC "audience" JWT claim. For Keycloak refresh tokens it -// holds the realm URL — exactly the URL we POST to for revocation. -const audClaim = "aud" - func newAuthLogoutCommand() *cobra.Command { return &cobra.Command{ Use: "logout", - Short: "Revoke the current refresh token and clear stored credentials", - Long: "Revokes the current refresh token at Checkmarx One IAM and clears every storage " + - "location: yaml cx_apikey, the global session file, and emits a shell-evaluable " + - "clear of CX_APIKEY for users who logged in via --session local. One universal " + - "logout — no --session flag needed; the active mode tells the CLI what to clean up.", + Short: "Clear the stored Checkmarx One credential", + Long: "Clears the cx_apikey stored in the config file. Idempotent — running it when no " + + "credential is stored is a no-op. Credentials provided via the CX_APIKEY or " + + "CX_CLIENT_ID/CX_CLIENT_SECRET environment variables are not affected.", Example: heredoc.Doc(` - # Default usage (clears yaml and the global file, revokes server-side) $ cx auth logout - - # If the current shell was logged in via --session local, also wrap the - # logout with Invoke-Expression so $env:CX_APIKEY gets cleared too - # PowerShell: - $ Invoke-Expression (cx auth logout) - # bash / zsh: - $ eval "$(cx auth logout)" `), RunE: runAuthLogout, } } -// runAuthLogout is the universal logout: it nukes every storage location's -// credential (server-side revoke + local clear), deletes the active-mode -// metadata file, and emits a shell-clear line so users who wrap the call -// with Invoke-Expression / eval also have CX_APIKEY cleared in their shell. +// runAuthLogout clears the cx_apikey field in the yaml config file. The +// client-credentials and env-provided credentials are intentionally left alone. func runAuthLogout(cmd *cobra.Command, _ []string) error { - clientID := viper.GetString(params.AccessKeyIDConfigKey) - if clientID == "" { - clientID = defaultLoginClientID + configPath, err := configuration.GetConfigFilePath() + if err != nil { + return errors.Wrap(err, "failed to resolve config file path") } - - nukeAllStorages(clientID) - - if err := wrappers.ClearActiveMode(); err != nil { - logger.PrintIfVerbose(fmt.Sprintf("failed to remove active-mode file: %v", err)) + if err := configuration.SafeWriteSingleConfigKeyString(configPath, params.AstAPIKey, ""); err != nil { + return errors.Wrap(err, "failed to clear stored credential") } - - // Always emit a shell-clear of CX_APIKEY to stdout. Wrapping the logout - // with Invoke-Expression (PowerShell) or eval (bash) clears the env var - // in the current shell. Without the wrapper the line just prints — no - // harm done for users who didn't use --session local. - shell := detectShell() - _, _ = fmt.Fprintln(cmd.OutOrStdout(), formatEnvAssignment(shell, params.AstAPIKeyEnv, "")) - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Logged out. If you used --session local in this shell, wrap with Invoke-Expression (PowerShell) or eval (bash) to clear CX_APIKEY.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Successfully logged out of Checkmarx One server!") return nil } diff --git a/internal/commands/auth_session_test.go b/internal/commands/auth_session_test.go deleted file mode 100644 index 82af65ee9..000000000 --- a/internal/commands/auth_session_test.go +++ /dev/null @@ -1,45 +0,0 @@ -//go:build !integration - -package commands - -import ( - "strings" - "testing" - - "github.com/checkmarx/ast-cli/internal/params" -) - -func TestValidateSessionFlag(t *testing.T) { - cases := []struct { - name string - value string - wantErr bool - errMatch string // substring expected in error message - }{ - {name: "empty is valid (default yaml mode)", value: "", wantErr: false}, - {name: "local is valid", value: params.SessionLocalValue, wantErr: false}, - {name: "global is valid", value: params.SessionGlobalValue, wantErr: false}, - {name: "rejects unknown value", value: "yolo", wantErr: true, errMatch: "invalid --session value"}, - {name: "rejects empty-looking but not equal", value: " ", wantErr: true, errMatch: "invalid --session value"}, - {name: "rejects case mismatch", value: "Local", wantErr: true, errMatch: "invalid --session value"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := validateSessionFlag(tc.value) - if tc.wantErr { - if err == nil { - t.Errorf("expected error for value %q, got nil", tc.value) - return - } - if tc.errMatch != "" && !strings.Contains(err.Error(), tc.errMatch) { - t.Errorf("expected error containing %q, got %q", tc.errMatch, err.Error()) - } - return - } - if err != nil { - t.Errorf("expected no error for value %q, got: %v", tc.value, err) - } - }) - } -} diff --git a/internal/commands/ignore_vulnerability.go b/internal/commands/ignore_vulnerability.go index 6e2d9f35c..092f7c3c6 100644 --- a/internal/commands/ignore_vulnerability.go +++ b/internal/commands/ignore_vulnerability.go @@ -147,15 +147,18 @@ func logIgnoreTelemetry(telemetryWrapper wrappers.TelemetryWrapper, scanType str } aiProvider := utils.GetOptionalParam("aiProvider") + sessionID := utils.GetOptionalParam("aiAgentSessionId") + agent := utils.GetOptionalParam("agent") engine := engineName(scanType) telemetryData := &wrappers.DataForAITelemetry{ - AIProvider: aiProvider, - Agent: aiProvider + "-cli", - Engine: engine, - ScanType: strings.ToLower(engine), - UniqueID: wrappers.GetUniqueID(), - Type: "hooks-ignore", - SubType: "ignorePackage", + AIProvider: aiProvider, + Agent: agent, + Engine: engine, + ScanType: strings.ToLower(engine), + UniqueID: wrappers.GetUniqueID(), + Type: "hooks-ignore", + SubType: "ignorePackage", + AiAgentSessionId: sessionID, } if err := telemetryWrapper.SendAIDataToLog(telemetryData); err != nil { diff --git a/internal/commands/result.go b/internal/commands/result.go index 378022fec..a8aa5ef7f 100644 --- a/internal/commands/result.go +++ b/internal/commands/result.go @@ -2117,7 +2117,7 @@ func translateReportSectionsForImproved(sections []string) []string { func convertCxResultsToSarif(results *wrappers.ScanResultsCollection) *wrappers.SarifResultsCollection { var sarif = new(wrappers.SarifResultsCollection) - sarif.Schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json" + sarif.Schema = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json" sarif.Version = "2.1.0" sarif.Runs = []wrappers.SarifRun{} sarif.Runs = append(sarif.Runs, createSarifRun(results)) @@ -2558,6 +2558,21 @@ func getSastStartColumn(column uint) uint { return column - 1 } +func ensureSarifRegionCoordinate(value uint) uint { + if value < 1 { + return 1 + } + return value +} + +func sarifEndColumn(startColumn, length uint) uint { + endColumn := startColumn + length + if endColumn <= startColumn { + return startColumn + 1 + } + return endColumn +} + func findRuleID(result *wrappers.ScanResult) (ruleID, ruleName, shortMessage string) { caser := cases.Title(language.English) @@ -2734,7 +2749,7 @@ func parseSarifResultKics(result *wrappers.ScanResult, scanResults []wrappers.Sa 1, ) scanLocation.PhysicalLocation.Region = &wrappers.SarifRegion{} - scanLocation.PhysicalLocation.Region.StartLine = result.ScanResultData.Line + scanLocation.PhysicalLocation.Region.StartLine = ensureSarifRegionCoordinate(result.ScanResultData.Line) scanLocation.PhysicalLocation.Region.StartColumn = 1 scanLocation.PhysicalLocation.Region.EndColumn = 2 scanResult.Locations = append(scanResult.Locations, scanLocation) @@ -2759,29 +2774,31 @@ func parseSarifResultSast(result *wrappers.ScanResult, scanResults []wrappers.Sa } scanLocation.PhysicalLocation.Region = &wrappers.SarifRegion{} scanLocation.PhysicalLocation.Region.StartLine = node.Line - column := node.Column - length := node.Length + column := ensureSarifRegionCoordinate(node.Column) scanLocation.PhysicalLocation.Region.StartColumn = column - scanLocation.PhysicalLocation.Region.EndColumn = column + length + scanLocation.PhysicalLocation.Region.EndColumn = sarifEndColumn(column, node.Length) allLocations = append(allLocations, scanLocation) } } - if len(allLocations) > 0 { - var threadFlowLocations []wrappers.SarifThreadFlowLocation - for _, loc := range allLocations { - threadFlowLocations = append(threadFlowLocations, wrappers.SarifThreadFlowLocation{Location: loc}) - } - scanResult.CodeFlows = []wrappers.SarifCodeFlow{ - { - ThreadFlows: []wrappers.SarifThreadFlow{ - { - Locations: threadFlowLocations, - }, + if len(allLocations) == 0 { + return scanResults + } + + scanResult.Locations = append(scanResult.Locations, allLocations[0]) + var threadFlowLocations []wrappers.SarifThreadFlowLocation + for _, loc := range allLocations { + threadFlowLocations = append(threadFlowLocations, wrappers.SarifThreadFlowLocation{Location: loc}) + } + scanResult.CodeFlows = []wrappers.SarifCodeFlow{ + { + ThreadFlows: []wrappers.SarifThreadFlow{ + { + Locations: threadFlowLocations, }, }, - } + }, } scanResults = append(scanResults, scanResult) @@ -2804,7 +2821,7 @@ func parseSarifResultsSscs(result *wrappers.ScanResult, scanResults []wrappers.S } scanLocation.PhysicalLocation.Region = &wrappers.SarifRegion{} - scanLocation.PhysicalLocation.Region.StartLine = result.ScanResultData.Line + scanLocation.PhysicalLocation.Region.StartLine = ensureSarifRegionCoordinate(result.ScanResultData.Line) scanLocation.PhysicalLocation.Region.StartColumn = 1 scanLocation.PhysicalLocation.Region.EndColumn = 2 if result.ScanResultData.Snippet != "" { diff --git a/internal/commands/result_test.go b/internal/commands/result_test.go index dff116ed3..ce82ad0ed 100644 --- a/internal/commands/result_test.go +++ b/internal/commands/result_test.go @@ -19,7 +19,6 @@ import ( "github.com/checkmarx/ast-cli/internal/wrappers" "github.com/checkmarx/ast-cli/internal/wrappers/mock" "github.com/pkg/errors" - assertion "github.com/stretchr/testify/assert" "golang.org/x/text/cases" "golang.org/x/text/language" "gotest.tools/assert" @@ -369,6 +368,59 @@ func TestParseSarifEmptyResultSast(t *testing.T) { } } +func TestParseSarifResultSastClampsZeroColumns(t *testing.T) { + result := &wrappers.ScanResult{ + ScanResultData: wrappers.ScanResultData{ + Nodes: []*wrappers.ScanResultNode{ + {FileName: "/src/app.go", Line: 10, Column: 0, Length: 0}, + {FileName: "/src/app.go", Line: 12, Column: 0, Length: 5}, + }, + }, + } + + sarifResults := parseSarifResultSast(result, nil) + assert.Assert(t, len(sarifResults) == 1) + assert.Assert(t, len(sarifResults[0].Locations) == 1) + + primary := sarifResults[0].Locations[0].PhysicalLocation.Region + assert.Equal(t, uint(1), primary.StartColumn) + assert.Equal(t, uint(2), primary.EndColumn) + + threadLocations := sarifResults[0].CodeFlows[0].ThreadFlows[0].Locations + assert.Equal(t, uint(1), threadLocations[0].Location.PhysicalLocation.Region.StartColumn) + assert.Equal(t, uint(2), threadLocations[0].Location.PhysicalLocation.Region.EndColumn) + assert.Equal(t, uint(1), threadLocations[1].Location.PhysicalLocation.Region.StartColumn) + assert.Equal(t, uint(6), threadLocations[1].Location.PhysicalLocation.Region.EndColumn) +} + +func TestParseSarifResultKicsClampsZeroStartLine(t *testing.T) { + result := &wrappers.ScanResult{ + ScanResultData: wrappers.ScanResultData{ + Filename: "/Dockerfile", + Line: 0, + }, + } + + sarifResults := parseSarifResultKics(result, nil) + assert.Assert(t, len(sarifResults) == 1) + assert.Equal(t, uint(1), sarifResults[0].Locations[0].PhysicalLocation.Region.StartLine) +} + +func TestParseSarifResultsSscsClampsZeroStartLine(t *testing.T) { + result := &wrappers.ScanResult{ + Type: params.SCSSecretDetectionType, + Description: "secret found", + ScanResultData: wrappers.ScanResultData{ + Filename: "config.yaml", + Line: 0, + }, + } + + sarifResults := parseSarifResultsSscs(result, nil) + assert.Assert(t, len(sarifResults) == 1) + assert.Equal(t, uint(1), sarifResults[0].Locations[0].PhysicalLocation.Region.StartLine) +} + func TestRunGetResultsByScanIdSonarFormat(t *testing.T) { execCmdNilAssertion(t, "results", "show", "--scan-id", "MOCK", "--report-format", "sonar") // Remove generated sonar file @@ -944,7 +996,14 @@ func assertURINonEmpty(t *testing.T) { var scanResults *wrappers.SarifResultsCollection err = json.Unmarshal(reportBytes, &scanResults) assert.NilError(t, err, "Error unmarshalling SARIF results") - assertion.Contains(t, scanResults.Runs[0].Results[10].Locations[0].PhysicalLocation.ArtifactLocation.URI, "This alert has no associated file") + + for i := range scanResults.Runs[0].Results { + locations := scanResults.Runs[0].Results[i].Locations + if len(locations) > 0 && strings.Contains(locations[0].PhysicalLocation.ArtifactLocation.URI, "This alert has no associated file") { + return + } + } + assert.Assert(t, false, "expected a SARIF result with the no-associated-file placeholder URI, found none") } func assertRulePresentSarif(t *testing.T, ruleID string, scanResultsCollection *wrappers.SarifResultsCollection) { diff --git a/internal/commands/scan.go b/internal/commands/scan.go index e5a5b83fb..cc50ab43e 100644 --- a/internal/commands/scan.go +++ b/internal/commands/scan.go @@ -28,6 +28,7 @@ import ( "github.com/checkmarx/ast-cli/internal/commands/util/printer" "github.com/checkmarx/ast-cli/internal/constants" exitCodes "github.com/checkmarx/ast-cli/internal/constants/exit-codes" + "github.com/checkmarx/ast-cli/internal/filtering" "github.com/checkmarx/ast-cli/internal/logger" "github.com/checkmarx/ast-cli/internal/services" "github.com/checkmarx/ast-cli/internal/services/osinstaller" @@ -925,6 +926,7 @@ func scanCreateSubCommand( createScanCmd.PersistentFlags().Bool(commonParams.SbomFlag, false, "Scan only the specified SBOM file (supported formats xml or json)") createScanCmd.PersistentFlags().Bool(commonParams.NoScanFlag, false, "Prevents CxOne scan from running after SBOM is generated locally. Relevant only when --sbom-first is submitted under --sca-resolver-params. Submitting this flag without --sbom-first causes an error.") createScanCmd.PersistentFlags().Bool(commonParams.GitIgnoreFileFilterFlag, false, commonParams.GitIgnoreFileFilterUsage) + createScanCmd.PersistentFlags().StringSlice(commonParams.AntFilterFlag, []string{}, commonParams.AntFilterUsage) return createScanCmd } @@ -1641,7 +1643,7 @@ func scanTypeEnabled(scanType string) bool { return false } -func compressFolder(sourceDir, filter, userIncludeFilter, scaResolver string) (string, error) { +func compressFolder(sourceDir, filter, userIncludeFilter, scaResolver string, antMatcher filtering.Matcher) (string, error) { scaToolPath := scaResolver outputFile, err := os.CreateTemp(os.TempDir(), "cx-*.zip") if err != nil { @@ -1651,7 +1653,7 @@ func compressFolder(sourceDir, filter, userIncludeFilter, scaResolver string) (s zipWriter := zip.NewWriter(outputFile) // First check if the directory is empty or all files are filtered out - isEmpty, err := isDirEmpty(sourceDir, getExcludeFilters(filter), getIncludeFilters(userIncludeFilter)) + isEmpty, err := isDirEmpty(sourceDir, getExcludeFilters(filter), getIncludeFilters(userIncludeFilter), antMatcher) if err != nil { return "", err } @@ -1669,7 +1671,7 @@ func compressFolder(sourceDir, filter, userIncludeFilter, scaResolver string) (s } } else { // Add directory files normally - err = addDirFiles(zipWriter, "", sourceDir, getExcludeFilters(filter), getIncludeFilters(userIncludeFilter)) + err = addDirFiles(zipWriter, "", sourceDir, getExcludeFilters(filter), getIncludeFilters(userIncludeFilter), antMatcher) if err != nil { return "", err } @@ -1701,7 +1703,7 @@ func isSingleContainerScanTriggered() bool { } // isDirEmpty checks if a directory is empty or if all files are filtered out -func isDirEmpty(dir string, excludeFilters, includeFilters []string) (bool, error) { +func isDirEmpty(dir string, excludeFilters, includeFilters []string, antMatcher filtering.Matcher) (bool, error) { empty := true err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { @@ -1714,20 +1716,32 @@ func isDirEmpty(dir string, excludeFilters, includeFilters []string) (bool, erro return nil } - // Skip directories - if info.IsDir() { - return nil - } - // Get relative path relPath, err := filepath.Rel(dir, path) if err != nil { return err } + relPathSlash := filepath.ToSlash(relPath) + + // Prune excluded directories rather than descending into them. + if info.IsDir() { + legacyFiltered, fErr := isDirFiltered(info.Name(), excludeFilters) + if fErr != nil { + return fErr + } + if legacyFiltered { + return filepath.SkipDir + } + if antMatcher.Excluded(relPathSlash, true) && !antMatcher.MustDescend(relPathSlash) { + return filepath.SkipDir + } + return nil + } // Check if file passes filters filename := filepath.Base(relPath) - if filterMatched(includeFilters, filename) && filterMatched(excludeFilters, filename) { + if filterMatched(includeFilters, filename) && filterMatched(excludeFilters, filename) && + !antMatcher.Excluded(relPathSlash, false) { empty = false return filepath.SkipAll // We found at least one file that will be included } @@ -1780,7 +1794,7 @@ func addDirFilesIgnoreFilter(zipWriter *zip.Writer, baseDir, parentDir string) e return nil } -func addDirFiles(zipWriter *zip.Writer, baseDir, parentDir string, filters, includeFilters []string) error { +func addDirFiles(zipWriter *zip.Writer, baseDir, parentDir string, filters, includeFilters []string, antMatcher filtering.Matcher) error { fileEntries, err := os.ReadDir(parentDir) if err != nil { return err @@ -1793,9 +1807,9 @@ func addDirFiles(zipWriter *zip.Writer, baseDir, parentDir string, filters, incl } if util.IsDirOrSymLinkToDir(parentDir, fileInfo) { - err = handleDir(zipWriter, baseDir, parentDir, filters, includeFilters, fileInfo) + err = handleDir(zipWriter, baseDir, parentDir, filters, includeFilters, fileInfo, antMatcher) } else { - err = handleFile(zipWriter, baseDir, parentDir, filters, includeFilters, fileInfo) + err = handleFile(zipWriter, baseDir, parentDir, filters, includeFilters, fileInfo, antMatcher) } if err != nil { @@ -1812,6 +1826,7 @@ func handleFile( filters, includeFilters []string, file fs.FileInfo, + antMatcher filtering.Matcher, ) error { fileName := parentDir + file.Name() absFilePath := filepath.Clean(fileName) @@ -1821,7 +1836,9 @@ func handleFile( return nil } } - if filterMatched(includeFilters, file.Name()) && filterMatched(filters, file.Name()) { + // relPath is forward-slash path from source root, used by antMatcher. + relPath := filepath.ToSlash(baseDir + file.Name()) + if filterMatched(includeFilters, file.Name()) && filterMatched(filters, file.Name()) && !antMatcher.Excluded(relPath, false) { logger.PrintIfVerbose("Included: " + fileName) dat, err := ioutil.ReadFile(parentDir + file.Name()) if err != nil { @@ -1852,6 +1869,7 @@ func handleDir( filters, includeFilters []string, file fs.FileInfo, + antMatcher filtering.Matcher, ) error { // Check if folder belongs to the disabled exclusions if commonParams.DisabledExclusions[file.Name()] { @@ -1860,6 +1878,7 @@ func handleDir( return addDirFilesIgnoreFilter(zipWriter, newBase, newParent) } + // Legacy exclude filter (e.g. !node_modules from BaseExcludeFilters). isFiltered, err := isDirFiltered(file.Name(), filters) if err != nil { return err @@ -1868,8 +1887,23 @@ func handleDir( logger.PrintIfVerbose("Excluded: " + parentDir + file.Name() + "/") return nil } + + // Ant-style pattern exclusion with negation support. + // relPath is the forward-slash path from the source root. + relPath := filepath.ToSlash(baseDir + file.Name()) + if antMatcher.Excluded(relPath, true) { + // Before pruning the entire sub-tree, check whether a later negation + // rule may re-include a descendant. If so, descend and evaluate each + // child individually rather than skipping wholesale. + if !antMatcher.MustDescend(relPath) { + logger.PrintIfVerbose("Excluded (ant-filter): " + parentDir + file.Name() + "/") + return nil + } + logger.PrintIfVerbose("Descending into ant-excluded dir (negation may re-include): " + parentDir + file.Name() + "/") + } + newParent, newBase := GetNewParentAndBase(parentDir, file, baseDir) - return addDirFiles(zipWriter, newBase, newParent, filters, includeFilters) + return addDirFiles(zipWriter, newBase, newParent, filters, includeFilters, antMatcher) } func isDirFiltered(filename string, filters []string) (bool, error) { @@ -1899,10 +1933,13 @@ func GetNewParentAndBase(parentDir string, file fs.FileInfo, baseDir string) (ne func filterMatched(filters []string, fileName string) bool { firstMatch := true matched := true + // Normalise to lowercase for case-insensitive matching on all platforms. + fileNameLower := strings.ToLower(fileName) for _, filter := range filters { - if filter[0] == '!' { + filterLower := strings.ToLower(filter) + if filterLower[0] == '!' { // it just needs to match one exclusion to be excluded. - excluded, _ := path.Match(filter[1:], fileName) + excluded, _ := path.Match(filterLower[1:], fileNameLower) if excluded { return false } @@ -1916,7 +1953,7 @@ func filterMatched(filters []string, fileName string) bool { // We can't immediately return as we can still find an exclusion further down the slice // So we store the match result and never try again if !matched { - matched, _ = path.Match(filter, fileName) + matched, _ = path.Match(filterLower, fileNameLower) } } } @@ -2092,6 +2129,16 @@ func getUploadURLFromSource(cmd *cobra.Command, uploadsWrapper wrappers.UploadsW scaResolverParams, scaResolver := getScaResolverFlags(cmd) isSbom, _ := cmd.PersistentFlags().GetBool(commonParams.SbomFlag) isGitIgnoreFilter, _ := cmd.Flags().GetBool(commonParams.GitIgnoreFileFilterFlag) + + // Build the Ant-style matcher from --file-filter-ext patterns. + // Construction errors are surfaced immediately so the user gets clear + // feedback (e.g. if they accidentally used a "!" negation prefix). + antPatterns, _ := cmd.Flags().GetStringSlice(commonParams.AntFilterFlag) + antMatcher, antErr := filtering.NewAntMatcher(antPatterns) + if antErr != nil { + return "", "", errors.Wrapf(antErr, failedCreating) + } + var directoryPath string if isSbom { sbomFile, _ := cmd.Flags().GetString(commonParams.SourcesFlag) @@ -2143,7 +2190,7 @@ func getUploadURLFromSource(cmd *cobra.Command, uploadsWrapper wrappers.UploadsW var errorUnzippingFile error userProvidedZip := len(zipFilePath) > 0 - unzip := ((len(sourceDirFilter) > 0 || len(userIncludeFilter) > 0) || containerScanTriggered) && userProvidedZip + unzip := ((sourceDirFilter != "" || userIncludeFilter != "" || len(antPatterns) > 0) || containerScanTriggered) && userProvidedZip if unzip { directoryPath, errorUnzippingFile = UnzipFile(zipFilePath) if errorUnzippingFile != nil { @@ -2237,7 +2284,7 @@ func getUploadURLFromSource(cmd *cobra.Command, uploadsWrapper wrappers.UploadsW } } else { if !isSbom { - zipFilePath, dirPathErr = compressFolder(directoryPath, sourceDirFilter, userIncludeFilter, scaResolver) + zipFilePath, dirPathErr = compressFolder(directoryPath, sourceDirFilter, userIncludeFilter, scaResolver, antMatcher) } // Clean up .checkmarx/containers directory after successful mixed scan (including containers) compression @@ -2607,6 +2654,23 @@ func runCreateScanCommand( if err != nil { return err } + + // For --no-scan (with --sbom-first), only the local SBOM generation is required. + // Build the scan handler directly instead of calling createScanModel so that no + // empty project is created on the server before we skip the scan submission. + // This is handled before the policy/timeout/threshold checks below, since none of + // those apply when the scan is not submitted (and it avoids an unnecessary policy + // permission API call for the no-scan case). + if noScan { + _, zipFilePath, handlerErr := setupScanHandler(cmd, uploadsWrapper, featureFlagsWrapper) + defer cleanUpTempZip(zipFilePath) + if handlerErr != nil { + return errors.Errorf("%s", handlerErr) + } + logger.Print("--no-scan set: skipping scan submission.") + return nil + } + ignorePolicy, _ := cmd.Flags().GetBool(commonParams.IgnorePolicyFlag) // Check if the user has permission to override policy management if --ignore-policy is set @@ -2648,10 +2712,6 @@ func runCreateScanCommand( if err != nil { return errors.Errorf("%s", err) } - if noScan { - logger.Print("--no-scan set: skipping scan submission.") - return nil - } scanResponseModel, errorModel, err := scansWrapper.Create(scanModel) if err != nil { diff --git a/internal/commands/scan_test.go b/internal/commands/scan_test.go index f95e62e23..126e9a919 100644 --- a/internal/commands/scan_test.go +++ b/internal/commands/scan_test.go @@ -17,6 +17,7 @@ import ( "github.com/checkmarx/ast-cli/internal/commands/util" errorConstants "github.com/checkmarx/ast-cli/internal/constants/errors" exitCodes "github.com/checkmarx/ast-cli/internal/constants/exit-codes" + "github.com/checkmarx/ast-cli/internal/filtering" "github.com/checkmarx/ast-cli/internal/logger" commonParams "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/wrappers" @@ -5343,7 +5344,9 @@ func TestSbomFileExcludedFromZip_WithCustomOutputName(t *testing.T) { sbomAbsoluteExcludes = computeSbomExclusions(projectDir, "", sbomOutputName) defer func() { sbomAbsoluteExcludes = nil }() - zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "") + noopMatcher, matcherErr := filtering.NewAntMatcher(nil) + assert.NilError(t, matcherErr) + zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "", noopMatcher) assert.NilError(t, err) defer func() { _ = os.Remove(zipPath) }() @@ -5372,7 +5375,9 @@ func TestDefaultSbomFileAlwaysExcludedFromZip(t *testing.T) { sbomAbsoluteExcludes = computeSbomExclusions(projectDir, sbomOutputPath, sbomOutputName) defer func() { sbomAbsoluteExcludes = nil }() - zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "") + noopMatcher, matcherErr := filtering.NewAntMatcher(nil) + assert.NilError(t, matcherErr) + zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "", noopMatcher) assert.NilError(t, err) defer func() { _ = os.Remove(zipPath) }() @@ -5403,7 +5408,9 @@ func TestSbomFileExcludedFromZip_InSubdirectory(t *testing.T) { sbomAbsoluteExcludes = computeSbomExclusions(projectDir, "./out", sbomOutputName) defer func() { sbomAbsoluteExcludes = nil }() - zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "") + noopMatcher, matcherErr := filtering.NewAntMatcher(nil) + assert.NilError(t, matcherErr) + zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "", noopMatcher) assert.NilError(t, err) defer func() { _ = os.Remove(zipPath) }() @@ -5440,7 +5447,9 @@ func TestSbomFileExcludedFromZip_AbsoluteSubdirWithCustomName(t *testing.T) { sbomAbsoluteExcludes = computeSbomExclusions(projectDir, sbomOutputPath, sbomOutputName) defer func() { sbomAbsoluteExcludes = nil }() - zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "") + noopMatcher, matcherErr := filtering.NewAntMatcher(nil) + assert.NilError(t, matcherErr) + zipPath, err := compressFolder(sbomTestSourceDir(projectDir), "", "", "", noopMatcher) assert.NilError(t, err) defer func() { _ = os.Remove(zipPath) }() diff --git a/internal/commands/scarealtime/sca-realtime.go b/internal/commands/scarealtime/sca-realtime.go index 197603117..495cd85b2 100644 --- a/internal/commands/scarealtime/sca-realtime.go +++ b/internal/commands/scarealtime/sca-realtime.go @@ -75,7 +75,7 @@ func RunScaRealtime(scaRealTimeWrapper wrappers.ScaRealTimeWrapper) func(*cobra. fmt.Println("Running SCA Realtime...") // Handle SCA Resolver. Checks if it already exists and if it is in the latest version - _, err = osinstaller.InstallOrUpgrade(&scaconfig.Params) + _, err = osinstaller.InstallOrUpgrade(&scaconfig.Params, nil) if err != nil { return err } diff --git a/internal/commands/shell_output.go b/internal/commands/shell_output.go deleted file mode 100644 index 5091afd73..000000000 --- a/internal/commands/shell_output.go +++ /dev/null @@ -1,47 +0,0 @@ -package commands - -import ( - "fmt" - "os" - "runtime" - "strings" -) - -// detectShell returns the user's likely shell so session-mode login/logout -// can emit env-var assignment lines in the right syntax. PowerShell is -// detected via PSModulePath (present in PowerShell sessions, absent in -// cmd.exe and *nix shells). Bash/zsh/fish are detected via SHELL. -// Defaults: PowerShell on Windows, bash elsewhere. -func detectShell() string { - if os.Getenv("PSModulePath") != "" { - return "powershell" - } - shell := strings.ToLower(os.Getenv("SHELL")) - switch { - case strings.Contains(shell, "fish"): - return "fish" - case strings.Contains(shell, "bash"), strings.Contains(shell, "zsh"): - return "bash" - } - if runtime.GOOS == "windows" { - return "powershell" - } - return "bash" -} - -// formatEnvAssignment returns a shell-evaluable env var assignment line. -// Examples: -// -// powershell → $env:CX_APIKEY = "value" -// bash/zsh → export CX_APIKEY="value" -// fish → set -gx CX_APIKEY "value" -func formatEnvAssignment(shell, name, value string) string { - switch shell { - case "powershell": - return fmt.Sprintf(`$env:%s = "%s"`, name, value) - case "fish": - return fmt.Sprintf(`set -gx %s "%s"`, name, value) - default: - return fmt.Sprintf(`export %s="%s"`, name, value) - } -} diff --git a/internal/commands/shell_output_test.go b/internal/commands/shell_output_test.go deleted file mode 100644 index 1fc24d93d..000000000 --- a/internal/commands/shell_output_test.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build !integration - -package commands - -import ( - "testing" -) - -func TestDetectShell(t *testing.T) { - cases := []struct { - name string - psModule string // value for PSModulePath env - shell string // value for SHELL env - want string - }{ - {name: "PSModulePath set → powershell", psModule: "C:\\Program Files\\PowerShell\\Modules", shell: "", want: "powershell"}, - {name: "PSModulePath wins over SHELL", psModule: "C:\\Program Files\\PowerShell\\Modules", shell: "/usr/bin/bash", want: "powershell"}, - {name: "bash via SHELL", psModule: "", shell: "/usr/bin/bash", want: "bash"}, - {name: "zsh via SHELL", psModule: "", shell: "/usr/bin/zsh", want: "bash"}, - {name: "fish via SHELL", psModule: "", shell: "/usr/local/bin/fish", want: "fish"}, - {name: "fish wins over bash substring matching", psModule: "", shell: "/usr/local/bin/fishtank", want: "fish"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Setenv("PSModulePath", tc.psModule) - t.Setenv("SHELL", tc.shell) - got := detectShell() - if got != tc.want { - t.Errorf("detectShell() = %q, want %q", got, tc.want) - } - }) - } -} - -func TestFormatEnvAssignment(t *testing.T) { - cases := []struct { - name string - shell string - key string - value string - want string - }{ - {name: "powershell with token", shell: "powershell", key: "CX_APIKEY", value: "abc.def", want: `$env:CX_APIKEY = "abc.def"`}, - {name: "powershell with empty value clears", shell: "powershell", key: "CX_APIKEY", value: "", want: `$env:CX_APIKEY = ""`}, - {name: "bash with token", shell: "bash", key: "CX_APIKEY", value: "abc.def", want: `export CX_APIKEY="abc.def"`}, - {name: "bash with empty value clears", shell: "bash", key: "CX_APIKEY", value: "", want: `export CX_APIKEY=""`}, - {name: "fish with token", shell: "fish", key: "CX_APIKEY", value: "abc.def", want: `set -gx CX_APIKEY "abc.def"`}, - {name: "unknown shell falls back to bash syntax", shell: "made-up-shell", key: "CX_APIKEY", value: "abc.def", want: `export CX_APIKEY="abc.def"`}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := formatEnvAssignment(tc.shell, tc.key, tc.value) - if got != tc.want { - t.Errorf("formatEnvAssignment(%q, %q, %q) = %q, want %q", tc.shell, tc.key, tc.value, got, tc.want) - } - }) - } -} diff --git a/internal/filtering/ant_matcher.go b/internal/filtering/ant_matcher.go new file mode 100644 index 000000000..71dcdec85 --- /dev/null +++ b/internal/filtering/ant_matcher.go @@ -0,0 +1,447 @@ +package filtering + +import ( + "fmt" + "strings" + + "github.com/bmatcuk/doublestar/v4" +) + +// AntMatcher implements [Matcher] using an ordered list of Ant-style glob +// rules following the Apache Ant patternset convention. +// +// # Pattern syntax +// +// Patterns use the Ant glob dialect as implemented by bmatcuk/doublestar: +// +// - "*" matches any sequence of non-separator characters within one path +// segment ("*.go" matches "main.go" but not "a/b.go") +// - "**" matches any sequence of characters including path separators, +// including zero segments ("src/**" matches "src" and everything under +// it; "**/*.go" matches any .go file at any depth) +// - "?" matches exactly one non-separator character +// - "[abc]" is a character class +// - "{a,b}" is an alternation ("*.{js,ts}" matches .js and .ts files) +// +// # Include / exclude semantics (Ant convention) +// +// Rules are evaluated in the order they are provided. The last matching rule +// wins, consistent with Apache Ant patternset evaluation. +// +// - A bare pattern (no prefix) → INCLUDE entries that match +// - A "!" prefix → EXCLUDE entries that match +// +// Default state: +// - When at least one bare (include) rule is present, the default is +// EXCLUDED: only entries that match an include rule are kept, subject to +// later exclude rules. +// - When only "!" (exclude) rules are present and no bare rules exist, the +// default is INCLUDED: everything passes through unless an exclude rule +// matches it. +// - Empty rule list: everything is INCLUDED (no restriction). +// +// Examples: +// +// ["**/*.java", "!**/Test*.java"] +// → include all .java files, then exclude those whose name starts with Test +// +// ["!**/node_modules/**"] +// → include everything except node_modules (exclude-only, no bare rules) +// +// # Directory precedence and sub-tree pruning +// +// A directory is pruned (not descended into) only when: +// 1. It is currently excluded (Excluded returns true), AND +// 2. No subsequent include rule could possibly match any descendant +// ([MustDescend] returns false). +// +// When condition 2 is not met, the traversal descends and applies per-entry +// evaluation. This is the correct behaviour for patterns such as: +// +// ["**/*.java", "!**/generated/**"] +// +// where .java files are included but those under generated/ are excluded; +// the traversal must descend into generated/ to evaluate per-file. +// +// # Implicit depth anchoring +// +// A pattern that contains no "/" (ignoring a trailing "/") is implicitly +// treated as "**/pattern", matching at any depth. "*.java" means "any .java +// file anywhere in the tree." +// +// # Trailing slash +// +// A pattern ending with "/" matches directories only and will never match a +// file, even one with the same name. +// +// # Relative-path contract +// +// All relPath arguments must use forward slashes and must not start with "/". +// On Windows, callers must normalise paths with filepath.ToSlash before +// passing them to this matcher. +type AntMatcher struct { + rules []antRule + hasIncludeRule bool // true when at least one bare (include) rule exists +} + +// antRule is a compiled, normalised pattern with its intent. +type antRule struct { + pattern string // normalised, anchored, ready for doublestar.Match + include bool // true = bare pattern (include); false = "!" pattern (exclude) + dirOnly bool // true = trailing "/" was present; only matches directories +} + +// NewAntMatcher parses and compiles patterns into an AntMatcher. +// +// Patterns are trimmed of whitespace; blank patterns are silently skipped. +// A leading "!" makes the rule an exclusion. A trailing "/" restricts the +// rule to directories only. Both may be combined: "!src/test/" excludes the +// src/test directory but not files named "src/test". +// +// An error is returned only for patterns that are syntactically invalid for +// the doublestar engine (e.g. unclosed bracket "src/[invalid"). +func NewAntMatcher(patterns []string) (*AntMatcher, error) { + rules := make([]antRule, 0, len(patterns)) + hasIncludeRule := false + + for _, raw := range patterns { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + + // "!" prefix marks this as an exclusion rule. + exclude := strings.HasPrefix(raw, "!") + norm := raw + if exclude { + norm = norm[1:] // strip "!" before further processing + } + + // Normalise OS path separators to forward slash. + norm = toSlash(norm) + + // Normalise to lowercase for case-insensitive matching on all platforms. + norm = strings.ToLower(norm) + + dirOnly := strings.HasSuffix(norm, "/") + norm = strings.TrimSuffix(norm, "/") + + if norm == "" { + // Pattern was just "!" or "!/" — meaningless, skip. + continue + } + + // Validate syntax against doublestar before storing. + if _, err := doublestar.Match(norm, "probe"); err != nil { + return nil, fmt.Errorf("filtering: invalid pattern %q: %w", raw, err) + } + + // Implicit depth anchoring: patterns without "/" match at any depth. + if !strings.Contains(norm, "/") { + norm = "**/" + norm + } + + include := !exclude + if include { + hasIncludeRule = true + } + + rules = append(rules, antRule{ + pattern: norm, + include: include, + dirOnly: dirOnly, + }) + } + return &AntMatcher{rules: rules, hasIncludeRule: hasIncludeRule}, nil +} + +// Excluded implements [Matcher]. +// +// The default state depends on the rule set: +// - If any bare (include) rule exists: default is excluded. +// - If only "!" (exclude) rules exist: default is included. +// - No rules: included. +// +// Rules are evaluated in order; the last matching rule wins. +func (m *AntMatcher) Excluded(relPath string, isDir bool) bool { + relPath = normalise(relPath) + + // When include rules are present, entries start as excluded and must be + // explicitly included. When only exclude rules exist, entries start as + // included and are explicitly excluded. + excluded := m.hasIncludeRule + matchedAny := false + + for _, r := range m.rules { + if r.dirOnly && !isDir { + continue // directory-only rule cannot match a file + } + if antMatches(r.pattern, relPath) { + matchedAny = true + if r.include { + excluded = false // bare rule: include this entry + } else { + excluded = true // "!" rule: exclude this entry + } + } + } + + // A directory that is a required stepping stone toward a literal-prefixed + // include pattern (e.g. "src" for pattern "src/**/*.test.*") must not be + // pruned by the default include-only exclusion just because it doesn't + // itself match. This only applies when no rule explicitly matched the + // directory; an explicit "!" exclusion still stands and is left to + // MustDescend to resolve. + if isDir && excluded && !matchedAny && m.hasIncludeRule { + for _, r := range m.rules { + if !r.include { + continue + } + if requiredAncestorPrefixMatch(r.pattern, relPath) { + excluded = false + break + } + } + } + + return excluded +} + +// MustDescend implements [Matcher]. +// +// It returns true when the directory at dirRelPath is currently excluded but a +// subsequent include rule in the list could potentially match a descendant. +// In that case the caller must descend into the directory and evaluate each +// child with [Excluded] rather than pruning the sub-tree. +// +// The algorithm: +// 1. Find the index of the last exclusion rule ("!" rule) that matched +// dirRelPath. If the directory is excluded because no include rule +// matched it (hasIncludeRule=true, no bare rule hit), search for any +// include rule that could match a child. +// 2. Check every include rule that appears after the last exclusion match. +// Use [couldMatchUnder] to determine whether it could reach a descendant. +// +// The check is conservative: it may return true when no actual descendant +// would be included (causing unnecessary descent). It will never return false +// when a descendant would genuinely be included. +func (m *AntMatcher) MustDescend(dirRelPath string) bool { + dirRelPath = normalise(dirRelPath) + + // Find the last exclusion ("!" rule) that matched this directory. + lastExcludeIdx := -1 + for i, r := range m.rules { + if r.include { + continue // not an exclusion rule + } + if antMatches(r.pattern, dirRelPath) { + lastExcludeIdx = i + } + } + + if lastExcludeIdx >= 0 { + // Directory was explicitly excluded by a "!" rule. + // Check for any include rule AFTER that exclusion that could reach a child. + for i := lastExcludeIdx + 1; i < len(m.rules); i++ { + r := m.rules[i] + if !r.include { + continue // not an include rule + } + if couldMatchUnder(r.pattern, dirRelPath) { + return true + } + } + return false + } + + // The directory is excluded because no bare include rule matched it + // (hasIncludeRule is true but no include rule hit dirRelPath). Check + // whether any include rule could match a descendant — if so, we must + // descend to give those children a chance to be included. + if m.hasIncludeRule { + for _, r := range m.rules { + if !r.include { + continue + } + if couldMatchUnder(r.pattern, dirRelPath) { + return true + } + } + } + + return false +} + +// couldMatchUnder returns true when pattern could match at least one path of +// the form dirRelPath+"/"+. It is used by [MustDescend] to decide +// whether an include rule can reach descendants of an excluded directory. +// +// The algorithm is conservative (may return true when no real match exists) +// but never returns false when a match genuinely exists: +// +// - Patterns starting with "**/" (or equal to "**") can reach any directory +// → always return true. +// - Otherwise extract the pattern's static prefix (segments before the first +// wildcard) and check whether it is compatible with dirRelPath: either the +// static prefix starts inside dirRelPath, or dirRelPath is inside the +// static prefix (wildcards may bridge the gap). +// - Patterns whose first segment is a wildcard (static prefix empty) could +// match under any single-level directory → return true conservatively. +func couldMatchUnder(pattern, dirRelPath string) bool { + // Fast path: "**/" prefix or bare "**" can reach any directory. + if pattern == "**" || strings.HasPrefix(pattern, "**/") { + return true + } + + staticPrefix := patternStaticPrefix(pattern) + + if staticPrefix == "" { + // Pattern starts with a wildcard at segment 0 — conservative true. + return true + } + + // Normalise to trailing-slash form for clean prefix comparison. + prefixWithSlash := staticPrefix + if !strings.HasSuffix(prefixWithSlash, "/") { + prefixWithSlash += "/" + } + dirWithSlash := dirRelPath + "/" + + // Pattern targets something inside dirRelPath. + if strings.HasPrefix(prefixWithSlash, dirWithSlash) { + return true + } + + // dirRelPath is inside the static prefix area — wildcards after the + // static prefix may reach children of dirRelPath. + if strings.HasPrefix(dirWithSlash, prefixWithSlash) { + return true + } + + return false +} + +// requiredAncestorPrefixMatch reports whether dirRelPath is necessarily on the +// path to a match of pattern, based solely on pattern's literal (non-wildcard) +// static prefix. Unlike [couldMatchUnder], it does not conservatively return +// true for patterns with no static prefix (e.g. "**/src") — those provide no +// concrete evidence that a specific, unrelated directory is relevant, so they +// must not override the default exclusion for arbitrary directories. +func requiredAncestorPrefixMatch(pattern, dirRelPath string) bool { + staticPrefix := patternStaticPrefix(pattern) + if staticPrefix == "" { + return false + } + + prefixWithSlash := staticPrefix + dirWithSlash := dirRelPath + "/" + + if strings.HasPrefix(prefixWithSlash, dirWithSlash) { + return true + } + if strings.HasPrefix(dirWithSlash, prefixWithSlash) { + return true + } + return false +} + +// patternStaticPrefix returns the slash-separated path segments of pattern +// that precede the first glob metacharacter (*, ?, [, {). +// +// Examples: +// +// "src/test/fixtures/**" → "src/test/fixtures/" +// "src/*/test" → "src/" +// "**/*.test.js" → "" (first segment is **) +// "*.go" → "" (first char is *) +func patternStaticPrefix(pattern string) string { + segments := strings.Split(pattern, "/") + var staticSegs []string + for _, seg := range segments { + if containsGlobMeta(seg) { + break + } + staticSegs = append(staticSegs, seg) + } + if len(staticSegs) == 0 { + return "" + } + return strings.Join(staticSegs, "/") + "/" +} + +// containsGlobMeta reports whether s contains any doublestar glob metacharacter. +func containsGlobMeta(s string) bool { + return strings.ContainsAny(s, "*?[{") +} + +// antMatches reports whether pattern matches relPath directly OR whether +// relPath is a descendant of a path that pattern matches (sub-path pruning). +// +// Sub-path pruning handles patterns like "src/test" matching files inside +// "src/test/Foo.java". It is suppressed for patterns whose last segment +// contains a dot (file-extension and dotfile patterns) or a glob +// metacharacter (the pattern already fully specifies its own depth via +// doublestar semantics, e.g. "*/src/test/*" or "test/**"), preventing a +// pattern like "**/*.test.js", "Mexico/.gitignore", or "*/src/test/*" from +// accidentally matching paths that are descendants of what it actually +// matches. +// +// Examples: +// +// antMatches("**/test", "src/test") → true (direct) +// antMatches("**/test", "src/test/Foo.java") → true (sub-path) +// antMatches("**/*.test.js", "src/Foo.test.js") → true (direct) +// antMatches("**/*.test.js", "src/Foo.go") → false +// antMatches("*/src/test/*", "a/src/test/sub/x") → false (last segment is a wildcard) +func antMatches(pattern, relPath string) bool { + if matched, _ := doublestar.Match(pattern, relPath); matched { + return true + } + + // Sub-path check: does the pattern match any ancestor directory of relPath? + // Suppressed for file/dotfile/wildcard-terminated patterns to avoid false + // positives. + if !looksLikeFilePattern(pattern) { + parts := strings.Split(relPath, "/") + for i := len(parts) - 1; i > 0; i-- { + ancestor := strings.Join(parts[:i], "/") + if matched, _ := doublestar.Match(pattern, ancestor); matched { + return true + } + } + } + + return false +} + +// looksLikeFilePattern returns true when the last segment of the pattern +// contains a dot anywhere or a glob metacharacter, indicating an +// extension-based, dotfile, or explicitly depth-bounded pattern. Such +// patterns suppress sub-path ancestor matching because they already fully +// specify their own matching depth. +// +// Examples: +// +// "**/*.test.js" → true (has dot → suppress sub-path) +// "Mexico/.gitignore" → true (has dot → suppress sub-path) +// "*/src/test/*" → true (last segment is "*" → suppress sub-path) +// "**/test" → false (no dot, literal segment → allow sub-path) +// "node_modules" → false (no dot, literal segment → allow sub-path) +func looksLikeFilePattern(pattern string) bool { + lastSlash := strings.LastIndex(pattern, "/") + lastSeg := pattern[lastSlash+1:] + return strings.ContainsRune(lastSeg, '.') || containsGlobMeta(lastSeg) +} + +// normalise converts backslashes to forward slashes and strips any leading slash. +func normalise(s string) string { + s = toSlash(s) + s = strings.TrimPrefix(s, "/") + // Normalise to lowercase for case-insensitive matching on all platforms. + return strings.ToLower(s) +} + +// toSlash converts backslashes to forward slashes. +func toSlash(s string) string { + return strings.ReplaceAll(s, "\\", "/") +} diff --git a/internal/filtering/ant_matcher_test.go b/internal/filtering/ant_matcher_test.go new file mode 100644 index 000000000..6f1a8b765 --- /dev/null +++ b/internal/filtering/ant_matcher_test.go @@ -0,0 +1,647 @@ +package filtering_test + +import ( + "strings" + "testing" + + "github.com/checkmarx/ast-cli/internal/filtering" +) + +// ── Construction ───────────────────────────────────────────────────────────── + +func TestNewAntMatcher_ValidPatterns(t *testing.T) { + t.Parallel() + cases := []struct { + name string + patterns []string + }{ + {"empty list", []string{}}, + {"nil list", nil}, + {"blank strings skipped", []string{"", " ", "\t"}}, + {"bare include", []string{"**/*.java"}}, + {"exclude with !", []string{"!**/test*"}}, + {"exclude directory with !", []string{"!src/test/"}}, + {"mixed include and exclude", []string{"**/*.java", "!**/Test*.java"}}, + {"trailing slash dir-only include", []string{"src/"}}, + {"trailing slash dir-only exclude", []string{"!node_modules/"}}, + {"just exclamation is skipped", []string{"!"}}, + {"just exclamation-slash is skipped", []string{"!/"}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, err := filtering.NewAntMatcher(tc.patterns) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected non-nil matcher") + } + }) + } +} + +func TestNewAntMatcher_InvalidSyntax(t *testing.T) { + t.Parallel() + cases := []struct { + name string + pattern string + wantErr string + }{ + {"unclosed bracket", "src/[invalid", "invalid pattern"}, + {"exclude with unclosed bracket", "![bad", "invalid pattern"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := filtering.NewAntMatcher([]string{tc.pattern}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err) + } + }) + } +} + +// ── Default state ───────────────────────────────────────────────────────────── + +func TestAntMatcher_DefaultState(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + patterns []string + relPath string + isDir bool + want bool // want Excluded + }{ + { + // No rules → everything included. + name: "no rules: everything included", + patterns: nil, + relPath: "src/Foo.java", + want: false, + }, + { + // Only exclude rules → default included; exclusion applied. + name: "only exclude rules: unmatched entry is included", + patterns: []string{"!**/node_modules/**"}, + relPath: "src/Foo.java", + want: false, + }, + { + // Only exclude rules → matched entry is excluded. + name: "only exclude rules: matched entry is excluded", + patterns: []string{"!**/node_modules/**"}, + relPath: "node_modules/lodash/index.js", + want: true, + }, + { + // Include rules present → default excluded; unmatched entry excluded. + name: "include rules present: unmatched entry is excluded", + patterns: []string{"**/*.java"}, + relPath: "src/Foo.go", + want: true, + }, + { + // Include rules present → matched entry is included. + name: "include rules present: matched entry is included", + patterns: []string{"**/*.java"}, + relPath: "src/Foo.java", + want: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, err := filtering.NewAntMatcher(tc.patterns) + if err != nil { + t.Fatalf("unexpected construction error: %v", err) + } + got := m.Excluded(tc.relPath, tc.isDir) + if got != tc.want { + t.Errorf("Excluded(%q, isDir=%v) = %v, want %v", tc.relPath, tc.isDir, got, tc.want) + } + }) + } +} + +// ── Include / exclude with requirement samples ──────────────────────────────── + +func TestAntMatcher_IncludeExclude_RequirementSamples(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + patterns []string + relPath string + isDir bool + want bool // want Excluded + }{ + // ── bare *.test.tsx means INCLUDE those files ──────────────────── + // With include rules present, everything else is excluded by default. + { + name: "*.test.tsx includes matching file", + patterns: []string{"*.test.tsx"}, + relPath: "Button.test.tsx", + want: false, // included + }, + { + name: "*.test.tsx includes in subdirectory (implicit **/)", + patterns: []string{"*.test.tsx"}, + relPath: "src/components/Button.test.tsx", + want: false, // included + }, + { + name: "*.test.tsx excludes non-test file (default excluded when include rules present)", + patterns: []string{"*.test.tsx"}, + relPath: "src/components/Button.tsx", + want: true, // excluded by default + }, + + // ── !*.test.tsx means EXCLUDE those files ─────────────────────── + // With only exclude rules, everything else is included by default. + { + name: "!*.test.tsx excludes matching file", + patterns: []string{"!*.test.tsx"}, + relPath: "src/components/Button.test.tsx", + want: true, // excluded + }, + { + name: "!*.test.tsx does not exclude non-test file", + patterns: []string{"!*.test.tsx"}, + relPath: "src/components/Button.tsx", + want: false, // included by default + }, + + // ── !**/*.test.js (exclude test files, include everything else) ── + { + name: "!**/*.test.js excludes at any depth", + patterns: []string{"!**/*.test.js"}, + relPath: "deep/src/utils/helper.test.js", + want: true, + }, + { + name: "!**/*.test.js does not exclude plain .js", + patterns: []string{"!**/*.test.js"}, + relPath: "src/utils/helper.js", + want: false, + }, + + // ── **/*.java, !**/Test*.java (Ant patternset idiom) ───────────── + { + name: "include java exclude test classes: Foo.java included", + patterns: []string{"**/*.java", "!**/Test*.java"}, + relPath: "src/main/Foo.java", + want: false, + }, + { + name: "include java exclude test classes: TestFoo.java excluded", + patterns: []string{"**/*.java", "!**/Test*.java"}, + relPath: "src/test/TestFoo.java", + want: true, + }, + { + name: "include java exclude test classes: Foo.go excluded (not java)", + patterns: []string{"**/*.java", "!**/Test*.java"}, + relPath: "src/main/Foo.go", + want: true, + }, + + // ── src/**/*.test.* include ────────────────────────────────────── + { + name: "src/**/*.test.* includes test files under src", + patterns: []string{"src/**/*.test.*"}, + relPath: "src/utils/helper.test.ts", + want: false, + }, + { + name: "src/**/*.test.* excludes files outside src (default)", + patterns: []string{"src/**/*.test.*"}, + relPath: "lib/utils/helper.test.ts", + want: true, + }, + + // ── !Mexico/.gitignore (exclude that specific file) ────────────── + { + name: "!Mexico/.gitignore excludes that exact file", + patterns: []string{"!Mexico/.gitignore"}, + relPath: "Mexico/.gitignore", + want: true, + }, + { + name: "!Mexico/.gitignore does not exclude other .gitignore", + patterns: []string{"!Mexico/.gitignore"}, + relPath: "src/.gitignore", + want: false, + }, + // Sub-path pruning must not fire for dotfile patterns. + { + name: "!Mexico/.gitignore does not sub-path match a path under it", + patterns: []string{"!Mexico/.gitignore"}, + relPath: "Mexico/.gitignore/impostor", + want: false, + }, + + // ── !Mexico/src/test (exclude path-anchored directory) ─────────── + { + name: "!Mexico/src/test excludes file inside via sub-path pruning", + patterns: []string{"!Mexico/src/test"}, + relPath: "Mexico/src/test/Foo.java", + want: true, + }, + + // ── Windows backslash normalisation ───────────────────────────── + { + name: "backslash relPath is normalised for include pattern", + patterns: []string{"src/**/*.test.ts"}, + relPath: `src\utils\helper.test.ts`, + want: false, // included + }, + { + name: "backslash relPath is normalised for exclude pattern", + patterns: []string{"!src/**/*.test.ts"}, + relPath: `src\utils\helper.test.ts`, + want: true, // excluded + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, err := filtering.NewAntMatcher(tc.patterns) + if err != nil { + t.Fatalf("unexpected construction error: %v", err) + } + got := m.Excluded(tc.relPath, tc.isDir) + if got != tc.want { + t.Errorf("Excluded(%q, isDir=%v) = %v, want %v", tc.relPath, tc.isDir, got, tc.want) + } + }) + } +} + +// ── Directory precedence ────────────────────────────────────────────────────── + +func TestAntMatcher_DirectoryPrecedence(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + patterns []string + relPath string + isDir bool + want bool // want Excluded + }{ + // !test/** — exclude-only: default included, test/ and its contents excluded + {"!test/** excludes the dir itself", []string{"!test/**"}, "test", true, true}, + {"!test/** excludes files inside", []string{"!test/**"}, "test/Foo.java", false, true}, + {"!test/** does not exclude unrelated dir", []string{"!test/**"}, "src", true, false}, + + // !src/test — exclude-only: sub-path pruning applies to files inside + {"!src/test excludes the directory", []string{"!src/test"}, "src/test", true, true}, + {"!src/test excludes files under it (sub-path)", []string{"!src/test"}, "src/test/Foo.java", false, true}, + {"!src/test does not exclude src/main", []string{"!src/test"}, "src/main", true, false}, + + // !**/node_modules/** + {"!**/node_modules/** excludes node_modules at root", []string{"!**/node_modules/**"}, "node_modules", true, true}, + {"!**/node_modules/** excludes nested node_modules", []string{"!**/node_modules/**"}, "packages/lib/node_modules", true, true}, + {"!**/node_modules/** excludes file deep inside", []string{"!**/node_modules/**"}, "packages/lib/node_modules/lodash/index.js", false, true}, + + // !*/src/test/* + {"!*/src/test/* excludes single-segment top dir", []string{"!*/src/test/*"}, "CICD/src/test/Foo.java", false, true}, + {"!*/src/test/* does not exclude two levels deep", []string{"!*/src/test/*"}, "CICD/src/test/sub/Foo.java", false, false}, + + // !*test*/** + {"!*test*/** excludes test-named dir", []string{"!*test*/**"}, "mytest_utils", true, true}, + {"!*test*/** excludes files inside test-named dir", []string{"!*test*/**"}, "mytest_utils/helper.go", false, true}, + + // trailing slash — directory-only exclude + {"!test/ excludes directory", []string{"!test/"}, "test", true, true}, + {"!test/ does not exclude file named test", []string{"!test/"}, "test", false, false}, + + // trailing slash — directory-only include + {"src/ includes the directory itself", []string{"src/"}, "src", true, false}, + {"src/ excludes unrelated dir (default)", []string{"src/"}, "lib", true, true}, + + // file pattern must not prune parent dir + {"src/**/*.test.* does not prune src dir itself", []string{"src/**/*.test.*"}, "src", true, false}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, err := filtering.NewAntMatcher(tc.patterns) + if err != nil { + t.Fatalf("unexpected construction error: %v", err) + } + got := m.Excluded(tc.relPath, tc.isDir) + if got != tc.want { + t.Errorf("Excluded(%q, isDir=%v) = %v, want %v", tc.relPath, tc.isDir, got, tc.want) + } + }) + } +} + +// ── Ordered evaluation (last match wins) ────────────────────────────────────── + +func TestAntMatcher_OrderedEvaluation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + patterns []string + relPath string + isDir bool + want bool // want Excluded + }{ + // Include all java, then exclude generated — fixtures re-included by include rule + { + name: "include java then exclude generated: generated file excluded", + patterns: []string{"**/*.java", "!**/generated/**"}, + relPath: "src/generated/Auto.java", + want: true, + }, + { + name: "include java then exclude generated: normal file included", + patterns: []string{"**/*.java", "!**/generated/**"}, + relPath: "src/main/Foo.java", + want: false, + }, + + // Exclude all tests, re-include fixtures (using include rule to restore) + { + name: "exclude tests re-include fixtures: fixture file included", + patterns: []string{"!**/test/**", "**/test/fixtures/**"}, + relPath: "src/test/fixtures/data.json", + want: false, + }, + { + name: "exclude tests re-include fixtures: non-fixture test excluded", + patterns: []string{"!**/test/**", "**/test/fixtures/**"}, + relPath: "src/test/Foo.java", + want: true, + }, + + // Three rules: include, exclude subset, re-include sub-subset + { + name: "three rules: re-included sub-subset is included", + patterns: []string{"**/*.java", "!**/test/**", "**/test/fixtures/**"}, + relPath: "src/test/fixtures/Data.java", + want: false, + }, + { + name: "three rules: excluded subset remains excluded", + patterns: []string{"**/*.java", "!**/test/**", "**/test/fixtures/**"}, + relPath: "src/test/Foo.java", + want: true, + }, + { + name: "three rules: non-java excluded by default", + patterns: []string{"**/*.java", "!**/test/**", "**/test/fixtures/**"}, + relPath: "src/main/script.py", + want: true, + }, + + // Order matters: reversed gives opposite result + { + name: "include after exclude wins", + patterns: []string{"!**/test/**", "**/*.java"}, + relPath: "src/test/Foo.java", + want: false, // last rule includes *.java + }, + { + name: "exclude after include wins", + patterns: []string{"**/*.java", "!**/test/**"}, + relPath: "src/test/Foo.java", + want: true, // last rule excludes test/** + }, + + // Trailing-slash include then specific exclude + { + name: "include src/ but exclude src/test/", + patterns: []string{"src/", "!src/test/"}, + relPath: "src/main", + isDir: true, + want: false, // included by src/ + }, + { + name: "include src/ but !src/test/ excludes test dir", + patterns: []string{"src/", "!src/test/"}, + relPath: "src/test", + isDir: true, + want: true, // excluded by !src/test/ + }, + + // dir-only exclude does not affect files + { + name: "!src/test/ does not exclude a file named src/test", + patterns: []string{"src/**", "!src/test/"}, + relPath: "src/test", + isDir: false, + want: false, // included by src/**; dir-only exclude does not apply to files + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, err := filtering.NewAntMatcher(tc.patterns) + if err != nil { + t.Fatalf("unexpected construction error: %v", err) + } + got := m.Excluded(tc.relPath, tc.isDir) + if got != tc.want { + t.Errorf("Excluded(%q, isDir=%v) = %v, want %v", tc.relPath, tc.isDir, got, tc.want) + } + }) + } +} + +// ── MustDescend ─────────────────────────────────────────────────────────────── + +func TestAntMatcher_MustDescend(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + patterns []string + dirRelPath string + wantExcluded bool + wantDescend bool + }{ + { + // Exclude-only rules. "src/test" is excluded by !**/test/**. + // A later include rule "src/test/fixtures/**" could match children. + // The "**/" fast path in couldMatchUnder triggers → MustDescend=true. + name: "include rule after exclude rule requires descent", + patterns: []string{"!**/test/**", "**/test/fixtures/**"}, + dirRelPath: "src/test", + wantExcluded: true, + wantDescend: true, + }, + { + // Exclude-only, no include rules follow. + name: "no include rule after exclude: prune safely", + patterns: []string{"!**/test/**"}, + dirRelPath: "src/test", + wantExcluded: true, + wantDescend: false, + }, + { + // Include rules present: "src/test" is excluded by default (no include matched). + // There IS an include rule "**/*.java" that could match children. + name: "include rules present: unmatched dir requires descent for child check", + patterns: []string{"**/*.java"}, + dirRelPath: "src/test", + wantExcluded: true, + wantDescend: true, + }, + { + // "src/test" is excluded by !**/test/** (last rule). + // Because the exclude rule is last, every child of src/test will also + // be excluded by it (last-match-wins). No include rule follows the + // last exclude, so MustDescend=false — safe to prune. + name: "explicit exclude is last rule: safe to prune despite prior include", + patterns: []string{"**/*.java", "!**/test/**"}, + dirRelPath: "src/test", + wantExcluded: true, + wantDescend: false, + }, + { + // Dir not excluded at all. + name: "non-excluded dir: MustDescend false", + patterns: []string{"!**/test/**"}, + dirRelPath: "src/main", + wantExcluded: false, + wantDescend: false, + }, + { + // No patterns. + name: "no patterns: nothing excluded or descended", + patterns: nil, + dirRelPath: "src/test", + wantExcluded: false, + wantDescend: false, + }, + { + // Static-prefix include inside excluded dir. + name: "static-prefix include inside excluded dir requires descent", + patterns: []string{"!src/**", "src/main/java/**"}, + dirRelPath: "src", + wantExcluded: true, + wantDescend: true, + }, + { + // Include for completely unrelated tree: "foo/**" after excluding "bar/". + // couldMatchUnder("foo/**", "bar"): staticPrefix="foo/", not compatible → false. + name: "include for unrelated tree does not require descent", + patterns: []string{"!bar/**", "foo/**"}, + dirRelPath: "bar", + wantExcluded: true, + wantDescend: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + m, err := filtering.NewAntMatcher(tc.patterns) + if err != nil { + t.Fatalf("unexpected construction error: %v", err) + } + + gotExcluded := m.Excluded(tc.dirRelPath, true) + if gotExcluded != tc.wantExcluded { + t.Errorf("Excluded(%q, isDir=true) = %v, want %v", tc.dirRelPath, gotExcluded, tc.wantExcluded) + } + + gotDescend := m.MustDescend(tc.dirRelPath) + if gotDescend != tc.wantDescend { + t.Errorf("MustDescend(%q) = %v, want %v", tc.dirRelPath, gotDescend, tc.wantDescend) + } + }) + } +} + +// ── Composition ─────────────────────────────────────────────────────────────── + +func TestNopMatcher(t *testing.T) { + t.Parallel() + var n filtering.NopMatcher + if n.Excluded("anything", false) { + t.Error("NopMatcher.Excluded should always return false") + } + if n.Excluded("anything", true) { + t.Error("NopMatcher.Excluded should always return false for dirs") + } + if n.MustDescend("anything") { + t.Error("NopMatcher.MustDescend should always return false") + } +} + +func TestMultiMatcher_Excluded_OrSemantics(t *testing.T) { + t.Parallel() + // Both matchers are exclude-only (! prefix) so default is included. + m1, _ := filtering.NewAntMatcher([]string{"!**/*.test.ts"}) + m2, _ := filtering.NewAntMatcher([]string{"!**/node_modules/**"}) + multi := filtering.NewMultiMatcher(m1, m2) + + if !multi.Excluded("src/Foo.test.ts", false) { + t.Error("m1 should have excluded .test.ts") + } + if !multi.Excluded("node_modules/lodash/index.js", false) { + t.Error("m2 should have excluded node_modules file") + } + if multi.Excluded("src/Foo.ts", false) { + t.Error("neither matcher should exclude src/Foo.ts") + } +} + +func TestMultiMatcher_MustDescend_OrSemantics(t *testing.T) { + t.Parallel() + // m1: exclude test/ but has an include rule for fixtures → must descend + m1, _ := filtering.NewAntMatcher([]string{"!**/test/**", "**/test/fixtures/**"}) + // m2: exclude test/ only → no descent needed + m2, _ := filtering.NewAntMatcher([]string{"!**/test/**"}) + multi := filtering.NewMultiMatcher(m1, m2) + + if !multi.MustDescend("src/test") { + t.Error("MultiMatcher should require descent because m1 does") + } +} + +func TestMultiMatcher_Add(t *testing.T) { + t.Parallel() + multi := filtering.NewMultiMatcher() + m, _ := filtering.NewAntMatcher([]string{"!**/*.test.ts"}) + multi.Add(m) + + if !multi.Excluded("src/Foo.test.ts", false) { + t.Error("expected exclusion after Add") + } +} + +func TestAntMatcher_EmptyPatterns_ExcludesNothing(t *testing.T) { + t.Parallel() + m, err := filtering.NewAntMatcher(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Excluded("src/Foo.go", false) { + t.Error("empty matcher should exclude nothing") + } + if m.MustDescend("src/test") { + t.Error("empty matcher should not require descent") + } +} diff --git a/internal/filtering/matcher.go b/internal/filtering/matcher.go new file mode 100644 index 000000000..a65e055cc --- /dev/null +++ b/internal/filtering/matcher.go @@ -0,0 +1,102 @@ +// Package filtering provides composable, path-aware file and directory +// filtering logic for source-tree traversal. +// +// # Design principles +// +// - The [Matcher] interface is the single extension point. New strategies +// (regex-based, policy-file-driven, per-directory gitignore) implement +// [Matcher] and compose via [MultiMatcher] without touching any call site +// in scan.go. +// +// - All matching operates on forward-slash relative paths from the source +// root (e.g. "src/test/Foo.java"), never on bare filenames. This makes +// path-segment patterns such as "src/**" meaningful and correct across +// platforms. +// +// - Directories are evaluated before files. When a directory is excluded +// AND no later negation rule can re-include anything under it, the entire +// sub-tree is pruned without descent ([Matcher.MustDescend] returns +// false). When a later negation rule may re-include children, the caller +// must descend and apply per-entry [Matcher.Excluded] checks. +// +// - This package is pure logic; it has no knowledge of zip writers, cobra +// commands, or any other infrastructure concern. +package filtering + +// Matcher decides whether a given filesystem entry should be excluded from a +// scan, and whether a currently-excluded directory must still be descended +// into because a later negation rule may re-include entries within it. +// +// relPath is the slash-separated path relative to the source root, e.g. +// "src/test/Foo.java" or "node_modules". It never starts with "/". +// isDir is true when the entry is a directory (or a symlink that resolves +// to one). +type Matcher interface { + // Excluded returns true when the entry at relPath should be skipped. + // For a directory, callers must also check [MustDescend] before deciding + // whether to prune the entire sub-tree. + Excluded(relPath string, isDir bool) bool + + // MustDescend returns true when the directory at dirRelPath is excluded + // by the current rule set BUT a later negation rule may re-include one or + // more of its descendants. When true, callers must descend into the + // directory and evaluate each child with [Excluded] rather than pruning + // the sub-tree wholesale. + // + // Implementations should err on the side of descending (returning true) + // when uncertain; the cost of unnecessary descent is performance, while + // the cost of incorrect pruning is silently missing included files. + MustDescend(dirRelPath string) bool +} + +// MultiMatcher composes multiple Matchers. An entry is excluded when ANY +// constituent Matcher excludes it (logical OR). Descent is required when ANY +// constituent Matcher requires it (logical OR), which preserves the +// conservative "descend when in doubt" invariant. +type MultiMatcher struct { + matchers []Matcher +} + +// NewMultiMatcher returns a MultiMatcher that composes all provided +// Matchers. The zero-value (no matchers) excludes nothing. +func NewMultiMatcher(matchers ...Matcher) *MultiMatcher { + return &MultiMatcher{matchers: matchers} +} + +// Excluded implements [Matcher]. +func (m *MultiMatcher) Excluded(relPath string, isDir bool) bool { + for _, matcher := range m.matchers { + if matcher.Excluded(relPath, isDir) { + return true + } + } + return false +} + +// MustDescend implements [Matcher]. +func (m *MultiMatcher) MustDescend(dirRelPath string) bool { + for _, matcher := range m.matchers { + if matcher.MustDescend(dirRelPath) { + return true + } + } + return false +} + +// Add appends additional Matchers to an existing MultiMatcher. This allows +// incremental composition (e.g. per-directory gitignore files discovered at +// walk time). +func (m *MultiMatcher) Add(matchers ...Matcher) { + m.matchers = append(m.matchers, matchers...) +} + +// NopMatcher is a Matcher that never excludes anything and never requires +// forced descent. Useful as a safe zero-value default when no patterns are +// configured. +type NopMatcher struct{} + +// Excluded implements [Matcher]; always returns false. +func (NopMatcher) Excluded(_ string, _ bool) bool { return false } + +// MustDescend implements [Matcher]; always returns false. +func (NopMatcher) MustDescend(_ string) bool { return false } diff --git a/internal/params/flags.go b/internal/params/flags.go index 9e90ab971..08e628a65 100644 --- a/internal/params/flags.go +++ b/internal/params/flags.go @@ -2,18 +2,11 @@ package params // Flags const ( - // OAuth browser login (cx auth login) + session storage modes + // OAuth browser login (cx auth login) LoginPortFlag = "port" LoginPortFlagUsage = "Local port for the OAuth callback listener (0 = pick a free port)" LoginNoBrowserFlag = "no-browser" LoginNoBrowserFlagUsage = "Print the authorization URL instead of opening a browser" - SessionFlag = "session" - SessionLocalValue = "local" - SessionGlobalValue = "global" - SessionYamlValue = "yaml" - SessionGlobalFileName = "session_global" - ActiveModeFileName = "active_mode" - SessionLoginFlagUsage = "Session mode: 'local' keeps the refresh token only in the current shell's environment (requires Invoke-Expression / eval wrapper); 'global' persists it to a dedicated file readable by every shell on the machine until explicit logout." AllStatesFlag = "all" AgentFlag = "agent" @@ -205,6 +198,8 @@ const ( LogFileConsoleUsage = "Saves logs to the specified file path as well as to the console" GitIgnoreFileFilterFlag = "use-gitignore" GitIgnoreFileFilterUsage = "Exclude files and directories from the scan based on the patterns defined in the directory's .gitignore file" + AntFilterFlag = "file-filter-ext" + AntFilterUsage = "Filter files/folders to include or exclude using Apache Ant-style glob patterns (e.g. **/*.java, !**/test/**)." // INDIVIDUAL FILTER FLAGS SastFilterFlag = "sast-filter" SastFilterUsage = "SAST filter" diff --git a/internal/services/asca.go b/internal/services/asca.go index fb55c664e..9bb61b74c 100644 --- a/internal/services/asca.go +++ b/internal/services/asca.go @@ -200,13 +200,11 @@ func manageASCAInstallation(ascaParams AscaScanParams, ascaWrappers AscaWrappers _ = ascaWrappers.ASCAWrapper.ShutDown() return err } - newInstallation, err := osinstaller.InstallOrUpgrade(&ascaconfig.Params) + _, err := osinstaller.InstallOrUpgrade(&ascaconfig.Params, ascaWrappers.ASCAWrapper) if err != nil { return err } - if newInstallation { - _ = ascaWrappers.ASCAWrapper.ShutDown() - } + } return nil } diff --git a/internal/services/osinstaller/os-installer.go b/internal/services/osinstaller/os-installer.go index 82994a457..f686cbf9c 100644 --- a/internal/services/osinstaller/os-installer.go +++ b/internal/services/osinstaller/os-installer.go @@ -9,9 +9,11 @@ import ( "net/http" "os" "path/filepath" + "time" "github.com/checkmarx/ast-cli/internal/logger" "github.com/checkmarx/ast-cli/internal/wrappers" + grpcs "github.com/checkmarx/ast-cli/internal/wrappers/grpcs" "github.com/pkg/errors" ) @@ -52,7 +54,7 @@ func downloadFile(downloadURLPath, filePath string) error { // InstallOrUpgrade Checks the version according to the hash file, // downloads the RealTime installation if the version is not up-to-date, // Extracts the RealTime installation according to the operating system type -func InstallOrUpgrade(installationConfiguration *InstallationConfiguration) (NewSuccessfulInstallation, error) { +func InstallOrUpgrade(installationConfiguration *InstallationConfiguration, ascaWrapper grpcs.AscaWrapper) (NewSuccessfulInstallation, error) { logger.PrintIfVerbose("Handling RealTime Installation...") if downloadNotNeeded(installationConfiguration) { logger.PrintIfVerbose("RealTime installation already exists and is up to date. Skipping download.") @@ -77,6 +79,10 @@ func InstallOrUpgrade(installationConfiguration *InstallationConfiguration) (New return false, err } + if ascaWrapper != nil { + shutDownAndWait(ascaWrapper) + } + // Unzip or extract downloaded zip depending on which OS is running err = UnzipOrExtractFiles(installationConfiguration) if err != nil { @@ -167,3 +173,27 @@ func downloadHashFile(hashURL, zipFileNameHash string) error { return nil } + +// shutDownAndWait sends a shutdown signal and polls until the service is no longer reachable, +// ensuring the process has released its file handles before the caller replaces the binary. +func shutDownAndWait(ascaWrapper grpcs.AscaWrapper) { + const ( + maxAttempts = 20 + pollInterval = 500 * time.Millisecond + ) + + logger.PrintIfVerbose("Shutting down Vorpal service before replacing binary...") + _ = ascaWrapper.ShutDown() + + port := ascaWrapper.GetPort() + for i := 0; i < maxAttempts; i++ { + // ConfigurePort resets the cached 'serving' flag, forcing a live connection attempt. + ascaWrapper.ConfigurePort(port) + if err := ascaWrapper.HealthCheck(); err != nil { + logger.PrintIfVerbose("Vorpal service has stopped.") + return + } + time.Sleep(pollInterval) + } + logger.PrintIfVerbose("Timed out waiting for Vorpal service to stop; proceeding anyway.") +} diff --git a/internal/wrappers/active_mode.go b/internal/wrappers/active_mode.go deleted file mode 100644 index 26fd4cfd4..000000000 --- a/internal/wrappers/active_mode.go +++ /dev/null @@ -1,88 +0,0 @@ -package wrappers - -import ( - "os" - "path/filepath" - "strings" - - "github.com/checkmarx/ast-cli/internal/params" - "github.com/checkmarx/ast-cli/internal/wrappers/configuration" - "github.com/pkg/errors" -) - -// File permission: owner read/write only. Active-mode metadata doesn't hold a -// credential itself, but it does reveal where the user's credential currently -// lives — keep it owner-only to avoid leaking that signal. -const activeModeFilePerm = 0o600 - -// ActiveModeFilePath returns the absolute path to the active-mode metadata -// file. Derived from the same config directory as the existing yaml so a -// custom --config-file-path is respected. -func ActiveModeFilePath() (string, error) { - configPath, err := configuration.GetConfigFilePath() - if err != nil { - return "", errors.Wrap(err, "failed to resolve config file path for active-mode file") - } - return filepath.Join(filepath.Dir(configPath), params.ActiveModeFileName), nil -} - -// ReadActiveMode returns the currently active session mode — one of -// params.SessionYamlValue, params.SessionLocalValue, or -// params.SessionGlobalValue. Returns ("", nil) if the file does not exist, -// which means "no active session" — every read path falls back to whatever -// the user has set directly (env var or yaml). -func ReadActiveMode() (string, error) { - path, err := ActiveModeFilePath() - if err != nil { - return "", err - } - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", errors.Wrap(err, "failed to read active-mode file") - } - mode := strings.TrimSpace(string(data)) - switch mode { - case params.SessionYamlValue, params.SessionLocalValue, params.SessionGlobalValue, "": - return mode, nil - default: - // Unknown value — treat as no active mode so the CLI doesn't get - // confused by a corrupt file. Caller can still fall back to defaults. - return "", nil - } -} - -// WriteActiveMode persists the active session mode. Creates the config -// directory if needed so the first-ever login on a fresh machine works. -func WriteActiveMode(mode string) error { - if mode != params.SessionYamlValue && mode != params.SessionLocalValue && mode != params.SessionGlobalValue { - return errors.Errorf("invalid active mode %q: must be %q, %q, or %q", - mode, params.SessionYamlValue, params.SessionLocalValue, params.SessionGlobalValue) - } - path, err := ActiveModeFilePath() - if err != nil { - return err - } - if mkErr := os.MkdirAll(filepath.Dir(path), 0o700); mkErr != nil { - return errors.Wrap(mkErr, "failed to create config directory for active-mode file") - } - if writeErr := os.WriteFile(path, []byte(mode), activeModeFilePerm); writeErr != nil { - return errors.Wrap(writeErr, "failed to write active-mode file") - } - return nil -} - -// ClearActiveMode removes the active-mode file. Returns nil if the file -// already does not exist (logout is idempotent). -func ClearActiveMode() error { - path, err := ActiveModeFilePath() - if err != nil { - return err - } - if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { - return errors.Wrap(rmErr, "failed to remove active-mode file") - } - return nil -} diff --git a/internal/wrappers/active_mode_test.go b/internal/wrappers/active_mode_test.go deleted file mode 100644 index baf20af7b..000000000 --- a/internal/wrappers/active_mode_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package wrappers - -import ( - "os" - "path/filepath" - "testing" - - "github.com/checkmarx/ast-cli/internal/params" -) - -func TestActiveModeFilePath_ReturnsPathInConfigDir(t *testing.T) { - dir := withTempConfigDir(t) - got, err := ActiveModeFilePath() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := filepath.Join(dir, params.ActiveModeFileName) - if got != want { - t.Errorf("ActiveModeFilePath() = %q, want %q", got, want) - } -} - -func TestReadActiveMode_EmptyWhenFileMissing(t *testing.T) { - withTempConfigDir(t) - got, err := ReadActiveMode() - if err != nil { - t.Fatalf("expected nil error when file absent, got: %v", err) - } - if got != "" { - t.Errorf("expected empty string, got %q", got) - } -} - -func TestWriteAndReadActiveMode_RoundTrip(t *testing.T) { - withTempConfigDir(t) - for _, mode := range []string{params.SessionYamlValue, params.SessionLocalValue, params.SessionGlobalValue} { - if err := WriteActiveMode(mode); err != nil { - t.Fatalf("WriteActiveMode(%q) failed: %v", mode, err) - } - got, err := ReadActiveMode() - if err != nil { - t.Fatalf("ReadActiveMode after writing %q failed: %v", mode, err) - } - if got != mode { - t.Errorf("round-trip mismatch: wrote %q, read %q", mode, got) - } - } -} - -func TestWriteActiveMode_RejectsInvalidValue(t *testing.T) { - withTempConfigDir(t) - err := WriteActiveMode("invalid-mode-value") - if err == nil { - t.Fatal("expected error for invalid mode value, got nil") - } -} - -func TestReadActiveMode_TreatsCorruptValueAsAbsent(t *testing.T) { - dir := withTempConfigDir(t) - // Manually write garbage to the active-mode file. - if err := os.WriteFile(filepath.Join(dir, params.ActiveModeFileName), []byte("garbage-mode"), 0o600); err != nil { - t.Fatalf("setup write failed: %v", err) - } - got, err := ReadActiveMode() - if err != nil { - t.Fatalf("ReadActiveMode returned unexpected error: %v", err) - } - if got != "" { - t.Errorf("expected corrupt value to be treated as absent (empty), got %q", got) - } -} - -func TestClearActiveMode_RemovesFile(t *testing.T) { - withTempConfigDir(t) - if err := WriteActiveMode(params.SessionGlobalValue); err != nil { - t.Fatalf("setup write failed: %v", err) - } - if err := ClearActiveMode(); err != nil { - t.Fatalf("ClearActiveMode failed: %v", err) - } - got, err := ReadActiveMode() - if err != nil { - t.Fatalf("ReadActiveMode after clear failed: %v", err) - } - if got != "" { - t.Errorf("expected empty after clear, got %q", got) - } -} - -func TestClearActiveMode_IdempotentWhenFileMissing(t *testing.T) { - withTempConfigDir(t) - if err := ClearActiveMode(); err != nil { - t.Errorf("expected nil error when file absent, got: %v", err) - } -} diff --git a/internal/wrappers/client.go b/internal/wrappers/client.go index 003b21324..142afe190 100644 --- a/internal/wrappers/client.go +++ b/internal/wrappers/client.go @@ -659,9 +659,7 @@ func getClientCredentialsFromCache(tokenExpirySeconds int) string { } // InvalidateAccessTokenCache forces the next GetAccessToken to re-exchange the -// stored credential instead of returning a cached access token. Used after a fresh -// `cx auth login` (which may target a different tenant) so the new ast-base-url -// claim is re-derived. Guarded by the same mutex that protects the cache writes. +// stored credential (e.g. after a login that may target a different tenant). func InvalidateAccessTokenCache() { credentialsMutex.Lock() defer credentialsMutex.Unlock() @@ -679,10 +677,8 @@ func writeCredentialsToCache(accessToken string) { cachedAccessTime = time.Now() } -// SetCachedAccessTokenForTest seeds (token != "") or clears (token == "") the -// in-memory access-token cache. Exported solely so tests in other packages can -// control the cache without reaching into unexported state. Guarded by the same -// mutex that protects the cache writes. +// SetCachedAccessTokenForTest seeds (or clears, when empty) the access-token +// cache for tests in other packages. func SetCachedAccessTokenForTest(token string) { credentialsMutex.Lock() defer credentialsMutex.Unlock() @@ -920,12 +916,8 @@ func GetRealmURL() (string, error) { override := viper.GetBool(commonParams.ApikeyOverrideFlag) apiKey := viper.GetString(commonParams.AstAPIKey) - // When override is set (e.g. `cx auth login`, which forces ApikeyOverrideFlag - // so the explicit --base-auth-uri/--tenant win), do NOT decode the stored API - // key. Decoding it here would surface a stale/malformed cx_apikey as a hard - // "failed to resolve IAM realm URL" error before the override branch below can - // build the realm from the flags — defeating the very purpose of the override - // and making login impossible until the bad key is manually cleared. + // On override, skip decoding the stored key so the flags win and a stale key + // can't block login with a decode error. if len(apiKey) > 0 && !override { logger.PrintIfVerbose("Base Auth URI - Extract from API KEY") authURI, err = ExtractFromTokenClaims(apiKey, audienceClaimKey) diff --git a/internal/wrappers/configuration/configuration.go b/internal/wrappers/configuration/configuration.go index 64a19b6d6..57509ef62 100644 --- a/internal/wrappers/configuration/configuration.go +++ b/internal/wrappers/configuration/configuration.go @@ -93,6 +93,37 @@ func PromptConfiguration() { } } +// PromptAuthConnection prompts for base-uri, base-auth-uri, and tenant (what cx auth +// login needs), like cx configure. Blank keeps the default; non-interactive stdin sets nothing. +func PromptAuthConnection() { + reader := bufio.NewReader(os.Stdin) + baseURI := viper.GetString(params.BaseURIKey) + baseURISrc := baseURI + baseAuthURI := viper.GetString(params.BaseAuthURIKey) + tenant := viper.GetString(params.TenantKey) + + fmt.Printf("AST Base URI [%s]: ", baseURI) + if v := readLine(reader); v != "" { + setConfigPropertyQuiet(params.BaseURIKey, v) + } + if baseAuthURI == "" { + baseAuthURI = baseURISrc + } + fmt.Printf("AST Base Auth URI (IAM) [%s]: ", baseAuthURI) + if v := readLine(reader); v != "" { + setConfigPropertyQuiet(params.BaseAuthURIKey, v) + } + fmt.Printf("AST Tenant [%s]: ", tenant) + if v := readLine(reader); v != "" { + setConfigPropertyQuiet(params.TenantKey, v) + } +} + +func readLine(reader *bufio.Reader) string { + s, _ := reader.ReadString('\n') + return strings.TrimSpace(s) +} + func obfuscateString(str string) string { if len(str) > obfuscateLimit { return "******" + str[len(str)-4:] diff --git a/internal/wrappers/oauth_pkce.go b/internal/wrappers/oauth_pkce.go index 55aebc5e7..fc2dfe95a 100644 --- a/internal/wrappers/oauth_pkce.go +++ b/internal/wrappers/oauth_pkce.go @@ -22,10 +22,15 @@ import ( "github.com/pkg/errors" ) -// pkceScopes matches the scopes requested by the Checkmarx One VS Code -// extension's OAuth flow. The ast-api / iam-api scopes are configured as -// default client scopes on the ide-integration Keycloak client, so they -// are granted automatically without being requested explicitly. +// Keycloak endpoints built directly from the realm URL, matching the IDE +// plugins (no OIDC .well-known discovery). +const ( + authorizeEndpointSuffix = "protocol/openid-connect/auth" + tokenEndpointSuffix = "protocol/openid-connect/token" +) + +// pkceScopes matches the ide-integration client's scopes; offline_access yields +// the refresh token stored as cx_apikey. const pkceScopes = "openid offline_access" const pkceLoginTimeout = 5 * time.Minute @@ -46,16 +51,9 @@ type PKCELoginOptions struct { OpenBrowser bool } -type oidcDiscovery struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` -} - // LoginWithPKCE runs an OAuth 2.0 Authorization Code + PKCE flow against the -// Keycloak realm at opts.RealmURL and returns the token response. The flow -// starts a one-shot HTTP listener on 127.0.0.1, opens the user's browser to -// the authorize URL, and waits for the redirect callback. The caller is -// responsible for persisting or printing the returned tokens. +// realm: opens the browser, listens on a loopback callback, and exchanges the +// returned code for tokens. The caller persists the result. func LoginWithPKCE(ctx context.Context, opts PKCELoginOptions) (*PKCETokenResponse, error) { if opts.RealmURL == "" { return nil, errors.New("realm URL is required") @@ -64,10 +62,9 @@ func LoginWithPKCE(ctx context.Context, opts PKCELoginOptions) (*PKCETokenRespon return nil, errors.New("client-id is required") } - disco, err := discoverOIDC(ctx, opts.RealmURL) - if err != nil { - return nil, errors.Wrap(err, "failed to fetch OIDC discovery document") - } + realm := strings.TrimRight(opts.RealmURL, "/") + authorizationEndpoint := realm + "/" + authorizeEndpointSuffix + tokenEndpoint := realm + "/" + tokenEndpointSuffix verifier, challenge, err := newPKCE() if err != nil { @@ -87,15 +84,10 @@ func LoginWithPKCE(ctx context.Context, opts PKCELoginOptions) (*PKCETokenRespon if !ok { return nil, errors.New("local listener did not bind to a TCP address") } - // The redirect URI uses the 'localhost' hostname and the '/checkmarx1/callback' - // path to match the pattern whitelisted on the 'ide-integration' Keycloak client - // — the same pattern used by the Checkmarx One VS Code extension. Because - // 'localhost' resolves to ::1 on IPv6-preferring systems, we ALSO bind an IPv6 - // loopback listener on the same port below (best-effort), so the browser callback - // reaches us whichever family 'localhost' resolves to. Both listeners are - // loopback-only (safe). + // /checkmarx1/callback on localhost matches the ide-integration client's + // whitelisted redirect pattern. redirectURI := fmt.Sprintf("http://localhost:%d/checkmarx1/callback", tcpAddr.Port) - authURL := buildAuthorizeURL(disco.AuthorizationEndpoint, opts.ClientID, redirectURI, state, challenge) + authURL := buildAuthorizeURL(authorizationEndpoint, opts.ClientID, redirectURI, state, challenge) type callbackResult struct { code string @@ -106,12 +98,8 @@ func LoginWithPKCE(ctx context.Context, opts PKCELoginOptions) (*PKCETokenRespon mux := http.NewServeMux() mux.HandleFunc("/checkmarx1/callback", func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - // Validate the anti-CSRF state FIRST. A request that does not carry our - // exact state value is unsolicited (a stray prefetch, a probe, or a CSRF - // attempt) and is IGNORED — we do NOT resolve resultCh, so it cannot - // abort the pending login, and we do not act on its other parameters. - // The genuine callback echoes our state and is the only thing that - // completes the flow; if none arrives, the outer timeout fires. + // Anti-CSRF: ignore any request without our exact state, so a stray/forged + // callback can't complete or abort the pending login. if got := q.Get("state"); got != state { writeBrowserMessage(w, "Authentication failed.", "State mismatch — this request was ignored. You can close this tab.") logger.PrintIfVerbose("OAuth callback: ignoring request with missing/mismatched state") @@ -135,9 +123,7 @@ func LoginWithPKCE(ctx context.Context, opts PKCELoginOptions) (*PKCETokenRespon server := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} go func() { _ = server.Serve(listener) }() - // Best-effort IPv6 loopback listener on the same port, so a browser that resolves - // 'localhost' to ::1 still reaches the callback. If it fails (no IPv6 stack, or the - // port is taken on ::1), proceed IPv4-only — unchanged from the previous behavior. + // Best-effort IPv6 loopback so 'localhost' resolving to ::1 still reaches us. if v6Listener, v6Err := net.Listen("tcp6", fmt.Sprintf("[::1]:%d", tcpAddr.Port)); v6Err == nil { defer v6Listener.Close() go func() { _ = server.Serve(v6Listener) }() @@ -150,10 +136,7 @@ func LoginWithPKCE(ctx context.Context, opts PKCELoginOptions) (*PKCETokenRespon _ = server.Shutdown(shutdownCtx) }() - // Diagnostic messages go to stderr so session mode's eval-able stdout - // (the env-var assignment line emitted by the caller after this returns) - // is not polluted. In default mode the diagnostics are still visible in - // the terminal since stderr renders to the console. + // Diagnostics to stderr so stdout stays clean for callers that capture it. fmt.Fprintf(os.Stderr, "Opening browser to: %s\n", authURL) fmt.Fprintln(os.Stderr, "If the browser does not open, copy and paste the URL above.") if opts.OpenBrowser { @@ -176,35 +159,7 @@ func LoginWithPKCE(ctx context.Context, opts PKCELoginOptions) (*PKCETokenRespon return nil, ctx.Err() } - return exchangeCodeForToken(ctx, disco.TokenEndpoint, opts.ClientID, code, verifier, redirectURI) -} - -func discoverOIDC(ctx context.Context, realmURL string) (*oidcDiscovery, error) { - discoURL := strings.TrimRight(realmURL, "/") + "/.well-known/openid-configuration" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoURL, nil) - if err != nil { - return nil, err - } - client := GetClient(15) - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return nil, errors.Errorf("realm not found at %s — check --tenant and --base-auth-uri", discoURL) - } - if resp.StatusCode != http.StatusOK { - return nil, errors.Errorf("discovery endpoint returned status %d", resp.StatusCode) - } - var d oidcDiscovery - if err := json.NewDecoder(resp.Body).Decode(&d); err != nil { - return nil, errors.Wrap(err, "failed to decode discovery document") - } - if d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" { - return nil, errors.New("discovery document is missing authorization_endpoint or token_endpoint") - } - return &d, nil + return exchangeCodeForToken(ctx, tokenEndpoint, opts.ClientID, code, verifier, redirectURI) } func buildAuthorizeURL(authEndpoint, clientID, redirectURI, state, challenge string) string { @@ -255,47 +210,6 @@ func exchangeCodeForToken(ctx context.Context, tokenEndpoint, clientID, code, ve return &tr, nil } -// RevokeRefreshToken invalidates the given refresh token at the Keycloak realm -// via the OAuth 2.0 Token Revocation endpoint (RFC 7009). This is deliberately -// the /revoke endpoint and NOT /logout: /logout is RP-initiated logout that -// ends the entire SSO session and would invalidate every token in that -// session — including tokens we want to keep alive in other CLI session modes. -// /revoke targets a single token, leaving sibling tokens in the same session -// untouched, which is what strict storage independence between --session -// modes requires. -// -// Idempotent: a 400 response (token already invalid) is treated as success -// so callers can use this as best-effort cleanup during auto-revoke and -// explicit logout. -func RevokeRefreshToken(ctx context.Context, realmURL, clientID, refreshToken string) error { - endpoint := strings.TrimRight(realmURL, "/") + "/protocol/openid-connect/revoke" - form := url.Values{} - form.Set("client_id", clientID) - form.Set("token", refreshToken) - form.Set("token_type_hint", "refresh_token") - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := GetClient(15).Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - if resp.StatusCode == http.StatusBadRequest { - return nil - } - body, _ := io.ReadAll(resp.Body) - return errors.Errorf("revoke request failed with status %d: %s", resp.StatusCode, string(body)) -} - func newPKCE() (verifier, challenge string, err error) { verifier, err = randomURLSafe(32) if err != nil { @@ -314,12 +228,14 @@ func randomURLSafe(byteLen int) (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -// openBrowser is a package-level var so tests can intercept the launch and -// simulate the user completing the OAuth flow without a real browser. +// openBrowser is a var so tests can intercept the launch. var openBrowser = func(targetURL string) error { switch runtime.GOOS { case "windows": - return exec.Command("rundll32", "url.dll,FileProtocolHandler", targetURL).Start() + // `start` reads a quoted first arg as the window title, so pass an empty + // one; escape & to ^& since cmd treats it as a command separator. + escaped := strings.ReplaceAll(targetURL, "&", "^&") + return exec.Command("cmd", "/c", "start", "", escaped).Start() case "darwin": return exec.Command("open", targetURL).Start() default: @@ -329,8 +245,7 @@ var openBrowser = func(targetURL string) error { func writeBrowserMessage(w http.ResponseWriter, title, body string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - // Escape both fields: body may contain server-supplied error/description - // text, so it must never be reflected into the page as raw HTML. + // Escape: body may carry server-supplied error text — never reflect raw HTML. safeTitle := html.EscapeString(title) safeBody := html.EscapeString(body) _, _ = fmt.Fprintf(w, `%s diff --git a/internal/wrappers/oauth_pkce_test.go b/internal/wrappers/oauth_pkce_test.go index a5ead20c3..055e10c5d 100644 --- a/internal/wrappers/oauth_pkce_test.go +++ b/internal/wrappers/oauth_pkce_test.go @@ -52,44 +52,6 @@ func TestBuildAuthorizeURL_IncludesAllRequiredParams(t *testing.T) { } } -func TestDiscoverOIDC_HappyPath(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/.well-known/openid-configuration" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "authorization_endpoint": "https://iam.example.com/auth", - "token_endpoint": "https://iam.example.com/token", - }) - })) - defer srv.Close() - - d, err := discoverOIDC(context.Background(), srv.URL) - if err != nil { - t.Fatalf("discoverOIDC returned error: %v", err) - } - if d.AuthorizationEndpoint != "https://iam.example.com/auth" || d.TokenEndpoint != "https://iam.example.com/token" { - t.Errorf("unexpected endpoints: %+v", d) - } -} - -func TestDiscoverOIDC_404(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.NotFound(w, r) - })) - defer srv.Close() - - _, err := discoverOIDC(context.Background(), srv.URL) - if err == nil { - t.Fatal("expected error on 404, got nil") - } - if !strings.Contains(err.Error(), "realm not found") { - t.Errorf("error %q should mention 'realm not found'", err.Error()) - } -} - func TestExchangeCodeForToken_HappyPath(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() @@ -154,25 +116,14 @@ func TestExchangeCodeForToken_KeycloakError(t *testing.T) { } } -// TestLoginWithPKCE_HappyPath drives the full flow end-to-end with a fake -// Keycloak. It hijacks the openBrowser var so the test itself plays the role -// of the browser — visiting the /authorize URL, which makes the fake Keycloak -// redirect back to the CLI's local listener with code+state. +// End-to-end against a fake Keycloak; openBrowser is stubbed to play the browser. func TestLoginWithPKCE_HappyPath(t *testing.T) { mux := http.NewServeMux() var srv *httptest.Server srv = httptest.NewServer(mux) defer srv.Close() - mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "authorization_endpoint": srv.URL + "/auth", - "token_endpoint": srv.URL + "/token", - }) - }) - - mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/protocol/openid-connect/auth", func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() redirectURI := q.Get("redirect_uri") state := q.Get("state") @@ -185,7 +136,7 @@ func TestLoginWithPKCE_HappyPath(t *testing.T) { w.WriteHeader(http.StatusOK) }) - mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() if r.Form.Get("code") != "fake-auth-code" { http.Error(w, "bad code", http.StatusBadRequest) @@ -225,58 +176,3 @@ func TestLoginWithPKCE_HappyPath(t *testing.T) { t.Errorf("got refresh_token %q, want fake-refresh", tokens.RefreshToken) } } - -func TestRevokeRefreshToken_HappyPath(t *testing.T) { - var gotForm url.Values - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/protocol/openid-connect/revoke" { - http.NotFound(w, r) - return - } - _ = r.ParseForm() - gotForm = r.Form - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - err := RevokeRefreshToken(context.Background(), srv.URL, "ast-app", "the-rt") - if err != nil { - t.Fatalf("RevokeRefreshToken returned error: %v", err) - } - if gotForm.Get("client_id") != "ast-app" { - t.Errorf("client_id form field: got %q, want ast-app", gotForm.Get("client_id")) - } - if gotForm.Get("token") != "the-rt" { - t.Errorf("token form field: got %q, want the-rt", gotForm.Get("token")) - } - if gotForm.Get("token_type_hint") != "refresh_token" { - t.Errorf("token_type_hint form field: got %q, want refresh_token", gotForm.Get("token_type_hint")) - } -} - -func TestRevokeRefreshToken_AlreadyInvalidIsIdempotent(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `{"error":"invalid_grant","error_description":"Refresh token expired"}`, http.StatusBadRequest) - })) - defer srv.Close() - - err := RevokeRefreshToken(context.Background(), srv.URL, "ast-app", "expired-rt") - if err != nil { - t.Errorf("400 response should be treated as already-logged-out (nil error), got: %v", err) - } -} - -func TestRevokeRefreshToken_ServerErrorIsSurfaced(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "boom", http.StatusInternalServerError) - })) - defer srv.Close() - - err := RevokeRefreshToken(context.Background(), srv.URL, "ast-app", "the-rt") - if err == nil { - t.Fatal("expected error on 500 response, got nil") - } - if !strings.Contains(err.Error(), "500") { - t.Errorf("error should mention status code 500: %q", err.Error()) - } -} diff --git a/internal/wrappers/session_global.go b/internal/wrappers/session_global.go deleted file mode 100644 index 8b84c6ef8..000000000 --- a/internal/wrappers/session_global.go +++ /dev/null @@ -1,145 +0,0 @@ -package wrappers - -import ( - "os" - "path/filepath" - "strings" - - "github.com/checkmarx/ast-cli/internal/params" - "github.com/checkmarx/ast-cli/internal/wrappers/configuration" - "github.com/pkg/errors" - "github.com/spf13/viper" -) - -// File permission: owner read/write only. The global session file holds a -// refresh token; readable group/world would be a credential leak. -const sessionGlobalFilePerm = 0o600 - -// SessionGlobalFilePath returns the absolute path to the global session file. -// Derived from the same config directory as the existing yaml (so a custom -// --config-file-path is respected), with the filename swapped to -// SessionGlobalFileName. -func SessionGlobalFilePath() (string, error) { - configPath, err := configuration.GetConfigFilePath() - if err != nil { - return "", errors.Wrap(err, "failed to resolve config file path for global session file") - } - dir := filepath.Dir(configPath) - return filepath.Join(dir, params.SessionGlobalFileName), nil -} - -// ReadSessionGlobal returns the refresh token persisted by --session global -// mode. Returns ("", nil) if the file does not exist — that just means the -// user has not logged in via global mode. Any other I/O error is surfaced. -func ReadSessionGlobal() (string, error) { - path, err := SessionGlobalFilePath() - if err != nil { - return "", err - } - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", errors.Wrap(err, "failed to read global session file") - } - return strings.TrimSpace(string(data)), nil -} - -// WriteSessionGlobal writes the refresh token to the global session file with -// owner-only permissions. Creates the parent directory if necessary so the -// first-ever --session global login on a fresh machine works. -func WriteSessionGlobal(refreshToken string) error { - path, err := SessionGlobalFilePath() - if err != nil { - return err - } - if mkErr := os.MkdirAll(filepath.Dir(path), 0o700); mkErr != nil { - return errors.Wrap(mkErr, "failed to create config directory for global session file") - } - if writeErr := os.WriteFile(path, []byte(refreshToken), sessionGlobalFilePerm); writeErr != nil { - return errors.Wrap(writeErr, "failed to write global session file") - } - return nil -} - -// ClearSessionGlobal removes the global session file. Returns nil if the file -// already does not exist (logout is idempotent — running it twice is fine). -func ClearSessionGlobal() error { - path, err := SessionGlobalFilePath() - if err != nil { - return err - } - if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) { - return errors.Wrap(rmErr, "failed to remove global session file") - } - return nil -} - -// LoadActiveCredential makes the refresh token from the active session mode -// available to viper, so every CLI command's existing cx_apikey lookup -// resolves to the right credential — without any precedence chain. -// -// The active-mode metadata file (~/.checkmarx/active_mode) tells us where -// the user's current credential lives: -// -// - "yaml": read cx_apikey from the yaml config file; viper.Set it so it -// wins over any stale CX_APIKEY env var left over from a -// previous --session local invocation -// - "local": no action — env-binding (viper.BindEnv) already gives env the -// right precedence; we want the user's current shell env to -// win -// - "global": read the dedicated global file; viper.Set it so it wins over -// any stale yaml or env value -// - "": no active session — viper's natural precedence applies -// (env > yaml). Backward-compatible with users who set -// CX_APIKEY directly or who logged in with the previous CLI. -// -// Called once at startup from main, after configuration.LoadConfiguration. -func LoadActiveCredential() { - mode, err := ReadActiveMode() - if err != nil || mode == "" { - return - } - switch mode { - case params.SessionGlobalValue: - rt, err := ReadSessionGlobal() - if err == nil && rt != "" { - viper.Set(params.AstAPIKey, rt) - } - case params.SessionYamlValue: - // Yaml's cx_apikey is already loaded by configuration.LoadConfiguration, - // but env-binding would override it if a stale CX_APIKEY is set in - // this shell. viper.Set with the yaml value forces yaml to win. - yamlRT := ReadYamlAPIKey() - if yamlRT != "" { - viper.Set(params.AstAPIKey, yamlRT) - } - case params.SessionLocalValue: - // Env binding already gives the current shell's CX_APIKEY the right - // precedence (env > config file in viper). Nothing to do here. - // If the user is in a shell that didn't run the --session local - // login, env will be empty and the command will surface a clear - // "not authenticated" error. - } -} - -// ReadYamlAPIKey reads cx_apikey directly from the yaml config file, bypassing -// viper's env-first precedence. Used by LoadActiveCredential to force yaml to -// win when the active mode is "yaml" but a stale CX_APIKEY env var exists, and -// by the auth login/logout nuke phase to revoke whatever yaml actually holds -// (not what viper currently resolves to, which could be a stale env var). -func ReadYamlAPIKey() string { - configPath, err := configuration.GetConfigFilePath() - if err != nil { - return "" - } - yamlConfig, err := configuration.LoadConfig(configPath) - if err != nil { - return "" - } - if v, ok := yamlConfig[params.AstAPIKey].(string); ok { - return v - } - return "" -} diff --git a/internal/wrappers/session_global_test.go b/internal/wrappers/session_global_test.go deleted file mode 100644 index 688415c81..000000000 --- a/internal/wrappers/session_global_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package wrappers - -import ( - "os" - "path/filepath" - "testing" - - "github.com/checkmarx/ast-cli/internal/params" - "github.com/spf13/viper" -) - -// withTempConfigDir points viper at a temp directory for the duration of one -// test, so the session_global helpers operate on a sandbox rather than the -// real user's ~/.checkmarx. Restores prior state via t.Cleanup. -func withTempConfigDir(t *testing.T) string { - t.Helper() - dir := t.TempDir() - prev := viper.GetString(params.ConfigFilePathKey) - viper.Set(params.ConfigFilePathKey, filepath.Join(dir, "checkmarxcli.yaml")) - t.Cleanup(func() { - viper.Set(params.ConfigFilePathKey, prev) - }) - return dir -} - -func TestSessionGlobalFilePath_ReturnsPathInConfigDir(t *testing.T) { - dir := withTempConfigDir(t) - got, err := SessionGlobalFilePath() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := filepath.Join(dir, params.SessionGlobalFileName) - if got != want { - t.Errorf("SessionGlobalFilePath() = %q, want %q", got, want) - } -} - -func TestReadSessionGlobal_ReturnsEmptyWhenFileMissing(t *testing.T) { - withTempConfigDir(t) - got, err := ReadSessionGlobal() - if err != nil { - t.Fatalf("expected nil error when file does not exist, got: %v", err) - } - if got != "" { - t.Errorf("expected empty string, got %q", got) - } -} - -func TestWriteAndReadSessionGlobal_RoundTrip(t *testing.T) { - withTempConfigDir(t) - token := "eyJhbGc.test-refresh-token.xyz" - if err := WriteSessionGlobal(token); err != nil { - t.Fatalf("WriteSessionGlobal failed: %v", err) - } - got, err := ReadSessionGlobal() - if err != nil { - t.Fatalf("ReadSessionGlobal failed: %v", err) - } - if got != token { - t.Errorf("round-trip mismatch: wrote %q, read %q", token, got) - } -} - -func TestReadSessionGlobal_TrimsWhitespace(t *testing.T) { - dir := withTempConfigDir(t) - // Write a token with trailing newline directly to disk to simulate a file - // edited by hand. - path := filepath.Join(dir, params.SessionGlobalFileName) - if err := os.WriteFile(path, []byte("the-token\n"), 0o600); err != nil { - t.Fatalf("setup write failed: %v", err) - } - got, err := ReadSessionGlobal() - if err != nil { - t.Fatalf("ReadSessionGlobal failed: %v", err) - } - if got != "the-token" { - t.Errorf("expected trailing whitespace trimmed, got %q", got) - } -} - -func TestClearSessionGlobal_RemovesFile(t *testing.T) { - withTempConfigDir(t) - if err := WriteSessionGlobal("some-token"); err != nil { - t.Fatalf("setup write failed: %v", err) - } - if err := ClearSessionGlobal(); err != nil { - t.Fatalf("ClearSessionGlobal failed: %v", err) - } - got, err := ReadSessionGlobal() - if err != nil { - t.Fatalf("ReadSessionGlobal after clear failed: %v", err) - } - if got != "" { - t.Errorf("expected empty after clear, got %q", got) - } -} - -func TestClearSessionGlobal_IdempotentWhenFileMissing(t *testing.T) { - withTempConfigDir(t) - // File never created; clearing it should not error. - if err := ClearSessionGlobal(); err != nil { - t.Errorf("expected nil error when file does not exist, got: %v", err) - } -} - -func TestLoadActiveCredential_GlobalModeLoadsFile(t *testing.T) { - withTempConfigDir(t) - t.Setenv(params.AstAPIKeyEnv, "") - if err := WriteSessionGlobal("global-token"); err != nil { - t.Fatalf("setup write failed: %v", err) - } - if err := WriteActiveMode(params.SessionGlobalValue); err != nil { - t.Fatalf("WriteActiveMode failed: %v", err) - } - viper.Set(params.AstAPIKey, "") - LoadActiveCredential() - if got := viper.GetString(params.AstAPIKey); got != "global-token" { - t.Errorf("expected global mode to load token into viper, got %q", got) - } -} - -func TestLoadActiveCredential_GlobalOverridesStaleEnv(t *testing.T) { - withTempConfigDir(t) - t.Setenv(params.AstAPIKeyEnv, "stale-env-token") - if err := WriteSessionGlobal("global-token"); err != nil { - t.Fatalf("setup write failed: %v", err) - } - if err := WriteActiveMode(params.SessionGlobalValue); err != nil { - t.Fatalf("WriteActiveMode failed: %v", err) - } - viper.Set(params.AstAPIKey, "") - LoadActiveCredential() - if got := viper.GetString(params.AstAPIKey); got != "global-token" { - t.Errorf("global mode must win over stale env, got %q", got) - } -} - -func TestLoadActiveCredential_LocalModeNoOpLetsEnvWin(t *testing.T) { - withTempConfigDir(t) - t.Setenv(params.AstAPIKeyEnv, "local-token") - if err := WriteActiveMode(params.SessionLocalValue); err != nil { - t.Fatalf("WriteActiveMode failed: %v", err) - } - viper.Set(params.AstAPIKey, "") - LoadActiveCredential() - // We don't viper.Set for local mode — env binding does the work. - // Verify that we didn't overwrite anything. - // (We can't easily verify env-binding inside this test without - // going through viper, so just confirm no error and no surprise Set.) - if got := viper.GetString(params.AstAPIKey); got != "" && got != "local-token" { - t.Errorf("local mode should not viper.Set anything; got unexpected %q", got) - } -} - -func TestLoadActiveCredential_NoActiveModeIsNoOp(t *testing.T) { - withTempConfigDir(t) - // No WriteActiveMode call — file is absent. - viper.Set(params.AstAPIKey, "") - LoadActiveCredential() - if got := viper.GetString(params.AstAPIKey); got != "" { - t.Errorf("expected viper.AstAPIKey to remain empty when no active mode, got %q", got) - } -} diff --git a/internal/wrappers/telemetry.go b/internal/wrappers/telemetry.go index b3e58781f..39ec639b1 100644 --- a/internal/wrappers/telemetry.go +++ b/internal/wrappers/telemetry.go @@ -1,16 +1,17 @@ package wrappers type DataForAITelemetry struct { - AIProvider string `json:"aiProvider"` - ProblemSeverity string `json:"problemSeverity"` - Type string `json:"type"` - SubType string `json:"subType"` - Agent string `json:"agent"` - Engine string `json:"engine"` - ScanType string `json:"scanType"` - Status string `json:"status"` - TotalCount int `json:"totalCount"` - UniqueID string `json:"uniqueId"` + AIProvider string `json:"aiProvider"` + ProblemSeverity string `json:"problemSeverity"` + Type string `json:"type"` + SubType string `json:"subType"` + Agent string `json:"agent"` + Engine string `json:"engine"` + ScanType string `json:"scanType"` + Status string `json:"status"` + TotalCount int `json:"totalCount"` + UniqueID string `json:"uniqueId"` + AiAgentSessionId string `json:"aiAgentSessionId,omitempty"` } type TelemetryWrapper interface { diff --git a/internal/wrappers/utils/utils.go b/internal/wrappers/utils/utils.go index 1453a10b4..b54f5d8a2 100644 --- a/internal/wrappers/utils/utils.go +++ b/internal/wrappers/utils/utils.go @@ -16,8 +16,10 @@ var ( ) var allowedOptionalKeys = map[string]bool{ - "asca-location": true, - "aiProvider": true, + "asca-location": true, + "aiProvider": true, + "aiAgentSessionId": true, + "agent": true, } // CleanURL returns a cleaned url removing double slashes diff --git a/test/integration/scan_test.go b/test/integration/scan_test.go index 95f21a66c..19333447d 100644 --- a/test/integration/scan_test.go +++ b/test/integration/scan_test.go @@ -2823,3 +2823,130 @@ func TestScanCreate_ProjectAlreadyAssociatedWithApplication_BranchPrimaryQueryOv err, _ := executeCommand(t, args...) assert.NilError(t, err, "Scan creation with branch-primary and query override flags should succeed without error") } + +// Create a scan using the Ant-style filter (--file-filter-ext) against the +// existing test/integration/data folder. +// Assert only files matching the include pattern are kept, and the excluded +// "manifests" directory (which also contains .json files) is dropped even +// though its nested files would otherwise match the include pattern. +func TestScanCreateAntFilterIncludeAndExcludeDirectory(t *testing.T) { + _, projectName := getRootProject(t) + + args := []string{ + "scan", "create", + flag(params.ProjectName), projectName, + flag(params.SourcesFlag), "data", + flag(params.ScanTypes), params.IacType, + flag(params.AntFilterFlag), "**/*.json,!manifests/**", + flag(params.BranchFlag), "dummy_branch", + flag(params.DebugFlag), + } + + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + + cmd := createASTIntegrationTestCommand(t) + err := execute(cmd, args...) + assert.NilError(t, err, "Scan creation with --file-filter-ext include/exclude should succeed") + + logText := buf.String() + + assert.Assert( + t, + strings.Contains(logText, "Included: ") && strings.Contains(logText, "results.json"), + "top-level .json files should be included by the ant filter", + ) + assert.Assert( + t, + strings.Contains(logText, "manifests/") && strings.Contains(logText, "Excluded"), + "the manifests/ directory should be excluded by the ant filter, even though it contains .json files", + ) +} + +// Create a scan using the Ant-style filter with a negation rule that +// re-includes a subset of an otherwise excluded directory. +// Assert the re-included direct child is kept while a nested (two-level-deep) +// file under the same directory remains excluded, and a non-json sibling +// file is also excluded. +func TestScanCreateAntFilterExcludeDirectoryWithReinclude(t *testing.T) { + _, projectName := getRootProject(t) + + args := []string{ + "scan", "create", + flag(params.ProjectName), projectName, + flag(params.SourcesFlag), "data", + flag(params.ScanTypes), params.IacType, + flag(params.AntFilterFlag), "!manifests/**,manifests/*.json", + flag(params.BranchFlag), "dummy_branch", + flag(params.DebugFlag), + } + + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + + cmd := createASTIntegrationTestCommand(t) + err := execute(cmd, args...) + assert.NilError(t, err, "Scan creation with --file-filter-ext negation re-include should succeed") + + logText := buf.String() + + assert.Assert( + t, + strings.Contains(logText, "Included: ") && strings.Contains(logText, "manifests/package.json"), + "manifests/package.json should be re-included by the negation rule (direct child)", + ) + assert.Assert( + t, + strings.Contains(logText, "Excluded") && + strings.Contains(logText, "manifests/no_dep_packageJson"), + "manifests/no_dep_packageJson/package.json is two levels deep and should remain excluded", + ) + assert.Assert( + t, + strings.Contains(logText, "Excluded") && strings.Contains(logText, "requirements.txt"), + "manifests/requirements.txt is not json and should remain excluded", + ) +} + +// Case 00280970: whitelist filtering (--file-include) must be case-agnostic. +// Add an inclusion for an uppercase extension pattern and assert real files +// on disk with the lowercase extension (under test/integration/data, which +// has .txt files not covered by the default scannable extension list) are +// still picked up. +func TestScanCreateIncludeFilterIsCaseInsensitive(t *testing.T) { + _, projectName := getRootProject(t) + + args := []string{ + "scan", "create", + flag(params.ProjectName), projectName, + flag(params.SourcesFlag), "data", + flag(params.ScanTypes), params.IacType, + flag(params.IncludeFilterFlag), "*.TXT", // uppercase pattern; actual files on disk are lowercase .txt + flag(params.BranchFlag), "dummy_branch", + flag(params.DebugFlag), + } + + var buf bytes.Buffer + log.SetOutput(&buf) + defer func() { + log.SetOutput(os.Stderr) + }() + + cmd := createASTIntegrationTestCommand(t) + err := execute(cmd, args...) + assert.NilError(t, err, "Scan creation with uppercase --file-include pattern should succeed") + + logText := buf.String() + + assert.Assert( + t, + strings.Contains(logText, "Included: ") && strings.Contains(logText, "broken_link.txt"), + "uppercase --file-include pattern *.TXT should still match lowercase .txt files on disk", + ) +} diff --git a/test/integration/user-count-azure_test.go b/test/integration/user-count-azure_test.go index 5323a4f7c..9528971f2 100644 --- a/test/integration/user-count-azure_test.go +++ b/test/integration/user-count-azure_test.go @@ -172,6 +172,7 @@ func TestAzureCountMultipleWorkspaceFailed(t *testing.T) { } func TestAzureUserCountWrongToken(t *testing.T) { + t.Skip("Skipping - waiting for a secret token") _ = viper.BindEnv(pat) err, _ := executeCommand( t, diff --git a/test/integration/user-count-bitbucket_test.go b/test/integration/user-count-bitbucket_test.go index a0faf282d..c8b089d19 100644 --- a/test/integration/user-count-bitbucket_test.go +++ b/test/integration/user-count-bitbucket_test.go @@ -24,6 +24,7 @@ const ( ) func TestBitbucketUserCountWorkspace(t *testing.T) { + t.Skip("Skipping - waiting for a secret token") _ = viper.BindEnv(pat) buffer := executeCmdNilAssertion( t, @@ -54,6 +55,7 @@ func TestBitbucketUserCountWorkspace(t *testing.T) { } func TestBitbucketUserCountRepos(t *testing.T) { + t.Skip("Skipping - waiting for a secret token") _ = viper.BindEnv(pat) buffer := executeCmdNilAssertion( t, @@ -86,6 +88,7 @@ func TestBitbucketUserCountRepos(t *testing.T) { } func TestBitbucketUserCountReposDebug(t *testing.T) { + t.Skip("Skipping - waiting for a secret token") _ = viper.BindEnv(pat) buffer := executeCmdNilAssertion( t,