From 688e2800d5e34dced99c66f666bb6e5464d77042 Mon Sep 17 00:00:00 2001 From: Anurag Dalke <120229307+cx-anurag-dalke@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:38:56 +0530 Subject: [PATCH 1/7] Split CI workflows per job, harden shared config, and update docs - Split monolithic ci-tests.yml into unit-tests.yml, ci-tests.yml (integration matrix), lint.yml, govulncheck.yml, and docker-image-scan.yml so each job runs and reports independently - Consolidate the nightly parallel integration-test matrix into ci-tests.yml and have nightly-parallel.yml invoke it via workflow_call, removing duplicated matrix logic - Rework unit-tests.yml to run tests via gotestsum (JUnit output), publish a job summary with per-test failure reasons and coverage %, raise the unit test coverage gate to 85%, and upload coverage/JUnit artifacts - Add internal/commands/.scripts/print_test_summary.py to render the job summary from the JUnit report; update up.sh to run tests via gotestsum - Add descriptive job names across all workflow files - Pin actions/checkout to v6 with persist-credentials: false everywhere it's used - Replace secrets: inherit in issue_automation.yml with explicit secret references - Remove deprecated pr-label.yml - Update CLAUDE.md to reflect the new workflow layout, coverage thresholds, and removal of stale/no-longer-existing workflows (pr-add-reviewers.yml, ai-code-review.yml, dependabot-auto-merge.yml, Dependabot config) --- .github/workflows/checkmarx-one-scan.yml | 8 +- .github/workflows/ci-tests.yml | 689 ++++++++++++++---- .github/workflows/docker-image-scan.yml | 38 + .github/workflows/govulncheck.yml | 26 + .github/workflows/issue_automation.yml | 10 +- .github/workflows/lint.yml | 34 + .github/workflows/nightly-parallel.yml | 558 +------------- .github/workflows/pr-label.yml | 34 - .github/workflows/pr-linter.yml | 1 + .github/workflows/release.yml | 6 +- .github/workflows/scan-github-action- | 30 + .github/workflows/trivy-cache.yml | 1 + .github/workflows/unit-tests.yml | 108 +++ CLAUDE.md | 43 +- .../commands/.scripts/print_test_summary.py | 97 +++ internal/commands/.scripts/up.sh | 4 +- 16 files changed, 914 insertions(+), 773 deletions(-) create mode 100644 .github/workflows/docker-image-scan.yml create mode 100644 .github/workflows/govulncheck.yml create mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/pr-label.yml create mode 100644 .github/workflows/scan-github-action- create mode 100644 .github/workflows/unit-tests.yml create mode 100644 internal/commands/.scripts/print_test_summary.py diff --git a/.github/workflows/checkmarx-one-scan.yml b/.github/workflows/checkmarx-one-scan.yml index 1871ff475..238d51295 100644 --- a/.github/workflows/checkmarx-one-scan.yml +++ b/.github/workflows/checkmarx-one-scan.yml @@ -8,13 +8,19 @@ on: schedule: - cron: '00 7 * * *' # Every day at 07:00 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: cx-scan: name: Checkmarx One Scan runs-on: cx-public-ubuntu-x64 steps: - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false - name: Checkmarx One CLI Action uses: checkmarx/ast-github-action@ef93013c95adc60160bc22060875e90800d3ecfc #v.2.3.19 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 29902a688..8fe0ad525 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -1,198 +1,573 @@ -name: Continuous Integration Tests +name: AST-CLI Integration Tests on: pull_request: + workflow_call: permissions: contents: read +env: + GO_COVERAGE_PKGS: "github.com/checkmarx/ast-cli/internal/commands,github.com/checkmarx/ast-cli/internal/services,github.com/checkmarx/ast-cli/internal/wrappers" + jobs: - unit-tests: + # ───────────────────────────────────────────────────────────────────────────── + # Job A: Scan all integration test files and detect any tests not yet assigned + # to a named matrix group, so they fall through to the catch-all run. + # ───────────────────────────────────────────────────────────────────────────── + validate-test-coverage: + name: Validate Test Coverage runs-on: cx-public-ubuntu-x64 + outputs: + uncovered_tests: ${{ steps.find-uncovered.outputs.uncovered_tests }} + has_uncovered: ${{ steps.find-uncovered.outputs.has_uncovered }} steps: - - name: Checkout the repository - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 - - name: Set up Go version - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 with: - go-version-file: go.mod - - run: go version - - name: go test with coverage - run: | - sudo chmod +x ./internal/commands/.scripts/up.sh - ./internal/commands/.scripts/up.sh - - name: Check if total coverage is greater then 77.7 - shell: bash + persist-credentials: false + + - name: Find tests not covered by any named group + id: find-uncovered run: | - CODE_COV=$(go tool cover -func cover.out | grep total | awk '{print substr($3, 1, length($3)-1)}') - EXPECTED_CODE_COV=77.7 - var=$(awk 'BEGIN{ print "'$CODE_COV'"<"'$EXPECTED_CODE_COV'" }') - if [ "$var" -eq 1 ];then - echo "Your code coverage is too low. Coverage precentage is: $CODE_COV" - exit 1 + # Combined regex of every pattern used across the 12 named matrix groups. + # Any test whose name does NOT match this will land in the catch-all group. + # Built via concatenation so every line stays at ≥10-space YAML indentation. + CP="TestCreateScan|TestScanCreate|TestScansE2E|TestFastScan" + CP="${CP}|TestLightQueries|TestRecommendedExclusions|TestScansUpdateProjectGroups" + CP="${CP}|TestBrokenLinkScan|TestScanWithPolicy|TestCreateAsyncScan|TestScanGLReport" + CP="${CP}|TestContainerEngineScansE2E|TestScanListWith|TestScanShowRequired" + CP="${CP}|TestRequiredScanId|TestScaResolver|TestInvalidSource|TestIncrementalScan" + CP="${CP}|TestBranchPrimary|TestCancelScan|TestScanTimeout|TestScanWorkflow|TestScanLog" + CP="${CP}|TestPartialScan|TestFailedScan|TestRunKics|TestRunSca|TestScaRealtime" + CP="${CP}|TestScanType|TestValidateScan|TestScanGenerating|TestResult|TestCodeBashing" + CP="${CP}|TestRiskManagement|TestCreateQueryDescription|TestPR|TestPreReceive" + CP="${CP}|TestPre_Receive|TestProject|TestCreateEmptyProject|TestCreateAlreadyExisting" + CP="${CP}|TestCreateWithInvalid|TestCreateProjectWhen|TestGetProject|TestSastUpdate" + CP="${CP}|TestGetAndUpdate|TestPredicate|TestTriage|TestScaUpdate|TestRunGetBfl" + CP="${CP}|TestContainerScan|TestContainerImage|TestContainersRealtime|TestIacRealtime" + CP="${CP}|TestSecrets_Realtime|TestOssRealtime|TestScanASCA|TestExecuteASCA" + CP="${CP}|TestEngineNameResolution|TestAuth|TestFailProxy|TestLoadConfiguration" + CP="${CP}|TestSetConfig|TestMain|TestRootVersion|TestSetLog|Test_Download|TestGitHub" + CP="${CP}|TestGitLab|TestBitbucket|TestBitBucket|TestAzure|TestHooksPreCommit" + CP="${CP}|TestGetLearnMore|TestImport|TestGetTenant|TestMaskSecrets|TestFailedMask" + CP="${CP}|TestScaRemediation|TestKicsRemediation|TestTelemetry|Test_Handle|TestChat" + COVERED_PATTERNS="${CP}" + + ALL_TESTS=$(grep -rh "^func Test" test/integration/*_test.go \ + | sed 's/func \(Test[^(]*\).*/\1/') + + UNCOVERED="" + while IFS= read -r test; do + [ -z "$test" ] && continue + if ! echo "$test" | grep -qE "$COVERED_PATTERNS"; then + UNCOVERED="${UNCOVERED:+${UNCOVERED}|}${test}" + fi + done <<< "$ALL_TESTS" + + if [ -n "$UNCOVERED" ]; then + echo "Uncovered tests detected: $UNCOVERED" + { + echo "uncovered_tests=$UNCOVERED" + echo "has_uncovered=true" + } >> "$GITHUB_OUTPUT" else - echo "Your code coverage test passed! Coverage precentage is: $CODE_COV" - exit 0 + echo "All tests are covered by named groups." + { + echo "uncovered_tests=" + echo "has_uncovered=false" + } >> "$GITHUB_OUTPUT" fi + + # ───────────────────────────────────────────────────────────────────────────── + # Job B: Run each test group in parallel across 13 matrix entries. + # The 13th entry (uncovered) is a dynamic catch-all driven by Job A. + # ───────────────────────────────────────────────────────────────────────────── integration-tests: + name: Integration Tests (${{ matrix.label }}) + needs: validate-test-coverage runs-on: cx-public-ubuntu-x64 + strategy: + fail-fast: false + matrix: + include: + # 1 ── Scan Creation (heavy; needs pre-run cleanup) + - name: scan-create + label: "Scan Creation" + run_pattern: "TestCreateScan|TestScanCreate|TestScansE2E|TestFastScan|TestLightQueries|TestRecommendedExclusions|TestScansUpdateProjectGroups|TestBrokenLinkScan|TestScanWithPolicy|TestCreateAsyncScan|TestScanGLReport|TestContainerEngineScansE2E" + timeout: "90m" + needs_precommit: "false" + run_cleandata: "true" + + # 2 ── Scan Operations (list, show, logs, kics, sca; needs pre-run cleanup) + - name: scan-ops + label: "Scan Operations" + run_pattern: "TestScanListWith|TestScanShowRequired|TestRequiredScanId|TestScaResolver|TestInvalidSource|TestIncrementalScan|TestBranchPrimary|TestCancelScan|TestScanTimeout|TestScanWorkflow|TestScanLog|TestPartialScan|TestFailedScan|TestRunKics|TestRunSca|TestScaRealtime|TestScanType|TestValidateScan|TestScanGenerating" + timeout: "90m" + needs_precommit: "false" + run_cleandata: "true" + + # 3 ── Results & Reports + - name: results + label: "Results & Reports" + run_pattern: "TestResult|TestCodeBashing|TestRiskManagement|TestCreateQueryDescription" + timeout: "60m" + needs_precommit: "false" + run_cleandata: "false" + + # 4 ── PR Decoration + - name: pr-decoration + label: "PR Decoration" + run_pattern: "TestPR|TestPreReceive|TestPre_Receive" + timeout: "60m" + needs_precommit: "false" + run_cleandata: "false" + + # 5 ── Projects (needs pre-run cleanup) + - name: projects + label: "Projects" + run_pattern: "TestProject|TestCreateEmptyProject|TestCreateAlreadyExisting|TestCreateWithInvalid|TestCreateProjectWhen|TestGetProject" + timeout: "60m" + needs_precommit: "false" + run_cleandata: "true" + + # 6 ── Predicates & BFL + - name: predicates + label: "Predicates & BFL" + run_pattern: "TestSastUpdate|TestGetAndUpdate|TestPredicate|TestTriage|TestScaUpdate|TestRunGetBfl" + timeout: "60m" + needs_precommit: "false" + run_cleandata: "false" + + # 7 ── Container Tests + - name: containers + label: "Container Tests" + run_pattern: "TestContainerScan|TestContainerImage" + timeout: "60m" + needs_precommit: "false" + run_cleandata: "false" + + # 8 ── Realtime Scanning (ASCA, IAC, Secrets, OSS) + - name: realtime + label: "Realtime Scanning" + run_pattern: "TestContainersRealtime|TestIacRealtime|TestSecrets_Realtime|TestOssRealtime|TestScanASCA|TestExecuteASCA|TestEngineNameResolution" + timeout: "60m" + needs_precommit: "false" + run_cleandata: "false" + + # 9 ── Auth & Config + - name: auth-config + label: "Auth & Config" + run_pattern: "TestAuth|TestFailProxy|TestLoadConfiguration|TestSetConfig" + timeout: "30m" + needs_precommit: "false" + run_cleandata: "false" + + # 10 ── Root & Logs + - name: root-logs + label: "Root & Logs" + run_pattern: "TestMain|TestRootVersion|TestSetLog|Test_Download" + timeout: "30m" + needs_precommit: "false" + run_cleandata: "false" + + # 11 ── SCM Rate Limit & User Count + - name: scm-tests + label: "SCM Rate Limit & User Count" + run_pattern: "TestGitHub|TestGitLab|TestBitbucket|TestBitBucket|TestAzure" + timeout: "60m" + needs_precommit: "false" + run_cleandata: "false" + + # 12 ── Miscellaneous (pre-commit required) + - name: misc + label: "Miscellaneous" + run_pattern: "TestHooksPreCommit|TestGetLearnMore|TestImport|TestGetTenant|TestMaskSecrets|TestFailedMask|TestScaRemediation|TestKicsRemediation|TestTelemetry|Test_Handle|TestChat" + timeout: "60m" + needs_precommit: "true" + run_cleandata: "false" + + # 13 ── Catch-All (dynamic; pattern injected at runtime from Job A output) + - name: uncovered + label: "Catch-All (Uncovered)" + run_pattern: "__UNCOVERED__" + timeout: "90m" + needs_precommit: "false" + run_cleandata: "false" + + env: + CX_BASE_URI: ${{ secrets.CX_BASE_URI }} + CX_CLIENT_ID: ${{ secrets.CX_CLIENT_ID }} + CX_CLIENT_SECRET: ${{ secrets.CX_CLIENT_SECRET }} + CX_BASE_AUTH_URI: ${{ secrets.CX_BASE_AUTH_URI }} + CX_AST_USERNAME: ${{ secrets.CX_AST_USERNAME }} + CX_AST_PASSWORD: ${{ secrets.CX_AST_PASSWORD }} + CX_APIKEY: ${{ secrets.CX_APIKEY }} + CX_TENANT: ${{ secrets.CX_TENANT }} + CX_SCAN_SSH_KEY: ${{ secrets.CX_SCAN_SSH_KEY }} + CX_ORIGIN: "cli-tests" + PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + PROXY_HOST: localhost + PROXY_PORT: 3128 + PROXY_USERNAME: ${{ secrets.PROXY_USER }} + PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }} + PR_GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + PR_GITHUB_NAMESPACE: "checkmarx" + PR_GITHUB_REPO_NAME: "ast-cli" + PR_GITHUB_NUMBER: 983 + PR_GITLAB_TOKEN: ${{ secrets.PR_GITLAB_TOKEN }} + PR_GITLAB_NAMESPACE: ${{ secrets.PR_GITLAB_NAMESPACE }} + PR_GITLAB_REPO_NAME: ${{ secrets.PR_GITLAB_REPO_NAME }} + PR_GITLAB_PROJECT_ID: ${{ secrets.PR_GITLAB_PROJECT_ID }} + PR_GITLAB_IID: ${{ secrets.PR_GITLAB_IID }} + AZURE_ORG: ${{ secrets.AZURE_ORG }} + AZURE_PROJECT: ${{ secrets.AZURE_PROJECT }} + AZURE_REPOS: ${{ secrets.AZURE_REPOS }} + AZURE_TOKEN: ${{ secrets.AZURE_TOKEN }} + AZURE_PR_NUMBER: 1 + BITBUCKET_WORKSPACE: ${{ secrets.BITBUCKET_WORKSPACE }} + BITBUCKET_REPOS: ${{ secrets.BITBUCKET_REPOS }} + BITBUCKET_USERNAME: ${{ secrets.BITBUCKET_USERNAME }} + BITBUCKET_PASSWORD: ${{ secrets.BITBUCKET_PASSWORD }} + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + PR_BITBUCKET_TOKEN: ${{ secrets.PR_BITBUCKET_TOKEN }} + PR_BITBUCKET_NAMESPACE: "AstSystemTest" + PR_BITBUCKET_REPO_NAME: "cliIntegrationTest" + PR_BITBUCKET_ID: 1 + steps: - - name: Checkout the repository - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 - - name: Set up Go version + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false + + - name: Set up Go uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 with: - go-version-file: go.mod - - run: go version - - name: Check Python version - run: python --version - - name: Install pre-commit - run: | - pip config set global.index-url https://:${{ secrets.ECHO_LIBRARIES_ACCESS_KEY }}@pypi.echohq.com/simple - pip index versions pre-commit - pip install pre-commit==4.6.0 - - name: Go Build + go-version: '1.25.x' + + - name: Build binary run: go build -o ./bin/cx ./cmd + - name: Install gocovmerge run: go install github.com/wadey/gocovmerge@latest - - name: Go Integration test - shell: bash - env: - CX_BASE_URI: ${{ secrets.CX_BASE_URI }} - CX_CLIENT_ID: ${{ secrets.CX_CLIENT_ID }} - CX_CLIENT_SECRET: ${{ secrets.CX_CLIENT_SECRET }} - CX_BASE_AUTH_URI: ${{ secrets.CX_BASE_AUTH_URI }} - CX_AST_USERNAME: ${{ secrets.CX_AST_USERNAME }} - CX_AST_PASSWORD: ${{ secrets.CX_AST_PASSWORD }} - CX_APIKEY: ${{ secrets.CX_APIKEY }} - CX_TENANT: ${{ secrets.CX_TENANT }} - CX_SCAN_SSH_KEY: ${{ secrets.CX_SCAN_SSH_KEY }} - CX_ORIGIN: "cli-tests" - PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - PROXY_HOST: localhost - PROXY_PORT: 3128 - PROXY_USERNAME: ${{ secrets.PROXY_USER }} - PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }} - PR_GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - PR_GITHUB_NAMESPACE: "checkmarx" - PR_GITHUB_REPO_NAME: "ast-cli" - PR_GITHUB_NUMBER: 983 - PR_GITLAB_TOKEN: ${{ secrets.PR_GITLAB_TOKEN }} - PR_GITLAB_NAMESPACE: ${{ secrets.PR_GITLAB_NAMESPACE }} - PR_GITLAB_REPO_NAME: ${{ secrets.PR_GITLAB_REPO_NAME }} - PR_GITLAB_PROJECT_ID: ${{ secrets.PR_GITLAB_PROJECT_ID }} - PR_GITLAB_IID: ${{ secrets.PR_GITLAB_IID }} - AZURE_ORG: ${{ secrets.AZURE_ORG }} - AZURE_PROJECT: ${{ secrets.AZURE_PROJECT }} - AZURE_REPOS: ${{ secrets.AZURE_REPOS }} - AZURE_TOKEN: ${{ secrets.AZURE_TOKEN }} - AZURE_PR_NUMBER: 1 - BITBUCKET_WORKSPACE: ${{ secrets.BITBUCKET_WORKSPACE }} - BITBUCKET_REPOS: ${{ secrets.BITBUCKET_REPOS }} - BITBUCKET_USERNAME: ${{ secrets.BITBUCKET_USERNAME }} - BITBUCKET_PASSWORD: ${{ secrets.BITBUCKET_PASSWORD }} - GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} - GITHUB_ACTOR: ${{ github.actor }} - PR_BITBUCKET_TOKEN: ${{ secrets.PR_BITBUCKET_TOKEN }} - PR_BITBUCKET_NAMESPACE: "AstSystemTest" - PR_BITBUCKET_REPO_NAME: "cliIntegrationTest" - PR_BITBUCKET_ID: 1 + + - name: Install pre-commit + if: matrix.needs_precommit == 'true' + run: | + pip install --index-url https://pypi.echohq.com/simple --extra-index-url https://pypi.org/simple pre-commit + pre-commit install + + - name: Start Squid proxy run: | - sudo chmod +x ./internal/commands/.scripts/integration_up.sh ./internal/commands/.scripts/integration_down.sh - ./internal/commands/.scripts/integration_up.sh - ./internal/commands/.scripts/integration_down.sh + docker run \ + --name squid \ + -d \ + -p 3128:3128 \ + -v $(pwd)/internal/commands/.scripts/squid/squid.conf:/etc/squid/squid.conf \ + -v $(pwd)/internal/commands/.scripts/squid/passwords:/etc/squid/passwords \ + ubuntu/squid:5.2-22.04_beta + + - name: Download ScaResolver + run: | + wget https://sca-downloads.s3.amazonaws.com/cli/latest/ScaResolver-linux64.tar.gz + tar -xzvf ScaResolver-linux64.tar.gz -C /tmp + rm -rf ScaResolver-linux64.tar.gz + + - name: Pre-test cleanup (${{ matrix.label }}) + if: matrix.run_cleandata == 'true' + run: go test -v github.com/checkmarx/ast-cli/test/cleandata + + - name: Run integration tests (${{ matrix.label }}) + if: matrix.name != 'uncovered' || needs.validate-test-coverage.outputs.has_uncovered == 'true' + 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 }}" + else + RUN_PATTERN="${{ matrix.run_pattern }}" + fi + + COVER_FILE="cover-${{ matrix.name }}.out" + + run_tests() { + local pattern="$1" outfile="$2" logfile="$3" timeout_val="$4" + go test \ + -tags integration \ + -v \ + -timeout "${timeout_val}" \ + -coverpkg "${{ env.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 "::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 }}" + run_tests "$FAILED" "$COVER_R1" "retry1_output.log" "30m" + echo "::endgroup::" + + if [ -f "$COVER_R1" ]; then + gocovmerge "$COVER_FILE" "$COVER_R1" > merged.out + mv merged.out "$COVER_FILE" + rm -f "$COVER_R1" + fi + + FAILED2=$(grep -E "^--- FAIL: " retry1_output.log | awk '{print $3}' | paste -sd '|' - || true) - - name: Coverage report + # ── 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 }}" + run_tests "$FAILED2" "$COVER_R2" "retry2_output.log" "30m" + echo "::endgroup::" + + if [ -f "$COVER_R2" ]; then + gocovmerge "$COVER_FILE" "$COVER_R2" > merged.out + mv merged.out "$COVER_FILE" + rm -f "$COVER_R2" + fi + + 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" + exit 1 + fi + fi + fi + + echo "All ${{ matrix.label }} tests passed." + + - name: Upload coverage artifact + if: always() && (matrix.name != 'uncovered' || needs.validate-test-coverage.outputs.has_uncovered == 'true') uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 with: - name: ${{ runner.os }}-coverage-latest - path: coverage.html + name: coverage-${{ matrix.name }} + path: cover-${{ matrix.name }}.out + retention-days: 7 + if-no-files-found: warn - - name: Check if total coverage is greater then 75 - shell: bash + - name: Upload test logs + if: always() + uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 + with: + name: test-logs-${{ matrix.name }} + path: | + test_output.log + retry1_output.log + retry2_output.log + retention-days: 7 + if-no-files-found: ignore + + - name: Collect failed tests + id: failed_tests + if: always() run: | - CODE_COV=$(go tool cover -func cover.out | grep total | awk '{print substr($3, 1, length($3)-1)}') - EXPECTED_CODE_COV=75 - var=$(awk 'BEGIN{ print "'$CODE_COV'"<"'$EXPECTED_CODE_COV'" }') - if [ "$var" -eq 1 ];then - echo "Your code coverage is too low. Coverage precentage is: $CODE_COV" - exit 1 + FAILED_LIST=$(grep -hE "^--- FAIL: " retry2_output.log retry1_output.log test_output.log 2>/dev/null \ + | awk '{print $3}' | sort -u || true) + + if [ -n "$FAILED_LIST" ]; then + FAIL_COUNT=$(echo "$FAILED_LIST" | wc -l | tr -d ' ') + FAILED_FORMATTED=$(echo "$FAILED_LIST" | tr '\n' '\n' | sed 's/^/• /' | paste -sd '\\n' -) + echo "has_failures=true" >> "$GITHUB_OUTPUT" + echo "fail_count=${FAIL_COUNT}" >> "$GITHUB_OUTPUT" + echo "failed_list=${FAILED_FORMATTED}" >> "$GITHUB_OUTPUT" else - echo "Your code coverage test passed! Coverage precentage is: $CODE_COV" - exit 0 + echo "has_failures=false" >> "$GITHUB_OUTPUT" + echo "fail_count=0" >> "$GITHUB_OUTPUT" + echo "failed_list=" >> "$GITHUB_OUTPUT" fi - lint: - permissions: - contents: read # for actions/checkout to fetch code - pull-requests: read # for golangci/golangci-lint-action to fetch pull requests - name: lint + + - 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() + run: docker stop squid && docker rm squid || true + + # ───────────────────────────────────────────────────────────────────────────── + # Job C: Download all per-group coverage files, merge them, check >= 75%, + # upload the HTML report, and run a final project cleanup. + # ───────────────────────────────────────────────────────────────────────────── + merge-coverage: + name: Merge Coverage Reports + needs: integration-tests runs-on: cx-public-ubuntu-x64 + if: always() + env: + CX_BASE_URI: ${{ secrets.CX_BASE_URI }} + CX_CLIENT_ID: ${{ secrets.CX_CLIENT_ID }} + CX_CLIENT_SECRET: ${{ secrets.CX_CLIENT_SECRET }} + CX_BASE_AUTH_URI: ${{ secrets.CX_BASE_AUTH_URI }} + CX_AST_USERNAME: ${{ secrets.CX_AST_USERNAME }} + CX_AST_PASSWORD: ${{ secrets.CX_AST_PASSWORD }} + CX_APIKEY: ${{ secrets.CX_APIKEY }} + CX_TENANT: ${{ secrets.CX_TENANT }} steps: - - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 - - name: Set up Go version + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false + + - name: Set up Go uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 with: - go-version-file: go.mod - - run: go version - - run: go mod tidy - - name: golangci-lint - uses: step-security/golangci-lint-action@1797facf9ea427614d729a4e9cab0fae1a7852d9 # v9.2.0 + go-version: '1.25.x' + + - name: Install gocovmerge + run: go install github.com/wadey/gocovmerge@latest + + - name: Download all coverage artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #v4 with: - skip-pkg-cache: true - version: v2.11.3 - args: -c .golangci.yml - --timeout 10m - only-new-issues: true + pattern: coverage-* + merge-multiple: true - govulncheck: - runs-on: cx-public-ubuntu-x64 - name: govulncheck - steps: - - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 - - name: Set up Go version - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 + - name: Merge coverage profiles + run: | + COVER_FILES=$(ls cover-*.out 2>/dev/null | tr '\n' ' ') + if [ -z "$COVER_FILES" ]; then + echo "::error::No coverage files found — all groups may have been skipped or failed." + exit 1 + fi + echo "Merging: $COVER_FILES" + # shellcheck disable=SC2086 + gocovmerge $COVER_FILES > cover.out + go tool cover -html=cover.out -o coverage.html + + - name: Check coverage threshold (>= 75%) + run: | + CODE_COV=$(go tool cover -func cover.out | grep total | awk '{print substr($3, 1, length($3)-1)}') + EXPECTED=75 + echo "Total coverage: ${CODE_COV}%" + var=$(awk 'BEGIN{ print "'$CODE_COV'"<"'$EXPECTED'" }') + if [ "$var" -eq 1 ]; then + echo "::error::Coverage too low: ${CODE_COV}% (required >= ${EXPECTED}%)" + exit 1 + else + echo "Coverage OK: ${CODE_COV}%" + fi + + - name: Upload merged HTML report + if: always() + uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 with: - go-version-file: go.mod - - name: Install govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@0782b76014f15f24e22a438f30f308df42899ba1 #1.3.0 - - name: Run govulncheck - run: govulncheck ./... - continue-on-error: true - - checkDockerImage: + name: coverage-report-merged + path: coverage.html + retention-days: 7 + + - name: Post-run project cleanup + run: go test -v github.com/checkmarx/ast-cli/test/cleandata + + # ───────────────────────────────────────────────────────────────────────────── + # 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 - name: scan Docker Image with Trivy + if: failure() steps: - - name: Checkout code - uses: actions/checkout@722adc63f1aa60a57ec37892e133b1d319cae598 #2.0.0 - - - # - name: Set up Docker - # uses: docker/setup-buildx-action@cf09c5c41b299b55c366aff30022701412eb6ab0 #v1.0.0 - - # - name: Log in to Docker Hub - # uses: docker/login-action@49ed152c8eca782a232dede0303416e8f356c37b #v2 - # with: - # username: ${{ secrets.DOCKER_USERNAME }} - # password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build the project - run: go build -o ./cx ./cmd - - name: Build Docker image - run: docker build -t ast-cli:${{ github.sha }} . - - name: Run Trivy scanner without downloading DBs - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 #v0.35.0 - with: - scan-type: 'image' - image-ref: ast-cli:${{ github.sha }} - format: 'table' - exit-code: '1' - ignore-unfixed: true - vuln-type: 'os,library' - output: './trivy-image-results.txt' + - name: Write failure summary env: - TRIVY_SKIP_JAVA_DB_UPDATE: true + INTEGRATION_RESULT: ${{ toJson(needs.integration-tests) }} + MERGE_RESULT: ${{ toJson(needs.merge-coverage) }} + run: | + cat >> "$GITHUB_STEP_SUMMARY" << 'SUMMARY' + ## AST-CLI Integration Tests — FAILED - - name: Inspect action report - if: always() - shell: bash - run: cat ./trivy-image-results.txt + | 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/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml new file mode 100644 index 000000000..431bfefd7 --- /dev/null +++ b/.github/workflows/docker-image-scan.yml @@ -0,0 +1,38 @@ +name: Docker Image Scan + +on: + pull_request: + +permissions: + contents: read + +jobs: + checkDockerImage: + runs-on: cx-public-ubuntu-x64 + name: Scan Docker Image with Trivy + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false + - name: Build the project + run: go build -o ./cx ./cmd + - name: Build Docker image + run: docker build -t ast-cli:${{ github.sha }} . + - name: Run Trivy scanner without downloading DBs + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 #v0.35.0 + with: + scan-type: 'image' + image-ref: ast-cli:${{ github.sha }} + format: 'table' + exit-code: '1' + ignore-unfixed: true + vuln-type: 'os,library' + output: './trivy-image-results.txt' + env: + TRIVY_SKIP_JAVA_DB_UPDATE: true + + - name: Inspect action report + if: always() + shell: bash + run: cat ./trivy-image-results.txt diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 000000000..9ff4e3074 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,26 @@ +name: Govulncheck + +on: + pull_request: + +permissions: + contents: read + +jobs: + govulncheck: + runs-on: cx-public-ubuntu-x64 + name: Vulnerability Scan (govulncheck) + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false + - name: Set up Go version + uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 + with: + go-version-file: go.mod + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@0782b76014f15f24e22a438f30f308df42899ba1 #1.3.0 + - name: Run govulncheck + run: govulncheck ./... + continue-on-error: true diff --git a/.github/workflows/issue_automation.yml b/.github/workflows/issue_automation.yml index 8e95370f2..d8bb874f2 100644 --- a/.github/workflows/issue_automation.yml +++ b/.github/workflows/issue_automation.yml @@ -14,7 +14,11 @@ jobs: body: ${{ github.event.issue.body }} html_url: ${{ github.event.issue.html_url }} repo: ${{ github.event.repository.full_name }} - secrets: inherit + secrets: + AST_JIRA_API_TOKEN: ${{ secrets.AST_JIRA_API_TOKEN }} + AST_JIRA_USER_EMAIL: ${{ secrets.AST_JIRA_USER_EMAIL }} + JIRA_FIELDS_BUG: ${{ secrets.JIRA_FIELDS_BUG }} + JIRA_FIELDS_OTHER: ${{ secrets.JIRA_FIELDS_OTHER }} close_jira: if: github.event.action == 'closed' @@ -23,4 +27,6 @@ jobs: with: issue_number: ${{ github.event.issue.number }} repo: ${{ github.event.repository.full_name }} - secrets: inherit + secrets: + AST_JIRA_API_TOKEN: ${{ secrets.AST_JIRA_API_TOKEN }} + AST_JIRA_USER_EMAIL: ${{ secrets.AST_JIRA_USER_EMAIL }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..135121fbd --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,34 @@ +name: Lint + +on: + pull_request: + +permissions: + contents: read + +jobs: + lint: + permissions: + contents: read # for actions/checkout to fetch code + pull-requests: read # for golangci/golangci-lint-action to fetch pull requests + name: Lint (golangci-lint) + runs-on: cx-public-ubuntu-x64 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false + - name: Set up Go version + uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 + with: + go-version-file: go.mod + - run: go version + - run: go mod tidy + - name: golangci-lint + uses: step-security/golangci-lint-action@1797facf9ea427614d729a4e9cab0fae1a7852d9 # v9.2.0 + with: + skip-pkg-cache: true + version: v2.11.3 + args: -c .golangci.yml + --timeout 10m + only-new-issues: true diff --git a/.github/workflows/nightly-parallel.yml b/.github/workflows/nightly-parallel.yml index a0fc33e04..f5fe32ce7 100644 --- a/.github/workflows/nightly-parallel.yml +++ b/.github/workflows/nightly-parallel.yml @@ -9,565 +9,15 @@ on: description: 'Reason for manual trigger' required: false default: 'Manual run' - concurrency: - group: nightly-parallel + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read -env: - GO_COVERAGE_PKGS: "github.com/checkmarx/ast-cli/internal/commands,github.com/checkmarx/ast-cli/internal/services,github.com/checkmarx/ast-cli/internal/wrappers" - jobs: - # ───────────────────────────────────────────────────────────────────────────── - # Job A: Scan all integration test files and detect any tests not yet assigned - # to a named matrix group, so they fall through to the catch-all run. - # ───────────────────────────────────────────────────────────────────────────── - validate-test-coverage: - runs-on: cx-public-ubuntu-x64 - outputs: - uncovered_tests: ${{ steps.find-uncovered.outputs.uncovered_tests }} - has_uncovered: ${{ steps.find-uncovered.outputs.has_uncovered }} - steps: - - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 - - - name: Find tests not covered by any named group - id: find-uncovered - run: | - # Combined regex of every pattern used across the 12 named matrix groups. - # Any test whose name does NOT match this will land in the catch-all group. - # Built via concatenation so every line stays at ≥10-space YAML indentation. - CP="TestCreateScan|TestScanCreate|TestScansE2E|TestFastScan" - CP="${CP}|TestLightQueries|TestRecommendedExclusions|TestScansUpdateProjectGroups" - CP="${CP}|TestBrokenLinkScan|TestScanWithPolicy|TestCreateAsyncScan|TestScanGLReport" - CP="${CP}|TestContainerEngineScansE2E|TestScanListWith|TestScanShowRequired" - CP="${CP}|TestRequiredScanId|TestScaResolver|TestInvalidSource|TestIncrementalScan" - CP="${CP}|TestBranchPrimary|TestCancelScan|TestScanTimeout|TestScanWorkflow|TestScanLog" - CP="${CP}|TestPartialScan|TestFailedScan|TestRunKics|TestRunSca|TestScaRealtime" - CP="${CP}|TestScanType|TestValidateScan|TestScanGenerating|TestResult|TestCodeBashing" - CP="${CP}|TestRiskManagement|TestCreateQueryDescription|TestPR|TestPreReceive" - CP="${CP}|TestPre_Receive|TestProject|TestCreateEmptyProject|TestCreateAlreadyExisting" - CP="${CP}|TestCreateWithInvalid|TestCreateProjectWhen|TestGetProject|TestSastUpdate" - CP="${CP}|TestGetAndUpdate|TestPredicate|TestTriage|TestScaUpdate|TestRunGetBfl" - CP="${CP}|TestContainerScan|TestContainerImage|TestContainersRealtime|TestIacRealtime" - CP="${CP}|TestSecrets_Realtime|TestOssRealtime|TestScanASCA|TestExecuteASCA" - CP="${CP}|TestEngineNameResolution|TestAuth|TestFailProxy|TestLoadConfiguration" - CP="${CP}|TestSetConfig|TestMain|TestRootVersion|TestSetLog|Test_Download|TestGitHub" - CP="${CP}|TestGitLab|TestBitbucket|TestBitBucket|TestAzure|TestHooksPreCommit" - CP="${CP}|TestGetLearnMore|TestImport|TestGetTenant|TestMaskSecrets|TestFailedMask" - CP="${CP}|TestScaRemediation|TestKicsRemediation|TestTelemetry|Test_Handle|TestChat" - COVERED_PATTERNS="${CP}" - - ALL_TESTS=$(grep -rh "^func Test" test/integration/*_test.go \ - | sed 's/func \(Test[^(]*\).*/\1/') - - UNCOVERED="" - while IFS= read -r test; do - [ -z "$test" ] && continue - if ! echo "$test" | grep -qE "$COVERED_PATTERNS"; then - UNCOVERED="${UNCOVERED:+${UNCOVERED}|}${test}" - fi - done <<< "$ALL_TESTS" - - if [ -n "$UNCOVERED" ]; then - echo "Uncovered tests detected: $UNCOVERED" - { - echo "uncovered_tests=$UNCOVERED" - echo "has_uncovered=true" - } >> "$GITHUB_OUTPUT" - else - echo "All tests are covered by named groups." - { - echo "uncovered_tests=" - echo "has_uncovered=false" - } >> "$GITHUB_OUTPUT" - fi - - # ───────────────────────────────────────────────────────────────────────────── - # Job B: Run each test group in parallel across 13 matrix entries. - # The 13th entry (uncovered) is a dynamic catch-all driven by Job A. - # ───────────────────────────────────────────────────────────────────────────── integration-tests: - needs: validate-test-coverage - runs-on: cx-public-ubuntu-x64 - strategy: - fail-fast: false - matrix: - include: - # 1 ── Scan Creation (heavy; needs pre-run cleanup) - - name: scan-create - label: "Scan Creation" - run_pattern: "TestCreateScan|TestScanCreate|TestScansE2E|TestFastScan|TestLightQueries|TestRecommendedExclusions|TestScansUpdateProjectGroups|TestBrokenLinkScan|TestScanWithPolicy|TestCreateAsyncScan|TestScanGLReport|TestContainerEngineScansE2E" - timeout: "90m" - needs_precommit: "false" - run_cleandata: "true" - - # 2 ── Scan Operations (list, show, logs, kics, sca; needs pre-run cleanup) - - name: scan-ops - label: "Scan Operations" - run_pattern: "TestScanListWith|TestScanShowRequired|TestRequiredScanId|TestScaResolver|TestInvalidSource|TestIncrementalScan|TestBranchPrimary|TestCancelScan|TestScanTimeout|TestScanWorkflow|TestScanLog|TestPartialScan|TestFailedScan|TestRunKics|TestRunSca|TestScaRealtime|TestScanType|TestValidateScan|TestScanGenerating" - timeout: "90m" - needs_precommit: "false" - run_cleandata: "true" - - # 3 ── Results & Reports - - name: results - label: "Results & Reports" - run_pattern: "TestResult|TestCodeBashing|TestRiskManagement|TestCreateQueryDescription" - timeout: "60m" - needs_precommit: "false" - run_cleandata: "false" - - # 4 ── PR Decoration - - name: pr-decoration - label: "PR Decoration" - run_pattern: "TestPR|TestPreReceive|TestPre_Receive" - timeout: "60m" - needs_precommit: "false" - run_cleandata: "false" - - # 5 ── Projects (needs pre-run cleanup) - - name: projects - label: "Projects" - run_pattern: "TestProject|TestCreateEmptyProject|TestCreateAlreadyExisting|TestCreateWithInvalid|TestCreateProjectWhen|TestGetProject" - timeout: "60m" - needs_precommit: "false" - run_cleandata: "true" - - # 6 ── Predicates & BFL - - name: predicates - label: "Predicates & BFL" - run_pattern: "TestSastUpdate|TestGetAndUpdate|TestPredicate|TestTriage|TestScaUpdate|TestRunGetBfl" - timeout: "60m" - needs_precommit: "false" - run_cleandata: "false" - - # 7 ── Container Tests - - name: containers - label: "Container Tests" - run_pattern: "TestContainerScan|TestContainerImage" - timeout: "60m" - needs_precommit: "false" - run_cleandata: "false" - - # 8 ── Realtime Scanning (ASCA, IAC, Secrets, OSS) - - name: realtime - label: "Realtime Scanning" - run_pattern: "TestContainersRealtime|TestIacRealtime|TestSecrets_Realtime|TestOssRealtime|TestScanASCA|TestExecuteASCA|TestEngineNameResolution" - timeout: "60m" - needs_precommit: "false" - run_cleandata: "false" - - # 9 ── Auth & Config - - name: auth-config - label: "Auth & Config" - run_pattern: "TestAuth|TestFailProxy|TestLoadConfiguration|TestSetConfig" - timeout: "30m" - needs_precommit: "false" - run_cleandata: "false" - - # 10 ── Root & Logs - - name: root-logs - label: "Root & Logs" - run_pattern: "TestMain|TestRootVersion|TestSetLog|Test_Download" - timeout: "30m" - needs_precommit: "false" - run_cleandata: "false" - - # 11 ── SCM Rate Limit & User Count - - name: scm-tests - label: "SCM Rate Limit & User Count" - run_pattern: "TestGitHub|TestGitLab|TestBitbucket|TestBitBucket|TestAzure" - timeout: "60m" - needs_precommit: "false" - run_cleandata: "false" - - # 12 ── Miscellaneous (pre-commit required) - - name: misc - label: "Miscellaneous" - run_pattern: "TestHooksPreCommit|TestGetLearnMore|TestImport|TestGetTenant|TestMaskSecrets|TestFailedMask|TestScaRemediation|TestKicsRemediation|TestTelemetry|Test_Handle|TestChat" - timeout: "60m" - needs_precommit: "true" - run_cleandata: "false" - - # 13 ── Catch-All (dynamic; pattern injected at runtime from Job A output) - - name: uncovered - label: "Catch-All (Uncovered)" - run_pattern: "__UNCOVERED__" - timeout: "90m" - needs_precommit: "false" - run_cleandata: "false" - - env: - CX_BASE_URI: ${{ secrets.CX_BASE_URI }} - CX_CLIENT_ID: ${{ secrets.CX_CLIENT_ID }} - CX_CLIENT_SECRET: ${{ secrets.CX_CLIENT_SECRET }} - CX_BASE_AUTH_URI: ${{ secrets.CX_BASE_AUTH_URI }} - CX_AST_USERNAME: ${{ secrets.CX_AST_USERNAME }} - CX_AST_PASSWORD: ${{ secrets.CX_AST_PASSWORD }} - CX_APIKEY: ${{ secrets.CX_APIKEY }} - CX_TENANT: ${{ secrets.CX_TENANT }} - CX_SCAN_SSH_KEY: ${{ secrets.CX_SCAN_SSH_KEY }} - CX_ORIGIN: "cli-tests" - PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - PROXY_HOST: localhost - PROXY_PORT: 3128 - PROXY_USERNAME: ${{ secrets.PROXY_USER }} - PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }} - PR_GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - PR_GITHUB_NAMESPACE: "checkmarx" - PR_GITHUB_REPO_NAME: "ast-cli" - PR_GITHUB_NUMBER: 983 - PR_GITLAB_TOKEN: ${{ secrets.PR_GITLAB_TOKEN }} - PR_GITLAB_NAMESPACE: ${{ secrets.PR_GITLAB_NAMESPACE }} - PR_GITLAB_REPO_NAME: ${{ secrets.PR_GITLAB_REPO_NAME }} - PR_GITLAB_PROJECT_ID: ${{ secrets.PR_GITLAB_PROJECT_ID }} - PR_GITLAB_IID: ${{ secrets.PR_GITLAB_IID }} - AZURE_ORG: ${{ secrets.AZURE_ORG }} - AZURE_PROJECT: ${{ secrets.AZURE_PROJECT }} - AZURE_REPOS: ${{ secrets.AZURE_REPOS }} - AZURE_TOKEN: ${{ secrets.AZURE_TOKEN }} - AZURE_PR_NUMBER: 1 - BITBUCKET_WORKSPACE: ${{ secrets.BITBUCKET_WORKSPACE }} - BITBUCKET_REPOS: ${{ secrets.BITBUCKET_REPOS }} - BITBUCKET_USERNAME: ${{ secrets.BITBUCKET_USERNAME }} - BITBUCKET_PASSWORD: ${{ secrets.BITBUCKET_PASSWORD }} - GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} - GITHUB_ACTOR: ${{ github.actor }} - PR_BITBUCKET_TOKEN: ${{ secrets.PR_BITBUCKET_TOKEN }} - PR_BITBUCKET_NAMESPACE: "AstSystemTest" - PR_BITBUCKET_REPO_NAME: "cliIntegrationTest" - PR_BITBUCKET_ID: 1 - - steps: - - name: Checkout repository - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 - - - name: Set up Go - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 - with: - go-version: '1.25.x' - - - name: Build binary - run: go build -o ./bin/cx ./cmd - - - name: Install gocovmerge - run: go install github.com/wadey/gocovmerge@latest - - - name: Install pre-commit - if: matrix.needs_precommit == 'true' - run: | - pip install --index-url https://pypi.echohq.com/simple --extra-index-url https://pypi.org/simple pre-commit - pre-commit install - - - name: Start Squid proxy - run: | - docker run \ - --name squid \ - -d \ - -p 3128:3128 \ - -v $(pwd)/internal/commands/.scripts/squid/squid.conf:/etc/squid/squid.conf \ - -v $(pwd)/internal/commands/.scripts/squid/passwords:/etc/squid/passwords \ - ubuntu/squid:5.2-22.04_beta - - - name: Download ScaResolver - run: | - wget https://sca-downloads.s3.amazonaws.com/cli/latest/ScaResolver-linux64.tar.gz - tar -xzvf ScaResolver-linux64.tar.gz -C /tmp - rm -rf ScaResolver-linux64.tar.gz - - - name: Pre-test cleanup (${{ matrix.label }}) - if: matrix.run_cleandata == 'true' - run: go test -v github.com/checkmarx/ast-cli/test/cleandata - - - name: Run integration tests (${{ matrix.label }}) - if: matrix.name != 'uncovered' || needs.validate-test-coverage.outputs.has_uncovered == 'true' - 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 }}" - else - RUN_PATTERN="${{ matrix.run_pattern }}" - fi - - COVER_FILE="cover-${{ matrix.name }}.out" - - run_tests() { - local pattern="$1" outfile="$2" logfile="$3" timeout_val="$4" - go test \ - -tags integration \ - -v \ - -timeout "${timeout_val}" \ - -coverpkg "${{ env.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 "::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 }}" - run_tests "$FAILED" "$COVER_R1" "retry1_output.log" "30m" - echo "::endgroup::" - - if [ -f "$COVER_R1" ]; then - gocovmerge "$COVER_FILE" "$COVER_R1" > merged.out - mv merged.out "$COVER_FILE" - rm -f "$COVER_R1" - fi - - FAILED2=$(grep -E "^--- FAIL: " retry1_output.log | awk '{print $3}' | paste -sd '|' - || true) - - # ── 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 }}" - run_tests "$FAILED2" "$COVER_R2" "retry2_output.log" "30m" - echo "::endgroup::" - - if [ -f "$COVER_R2" ]; then - gocovmerge "$COVER_FILE" "$COVER_R2" > merged.out - mv merged.out "$COVER_FILE" - rm -f "$COVER_R2" - fi - - 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" - exit 1 - fi - fi - fi - - echo "All ${{ matrix.label }} tests passed." - - - name: Upload coverage artifact - if: always() && (matrix.name != 'uncovered' || needs.validate-test-coverage.outputs.has_uncovered == 'true') - uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 - with: - name: coverage-${{ matrix.name }} - path: cover-${{ matrix.name }}.out - retention-days: 7 - if-no-files-found: warn - - - name: Upload test logs - if: always() - uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 - with: - name: test-logs-${{ matrix.name }} - path: | - test_output.log - retry1_output.log - retry2_output.log - retention-days: 7 - if-no-files-found: ignore - - - name: Collect failed tests - id: failed_tests - if: always() - run: | - FAILED_LIST=$(grep -hE "^--- FAIL: " retry2_output.log retry1_output.log test_output.log 2>/dev/null \ - | awk '{print $3}' | sort -u || true) - - if [ -n "$FAILED_LIST" ]; then - FAIL_COUNT=$(echo "$FAILED_LIST" | wc -l | tr -d ' ') - FAILED_FORMATTED=$(echo "$FAILED_LIST" | tr '\n' '\n' | sed 's/^/• /' | paste -sd '\\n' -) - echo "has_failures=true" >> "$GITHUB_OUTPUT" - echo "fail_count=${FAIL_COUNT}" >> "$GITHUB_OUTPUT" - echo "failed_list=${FAILED_FORMATTED}" >> "$GITHUB_OUTPUT" - else - echo "has_failures=false" >> "$GITHUB_OUTPUT" - echo "fail_count=0" >> "$GITHUB_OUTPUT" - 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: Stop Squid proxy - if: always() - run: docker stop squid && docker rm squid || true - - # ───────────────────────────────────────────────────────────────────────────── - # Job C: Download all per-group coverage files, merge them, check >= 75%, - # upload the HTML report, and run a final project cleanup. - # ───────────────────────────────────────────────────────────────────────────── - merge-coverage: - needs: integration-tests - runs-on: cx-public-ubuntu-x64 - if: always() - env: - CX_BASE_URI: ${{ secrets.CX_BASE_URI }} - CX_CLIENT_ID: ${{ secrets.CX_CLIENT_ID }} - CX_CLIENT_SECRET: ${{ secrets.CX_CLIENT_SECRET }} - CX_BASE_AUTH_URI: ${{ secrets.CX_BASE_AUTH_URI }} - CX_AST_USERNAME: ${{ secrets.CX_AST_USERNAME }} - CX_AST_PASSWORD: ${{ secrets.CX_AST_PASSWORD }} - CX_APIKEY: ${{ secrets.CX_APIKEY }} - CX_TENANT: ${{ secrets.CX_TENANT }} - steps: - - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 - - - name: Set up Go - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 - with: - go-version: '1.25.x' - - - name: Install gocovmerge - run: go install github.com/wadey/gocovmerge@latest - - - name: Download all coverage artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 #v4 - with: - pattern: coverage-* - merge-multiple: true - - - name: Merge coverage profiles - run: | - COVER_FILES=$(ls cover-*.out 2>/dev/null | tr '\n' ' ') - if [ -z "$COVER_FILES" ]; then - echo "::error::No coverage files found — all groups may have been skipped or failed." - exit 1 - fi - echo "Merging: $COVER_FILES" - # shellcheck disable=SC2086 - gocovmerge $COVER_FILES > cover.out - go tool cover -html=cover.out -o coverage.html - - - name: Check coverage threshold (>= 75%) - run: | - CODE_COV=$(go tool cover -func cover.out | grep total | awk '{print substr($3, 1, length($3)-1)}') - EXPECTED=75 - echo "Total coverage: ${CODE_COV}%" - var=$(awk 'BEGIN{ print "'$CODE_COV'"<"'$EXPECTED'" }') - if [ "$var" -eq 1 ]; then - echo "::error::Coverage too low: ${CODE_COV}% (required >= ${EXPECTED}%)" - exit 1 - else - echo "Coverage OK: ${CODE_COV}%" - fi - - - name: Upload merged HTML report - if: always() - uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 - with: - name: coverage-report-merged - path: coverage.html - retention-days: 7 - - - name: Post-run project cleanup - run: go test -v github.com/checkmarx/ast-cli/test/cleandata - - # ───────────────────────────────────────────────────────────────────────────── - # 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: - 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' - ## Nightly Parallel Integration Tests — FAILED - - | Field | Value | - |-------|-------| - | Run | ${{ github.run_id }} | - | Triggered by | ${{ github.event_name }} | - | Branch | ${{ github.ref_name }} | - | Commit | ${{ github.sha }} | - | Schedule | 12:00 AM IST (18:30 UTC) | - 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 - + name: Run Full Integration Test Suite + uses: ./.github/workflows/ci-tests.yml + secrets: inherit diff --git a/.github/workflows/pr-label.yml b/.github/workflows/pr-label.yml deleted file mode 100644 index 56b23b495..000000000 --- a/.github/workflows/pr-label.yml +++ /dev/null @@ -1,34 +0,0 @@ -# This action is no longer maintained. Repo owner - find a different solution. -name: PR Labeler -on: - pull_request: - types: [opened] - -permissions: - contents: read - -jobs: - do-nothing: - runs-on: cx-public-ubuntu-x64 - steps: - - name: Hello World - run: echo "The pr-labeler action is no longer maintained. Repo owner - find a different solution." && exit 1 -# name: PR Labeler -# on: -# pull_request: -# types: [opened] - -# permissions: -# contents: read - -# jobs: -# pr-labeler: -# permissions: -# pull-requests: write # for TimonVS/pr-labeler-action to add labels in PR -# runs-on: cx-public-ubuntu-x64 -# steps: -# - uses: TimonVS/pr-labeler-action@cf8bb780d49439d85e4b34e4ac7ce7bd77aedf97 #v4 -# with: -# configuration-path: .github/pr-labeler.yml # optional, .github/pr-labeler.yml is the default value -# env: -# # GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.github/workflows/pr-linter.yml b/.github/workflows/pr-linter.yml index 3f4a82e32..003974db9 100644 --- a/.github/workflows/pr-linter.yml +++ b/.github/workflows/pr-linter.yml @@ -9,6 +9,7 @@ permissions: jobs: lint: + name: Validate PR Title and Branch Name runs-on: cx-public-ubuntu-x64 steps: - name: Check PR Title and Branch diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a886c410a..083c7646d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: AST Cli Release +name: AST CLI Release on: workflow_call: @@ -67,6 +67,7 @@ permissions: jobs: build: + name: Build, Sign & Publish Release runs-on: macos-15-intel env: AC_PASSWORD: ${{ secrets.AC_PASSWORD }} @@ -83,9 +84,10 @@ jobs: use-policy-store: true api-key: ${{ secrets.STEP_SECURITY_API_KEY }} - name: Checkout - uses: actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2 #v4.0.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 with: fetch-depth: 0 + persist-credentials: false - name: Install Go uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 with: diff --git a/.github/workflows/scan-github-action- b/.github/workflows/scan-github-action- new file mode 100644 index 000000000..e41d5bbf7 --- /dev/null +++ b/.github/workflows/scan-github-action- @@ -0,0 +1,30 @@ +name: Scan for GitHub Actions issues + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.ref }} + +permissions: {} + +jobs: + zizmor: + name: Scan repository contents + runs-on: cx-private-ubuntu-x64 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Run Zizmor linter + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + with: + advanced-security: false + annotations: false + persona: pedantic + fail-on-no-inputs: false + online-audits: false \ No newline at end of file diff --git a/.github/workflows/trivy-cache.yml b/.github/workflows/trivy-cache.yml index fe87863b6..80b6e80fe 100644 --- a/.github/workflows/trivy-cache.yml +++ b/.github/workflows/trivy-cache.yml @@ -9,6 +9,7 @@ on: jobs: update-trivy-db: + name: Update Trivy Vulnerability DB Cache runs-on: cx-public-ubuntu-x64 steps: - name: Setup oras diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 000000000..3b26ab991 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,108 @@ +name: AST-CLI Unit Tests + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: unit-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + MIN_COVERAGE: "85" + +jobs: + unit-tests: + name: Unit Tests + runs-on: cx-public-ubuntu-x64 + steps: + - name: Checkout the repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false + + - name: Set up Go version + uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 #v4 + with: + go-version-file: go.mod + + - run: go version + + - name: Install gotestsum + run: go install gotest.tools/gotestsum@v1.12.0 + + - name: Run unit tests with coverage + id: run_tests + shell: bash + run: | + sudo chmod +x ./internal/commands/.scripts/up.sh + ./internal/commands/.scripts/up.sh + echo "test_exit_code=$?" >> "$GITHUB_OUTPUT" + + - name: Compute coverage percentage + id: coverage + if: always() + shell: bash + run: | + if [ -f cover.out ]; then + CODE_COV=$(go tool cover -func cover.out | grep total | awk '{print substr($3, 1, length($3)-1)}') + go tool cover -html=cover.out -o coverage.html + else + CODE_COV="0" + fi + echo "coverage=$CODE_COV" >> "$GITHUB_OUTPUT" + + - name: Publish job summary + if: always() + shell: bash + 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 }}" \ + >> "$GITHUB_STEP_SUMMARY" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 + with: + name: unit-tests-coverage + path: coverage.html + retention-days: 7 + if-no-files-found: warn + + - name: Upload JUnit report + if: always() + uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 #v4 + with: + name: unit-tests-junit + path: junit.xml + retention-days: 7 + if-no-files-found: warn + + - name: Enforce test and coverage gate + if: always() + shell: bash + run: | + FAILED=0 + + if [ "${{ steps.run_tests.outputs.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 }}" + 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 + else + echo "Code coverage passed! Coverage percentage is: ${CODE_COV}%" + fi + + if [ "$FAILED" -eq 1 ]; then + exit 1 + fi diff --git a/CLAUDE.md b/CLAUDE.md index 54116995e..64f04defa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ internal/ asca/ AI-powered code analysis subcommand dast/ DAST environment management util/ Shared utilities, printer, user count - .scripts/ CI test runner scripts (up.sh, integration_up.sh) + .scripts/ CI test runner scripts (up.sh, integration_up.sh, print_test_summary.py) services/ Business logic (projects, applications, groups, export) wrappers/ HTTP API abstractions (30+ wrapper interfaces) mock/ Mock implementations for unit testing @@ -207,7 +207,7 @@ Integration tests require these environment variables (set via `.env` file or ID | `PR_GITHUB_TOKEN`, `PR_GITLAB_TOKEN`, `AZURE_TOKEN` | PR decoration tests | | `PROXY_HOST`, `PROXY_PORT`, `PROXY_USERNAME`, `PROXY_PASSWORD` | Proxy tests | -No `.env.example` file exists — refer to `.github/workflows/ci-tests.yml` (lines 55-93) for the full list of required secrets. +No `.env.example` file exists — refer to the `integration-tests` job's `env:` block in `.github/workflows/ci-tests.yml` (around lines 199-237) for the full list of required secrets. ## Coding Standards @@ -231,15 +231,15 @@ No `.env.example` file exists — refer to `.github/workflows/ci-tests.yml` (lin - Exit codes are engine-specific (SAST=2, SCA=3, KICS=4, API Security=5, multiple=1) — do not change these as CI/CD pipelines and downstream plugins depend on them - The `depguard` linter will reject any import not on the allowlist — add new external packages to `.golangci.yml` before using them - **Do not break CLI flag names, output formats, or exit codes without coordinating with the plugin ecosystem** — Java wrapper, JavaScript wrapper, and all IDE/CI plugins parse CLI output and depend on stable interfaces -- Dependabot PRs are auto-merged after CI passes — do not add manual approval gates to dependency update workflows +- Dependabot is not currently configured for this repo (no `.github/dependabot.yml`, no auto-merge workflow) — dependency updates are manual ## Testing Strategy ### Coverage Thresholds -- **Unit tests:** **77.7%** minimum (CI-enforced) -- **Integration tests:** **75%** minimum (CI-enforced) -- **CI pipeline order:** Unit tests → Integration tests → Lint (`golangci-lint`) → Vulnerability scan (`govulncheck`) → Docker image scan (Trivy) +- **Unit tests:** **85%** minimum (CI-enforced in `unit-tests.yml`) +- **Integration tests:** **75%** minimum (CI-enforced in `ci-tests.yml`) +- **CI checks on PR:** Unit tests, integration tests, lint, vulnerability scan, and Docker image scan each run as independent workflows (see CI/CD Workflows table below) triggered in parallel on `pull_request` — there is no sequential ordering/dependency between them ### Test File Creation Rules @@ -299,6 +299,8 @@ func TestScanCreate(t *testing.T) { **Flaky test handling:** Integration test CI script (`internal/commands/.scripts/integration_up.sh`) includes automatic retry logic for flaky tests and uses `gocovmerge` to merge coverage profiles from retried runs. +**Unit test CI reporting:** `internal/commands/.scripts/up.sh` runs unit tests via `gotestsum` (writes `junit.xml`), and `unit-tests.yml` renders a GitHub Actions job summary — pass/fail counts, coverage %, and a collapsible per-test failure reason — via `internal/commands/.scripts/print_test_summary.py`. + ## External Integrations @@ -340,17 +342,19 @@ func TestScanCreate(t *testing.T) { | Workflow | Purpose | |---|---| -| `ci-tests.yml` | Unit tests, integration tests, lint, govulncheck, Trivy | +| `unit-tests.yml` | Unit tests via `gotestsum`; job summary with coverage % and per-test failure reasons; 85% coverage gate | +| `ci-tests.yml` | Full parallel integration-test matrix (13 groups); triggers on `pull_request` and is also reused via `workflow_call` by `nightly-parallel.yml`; 75% coverage gate | +| `nightly-parallel.yml` | Calls `ci-tests.yml`'s integration matrix on a nightly schedule (plus manual `workflow_dispatch`) | +| `lint.yml` | `golangci-lint` | +| `govulncheck.yml` | Go vulnerability scan (`govulncheck`) | +| `docker-image-scan.yml` | Docker image build + Trivy scan | +| `checkmarx-one-scan.yml` | Checkmarx One SAST/SCA/IaC scan on PRs, pushes to `main`, and a daily schedule | +| `trivy-cache.yml` | Refreshes the cached Trivy vulnerability DB daily | +| `pr-linter.yml` | Enforces PR title (JIRA ID) and branch naming conventions | +| `issue_automation.yml` | Creates/closes JIRA tickets from GitHub issue open/close events | | `release.yml` | Full build, sign, publish, notify pipeline | -| `issue_automation.yml` | Auto-label and assign issues | -| `pr-add-reviewers.yml` | Auto-assign reviewers to PRs | -| `pr-label.yml` | Auto-label PRs | -| `pr-linter.yml` | Enforce PR guidelines | -| `checkmarx-one-scan.yml` | Security scan on PRs | -| `trivy-cache.yml` | Keep Trivy vulnerability definitions current | -| `ai-code-review.yml` | AI-powered code review on PRs | -| `dependabot-auto-merge.yml` | Auto-merge Dependabot PRs after CI passes | -| `nightly-parallel.yml` | Nightly parallel test runs | + +Note: `pr-label.yml`, `pr-add-reviewers.yml`, `ai-code-review.yml`, and `dependabot-auto-merge.yml` (along with Dependabot config) have all been removed from this repo; do not assume they exist. ### JIRA Integration @@ -490,11 +494,6 @@ cx_proxy_ntlm_domain: dm.cx ### PR Requirements — Team Members - Must have a valid JIRA ID in the PR title (e.g., `AST-3432`) — enables JIRA automation and traceability -- CI workflow must pass: **unit-tests**, **integration-tests**, **lint**, **cx-scan** +- Required checks (job names, each now defined in its own workflow file — see CI/CD Workflows table): **unit-tests** (`unit-tests.yml`), **integration-tests** (`ci-tests.yml`), **lint** (`lint.yml`), **cx-scan** (`checkmarx-one-scan.yml`) - Review by at least one team member - Use the PR template (`.github/PULL_REQUEST_TEMPLATE.md`) — includes sections for changes, related issues, testing, and checklist - -### PR Requirements — Dependabot - -- CI workflow must pass: **unit-tests**, **integration-tests**, **lint**, **cx-scan** -- Auto-merged after CI passes (no manual review required) diff --git a/internal/commands/.scripts/print_test_summary.py b/internal/commands/.scripts/print_test_summary.py new file mode 100644 index 000000000..cf51dd1d4 --- /dev/null +++ b/internal/commands/.scripts/print_test_summary.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Render a GitHub Actions job summary from a Go test JUnit report and coverage percentage. + +Usage: print_test_summary.py +""" +import sys +import xml.etree.ElementTree as ET + + +def escape(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def parse_failures(junit_path): + failures = [] + total_tests = 0 + try: + root = ET.parse(junit_path).getroot() + except (FileNotFoundError, ET.ParseError): + return failures, total_tests + + suites = root.findall("testsuite") if root.tag == "testsuites" else [root] + for suite in suites: + package = suite.get("name", "unknown") + for case in suite.findall("testcase"): + total_tests += 1 + failure = case.find("failure") + if failure is not None: + reason = (failure.text or failure.get("message") or "").strip() + failures.append({ + "package": package, + "test": case.get("name", "unknown"), + "reason": reason, + }) + return failures, total_tests + + +def to_float(value, default=0.0): + try: + return float(value) + except (TypeError, ValueError): + return default + + +def main() -> int: + if len(sys.argv) != 5: + print("usage: print_test_summary.py ", + file=sys.stderr) + return 2 + + sys.stdout.reconfigure(encoding="utf-8") + + junit_path, coverage_arg, min_coverage_arg, exit_code_arg = sys.argv[1:5] + coverage = to_float(coverage_arg) + min_coverage = to_float(min_coverage_arg) + exit_code = to_float(exit_code_arg, default=1.0) + + failures, total_tests = parse_failures(junit_path) + coverage_ok = coverage >= min_coverage + tests_ok = exit_code == 0 + overall_ok = coverage_ok and tests_ok + + lines = [ + "## Unit Test Results", + "", + f"{'✅' if overall_ok else '❌'} **{len(failures)} failed / {total_tests} total** " + f"— coverage {'✅' if coverage_ok else '❌'} **{coverage:.1f}%** (min {min_coverage:.1f}%)", + "", + ] + + if failures: + lines.append(f"### ❌ Failed Tests ({len(failures)})") + lines.append("") + for f in failures: + lines.append(f"
{escape(f['test'])} — {escape(f['package'])}") + lines.append("") + lines.append("```text") + lines.append(f["reason"] or "(no failure message captured)") + lines.append("```") + lines.append("
") + lines.append("") + elif not tests_ok: + lines.append(f"### ⚠️ Test run exited with code {int(exit_code)}") + lines.append("") + lines.append("No individual failing tests were captured in the JUnit report — this usually means a " + "build/compile error occurred before tests ran. Check the raw job logs for details.") + lines.append("") + else: + lines.append("All tests passed. 🎉") + lines.append("") + + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/internal/commands/.scripts/up.sh b/internal/commands/.scripts/up.sh index e11ebf8c8..fbfcebc02 100755 --- a/internal/commands/.scripts/up.sh +++ b/internal/commands/.scripts/up.sh @@ -4,4 +4,6 @@ wget https://sca-downloads.s3.amazonaws.com/cli/latest/ScaResolver-linux64.tar.g tar -xzvf ScaResolver-linux64.tar.gz -C /tmp rm -rf ScaResolver-linux64.tar.gz # ignore mock and wrappers packages, as they checked by integration tests -go test $(go list ./... | grep -v "mock" | grep -v "wrappers" | grep -v "bitbucketserver" | grep -v "logger") -timeout 25m -coverprofile cover.out \ No newline at end of file +gotestsum --junitfile junit.xml --format testname -- \ + $(go list ./... | grep -v "mock" | grep -v "wrappers" | grep -v "bitbucketserver" | grep -v "logger") \ + -timeout 25m -coverprofile cover.out \ No newline at end of file From 73616eee59d6835d90b3d037bec29f5b1d5847e6 Mon Sep 17 00:00:00 2001 From: Anurag Dalke <120229307+cx-anurag-dalke@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:23:05 +0530 Subject: [PATCH 2/7] updated go lang & gotestsum version --- .github/workflows/unit-tests.yml | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3b26ab991..841ae0d4c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -31,7 +31,7 @@ jobs: - run: go version - name: Install gotestsum - run: go install gotest.tools/gotestsum@v1.12.0 + run: go install gotest.tools/gotestsum@v1.13.0 - name: Run unit tests with coverage id: run_tests diff --git a/go.mod b/go.mod index 0d3a97fad..8d9c97d8c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/checkmarx/ast-cli -go 1.26.4 +go 1.26.5 require ( github.com/Checkmarx/ast-cx-hooks v1.0.4 From 6ebf301b71620d3dba49f1399271798e86dc896c Mon Sep 17 00:00:00 2001 From: Anurag Dalke <120229307+cx-anurag-dalke@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:19:05 +0530 Subject: [PATCH 3/7] rename scan-github-action yml file --- .github/workflows/{scan-github-action- => scan-github-action.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{scan-github-action- => scan-github-action.yml} (100%) diff --git a/.github/workflows/scan-github-action- b/.github/workflows/scan-github-action.yml similarity index 100% rename from .github/workflows/scan-github-action- rename to .github/workflows/scan-github-action.yml From aa05ee03f2d34dc41de9392d9fbca2a8c6217cf4 Mon Sep 17 00:00:00 2001 From: Anurag Dalke <120229307+cx-anurag-dalke@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:31:25 +0530 Subject: [PATCH 4/7] added ECHO_LIBRARIES_ACCESS_KEY --- .github/workflows/ci-tests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 8fe0ad525..ac76bbd42 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -256,8 +256,9 @@ jobs: - name: Install pre-commit if: matrix.needs_precommit == 'true' run: | - pip install --index-url https://pypi.echohq.com/simple --extra-index-url https://pypi.org/simple pre-commit - pre-commit install + pip config set global.index-url https://:${{ secrets.ECHO_LIBRARIES_ACCESS_KEY }}@pypi.echohq.com/simple + pip index versions pre-commit + pip install pre-commit==4.6.0 - name: Start Squid proxy run: | From 5a09fc7f1541b2154db3aea605031fafea8ad976 Mon Sep 17 00:00:00 2001 From: Anurag Dalke <120229307+cx-anurag-dalke@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:28:59 +0530 Subject: [PATCH 5/7] Updated GH Action --- .github/workflows/Trivy Scan.yml | 39 ++++++++++++++++++++++++ .github/workflows/issue_automation.yml | 32 ------------------- .github/workflows/scan-github-action.yml | 1 + .github/workflows/unit-tests.yml | 1 + 4 files changed, 41 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/Trivy Scan.yml delete mode 100644 .github/workflows/issue_automation.yml diff --git a/.github/workflows/Trivy Scan.yml b/.github/workflows/Trivy Scan.yml new file mode 100644 index 000000000..df0a25200 --- /dev/null +++ b/.github/workflows/Trivy Scan.yml @@ -0,0 +1,39 @@ +name: Trivy Scan + +on: + pull_request: + workflow_call: + +permissions: + contents: read + +jobs: + checkDockerImage: + runs-on: cx-public-ubuntu-x64 + name: Scan Docker Image with Trivy + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 + with: + persist-credentials: false + - name: Build the project + run: go build -o ./cx ./cmd + - name: Build Docker image + run: docker build -t ast-cli:${{ github.sha }} . + - name: Run Trivy scanner without downloading DBs + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 #v0.35.0 + with: + scan-type: 'image' + image-ref: ast-cli:${{ github.sha }} + format: 'table' + exit-code: '1' + ignore-unfixed: true + vuln-type: 'os,library' + output: './trivy-image-results.txt' + env: + TRIVY_SKIP_JAVA_DB_UPDATE: true + + - name: Inspect action report + if: always() + shell: bash + run: cat ./trivy-image-results.txt diff --git a/.github/workflows/issue_automation.yml b/.github/workflows/issue_automation.yml deleted file mode 100644 index d8bb874f2..000000000 --- a/.github/workflows/issue_automation.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Issue automation - -on: - issues: - types: [opened, closed] - -jobs: - notify_jira: - if: github.event.action == 'opened' - name: Notify Jira - uses: Checkmarx/plugins-release-workflow/.github/workflows/jira_notify.yml@main - with: - title: ${{ github.event.issue.title }} - body: ${{ github.event.issue.body }} - html_url: ${{ github.event.issue.html_url }} - repo: ${{ github.event.repository.full_name }} - secrets: - AST_JIRA_API_TOKEN: ${{ secrets.AST_JIRA_API_TOKEN }} - AST_JIRA_USER_EMAIL: ${{ secrets.AST_JIRA_USER_EMAIL }} - JIRA_FIELDS_BUG: ${{ secrets.JIRA_FIELDS_BUG }} - JIRA_FIELDS_OTHER: ${{ secrets.JIRA_FIELDS_OTHER }} - - close_jira: - if: github.event.action == 'closed' - name: Close Jira - uses: Checkmarx/plugins-release-workflow/.github/workflows/jira_close.yml@main - with: - issue_number: ${{ github.event.issue.number }} - repo: ${{ github.event.repository.full_name }} - secrets: - AST_JIRA_API_TOKEN: ${{ secrets.AST_JIRA_API_TOKEN }} - AST_JIRA_USER_EMAIL: ${{ secrets.AST_JIRA_USER_EMAIL }} diff --git a/.github/workflows/scan-github-action.yml b/.github/workflows/scan-github-action.yml index e41d5bbf7..578b35be7 100644 --- a/.github/workflows/scan-github-action.yml +++ b/.github/workflows/scan-github-action.yml @@ -2,6 +2,7 @@ name: Scan for GitHub Actions issues on: pull_request: + workflow_call: concurrency: group: ${{ github.workflow }}-${{ github.repository }}-${{ github.ref }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 841ae0d4c..37a81bb46 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -2,6 +2,7 @@ name: AST-CLI Unit Tests on: pull_request: + workflow_call: permissions: contents: read From b103c0bac003217d024165dd9a2fc134ea4a8e29 Mon Sep 17 00:00:00 2001 From: Anurag Dalke <120229307+cx-anurag-dalke@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:29:25 +0530 Subject: [PATCH 6/7] rename file name --- .github/workflows/docker-image-scan.yml | 38 ------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/workflows/docker-image-scan.yml diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml deleted file mode 100644 index 431bfefd7..000000000 --- a/.github/workflows/docker-image-scan.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Docker Image Scan - -on: - pull_request: - -permissions: - contents: read - -jobs: - checkDockerImage: - runs-on: cx-public-ubuntu-x64 - name: Scan Docker Image with Trivy - steps: - - name: Checkout code - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v6 - with: - persist-credentials: false - - name: Build the project - run: go build -o ./cx ./cmd - - name: Build Docker image - run: docker build -t ast-cli:${{ github.sha }} . - - name: Run Trivy scanner without downloading DBs - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 #v0.35.0 - with: - scan-type: 'image' - image-ref: ast-cli:${{ github.sha }} - format: 'table' - exit-code: '1' - ignore-unfixed: true - vuln-type: 'os,library' - output: './trivy-image-results.txt' - env: - TRIVY_SKIP_JAVA_DB_UPDATE: true - - - name: Inspect action report - if: always() - shell: bash - run: cat ./trivy-image-results.txt From 8a96a1f278d598d151d28798356292d1acfe377d Mon Sep 17 00:00:00 2001 From: atishj99 Date: Fri, 17 Jul 2026 19:38:26 +0530 Subject: [PATCH 7/7] Squashed commit of the following: commit 8f8feb02284c1d4cb37b15750d4fb29dc924477c Author: Atish Jadhav Date: Thu Jul 16 16:56:41 2026 +0530 Add session telemetry and expand SCA manifest support (AST-163052) (#1522) * Add session telemetry and expand SCA manifest support * vulnerability and trivy fixes * lint and unit test fixes * Update SCA remediation with cx-devassist-sca skill * removing unwanted comments * lint fixes --- go.mod | 49 ++--- go.sum | 80 ++++---- internal/commands/agenthooks/cx/hooks.go | 65 +++++- internal/commands/agenthooks/cx/hooks_test.go | 22 ++ .../commands/agenthooks/guardrails/policy.go | 3 +- internal/commands/agenthooks/sca/diff.go | 19 +- internal/commands/agenthooks/sca/diff_test.go | 40 +++- internal/commands/agenthooks/sca/manifests.go | 61 +++++- .../commands/agenthooks/sca/manifests_test.go | 10 + internal/commands/agenthooks/sca/prompts.go | 24 ++- internal/commands/agenthooks/sca/sca.go | 2 +- internal/commands/agenthooks/sca/sca_test.go | 10 +- internal/commands/agenthooks/sca/synth.go | 53 +++++ .../commands/agenthooks/sca/synth_test.go | 21 ++ .../agenthooks/sessiontally/sessiontally.go | 193 ++++++++++++++++++ .../sessiontally/sessiontally_test.go | 114 +++++++++++ .../ossrealtime/oss-realtime.go | 8 + 17 files changed, 667 insertions(+), 107 deletions(-) create mode 100644 internal/commands/agenthooks/cx/hooks_test.go create mode 100644 internal/commands/agenthooks/sessiontally/sessiontally.go create mode 100644 internal/commands/agenthooks/sessiontally/sessiontally_test.go diff --git a/go.mod b/go.mod index 8d9c97d8c..2b257eccd 100644 --- a/go.mod +++ b/go.mod @@ -29,24 +29,23 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 github.com/xeipuuv/gojsonschema v1.2.0 - golang.org/x/crypto v0.52.0 - golang.org/x/sync v0.20.0 - golang.org/x/text v0.37.0 - google.golang.org/grpc v1.80.0 + 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/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 gotest.tools v2.2.0+incompatible ) require ( - cyphar.com/go-pathrs v0.2.1 // indirect + cyphar.com/go-pathrs v0.2.4 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20240914100643-eb91380d8434 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/clipperhouse/uax29/v2 v2.6.0 // indirect - github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/distribution/distribution/v3 v3.1.1 // indirect github.com/docker/docker v28.0.3+incompatible // indirect github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect @@ -83,7 +82,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect - github.com/Microsoft/hcsshim v0.15.0-rc.1 // indirect + github.com/Microsoft/hcsshim v0.15.0-rc.3 // indirect github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/acobaugh/osrelease v0.1.0 // indirect github.com/adrg/xdg v0.5.3 // indirect @@ -119,24 +118,23 @@ require ( github.com/charmbracelet/x/term v0.2.2 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/cgroups/v3 v3.1.3 // indirect - github.com/containerd/containerd v1.7.30 // indirect - github.com/containerd/containerd/api v1.9.0 // indirect + github.com/containerd/containerd v1.7.33 // indirect + github.com/containerd/containerd/api v1.10.0 // indirect github.com/containerd/continuity v0.5.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/fifo v1.1.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v1.0.0-rc.4 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/containerd/ttrpc v1.2.8 // indirect - github.com/containerd/typeurl/v2 v2.2.3 // indirect + github.com/containerd/typeurl/v2 v2.3.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.4.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -174,7 +172,7 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-containerregistry v0.21.5 // indirect + github.com/google/go-containerregistry v0.21.6 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -199,7 +197,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -271,7 +269,6 @@ require ( github.com/therootcompany/xz v1.0.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/vbatts/go-mtree v0.7.0 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect github.com/vifraa/gopom v1.0.0 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 // indirect @@ -288,20 +285,20 @@ require ( go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/mock v0.6.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect + 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 @@ -333,9 +330,7 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -replace github.com/containerd/containerd => github.com/containerd/containerd v1.7.33 - -replace github.com/containerd/containerd/v2 => github.com/containerd/containerd/v2 v2.1.5 +replace github.com/docker/go-connections => github.com/docker/go-connections v0.6.0 replace github.com/opencontainers/selinux => github.com/opencontainers/selinux v1.13.0 diff --git a/go.sum b/go.sum index d9aa5f1a4..9e8514478 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= -cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +cyphar.com/go-pathrs v0.2.4 h1:iD/mge36swa1UFKdINkr1Frkpp6wZsy3YYEildj9cLY= +cyphar.com/go-pathrs v0.2.4/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -105,8 +105,8 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= -github.com/Microsoft/hcsshim v0.15.0-rc.1 h1:FbbwtQmiD+BVHynGkx5S65JkLyhkEiiTP8nrpmg2SZw= -github.com/Microsoft/hcsshim v0.15.0-rc.1/go.mod h1:HWvvUPIy9HF6LotILj1G4VyS065rcLQ6tqj6tMUdOfI= +github.com/Microsoft/hcsshim v0.15.0-rc.3 h1:ZTNzOp0QwJ1EiL3zopSOawIG0j7zAvzJx0rBmcR6HJ0= +github.com/Microsoft/hcsshim v0.15.0-rc.3/go.mod h1:VhDiwXgb8cEJxO9H57YL4NNIYqvZKpqvSDcimLyo7m8= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= @@ -255,8 +255,8 @@ github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6 github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd v1.7.33 h1:iAkYGC/ifR/V+0eR4iXWHNGYUF0DF2PmGV5iz4Irj5M= github.com/containerd/containerd v1.7.33/go.mod h1:gSbSCVjPCdkfJCjyrzz7aRC+xFlqVbatNpfHfVCYGUM= -github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0= -github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI= +github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= +github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM= github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -269,12 +269,10 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v1.0.0-rc.4 h1:M42JrUT4zfZTqtkUwkr0GzmUWbfyO5VO0Q5b3op97T4= github.com/containerd/platforms v1.0.0-rc.4/go.mod h1:lKlMXyLybmBedS/JJm11uDofzI8L2v0J2ZbYvNsbq1A= -github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/containerd/ttrpc v1.2.8 h1:xbVu6D4qF2jihdh9rDVOKqUMiFBQk6YctTdo1zk087Y= github.com/containerd/ttrpc v1.2.8/go.mod h1:wyZW2K79t4Hfcxl+GUvkZqRBzJlqFFvgEeeWXa42tyE= -github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= -github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= +github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ= +github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -502,8 +500,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= -github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= +github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= +github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= @@ -656,8 +654,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -989,8 +987,6 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbatts/go-mtree v0.7.0 h1:ytmOc3MTRidZiBi9VBCyZ2BHe4fZS47L5v7BVXDWW4E= github.com/vbatts/go-mtree v0.7.0/go.mod h1:EjdpFC+LZy1TXbRGNa1MKKgjQ+7ew3foMFJK8o4/TdY= -github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= -github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vifraa/gopom v1.0.0 h1:L9XlKbyvid8PAIK8nr0lihMApJQg/12OBvMA28BcWh0= github.com/vifraa/gopom v1.0.0/go.mod h1:oPa1dcrGrtlO37WPDBm5SqHAT+wTgF8An1Q71Z6Vv4o= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= @@ -1052,8 +1048,8 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9a go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= @@ -1078,16 +1074,16 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1J go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= @@ -1098,8 +1094,8 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -1116,8 +1112,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1157,8 +1153,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1204,8 +1200,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1237,8 +1233,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1315,13 +1311,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1332,8 +1328,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1394,8 +1390,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1539,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.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +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/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/cx/hooks.go b/internal/commands/agenthooks/cx/hooks.go index 14b81c7f0..c2f0ac03b 100644 --- a/internal/commands/agenthooks/cx/hooks.go +++ b/internal/commands/agenthooks/cx/hooks.go @@ -3,17 +3,61 @@ package cx import ( "log" "os" + "path/filepath" "strings" agenthooks "github.com/Checkmarx/ast-cx-hooks" + "github.com/Checkmarx/ast-cx-hooks/claude" "github.com/Checkmarx/ast-cx-hooks/cursor" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/asca" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/kics" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/sca" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/sessiontally" "github.com/checkmarx/ast-cli/internal/wrappers" ) +// canonical per-engine labels for the session summary (normalizes the legacy SCA/Oss split used by +// the per-event remediation telemetry; ASCA already uses "Asca"). +const ( + engineAsca = "Asca" + engineSca = "Sca" + 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. @@ -25,11 +69,21 @@ var kicsScanner *kics.Scanner var telemetryWrapper wrappers.TelemetryWrapper -// cxWhenAgentIdle: agent finished its turn. Nothing to enforce yet. func cxWhenAgentIdle(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { return agenthooks.Resume() } +// sessionIDFromToolCall recovers the Claude session id for a ToolCall event. Unlike FileEditEvent, +// the library's ToolCallEvent carries no SessionID field; only Claude's raw payload is assertable +// (mirrors promptWorkspaceRoots' Raw type-assertion). Every non-Claude agent falls back to "" (the +// shared default tally bucket), which the Stop handler also reads and clears. +func sessionIDFromToolCall(ev *agenthooks.ToolCallEvent) string { + if e, ok := ev.Raw.(*claude.PreToolUseEvent); ok { + return e.SessionID + } + return "" +} + // cxBeforeToolCall gates shell execution against the organization's blacklist, // tool rules, and the SCA guardrail (malicious / vulnerable package installs). func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { @@ -45,7 +99,10 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { } if scaScanner != nil { if finding, remediation := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir); finding != "" { + touchSessionFindingsMarker() agent := agentToString(ev.Agent) + sid := sessionIDFromToolCall(&ev) + sessiontally.Add(sid, engineSca, 1, 1) logRemediationTelemetry(agent, "SCA", finding, remediation) return agenthooks.DenyWithContext(finding, remediation) } @@ -90,17 +147,23 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict { } agent := agentToString(ev.Agent) if blocked, reason, context := asca.ScanFileEdit(ev, telemetryWrapper, agent); blocked { + touchSessionFindingsMarker() + sessiontally.Add(ev.SessionID, engineAsca, 1, 1) logRemediationTelemetry(agent, "Asca", reason, context) 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() + sessiontally.Add(ev.SessionID, engineSca, 1, 1) logRemediationTelemetry(agent, "Oss", finding, remediation) return agenthooks.RejectEditWithContext(finding, remediation) } diff --git a/internal/commands/agenthooks/cx/hooks_test.go b/internal/commands/agenthooks/cx/hooks_test.go new file mode 100644 index 000000000..0ac3b4b04 --- /dev/null +++ b/internal/commands/agenthooks/cx/hooks_test.go @@ -0,0 +1,22 @@ +//go:build !integration + +package cx + +import ( + "testing" + + agenthooks "github.com/Checkmarx/ast-cx-hooks" + "github.com/Checkmarx/ast-cx-hooks/claude" +) + +func TestSessionIDFromToolCall(t *testing.T) { + claudeEv := agenthooks.ToolCallEvent{ + Raw: &claude.PreToolUseEvent{EventBase: claude.EventBase{SessionID: "S9"}}, + } + if got := sessionIDFromToolCall(&claudeEv); got != "S9" { + t.Errorf("claude raw: want S9, got %q", got) + } + if got := sessionIDFromToolCall(&agenthooks.ToolCallEvent{Raw: nil}); got != "" { + t.Errorf("nil raw: want empty, got %q", got) + } +} diff --git a/internal/commands/agenthooks/guardrails/policy.go b/internal/commands/agenthooks/guardrails/policy.go index 420827d6b..0c6a13c41 100644 --- a/internal/commands/agenthooks/guardrails/policy.go +++ b/internal/commands/agenthooks/guardrails/policy.go @@ -488,7 +488,8 @@ func ResolveRestrictedPaths(globalPaths, rulePaths []string, strategy string) [] // strip the leading slash before a drive letter so PathUnderAny's prefix match // lines up with policy entries like "C:\\foo\\bar\\". func NormalizeWorkspaceRoot(root string) string { - r := filepath.ToSlash(root) + // Explicit replace (not filepath.ToSlash) so backslashes normalize on any host OS, not just Windows. + r := strings.ReplaceAll(root, "\\", "/") if len(r) >= 3 && r[0] == '/' && isASCIILetter(r[1]) && r[2] == ':' { r = r[1:] } diff --git a/internal/commands/agenthooks/sca/diff.go b/internal/commands/agenthooks/sca/diff.go index e51e83188..a3254153b 100644 --- a/internal/commands/agenthooks/sca/diff.go +++ b/internal/commands/agenthooks/sca/diff.go @@ -11,16 +11,17 @@ import ( // before, keyed by name+version. A version bump on an existing package is // reported as added (its new version is new). // -// Both before and after are parsed via manifest-parser's factory; we write -// them to temp files using a name that matches the format so the factory -// selects the right parser. An empty/missing before parses as zero packages -// (so every after-package is "added"). -func AddedPackages(format Format, before, after []byte) ([]Package, error) { - beforePkgs, err := parseManifestBytes(format, before) +// manifestPath is the real path of the edited file — used only for its +// basename, so manifest-parser's factory picks the parser that matches the +// file being diffed (IsManifest already confirmed it recognises the name). +// Both before and after are parsed via that same parser. An empty/missing +// before parses as zero packages (so every after-package is "added"). +func AddedPackages(manifestPath string, before, after []byte) ([]Package, error) { + beforePkgs, err := parseManifestBytes(manifestPath, before) if err != nil { return nil, err } - afterPkgs, err := parseManifestBytes(format, after) + afterPkgs, err := parseManifestBytes(manifestPath, after) if err != nil { return nil, err } @@ -39,7 +40,7 @@ func AddedPackages(format Format, before, after []byte) ([]Package, error) { return added, nil } -func parseManifestBytes(format Format, content []byte) ([]Package, error) { +func parseManifestBytes(manifestPath string, content []byte) ([]Package, error) { if len(content) == 0 { return nil, nil } @@ -49,7 +50,7 @@ func parseManifestBytes(format Format, content []byte) ([]Package, error) { } defer os.RemoveAll(dir) - path := filepath.Join(dir, format.SynthFileName()) + path := filepath.Join(dir, filepath.Base(manifestPath)) if writeErr := os.WriteFile(path, content, 0600); writeErr != nil { return nil, writeErr } diff --git a/internal/commands/agenthooks/sca/diff_test.go b/internal/commands/agenthooks/sca/diff_test.go index 19844ab53..b24c647b4 100644 --- a/internal/commands/agenthooks/sca/diff_test.go +++ b/internal/commands/agenthooks/sca/diff_test.go @@ -6,10 +6,13 @@ import ( "testing" ) +// wantComExampleBar is the added-package name shared by the Gradle and Sbt "added package" tests. +const wantComExampleBar = "com.example:bar" + func TestAddedPackages_Npm_AddedNewPackage(t *testing.T) { before := []byte(`{"name":"x","version":"1.0.0","dependencies":{"lodash":"4.17.21"}}`) after := []byte(`{"name":"x","version":"1.0.0","dependencies":{"lodash":"4.17.21","axios":"1.0.0"}}`) - added, err := AddedPackages(FormatNpmPackageJson, before, after) + added, err := AddedPackages("package.json", before, after) if err != nil { t.Fatalf("AddedPackages: %v", err) } @@ -21,7 +24,7 @@ func TestAddedPackages_Npm_AddedNewPackage(t *testing.T) { func TestAddedPackages_Npm_VersionBumpCountsAsAdded(t *testing.T) { before := []byte(`{"name":"x","version":"1.0.0","dependencies":{"lodash":"4.17.0"}}`) after := []byte(`{"name":"x","version":"1.0.0","dependencies":{"lodash":"4.17.21"}}`) - added, err := AddedPackages(FormatNpmPackageJson, before, after) + added, err := AddedPackages("package.json", before, after) if err != nil { t.Fatalf("AddedPackages: %v", err) } @@ -33,7 +36,7 @@ func TestAddedPackages_Npm_VersionBumpCountsAsAdded(t *testing.T) { func TestAddedPackages_Npm_RemovedPackageIsIgnored(t *testing.T) { before := []byte(`{"name":"x","version":"1.0.0","dependencies":{"lodash":"4.17.21","axios":"1.0.0"}}`) after := []byte(`{"name":"x","version":"1.0.0","dependencies":{"lodash":"4.17.21"}}`) - added, err := AddedPackages(FormatNpmPackageJson, before, after) + added, err := AddedPackages("package.json", before, after) if err != nil { t.Fatalf("AddedPackages: %v", err) } @@ -44,7 +47,7 @@ func TestAddedPackages_Npm_RemovedPackageIsIgnored(t *testing.T) { func TestAddedPackages_Npm_NewFile(t *testing.T) { after := []byte(`{"name":"x","version":"1.0.0","dependencies":{"lodash":"4.17.21","axios":"1.0.0"}}`) - added, err := AddedPackages(FormatNpmPackageJson, nil, after) + added, err := AddedPackages("package.json", nil, after) if err != nil { t.Fatalf("AddedPackages: %v", err) } @@ -56,7 +59,7 @@ func TestAddedPackages_Npm_NewFile(t *testing.T) { func TestAddedPackages_Pypi_AddedPackage(t *testing.T) { before := []byte("requests==2.25.1\n") after := []byte("requests==2.25.1\nflask==2.0.0\n") - added, err := AddedPackages(FormatPypiRequirements, before, after) + added, err := AddedPackages("requirements.txt", before, after) if err != nil { t.Fatalf("AddedPackages: %v", err) } @@ -65,10 +68,35 @@ func TestAddedPackages_Pypi_AddedPackage(t *testing.T) { } } +func TestAddedPackages_Gradle_AddedPackage(t *testing.T) { + before := []byte("dependencies {\n implementation 'com.example:foo:1.0.0'\n}\n") + after := []byte("dependencies {\n implementation 'com.example:foo:1.0.0'\n implementation 'com.example:bar:2.0.0'\n}\n") + added, err := AddedPackages("build.gradle", before, after) + if err != nil { + t.Fatalf("AddedPackages: %v", err) + } + if len(added) != 1 || added[0].Name != wantComExampleBar { + t.Errorf("got added=%v, want [com.example:bar]", added) + } +} + +func TestAddedPackages_Sbt_AddedPackage(t *testing.T) { + before := []byte(`libraryDependencies += "com.example" % "foo" % "1.0.0"` + "\n") + after := []byte(`libraryDependencies += "com.example" % "foo" % "1.0.0"` + "\n" + + `libraryDependencies += "com.example" % "bar" % "2.0.0"` + "\n") + added, err := AddedPackages("build.sbt", before, after) + if err != nil { + t.Fatalf("AddedPackages: %v", err) + } + if len(added) != 1 || added[0].Name != wantComExampleBar { + t.Errorf("got added=%v, want [com.example:bar]", added) + } +} + func TestAddedPackages_UnparseableContent(t *testing.T) { // Note: behaviour for unparseable content depends on the upstream parser. // We assert that errors flow back to the caller, not that any specific // content causes an error — the caller's contract is "treat errors as // fail-open" so callers don't depend on a particular outcome here. - _, _ = AddedPackages(FormatNpmPackageJson, nil, []byte("{not valid json")) + _, _ = AddedPackages("package.json", nil, []byte("{not valid json")) } diff --git a/internal/commands/agenthooks/sca/manifests.go b/internal/commands/agenthooks/sca/manifests.go index 0d6702346..0652c728f 100644 --- a/internal/commands/agenthooks/sca/manifests.go +++ b/internal/commands/agenthooks/sca/manifests.go @@ -5,6 +5,8 @@ package sca import ( "path/filepath" "strings" + + "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime" ) // Format identifies one of the manifest shapes that the SCA guardrails care @@ -21,26 +23,51 @@ const ( FormatDotnetCsproj FormatDotnetDirectoryPackagesProps FormatDotnetPackagesConfig + FormatGradleBuild + FormatGradleVersionCatalog + FormatSbtBuild +) + +// gradleBuildFileName and gradleVersionCatalogFileName are the canonical basenames for the Gradle +// manifest formats, shared between the classifier switch below and SynthFileName. +const ( + gradleBuildFileName = "build.gradle" + gradleVersionCatalogFileName = "libs.versions.toml" ) // IsManifest reports whether path names a manifest file the OSS realtime -// scanner can analyse. The rules mirror manifest-parser's selectManifestFile: +// scanner can analyse. ossrealtime.IsSupportedManifestFile is the single +// source of truth for which filenames the scanner accepts — it is also used +// to gate the actual scan, so hooks and the scanner can never drift apart on +// what counts as a supported manifest. The switch below only classifies an +// already-accepted path into the Format the guardrails need to re-synthesise +// a minimal manifest for the added packages (see Synthesize). // -// - *.csproj → Dotnet csproj -// - requirements*.txt, packages*.txt → Pypi requirements -// - pom.xml → Maven -// - package.json → Npm -// - Directory.Packages.props → Dotnet central package management -// - packages.config → Dotnet legacy -// - go.mod → Go modules +// - *.csproj → Dotnet csproj +// - *.sbt → Sbt build +// - requirements*.txt, packages*.txt, constraint*.txt → Pypi requirements +// - setup.cfg, setup.py, pyproject.toml → Pypi (alt. formats) +// - pom.xml → Maven +// - package.json → Npm +// - Directory.Packages.props → Dotnet central package management +// - packages.config → Dotnet legacy +// - go.mod → Go modules +// - build.gradle, build.gradle.kts → Gradle build +// - libs.versions.toml → Gradle version catalog func IsManifest(path string) (Format, bool) { + if !ossrealtime.IsSupportedManifestFile(path) { + return FormatUnknown, false + } + base := filepath.Base(path) ext := filepath.Ext(base) switch { case ext == ".csproj": return FormatDotnetCsproj, true - case ext == ".txt" && (strings.HasPrefix(base, "requirement") || strings.HasPrefix(base, "packages")): + case ext == ".sbt": + return FormatSbtBuild, true + case ext == ".txt" && (strings.HasPrefix(base, "requirement") || strings.HasPrefix(base, "packages") || strings.HasPrefix(base, "constraint")): return FormatPypiRequirements, true case base == "pom.xml": return FormatMavenPom, true @@ -52,6 +79,12 @@ func IsManifest(path string) (Format, bool) { return FormatDotnetPackagesConfig, true case base == "go.mod": return FormatGoMod, true + case base == gradleBuildFileName, base == gradleBuildFileName+".kts": + return FormatGradleBuild, true + case base == gradleVersionCatalogFileName: + return FormatGradleVersionCatalog, true + case base == "setup.cfg", base == "setup.py", base == "pyproject.toml": + return FormatPypiRequirements, true } return FormatUnknown, false } @@ -70,6 +103,10 @@ func (f Format) ManagerName() string { return "maven" case FormatDotnetCsproj, FormatDotnetDirectoryPackagesProps, FormatDotnetPackagesConfig: return "nuget" + case FormatGradleBuild, FormatGradleVersionCatalog: + return "gradle" + case FormatSbtBuild: + return "sbt" } return "" } @@ -93,6 +130,12 @@ func (f Format) SynthFileName() string { return "Directory.Packages.props" case FormatDotnetPackagesConfig: return "packages.config" + case FormatGradleBuild: + return gradleBuildFileName + case FormatGradleVersionCatalog: + return gradleVersionCatalogFileName + case FormatSbtBuild: + return "synth.sbt" } return "" } diff --git a/internal/commands/agenthooks/sca/manifests_test.go b/internal/commands/agenthooks/sca/manifests_test.go index 801f22e58..74784cf4e 100644 --- a/internal/commands/agenthooks/sca/manifests_test.go +++ b/internal/commands/agenthooks/sca/manifests_test.go @@ -21,6 +21,16 @@ func TestIsManifest(t *testing.T) { {"Project.csproj", true, FormatDotnetCsproj}, {"Directory.Packages.props", true, FormatDotnetDirectoryPackagesProps}, {"packages.config", true, FormatDotnetPackagesConfig}, + {"build.gradle", true, FormatGradleBuild}, + {"build.gradle.kts", true, FormatGradleBuild}, + {"/repo/app/build.gradle.kts", true, FormatGradleBuild}, + {"libs.versions.toml", true, FormatGradleVersionCatalog}, + {"build.sbt", true, FormatSbtBuild}, + {"plugins.sbt", true, FormatSbtBuild}, + {"constraints.txt", true, FormatPypiRequirements}, + {"setup.cfg", true, FormatPypiRequirements}, + {"setup.py", true, FormatPypiRequirements}, + {"pyproject.toml", true, FormatPypiRequirements}, // Negatives. {"main.go", false, FormatUnknown}, diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index d80268d0b..b128cfad0 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -35,20 +35,23 @@ func DenyVulnerable(pkgs []ossrealtime.OssPackage, workDir string) (finding, rem } // remediationNote returns the action steps to include as additionalContext for malicious packages. -// Remediation must go through the MCP tool; if it is unavailable Claude must self-recover -// by running the registration helper rather than asking the user. +// 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 { return fmt.Sprintf( "Action required:\n"+ - " 1. Use mcp__Checkmarx__packageRemediation for each %s to find the %s.\n"+ + " 1. For each %s, invoke the cx-devassist:cx-devassist-sca skill — "+ + "the findings are already in context so it will skip the scan and go directly to "+ + "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"+ - " 2. If mcp__Checkmarx__packageRemediation is unavailable, recover it yourself — do NOT ask\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 no safe version exists, the user can acknowledge the finding via\n"+ + " 4. If no safe version exists, the user can acknowledge the finding via\n"+ " the Checkmarx Dev Assist interface.", - subject, goal) + subject, goal, subject) } // vulnerableRemediationNote returns the action steps for vulnerable packages. @@ -68,13 +71,16 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir string) st } return fmt.Sprintf( "Action required:\n"+ - " 1. Use mcp__Checkmarx__packageRemediation for each affected package to find the non-vulnerable version.\n"+ + " 1. For each affected package, invoke the cx-devassist:cx-devassist-sca skill — "+ + "the findings are already in context so it will skip the scan and go directly to "+ + "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"+ - " 2. If mcp__Checkmarx__packageRemediation is unavailable, recover it yourself — do NOT ask\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 no safe version exists for a package, suppress it by running the corresponding command\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", suppressCmds.String()) } diff --git a/internal/commands/agenthooks/sca/sca.go b/internal/commands/agenthooks/sca/sca.go index af01f0d0f..0564a20a9 100644 --- a/internal/commands/agenthooks/sca/sca.go +++ b/internal/commands/agenthooks/sca/sca.go @@ -53,7 +53,7 @@ func (s *Scanner) CheckManifestEdit(filePath string, afterContent []byte, workDi return "", "" } before, _ := os.ReadFile(filePath) // missing → empty before - added, err := AddedPackages(format, before, afterContent) + added, err := AddedPackages(filePath, before, afterContent) if err != nil || len(added) == 0 { return "", "" } diff --git a/internal/commands/agenthooks/sca/sca_test.go b/internal/commands/agenthooks/sca/sca_test.go index 7fe717ee5..ce41affe0 100644 --- a/internal/commands/agenthooks/sca/sca_test.go +++ b/internal/commands/agenthooks/sca/sca_test.go @@ -43,8 +43,11 @@ func TestCheckBashInstall_MaliciousMentionsMCP(t *testing.T) { if !strings.Contains(remediation, "mcp__Checkmarx__packageRemediation") { t.Errorf("expected remediation to reference MCP tool, got %q", remediation) } - if !strings.Contains(remediation, "install or enable") { - t.Errorf("expected remediation to mention installing/enabling MCP, got %q", remediation) + 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, "Dev Assist") { t.Errorf("expected remediation to mention Dev Assist fallback, got %q", remediation) @@ -60,6 +63,9 @@ func TestCheckBashInstall_Vulnerable(t *testing.T) { if !strings.Contains(remediation, "mcp__Checkmarx__packageRemediation") { t.Errorf("expected remediation to reference MCP tool, got %q", remediation) } + 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, "ignore-vulnerability") { t.Errorf("expected remediation to include ignore command, got %q", remediation) } diff --git a/internal/commands/agenthooks/sca/synth.go b/internal/commands/agenthooks/sca/synth.go index f0f99f34f..b4f07734c 100644 --- a/internal/commands/agenthooks/sca/synth.go +++ b/internal/commands/agenthooks/sca/synth.go @@ -36,6 +36,12 @@ func Synthesize(format Format, pkgs []Package, dir string) (string, error) { content = synthDirectoryPackagesProps(pkgs) case FormatDotnetPackagesConfig: content = synthPackagesConfig(pkgs) + case FormatGradleBuild: + content = synthGradleBuild(pkgs) + case FormatGradleVersionCatalog: + content = synthGradleVersionCatalog(pkgs) + case FormatSbtBuild: + content = synthSbt(pkgs) default: return "", fmt.Errorf("sca: unsupported synth format %d", format) } @@ -147,3 +153,50 @@ func synthPackagesConfig(pkgs []Package) []byte { b.WriteString("\n") return []byte(b.String()) } + +// synthGradleBuild writes one `implementation 'group:artifact:version'` line +// per package — the simplest form the Gradle parser's dependency regexes +// recognise. Name is "group:artifact" (mirrors Maven's groupId:artifactId). +func synthGradleBuild(pkgs []Package) []byte { + var b strings.Builder + for _, p := range pkgs { + group, artifact := splitMavenName(p.Name) + v := p.Version + if v == "" { + v = "latest" + } + fmt.Fprintf(&b, "implementation '%s:%s:%s'\n", group, artifact, v) + } + return []byte(b.String()) +} + +// synthGradleVersionCatalog writes a minimal libs.versions.toml [libraries] +// section — one inline "group:artifact:version" entry per package. +func synthGradleVersionCatalog(pkgs []Package) []byte { + var b strings.Builder + b.WriteString("[libraries]\n") + for i, p := range pkgs { + group, artifact := splitMavenName(p.Name) + v := p.Version + if v == "" { + v = "latest" + } + fmt.Fprintf(&b, "dep%d = \"%s:%s:%s\"\n", i, group, artifact, v) + } + return []byte(b.String()) +} + +// synthSbt writes one `libraryDependencies += "group" % "artifact" % "version"` +// line per package, the form the SBT parser's dependency regex recognises. +func synthSbt(pkgs []Package) []byte { + var b strings.Builder + for _, p := range pkgs { + group, artifact := splitMavenName(p.Name) + v := p.Version + if v == "" { + v = "latest" + } + fmt.Fprintf(&b, "libraryDependencies += \"%s\" %% \"%s\" %% \"%s\"\n", group, artifact, v) + } + return []byte(b.String()) +} diff --git a/internal/commands/agenthooks/sca/synth_test.go b/internal/commands/agenthooks/sca/synth_test.go index 3cf8fa346..83e6e75bc 100644 --- a/internal/commands/agenthooks/sca/synth_test.go +++ b/internal/commands/agenthooks/sca/synth_test.go @@ -86,6 +86,27 @@ func TestSynthesize_PackagesConfig(t *testing.T) { }) } +func TestSynthesize_GradleBuild(t *testing.T) { + roundTrip(t, FormatGradleBuild, []Package{ + {Name: "com.example:foo", Version: "1.0.0"}, + {Name: "com.example:bar", Version: "2.0.0"}, + }) +} + +func TestSynthesize_GradleVersionCatalog(t *testing.T) { + roundTrip(t, FormatGradleVersionCatalog, []Package{ + {Name: "com.example:foo", Version: "1.0.0"}, + {Name: "com.example:bar", Version: "2.0.0"}, + }) +} + +func TestSynthesize_Sbt(t *testing.T) { + roundTrip(t, FormatSbtBuild, []Package{ + {Name: "com.example:foo", Version: "1.0.0"}, + {Name: "com.example:bar", Version: "2.0.0"}, + }) +} + func TestSynthesize_UnsupportedFormat(t *testing.T) { dir, _ := os.MkdirTemp("", "synth-test-") defer os.RemoveAll(dir) diff --git a/internal/commands/agenthooks/sessiontally/sessiontally.go b/internal/commands/agenthooks/sessiontally/sessiontally.go new file mode 100644 index 000000000..9d2196d84 --- /dev/null +++ b/internal/commands/agenthooks/sessiontally/sessiontally.go @@ -0,0 +1,193 @@ +// Package sessiontally accumulates per-engine scan counts across an AI-agent session so the +// session-end (Stop) hook can emit a per-engine telemetry summary. +// +// Why on disk: every `cx hooks ` invocation is a FRESH OS process (see +// internal/commands/agenthooks.go HookDispatchCommands). No in-memory state survives between the +// per-edit/per-tool detect events and the session-end idle event, so the running tally must live on +// disk — the same reason the sibling `.session-cx-findings` marker exists. +// +// Design guarantees (mirroring touchSessionFindingsMarker's contract): every function here is +// BEST-EFFORT and must NEVER return an error or panic into its caller — a tally write failure must +// never affect a scan verdict. Writes are append-only NDJSON so concurrent hook processes (parallel +// tool calls / multi-file edits) never lose records to a read-modify-write race without needing a +// lock file; Load folds the records into a per-engine map. +// +// Session scoping: files are keyed by a sanitized session id. Events whose session id could not be +// resolved (e.g. a non-Claude ToolCall, whose library event carries no SessionID) are written to a +// shared "default" bucket that Load also reads and Clear also removes. Two concurrent sessions can +// therefore both feed the default bucket and one session's Clear can discard the other's +// default-bucket counts — accepted best-effort noise, since the summary itself is best-effort. +package sessiontally + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Counts is the per-engine roll-up Load returns. JSON tags let it serialize cleanly into the local +// session-summary audit log. +type Counts struct { + VulnerabilitiesFound int `json:"vulnerabilitiesFound"` + RemediationsOffered int `json:"remediationsOffered"` +} + +const ( + filePrefix = ".session-cx-tallies-" + fileSuffix = ".json" + defaultKey = "default" + maxAgeHours = 24 + maxIDLen = 128 + dirPerm = 0o700 + filePerm = 0o600 + // scanBufSize/scanMaxSize bound bufio.Scanner's growth when folding a tally file: + // start at 64KiB, allow growth up to 1MiB per line before Scan reports an error. + scanBufSize = 64 * 1024 + scanMaxSize = 1024 * 1024 +) + +// Only filename-safe characters survive; everything else (path separators, "..", spaces, control +// bytes) collapses to '_', so a hostile session id can never escape the ~/.checkmarx directory. +var sanitizeRe = regexp.MustCompile(`[^A-Za-z0-9._-]`) + +// record is one append-only NDJSON line. +type record struct { + Engine string `json:"engine"` + Found int `json:"found"` + RemOffered int `json:"remOffered"` +} + +func baseDir() (string, bool) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".checkmarx"), true +} + +func sanitize(sessionID string) string { + s := sanitizeRe.ReplaceAllString(sessionID, "_") + if s == "" { + s = defaultKey + } + if len(s) > maxIDLen { + s = s[:maxIDLen] + } + return s +} + +func tallyPath(sessionID string) (string, bool) { + dir, ok := baseDir() + if !ok { + return "", false + } + return filepath.Join(dir, filePrefix+sanitize(sessionID)+fileSuffix), true +} + +// Add appends one best-effort NDJSON record for (sessionID, engine). Never returns or panics. +func Add(sessionID, engine string, foundDelta, remOfferedDelta int) { + defer func() { _ = recover() }() + path, ok := tallyPath(sessionID) + if !ok { + return + } + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return + } + line, err := json.Marshal(record{Engine: engine, Found: foundDelta, RemOffered: remOfferedDelta}) + if err != nil { + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, filePerm) + if err != nil { + return + } + defer func() { _ = f.Close() }() + _, _ = f.Write(append(line, '\n')) +} + +// Load folds the session's tally file AND the shared default file into a per-engine map. Best-effort: +// any read/parse error is skipped and whatever parsed so far is returned. Also opportunistically +// removes stale tally files (>24h) so a session whose Stop hook never fired cannot leak forever. +func Load(sessionID string) map[string]Counts { + out := map[string]Counts{} + defer func() { _ = recover() }() + seen := map[string]bool{} + for _, sid := range []string{sessionID, defaultKey} { + path, ok := tallyPath(sid) + if !ok || seen[path] { + continue + } + seen[path] = true + foldFile(path, out) + } + cleanupStale() + return out +} + +func foldFile(path string, out map[string]Counts) { + f, err := os.Open(path) + if err != nil { + return + } + defer func() { _ = f.Close() }() + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, scanBufSize), scanMaxSize) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var r record + if json.Unmarshal([]byte(line), &r) != nil || r.Engine == "" { + continue + } + c := out[r.Engine] + c.VulnerabilitiesFound += r.Found + c.RemediationsOffered += r.RemOffered + out[r.Engine] = c + } +} + +// Clear removes the session's tally file AND the shared default file. Best-effort. +func Clear(sessionID string) { + defer func() { _ = recover() }() + seen := map[string]bool{} + for _, sid := range []string{sessionID, defaultKey} { + path, ok := tallyPath(sid) + if !ok || seen[path] { + continue + } + seen[path] = true + _ = os.Remove(path) + } +} + +func cleanupStale() { + dir, ok := baseDir() + if !ok { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-maxAgeHours * time.Hour) + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, filePrefix) || !strings.HasSuffix(name, fileSuffix) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, name)) + } + } +} diff --git a/internal/commands/agenthooks/sessiontally/sessiontally_test.go b/internal/commands/agenthooks/sessiontally/sessiontally_test.go new file mode 100644 index 000000000..dbe153c76 --- /dev/null +++ b/internal/commands/agenthooks/sessiontally/sessiontally_test.go @@ -0,0 +1,114 @@ +//go:build !integration + +package sessiontally + +import ( + "os" + "sync" + "testing" +) + +// sandbox points ~/.checkmarx at a temp dir on every OS (os.UserHomeDir uses USERPROFILE on Windows, +// HOME elsewhere), so tests never touch the real home directory. +func sandbox(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) +} + +func TestAddLoadClearRoundTrip(t *testing.T) { + sandbox(t) + Add("S1", "Asca", 2, 2) + Add("S1", "Asca", 1, 1) + Add("S1", "Sca", 3, 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"]) + } + + Clear("S1") + if n := len(Load("S1")); n != 0 { + t.Errorf("expected empty after Clear, got %d engines", n) + } +} + +func TestConcurrentAddDoesNotLoseRecords(t *testing.T) { + sandbox(t) + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + 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) + } +} + +func TestMissingHomeIsNoOpNoPanic(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + // os.UserHomeDir fails → every function must no-op without panicking. + Add("S1", "Asca", 1, 1) + if n := len(Load("S1")); n != 0 { + t.Errorf("Load with no home should be empty, got %d", n) + } + Clear("S1") +} + +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) + } + // The traversal must not have created a file outside ~/.checkmarx. + if _, err := os.Stat("etc/passwd weird"); err == nil { + t.Errorf("hostile id escaped the sandbox directory") + } +} + +func TestMalformedLinesSkipped(t *testing.T) { + sandbox(t) + Add("S1", "Asca", 1, 1) + 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 != nil { + t.Fatal(err) + } + _, _ = f.WriteString("not json\n{}\n{\"engine\":\"\",\"found\":9}\n") + _ = f.Close() + + if got := Load("S1")["Asca"].VulnerabilitiesFound; got != 1 { + t.Errorf("malformed lines should be skipped, Asca=%d want 1", got) + } +} diff --git a/internal/services/realtimeengine/ossrealtime/oss-realtime.go b/internal/services/realtimeengine/ossrealtime/oss-realtime.go index bcf53af41..65d619033 100644 --- a/internal/services/realtimeengine/ossrealtime/oss-realtime.go +++ b/internal/services/realtimeengine/ossrealtime/oss-realtime.go @@ -179,6 +179,14 @@ func getPackageEntryFromPackageMap( return &entry } +// IsSupportedManifestFile reports whether filePath names a manifest file the +// OSS realtime scanner is able to parse and scan. Exported so other callers +// (e.g. the agent-hooks SCA guardrails) can gate on the same rule set instead +// of re-implementing their own list of supported manifest files. +func IsSupportedManifestFile(filePath string) bool { + return validateSupportedManifestFile(filePath) == nil +} + // validateSupportedManifestFile checks if the manifest file format is supported by OSS realtime scanner. func validateSupportedManifestFile(filePath string) error { manifestFileName := filepath.Base(filePath)