diff --git a/.github/instructions/java.instructions.md b/.github/instructions/java.instructions.md index 0ddc7742e3c..6b0b54a46d2 100644 --- a/.github/instructions/java.instructions.md +++ b/.github/instructions/java.instructions.md @@ -30,16 +30,19 @@ Always use `headerReplace(name, value)` — never `accept()`, `contentType()`, o - New/updated code should throw only fcli-domain exceptions (`Fcli*Exception`), module-domain exceptions (for example `Aviator*Exception` in Aviator modules), or picocli exceptions (`ParameterException` and related) when integrating with command parsing. - Avoid throwing standard Java runtime exceptions (`IllegalArgumentException`, `IllegalStateException`, `RuntimeException`, and similar) for user-facing or command-flow errors. +- **Checked exceptions** (e.g., `IOException`, `JsonProcessingException`): Wrap in `FcliTechnicalException` to preserve the cause chain. +- **Runtime exceptions** (e.g., `UnexpectedHttpResponseException`): Re-throw as-is unless special handling is needed (e.g., a specific error code requires a user-friendly message). Avoid unnecessary wrapping to keep stack traces short and relevant. | Scenario | Exception | |----------|-----------| | Invalid/missing user input | `FcliSimpleException` | | External resource not found | `FcliSimpleException` with remediation | | User abort | `FcliAbortedByUserException` | -| I/O, network, JSON parse | `FcliTechnicalException` (wrap cause) | +| Checked exception (I/O, JSON parse) | `FcliTechnicalException` (wrap cause) | +| Runtime exception (no special handling needed) | Re-throw as-is | | Invariant violation, unreachable | `FcliBugException` | -Messages: actionable, sentence case, no trailing periods. Preserve root cause in wrapping. +Messages: actionable, sentence case, no trailing periods. Wrap root cause only for checked exceptions. ## Design Patterns diff --git a/.github/workflows/bump-shared-github.yml b/.github/workflows/bump-shared-github.yml new file mode 100644 index 00000000000..d923db75a78 --- /dev/null +++ b/.github/workflows/bump-shared-github.yml @@ -0,0 +1,17 @@ +name: Bump shared-github pin + +on: + schedule: + - cron: '0 6 * * *' # daily + workflow_dispatch: + push: + repository_dispatch: + types: + - shared-github-updated + +permissions: + contents: read + +jobs: + bump: + uses: fortify/shared-github/.github/workflows/reusable-bump-shared-pin.yml@5ad1afec0f56a21b03f0887eea35505cb6a66554 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0c0c404094..cd7131f3170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ env: jobs: check-duplicate-run: - uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main + uses: fortify/shared-github/.github/workflows/reusable-check-duplicate-run.yml@5ad1afec0f56a21b03f0887eea35505cb6a66554 create-release: name: create-release @@ -50,10 +50,10 @@ jobs: patch: ${{ steps.create_prod_release.outputs.patch }} steps: - name: Check-out source code - uses: actions/checkout@v6 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 - name: PROD - Prepare GitHub release id: create_prod_release - uses: fortify/3rdparty-actions/actions/googleapis/release-please-action/v5@main + uses: fortify/shared-github/actions/3rdparty/googleapis/release-please-action/v5@5ad1afec0f56a21b03f0887eea35505cb6a66554 if: contains(github.ref, 'refs/heads/rel/') with: skip-github-pull-request: true @@ -68,8 +68,8 @@ jobs: contents: read steps: - name: Check-out source code - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 + - uses: fortify/shared-github/actions/3rdparty/actions/setup-java/v5@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: distribution: 'temurin' java-version: '17' @@ -124,7 +124,7 @@ jobs: - name: Archive test results if: env.DO_BUILD && (success() || failure()) - uses: actions/upload-artifact@v7 + uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: name: test-results path: | @@ -137,17 +137,17 @@ jobs: run: java -jar build/libs/fcli.jar --version | tee /dev/stderr | grep -E '[0-9]+\.[0-9]+\.[0-9]+' >/dev/null || (echo "fcli --version doesn't output proper version number"; exit 1) - name: Publish build artifacts - uses: actions/upload-artifact@v7 + uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: name: build-output path: build/dist/**/* - - uses: actions/upload-artifact@v7 + - uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: name: fcli-jar path: build/dist/release-assets/fcli.jar - - uses: actions/upload-artifact@v7 + - uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: name: fcli-ftest-jar path: build/dist/fcli-ftest.jar @@ -173,16 +173,16 @@ jobs: # CC: /opt/musl_cc/x86_64-linux-musl-native/bin/gcc steps: - name: Check-out source code - uses: actions/checkout@v6 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 - - uses: fortify/3rdparty-actions/actions/graalvm/setup-graalvm/v1@main + - uses: fortify/shared-github/actions/3rdparty/graalvm/setup-graalvm/v1@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: distribution: ${{ env.graal_distribution }} java-version: ${{ env.graal_java_version }} native-image-musl: true github-token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/download-artifact@v8 + - uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts name: build-output @@ -198,7 +198,7 @@ jobs: run: native-image ${{ env.native_image_opts }} --static --libc=musl -Djansi.disable=true --initialize-at-build-time=com.fortify.cli.common.util.JAnsiConfig -H:ExcludeResources="org/fusesource/jansi/internal/native/.*" -jar ./artifacts/release-assets/fcli.jar fcli - name: Compress native fcli - uses: fortify/3rdparty-actions/actions/crazy-max/ghaction-upx/v4@main + uses: fortify/shared-github/actions/3rdparty/crazy-max/ghaction-upx/v4@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: files: fcli @@ -211,7 +211,7 @@ jobs: - name: Package native fcli run: tar -zcvf artifacts/release-assets/fcli-linux.tgz fcli -C ./artifacts fcli_completion - - uses: actions/upload-artifact@v7 + - uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts/**/fcli-linux.tgz name: fcli-linux @@ -222,15 +222,15 @@ jobs: runs-on: macos-latest steps: - name: Check-out source code - uses: actions/checkout@v6 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 - - uses: fortify/3rdparty-actions/actions/graalvm/setup-graalvm/v1@main + - uses: fortify/shared-github/actions/3rdparty/graalvm/setup-graalvm/v1@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: distribution: ${{ env.graal_distribution }} java-version: ${{ env.graal_java_version }} github-token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/download-artifact@v8 + - uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts name: build-output @@ -244,7 +244,7 @@ jobs: # Disabled for now, as compressed binaries crash on macOS Ventura or above #- name: Compress native fcli #- name: Compress native fcli - # uses: fortify/3rdparty-actions/actions/svenstaro/upx-action/v2@main + # uses: fortify/shared-github/actions/3rdparty/svenstaro/upx-action/v2@5ad1afec0f56a21b03f0887eea35505cb6a66554 # with: # files: fcli @@ -254,7 +254,7 @@ jobs: - name: Package native fcli run: tar -zcvf ./artifacts/release-assets/fcli-mac.tgz fcli -C ./artifacts fcli_completion - - uses: actions/upload-artifact@v7 + - uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts/**/fcli-mac.tgz name: fcli-mac @@ -264,13 +264,13 @@ jobs: needs: build runs-on: windows-2022 steps: - - uses: fortify/3rdparty-actions/actions/graalvm/setup-graalvm/v1@main + - uses: fortify/shared-github/actions/3rdparty/graalvm/setup-graalvm/v1@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: distribution: ${{ env.graal_distribution }} java-version: ${{ env.graal_java_version }} github-token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/download-artifact@v8 + - uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts name: build-output @@ -287,7 +287,7 @@ jobs: # We don't compress the Windows binary for now as this is incompatible with current Graal version. # See https://github.com/fortify/fcli/issues/148 ## - name: Compress native fcli -## uses: fortify/3rdparty-actions/actions/svenstaro/upx-action/v2@main +## uses: fortify/shared-github/actions/3rdparty/svenstaro/upx-action/v2@5ad1afec0f56a21b03f0887eea35505cb6a66554 ## with: ## files: fcli.exe @@ -300,7 +300,7 @@ jobs: - name: Package native fcli run: 7z a artifacts\release-assets\fcli-windows.zip fcli*.exe - - uses: actions/upload-artifact@v7 + - uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts/**/fcli-windows.zip name: fcli-windows @@ -310,7 +310,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download artifacts - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts merge-multiple: true @@ -329,7 +329,7 @@ jobs: env: SIGN_PASSPHRASE: ${{ secrets.SIGN_PASSPHRASE }} SIGN_KEY: ${{ secrets.SIGN_KEY }} - - uses: actions/upload-artifact@v7 + - uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts name: combined-artifacts @@ -346,7 +346,7 @@ jobs: # - The workflow_run trigger executes the workflow file from the default branch, whereas this approach # allows for running the workflow file from the same branch as the ci workflow. - name: Check-out source code - uses: actions/checkout@v6 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 - run: | # Use branch output from build job if available, otherwise fall back to GITHUB_REF if [ -n "${BUILD_BRANCH}" ]; then @@ -372,17 +372,17 @@ jobs: id-token: write steps: - name: Check-out source code - uses: actions/checkout@v6 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 - name: Download artifacts - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts name: combined-artifacts - name: PROD - Prepare release PR if: contains(github.ref, 'refs/heads/rel/') - uses: fortify/3rdparty-actions/actions/googleapis/release-please-action/v5@main + uses: fortify/shared-github/actions/3rdparty/googleapis/release-please-action/v5@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: skip-github-release: true target-branch: ${{ github.ref_name }} @@ -399,7 +399,7 @@ jobs: RELEASE_BRANCH: ${{ needs.build.outputs.branch }} - name: DEV - Update ${{ needs.build.outputs.release_tag }} tag - uses: fortify/.github/.github/actions/update-tag@main + uses: fortify/shared-github/actions/fortify/update-tag@5ad1afec0f56a21b03f0887eea35505cb6a66554 if: needs.build.outputs.do_dev_release with: tag_name: ${{ needs.build.outputs.release_tag }} @@ -432,16 +432,16 @@ jobs: contents: write steps: - name: Check-out source code - uses: actions/checkout@v6 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 - name: Download artifacts - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./artifacts name: combined-artifacts - name: PROD - Publish ${{ matrix.semantic_version }} tag - uses: fortify/.github/.github/actions/update-tag@main + uses: fortify/shared-github/actions/fortify/update-tag@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: tag_name: ${{ matrix.semantic_version }} github_token: ${{ secrets.GITHUB_TOKEN }} @@ -488,12 +488,12 @@ jobs: contents: write steps: - name: Check-out existing docs from gh-pages branch - uses: actions/checkout@v6 + uses: fortify/shared-github/actions/3rdparty/actions/checkout/v6@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: ref: gh-pages - name: Download artifacts - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: path: ./tmp name: build-output diff --git a/.github/workflows/fortify-analysis.yml b/.github/workflows/fortify-analysis.yml index d46f41641fc..54f3e344056 100644 --- a/.github/workflows/fortify-analysis.yml +++ b/.github/workflows/fortify-analysis.yml @@ -15,12 +15,12 @@ permissions: jobs: check-duplicate-run: - uses: fortify/.github/.github/workflows/check-duplicate-run.yml@main + uses: fortify/shared-github/.github/workflows/reusable-check-duplicate-run.yml@5ad1afec0f56a21b03f0887eea35505cb6a66554 FoD-Scan: needs: check-duplicate-run if: needs.check-duplicate-run.outputs.should_skip != 'true' - uses: fortify/.github/.github/workflows/fortify-analysis.yml@main + uses: fortify/shared-github/.github/workflows/reusable-fortify-analysis.yml@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: java-version: '17' secrets: inherit diff --git a/.github/workflows/update-repo-docs.yml b/.github/workflows/update-repo-docs.yml index 42e26d03c97..26f3c503e2e 100644 --- a/.github/workflows/update-repo-docs.yml +++ b/.github/workflows/update-repo-docs.yml @@ -12,4 +12,5 @@ jobs: update-repo-docs: permissions: contents: write - uses: fortify/shared-doc-resources/.github/workflows/update-repo-docs.yml@main \ No newline at end of file + pull-requests: write + uses: fortify/shared-github/.github/workflows/reusable-update-repo-docs.yml@5ad1afec0f56a21b03f0887eea35505cb6a66554 diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index 50e013f1dab..3bd798988b7 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -44,13 +44,13 @@ jobs: steps: # Java is required for running the functional tests - name: Setup Java - uses: actions/setup-java@v5 + uses: fortify/shared-github/actions/3rdparty/actions/setup-java/v5@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: distribution: 'temurin' java-version: '17' - name: Download release artifact - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: run-id: ${{ inputs.runId }} name: ${{ matrix.type == 'jar' && 'fcli-jar' || matrix.os == 'ubuntu-latest' && 'fcli-linux' || matrix.os == 'windows-latest' && 'fcli-windows' || 'fcli-mac' }} @@ -58,7 +58,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Download fcli-ftest.jar - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: run-id: ${{ inputs.runId }} name: fcli-ftest-jar @@ -93,7 +93,7 @@ jobs: - name: Publish test logs if: failure() - uses: actions/upload-artifact@v7 + uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: name: test-log-${{ matrix.os }}-${{ matrix.type }} path: test-*.log @@ -111,13 +111,13 @@ jobs: steps: # Java is required for running the functional tests - name: Setup Java - uses: actions/setup-java@v5 + uses: fortify/shared-github/actions/3rdparty/actions/setup-java/v5@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: distribution: 'temurin' java-version: '17' - name: Download fcli-linux.tgz - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: run-id: ${{ inputs.runId }} name: fcli-linux @@ -125,7 +125,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Download fcli-ftest.jar - uses: actions/download-artifact@v8 + uses: fortify/shared-github/actions/3rdparty/actions/download-artifact/v8@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: run-id: ${{ inputs.runId }} name: fcli-ftest-jar @@ -164,7 +164,7 @@ jobs: - name: Publish test logs if: always() - uses: actions/upload-artifact@v7 + uses: fortify/shared-github/actions/3rdparty/actions/upload-artifact/v7@5ad1afec0f56a21b03f0887eea35505cb6a66554 with: name: test-log-${{ matrix.type }} path: test-*.log diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1f12ca48152..aa0ff11f072 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.23.3" + ".": "3.26.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 17879e3a19a..6ffeb13f51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # Changelog +## [3.26.1](https://github.com/fortify/fcli/compare/v3.26.0...v3.26.1) (2026-09-22) + + +### Bug Fixes + +* Skip unused FoD vulnerability filters for large-tenant performance ([80c6b1b](https://github.com/fortify/fcli/commit/80c6b1bd24de0c51ee6a9bfe4f919f6af9b83f2d)) + +## [3.26.0](https://github.com/fortify/fcli/compare/v3.25.0...v3.26.0) (2026-09-18) + + +### Features + +* SSC `ci` action: Add support for running DAST scans using existing DAST settings in SSC, and optionally waiting for DAST scan completion ([3bc78d7](https://github.com/fortify/fcli/commit/3bc78d72b8ddcea7a21080113ecea6cca8b0319c)) + + +### Bug Fixes + +* `fcli aviator ssc apply-remediations`: Improve handling of non-writable files ([270a143](https://github.com/fortify/fcli/commit/270a143777ec57ebae2acc951781657dd914aac3)) +* `fcli aviator ssc apply-remediations`: Improve handling of overlapping remediations ([f4a46aa](https://github.com/fortify/fcli/commit/f4a46aa2d04bbcbfd6c865888c52b9ff519f9cce)) +* `fcli aviator ssc apply-remediations`: More accurate application of auto-remediations ([f4a46aa](https://github.com/fortify/fcli/commit/f4a46aa2d04bbcbfd6c865888c52b9ff519f9cce)) +* `fcli fod aviator apply-remediations`: Improve handling of non-writable files ([270a143](https://github.com/fortify/fcli/commit/270a143777ec57ebae2acc951781657dd914aac3)) +* `fcli fod aviator apply-remediations`: Improve handling of overlapping remediations ([f4a46aa](https://github.com/fortify/fcli/commit/f4a46aa2d04bbcbfd6c865888c52b9ff519f9cce)) +* `fcli fod aviator apply-remediations`: More accurate application of auto-remediations ([f4a46aa](https://github.com/fortify/fcli/commit/f4a46aa2d04bbcbfd6c865888c52b9ff519f9cce)) +* `fcli fod`: Change id fields from `int` to `long` to avoid potential integer overflows ([93f166d](https://github.com/fortify/fcli/commit/93f166d09c0c0c033f25e458e3a26de1c9052001)) +* `fcli ssc ac create-local-user`: The `--roles` option now properly accepts role names as per option description ([05bf6c4](https://github.com/fortify/fcli/commit/05bf6c4c89343a0c31af3a5269b4396866c399ef)) +* `fcli ssc ac update-local-user`: The `--roles`, `--add-roles`, and `--rm-roles` options now properly accept role names as per option description ([05bf6c4](https://github.com/fortify/fcli/commit/05bf6c4c89343a0c31af3a5269b4396866c399ef)) +* Various MCP & Aviator security fixes ([99fa798](https://github.com/fortify/fcli/commit/99fa798125fada823d64265470ddf0f86fba1c21)) + +## [3.25.0](https://github.com/fortify/fcli/compare/v3.24.0...v3.25.0) (2026-08-31) + + +### Features + +* `fcli aviator ssc audit`: Add `--force-reaudit` to re-audit Aviator-processed issues without overwriting human triage ([840ee90](https://github.com/fortify/fcli/commit/840ee90beed814507f06c01c0e456f1b610f4234)) + + +### Bug Fixes + +* `fcli aviator ssc apply-remediations`: Skip remediations when source context matches multiple locations ([840ee90](https://github.com/fortify/fcli/commit/840ee90beed814507f06c01c0e456f1b610f4234)) + +## [3.24.0](https://github.com/fortify/fcli/compare/v3.23.3...v3.24.0) (2026-08-21) + + +### Features + +* `fcli ai-assist mcp start-stdio`: Add `--server-name` option to configure custom MCP server name, defaulting to either `fcli-` or `fcli` (depending on whether `--module` is specified) ([65ddc65](https://github.com/fortify/fcli/commit/65ddc653789849de6d5703c5b40a5058df95c1c7)) +* `fcli aviator ssc apply-remediations`: Add `--source-encodings` option for source file decoding and encoding ([e63c814](https://github.com/fortify/fcli/commit/e63c814e531c5aab8748a145dff4000c0c21913e)) +* `fcli aviator ssc audit`: Add `--source-encodings` option for source decoding and audit skip reporting ([e63c814](https://github.com/fortify/fcli/commit/e63c814e531c5aab8748a145dff4000c0c21913e)) +* `fcli fod aviator apply-remediations`: Add `--source-encodings` option for source file decoding and encoding ([e63c814](https://github.com/fortify/fcli/commit/e63c814e531c5aab8748a145dff4000c0c21913e)) +* `fcli fod issue get`: New command for retrieving issue data for a single issue ([f4f5bf1](https://github.com/fortify/fcli/commit/f4f5bf1498a4ff79ba4c8db38a317508373f43ec)) +* `fcli fod session login`: Add `--code` and `--totp` options to support MFA-based logins (resolves [#1059](https://github.com/fortify/fcli/issues/1059)) ([cf882bb](https://github.com/fortify/fcli/commit/cf882bb41eafa92f66e570facad1fb0a008a78f5)) +* `fcli ssc issue get`: New command for retrieving issue data for a single issue ([f4f5bf1](https://github.com/fortify/fcli/commit/f4f5bf1498a4ff79ba4c8db38a317508373f43ec)) + + +### Bug Fixes + +* `fcli aviator ssc apply-remediations`: apply source edits using the encoding declared in `audit.fvdl` instead of assuming UTF-8 ([71919f7](https://github.com/fortify/fcli/commit/71919f76a58fa21ba029b03659ee7202c010e16f)) +* `fcli aviator ssc apply-remediations`: Atomic Remediation with Exception Handling and Compliance Reporting ([92bcbe1](https://github.com/fortify/fcli/commit/92bcbe1aa035ec29a807075333ce3603545ca55d)) +* `fcli aviator ssc audit`: Upload only issues written in the current audit run ([#1063](https://github.com/fortify/fcli/issues/1063)) ([97e587a](https://github.com/fortify/fcli/commit/97e587ab0cd730b3fff76c8d4f1963cb63e5f2d0)) +* `fcli fod aviator apply-remediations`: apply source edits using the encoding declared in `audit.fvdl` instead of assuming UTF-8 ([71919f7](https://github.com/fortify/fcli/commit/71919f76a58fa21ba029b03659ee7202c010e16f)) +* `fcli fod aviator apply-remediations`: Atomic Remediation with Exception Handling and Compliance Reporting ([92bcbe1](https://github.com/fortify/fcli/commit/92bcbe1aa035ec29a807075333ce3603545ca55d)) +* `fcli fod dast-scan start`: Restore `--vpn` support and 422 active-scan fallback (lost in Aviator 26.2 merge) ([fbf4f7b](https://github.com/fortify/fcli/commit/fbf4f7bcbd20f24e56445a2b70adc056d7e115fc)) +* `fcli fod dast-scan start`: Restore fix to allow DAST scan to start if no prior scans exist (lost in Aviator 26.2 merge) (fixes [#1068](https://github.com/fortify/fcli/issues/1068)) ([fbf4f7b](https://github.com/fortify/fcli/commit/fbf4f7bcbd20f24e56445a2b70adc056d7e115fc)) +* `fcli fod microservice create`: Re-add non-microservice application guard (lost in Aviator 26.2 merge) ([fbf4f7b](https://github.com/fortify/fcli/commit/fbf4f7bcbd20f24e56445a2b70adc056d7e115fc)) + ## [3.23.3](https://github.com/fortify/fcli/compare/v3.23.2...v3.23.3) (2026-07-15) diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java index 0301fcf0a59..8c82de69216 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/cli/cmd/AiAssistMCPStartStdioCommand.java @@ -93,6 +93,7 @@ public class AiAssistMCPStartStdioCommand extends AbstractRunnableCommand implem @Option(names={"--module", "-m"}, required = false) private McpModule module; @DisableTest(TestType.MULTI_OPT_PLURAL_NAME) @Option(names={"--import"}, split=",") private List importFiles; + @Option(names={"--server-name"}) private String serverName; @Option(names={"--work-threads"}, defaultValue="10") private int workThreads; @Option(names={"--progress-threads"}, defaultValue="4") private int progressThreads; @Option(names={"--job-safe-return"}, defaultValue="25s") private String jobSafeReturnPeriod; @@ -111,6 +112,13 @@ public Integer call() throws Exception { if (module == null && (importFiles == null || importFiles.isEmpty())) { throw new FcliSimpleException("At least one of --module or --import must be specified"); } + if (serverName == null) { + if (module != null) { + serverName = "fcli-" + module.toString(); + } else { + serverName = "fcli"; + } + } var rawOut = StdioHelper.getRawOut(); var rawErr = StdioHelper.getRawErr(); // Redirect progress output to stderr to prevent progress messages @@ -147,7 +155,7 @@ public Integer call() throws Exception { // Use rawOut to bypass the delegation/masking stack, ensuring // MCP JSON-RPC responses are never corrupted by masking var serverBuilder = McpServer.sync(new StdioServerTransportProvider(new JacksonMcpJsonMapper(objectMapper), wrappedIn, rawOut)) - .serverInfo("fcli", FcliBuildProperties.INSTANCE.getFcliVersion()) + .serverInfo(serverName, FcliBuildProperties.INSTANCE.getFcliVersion()) .requestTimeout(Duration.ofSeconds(120)) .instructions(""" - For tools that accept a --*-session option and user hasn't asked for a specific \ diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java index 059de4fa1c3..820744522d1 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/arg/MCPToolArgHandlerActionOption.java @@ -59,7 +59,21 @@ public String getFcliCmdArgs(Map toolArgs) { if ( values.isEmpty() ) { return ""; } - return String.format("\"%s=%s\"", name, String.join(",", values)); + // Escape embedded quotes to prevent command injection via quote breakout + var escapedValues = values.stream() + .map(MCPToolArgHandlerActionOption::escapeQuotes) + .toList(); + return String.format("\"%s=%s\"", name, String.join(",", escapedValues)); + } + + /** + * Escapes embedded double quotes by prefixing with backslash. + * Prevents command injection when this value is used in a quoted command string. + * @param value The unescaped value + * @return The value with embedded quotes escaped (e.g., " becomes \") + */ + private static String escapeQuotes(String value) { + return value.replace("\"", "\\\""); } private static Stream streamValueElements(Object value) { diff --git a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java index 8a37a37baa0..c7bd5d5059f 100644 --- a/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java +++ b/fcli-core/fcli-ai-assist/src/main/java/com/fortify/cli/ai_assist/mcp/helper/http/MCPServerHttpSessionDescriptorResolver.java @@ -41,7 +41,7 @@ import com.fortify.cli.fod._common.session.helper.oauth.FoDOAuthHelper; import com.fortify.cli.fod._common.session.helper.oauth.FoDTokenCreateResponse; import com.fortify.cli.fod._common.session.helper.oauth.IFoDClientCredentials; -import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials; +import com.fortify.cli.fod._common.session.helper.oauth.impl.BasicFoDUserCredentials; import com.fortify.cli.ssc._common.session.cli.mixin.SSCAndScanCentralSessionLoginOptions.SSCAndScanCentralUrlConfigOptions.SSCComponentDisable; import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralCredentialsConfig; import com.fortify.cli.ssc._common.session.helper.ISSCAndScanCentralUrlConfig; @@ -348,11 +348,11 @@ private FoDTokenCreateResponse createFoDTokenResponse(ParsedAuthorization auth, try { return FoDOAuthHelper.createToken( urlConfig, - new HttpMcpFoDUserCredentials( - auth.fodTenant(), - auth.fodUser(), - pwd - ), + BasicFoDUserCredentials.builder() + .tenant(auth.fodTenant()) + .user(auth.fodUser()) + .password(pwd) + .build(), DEFAULT_FOD_SCOPES ); } finally { @@ -380,33 +380,6 @@ public String getClientSecret() { } } - private static final class HttpMcpFoDUserCredentials implements IFoDUserCredentials { - private final String tenant; - private final String user; - private final char[] password; - - private HttpMcpFoDUserCredentials(String tenant, String user, char[] password) { - this.tenant = tenant; - this.user = user; - this.password = password; - } - - @Override - public String getUser() { - return user; - } - - @Override - public char[] getPassword() { - return password; - } - - @Override - public String getTenant() { - return tenant; - } - } - private static final class HttpMcpSscUrlConfig implements ISSCAndScanCentralUrlConfig { private final MCPServerHttpConfig.SscConfig config; diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties index 30b5794df96..0d50f0afd0d 100644 --- a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties @@ -81,6 +81,7 @@ fcli.ai-assist.mcp.start-stdio.progress-threads = Number of threads used for upd fcli.ai-assist.mcp.start-stdio.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. fcli.ai-assist.mcp.start-stdio.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). fcli.ai-assist.mcp.start-stdio.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. +fcli.ai-assist.mcp.start-stdio.server-name = Custom name for this MCP server instance. If not specified, defaults to 'fcli-' when --module is provided, or 'fcli-imported-functions' when only --import is provided. fcli.ai-assist.mcp.start-http.usage.header = Start fcli HTTP MCP server. fcli.ai-assist.mcp.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. Generate a sample config file with 'fcli ai-assist mcp create-http-config --type ' and customize the generated YAML for your environment. The server listens for MCP POST requests on the /mcp endpoint. Each request must include the product-specific auth header as semicolon-separated key=value pairs; escape literal '\\', ';', or '=' characters as '\\\\', '\\;', or '\\='.\ diff --git a/fcli-core/fcli-app/build.gradle.kts b/fcli-core/fcli-app/build.gradle.kts index 12c38c2a221..3cbddfca67c 100644 --- a/fcli-core/fcli-app/build.gradle.kts +++ b/fcli-core/fcli-app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { // Inter-project dependencies val refs = listOf( "fcliCommonRef","fcliCommonThirdpartyRef","fcliCommonCiRef","fcliCommonActionRef","fcliCommonToolRef", - "fcliActionRef","fcliAiAssistRef","fcliAviatorRef","fcliConfigRef", + "fcliActionRef","fcliAiAssistRef","fcliAviatorCommonRef","fcliAviatorRef","fcliConfigRef", "fcliFoDRef","fcliSSCRef","fcliSCSastRef","fcliSCDastRef", "fcliToolRef","fcliLicenseRef","fcliUtilRef" ) diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json index 8a1c339eb0d..fa3fa4e7f0a 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/grpc/reflect-config.json @@ -600,7 +600,7 @@ "name":"com.fortify.cli.aviator.config.TagMappingConfig", "allDeclaredFields":true, "queryAllPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + "methods":[{"name":"","parameterTypes":[] }, {"name":"setDast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Mapping", @@ -631,6 +631,13 @@ { "name":"com.fortify.cli.aviator.config.TagMappingConfig$MappingCustomizer" }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping", + "allDeclaredConstructors":true, + "allDeclaredFields":true, + "queryAllPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Result", "allDeclaredConstructors": true, diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json index d42d5a366a6..421d583441c 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/yaml/reflect-config.json @@ -43,7 +43,7 @@ "name":"com.fortify.cli.aviator.config.TagMappingConfig", "allDeclaredFields":true, "queryAllPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + "methods":[{"name":"","parameterTypes":[] }, {"name":"setDast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSast","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Mapping", @@ -74,6 +74,13 @@ { "name":"com.fortify.cli.aviator.config.TagMappingConfig$MappingCustomizer" }, + { + "name":"com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping", + "allDeclaredConstructors":true, + "allDeclaredFields":true, + "queryAllPublicMethods":true, + "methods":[{"name":"","parameterTypes":[] }, {"name":"setMapping","parameterTypes":["com.fortify.cli.aviator.config.TagMappingConfig$Mapping"] }, {"name":"setSuppression_exclusions","parameterTypes":["java.util.List"] }, {"name":"setTag_id","parameterTypes":["java.lang.String"] }] + }, { "name":"com.fortify.cli.aviator.config.TagMappingConfig$Result", "allDeclaredConstructors": true, diff --git a/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml b/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml index a3d3f360c6d..d962bd35d9a 100644 --- a/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml +++ b/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml @@ -353,7 +353,7 @@ formatters: \n* Debug logging for Scancentral Client is disabled; pass --debug on the fcli invocation to enable debug logging. - title: Scan Execution overview: >- - Configure SAST and Debricked scan execution and waiting behavior for ${productNames.ssc}. + Configure SAST, DAST, and Debricked scan execution and waiting behavior for ${productNames.ssc}. vars: - names: DO_SAST_SCAN\nSAST_SCAN_EXTRA_OPTS desc: >- @@ -363,6 +363,21 @@ formatters: the fcliCmd:fcli sc-sast scan start: command, for example to request a scan completion email notification. Note that these environment variables only control the submission of the scan request; see the information below for details on waiting for the scan to complete. + - names: DAST_SETTINGS\nDO_DAST_SCAN\nDAST_SCAN_NAME\nDAST_SCAN_EXTRA_OPTS + desc: >- + The fcli `ci` action can run a ScanCentral DAST scan using pre-existing scan settings that have + already been configured for the target application version; this action does not create or modify + scan settings. Set `DAST_SETTINGS` to the CI/CD token or numeric id of the SC-DAST scan settings to + use (see the `cicdToken` and `id` fields returned by fcliCmd:fcli sc-dast scan-settings list:). + Unlike the SAST scan, the DAST scan is disabled by default, even if `DAST_SETTINGS` is configured, + as `DAST_SETTINGS` may be a permanent CI/CD variable while DAST scans are often only meant to run + on demand. Set `DO_DAST_SCAN` to `true` to enable the DAST scan for a given pipeline run (in + addition to `DAST_SETTINGS` being set). By default, the scan name is auto-generated from the + application version and run id (with `:`/`/` characters replaced by `-`); set `DAST_SCAN_NAME` to + use a custom scan name instead. The `DAST_SCAN_EXTRA_OPTS` environment variable can be used + to provide additional options to the fcliCmd:fcli sc-dast scan start: command. Note that these + environment variables only control the submission of the scan request; see the information below + for details on waiting for the scan to complete. - names: DO_DEBRICKED_SCAN\nDEBRICKED_SCAN_EXTRA_OPTS\nDEBRICKED_ACCESS_TOKEN\nDEBRICKED_VERSION\nDEBRICKED_HOME desc: >- The fcli `ci` action supports running a Debricked Software Composition Analysis (SCA) scan, which @@ -377,13 +392,19 @@ formatters: \n * `latest` to use the latest available Debricked CLI version \n * `auto` (default) to use a pre-installed version if available, otherwise installs latest \n* If neither `DEBRICKED_VERSION` nor `DEBRICKED_HOME` are set, defaults to `auto` behavior. - - names: DO_WAIT\nDO_SAST_WAIT\nSAST_WAIT_EXTRA_OPTS\nDEBRICKED_WAIT_EXTRA_OPTS + - names: DO_WAIT\nDO_SAST_WAIT\nSAST_WAIT_EXTRA_OPTS\nDO_DAST_WAIT\nDAST_WAIT_EXTRA_OPTS\nDEBRICKED_WAIT_EXTRA_OPTS desc: >- By default, the fcli `ci` action will wait for all started scans to complete; set `DO_WAIT` to `false` to just kick off any configured scans without waiting for completion. Note that doing so will skip any post-scan tasks. The `SAST_WAIT_EXTRA_OPTS` environment variable can be used to pass - extra options to the fcliCmd:fcli sc-sast scan wait-for: command, and similarly, the `DEBRICKED_WAIT_EXTRA_OPTS` - environment variable can be used to pass extra options to the fcliCmd:fcli ssc artifact wait-for: command. + extra options to the fcliCmd:fcli sc-sast scan wait-for: command, and similarly, the `DAST_WAIT_EXTRA_OPTS` + environment variable can be used to pass extra options to the fcliCmd:fcli sc-dast scan wait-for: command, + and the `DEBRICKED_WAIT_EXTRA_OPTS` environment variable can be used to pass extra options to the + fcliCmd:fcli ssc artifact wait-for: command.\n\nUnlike other scan types, waiting for DAST scans is disabled + by default (as DAST scans are often long-running and only run on demand); set `DO_DAST_WAIT` to `true` to + wait for DAST scan completion.\n\nDAST scans can take considerably longer than SAST scans, potentially + exceeding CI/CD job time limits (for example on GitHub Actions or GitLab CI); if the default wait timeout + doesn't suffice, pass a `--timeout` option through `DAST_WAIT_EXTRA_OPTS`. - title: Post-Scan Actions overview: > Configure post-scan tasks including Aviator auditing, application version summaries, policy checks, diff --git a/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java b/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java index 671e032646f..c61a672e005 100644 --- a/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java +++ b/fcli-core/fcli-app/src/test/java/com/fortify/cli/NativeReflectConfigTest.java @@ -38,6 +38,7 @@ class NativeReflectConfigTest { private static final List TAG_MAPPING_NESTED_CLASSES = List.of( "com.fortify.cli.aviator.config.TagMappingConfig$SuppressionExclusion", "com.fortify.cli.aviator.config.TagMappingConfig$Mapping", + "com.fortify.cli.aviator.config.TagMappingConfig$ProductMapping", "com.fortify.cli.aviator.config.TagMappingConfig$Tier", "com.fortify.cli.aviator.config.TagMappingConfig$Result"); diff --git a/fcli-core/fcli-aviator-common/build.gradle.kts b/fcli-core/fcli-aviator-common/build.gradle.kts index f68eabca1cb..5049e10a888 100644 --- a/fcli-core/fcli-aviator-common/build.gradle.kts +++ b/fcli-core/fcli-aviator-common/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("fcli.module-conventions") id("java-library") + id("java-test-fixtures") id("com.google.protobuf") } @@ -13,6 +14,7 @@ tasks.withType().configureEach { dependsOn("generateProto") } dependencies { implementation(project(":fcli-core:fcli-common-core")) + testFixturesImplementation(project(":fcli-core:fcli-common-core")) implementation("org.yaml:snakeyaml:2.3") // JAXB for XML object marshalling (used in FVDLProcessor legacy parser) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/converter/SourceDecoderConverter.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/converter/SourceDecoderConverter.java new file mode 100644 index 00000000000..083d74bbfe1 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/converter/SourceDecoderConverter.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.cli.converter; + +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; + +import picocli.CommandLine.ITypeConverter; +import picocli.CommandLine.TypeConversionException; + +/** + * Picocli adapter: maps a single {@code --source-encodings} token to an + * {@link ISourceDecoder} via the domain factory {@link SourceDecoders}. + */ +public final class SourceDecoderConverter implements ITypeConverter { + @Override + public ISourceDecoder convert(String value) { + try { + return SourceDecoders.fromToken(value); + } catch (IllegalArgumentException e) { + // Covers blank tokens, IllegalCharsetNameException, UnsupportedCharsetException + throw new TypeConversionException( + e.getMessage() != null ? e.getMessage() : "Invalid source encoding '" + value + "'"); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java new file mode 100644 index 00000000000..ed230b8f17f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/AbstractApplyRemediationsOptionsMixin.java @@ -0,0 +1,93 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.cli.mixin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.aviator._common.remediations_cache.IApplyRemediationsOptions; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Getter; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Abstract base for apply-remediations options. Provides shared CLI options and validation template method. + * Product-specific subclasses declare source-selection mixins and implement validation hooks. + */ +@Getter +public abstract class AbstractApplyRemediationsOptionsMixin implements IApplyRemediationsOptions { + @Mixin + private SourceEncodingsMixin sourceEncodingsMixin = new SourceEncodingsMixin(); + + @Option(names = {"--source-dir"}) + private String sourceCodeDirectory = System.getProperty("user.dir"); + + @Option(names = {"--issue-ids"}, split = ",") + private List issueIds; + + @Option(names = {"--preview"}) + private boolean previewMode = false; + + public RemediationExecutionMode executionMode() { + return previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY; + } + + @Override + public ISourceDecoder getSourceDecoder() { + return sourceEncodingsMixin.getSourceDecoder(); + } + + /** + * Validates all options by calling validation hooks in order. + * Template method: ensures consistent validation sequence across SSC and FoD. + */ + @Override + public final void validate() { + validateSourceSelection(); + validateSourceDir(); + validateIssueIdsConstraints(); + } + + /** Hook for product-specific source selection validation (--from-cache vs online selection). */ + protected abstract void validateSourceSelection(); + + /** Hook to determine if --from-cache is selected (needed for --issue-ids constraint validation). */ + protected abstract boolean isCacheMode(); + + private void validateSourceDir() { + FcliSimpleException.throwIf( + StringUtils.isBlank(sourceCodeDirectory), + "--source-dir must specify a valid directory path"); + Path path = Path.of(sourceCodeDirectory); + FcliSimpleException.throwIf( + !Files.exists(path) || !Files.isDirectory(path), + "--source-dir path does not exist or is not a directory: %s", sourceCodeDirectory); + FcliSimpleException.throwIf( + !Files.isReadable(path), + "--source-dir path is not accessible: %s", sourceCodeDirectory); + } + + private void validateIssueIdsConstraints() { + FcliSimpleException.throwIf( + issueIds != null && !issueIds.isEmpty() && !isCacheMode(), + "--issue-ids can only be used with --from-cache; " + + "create a cache with download-remediations-cache and rerun with --from-cache"); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/SourceEncodingsMixin.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/SourceEncodingsMixin.java new file mode 100644 index 00000000000..e58f0e86d0d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/cli/mixin/SourceEncodingsMixin.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.cli.mixin; + +import java.util.List; + +import com.fortify.cli.aviator._common.cli.converter.SourceDecoderConverter; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; + +import lombok.Getter; +import picocli.CommandLine.Option; + +/** + * Shared {@code --source-encodings} option for Aviator commands that decode + * (and optionally re-encode) source files from an FPR. + */ +public class SourceEncodingsMixin { + @Getter + @Option(names = {"--source-encodings"}, + split = ",", + converter = SourceDecoderConverter.class, + defaultValue = SourceDecoders.DEFAULT_SOURCE_ENCODINGS, + paramLabel = "encoding", + descriptionKey = "fcli.aviator.source-encodings") + private List sourceDecoders; + + /** + * Returns a single decoder that tries the configured candidates in order. + */ + public ISourceDecoder getSourceDecoder() { + return SourceDecoders.of(sourceDecoders); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java index 6787f8a4d0f..e231af243f4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/config/AviatorConfigManager.java @@ -91,4 +91,8 @@ public TagMappingConfig getDefaultTagMappingConfig() { } return defaultTagMappingConfig; } + + public TagMappingConfig getDefaultDastTagMappingConfig() { + return getDefaultTagMappingConfig(); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java new file mode 100644 index 00000000000..07cb8e97da8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/exception/UnsupportedAviatorUrlSchemeException.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.exception; + +/** + * User-facing rejection of a non-{@code https} Aviator target scheme. + *

+ * Typed so diagnose can map endpoint UX without parsing exception message text. + */ +public class UnsupportedAviatorUrlSchemeException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + public static final String STAGE_SUMMARY = "Unsupported URL scheme"; + public static final String STAGE_GUIDANCE = "Use a supported Aviator URL (https://host[:port])"; + + private final String scheme; + private final String providedUrl; + + public UnsupportedAviatorUrlSchemeException(String scheme, String providedUrl) { + super(STAGE_SUMMARY+" '"+scheme+"'. "+STAGE_GUIDANCE+". Provided URL: "+providedUrl); + this.scheme = scheme; + this.providedUrl = providedUrl; + } + + public String getScheme() { + return scheme; + } + + public String getProvidedUrl() { + return providedUrl; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java new file mode 100644 index 00000000000..d866daf1f43 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/output/cli/cmd/AbstractAviatorApplyRemediationsCommand.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.output.cli.cmd; + +import java.util.Set; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fortify.cli.aviator._common.cli.mixin.AbstractApplyRemediationsOptionsMixin; +import com.fortify.cli.aviator._common.remediations_cache.IRemediationsFprSource; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper; +import com.fortify.cli.aviator._common.remediations_cache.RemediationsApplyHelper.ApplyResult; +import com.fortify.cli.aviator._common.util.AviatorIssueIdFilterUtils; +import com.fortify.cli.aviator.config.AviatorLoggerImpl; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.progress.cli.mixin.ProgressWriterFactoryMixin; +import com.fortify.cli.common.progress.helper.IProgressWriter; + +import lombok.Getter; +import picocli.CommandLine.Mixin; + +/** + * Abstract base command for applying remediations. Orchestrates validation, FPR source acquisition, + * and remediation application. Product-specific subclasses provide options mixin and implement hooks. + */ +public abstract class AbstractAviatorApplyRemediationsCommand extends AbstractOutputCommand + implements IJsonNodeSupplier { + + @Getter @Mixin private OutputHelperMixins.DetailsNoQuery outputHelper; + @Mixin private ProgressWriterFactoryMixin progressWriterFactoryMixin; + + /** Subclasses declare their product-specific options mixin (SSC or FoD). */ + protected abstract AbstractApplyRemediationsOptionsMixin getApplyOptions(); + + @Override + public final JsonNode getJsonNode() { + AbstractApplyRemediationsOptionsMixin applyOptions = getApplyOptions(); + applyOptions.validate(); + Set issueIdFilter = AviatorIssueIdFilterUtils.normalizeIssueIds(applyOptions.getIssueIds()); + try (IProgressWriter progressWriter = progressWriterFactoryMixin.create()) { + AviatorLoggerImpl logger = new AviatorLoggerImpl(progressWriter); + try (IRemediationsFprSource fprSource = openFprSource(logger, progressWriter)) { + ApplyResult result = RemediationsApplyHelper.apply(fprSource, applyOptions, issueIdFilter, logger); + return buildResultNode(fprSource, result, issueIdFilter); + } + } + } + + protected abstract IRemediationsFprSource openFprSource(AviatorLoggerImpl logger, IProgressWriter progressWriter); + + protected abstract JsonNode buildResultNode(IRemediationsFprSource fprSource, ApplyResult result, Set issueIdFilter); + + @Override + public final boolean isSingular() { return true; } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java new file mode 100644 index 00000000000..e6b90a040ff --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/CacheRemediationsFprSource.java @@ -0,0 +1,82 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; +import java.util.List; + +import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheReader.ResolvedFpr; +import com.fortify.cli.common.exception.FcliBugException; + +/** + * Offline remediations source: ordered FPR paths from a remediations cache zip. + * Product identity (and thus artifact vs release id) comes from the validated entry model. + */ +public final class CacheRemediationsFprSource implements IRemediationsFprSource { + private final RemediationsCacheReader reader; + + private CacheRemediationsFprSource(RemediationsCacheReader reader) { + this.reader = reader; + } + + public static CacheRemediationsFprSource open(Path cacheZip, String expectedProduct) { + RemediationsCacheReader reader = RemediationsCacheReader.open(cacheZip); + try { + reader.requireProduct(expectedProduct); + return new CacheRemediationsFprSource(reader); + } catch (RuntimeException e) { + reader.close(); + throw e; + } + } + + public RemediationsCacheReader reader() { + return reader; + } + + @Override + public void forEachEntry(EntryAction action) { + List units = reader.getOrderedResolvedFprs(); + int total = units.size(); + for (int i = 0; i < total; i++) { + ResolvedFpr unit = units.get(i); + RemediationsCacheEntry entry = unit.entry(); + String label = entry.getPath() != null + ? entry.getPath() + : unit.fprPath().getFileName().toString(); + if (!action.accept(unit.fprPath(), label, productId(entry), i + 1, total)) { + break; + } + } + } + + /** + * Id for progress/result lists: artifactId (SSC) or releaseId (FoD). + * Entries are validated before resolve, so exactly one product block is present. + */ + private static String productId(RemediationsCacheEntry entry) { + if (entry.getSscData() != null) { + return entry.getSscData().getArtifactId(); + } + if (entry.getFodData() != null) { + return entry.getFodData().getReleaseId(); + } + throw new FcliBugException( + "Remediations cache entry missing product data after validation: " + entry.getPath()); + } + + @Override + public void close() { + reader.close(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java new file mode 100644 index 00000000000..1fccbfb1a0d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IApplyRemediationsOptions.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.util.List; + +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; + +/** Abstraction over the shared apply-remediations CLI options, allowing RemediationsApplyHelper + * to remain independent of concrete Picocli types. */ +public interface IApplyRemediationsOptions { + String getSourceCodeDirectory(); + List getIssueIds(); + ISourceDecoder getSourceDecoder(); + boolean isPreviewMode(); + void validate(); +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java new file mode 100644 index 00000000000..150c8cfdcea --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/IRemediationsFprSource.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; + +/** + * Source of audited FPR files for apply-remediations (cache zip or online download). + * Implementations own any resources (zip FS, temp files) until {@link #close()}. + * + *

Paths passed to {@link EntryAction#accept} are valid only for the duration of that call + * (online sources may download to a temp file and delete it afterward). + */ +public interface IRemediationsFprSource extends AutoCloseable { + + /** + * Invokes {@code action} for each FPR in order. Returning {@code false} from the action + * stops iteration early (for example when an issue-id filter is exhausted). + */ + void forEachEntry(EntryAction action); + + @Override + void close(); + + @FunctionalInterface + interface EntryAction { + /** + * @param fprPath host-readable FPR path (zip entry or temp file) + * @param label human-readable label for progress/logs + * @param id artifact id, release id, or empty + * @param index 1-based index + * @param total total entries + * @return {@code false} to stop processing further entries + */ + boolean accept(Path fprPath, String label, String id, int index, int total); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java new file mode 100644 index 00000000000..0ea1c0b051d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsApplyHelper.java @@ -0,0 +1,150 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.util.AviatorRemediationMetricsHelper; +import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessingOptions; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliTechnicalException; + +import lombok.extern.slf4j.Slf4j; + +/** + * Single apply-remediations loop for any {@link IRemediationsFprSource} + * (cache zip entries or online downloads). Soft-skips on {@link AviatorSimpleException}. + */ +@Slf4j +public final class RemediationsApplyHelper { + private RemediationsApplyHelper() {} + + public record ApplyResult( + List processedEntries, + List processedIds, + int skipped, + List metrics) {} + + /** + * Applies or previews remediations for each FPR source entry until done or the issue-id filter is exhausted. + * Caller owns {@code fprSource} lifecycle (try-with-resources). + */ + public static ApplyResult apply( + IRemediationsFprSource fprSource, + IApplyRemediationsOptions options, + Set issueIdFilter, + IAviatorLogger logger) { + Accumulator acc = new Accumulator(issueIdFilter); + fprSource.forEachEntry((fprPath, label, id, index, total) -> { + if (acc.remaining != null && acc.remaining.isEmpty()) { + return false; + } + RemediationMetric metric = applyOne( + fprPath, label, index, total, options, logger, acc.remaining); + if (metric == null) { + acc.skipped++; + } else { + acc.metrics.add(metric); + acc.processedEntries.add(label); + acc.processedIds.add(id != null ? id : ""); + acc.remaining = AviatorRemediationMetricsHelper.getRemainingIssueIds(acc.remaining, metric); + } + return true; + }); + ApplyResult result = acc.toResult(); + logFinalSkippedSummary(result, issueIdFilter, options.isPreviewMode(), logger); + return result; + } + + /** + * Logs a final, aggregated summary of every skipped instance ID grouped by skip reason, so + * troubleshooting which source context/file is missing doesn't require scraping per-FPR log lines. + */ + private static void logFinalSkippedSummary( + ApplyResult result, Set issueIdFilter, boolean previewMode, IAviatorLogger logger) { + if (result.metrics().isEmpty()) { + return; + } + RemediationExecutionMode mode = previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY; + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics(issueIdFilter, result.metrics(), mode); + if (aggregated.skippedByReason().isEmpty()) { + return; + } + logger.info("Skipped remediation summary: {}", + AviatorRemediationMetricsHelper.formatSkippedReasons(aggregated.skippedByReason())); + AviatorRemediationMetricsHelper.groupIssueIdsByReason(aggregated.issueSkipReasons()) + .forEach((reason, issueIds) -> logger.info(" {} ({}): {}", reason, issueIds.size(), issueIds)); + } + + /** @see AviatorRemediationMetricsHelper#actionLabel(RemediationMetric) */ + public static String actionLabel(RemediationMetric metric) { + return AviatorRemediationMetricsHelper.actionLabel(metric); + } + + private static RemediationMetric applyOne( + Path fprPath, + String entryLabel, + int index, + int total, + IApplyRemediationsOptions options, + IAviatorLogger logger, + Set issueFilter) { + boolean previewMode = options.isPreviewMode(); + logger.progress("Processing FPR " + index + "/" + total + " (" + entryLabel + ")"); + logger.progress("Status: Processing FPR with Aviator for " + (previewMode ? "Previewing" : "Applying") + " Auto Remediations"); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + RemediationProcessingOptions processingOptions = new RemediationProcessingOptions( + issueFilter, + previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY, + options.getSourceDecoder()); + return ApplyAutoRemediationOnSource.applyRemediations( + fprHandle, options.getSourceCodeDirectory(), logger, processingOptions); + } catch (AviatorSimpleException e) { + log.warn("Skipping entry {} as {}", entryLabel, e.getMessage()); + return null; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to close FPR handle for entry " + entryLabel, e); + } + } + + /** Sequential-loop state (not concurrent — avoids Atomic* only to satisfy lambda capture rules). */ + private static final class Accumulator { + private final List metrics = new ArrayList<>(); + private final List processedEntries = new ArrayList<>(); + private final List processedIds = new ArrayList<>(); + private int skipped; + private Set remaining; + + private Accumulator(Set issueIdFilter) { + this.remaining = issueIdFilter == null ? null : new LinkedHashSet<>(issueIdFilter); + } + + private ApplyResult toResult() { + return new ApplyResult( + List.copyOf(processedEntries), + List.copyOf(processedIds), + skipped, + List.copyOf(metrics)); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheConstants.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheConstants.java new file mode 100644 index 00000000000..26a4293de3f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheConstants.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +public final class RemediationsCacheConstants { + public static final int SCHEMA_VERSION = 1; + public static final String KIND = "aviator-remediations-cache"; + public static final String MANIFEST_ENTRY = "manifest.json"; + public static final String FPRS_DIR = "fprs"; + public static final String PRODUCT_SSC = "ssc"; + public static final String PRODUCT_FOD = "fod"; + + private RemediationsCacheConstants() {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java new file mode 100644 index 00000000000..2a5d1f5d5b9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheEntry.java @@ -0,0 +1,151 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; + +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Wire model for one FPR in a remediations cache manifest. + * Product identity is nested ({@link #sscData} or {@link #fodData}), not sibling + * optional ids on this type. Prefer {@link #forSsc} / {@link #forFod} with a product model + * from {@link SSCData#of} / {@link FoDData#of}; do not add {@code @Builder} on this + * {@code @Reflectable} class. + */ +@Reflectable +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RemediationsCacheEntry { + private int order; + private String path; + private String sha256; + private SSCData sscData; + private FoDData fodData; + + /** + * Factory for SSC cache writers (not Jackson). Assigns shared fields and the provided + * SSC product block; product field construction belongs on {@link SSCData#of}. + */ + public static RemediationsCacheEntry forSsc(int order, String path, String sha256, SSCData sscData) { + FcliSimpleException.throwIf(sscData == null, "sscData is required for an SSC remediations cache entry"); + RemediationsCacheEntry entry = new RemediationsCacheEntry(); + entry.setOrder(order); + entry.setPath(path); + entry.setSha256(sha256); + entry.setSscData(sscData); + return entry; + } + + /** + * Factory for FoD cache writers (not Jackson). Assigns shared fields and the provided + * FoD product block; product field construction belongs on {@link FoDData#of}. + */ + public static RemediationsCacheEntry forFod(int order, String path, String sha256, FoDData fodData) { + FcliSimpleException.throwIf(fodData == null, "fodData is required for a FoD remediations cache entry"); + RemediationsCacheEntry entry = new RemediationsCacheEntry(); + entry.setOrder(order); + entry.setPath(path); + entry.setSha256(sha256); + entry.setFodData(fodData); + return entry; + } + + /** Validates structural invariants (no cache path context). */ + public void validate() { + validate(null); + } + + /** + * Validates structural invariants of this entry (path, checksum, exactly one product block). + * Product-vs-manifest consistency is checked by {@link RemediationsCacheManifest#validate(Path)}. + * + * @param cacheZip optional cache path included in error messages for diagnostics + */ + public void validate(Path cacheZip) { + String where = where(cacheZip); + FcliSimpleException.throwIf(StringUtils.isBlank(path), + "Remediations cache entry is missing path%s", where); + FcliSimpleException.throwIf(StringUtils.isBlank(sha256), + "Remediations cache entry is missing sha256: %s%s", path, where); + boolean hasSsc = sscData != null; + boolean hasFod = fodData != null; + FcliSimpleException.throwIf(hasSsc == hasFod, + "Remediations cache entry must have exactly one of sscData or fodData: %s%s", path, where); + if (hasSsc) { + sscData.validate(path, where); + } else { + fodData.validate(path, where); + } + } + + private static String where(Path cacheZip) { + return cacheZip != null ? " (" + cacheZip + ")" : ""; + } + + /** SSC-specific fields for one cache entry. */ + @Reflectable + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class SSCData { + private String artifactId; + private String uploadDate; + + /** Factory for writers (not Jackson). */ + public static SSCData of(String artifactId, String uploadDate) { + SSCData ssc = new SSCData(); + ssc.setArtifactId(artifactId); + ssc.setUploadDate(uploadDate); + return ssc; + } + + void validate(String entryPath, String where) { + FcliSimpleException.throwIf(StringUtils.isBlank(artifactId), + "Remediations cache SSC entry is missing artifactId: %s%s", entryPath, where); + } + } + + /** FoD-specific fields for one cache entry. */ + @Reflectable + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class FoDData { + private String releaseId; + + /** Factory for writers (not Jackson). */ + public static FoDData of(String releaseId) { + FoDData fod = new FoDData(); + fod.setReleaseId(releaseId); + return fod; + } + + void validate(String entryPath, String where) { + FcliSimpleException.throwIf(StringUtils.isBlank(releaseId), + "Remediations cache FoD entry is missing releaseId: %s%s", entryPath, where); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java new file mode 100644 index 00000000000..8f6164d82ea --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheManifest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliSimpleException; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Reflectable +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RemediationsCacheManifest { + private int schemaVersion; + private String kind; + private String product; + private String createdAt; + private Map selection = new LinkedHashMap<>(); + private List entries = new ArrayList<>(); + + /** Validates schema, product, and every entry (no cache path context). */ + public void validate() { + validate(null); + } + + /** + * Validates schema, product, and every entry (including product-block consistency). + * Call after Jackson load and before publishing a written cache. + * + * @param cacheZip optional cache path included in error messages for diagnostics + */ + public void validate(Path cacheZip) { + String where = cacheZip != null ? ": " + cacheZip : ""; + FcliSimpleException.throwIf(schemaVersion != RemediationsCacheConstants.SCHEMA_VERSION, + "Unsupported remediations cache schemaVersion %s (expected %s)%s", + schemaVersion, RemediationsCacheConstants.SCHEMA_VERSION, where); + FcliSimpleException.throwIf(!RemediationsCacheConstants.KIND.equals(kind), + "Invalid remediations cache kind '%s' (expected %s)%s", + kind, RemediationsCacheConstants.KIND, where); + FcliSimpleException.throwIf(StringUtils.isBlank(product), + "Remediations cache manifest is missing product%s", where); + boolean sscProduct = RemediationsCacheConstants.PRODUCT_SSC.equals(product); + boolean fodProduct = RemediationsCacheConstants.PRODUCT_FOD.equals(product); + FcliSimpleException.throwIf(!sscProduct && !fodProduct, + "Unsupported remediations cache product '%s' (expected %s or %s)%s", + product, RemediationsCacheConstants.PRODUCT_SSC, RemediationsCacheConstants.PRODUCT_FOD, where); + FcliSimpleException.throwIf(entries == null || entries.isEmpty(), + "Remediations cache has no entries%s", where); + for (RemediationsCacheEntry entry : entries) { + FcliSimpleException.throwIf(entry == null, + "Remediations cache contains a null entry%s", where); + entry.validate(cacheZip); + if (sscProduct) { + FcliSimpleException.throwIf(entry.getSscData() == null, + "SSC remediations cache entry is missing sscData: %s%s", + entry.getPath(), where); + } else { + FcliSimpleException.throwIf(entry.getFodData() == null, + "FoD remediations cache entry is missing fodData: %s%s", + entry.getPath(), where); + } + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java new file mode 100644 index 00000000000..d577f8c4e85 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheReader.java @@ -0,0 +1,198 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.ZipHelper; + +import lombok.Getter; + +/** + * Opens a remediations cache zip as a {@link FileSystem} and exposes manifest data and + * ordered FPR paths. Construction only validates the zip path and opens the filesystem; + * manifest/entry validation happens lazily via getters so a single try-with-resources on + * this reader owns {@code cacheFs} cleanup. + */ +public final class RemediationsCacheReader implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheReader.class); + + private final Path cacheZip; + private final FileSystem cacheFs; + + @Getter(lazy = true) + private final RemediationsCacheManifest manifest = loadAndValidateManifest(); + + @Getter(lazy = true) + private final List orderedEntries = loadOrderedEntries(); + + @Getter(lazy = true) + private final List orderedResolvedFprs = loadOrderedResolvedFprs(); + + @Getter(lazy = true) + private final List orderedFprPaths = loadOrderedFprPaths(); + + /** Manifest entry paired with its validated ZipFS path (same order as apply). */ + public record ResolvedFpr(RemediationsCacheEntry entry, Path fprPath) {} + + /** + * Validates {@code cacheZip} and opens it as a zip file system. Prefer use via + * try-with-resources so {@link #close()} always runs. + */ + public RemediationsCacheReader(Path cacheZip) { + Path validated = validateCacheZip(cacheZip); + FileSystem opened; + try { + opened = ZipHelper.openZipFileSystem(validated); + } catch (AbstractFcliException e) { + throw e; + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to open remediations cache: " + validated, e); + } + this.cacheZip = validated; + this.cacheFs = opened; + } + + /** Factory alias for call sites that prefer a static open style. */ + public static RemediationsCacheReader open(Path cacheZip) { + return new RemediationsCacheReader(cacheZip); + } + + public Path getCacheZip() { + return cacheZip; + } + + public List getOrderedEntryPaths() { + return getOrderedEntries().stream().map(RemediationsCacheEntry::getPath).toList(); + } + + /** + * Ensures the cache was produced for the expected product ({@code ssc} or {@code fod}). + */ + public void requireProduct(String expectedProduct) { + String actual = getManifest().getProduct(); + FcliSimpleException.throwIf(!expectedProduct.equals(actual), + "Remediations cache product is '%s' but this command expects '%s': %s", + actual, expectedProduct, cacheZip); + } + + @Override + public void close() { + if (cacheFs == null || !cacheFs.isOpen()) { + return; + } + try { + cacheFs.close(); + } catch (IOException e) { + logger.warn("Failed to close cache filesystem", e); + } + } + + private static Path validateCacheZip(Path cacheZip) { + FcliSimpleException.throwIf(cacheZip == null, + "--from-cache must specify a remediations cache zip path"); + FcliSimpleException.throwIf(!Files.exists(cacheZip), + "Remediations cache file does not exist: %s", cacheZip); + FcliSimpleException.throwIf(!Files.isRegularFile(cacheZip), + "Remediations cache path is not a regular file: %s", cacheZip); + FcliSimpleException.throwIf(!Files.isReadable(cacheZip), + "Remediations cache file is not readable: %s", cacheZip); + return cacheZip; + } + + private RemediationsCacheManifest loadAndValidateManifest() { + Path manifestPath = cacheFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY); + FcliSimpleException.throwIf(!Files.isRegularFile(manifestPath), + "Remediations cache is missing %s: %s", + RemediationsCacheConstants.MANIFEST_ENTRY, cacheZip); + try (var manifestInputStream = Files.newInputStream(manifestPath)) { + RemediationsCacheManifest manifest = JsonHelper.getObjectMapper() + .readValue(manifestInputStream, RemediationsCacheManifest.class); + FcliSimpleException.throwIf(manifest == null, + "Remediations cache manifest is empty: %s", cacheZip); + // Model owns field invariants; Reader only loads and delegates. + manifest.validate(cacheZip); + return manifest; + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to read remediations cache manifest: " + cacheZip, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to parse remediations cache manifest: " + cacheZip, e); + } + } + + private List loadOrderedEntries() { + List entries = new ArrayList<>(getManifest().getEntries()); + entries.sort(Comparator.comparingInt(RemediationsCacheEntry::getOrder)); + return List.copyOf(entries); + } + + private List loadOrderedResolvedFprs() { + List resolved = new ArrayList<>(); + for (RemediationsCacheEntry entry : getOrderedEntries()) { + Path fprPath = resolveEntryPath(entry.getPath()); + FcliSimpleException.throwIf(!Files.isRegularFile(fprPath), + "Remediations cache entry path not found in zip: %s", entry.getPath()); + String actualSha = RemediationsCacheSha256.hashFile(fprPath); + FcliSimpleException.throwIf(!actualSha.equalsIgnoreCase(entry.getSha256()), + "SHA-256 mismatch for cache entry %s (expected %s, actual %s)", + entry.getPath(), entry.getSha256(), actualSha); + resolved.add(new ResolvedFpr(entry, fprPath)); + } + FcliSimpleException.throwIf(resolved.isEmpty(), + "Remediations cache contains no FPR entries: %s", cacheZip); + return List.copyOf(resolved); + } + + private List loadOrderedFprPaths() { + return getOrderedResolvedFprs().stream().map(ResolvedFpr::fprPath).toList(); + } + + /** + * Resolves a manifest entry path inside the zip FS. Rejects empty, absolute, and parent-escape paths. + * ZipFS keeps paths in-archive (not host zip-slip), but untrusted manifests must still stay relative. + */ + private Path resolveEntryPath(String entryPath) { + FcliSimpleException.throwIf(StringUtils.isBlank(entryPath), + "Remediations cache entry path is blank: %s", cacheZip); + String normalized = entryPath.replace('\\', '/').trim(); + while (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + FcliSimpleException.throwIf(normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*"), + "Remediations cache contains absolute path: %s", entryPath); + FcliSimpleException.throwIf(normalized.contains(".."), + "Remediations cache contains unsafe path: %s", entryPath); + Path resolved = cacheFs.getPath(normalized).normalize(); + // After normalize, parent segments must not reappear. + String resolvedStr = resolved.toString().replace('\\', '/'); + FcliSimpleException.throwIf(resolvedStr.contains("..") || resolvedStr.startsWith("/"), + "Remediations cache contains unsafe path: %s", entryPath); + return resolved; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheSha256.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheSha256.java new file mode 100644 index 00000000000..9fcf331b880 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheSha256.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import com.fortify.cli.common.exception.FcliTechnicalException; + +public final class RemediationsCacheSha256 { + private RemediationsCacheSha256() {} + + public static String hashFile(Path path) { + try (InputStream in = Files.newInputStream(path)) { + return hashStream(in); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to compute SHA-256 for " + path, e); + } + } + + public static String hashStream(InputStream in) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (DigestInputStream dis = new DigestInputStream(in, digest)) { + byte[] buffer = new byte[8192]; + while (dis.read(buffer) != -1) { + // Digest is updated by DigestInputStream + } + } + return toHex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new FcliTechnicalException("SHA-256 algorithm not available", e); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to compute SHA-256", e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java new file mode 100644 index 00000000000..99b5bf940df --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheWriter.java @@ -0,0 +1,333 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.ZipHelper; + +/** + * Builds a remediations cache zip at a destination path. FPR content is written + * directly into a ZipFS (no per-FPR temp staging). The ZipFS is opened on a sibling + * {@code *.partial} work file; on successful {@link #commit()}, the work file is moved + * onto {@code destination} (atomic when the filesystem supports it). + * + *

Use with try-with-resources. Call {@link #commit()} after all entries have been written + * successfully. {@link #close()} closes ZipFS and discards any uncommitted work file. + */ +public final class RemediationsCacheWriter implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(RemediationsCacheWriter.class); + private static final String PARTIAL_SUFFIX = ".partial"; + + private final Path destination; + private final Path workPath; + private final FileSystem zipFs; + private final RemediationsCacheManifest manifest; + private int nextOrder = 1; + private boolean committed; + + /** + * Opens ZipFS on a sibling work file. Exceptions from open are thrown immediately. + * Entry parent dirs (including {@code fprs/}) are created lazily in {@link #prepareEntryPath}. + */ + private RemediationsCacheWriter(Path destination, String product, Map selection) { + this.destination = destination; + this.workPath = workPathFor(destination); + this.manifest = newManifest(product, selection); + this.zipFs = ZipHelper.createZipFileSystem(workPath); + } + + /** + * Opens a work zip next to {@code destination}. Use with try-with-resources. + */ + public static RemediationsCacheWriter create(Path destination, String product, Map selection) { + FcliSimpleException.throwIf(destination == null, + "-f/--file must specify a remediations cache zip path"); + return new RemediationsCacheWriter(destination, product, selection); + } + + /** + * Convenience for callers that already have FPR files on disk (for example unit tests). + * Publishes after all entries have been written successfully. + */ + public static RemediationsCacheManifest write( + Path destination, + String product, + Map selection, + List fprSources) { + FcliSimpleException.throwIf(fprSources == null || fprSources.isEmpty(), + "Cannot create remediations cache: no FPR files to include"); + try (RemediationsCacheWriter writer = create(destination, product, selection)) { + for (LocalFpr source : fprSources) { + if (source instanceof SSCFpr ssc) { + writer.addFprFromFile(ssc); + } else if (source instanceof FoDFpr fod) { + writer.addFprFromFile(fod); + } else { + throw new FcliTechnicalException("Unsupported local FPR type: " + source.getClass().getName()); + } + } + writer.commit(); + return writer.getManifest(); + } + } + + /** In-memory manifest (complete after successful commit with entries). */ + public RemediationsCacheManifest getManifest() { + return manifest; + } + + /** + * Writes an SSC artifact FPR into the next zip entry (for streaming downloads). + */ + public void addSscFpr(String artifactId, String uploadDate, Consumer contentWriter) { + int order = nextOrder++; + writeAndRecord( + sscEntryPath(order, artifactId), + contentWriter, + (entryPath, sha256) -> RemediationsCacheEntry.forSsc( + order, entryPath, sha256, RemediationsCacheEntry.SSCData.of(artifactId, uploadDate))); + } + + /** + * Writes a FoD release FPR into the next zip entry (for streaming downloads). + */ + public void addFodFpr(String releaseId, Consumer contentWriter) { + int order = nextOrder++; + writeAndRecord( + fodEntryPath(order, releaseId), + contentWriter, + (entryPath, sha256) -> RemediationsCacheEntry.forFod( + order, entryPath, sha256, RemediationsCacheEntry.FoDData.of(releaseId))); + } + + @FunctionalInterface + private interface EntryBuilder { + RemediationsCacheEntry build(String entryPath, String sha256); + } + + private void writeAndRecord(String entryPath, Consumer contentWriter, EntryBuilder entryBuilder) { + try { + Path target = prepareEntryPath(entryPath); + writeEntryContent(target, entryPath, contentWriter); + RemediationsCacheEntry entry = entryBuilder.build(entryPath, RemediationsCacheSha256.hashFile(target)); + entry.validate(destination); + manifest.getEntries().add(entry); + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to add remediations cache entry " + entryPath, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to add remediations cache entry " + entryPath, e); + } + } + + /** Copies an existing SSC FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(SSCFpr source) { + validateLocalPath(source.path()); + addSscFpr(source.artifactId(), source.uploadDate(), copyFrom(source.path())); + } + + /** Copies an existing FoD FPR file into the cache zip and records its checksum. */ + public void addFprFromFile(FoDFpr source) { + validateLocalPath(source.path()); + addFodFpr(source.releaseId(), copyFrom(source.path())); + } + + private Path prepareEntryPath(String entryPath) throws IOException { + Path target = zipFs.getPath(entryPath); + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + return target; + } + + private static void writeEntryContent(Path target, String entryPath, Consumer contentWriter) { + contentWriter.accept(target); + FcliSimpleException.throwIf(!Files.isRegularFile(target), + "Cache entry was not written: %s", entryPath); + } + + /** Completes the cache and publishes it after all entries have been written successfully. */ + public void commit() { + if (committed) { + return; + } + try { + if (zipFs.isOpen()) { + if (!manifest.getEntries().isEmpty()) { + writeManifest(); + } + zipFs.close(); + } + } catch (AbstractFcliException e) { + deleteQuietly(workPath); + throw e; + } catch (IOException e) { + deleteQuietly(workPath); + throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); + } catch (RuntimeException e) { + deleteQuietly(workPath); + throw new FcliTechnicalException("Failed to finalize remediations cache zip: " + destination, e); + } + // Guard with workPath existence so a second commit is a no-op after publish/delete. + if (!Files.exists(workPath)) { + committed = true; + return; + } + if (!manifest.getEntries().isEmpty()) { + publishWorkFile(); + } else { + deleteQuietly(workPath); + } + committed = true; + } + + /** Discards an uncommitted work file when the try-with-resources body fails. */ + @Override + public void close() { + if (committed) { + return; + } + try { + if (zipFs.isOpen()) { + zipFs.close(); + } + } catch (IOException e) { + throw new FcliTechnicalException("Failed to close remediations cache zip: " + destination, e); + } finally { + deleteQuietly(workPath); + } + } + + private void writeManifest() { + try { + manifest.validate(destination); + byte[] manifestBytes = JsonHelper.getObjectMapper().writerWithDefaultPrettyPrinter() + .writeValueAsBytes(manifest); + Files.write(zipFs.getPath(RemediationsCacheConstants.MANIFEST_ENTRY), manifestBytes); + } catch (AbstractFcliException e) { + throw e; + } catch (IOException e) { + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + } catch (RuntimeException e) { + throw new FcliTechnicalException("Failed to write remediations cache manifest to " + destination, e); + } + } + + private void publishWorkFile() { + try { + try { + Files.move(workPath, destination, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(workPath, destination, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + deleteQuietly(workPath); + throw new FcliTechnicalException( + "Failed to publish remediations cache zip to " + destination, e); + } + } + + private static void validateLocalPath(Path path) { + FcliSimpleException.throwIf(path == null, "FPR source path is required"); + FcliSimpleException.throwIf(!Files.isRegularFile(path), + "FPR source is not a readable regular file: %s", path); + } + + private static Consumer copyFrom(Path source) { + return target -> { + try { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to copy FPR into cache: " + source, e); + } + }; + } + + private static Path workPathFor(Path destination) { + Path fileName = destination.getFileName(); + String name = fileName != null ? fileName.toString() : "remediations-cache.zip"; + Path parent = destination.toAbsolutePath().getParent(); + Path workName = Path.of(name + PARTIAL_SUFFIX); + return parent != null ? parent.resolve(workName) : workName; + } + + private static RemediationsCacheManifest newManifest(String product, Map selection) { + RemediationsCacheManifest manifest = new RemediationsCacheManifest(); + manifest.setSchemaVersion(RemediationsCacheConstants.SCHEMA_VERSION); + manifest.setKind(RemediationsCacheConstants.KIND); + manifest.setProduct(product); + manifest.setCreatedAt(Instant.now().toString()); + if (selection != null) { + manifest.getSelection().putAll(selection); + } + return manifest; + } + + private static String sscEntryPath(int order, String artifactId) { + return String.format("%s/%03d_artifact_%s.fpr", + RemediationsCacheConstants.FPRS_DIR, order, sanitizePathSegment(artifactId)); + } + + private static String fodEntryPath(int order, String releaseId) { + return String.format("%s/%03d_release_%s.fpr", + RemediationsCacheConstants.FPRS_DIR, order, sanitizePathSegment(releaseId)); + } + + private static String sanitizePathSegment(String id) { + FcliSimpleException.throwIf(id == null || id.isBlank(), "Entry id is required for cache path"); + String cleaned = id.replaceAll("[^A-Za-z0-9_-]", "_"); + return cleaned.isEmpty() ? "id" : cleaned; + } + + private static void deleteQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Failed to delete incomplete remediations cache work file: {}", path, e); + } + } + + /** Local on-disk FPR to copy into the cache (product-specific; no shared null fields). */ + public sealed interface LocalFpr permits SSCFpr, FoDFpr { + Path path(); + } + + /** SSC artifact FPR for cache write helpers/tests. */ + public record SSCFpr(Path path, String artifactId, String uploadDate) implements LocalFpr {} + + /** FoD release FPR for cache write helpers/tests. */ + public record FoDFpr(Path path, String releaseId) implements LocalFpr {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtils.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtils.java new file mode 100644 index 00000000000..dbd1d50df51 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtils.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.exception.FcliSimpleException; + +public final class AviatorIssueIdFilterUtils { + private AviatorIssueIdFilterUtils() {} + + public static Set normalizeIssueIds(List issueIds) { + if (issueIds == null) { + return null; + } + Set normalizedIssueIds = issueIds.stream() + .map(StringUtils::trimToNull) + .filter(StringUtils::isNotBlank) + .collect(LinkedHashSet::new, Set::add, Set::addAll); + if (normalizedIssueIds.isEmpty()) { + throw new FcliSimpleException("--issue-ids must contain at least one non-blank issue ID"); + } + return normalizedIssueIds; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java new file mode 100644 index 00000000000..8c1164507b9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelper.java @@ -0,0 +1,59 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.AbstractFcliException; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; + +public final class AviatorLocalFprHelper { + private AviatorLocalFprHelper() {} + + public static void validateLocalFprs(List fprPaths) { + validateLocalFprs(fprPaths, "FPR file"); + } + + public static void validateLocalFprs(List fprPaths, String sourceLabel) { + FcliSimpleException.throwIf(fprPaths == null || fprPaths.isEmpty(), + "%s list must contain at least one FPR file", sourceLabel); + for (Path fprPath : fprPaths) { + validateLocalFpr(fprPath, sourceLabel); + } + } + + private static void validateLocalFpr(Path fprPath, String sourceLabel) { + FcliSimpleException.throwIf(fprPath == null, + "%s path must be a valid FPR file path", sourceLabel); + FcliSimpleException.throwIf(!Files.exists(fprPath), + "%s does not exist: %s", sourceLabel, fprPath); + FcliSimpleException.throwIf(!Files.isRegularFile(fprPath), + "%s is not a regular file: %s", sourceLabel, fprPath); + FcliSimpleException.throwIf(!Files.isReadable(fprPath), + "%s is not readable: %s", sourceLabel, fprPath); + try (FprHandle fprHandle = new FprHandle(fprPath)) { + fprHandle.validate(); + } catch (AbstractFcliException e) { + throw e; + } catch (RuntimeException e) { + throw new FcliSimpleException(sourceLabel + " is not a valid audited SAST FPR: " + fprPath, e); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to close " + sourceLabel + ": " + fprPath, e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java new file mode 100644 index 00000000000..6062236efad --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelper.java @@ -0,0 +1,276 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; + +/** + * Shared metric aggregation and result-field helpers for SSC/FoD apply-remediations flows. + */ +public final class AviatorRemediationMetricsHelper { + private static final String REQUESTED_ISSUE_NOT_FOUND = "Requested issue not found in remediations"; + + private AviatorRemediationMetricsHelper() {} + + /** + * Aggregates per-FPR metrics. {@code requestedIssueIds == null} selects unfiltered + * aggregation (XML totals); non-null selects filtered aggregation (requested IDs). + * An empty metric list is apply mode. + */ + public static RemediationMetric aggregateMetrics(Set requestedIssueIds, Collection metrics) { + return aggregateMetrics(requestedIssueIds, metrics, RemediationExecutionMode.APPLY); + } + + /** + * Same aggregation, with the command's requested mode kept when every FPR was skipped + * before a metric existed. A preview metric still forces preview. + */ + public static RemediationMetric aggregateMetrics( + Set requestedIssueIds, + Collection metrics, + RemediationExecutionMode requestedMode) { + Collection safeMetrics = metrics == null ? List.of() : metrics; + RemediationExecutionMode mode = requestedMode == null ? RemediationExecutionMode.APPLY : requestedMode; + return requestedIssueIds == null + ? aggregateUnfiltered(safeMetrics, mode) + : aggregateFiltered(requestedIssueIds, safeMetrics, mode); + } + + private static RemediationMetric aggregateUnfiltered( + Collection metrics, RemediationExecutionMode requestedMode) { + RemediationMetric.RemediationMetricBuilder builder = RemediationMetric.builder() + .executionMode(requestedMode); + for (RemediationMetric metric : metrics) { + builder.add(metric); + } + return builder.build(); + } + + private static RemediationMetric aggregateFiltered( + Set requestedIssueIds, + Collection metrics, + RemediationExecutionMode requestedMode) { + Set seenIssueIds = new LinkedHashSet<>(); + Set satisfiedIssueIds = new LinkedHashSet<>(); + Set appliedIssueIds = new LinkedHashSet<>(); + Set identicalIssueIds = new LinkedHashSet<>(); + Set supersededIssueIds = new LinkedHashSet<>(); + Set possiblyRemediatedIssueIds = new LinkedHashSet<>(); + Set modifiedFiles = new LinkedHashSet<>(); + Map issueSkipReasons = new LinkedHashMap<>(); + Map previewDetailsByIssue = new LinkedHashMap<>(); + boolean previewMode = requestedMode == RemediationExecutionMode.PREVIEW; + for (RemediationMetric metric : metrics) { + seenIssueIds.addAll(metric.seenIssueIds()); + satisfiedIssueIds.addAll(metric.satisfiedIssueIds()); + appliedIssueIds.addAll(metric.appliedIssueIds()); + identicalIssueIds.addAll(metric.identicalIssueIds()); + supersededIssueIds.addAll(metric.supersededIssueIds()); + possiblyRemediatedIssueIds.addAll(metric.possiblyRemediatedIssueIds()); + modifiedFiles.addAll(metric.modifiedFiles()); + issueSkipReasons.putAll(metric.issueSkipReasons()); + previewMode |= metric.isPreview(); + for (PreviewDetail detail : metric.previewDetails()) { + PreviewDetail existing = previewDetailsByIssue.get(detail.issueId()); + if (existing == null || detail.isAvailable() || !existing.isAvailable()) { + previewDetailsByIssue.put(detail.issueId(), detail); + } + } + } + + issueSkipReasons.keySet().removeAll(satisfiedIssueIds); + possiblyRemediatedIssueIds.removeAll(satisfiedIssueIds); + for (String requestedIssueId : requestedIssueIds) { + if (!satisfiedIssueIds.contains(requestedIssueId) && !seenIssueIds.contains(requestedIssueId)) { + issueSkipReasons.put(requestedIssueId, REQUESTED_ISSUE_NOT_FOUND); + if (previewMode) { + previewDetailsByIssue.put(requestedIssueId, + PreviewDetail.skipped(requestedIssueId, null)); + } + } + } + + Map skippedByReason = new LinkedHashMap<>(); + issueSkipReasons.values().forEach(reason -> skippedByReason.merge(reason, 1, Integer::sum)); + int skippedRemediations = requestedIssueIds.size() - satisfiedIssueIds.size(); + return RemediationMetric.builder() + .totalRemediations(requestedIssueIds.size()) + .appliedRemediations(appliedIssueIds.size()) + .identicalRemediations(identicalIssueIds.size()) + .supersededRemediations(supersededIssueIds.size()) + .possiblyRemediatedRemediations(possiblyRemediatedIssueIds.size()) + .skippedRemediations(skippedRemediations) + .modifiedFiles(modifiedFiles) + .skippedByReason(skippedByReason) + .executionMode(previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY) + .requestedIssueIds(requestedIssueIds) + .seenIssueIds(seenIssueIds) + .satisfiedIssueIds(satisfiedIssueIds) + .appliedIssueIds(appliedIssueIds) + .identicalIssueIds(identicalIssueIds) + .supersededIssueIds(supersededIssueIds) + .possiblyRemediatedIssueIds(possiblyRemediatedIssueIds) + .issueSkipReasons(issueSkipReasons) + .previewDetails(new ArrayList<>(previewDetailsByIssue.values())) + .build(); + } + + public static Set getRemainingIssueIds(Set requestedIssueIds, RemediationMetric metric) { + if (requestedIssueIds == null || requestedIssueIds.isEmpty()) { + return requestedIssueIds; + } + Set remainingIssueIds = new LinkedHashSet<>(requestedIssueIds); + remainingIssueIds.removeAll(metric.satisfiedIssueIds()); + return remainingIssueIds; + } + + public static void mergeSkippedByReason(Map target, Map source) { + if (source == null || source.isEmpty()) { + return; + } + source.forEach((reason, count) -> target.merge(reason, count, Integer::sum)); + } + + /** Compact table-friendly summary: {@code reason=count, ...}. */ + public static String formatSkippedReasons(Map skippedByReason) { + if (skippedByReason == null || skippedByReason.isEmpty()) { + return ""; + } + List parts = new ArrayList<>(); + skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); + return String.join(", ", parts); + } + + public static String actionLabel(RemediationMetric metric) { + boolean previewMode = metric != null && metric.isPreview(); + if (metric != null && metric.appliedRemediations() > 0) { + return previewMode ? "Remediation-Previewed" : "Remediation-Applied"; + } else { + return previewMode ? "No-Remediation-Previewed" : "No-Remediation-Applied"; + } + } + + public static String na(String value) { + return value != null ? value : "N/A"; + } + + /** + * Writes always-present remediation metric fields onto a result node + * (totals, skip reasons, modified files). Does not set {@code __action__}. + */ + public static void putRemediationMetricFields(ObjectNode result, RemediationMetric metric) { + int total = metric == null ? 0 : metric.totalRemediations(); + int applied = metric == null ? 0 : metric.appliedRemediations(); + int skipped = metric == null ? 0 : metric.skippedRemediations(); + String appliedFieldName = metric != null && metric.isPreview() ? "availableRemediation" : "appliedRemediation"; + Map skippedByReason = metric == null ? Map.of() : metric.skippedByReason(); + Map issueSkipReasons = metric == null ? Map.of() : metric.issueSkipReasons(); + Set modifiedFiles = metric == null ? Set.of() : metric.modifiedFiles(); + + result.put("totalRemediation", total); + result.put(appliedFieldName, applied); + result.put("identicalRemediation", metric == null ? 0 : metric.identicalRemediations()); + result.put("supersededRemediation", metric == null ? 0 : metric.supersededRemediations()); + result.put("possiblyRemediatedRemediation", metric == null ? 0 : metric.possiblyRemediatedRemediations()); + result.put("skippedRemediation", skipped); + result.put("skippedReasons", formatSkippedReasons(skippedByReason)); + result.set("skippedByReason", toSkippedByReasonNode(skippedByReason, issueSkipReasons)); + result.set("modifiedFiles", toArrayNode(modifiedFiles)); + } + + /** Metric fields plus {@code __action__} and, for preview results, preview details (shared by SSC/FoD result builders). */ + public static void putMetricAndAction(ObjectNode result, RemediationMetric metric) { + putRemediationMetricFields(result, metric); + result.put(IActionCommandResultSupplier.actionFieldName, actionLabel(metric)); + + if (metric != null && metric.isPreview()) { + result.set("previewDetails", toPreviewDetailsArray(metric.previewDetails())); + } + } + + private static ArrayNode toPreviewDetailsArray(List previewDetails) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (previewDetails != null) { + previewDetails.forEach(detail -> array.add(JsonHelper.getObjectMapper().valueToTree(detail))); + } + return array; + } + + /** + * Cache-mode extras shared by SSC/FoD: file path, entry list, and product id list field. + * + * @param idArrayField {@code artifactIds} (SSC) or {@code releaseIds} (FoD) + */ + public static void putCacheExtras( + ObjectNode result, Path cacheZip, List entryPaths, String idArrayField, List ids) { + result.put("file", cacheZip.toString()); + result.set("entries", toStringArrayNode(entryPaths)); + result.set(idArrayField, toStringArrayNode(ids)); + } + + /** Inverts an issueId-to-reason map into reason-to-issueIds, preserving encounter order. */ + public static Map> groupIssueIdsByReason(Map issueSkipReasons) { + Map> issueIdsByReason = new LinkedHashMap<>(); + if (issueSkipReasons != null) { + issueSkipReasons.forEach((issueId, reason) -> + issueIdsByReason.computeIfAbsent(reason, r -> new ArrayList<>()).add(issueId)); + } + return issueIdsByReason; + } + + /** Per-reason {@code {count, issueIds}} object, so troubleshooting a reason doesn't require cross-referencing a separate list. */ + public static ObjectNode toSkippedByReasonNode(Map skippedByReason, Map issueSkipReasons) { + ObjectNode object = JsonHelper.getObjectMapper().createObjectNode(); + if (skippedByReason == null) { + return object; + } + Map> issueIdsByReason = groupIssueIdsByReason(issueSkipReasons); + skippedByReason.forEach((reason, count) -> { + ObjectNode reasonNode = object.putObject(reason); + reasonNode.put("count", count); + reasonNode.set("issueIds", toStringArrayNode(issueIdsByReason.getOrDefault(reason, List.of()))); + }); + return object; + } + + public static ArrayNode toArrayNode(Set files) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (files != null) { + files.forEach(array::add); + } + return array; + } + + public static ArrayNode toStringArrayNode(List values) { + ArrayNode array = JsonHelper.getObjectMapper().createArrayNode(); + if (values != null) { + values.forEach(array::add); + } + return array; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java new file mode 100644 index 00000000000..bfc3de2f495 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorTempFprFile.java @@ -0,0 +1,74 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.common.exception.FcliTechnicalException; + +/** + * Host-filesystem temporary FPR used only when a real {@link Path} is required + * (for example online downloads before {@code FprHandle} can open a nested zip FS). + * Prefer ZipFS entry paths from {@code RemediationsCacheReader}/{@code RemediationsCacheWriter} + * whenever content already lives in a durable cache zip. + * + *

Owns cleanup via {@link AutoCloseable}; use try-with-resources so interrupt/exception + * paths still delete the file. + */ +public final class AviatorTempFprFile implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(AviatorTempFprFile.class); + + private final Path path; + + private AviatorTempFprFile(Path path) { + this.path = path; + } + + /** + * @param nameHint short label (artifact/release id); sanitized into the temp file prefix + */ + public static AviatorTempFprFile create(String nameHint) { + String safe = sanitize(nameHint); + try { + return new AviatorTempFprFile(Files.createTempFile("aviator-" + safe + "-", ".fpr")); + } catch (IOException e) { + throw new FcliTechnicalException("Failed to create temporary FPR file for " + safe, e); + } + } + + public Path path() { + return path; + } + + @Override + public void close() { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Failed to delete temporary FPR file: {}", path, e); + } + } + + private static String sanitize(String nameHint) { + if (nameHint == null || nameHint.isBlank()) { + return "fpr"; + } + String cleaned = nameHint.replaceAll("[^A-Za-z0-9._-]", "_"); + return cleaned.length() > 40 ? cleaned.substring(0, 40) : cleaned; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java index e2db9d777cb..3e9afcbbf43 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/applyRemediation/ApplyAutoRemediationOnSource.java @@ -12,33 +12,70 @@ */ package com.fortify.cli.aviator.applyRemediation; +import java.util.Objects; +import java.util.Set; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.config.IAviatorLogger; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor; -import com.fortify.cli.aviator.fpr.processor.RemediationProcessor.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessingOptions; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessor; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.util.FprHandle; - public class ApplyAutoRemediationOnSource { private static final Logger LOG = LoggerFactory.getLogger(ApplyAutoRemediationOnSource.class); - public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger) + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory) + throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, SourceDecoders.defaults()); + } + + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, + ISourceDecoder sourceDecoder) + throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, null, + new RemediationProcessingOptions(Set.of(), RemediationExecutionMode.APPLY, sourceDecoder)); + } + + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter) throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter, false, SourceDecoders.defaults()); + } + + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter, boolean previewMode) + throws AviatorSimpleException, AviatorTechnicalException { + return applyRemediations(fprHandle, sourceCodeDirectory, logger, issueIdFilter, previewMode, SourceDecoders.defaults()); + } - LOG.info("Starting apply auto-remediation process for file: {}", fprHandle.getFprPath()); + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + Set issueIdFilter, boolean previewMode, ISourceDecoder sourceDecoder) + throws AviatorSimpleException, AviatorTechnicalException { + RemediationExecutionMode executionMode = previewMode ? RemediationExecutionMode.PREVIEW : RemediationExecutionMode.APPLY; + return applyRemediations(fprHandle, sourceCodeDirectory, logger, + new RemediationProcessingOptions(issueIdFilter, executionMode, sourceDecoder)); + } + public static RemediationMetric applyRemediations(FprHandle fprHandle, String sourceCodeDirectory, IAviatorLogger logger, + RemediationProcessingOptions options) + throws AviatorSimpleException, AviatorTechnicalException { + Objects.requireNonNull(options, "options"); + LOG.info("Starting {} process for file: {}", + options.isPreview() ? "preview" : "apply auto-remediation", fprHandle.getFprPath()); if (!fprHandle.hasRemediations()) { - //LOG.error("FPR file does not contain remediations.xml file: {}", fprHandle.getFprPath()); throw new AviatorSimpleException("FPR file does not contain remediations.xml file."); } LOG.info("FPR validation successful"); - RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory); + RemediationProcessor remediationProcessor = new RemediationProcessor(fprHandle, sourceCodeDirectory, options); return remediationProcessor.processRemediationXML(); - } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java index 4571af0bc7f..95edd424942 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/AuditFPR.java @@ -14,6 +14,7 @@ import java.io.File; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -30,7 +31,6 @@ import com.fortify.cli.aviator.audit.model.FPRAuditResult; import com.fortify.cli.aviator.audit.model.FilterSelection; import com.fortify.cli.aviator.audit.model.ParsedFprData; -import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.fpr.FPRProcessor; import com.fortify.cli.aviator.fpr.Vulnerability; @@ -39,6 +39,7 @@ import com.fortify.cli.aviator.fpr.model.FPRInfo; import com.fortify.cli.aviator.fpr.processor.AuditProcessor; import com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.ResourceUtil; @@ -52,8 +53,11 @@ public static FPRAuditResult auditFPR(AuditFprOptions options) options.getFprHandle().validate(); AviatorConfigManager.getInstance(); + // Non-null: AuditFprOptions defaults via @Builder.Default; CLI mixin always supplies a decoder. + ISourceDecoder sourceDecoder = options.getSourceDecoder(); + // --- STAGE 1: PARSING --- - ParsedFprData parsedData = prepareAndParseFpr(options.getFprHandle()); + ParsedFprData parsedData = prepareAndParseFpr(options.getFprHandle(), sourceDecoder); TagMappingConfig tagMappingConfig = loadTagMappingConfig(options.getTagMappingPath()); Map issueCategoryLookup = tagMappingConfig.requiresCategoryForSuppressionEvaluation() ? buildIssueCategoryLookup(parsedData.vulnerabilities) @@ -67,24 +71,20 @@ public static FPRAuditResult auditFPR(AuditFprOptions options) // --- STAGE 3: AUDITING --- Map auditResponses = new ConcurrentHashMap<>(); - AuditOutcome auditOutcome = performAviatorAudit( - parsedData, options.getLogger(), options.getToken(), options.getAppVersion(), options.getUrl(), options.getSscAppName(), options.getSscAppVersion(), - auditResponses, filterSelection, options.getFprHandle(), options.getFolderPriorityOrder() - ); + AuditOutcome auditOutcome = performAviatorAudit(parsedData, auditResponses, filterSelection, options); // --- STAGE 4: FINALIZATION --- return finalizeFprAudit( auditOutcome, auditResponses, parsedData.auditProcessor, - tagMappingConfig, issueCategoryLookup, parsedData.fprInfo + tagMappingConfig, issueCategoryLookup, parsedData.fprInfo, parsedData.streamingFVDLProcessor ); } - private static ParsedFprData prepareAndParseFpr(FprHandle fprHandle) { + private static ParsedFprData prepareAndParseFpr(FprHandle fprHandle, ISourceDecoder sourceDecoder) { try { // Processors now take the FprHandle directly, no more extracted path - AuditProcessor auditProcessor = new AuditProcessor(fprHandle); - //FVDLProcessor fvdlProcessor = new FVDLProcessor(fprHandle); - StreamingFVDLProcessor streamingFVDLProcessor = new StreamingFVDLProcessor(fprHandle); + AuditProcessor auditProcessor = new AuditProcessor(fprHandle, sourceDecoder); + StreamingFVDLProcessor streamingFVDLProcessor = new StreamingFVDLProcessor(fprHandle, sourceDecoder); Map auditIssueMap = auditProcessor.processAuditXML(); FPRProcessor fprProcessor = new FPRProcessor(fprHandle, auditIssueMap, auditProcessor); @@ -108,8 +108,7 @@ private static TagMappingConfig loadTagMappingConfig(String tagMappingFilePath) tagMappingConfig = AviatorConfigManager.getInstance().getDefaultTagMappingConfig(); } - tagMappingConfig.validate(); - return tagMappingConfig; + return tagMappingConfig.resolveForSast(); } private static Map buildIssueCategoryLookup(List vulnerabilities) { @@ -123,61 +122,59 @@ private static Map buildIssueCategoryLookup(List return issueCategoryLookup; } - private static AuditOutcome performAviatorAudit( - ParsedFprData parsedData, IAviatorLogger logger, - String token, String appVersion, String url, String sscAppName, String sscAppVersion, - Map auditResponsesToFill, FilterSelection filterSelection, FprHandle fprHandle, List folderPriorityOrder) { + private static AuditOutcome performAviatorAudit(ParsedFprData parsedData, Map auditResponsesToFill, + FilterSelection filterSelection, AuditFprOptions options) { SourceLanguageResolver sourceLanguageResolver = new SourceLanguageResolver(parsedData.streamingFVDLProcessor.getFvdlMetadata()); parsedData.streamingFVDLProcessor.getFvdlMetadata().clearSourceFileTypeIndexes(); - IssueAuditor issueAuditor = new IssueAuditor( - parsedData.vulnerabilities, - parsedData.auditProcessor, - parsedData.auditIssueMap, - parsedData.fprInfo, - sscAppName, - sscAppVersion, - filterSelection, - logger, - folderPriorityOrder, - sourceLanguageResolver - ); - return issueAuditor.performAudit( - auditResponsesToFill, token, appVersion, parsedData.fprInfo.getBuildId(), url, fprHandle - ); + IssueAuditor issueAuditor = IssueAuditor.builder() + .vulnerabilities(parsedData.vulnerabilities) + .auditProcessor(parsedData.auditProcessor) + .auditIssueMap(parsedData.auditIssueMap) + .fprInfo(parsedData.fprInfo) + .filterSelection(filterSelection) + .sourceLanguageResolver(sourceLanguageResolver) + .fvdlMetadata(parsedData.streamingFVDLProcessor.getFvdlMetadata()) + .options(options) + .build(); + return issueAuditor.performAudit(auditResponsesToFill); } private static FPRAuditResult finalizeFprAudit( AuditOutcome auditOutcome, Map auditResponses, AuditProcessor auditProcessor, TagMappingConfig tagMappingConfig, - Map issueCategoryLookup, FPRInfo fprInfo) { + Map issueCategoryLookup, FPRInfo fprInfo, StreamingFVDLProcessor streamingFVDLProcessor) { int totalIssuesToAudit = auditOutcome.getTotalIssuesToAudit(); + int issuesSubmitted = getSubmittedAuditCount(auditResponses); if (auditResponses.isEmpty()) { if (totalIssuesToAudit == 0) { LOG.info("No issues were audited, skipping update and upload"); - return new FPRAuditResult(null, "SKIPPED", "No issues to audit", 0, totalIssuesToAudit); + return new FPRAuditResult(null, "SKIPPED", "No issues to audit", 0, totalIssuesToAudit, + issuesSubmitted, 0, Map.of(), 0, Map.of()); } else { LOG.error("No audit responses received for {} issues", totalIssuesToAudit); - return new FPRAuditResult(null, "FAILED", "No audit responses received from server", 0, totalIssuesToAudit); + return new FPRAuditResult(null, "FAILED", "No audit responses received from server", 0, totalIssuesToAudit, + issuesSubmitted, 0, Map.of(), 0, Map.of()); } } long issuesSuccessfullyAudited = auditResponses.values().stream() .filter(response -> "SUCCESS".equalsIgnoreCase(response.getStatus())) .count(); + Map skippedByReason = getSkippedAuditReasons(auditResponses, totalIssuesToAudit); + int issuesSkipped = skippedByReason.values().stream().mapToInt(Integer::intValue).sum(); String status; String message = null; - if (issuesSuccessfullyAudited == totalIssuesToAudit) { - status = "AUDITED"; - } else if (issuesSuccessfullyAudited > 0) { - status = "PARTIALLY_AUDITED"; - } else { - status = "FAILED"; + status = determineAuditStatus(issuesSuccessfullyAudited, issuesSkipped, totalIssuesToAudit, auditResponses.size()); + if ("SKIPPED".equals(status)) { + message = String.format("All %d issues were skipped", totalIssuesToAudit); + } else if ("FAILED".equals(status)) { String commonFailureReason = auditResponses.values().stream() + .filter(response -> !"SKIPPED".equalsIgnoreCase(response.getStatus())) .map(AuditResponse::getStatusMessage) .filter(msg -> msg != null && !msg.isBlank()) .findFirst() @@ -186,16 +183,69 @@ private static FPRAuditResult finalizeFprAudit( if (commonFailureReason.startsWith("Client-side pre-processing error: ")) { commonFailureReason = commonFailureReason.substring("Client-side pre-processing error: ".length()); } - message = String.format("All %d issues failed (%s)", totalIssuesToAudit, commonFailureReason); + message = String.format("No issues were audited (%d skipped; failure details: %s)", + issuesSkipped, commonFailureReason); } File updatedFile = null; if (issuesSuccessfullyAudited > 0) { updatedFile = auditProcessor.updateAndSaveAuditAndRemediationsXml( - auditResponses, tagMappingConfig, issueCategoryLookup, fprInfo); + auditResponses, tagMappingConfig, issueCategoryLookup, fprInfo, + streamingFVDLProcessor.getFvdlMetadata()); + } + AuditProcessor.RemediationGenerationMetric remediationGenerationMetric = auditProcessor.getLastRemediationGenerationMetric(); + + if (!skippedByReason.isEmpty()) { + LOG.info("Skipped audit issues by reason: {}", skippedByReason); + } + if (!remediationGenerationMetric.skippedByReason().isEmpty()) { + LOG.info("Skipped audit remediation generation by reason: {}", remediationGenerationMetric.skippedByReason()); } LOG.info("FPR audit process completed with status: {}", status); - return new FPRAuditResult(updatedFile, status, message, (int) issuesSuccessfullyAudited, totalIssuesToAudit); + return new FPRAuditResult(updatedFile, status, message, (int) issuesSuccessfullyAudited, totalIssuesToAudit, + issuesSubmitted, issuesSkipped, skippedByReason, remediationGenerationMetric.skippedRemediations(), + remediationGenerationMetric.skippedByReason()); + } + + static int getSubmittedAuditCount(Map auditResponses) { + return (int) auditResponses.values().stream() + .filter(AuditResponse::isSubmittedToAviator) + .count(); + } + + static String determineAuditStatus(long issuesSuccessfullyAudited, int issuesSkipped, + int totalIssuesToAudit, int responseCount) { + if (issuesSuccessfullyAudited == totalIssuesToAudit) { + return "AUDITED"; + } + if (issuesSuccessfullyAudited > 0) { + return "PARTIALLY_AUDITED"; + } + if (issuesSkipped == totalIssuesToAudit && responseCount == totalIssuesToAudit) { + return "SKIPPED"; + } + return "FAILED"; + } + + static Map getSkippedAuditReasons(Map auditResponses, int totalIssuesToAudit) { + Map skippedByReason = new LinkedHashMap<>(); + auditResponses.values().stream() + .filter(response -> "SKIPPED".equalsIgnoreCase(response.getStatus())) + .map(AuditFPR::getSkippedAuditReason) + .forEach(reason -> recordSkipped(skippedByReason, reason)); + int missingResponses = Math.max(0, totalIssuesToAudit - auditResponses.size()); + if (missingResponses > 0) { + skippedByReason.merge("No audit response received", missingResponses, Integer::sum); + } + return skippedByReason; + } + + private static String getSkippedAuditReason(AuditResponse response) { + return response.getAuditSkipReason().getDisplayMessage(); + } + + private static void recordSkipped(Map skippedByReason, String reason) { + skippedByReason.merge(reason, 1, Integer::sum); } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java new file mode 100644 index 00000000000..25132f1cc51 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapper.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResult; +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.util.Constants; + +/** + * Converts structured DAST decisions to conservative FCLI audit results. + */ +public final class DastAuditDecisionMapper { + private DastAuditDecisionMapper() {} + + public static AuditResponse toAuditResponse(DastAuditResult result) { + if (!(result instanceof DastAuditResult.Success success)) { + return AuditResponse.builder() + .issueId(result.issueId()) + .status(result.status()) + .statusMessage(result.statusMessage()) + .build(); + } + + AuditTier tier = success.tier(); + boolean tierOne = tier == AuditTier.GOLD; + String tagValue; + String prediction; + if (success.truePositive()) { + tagValue = Constants.EXPLOITABLE; + prediction = tierOne ? Constants.AVIATOR_REMEDIATION_REQUIRED : Constants.AVIATOR_LIKELY_TP; + } else { + tagValue = Constants.NOT_AN_ISSUE; + prediction = tierOne ? Constants.AVIATOR_NOT_AN_ISSUE : Constants.AVIATOR_LIKELY_FP; + } + + String comment = success.finalComment() != null && !success.finalComment().isBlank() + ? success.finalComment() + : success.reasoning(); + return AuditResponse.builder() + .issueId(success.issueId()) + .status("SUCCESS") + .tier(tier.name()) + .aviatorPredictionTag(prediction) + .isAviatorProcessed(true) + .auditResult(AuditResult.builder().tagValue(tagValue).comment(comment).build()) + .build(); + } + +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java new file mode 100644 index 00000000000..ebc66874c38 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFPR.java @@ -0,0 +1,319 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.dast.DastSession; +import com.fortify.cli.aviator.dast.StreamingWebInspectParser; +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.fpr.processor.AuditProcessor; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamResult; +import com.fortify.cli.aviator.grpc.DastAuditWorkItem; +import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FprHandle; + +import lombok.Builder; + +/** + * Coordinates parsing, filtering, server auditing, and DAST audit.xml updates. + */ +public final class DastAuditFPR { + private static final Logger LOG = LoggerFactory.getLogger(DastAuditFPR.class); + + private DastAuditFPR() {} + + @Builder + private record EligibilityResult( + List workItems, + int missingId, + int duplicate, + int suppressed, + int alreadyProcessed, + int humanAudited) { + private static class EligibilityResultBuilder { + private List workItems = new ArrayList<>(); + + private EligibilityResultBuilder addWorkItem(DastAuditWorkItem workItem) { + workItems.add(workItem); + return this; + } + + private EligibilityResultBuilder incrementMissingId() { + missingId++; + return this; + } + + private EligibilityResultBuilder incrementDuplicate() { + duplicate++; + return this; + } + + private EligibilityResultBuilder incrementSuppressed() { + suppressed++; + return this; + } + + private EligibilityResultBuilder incrementAlreadyProcessed() { + alreadyProcessed++; + return this; + } + + private EligibilityResultBuilder incrementHumanAudited() { + humanAudited++; + return this; + } + } + } + + private record ResponseSummary( + Map successfulResponses, + int truePositives, + int falsePositivesSuppressed, + int likelyFalsePositives, + int serverSkipped, + int failed, + int missingResponses) {} + + @FunctionalInterface + public interface StreamRunner { + CompletableFuture run( + DastAuditStreamConfig config, List workItems, int totalReportedIssues); + } + + public static DastAuditFprResult audit( + FprHandle fprHandle, + DastAuditStreamConfig config, + TagMappingConfig tagMappingConfig, + StreamRunner streamRunner) { + tagMappingConfig.validateForDast(); + var auditProcessor = new AuditProcessor(fprHandle); + Map auditIssues = auditProcessor.processAuditXML(); + var sessions = new StreamingWebInspectParser(fprHandle).parseSessions(); + EligibilityResult eligibility = eligibleWorkItems(sessions, auditIssues); + List workItems = eligibility.workItems(); + int totalReported = sessions.stream().mapToInt(session -> session.getIssues().size()).sum(); + int locallySkipped = totalReported - workItems.size(); + LOG.info("DAST audit eligibility: reported={}, eligible={}, skipped={} " + + "(missingId={}, duplicate={}, suppressed={}, alreadyProcessed={}, humanAudited={})", + totalReported, workItems.size(), locallySkipped, eligibility.missingId(), + eligibility.duplicate(), eligibility.suppressed(), eligibility.alreadyProcessed(), + eligibility.humanAudited()); + + if (workItems.isEmpty()) { + LOG.info("DAST audit skipped because no eligible findings remain"); + return emptyResult(totalReported, locallySkipped); + } + + CompletableFuture streamFuture = streamRunner.run(config, workItems, totalReported); + if (streamFuture == null) { + throw new AviatorTechnicalException("DAST audit stream did not return a completion future"); + } + DastAuditStreamResult streamResult = streamFuture.join(); + if (streamResult == null) { + throw new AviatorTechnicalException("DAST audit stream completed without a result"); + } + ResponseSummary summary = summarizeResponses(streamResult, workItems, tagMappingConfig); + var updatedFile = summary.successfulResponses().isEmpty() + ? null + : auditProcessor.updateAndSaveDastAuditXml(summary.successfulResponses(), tagMappingConfig); + return buildResult(streamResult, summary, updatedFile, totalReported, locallySkipped, workItems.size()); + } + + private static ResponseSummary summarizeResponses( + DastAuditStreamResult streamResult, + List workItems, + TagMappingConfig tagMappingConfig) { + Map successfulResponses = new LinkedHashMap<>(); + int truePositives = 0; + int falsePositivesSuppressed = 0; + int likelyFalsePositives = 0; + int failed = 0; + int serverSkipped = 0; + Set respondedIssueIds = new HashSet<>(); + + for (var result : streamResult.results()) { + respondedIssueIds.add(result.issueId()); + AuditResponse response = DastAuditDecisionMapper.toAuditResponse(result); + if ("SUCCESS".equalsIgnoreCase(response.getStatus()) && response.getAuditResult() != null) { + successfulResponses.put(result.issueId(), response); + var success = (DastAuditResult.Success) result; + LOG.debug("DAST issue {} audited successfully: confidence={}, tier={}, result={}", + result.issueId(), success.confidence(), response.getTier(), response.getAuditResult().getTagValue()); + if (Constants.EXPLOITABLE.equals(response.getAuditResult().getTagValue())) { + truePositives++; + } else if (isSuppressedFalsePositive(response, tagMappingConfig)) { + falsePositivesSuppressed++; + } else { + likelyFalsePositives++; + } + } else if ("SKIPPED".equalsIgnoreCase(result.status())) { + serverSkipped++; + LOG.debug("DAST issue {} skipped by server: statusMessage={}", + result.issueId(), result.statusMessage()); + } else { + failed++; + LOG.warn("DAST issue {} failed: status={}, statusMessage={}", + result.issueId(), result.status(), result.statusMessage()); + } + } + int missingResponses = countMissingResponses(workItems, respondedIssueIds); + return new ResponseSummary(successfulResponses, truePositives, falsePositivesSuppressed, + likelyFalsePositives, serverSkipped, failed + missingResponses, missingResponses); + } + + private static int countMissingResponses( + List workItems, + Set respondedIssueIds) { + int missingResponses = 0; + for (DastAuditWorkItem workItem : workItems) { + if (!respondedIssueIds.contains(workItem.issue().getId())) { + missingResponses++; + LOG.warn("DAST issue {} received no terminal server response", workItem.issue().getId()); + } + } + return missingResponses; + } + + private static DastAuditFprResult buildResult( + DastAuditStreamResult streamResult, + ResponseSummary summary, + File updatedFile, + int totalReported, + int locallySkipped, + int submitted) { + int succeeded = summary.successfulResponses().size(); + LOG.info("DAST audit responses: submitted={}, succeeded={}, serverSkipped={}, failed={}, missingResponses={}", + submitted, succeeded, summary.serverSkipped(), summary.failed(), summary.missingResponses()); + DastAuditFprStatus status = succeeded == submitted ? DastAuditFprStatus.AUDITED + : succeeded > 0 ? DastAuditFprStatus.PARTIALLY_AUDITED : DastAuditFprStatus.FAILED; + String message = succeeded == 0 ? "No DAST audit responses were successfully processed" : null; + return DastAuditFprResult.builder() + .updatedFile(updatedFile) + .status(status) + .message(message) + .totalReported(totalReported) + .eligible(submitted) + .submitted(submitted) + .succeeded(succeeded) + .truePositives(summary.truePositives()) + .falsePositivesSuppressed(summary.falsePositivesSuppressed()) + .likelyFalsePositives(summary.likelyFalsePositives()) + .skipped(locallySkipped + summary.serverSkipped()) + .failed(summary.failed()) + .reservedQuota(streamResult.reservedQuota()) + .exceededCount(streamResult.exceededCount()) + .unlimitedQuota(streamResult.unlimitedQuota()) + .quotaLastUpdated(streamResult.quotaLastUpdated()) + .nextQuotaUpdateMessage(streamResult.nextQuotaUpdateMessage()) + .build(); + } + + private static boolean isSuppressedFalsePositive(AuditResponse response, TagMappingConfig tagMappingConfig) { + boolean tierOne = AuditTier.fromServerValue(response.getTier()) == AuditTier.GOLD; + return Boolean.TRUE.equals(tagMappingConfig.getResult( + tierOne, TagMappingConfig.ResultType.FP).getSuppress()); + } + + private static EligibilityResult eligibleWorkItems( + List sessions, + Map auditIssues) { + var result = EligibilityResult.builder(); + var seenIssueIds = new HashSet(); + for (var session : sessions) { + for (var issue : session.getIssues()) { + String issueId = issue.getId(); + if (issueId == null || issueId.isBlank()) { + result.incrementMissingId(); + LOG.debug("Skipping DAST finding without an issue ID in session {}", session.getRequestId()); + continue; + } + if (!seenIssueIds.add(issueId)) { + result.incrementDuplicate(); + LOG.debug("Skipping duplicate DAST issue {} in session {}", issueId, session.getRequestId()); + continue; + } + AuditIssue auditIssue = auditIssues.get(issueId); + if (auditIssue != null && auditIssue.isSuppressed()) { + result.incrementSuppressed(); + LOG.debug("Skipping DAST issue {} because it is already suppressed", issueId); + continue; + } + if (auditIssue != null && isProcessedByAviator(auditIssue)) { + result.incrementAlreadyProcessed(); + LOG.debug("Skipping DAST issue {} because it is already processed by Aviator", issueId); + continue; + } + if (auditIssue != null && isHumanAudited(auditIssue)) { + result.incrementHumanAudited(); + LOG.debug("Skipping DAST issue {} because it is already audited by a human", issueId); + continue; + } + result.addWorkItem(new DastAuditWorkItem(session, issue)); + } + } + return result.build(); + } + + private static boolean isProcessedByAviator(AuditIssue auditIssue) { + Map tags = getTags(auditIssue); + return Constants.PROCESSED_BY_AVIATOR.equalsIgnoreCase(tags.get(Constants.AVIATOR_STATUS_TAG_ID)) + || tags.containsKey(Constants.AVIATOR_EXPECTED_OUTCOME_TAG_ID); + } + + private static boolean isHumanAudited(AuditIssue auditIssue) { + Map tags = getTags(auditIssue); + return isAuditDecision(tags.get(Constants.AUDITOR_STATUS_TAG_ID)) + || isAuditDecision(tags.get(Constants.FOD_TAG_ID)) + || isAnalysisDecision(tags.get(Constants.ANALYSIS_TAG_ID)); + } + + private static Map getTags(AuditIssue auditIssue) { + return auditIssue.getTags() == null ? Map.of() : auditIssue.getTags(); + } + + private static boolean isAuditDecision(String value) { + return value != null && !value.isBlank() + && !Constants.PENDING_REVIEW.equalsIgnoreCase(value) + && !"Pending Review".equalsIgnoreCase(value); + } + + private static boolean isAnalysisDecision(String value) { + return isAuditDecision(value) && !"Not Set".equalsIgnoreCase(value); + } + + private static DastAuditFprResult emptyResult(int totalReported, int skipped) { + return DastAuditFprResult.builder() + .status(DastAuditFprStatus.SKIPPED) + .message("No eligible DAST findings to audit") + .totalReported(totalReported) + .skipped(skipped) + .build(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java new file mode 100644 index 00000000000..005932f3059 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprResult.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import java.io.File; + +import lombok.Builder; + +/** + * Summary of processing one DAST FPR. + */ +@Builder +public record DastAuditFprResult( + File updatedFile, + DastAuditFprStatus status, + String message, + int totalReported, + int eligible, + int submitted, + int succeeded, + int truePositives, + int falsePositivesSuppressed, + int likelyFalsePositives, + int skipped, + int failed, + int reservedQuota, + int exceededCount, + boolean unlimitedQuota, + String quotaLastUpdated, + String nextQuotaUpdateMessage +) {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprStatus.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprStatus.java new file mode 100644 index 00000000000..09c9e385754 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/DastAuditFprStatus.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +/** Outcome of processing a DAST FPR. */ +public enum DastAuditFprStatus { + AUDITED, + PARTIALLY_AUDITED, + SKIPPED, + FAILED +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java index d25c1286183..79e400cd0f1 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/IssueAuditor.java @@ -14,11 +14,14 @@ import static com.fortify.cli.aviator.util.Constants.DEFAULT_PING_INTERVAL_SECONDS; +import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -35,11 +38,12 @@ import com.fortify.cli.aviator._common.exception.AviatorQuotaFilterException; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.audit.model.AuditFprOptions; import com.fortify.cli.aviator.audit.model.AuditOutcome; import com.fortify.cli.aviator.audit.model.AuditResponse; import com.fortify.cli.aviator.audit.model.FilterSelection; import com.fortify.cli.aviator.audit.model.UserPrompt; -import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.filter.Filter; import com.fortify.cli.aviator.fpr.filter.FilterSet; @@ -48,13 +52,16 @@ import com.fortify.cli.aviator.fpr.filter.VulnerabilityFilterer; import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.fpr.model.FPRInfo; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; import com.fortify.cli.aviator.fpr.processor.AuditProcessor; import com.fortify.cli.aviator.grpc.AviatorGrpcClient; import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; import com.fortify.cli.aviator.util.Constants; -import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; import com.fortify.cli.aviator.util.StringUtil; +import lombok.Builder; + public class IssueAuditor { @@ -80,16 +87,17 @@ public class IssueAuditor { private TagDefinition humanAuditTag; private TagDefinition aviatorStatusTag; private final SourceLanguageResolver sourceLanguageResolver; + private final FVDLMetadata fvdlMetadata; + private final AuditFprOptions options; + private final Set resultTagIds; - private final IAviatorLogger logger; - private final List customPriorityOrder; - + @Builder public IssueAuditor(List vulnerabilities, AuditProcessor auditProcessor, Map auditIssueMap, - FPRInfo fprInfo, String SSCApplicationName, String SSCApplicationVersion, - FilterSelection filterSelection, IAviatorLogger logger, List customPriorityOrder, - SourceLanguageResolver sourceLanguageResolver) { - this.logger = logger; - this.customPriorityOrder = customPriorityOrder; + FPRInfo fprInfo, FilterSelection filterSelection, SourceLanguageResolver sourceLanguageResolver, + FVDLMetadata fvdlMetadata, AuditFprOptions options) { + Objects.requireNonNull(options, "options"); + Objects.requireNonNull(options.getSourceDecoder(), "sourceDecoder"); + this.options = options; this.MAX_PER_CATEGORY = Constants.MAX_PER_CATEGORY; this.MAX_TOTAL = Constants.MAX_TOTAL; this.MAX_PER_CATEGORY_EXCEEDED = Constants.MAX_PER_CATEGORY_EXCEEDED; @@ -100,11 +108,13 @@ public IssueAuditor(List vulnerabilities, AuditProcessor auditPro this.auditIssueMap = auditIssueMap; this.fprInfo = fprInfo; this.filterSelection = filterSelection; - this.SSCApplicationName = SSCApplicationName; - this.SSCApplicationVersion = SSCApplicationVersion; + this.SSCApplicationName = options.getSscAppName(); + this.SSCApplicationVersion = options.getSscAppVersion(); this.sourceLanguageResolver = sourceLanguageResolver; + this.fvdlMetadata = fvdlMetadata; this.analysisTag = fprInfo.getFilterTemplate().getTagDefinitions().stream().filter(t -> "Analysis".equalsIgnoreCase(t.getName())).findFirst().orElse(null); this.resultsTag = resolveResultTag("", "", analysisTag); + this.resultTagIds = resolveResultTagIds(); } private TagDefinition resolveResultTag(String tagName, String tagGuid, TagDefinition analysisTag) { @@ -148,29 +158,30 @@ private TagDefinition resolveHumanAuditStatus() { return new TagDefinition(name, id, values, false); } - public AuditOutcome performAudit(Map auditResponses, String token, - String projectName, String projectBuildId, String url, FprHandle fprHandle) { - projectName = StringUtil.isEmpty(projectName) ? projectBuildId : projectName; - logger.progress("Starting audit for project: %s", projectName); + public AuditOutcome performAudit(Map auditResponses) { + String projectName = StringUtil.isEmpty(options.getAppVersion()) ? fprInfo.getBuildId() : options.getAppVersion(); + options.getLogger().progress("Starting audit for project: %s", projectName); ConcurrentLinkedDeque promptsToAudit = prepareAndFilterPrompts(); int totalIssuesToAudit = promptsToAudit.size(); - logger.progress("Final count of issues to be audited: %d", totalIssuesToAudit); + options.getLogger().progress("Final count of issues to be audited: %d", totalIssuesToAudit); if (promptsToAudit.isEmpty()) { - logger.progress("Audit skipped - no issues to process after filtering."); + options.getLogger().progress("Audit skipped - no issues to process after filtering."); } else { - try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(url, logger, DEFAULT_PING_INTERVAL_SECONDS)) { + try (AviatorGrpcClient client = AviatorGrpcClientHelper.createClient(options.getUrl(), options.getLogger(), DEFAULT_PING_INTERVAL_SECONDS)) { CompletableFuture> future = - client.processBatchRequests(promptsToAudit, projectName, fprInfo.getBuildId(), SSCApplicationName, SSCApplicationVersion, token, fprHandle, customPriorityOrder); + client.processBatchRequests(promptsToAudit, projectName, fprInfo.getBuildId(), SSCApplicationName, + SSCApplicationVersion, options.getToken(), options.getFprHandle(), + options.getFolderPriorityOrder(), options.getSourceDecoder(), fvdlMetadata); Map responses = future.get(500, TimeUnit.MINUTES); responses.forEach((requestId, response) -> auditResponses.put(response.getIssueId(), response)); - logger.progress("Audit completed"); + options.getLogger().progress("Audit completed"); } catch (ExecutionException e) { Throwable cause = e.getCause(); // Handle quota filtering exception (all issues filtered out) if (cause instanceof AviatorQuotaFilterException) { - logger.progress("All issues filtered out due to quota constraints: %s", cause.getMessage()); + options.getLogger().progress("All issues filtered out due to quota constraints: %s", cause.getMessage()); // Update totalIssuesToAudit to reflect actual auditable count (0) totalIssuesToAudit = 0; } else if (cause instanceof AviatorSimpleException) { @@ -181,10 +192,10 @@ public AuditOutcome performAudit(Map auditResponses, Stri throw new AviatorTechnicalException("Unexpected error during audit execution", cause); } } catch (TimeoutException e) { - logger.error("Audit failed due to timeout after 500 minutes"); + options.getLogger().error("Audit failed due to timeout after 500 minutes"); throw new AviatorTechnicalException("Audit timed out after 500 minutes", e); } catch (InterruptedException e) { - logger.error("Audit failed due to interruption"); + options.getLogger().error("Audit failed due to interruption"); Thread.currentThread().interrupt(); throw new AviatorTechnicalException("Audit interrupted", e); } @@ -225,31 +236,134 @@ private ConcurrentLinkedDeque prepareAndFilterPrompts() { private boolean shouldInclude(UserPrompt userPrompt) { + AuditIssue auditIssue = auditIssue(userPrompt); + if (options.isForceReaudit()) { + if (hasHumanTriage(auditIssue)) { + LOG.debug("Skipping force re-audit for suppressed or human-triaged issue ID: {}", + userPrompt.getIssueData().getInstanceID()); + return false; + } + if (isAviatorWork(auditIssue)) { + LOG.debug("Including previously processed Aviator issue for force re-audit: {}", + userPrompt.getIssueData().getInstanceID()); + return true; + } + } if (isAudited(userPrompt)) { LOG.debug("Skipping already audited issue ID: {}", userPrompt.getIssueData().getInstanceID()); return false; } - if (humanAuditTag != null) { - String issueId = userPrompt.getIssueData().getInstanceID(); - String status = Optional.ofNullable(auditIssueMap.get(issueId)).map(AuditIssue::getTags).map(tags -> tags.get("604f0fbe-b5fe-47cd-a9cb-587ad8ebe93a")).orElse(null); - if (!StringUtil.isEmpty(status) && !Constants.PENDING_REVIEW.equalsIgnoreCase(status)) { - LOG.debug("Skipping because already manually audited: {}", issueId); - return false; + if (humanAuditTag != null && isManuallyAudited(auditIssue)) { + LOG.debug("Skipping because already manually audited: {}", userPrompt.getIssueData().getInstanceID()); + return false; + } + + if (aviatorStatusTag != null && isProcessedByAviator(auditIssue)) { + LOG.debug("Skipping already processed by Aviator: {}", userPrompt.getIssueData().getInstanceID()); + return false; + } + + return true; + } + + private AuditIssue auditIssue(UserPrompt userPrompt) { + return auditIssueMap.get(userPrompt.getIssueData().getInstanceID()); + } + + private static boolean isManuallyAudited(AuditIssue auditIssue) { + if (auditIssue == null || auditIssue.getTags() == null) { + return false; + } + return !StringUtil.isPendingReviewValue(auditIssue.getTags().get(Constants.FOD_TAG_ID)); + } + + private boolean isProcessedByAviator(AuditIssue auditIssue) { + if (auditIssue == null || auditIssue.getTags() == null) { + return false; + } + String status = auditIssue.getTags().get(Constants.AVIATOR_STATUS_TAG_ID); + return !StringUtil.isEmpty(status) && Constants.PROCESSED_BY_AVIATOR.equalsIgnoreCase(status); + } + + private boolean hasHumanTriage(AuditIssue auditIssue) { + if (auditIssue == null) { + return false; + } + if (auditIssue.isSuppressed()) { + return true; + } + boolean processedByAviator = isProcessedByAviator(auditIssue); + for (String tagId : resultTagIds) { + if (!isResultTagSet(auditIssue, tagId)) { + continue; + } + String username = lastWriterUsername(auditIssue, tagId); + if (Constants.isAviatorAuditUsername(username)) { + continue; + } + if (!StringUtil.isEmpty(username) || Constants.FOD_TAG_ID.equalsIgnoreCase(tagId) || !processedByAviator) { + return true; } } + return false; + } - if (aviatorStatusTag != null) { - String issueId = userPrompt.getIssueData().getInstanceID(); - String status = Optional.ofNullable(auditIssueMap.get(issueId)).map(AuditIssue::getTags).map(tags -> tags.get("FB7B0462-2C2E-46D9-811A-DCC1F3C83051")).orElse(null); - if (!StringUtil.isEmpty(status) && Constants.PROCESSED_BY_AVIATOR.equalsIgnoreCase(status)) { - LOG.debug("Skipping already PROCESSED_BY_AVIATOR: {}", issueId); - return false; + private boolean isAviatorWork(AuditIssue auditIssue) { + if (isProcessedByAviator(auditIssue)) { + return true; + } + for (String tagId : resultTagIds) { + if (isResultTagSet(auditIssue, tagId) && Constants.isAviatorAuditUsername(lastWriterUsername(auditIssue, tagId))) { + return true; } } + return false; + } - return true; + private boolean isResultTagSet(AuditIssue auditIssue, String tagId) { + if (auditIssue == null) { + return false; + } + Map tags = auditIssue.getTags(); + return tags != null && !StringUtil.isPendingReviewValue(tags.get(tagId)); + } + + private String lastWriterUsername(AuditIssue auditIssue, String tagId) { + Map lastTagUsernames = auditIssue.getLastTagUsernames(); + if (lastTagUsernames == null) { + return null; + } + String username = lastTagUsernames.get(tagId); + return StringUtil.isEmpty(username) ? null : username; + } + + private Set resolveResultTagIds() { + Set tagIds = new LinkedHashSet<>(List.of( + Constants.ANALYSIS_TAG_ID, Constants.AUDITOR_STATUS_TAG_ID, Constants.FOD_TAG_ID)); + if (analysisTag != null && !StringUtil.isEmpty(analysisTag.getId())) { + tagIds.add(analysisTag.getId()); + } + String mappedTagId = mappedTagId(); + if (!StringUtil.isEmpty(mappedTagId)) { + tagIds.add(mappedTagId); + } + return tagIds; + } + + private String mappedTagId() { + String tagMappingPath = options.getTagMappingPath(); + if (tagMappingPath == null || tagMappingPath.isBlank()) { + return null; + } + try { + TagMappingConfig config = ResourceUtil.loadYamlFile(new File(tagMappingPath), TagMappingConfig.class); + return config == null ? null : config.getTag_id(); + } catch (Exception e) { + LOG.debug("Could not read tag mapping file {}", tagMappingPath, e); + return null; + } } private boolean isAudited(UserPrompt userPrompt) { @@ -264,7 +378,7 @@ private boolean isAudited(UserPrompt userPrompt) { String auditorStatusTag = Constants.AUDITOR_STATUS_TAG_ID; String auditorStatusValue = tags.get(auditorStatusTag); - if (auditorStatusValue != null && !auditorStatusValue.equalsIgnoreCase("Pending Review")) { + if (!StringUtil.isPendingReviewValue(auditorStatusValue)) { return true; } @@ -281,12 +395,12 @@ private boolean isAudited(UserPrompt userPrompt) { if (analysisTag != null && tags.containsKey(analysisTag.getId())) { String tagValue = tags.get(analysisTag.getId()); - if (tagValue != null && !tagValue.equalsIgnoreCase("Not Set") && !tagValue.equalsIgnoreCase(Constants.PENDING_REVIEW) && !tagValue.trim().isEmpty()) { + if (!StringUtil.isPendingReviewValue(tagValue)) { return true; } } - if (tags.containsKey(analysisTagS) && !tags.get(analysisTagS).equalsIgnoreCase("Not Set") && !StringUtil.isEmpty(tags.get(analysisTagS))) { + if (tags.containsKey(analysisTagS) && !StringUtil.isPendingReviewValue(tags.get(analysisTagS))) { return true; } } @@ -334,7 +448,7 @@ private List filterVulnerabilities(List allVulnera .flatMap(List::stream) .distinct() .collect(Collectors.toList()); - logger.info("FilterSet '{}' applied. {} of {} total vulnerabilities remain.", fs.getTitle(), result.size(), allVulnerabilities.size()); + options.getLogger().info("FilterSet '{}' applied. {} of {} total vulnerabilities remain.", fs.getTitle(), result.size(), allVulnerabilities.size()); return result; } else { Set targetFolderIds = fs.getFolderDefinitions().stream() @@ -353,7 +467,7 @@ private List filterVulnerabilities(List allVulnera .distinct() .collect(Collectors.toList()); - logger.info("Filtered by folder(s) '{}'. {} vulnerabilities remain.", targetFolderNames, result.size()); + options.getLogger().info("Filtered by folder(s) '{}'. {} vulnerabilities remain.", targetFolderNames, result.size()); return result; } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java index 54b8c803038..f05b9dffa59 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditFprOptions.java @@ -14,6 +14,8 @@ import java.util.List; import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.util.FprHandle; import lombok.Builder; @@ -34,4 +36,6 @@ public class AuditFprOptions { private final boolean noFilterSet; private final List folderNames; private final List folderPriorityOrder; + @Builder.Default private final boolean forceReaudit = false; + @Builder.Default private final ISourceDecoder sourceDecoder = SourceDecoders.defaults(); } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditResponse.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditResponse.java index 220379cf776..8406c07cdde 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditResponse.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditResponse.java @@ -12,32 +12,57 @@ */ package com.fortify.cli.aviator.audit.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.formkiq.graalvm.annotations.Reflectable; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; @Data -@AllArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor @Builder @Reflectable public class AuditResponse { + @Getter + @RequiredArgsConstructor + public enum AuditSkipReason { + SOURCE_FILE_DECODE_FAILED( + "Could not decode source file%s: %s%s", + "Source file decode failed"), + SOURCE_FILE_READ_FAILED( + "%s could not be read from the FPR%s", + "Source file read failed"), + SKIPPED_BY_AVIATOR("SKIPPED", "Skipped by Aviator"); + + private final String messageFormat; + private final String displayMessage; + + public String format(Object... args) { + return String.format(messageFormat, args); + } + } + private AuditResult auditResult; private int inputToken; private int outputToken; - private String status; private String statusMessage; + @JsonIgnore + private AuditSkipReason auditSkipReason; private String issueId; + @JsonIgnore + private boolean submittedToAviator; private String tier; private String aviatorPredictionTag; private Boolean isAviatorProcessed; private String userPrompt; private String systemPrompt; - } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditTier.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditTier.java new file mode 100644 index 00000000000..be63d1100f4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/AuditTier.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit.model; + +/** Supported Aviator audit decision tiers. */ +public enum AuditTier { + GOLD, + SILVER; + + /** + * Parses a server-provided tier, defaulting unknown values to the conservative tier. + * + * @param value server-provided tier value + * @return parsed tier, or {@link #SILVER} when missing or unknown + */ + public static AuditTier fromServerValue(String value) { + return GOLD.name().equalsIgnoreCase(value) ? GOLD : SILVER; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/FPRAuditResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/FPRAuditResult.java index 94d8b9a4e18..db7704cd69e 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/FPRAuditResult.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/audit/model/FPRAuditResult.java @@ -13,6 +13,7 @@ package com.fortify.cli.aviator.audit.model; import java.io.File; +import java.util.Map; import lombok.Data; @@ -23,13 +24,39 @@ public class FPRAuditResult { private String message; private int issuesSuccessfullyAudited; private int totalIssuesToAudit; + private int issuesSubmitted; + private int issuesSkipped; + private Map skippedByReason; + private int remediationGenerationSkipped; + private Map remediationGenerationSkippedByReason; public FPRAuditResult(File updatedFile, String status, String message, int issuesSuccessfullyAudited, int totalIssuesToAudit) { + this(updatedFile, status, message, issuesSuccessfullyAudited, totalIssuesToAudit, + totalIssuesToAudit, Math.max(0, totalIssuesToAudit - issuesSuccessfullyAudited), Map.of(), 0, Map.of()); + } + + public FPRAuditResult(File updatedFile, String status, String message, + int issuesSuccessfullyAudited, int totalIssuesToAudit, int issuesSkipped, + Map skippedByReason, int remediationGenerationSkipped, + Map remediationGenerationSkippedByReason) { + this(updatedFile, status, message, issuesSuccessfullyAudited, totalIssuesToAudit, totalIssuesToAudit, + issuesSkipped, skippedByReason, remediationGenerationSkipped, remediationGenerationSkippedByReason); + } + + public FPRAuditResult(File updatedFile, String status, String message, + int issuesSuccessfullyAudited, int totalIssuesToAudit, int issuesSubmitted, + int issuesSkipped, Map skippedByReason, int remediationGenerationSkipped, + Map remediationGenerationSkippedByReason) { this.updatedFile = updatedFile; this.status = status; this.message = message; this.issuesSuccessfullyAudited = issuesSuccessfullyAudited; this.totalIssuesToAudit = totalIssuesToAudit; + this.issuesSubmitted = issuesSubmitted; + this.issuesSkipped = issuesSkipped; + this.skippedByReason = skippedByReason == null ? Map.of() : Map.copyOf(skippedByReason); + this.remediationGenerationSkipped = remediationGenerationSkipped; + this.remediationGenerationSkippedByReason = remediationGenerationSkippedByReason == null ? Map.of() : Map.copyOf(remediationGenerationSkippedByReason); } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java index 2f21bf4c364..f5f033707c8 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/config/TagMappingConfig.java @@ -34,6 +34,8 @@ public class TagMappingConfig { private String tag_id = "87f2364f-dcd4-49e6-861d-f8d3f351686b"; private List suppression_exclusions = new ArrayList<>(); private Mapping mapping; + private ProductMapping sast; + private ProductMapping dast; public void setSuppression_exclusions(List suppression_exclusions) { this.suppression_exclusions = suppression_exclusions == null ? new ArrayList<>() : suppression_exclusions; @@ -61,6 +63,46 @@ public void validate() { } } + public void validateForDast() { + validate(); + if (hasSuppressionExclusions()) { + throw new AviatorSimpleException( + "Invalid DAST tag mapping configuration: suppression_exclusions are not supported"); + } + } + + public TagMappingConfig resolveForSast() { + TagMappingConfig resolved = resolve(sast); + resolved.validate(); + return resolved; + } + + public TagMappingConfig resolveForDast() { + TagMappingConfig resolved = resolve(dast); + resolved.validateForDast(); + return resolved; + } + + private TagMappingConfig resolve(ProductMapping productMapping) { + TagMappingConfig resolved = new TagMappingConfig(); + resolved.setTag_id(productMapping != null && productMapping.getTag_id() != null + ? productMapping.getTag_id() + : tag_id); + resolved.setMapping(productMapping != null && productMapping.getMapping() != null + ? productMapping.getMapping() + : mapping); + List productExclusions = productMapping == null + ? null + : productMapping.getSuppression_exclusions(); + List effectiveExclusions = productExclusions != null + ? productExclusions + : suppression_exclusions; + resolved.setSuppression_exclusions(effectiveExclusions == null + ? Collections.emptyList() + : new ArrayList<>(effectiveExclusions)); + return resolved; + } + public boolean hasSuppressionExclusions() { return suppression_exclusions != null && suppression_exclusions.stream() @@ -84,6 +126,40 @@ public boolean isSuppressionExcluded(SuppressionExclusionContext context) { .anyMatch(exclusion -> exclusion.matches(context)); } + public Result getResult(boolean tierOne, ResultType resultType) { + Tier tier = tierOne ? mapping.getTier_1() : mapping.getTier_2(); + return switch (resultType) { + case FP -> tier.getFp(); + case TP -> tier.getTp(); + case UNSURE -> tier.getUnsure(); + }; + } + + public Set getMappedValues() { + if (mapping == null) { + return Collections.emptySet(); + } + LinkedHashSet values = new LinkedHashSet<>(); + addMappedValues(values, mapping.getTier_1()); + addMappedValues(values, mapping.getTier_2()); + return Collections.unmodifiableSet(values); + } + + private void addMappedValues(Set values, Tier tier) { + if (tier == null) { + return; + } + addMappedValue(values, tier.getFp()); + addMappedValue(values, tier.getTp()); + addMappedValue(values, tier.getUnsure()); + } + + private void addMappedValue(Set values, Result result) { + if (result != null && result.getValue() != null && !result.getValue().isBlank()) { + values.add(result.getValue()); + } + } + private void validateTier(List errors, String path, Tier tier) { validateRequired(errors, path, tier); if (tier != null) { @@ -259,6 +335,19 @@ public static class Mapping { private Tier tier_2; } + @Data @Reflectable + public static class ProductMapping { + private String tag_id; + private List suppression_exclusions; + private Mapping mapping; + } + + public enum ResultType { + FP, + TP, + UNSURE + } + @Data @Reflectable public static class Tier { private Result fp; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java index cdb564c258f..5b174eb209f 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastIssue.java @@ -13,8 +13,8 @@ package com.fortify.cli.aviator.dast; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -37,6 +37,7 @@ public class DastIssue { private String cweDescription; // Full CWE description text private String sessionUrl; // URL of the session containing this issue private List reproStepUrls = new ArrayList<>(); + private List reproSteps = new ArrayList<>(); // ReportSections for audit context private String summary; // Summary from ReportSection @@ -46,7 +47,7 @@ public class DastIssue { private String referenceInfo; // Reference Info from ReportSection // Additional classifications - private Map classifications = new HashMap<>(); // kind -> value + private Map classifications = new LinkedHashMap<>(); // kind -> value // Audit status private boolean suppressed = false; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java new file mode 100644 index 00000000000..bcf4898d25a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/DastReproStep.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import lombok.Data; + +/** + * Represents one ordered navigation or attack step reported by WebInspect. + */ +@Data +public class DastReproStep { + private String source; + private String url; + private String postParams; +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java index c3fd3994dd2..f6ac5ec2cf3 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/dast/StreamingWebInspectParser.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.util.FprHandle; /** @@ -64,7 +65,7 @@ public List parse() { Path webInspectPath = fprHandle.getPath("/webinspect.xml"); if (!Files.exists(webInspectPath)) { - throw new RuntimeException("webinspect.xml not found in DAST FPR"); + throw new AviatorTechnicalException("webinspect.xml not found in DAST FPR"); } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { @@ -86,9 +87,9 @@ public List parse() { logger.info("Parsed {} DAST issues from webinspect.xml (streaming)", issues.size()); } catch (XMLStreamException e) { - throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to parse webinspect.xml", e); } catch (IOException e) { - throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to read webinspect.xml", e); } return issues; @@ -106,7 +107,7 @@ public List parseSessions() { Path webInspectPath = fprHandle.getPath("/webinspect.xml"); if (!Files.exists(webInspectPath)) { - throw new RuntimeException("webinspect.xml not found in DAST FPR"); + throw new AviatorTechnicalException("webinspect.xml not found in DAST FPR"); } try (InputStream inputStream = Files.newInputStream(webInspectPath)) { @@ -133,9 +134,9 @@ public List parseSessions() { sessions.size(), totalIssues); } catch (XMLStreamException e) { - throw new RuntimeException("Failed to parse webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to parse webinspect.xml", e); } catch (IOException e) { - throw new RuntimeException("Failed to read webinspect.xml: " + e.getMessage(), e); + throw new AviatorTechnicalException("Failed to read webinspect.xml", e); } return sessions; @@ -429,12 +430,12 @@ private void parseReproSteps(XMLStreamReader reader, DastIssue issue) while (reader.hasNext()) { int event = reader.next(); - if (event == XMLStreamConstants.START_ELEMENT) { - if ("Url".equals(reader.getLocalName())) { - String url = readElementText(reader); - if (url != null && !url.isEmpty()) { - issue.getReproStepUrls().add(url); - } + if (event == XMLStreamConstants.START_ELEMENT + && "ReproStep".equals(reader.getLocalName())) { + DastReproStep step = parseReproStep(reader); + if (step.getUrl() != null && !step.getUrl().isEmpty()) { + issue.getReproStepUrls().add(step.getUrl()); + issue.getReproSteps().add(step); } } else if (event == XMLStreamConstants.END_ELEMENT && "ReproSteps".equals(reader.getLocalName())) { @@ -443,6 +444,25 @@ private void parseReproSteps(XMLStreamReader reader, DastIssue issue) } } + private DastReproStep parseReproStep(XMLStreamReader reader) throws XMLStreamException { + var step = new DastReproStep(); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + switch (reader.getLocalName()) { + case "Source" -> step.setSource(readElementText(reader)); + case "Url" -> step.setUrl(readElementText(reader)); + case "PostParams" -> step.setPostParams(readElementText(reader)); + default -> { } + } + } else if (event == XMLStreamConstants.END_ELEMENT + && "ReproStep".equals(reader.getLocalName())) { + return step; + } + } + return step; + } + // ========================================================================= // ReportSection parsing // ========================================================================= diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java new file mode 100644 index 00000000000..820839f2d8f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnostics.java @@ -0,0 +1,340 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.Arrays; +import java.util.List; +import java.util.StringJoiner; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator._common.exception.AviatorBugException; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.common.json.JsonHelper; + +public class AviatorConnectionDiagnostics { + private static final List TRANSPORT_STAGES = List.of( + AviatorDiagnosticStage.DNS, + AviatorDiagnosticStage.TCP, + AviatorDiagnosticStage.PROXY, + AviatorDiagnosticStage.TLS, + AviatorDiagnosticStage.GRPC); + + private final IAviatorDiagnosticProbe probe; + + public AviatorConnectionDiagnostics() { + this(new AviatorDefaultDiagnosticProbe()); + } + + /** Visible for tests and product helpers that inject a probe. */ + public AviatorConnectionDiagnostics(IAviatorDiagnosticProbe probe) { + this.probe = probe; + } + + public AviatorDiagnosticReport diagnose(String url, int timeoutSeconds, String sourceType) { + var report = new AviatorDiagnosticReport(); + report.begin(AviatorDiagnosticStage.ENDPOINT); + try { + return diagnoseValidated(report, AviatorGrpcClientHelper.createConnectionPlan(url), timeoutSeconds, + sourceType); + } catch (AviatorSimpleException e) { + failEndpoint(report, e); + skipAfter(report, null, AviatorDiagnosticStage.ENDPOINT, "endpoint validation failed"); + return report; + } + } + + /** + * Run diagnostics for a pre-built connection plan (tests inject plans to avoid ambient proxy env). + */ + public AviatorDiagnosticReport diagnose(AviatorConnectionPlan connectionPlan, int timeoutSeconds, String sourceType) { + var report = new AviatorDiagnosticReport(); + report.begin(AviatorDiagnosticStage.ENDPOINT); + return diagnoseValidated(report, connectionPlan, timeoutSeconds, sourceType); + } + + private AviatorDiagnosticReport diagnoseValidated(AviatorDiagnosticReport report, + AviatorConnectionPlan connectionPlan, int timeoutSeconds, String sourceType) { + report.pass(AviatorDiagnosticStage.ENDPOINT, + "Endpoint is valid: "+connectionPlan.normalizedUrl(), "No action required", + endpointEvidence(connectionPlan, sourceType)); + + if (!runDns(report, connectionPlan)) { + skipAfter(report, connectionPlan, AviatorDiagnosticStage.DNS, "DNS resolution failed"); + return report; + } + if (!runTcp(report, connectionPlan, timeoutSeconds)) { + skipAfter(report, connectionPlan, AviatorDiagnosticStage.TCP, "TCP connectivity failed"); + return report; + } + if (!runTunnelStages(report, connectionPlan, timeoutSeconds)) { + return report; + } + runGrpc(report, connectionPlan, timeoutSeconds); + return report; + } + + private static void failEndpoint(AviatorDiagnosticReport report, AviatorSimpleException e) { + var evidence = AviatorDiagnosticEvidence.errorEvidence(e); + if (e instanceof UnsupportedAviatorUrlSchemeException schemeFailure) { + evidence.put("scheme", schemeFailure.getScheme()); + evidence.put("providedUrl", schemeFailure.getProvidedUrl()); + report.fail(AviatorDiagnosticStage.ENDPOINT, + UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, + UnsupportedAviatorUrlSchemeException.STAGE_GUIDANCE, + evidence); + return; + } + report.fail(AviatorDiagnosticStage.ENDPOINT, + "Endpoint is invalid", "Use a valid Aviator host name and optional port", evidence); + } + + private boolean runDns(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan) { + report.begin(AviatorDiagnosticStage.DNS); + try { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + addAddresses(evidence, "resolvedAddresses", probe.resolve(connectionPlan.target().host())); + if (connectionPlan.proxyDescriptor().isPresent()) { + var proxy = connectionPlan.proxyDescriptor().get(); + addAddresses(evidence, "proxyResolvedAddresses", probe.resolve(proxy.getProxyHost())); + } + report.pass(AviatorDiagnosticStage.DNS, + "Host name resolved: "+addresses(evidence, "resolvedAddresses"), "No action required", evidence); + return true; + } catch (IOException e) { + report.fail(AviatorDiagnosticStage.DNS, + "DNS resolution failed", "Check the Aviator host name, DNS, VPN, or proxy settings", + AviatorDiagnosticEvidence.errorEvidence(e)); + return false; + } + } + + /** + * Probes TCP to the next hop (proxy or Aviator host) and closes the socket. + *

+ * Intentional second open: {@link #runTunnelStages} opens a new connection for + * CONNECT/TLS so a pure next-hop TCP failure stays distinct from proxy CONNECT or TLS + * handshake failure. Do not fold TCP into the tunnel solely to avoid a double connect. + */ + private boolean runTcp(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + report.begin(AviatorDiagnosticStage.TCP); + var proxyDescriptor = connectionPlan.proxyDescriptor(); + var nextHopHost = proxyDescriptor.map(proxy -> proxy.getProxyHost()).orElse(connectionPlan.target().host()); + var nextHopPort = proxyDescriptor.map(proxy -> proxy.getProxyPort()).orElse(connectionPlan.effectivePort()); + var evidence = nextHopEvidence(nextHopHost, nextHopPort, proxyDescriptor.isPresent()); + try { + probe.connect(nextHopHost, nextHopPort, timeoutSeconds); + report.pass(AviatorDiagnosticStage.TCP, + "TCP connection opened to "+nextHopHost+":"+nextHopPort, "No action required", evidence); + return true; + } catch (Exception e) { + putError(evidence, e); + var guidance = proxyDescriptor.isPresent() + ? "Check proxy host, port, credentials, and firewall access" + : "Check firewall, VPN, proxy, and port 443 access"; + report.fail(AviatorDiagnosticStage.TCP, "TCP connection failed", guidance, evidence); + return false; + } + } + + /** + * PROXY + TLS from one tunnel session (single CONNECT when proxy is configured). + * Runs after the TCP stage, which already opened and closed a next-hop probe socket + * so stage failures remain isolated (see {@link #runTcp}). + * + * @return true if TLS stage continued the pipeline (pass or alpn warn) + */ + private boolean runTunnelStages(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + int timeoutSeconds) { + var hasProxy = connectionPlan.proxyDescriptor().isPresent(); + // First stage that will be recorded from this shared tunnel session. + report.begin(hasProxy ? AviatorDiagnosticStage.PROXY : AviatorDiagnosticStage.TLS); + var tunnel = probe.probeTunnel(connectionPlan, timeoutSeconds); + if (tunnel instanceof AviatorTunnelResult.ProxyConnectFailed failed) { + appendProxyFailure(report, connectionPlan, failed); + skipAfter(report, connectionPlan, AviatorDiagnosticStage.PROXY, "proxy CONNECT failed"); + return false; + } + appendProxyPassIfConfigured(report, connectionPlan, tunnel); + if (hasProxy) { + report.begin(AviatorDiagnosticStage.TLS); + } + if (tunnel instanceof AviatorTunnelResult.TlsFailed failed) { + appendTlsFailure(report, connectionPlan, failed); + skipAfter(report, connectionPlan, AviatorDiagnosticStage.TLS, "TLS handshake failed"); + return false; + } + if (tunnel instanceof AviatorTunnelResult.TlsSucceeded ok) { + appendTlsSuccess(report, ok); + return true; + } + throw new AviatorBugException("Unhandled tunnel result: " + tunnel); + } + + private void appendProxyFailure(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + AviatorTunnelResult.ProxyConnectFailed failed) { + var evidence = AviatorDiagnosticEvidence.errorEvidence(failed.error()); + putProxyEvidence(evidence, connectionPlan); + report.fail(AviatorDiagnosticStage.PROXY, "Proxy CONNECT failed", + "Check proxy host, port, credentials, CONNECT allow-list, and authentication method", evidence); + } + + private void appendProxyPassIfConfigured(AviatorDiagnosticReport report, + AviatorConnectionPlan connectionPlan, AviatorTunnelResult tunnel) { + if (connectionPlan.proxyDescriptor().isEmpty()) { + return; + } + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("proxyConnectStatus", tunnel.proxyConnectStatus()); + putProxyEvidence(evidence, connectionPlan); + report.pass(AviatorDiagnosticStage.PROXY, + "Proxy CONNECT succeeded through "+proxyEndpoint(evidence), "No action required", evidence); + } + + private void appendTlsSuccess(AviatorDiagnosticReport report, AviatorTunnelResult.TlsSucceeded ok) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("tlsProtocol", ok.protocol()); + evidence.put("tlsCipherSuite", ok.cipherSuite()); + evidence.put("tlsPeerSubject", ok.peerSubject()); + evidence.put("tlsAlpnProtocol", ok.applicationProtocol()); + evidence.put("proxyConnectStatus", ok.proxyConnectStatusLine()); + evidence.put("tlsPhase", AviatorTlsPhase.HANDSHAKE.id()); + if (!"h2".equals(ok.applicationProtocol())) { + report.warn(AviatorDiagnosticStage.TLS, + tlsSummary("TLS works, but HTTP/2 was not enabled", ok), + "Allow ALPN h2 through the proxy or gateway to aviator-grpc-server", true, evidence); + } else { + report.pass(AviatorDiagnosticStage.TLS, + tlsSummary("TLS and HTTP/2 are available", ok), "No action required", evidence); + } + } + + private void appendTlsFailure(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + AviatorTunnelResult.TlsFailed failed) { + var evidence = AviatorDiagnosticEvidence.errorEvidence(failed.error()); + evidence.put("tlsPhase", failed.phase().id()); + if (failed.proxyConnectStatusLine() != null) { + evidence.put("proxyConnectStatus", failed.proxyConnectStatusLine()); + } + putProxyEvidence(evidence, connectionPlan); + if (failed.phase() == AviatorTlsPhase.HANDSHAKE) { + report.fail(AviatorDiagnosticStage.TLS, "TLS handshake failed", + "Check certificate trust, SNI, and TLS inspection settings", evidence); + return; + } + var guidance = connectionPlan.proxyDescriptor().isPresent() + ? "Check proxy host, port, credentials, and firewall access to the proxy" + : "Check firewall, VPN, proxy, and port access to the Aviator host"; + report.fail(AviatorDiagnosticStage.TLS, "Could not open connection for TLS probe", guidance, evidence); + } + + private void runGrpc(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + report.begin(AviatorDiagnosticStage.GRPC); + try { + applyGrpc(report, probe.probeGrpc(connectionPlan.originalUrl(), timeoutSeconds)); + } catch (Exception e) { + applyGrpc(report, AviatorGrpcReachabilityResult.probeError(e)); + } + } + + private void applyGrpc(AviatorDiagnosticReport report, AviatorGrpcReachabilityResult grpc) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + grpc.putEvidence(evidence); + if (grpc.pattern() != null) { + evidence.put("pattern", grpc.pattern().wireId()); + } + var summary = grpc.stageSummary(); + if (grpc.stagePass()) { + report.pass(AviatorDiagnosticStage.GRPC, summary, grpc.stageGuidance(), evidence); + } else { + report.fail(AviatorDiagnosticStage.GRPC, summary, grpc.stageGuidance(), evidence); + } + } + + private ObjectNode endpointEvidence(AviatorConnectionPlan connectionPlan, String sourceType) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("sourceType", sourceType); + evidence.put("targetHost", connectionPlan.target().host()); + evidence.put("targetPort", connectionPlan.effectivePort()); + evidence.put("normalizedUrl", connectionPlan.normalizedUrl()); + putProxyEvidence(evidence, connectionPlan); + return evidence; + } + + private static ObjectNode nextHopEvidence(String host, int port, boolean proxy) { + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + evidence.put("nextHopHost", host); + evidence.put("nextHopPort", port); + evidence.put("nextHopType", proxy ? "proxy" : "aviator"); + return evidence; + } + + private static void putProxyEvidence(ObjectNode evidence, AviatorConnectionPlan connectionPlan) { + connectionPlan.proxyDescriptor().ifPresent(proxy -> { + evidence.put("proxyHost", proxy.getProxyHost()); + evidence.put("proxyPort", proxy.getProxyPort()); + evidence.put("proxyAuthConfigured", proxy.getProxyUser() != null); + }); + } + + private static void putError(ObjectNode evidence, Exception e) { + AviatorDiagnosticEvidence.merge(evidence, AviatorDiagnosticEvidence.errorEvidence(e)); + } + + /** + * Append WARN skips for every transport stage strictly after {@code failedStage}. + * PROXY is omitted when the plan has no proxy (or plan is null after endpoint failure). + */ + private static void skipAfter(AviatorDiagnosticReport report, AviatorConnectionPlan connectionPlan, + AviatorDiagnosticStage failedStage, String reason) { + var hasProxy = connectionPlan != null && connectionPlan.proxyDescriptor().isPresent(); + var afterFailed = failedStage == AviatorDiagnosticStage.ENDPOINT; + for (var stage : TRANSPORT_STAGES) { + if (!afterFailed) { + if (stage == failedStage) { + afterFailed = true; + } + continue; + } + if (stage == AviatorDiagnosticStage.PROXY && !hasProxy) { + continue; + } + report.skipWarn(stage, reason, + "Resolve the previous failed required stage first", + AviatorDiagnosticEvidence.empty()); + } + } + + private static void addAddresses(ObjectNode evidence, String fieldName, InetAddress[] addresses) { + var array = evidence.putArray(fieldName); + Arrays.stream(addresses).map(InetAddress::getHostAddress).forEach(array::add); + } + + private static String addresses(ObjectNode evidence, String fieldName) { + var result = new StringJoiner(", "); + evidence.withArray(fieldName).forEach(address -> result.add(address.asText())); + return result.toString(); + } + + private static String proxyEndpoint(ObjectNode evidence) { + return evidence.path("proxyHost").asText()+":"+evidence.path("proxyPort").asInt(); + } + + private static String tlsSummary(String summary, AviatorTunnelResult.TlsSucceeded result) { + return summary+": "+result.protocol()+", ALPN "+result.applicationProtocol(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java new file mode 100644 index 00000000000..96ee570dfb7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbe.java @@ -0,0 +1,264 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; +import java.util.regex.Pattern; + +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper; +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; +import com.fortify.cli.common.http.ssl.trust.FcliTrustManager; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +public class AviatorDefaultDiagnosticProbe implements IAviatorDiagnosticProbe { + private static final Pattern NON_GRPC_HTTP_RESPONSE_PATTERN = Pattern.compile( + "HTTP status code (\\d+) invalid content-type: ([^\\s;]+(?:;\\s*[^\\s]+)?)", Pattern.CASE_INSENSITIVE); + private static final int MAX_CONNECT_HEADER_BYTES = 64 * 1024; + + @Override + public InetAddress[] resolve(String host) throws IOException { + return InetAddress.getAllByName(host); + } + + @Override + public void connect(String host, int port, int timeoutSeconds) throws IOException { + try (var socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), toMillis(timeoutSeconds)); + } + } + + @Override + public AviatorTunnelResult probeTunnel(AviatorConnectionPlan connectionPlan, int timeoutSeconds) { + var proxyConfigured = connectionPlan.proxyDescriptor().isPresent(); + try (var rawSocket = new Socket()) { + var opened = openTunnelSocket(rawSocket, connectionPlan, timeoutSeconds, proxyConfigured); + if (opened instanceof OpenFailed failed) { + return failed.result(); + } + if (opened instanceof OpenedSocket openedOk) { + return handshakeTls(rawSocket, connectionPlan, timeoutSeconds, proxyConfigured, + openedOk.proxyConnectStatus()); + } + // Sealed OpenResult exhaustiveness; should be unreachable. + throw new AviatorBugException("Unhandled tunnel open result: " + opened.getClass().getName()); + } catch (Exception e) { + if (e instanceof AviatorBugException bug) { + throw bug; + } + return new AviatorTunnelResult.TlsFailed(proxyConfigured, "not-used", AviatorTlsPhase.CONNECT, e); + } + } + + private OpenResult openTunnelSocket(Socket rawSocket, AviatorConnectionPlan connectionPlan, int timeoutSeconds, + boolean proxyConfigured) { + try { + if (proxyConfigured) { + var proxy = connectionPlan.proxyDescriptor().get(); + rawSocket.connect(new InetSocketAddress(proxy.getProxyHost(), proxy.getProxyPort()), toMillis(timeoutSeconds)); + rawSocket.setSoTimeout(toMillis(timeoutSeconds)); + var status = performProxyConnect(rawSocket, connectionPlan); + return new OpenedSocket(status); + } + rawSocket.connect(new InetSocketAddress(connectionPlan.target().host(), connectionPlan.effectivePort()), + toMillis(timeoutSeconds)); + rawSocket.setSoTimeout(toMillis(timeoutSeconds)); + return new OpenedSocket("not-used"); + } catch (AviatorProxyConnectException e) { + return new OpenFailed(new AviatorTunnelResult.ProxyConnectFailed(e)); + } catch (Exception e) { + return new OpenFailed(new AviatorTunnelResult.TlsFailed(proxyConfigured, "not-used", AviatorTlsPhase.CONNECT, e)); + } + } + + private AviatorTunnelResult handshakeTls(Socket rawSocket, AviatorConnectionPlan connectionPlan, int timeoutSeconds, + boolean proxyConfigured, String proxyConnectStatus) { + try { + var sslSocketFactory = createSslSocketFactory(); + try (var sslSocket = (SSLSocket) sslSocketFactory.createSocket( + rawSocket, connectionPlan.target().host(), connectionPlan.effectivePort(), true)) { + sslSocket.setSoTimeout(toMillis(timeoutSeconds)); + applyTlsParameters(sslSocket, connectionPlan.target().host()); + sslSocket.startHandshake(); + return toTlsSucceeded(sslSocket, proxyConfigured, proxyConnectStatus); + } + } catch (Exception e) { + var phase = AviatorTlsFailureDetector.isTlsFailure(e) ? AviatorTlsPhase.HANDSHAKE : AviatorTlsPhase.CONNECT; + return new AviatorTunnelResult.TlsFailed(proxyConfigured, proxyConnectStatus, phase, e); + } + } + + private static void applyTlsParameters(SSLSocket sslSocket, String host) { + SSLParameters parameters = sslSocket.getSSLParameters(); + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + parameters.setServerNames(List.of(new SNIHostName(host))); + parameters.setApplicationProtocols(new String[] {"h2"}); + sslSocket.setSSLParameters(parameters); + } + + private static AviatorTunnelResult.TlsSucceeded toTlsSucceeded(SSLSocket sslSocket, boolean proxyConfigured, + String proxyConnectStatus) throws Exception { + var session = sslSocket.getSession(); + var certificates = session.getPeerCertificates(); + var peerSubject = certificates.length > 0 && certificates[0] instanceof X509Certificate certificate + ? certificate.getSubjectX500Principal().getName() + : "unknown"; + return new AviatorTunnelResult.TlsSucceeded( + proxyConfigured, proxyConnectStatus, session.getProtocol(), session.getCipherSuite(), + peerSubject, sslSocket.getApplicationProtocol()); + } + + @Override + public AviatorGrpcReachabilityResult probeGrpc(String url, int timeoutSeconds) throws Exception { + try (var client = AviatorGrpcClientHelper.createClient(url)) { + client.probeGetDefaultQuota(timeoutSeconds); + return AviatorGrpcReachabilityResult.responseReceived("OK", "Default quota response received"); + } catch (StatusRuntimeException e) { + var status = e.getStatus(); + var responseReceived = isResponseReceived(status.getCode()); + var description = summarizeDescription(status.getDescription()); + var httpResponse = parseNonGrpcHttpResponse(description); + if (httpResponse != null) { + return AviatorGrpcReachabilityResult.nonGrpcHttp(status.getCode().name(), + httpResponse.statusCode(), httpResponse.contentType(), description); + } + if (responseReceived) { + return AviatorGrpcReachabilityResult.responseReceived(status.getCode().name(), description); + } + var tlsFailure = AviatorTlsFailureDetector.isTlsFailure(e, description) + || AviatorTlsFailureDetector.isTlsFailure(status.getCause(), description); + if (tlsFailure) { + return AviatorGrpcReachabilityResult.tlsFailed(status.getCode().name(), description); + } + return AviatorGrpcReachabilityResult.noResponse(status.getCode().name(), description); + } + } + + private static SSLSocketFactory createSslSocketFactory() throws IOException { + try { + FcliTrustManager.refreshIfChanged(); + var context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[] {FcliTrustManager.getInstance()}, null); + return context.getSocketFactory(); + } catch (GeneralSecurityException e) { + throw new IOException("Unable to initialize TLS trust context for Aviator diagnostics", e); + } + } + + static String performProxyConnect(Socket socket, AviatorConnectionPlan connectionPlan) throws IOException { + var proxy = connectionPlan.proxyDescriptor() + .orElseThrow(() -> new AviatorBugException("performProxyConnect requires a proxy on the connection plan")); + var target = connectionPlan.target().host() + ":" + connectionPlan.effectivePort(); + var writer = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.ISO_8859_1); + writer.write("CONNECT " + target + " HTTP/1.1\r\n"); + writer.write("Host: " + target + "\r\n"); + if (proxy.getProxyUser() != null && proxy.getProxyPasswordAsString() != null) { + var credentials = proxy.getProxyUser() + ":" + proxy.getProxyPasswordAsString(); + var encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + writer.write("Proxy-Authorization: Basic " + encoded + "\r\n"); + } + writer.write("Proxy-Connection: Keep-Alive\r\n"); + writer.write("\r\n"); + writer.flush(); + + var statusLine = readHttpHeaders(socket.getInputStream()); + validateProxyConnectStatusLine(statusLine); + return statusLine; + } + + /** + * Validates the CONNECT status line after headers are read. Package-visible for unit tests. + */ + static void validateProxyConnectStatusLine(String statusLine) throws AviatorProxyConnectException { + if (statusLine == null || !statusLine.startsWith("HTTP/")) { + throw new AviatorProxyConnectException("Proxy did not return an HTTP CONNECT response"); + } + if (!statusLine.matches("HTTP/\\d(?:\\.\\d)? 2\\d\\d.*")) { + throw new AviatorProxyConnectException("Proxy CONNECT failed: " + statusLine); + } + } + + static String readHttpHeaders(InputStream input) throws IOException { + var buffer = new ByteArrayOutputStream(); + var match = 0; + final byte[] end = new byte[] {'\r', '\n', '\r', '\n'}; + while (match < end.length) { + var value = input.read(); + if (value < 0) { + break; + } + buffer.write(value); + if (buffer.size() > MAX_CONNECT_HEADER_BYTES) { + throw new AviatorProxyConnectException( + "Proxy CONNECT response headers exceeded " + MAX_CONNECT_HEADER_BYTES + " bytes"); + } + match = value == end[match] ? match + 1 : (value == end[0] ? 1 : 0); + } + var headers = buffer.toString(StandardCharsets.ISO_8859_1); + var lineEnd = headers.indexOf("\r\n"); + return lineEnd < 0 ? headers.trim() : headers.substring(0, lineEnd); + } + + private static NonGrpcHttpResponse parseNonGrpcHttpResponse(String description) { + if (description == null) { + return null; + } + var matcher = NON_GRPC_HTTP_RESPONSE_PATTERN.matcher(description); + return matcher.find() ? new NonGrpcHttpResponse(matcher.group(1), matcher.group(2)) : null; + } + + private static String summarizeDescription(String description) { + if (description == null) { + return null; + } + var oneLine = description.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ').trim(); + return oneLine.length() > 500 ? oneLine.substring(0, 500) + "..." : oneLine; + } + + private static boolean isResponseReceived(Status.Code code) { + return switch (code) { + case DEADLINE_EXCEEDED, UNAVAILABLE, UNKNOWN, CANCELLED -> false; + default -> true; + }; + } + + private static int toMillis(int timeoutSeconds) { + return Math.max(1, timeoutSeconds) * 1000; + } + + private sealed interface OpenResult {} + private record OpenedSocket(String proxyConnectStatus) implements OpenResult {} + private record OpenFailed(AviatorTunnelResult result) implements OpenResult {} + + private record NonGrpcHttpResponse(String statusCode, String contentType) {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticEvidence.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticEvidence.java new file mode 100644 index 00000000000..c4df5c02ab0 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticEvidence.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; + +/** + * Shared helpers for diagnostic evidence JSON (no orchestration dependency). + */ +public final class AviatorDiagnosticEvidence { + private AviatorDiagnosticEvidence() {} + + public static ObjectNode empty() { + return JsonHelper.getObjectMapper().createObjectNode(); + } + + public static ObjectNode errorEvidence(Exception e) { + var evidence = empty(); + if (e == null) { + return evidence; + } + evidence.put("exceptionType", e.getClass().getName()); + evidence.put("exceptionMessage", e.getMessage()); + var cause = e.getCause(); + if (cause != null) { + evidence.put("causeType", cause.getClass().getName()); + evidence.put("causeMessage", cause.getMessage()); + var nested = cause.getCause(); + if (nested != null) { + evidence.put("rootCauseType", nested.getClass().getName()); + evidence.put("rootCauseMessage", nested.getMessage()); + } + } + return evidence; + } + + public static void merge(ObjectNode target, ObjectNode source) { + if (target == null || source == null) { + return; + } + source.fields().forEachRemaining(entry -> target.set(entry.getKey(), entry.getValue())); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java new file mode 100644 index 00000000000..64a3544ba50 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticReport.java @@ -0,0 +1,154 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; + +/** + * Owns stage order, collects diagnostic rows, and emits support log lines for one diagnose run. + *

+ * Call {@link #begin(AviatorDiagnosticStage)} (or the string overload) before running a stage; + * {@code pass}/{@code fail}/{@code warn}/{@code skip*} record the row and log the outcome. + * Skip APIs take a structured {@code reason} so log formatting never parses summary English. + */ +public final class AviatorDiagnosticReport { + private static final Logger LOG = LoggerFactory.getLogger(AviatorDiagnosticReport.class); + + private final List stages = new ArrayList<>(); + + public int nextOrder() { + return stages.size() + 1; + } + + /** DEBUG start line before a stage is executed (not for pure skip rows). */ + public void begin(AviatorDiagnosticStage stage) { + LOG.debug("Starting {} diagnostic", stage.displayName()); + } + + /** DEBUG start line for product stages (token/admin) that are not transport enums. */ + public void begin(String stageId) { + LOG.debug("Starting {} diagnostic", AviatorDiagnosticStage.displayNameFor(stageId)); + } + + public void add(AviatorDiagnosticStageResult result) { + stages.add(result); + logStageOutcome(result, false, null); + } + + private void addSkip(AviatorDiagnosticStageResult result, String reason) { + stages.add(result); + logStageOutcome(result, true, reason); + } + + private static void logStageOutcome(AviatorDiagnosticStageResult result, boolean skipped, String skipReason) { + var name = AviatorDiagnosticStage.displayNameFor(result.stage()); + if (skipped) { + LOG.info("{} skipped because {}", name, nullToEmpty(skipReason)); + return; + } + switch (result.status()) { + case PASS -> LOG.info("{}: PASS - {}", name, nullToEmpty(result.summary())); + case FAIL -> LOG.error("{}: FAIL - {}", name, failDetail(result)); + case WARN -> LOG.info("{}: WARN - {}", name, nullToEmpty(result.summary())); + } + } + + private static String failDetail(AviatorDiagnosticStageResult result) { + var summary = nullToEmpty(result.summary()); + var evidence = result.evidence(); + if (evidence == null || !evidence.hasNonNull("exceptionMessage")) { + return summary; + } + var exceptionMessage = evidence.get("exceptionMessage").asText(); + if (exceptionMessage == null || exceptionMessage.isBlank()) { + return summary; + } + return summary+" ("+exceptionMessage+")"; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public void pass(AviatorDiagnosticStage stage, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.pass(nextOrder(), stage, summary, guidance, evidence)); + } + + public void fail(AviatorDiagnosticStage stage, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.fail(nextOrder(), stage, summary, guidance, evidence)); + } + + /** + * Required transport skip WARN. Builds summary {@code Skipped because {reason}} and logs + * as a skip without parsing summary text. + */ + public void skipWarn(AviatorDiagnosticStage stage, String reason, String guidance, ObjectNode evidence) { + addSkip(AviatorDiagnosticStageResult.warn(nextOrder(), stage, skipSummary(reason), guidance, true, evidence), + reason); + } + + public void warn(AviatorDiagnosticStage stage, String summary, String guidance, boolean required, + ObjectNode evidence) { + add(AviatorDiagnosticStageResult.warn(nextOrder(), stage, summary, guidance, required, evidence)); + } + + public void optionalPass(String stage, String description, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.optionalPass(nextOrder(), stage, description, summary, guidance, evidence)); + } + + public void optionalFail(String stage, String description, String summary, String guidance, ObjectNode evidence) { + add(AviatorDiagnosticStageResult.optionalFail(nextOrder(), stage, description, summary, guidance, evidence)); + } + + /** + * Optional product-stage skip WARN. Builds summary {@code Skipped because {reason}}. + */ + public void optionalSkipWarn(String stage, String description, String reason, String guidance, ObjectNode evidence) { + addSkip(AviatorDiagnosticStageResult.warn(nextOrder(), stage, description, skipSummary(reason), guidance, false, + evidence), reason); + } + + private static String skipSummary(String reason) { + return "Skipped because "+reason; + } + + public List stages() { + return Collections.unmodifiableList(stages); + } + + public ArrayNode toArrayNode() { + var array = JsonHelper.getObjectMapper().createArrayNode(); + stages.stream().map(AviatorDiagnosticStageResult::asObjectNode).forEach(array::add); + return array; + } + + public boolean hasRequiredFailure() { + return stages.stream().anyMatch(AviatorDiagnosticStageResult::isRequiredFailure); + } + + /** True when the gRPC stage completed with status PASS (server response received). */ + public boolean hasGrpcStagePass() { + return stages.stream() + .anyMatch(result -> result.isStage(AviatorDiagnosticStage.GRPC) + && AviatorDiagnosticStatus.PASS.equals(result.status())); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java new file mode 100644 index 00000000000..d4cdab1c173 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStage.java @@ -0,0 +1,65 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +/** + * Transport diagnostic stage identifiers for report rows and evidence. + *

+ * Pipeline: {@link #ENDPOINT} then DNS → TCP → optional {@link #PROXY} → {@link #TLS} → {@link #GRPC}. + * Product-layer credential stages (token/admin) are not part of this enum; they append + * string stage ids from the connection diagnose helper only. + */ +public enum AviatorDiagnosticStage { + ENDPOINT("endpoint", "Aviator endpoint configuration validation", "Endpoint"), + DNS("dns", "DNS resolution", "DNS"), + TCP("tcp", "TCP connectivity", "TCP"), + PROXY("proxy", "HTTP proxy CONNECT", "Proxy"), + TLS("tls", "TLS handshake", "TLS"), + GRPC("grpc", "gRPC request/response reachability", "gRPC"); + + private final String id; + private final String description; + /** Human-readable label for log lines (e.g. {@code Endpoint}, {@code gRPC}). */ + private final String displayName; + + AviatorDiagnosticStage(String id, String description, String displayName) { + this.id = id; + this.description = description; + this.displayName = displayName; + } + + public String id() { + return id; + } + + public String description() { + return description; + } + + public String displayName() { + return displayName; + } + + /** Resolve wire id to display name; falls back to a simple capitalization of {@code stageId}. */ + public static String displayNameFor(String stageId) { + if (stageId == null || stageId.isBlank()) { + return "Stage"; + } + for (var stage : values()) { + if (stage.id.equals(stageId)) { + return stage.displayName; + } + } + return Character.toUpperCase(stageId.charAt(0)) + stageId.substring(1); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStageResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStageResult.java new file mode 100644 index 00000000000..4ff1eb37571 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStageResult.java @@ -0,0 +1,96 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; + +/** + * One diagnostic stage row. + *

+ * {@code required} only affects process exit when {@code status} is {@link AviatorDiagnosticStatus#FAIL}: + * a required FAIL forces soft exit 1 after the table is written. WARN rows may carry + * {@code required=true} for documentation of skip chains, but WARN never drives exit code. + */ +public record AviatorDiagnosticStageResult( + int order, + String stage, + String description, + AviatorDiagnosticStatus status, + boolean required, + String summary, + String guidance, + ObjectNode evidence) { + + public static AviatorDiagnosticStageResult of(int order, String stage, String description, + AviatorDiagnosticStatus status, boolean required, String summary, String guidance, ObjectNode evidence) { + return new AviatorDiagnosticStageResult(order, stage, description, status, required, summary, guidance, + evidence == null ? JsonHelper.getObjectMapper().createObjectNode() : evidence); + } + + public static AviatorDiagnosticStageResult of(int order, AviatorDiagnosticStage stage, + AviatorDiagnosticStatus status, boolean required, String summary, String guidance, ObjectNode evidence) { + return of(order, stage.id(), stage.description(), status, required, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult pass(int order, AviatorDiagnosticStage stage, String summary, + String guidance, ObjectNode evidence) { + return of(order, stage, AviatorDiagnosticStatus.PASS, true, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult fail(int order, AviatorDiagnosticStage stage, String summary, + String guidance, ObjectNode evidence) { + return of(order, stage, AviatorDiagnosticStatus.FAIL, true, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult warn(int order, AviatorDiagnosticStage stage, String summary, + String guidance, boolean required, ObjectNode evidence) { + return of(order, stage, AviatorDiagnosticStatus.WARN, required, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult warn(int order, String stage, String description, String summary, + String guidance, boolean required, ObjectNode evidence) { + return of(order, stage, description, AviatorDiagnosticStatus.WARN, required, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult optionalPass(int order, String stage, String description, + String summary, String guidance, ObjectNode evidence) { + return of(order, stage, description, AviatorDiagnosticStatus.PASS, false, summary, guidance, evidence); + } + + public static AviatorDiagnosticStageResult optionalFail(int order, String stage, String description, + String summary, String guidance, ObjectNode evidence) { + return of(order, stage, description, AviatorDiagnosticStatus.FAIL, false, summary, guidance, evidence); + } + + public boolean isRequiredFailure() { + return required && AviatorDiagnosticStatus.FAIL.equals(status); + } + + public boolean isStage(AviatorDiagnosticStage stage) { + return stage != null && stage.id().equals(this.stage); + } + + public ObjectNode asObjectNode() { + var node = JsonHelper.getObjectMapper().createObjectNode(); + node.put("order", order); + node.put("stage", stage); + node.put("description", description); + node.put("status", status.name()); + node.put("required", required); + node.put("summary", summary); + node.put("guidance", guidance); + node.set("evidence", evidence); + return node; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStatus.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStatus.java new file mode 100644 index 00000000000..87663cc49b3 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorDiagnosticStatus.java @@ -0,0 +1,19 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +public enum AviatorDiagnosticStatus { + PASS, + FAIL, + WARN +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcPattern.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcPattern.java new file mode 100644 index 00000000000..24a0c64d7a8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcPattern.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +/** + * Public gRPC stage evidence {@code pattern} wire ids. Sole automation vocabulary for gRPC failures. + */ +public enum AviatorGrpcPattern { + HTTP_RESPONSE_NOT_GRPC("http-response-not-grpc"), + /** gRPC path got no response (transport TLS may still have passed). */ + GRPC_NO_RESPONSE("grpc-no-response"), + GRPC_TLS_FAILED("grpc-tls-failed"), + GRPC_PROBE_ERROR("grpc-probe-error"); + + private final String wireId; + + AviatorGrpcPattern(String wireId) { + this.wireId = wireId; + } + + public String wireId() { + return wireId; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResult.java new file mode 100644 index 00000000000..550d5e407ff --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResult.java @@ -0,0 +1,256 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Sealed gRPC probe outcome: taxonomy, stage summary/guidance, and wire {@code pattern} live here + * (no separate failure-category + classification re-label layer). + */ +public sealed interface AviatorGrpcReachabilityResult { + + String TLS_GUIDANCE = + "Check certificate trust, SNI, TLS inspection, and that the proxy does not break TLS to aviator-grpc-server"; + String NO_RESPONSE_GUIDANCE = + "Allow HTTP/2 gRPC traffic through the proxy, VPN, gateway, or load balancer to aviator-grpc-server"; + + String statusCode(); + + String description(); + + boolean stagePass(); + + String stageSummary(); + + String stageGuidance(); + + /** Public evidence pattern wire id, or {@code null} when the stage passes. */ + AviatorGrpcPattern pattern(); + + void putEvidence(ObjectNode evidence); + + /** Application-level or OK status codes still count as reachability success. */ + static AviatorGrpcReachabilityResult responseReceived(String statusCode, String description) { + return new ResponseReceived(statusCode, description); + } + + static AviatorGrpcReachabilityResult nonGrpcHttp(String statusCode, String httpStatus, String contentType, + String description) { + return new NonGrpcHttp(statusCode, httpStatus, contentType, description); + } + + static AviatorGrpcReachabilityResult tlsFailed(String statusCode, String description) { + return new TlsFailed(statusCode, description); + } + + static AviatorGrpcReachabilityResult noResponse(String statusCode, String description) { + return new NoResponse(statusCode, description); + } + + /** + * Exception thrown outside a classified gRPC status. Always preserves exception/cause evidence; + * TLS vs probe-error pattern is chosen from the exception chain. + */ + static AviatorGrpcReachabilityResult probeError(Exception e) { + return new ProbeError(e); + } + + record ResponseReceived(String statusCode, String description) implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return true; + } + + @Override + public String stageSummary() { + return "Aviator gRPC responded"; + } + + @Override + public String stageGuidance() { + return "No action required"; + } + + @Override + public AviatorGrpcPattern pattern() { + return null; + } + + @Override + public void putEvidence(ObjectNode evidence) { + evidence.put("grpcResponseReceived", true); + evidence.put("grpcStatusCode", statusCode); + putDescription(evidence, description); + evidence.put("httpResponseReceived", false); + } + } + + record NonGrpcHttp(String statusCode, String httpStatusCode, String httpContentType, String description) + implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return "Received an HTTP page instead of gRPC"; + } + + @Override + public String stageGuidance() { + return "A VPN, proxy, or gateway returned a block, login, or error page; allow direct gRPC/HTTP2 to aviator-grpc-server"; + } + + @Override + public AviatorGrpcPattern pattern() { + return AviatorGrpcPattern.HTTP_RESPONSE_NOT_GRPC; + } + + @Override + public void putEvidence(ObjectNode evidence) { + evidence.put("grpcResponseReceived", false); + evidence.put("grpcStatusCode", statusCode); + putDescription(evidence, description); + evidence.put("httpResponseReceived", true); + if (httpStatusCode != null) { + evidence.put("httpStatusCode", httpStatusCode); + } + if (httpContentType != null) { + evidence.put("httpContentType", httpContentType); + } + } + } + + record TlsFailed(String statusCode, String description) implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return "gRPC TLS handshake failed"; + } + + @Override + public String stageGuidance() { + return TLS_GUIDANCE; + } + + @Override + public AviatorGrpcPattern pattern() { + return AviatorGrpcPattern.GRPC_TLS_FAILED; + } + + @Override + public void putEvidence(ObjectNode evidence) { + putNoResponseEvidence(evidence, statusCode, description); + } + } + + record NoResponse(String statusCode, String description) implements AviatorGrpcReachabilityResult { + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return "No gRPC response received"; + } + + @Override + public String stageGuidance() { + return NO_RESPONSE_GUIDANCE; + } + + @Override + public AviatorGrpcPattern pattern() { + return AviatorGrpcPattern.GRPC_NO_RESPONSE; + } + + @Override + public void putEvidence(ObjectNode evidence) { + putNoResponseEvidence(evidence, statusCode, description); + } + } + + /** + * Exception path: always includes exception/cause evidence keys; pattern is TLS or probe-error. + */ + record ProbeError(Exception error) implements AviatorGrpcReachabilityResult { + public ProbeError { + if (error == null) { + throw new NullPointerException("error"); + } + } + + private boolean tlsFailure() { + return AviatorTlsFailureDetector.isTlsFailure(error); + } + + @Override + public String statusCode() { + return "EXCEPTION"; + } + + @Override + public String description() { + return error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); + } + + @Override + public boolean stagePass() { + return false; + } + + @Override + public String stageSummary() { + return tlsFailure() ? "gRPC TLS handshake failed" : "gRPC probe failed"; + } + + @Override + public String stageGuidance() { + return tlsFailure() ? TLS_GUIDANCE : NO_RESPONSE_GUIDANCE; + } + + @Override + public AviatorGrpcPattern pattern() { + return tlsFailure() ? AviatorGrpcPattern.GRPC_TLS_FAILED : AviatorGrpcPattern.GRPC_PROBE_ERROR; + } + + @Override + public void putEvidence(ObjectNode evidence) { + AviatorDiagnosticEvidence.merge(evidence, AviatorDiagnosticEvidence.errorEvidence(error)); + evidence.put("grpcResponseReceived", false); + evidence.put("grpcStatusCode", statusCode()); + putDescription(evidence, description()); + evidence.put("httpResponseReceived", false); + } + } + + private static void putNoResponseEvidence(ObjectNode evidence, String statusCode, String description) { + evidence.put("grpcResponseReceived", false); + evidence.put("grpcStatusCode", statusCode); + putDescription(evidence, description); + evidence.put("httpResponseReceived", false); + } + + private static void putDescription(ObjectNode evidence, String description) { + if (description != null) { + evidence.put("grpcDescription", description); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorProxyConnectException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorProxyConnectException.java new file mode 100644 index 00000000000..ef20c3cb015 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorProxyConnectException.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import java.io.IOException; + +/** + * Thrown when HTTP CONNECT through a proxy fails (distinct from TLS handshake failures). + */ +public class AviatorProxyConnectException extends IOException { + private static final long serialVersionUID = 1L; + + public AviatorProxyConnectException(String message) { + super(message); + } + + public AviatorProxyConnectException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsFailureDetector.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsFailureDetector.java new file mode 100644 index 00000000000..bf89108f87a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsFailureDetector.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import java.security.cert.CertificateException; +import java.util.Locale; + +import javax.net.ssl.SSLException; + +/** + * Detects TLS/trust failures from throwables and status descriptions (cause-chain aware). + */ +public final class AviatorTlsFailureDetector { + private AviatorTlsFailureDetector() {} + + public static boolean isTlsFailure(Throwable throwable, String statusDescription) { + if (isTlsFailureMessage(statusDescription)) { + return true; + } + return isTlsFailure(throwable); + } + + public static boolean isTlsFailure(Throwable throwable) { + for (var current = throwable; current != null; current = current.getCause()) { + if (current instanceof SSLException || current instanceof CertificateException) { + return true; + } + var typeAndMessage = current.getClass().getName() + + " " + + (current.getMessage() == null ? "" : current.getMessage()); + if (isTlsFailureMessage(typeAndMessage)) { + return true; + } + } + return false; + } + + private static final String[] TLS_MESSAGE_MARKERS = { + "sslhandshakeexception", + "sslexception", + "sslengine", + "openssl", + "pkix path", + "unable to find valid certification path", + "certificateexception", + "certpathbuilderexception", + "certpathvalidatorexception", + "certificate_unknown", + "unknown_ca", + "certificate_required", + "handshake_failure" + }; + + static boolean isTlsFailureMessage(String description) { + if (description == null || description.isBlank()) { + return false; + } + var text = description.toLowerCase(Locale.ROOT); + for (var marker : TLS_MESSAGE_MARKERS) { + if (text.contains(marker)) { + return true; + } + } + return false; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsPhase.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsPhase.java new file mode 100644 index 00000000000..c62e906f3cd --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTlsPhase.java @@ -0,0 +1,28 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +public enum AviatorTlsPhase { + CONNECT("connect"), + HANDSHAKE("handshake"); + + private final String id; + + AviatorTlsPhase(String id) { + this.id = id; + } + + public String id() { + return id; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTunnelResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTunnelResult.java new file mode 100644 index 00000000000..9e7ff1f4176 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/AviatorTunnelResult.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +/** + * Outcome of a single TCP(+CONNECT)+TLS probe session. CONNECT and TLS share one socket + * so diagnostics do not re-open the tunnel between the PROXY and TLS stages. + */ +public sealed interface AviatorTunnelResult { + + boolean proxyConfigured(); + + /** CONNECT status line or {@code not-used} when no proxy status applies. */ + default String proxyConnectStatus() { + if (this instanceof TlsSucceeded s) { + return s.proxyConnectStatusLine(); + } + if (this instanceof TlsFailed f) { + return f.proxyConnectStatusLine(); + } + return "not-used"; + } + + record ProxyConnectFailed(Exception error) implements AviatorTunnelResult { + @Override + public boolean proxyConfigured() { + return true; + } + } + + record TlsFailed( + boolean proxyConfigured, + String proxyConnectStatusLine, + AviatorTlsPhase phase, + Exception error) implements AviatorTunnelResult {} + + record TlsSucceeded( + boolean proxyConfigured, + String proxyConnectStatusLine, + String protocol, + String cipherSuite, + String peerSubject, + String applicationProtocol) implements AviatorTunnelResult {} +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/IAviatorDiagnosticProbe.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/IAviatorDiagnosticProbe.java new file mode 100644 index 00000000000..68f90f3ebc3 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/diagnose/IAviatorDiagnosticProbe.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import java.io.IOException; +import java.net.InetAddress; + +import com.fortify.cli.aviator.grpc.AviatorGrpcClientHelper.AviatorConnectionPlan; + +public interface IAviatorDiagnosticProbe { + InetAddress[] resolve(String host) throws IOException; + + /** + * Short-lived TCP connect used only for the TCP diagnostic stage (open then close). + * The tunnel probe opens a separate connection afterward on purpose. + */ + void connect(String host, int port, int timeoutSeconds) throws IOException; + + /** + * Single session: TCP to next hop, optional HTTP CONNECT, then TLS. Emits one + * structured result so PROXY and TLS stages share a tunnel (no re-CONNECT between + * those stages). Called after {@link #connect}; the second TCP open is intentional. + */ + AviatorTunnelResult probeTunnel(AviatorConnectionPlan connectionPlan, int timeoutSeconds); + + AviatorGrpcReachabilityResult probeGrpc(String url, int timeoutSeconds) throws Exception; +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java index b0899d4f593..b8744a29020 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/FPRProcessor.java @@ -146,7 +146,7 @@ private boolean isAudited(AuditIssue auditIssue) { } String auditorStatusValue = tags.get(Constants.AUDITOR_STATUS_TAG_ID); - if (!isPendingReviewValue(auditorStatusValue)) { + if (!StringUtil.isPendingReviewValue(auditorStatusValue)) { return true; } @@ -159,16 +159,7 @@ private boolean isAudited(AuditIssue auditIssue) { } String analysisTagValue = tags.get(Constants.ANALYSIS_TAG_ID); - return analysisTagValue != null - && !analysisTagValue.equalsIgnoreCase("Not Set") - && !analysisTagValue.equalsIgnoreCase(Constants.PENDING_REVIEW) - && !StringUtil.isEmpty(analysisTagValue); - } - - private boolean isPendingReviewValue(String value) { - return StringUtil.isEmpty(value) - || value.equalsIgnoreCase("Pending Review") - || value.equalsIgnoreCase(Constants.PENDING_REVIEW); + return !StringUtil.isPendingReviewValue(analysisTagValue); } private String resolveIssueStatus(AuditIssue auditIssue) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java index 75c616be25c..f62638a885a 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/model/AuditIssue.java @@ -30,6 +30,7 @@ public class AuditIssue { private boolean suppressed; private int revision; @Builder.Default private Map tags = new HashMap<>(); + @Builder.Default private Map lastTagUsernames = new HashMap<>(); @Builder.Default private List threadedComments = new ArrayList<>(); public void addTag(String tagId, String tagValue) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java index 602367fbf03..14e9a68dbc4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/AuditProcessor.java @@ -27,8 +27,10 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -56,16 +58,22 @@ import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; import com.fortify.cli.aviator.config.TagMappingConfig; import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.fpr.model.FPRInfo; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; import com.fortify.cli.aviator.fpr.utils.FileUtils; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.aviator.util.FprHandle; import lombok.Setter; + public class AuditProcessor { Logger logger = LoggerFactory.getLogger(AuditProcessor.class); @@ -83,9 +91,39 @@ public class AuditProcessor { private final Map auditIssueMap = new HashMap<>(); private final FprHandle fprHandle; + private final ISourceDecoder sourceDecoder; + private RemediationGenerationMetric lastRemediationGenerationMetric = RemediationGenerationMetric.empty(); + + public record RemediationGenerationMetric(int skippedRemediations, Map skippedByReason) { + public static RemediationGenerationMetric empty() { + return new RemediationGenerationMetric(0, Map.of()); + } + } + + private enum RemediationSkipReason { + SOURCE_READ_OR_DECODE_FAILED("Source file read/decode failed"), + INVALID_LINE_NUMBER("Invalid line number"), + STRUCTURALLY_INVALID("Structurally invalid remediation"), + NO_VALID_CHANGES("No valid changes"); + + private final String label; + + RemediationSkipReason(String label) { + this.label = label; + } + } public AuditProcessor(FprHandle fprHandle) { + this(fprHandle, SourceDecoders.defaults()); + } + + public AuditProcessor(FprHandle fprHandle, ISourceDecoder sourceDecoder) { this.fprHandle = fprHandle; + this.sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); + } + + public RemediationGenerationMetric getLastRemediationGenerationMetric() { + return lastRemediationGenerationMetric; } /** @@ -214,6 +252,7 @@ private AuditIssue processAuditIssue(Element issueElement) { tags.put(tagId, tagValue); } auditIssueBuilder.tags(tags); + auditIssueBuilder.lastTagUsernames(lastTagUsernames(issueElement)); List threadedComments = new ArrayList<>(); NodeList commentNodes = issueElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Comment"); @@ -232,6 +271,24 @@ private AuditIssue processAuditIssue(Element issueElement) { } + private Map lastTagUsernames(Element issueElement) { + Map lastTagUsernames = new HashMap<>(); + NodeList tagHistories = issueElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "TagHistory"); + for (int index = 0; index < tagHistories.getLength(); index++) { + Element tagHistory = (Element) tagHistories.item(index); + NodeList tags = tagHistory.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Tag"); + if (tags.getLength() == 0) { + continue; + } + String tagId = ((Element) tags.item(0)).getAttribute("id"); + String username = Optional.ofNullable(getFirstElementContentNS(tagHistory, "Username")).orElse(""); + if (tagId != null && !tagId.isBlank()) { + lastTagUsernames.put(tagId, username); + } + } + return lastTagUsernames; + } + private String getTagValue(Element tagElement) { NodeList valueNodes = tagElement.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "Value"); if (valueNodes.getLength() > 0) { @@ -709,6 +766,13 @@ private String addCommentToIssueElement(Element issueElement, String commentText public File updateAndSaveAuditAndRemediationsXml(Map auditResponses, TagMappingConfig tagMappingConfig, Map issueCategoryLookup, FPRInfo fprInfo) throws AviatorTechnicalException { + return updateAndSaveAuditAndRemediationsXml(auditResponses, tagMappingConfig, issueCategoryLookup, fprInfo, null); + } + + public File updateAndSaveAuditAndRemediationsXml(Map auditResponses, + TagMappingConfig tagMappingConfig, Map issueCategoryLookup, + FPRInfo fprInfo, FVDLMetadata fvdlMetadata) throws AviatorTechnicalException { + lastRemediationGenerationMetric = RemediationGenerationMetric.empty(); // Step 1: Apply this save's audit responses. writtenInstanceIds is the local retain set. Map effectiveIssueCategoryLookup = issueCategoryLookup == null ? Map.of() : issueCategoryLookup; AuditXmlUpdateResult updateResult = updateAuditXml( @@ -728,7 +792,10 @@ public File updateAndSaveAuditAndRemediationsXml(Map audi // Step 4: Generate the in-memory remediations.xml document if needed. if (hasRemediations && !remediationCommentTimestamps.isEmpty()) { - this.remediationsDoc = generateRemediationsXml(auditResponses, remediationCommentTimestamps, fprInfo); + Map skippedByReason = new LinkedHashMap<>(); + this.remediationsDoc = generateRemediationsXml(auditResponses, remediationCommentTimestamps, fprInfo, + fvdlMetadata, skippedByReason); + lastRemediationGenerationMetric = toRemediationGenerationMetric(skippedByReason); } else { this.remediationsDoc = null; if (hasRemediations) { @@ -763,9 +830,100 @@ public File updateAndSaveAuditAndRemediationsXml(Map audi return fprHandle.getFprPath().toFile(); } + /** + * Applies DAST audit decisions to audit.xml without creating SAST remediation artifacts. + */ + public File updateAndSaveDastAuditXml(Map auditResponses, + TagMappingConfig tagMappingConfig) { + Set writtenInstanceIds = new HashSet<>(); + for (Map.Entry entry : auditResponses.entrySet()) { + AuditResponse response = entry.getValue(); + if (response == null || !"SUCCESS".equalsIgnoreCase(response.getStatus()) + || response.getAuditResult() == null) { + continue; + } + Element issueElement = findIssueElement(entry.getKey()); + if (issueElement == null) { + issueElement = createDastIssueElement(entry.getKey()); + } else { + int revision = Optional.ofNullable(issueElement.getAttribute("revision")) + .filter(value -> !value.isBlank()) + .map(value -> { + try { return Integer.parseInt(value); } catch (NumberFormatException e) { return 0; } + }) + .orElse(0); + issueElement.setAttribute("revision", String.valueOf(revision + 1)); + } + applyDastAuditResponse(issueElement, response, tagMappingConfig); + writtenInstanceIds.add(entry.getKey()); + } + + AuditXmlIssuePruner.retainOnly(auditDoc, writtenInstanceIds); + + try (OutputStream os = Files.newOutputStream(fprHandle.getPath("/audit.xml"))) { + transformDomToStream(auditDoc, os); + } catch (Exception e) { + throw new AviatorTechnicalException("Failed to write DAST audit data back into the FPR file", e); + } + return fprHandle.getFprPath().toFile(); + } + + private Element createDastIssueElement(String instanceId) { + Element issueList = (Element) auditDoc.getElementsByTagNameNS(AUDIT_NAMESPACE_URI, "IssueList").item(0); + if (issueList == null) { + issueList = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "IssueList"); + auditDoc.getDocumentElement().appendChild(issueList); + } + Element issueElement = auditDoc.createElementNS(AUDIT_NAMESPACE_URI, "Issue"); + issueElement.setAttribute("instanceId", instanceId); + issueElement.setAttribute("revision", "0"); + issueElement.setAttribute("suppressed", "false"); + issueList.appendChild(issueElement); + return issueElement; + } + + private void applyDastAuditResponse(Element issueElement, AuditResponse response, + TagMappingConfig tagMappingConfig) { + String prediction = response.getAviatorPredictionTag(); + updateOrAddTag(issueElement, Constants.AVIATOR_PREDICTION_TAG_ID, prediction); + TagMappingConfig.Result resultConfig = getDastResultConfig(response, tagMappingConfig); + if (resultConfig.getValue() != null && !resultConfig.getValue().isBlank()) { + updateOrAddTag(issueElement, tagMappingConfig.getTag_id(), resultConfig.getValue()); + } + Boolean suppressedHistoryValue = updateSuppressedState( + issueElement, Boolean.TRUE.equals(resultConfig.getSuppress())); + updateOrAddTag(issueElement, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); + if (response.getAuditResult().getComment() != null) { + updateOrAddComment(issueElement, response.getAuditResult().getComment()); + } + Element clientAuditTrail = getClientAuditTrailElement(issueElement); + addTagHistory(clientAuditTrail, Constants.AVIATOR_PREDICTION_TAG_ID, prediction); + if (resultConfig.getValue() != null && !resultConfig.getValue().isBlank()) { + addTagHistory(clientAuditTrail, tagMappingConfig.getTag_id(), resultConfig.getValue()); + } + addTagHistory(clientAuditTrail, Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR); + if (suppressedHistoryValue != null) { + addTagHistory(clientAuditTrail, Constants.SUPPRESSED_TAG_ID, suppressedHistoryValue.toString()); + } + } + + private TagMappingConfig.Result getDastResultConfig(AuditResponse response, + TagMappingConfig tagMappingConfig) { + boolean tierOne = AuditTier.fromServerValue(response.getTier()) == AuditTier.GOLD; + String tagValue = response.getAuditResult().getTagValue(); + if (Constants.NOT_AN_ISSUE.equalsIgnoreCase(tagValue)) { + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.FP); + } + if (Constants.EXPLOITABLE.equalsIgnoreCase(tagValue)) { + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.TP); + } + return tagMappingConfig.getResult(tierOne, TagMappingConfig.ResultType.UNSURE); + } + private Document generateRemediationsXml(Map auditResponses, Map remediationCommentTimestamps, - FPRInfo fprInfo) throws AviatorTechnicalException { + FPRInfo fprInfo, FVDLMetadata fvdlMetadata, + Map skippedByReason) throws AviatorTechnicalException { try { DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance(); docFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); @@ -794,6 +952,7 @@ private Document generateRemediationsXml(Map auditRespons Element remediationListElement = finalDoc.createElementNS(REMEDIATIONS_NAMESPACE_URI, "RemediationList"); rootElement.appendChild(remediationListElement); + FileUtils fileUtils = new FileUtils(this.sourceDecoder, fvdlMetadata); int validRemediationCount = 0; for (Map.Entry entry : auditResponses.entrySet()) { @@ -828,12 +987,11 @@ private Document generateRemediationsXml(Map auditRespons filenameElement.setTextContent(filename); fileChangesElement.appendChild(filenameElement); - //Optional originalFileContentOptional = fvdlProcessor.getSourceFileContent(filename); - FileUtils fileUtils = new FileUtils(); - Optional originalFileContentOptional = fileUtils.getSourceFileContent(fprHandle, filename); + Optional originalFileContentOptional = fileUtils.getSourceFileContent(fprHandle, filename); if (originalFileContentOptional.isEmpty()) { logger.warn("WARN: Could not retrieve source code for file '{}'. Skipping remediation generation for this file for instanceId '{}'.", filename, instanceId); + recordSkipped(skippedByReason, RemediationSkipReason.SOURCE_READ_OR_DECODE_FAILED); continue; } @@ -873,7 +1031,9 @@ private Document generateRemediationsXml(Map auditRespons changeElement.appendChild(originalCodeElement); Element newCodeElement = finalDoc.createElementNS(REMEDIATIONS_NAMESPACE_URI, "NewCode"); - newCodeElement.appendChild(finalDoc.createCDATASection(change.getReplaceWith() != null ? change.getReplaceWith() : "")); + String sanitizedNewCode = FileUtil.stripSyntheticLineMarkers( + change.getReplaceWith() != null ? change.getReplaceWith() : "", filename); + newCodeElement.appendChild(finalDoc.createCDATASection(sanitizedNewCode)); changeElement.appendChild(newCodeElement); final int CONTEXT_LINES = 3; @@ -900,6 +1060,7 @@ private Document generateRemediationsXml(Map auditRespons fileChangesElement.appendChild(changeElement); } catch (NumberFormatException e) { logger.error("Skipping change for issue {} due to invalid line number format. Details: {}", instanceId, e.getMessage()); + recordSkipped(skippedByReason, RemediationSkipReason.INVALID_LINE_NUMBER); } } if (fileChangesElement.getElementsByTagNameNS(REMEDIATIONS_NAMESPACE_URI, "Change").getLength() > 0) { @@ -915,9 +1076,11 @@ private Document generateRemediationsXml(Map auditRespons validRemediationCount++; } else { logger.warn("WARN: Skipping structurally invalid remediation for issue instanceId: {}", instanceId); + recordSkipped(skippedByReason, RemediationSkipReason.STRUCTURALLY_INVALID); } } else { logger.warn("WARN: Skipping remediation for instanceId '{}' because all of its proposed changes were invalid and could not be processed.", instanceId); + recordSkipped(skippedByReason, RemediationSkipReason.NO_VALID_CHANGES); } } @@ -929,11 +1092,23 @@ private Document generateRemediationsXml(Map auditRespons } } + private RemediationGenerationMetric toRemediationGenerationMetric(Map skippedByReason) { + int skippedRemediations = skippedByReason.values().stream().mapToInt(Integer::intValue).sum(); + return new RemediationGenerationMetric(skippedRemediations, Map.copyOf(skippedByReason)); + } + + private void recordSkipped(Map skippedByReason, RemediationSkipReason reason) { + skippedByReason.merge(reason.label, 1, Integer::sum); + } + private String calculateHashBase64(String content, String algorithm) { - if (content == null) return ""; try { MessageDigest md = MessageDigest.getInstance(algorithm); - byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + // hash the canonical form (LF-normalised, no trailing newline) so the apply + // side can reproduce the digest regardless of the OS that ran the audit or the + // file's trailing-newline state. + String canonical = FileUtil.canonicalizeForHash(content); + byte[] digest = md.digest(canonical.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(digest); } catch (NoSuchAlgorithmException e) { throw new AviatorTechnicalException("Hashing algorithm not available: " + algorithm, e); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java deleted file mode 100644 index 97c1ffce863..00000000000 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java +++ /dev/null @@ -1,556 +0,0 @@ -/* - * Copyright 2021-2026 Open Text. - * - * The only warranties for products and services of Open Text - * and its affiliates and licensors ("Open Text") are as may - * be set forth in the express warranty statements accompanying - * such products and services. Nothing herein should be construed - * as constituting an additional warranty. Open Text shall not be - * liable for technical or editorial errors or omissions contained - * herein. The information contained herein is subject to change - * without notice. - */ -package com.fortify.cli.aviator.fpr.processor; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.Charset; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.zip.ZipFile; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -import com.fortify.cli.aviator._common.exception.AviatorSimpleException; -import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; -import com.fortify.cli.aviator.fpr.model.FVDLMetadata; -import com.fortify.cli.aviator.util.FprHandle; -import com.fortify.cli.aviator.util.FuzzyContextSearcher; - -public class RemediationProcessor { - private static final Logger LOG = LoggerFactory.getLogger(RemediationProcessor.class); - private static final String NAMESPACE_URI = "xmlns://www.fortify.com/schema/remediations"; - - private final FprHandle fprHandle; - private final String sourceCodeDirectory; - - public record RemediationMetric(int totalRemediations, int appliedRemediations, int skippedRemediations, Set modifiedFiles, - Map skippedByReason) { - public RemediationMetric(int totalRemediations, int appliedRemediations, int skippedRemediations, Set modifiedFiles) { - this(totalRemediations, appliedRemediations, skippedRemediations, modifiedFiles, Map.of()); - } - } - - private record FvdlMetadataResult(FVDLMetadata metadata, SkipReason skipReason) {} - - private record PendingFileWrite(String filename, Path filePath, String content, byte[] updatedBytes) {} - - private record RollbackFileWrite(String filename, Path filePath, byte[] originalBytes) {} - - private enum SkipReason { - FVDL_METADATA_UNAVAILABLE("FVDL metadata unavailable"), - FVDL_ENCODING_MISSING("FVDL source encoding missing"), - FVDL_ENCODING_UNSUPPORTED("FVDL source encoding unsupported"), - SOURCE_FILE_MISSING("Source file missing"), - SOURCE_FILE_OUTSIDE_SOURCE_DIR("Source file outside source directory"), - SOURCE_READ_FAILED("Source file read failed"), - SOURCE_DECODE_FAILED("Source file decode failed"), - REMEDIATION_DATA_INVALID("Remediation data invalid"), - REMEDIATION_LINE_RANGE_INVALID("Remediation line range invalid"), - SOURCE_CONTEXT_NOT_FOUND("Source context not found"), - ORIGINAL_CODE_NOT_FOUND("Original code not found"), - REMEDIATION_ENCODE_FAILED("Remediation encode failed"), - SOURCE_WRITE_FAILED("Source file write failed"), - NO_CHANGES("No file changes found"), - UNEXPECTED_ERROR("Unexpected remediation processing error"); - - private final String displayName; - - SkipReason(String displayName) { - this.displayName = displayName; - } - } - - private static class SkipRemediationException extends AviatorSimpleException { - private static final long serialVersionUID = 1L; - - private final SkipReason reason; - - SkipRemediationException(SkipReason reason, String message) { - super(message); - this.reason = reason; - } - - SkipRemediationException(SkipReason reason, String message, Throwable cause) { - super(message, cause); - this.reason = reason; - } - } - - private static class RemediationCommitException extends AviatorTechnicalException { - private static final long serialVersionUID = 1L; - - private final List rollbacks; - - RemediationCommitException(String message, Throwable cause, List rollbacks) { - super(message, cause); - this.rollbacks = rollbacks; - } - - List getRollbacks() { - return rollbacks; - } - } - - private static class RollbackRemediationException extends AviatorTechnicalException { - private static final long serialVersionUID = 1L; - - RollbackRemediationException(String message, Throwable cause) { - super(message, cause); - } - } - - public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory) { - this.fprHandle = fprHandle; - this.sourceCodeDirectory = sourceCodeDirectory; - } - - public RemediationMetric processRemediationXML() { - Path remediationPath = fprHandle.getPath("/remediations.xml"); - Document remediationDoc; - int totalRemediations; - int appliedRemediations; - Set modifiedFiles = new LinkedHashSet<>(); - Map skippedByReason = new LinkedHashMap<>(); - - // Sanitize and normalize the base source directory path once. - String trimmedSourceDir = sourceCodeDirectory.trim(); - if (trimmedSourceDir.length() > 1 && - ((trimmedSourceDir.startsWith("\"") && trimmedSourceDir.endsWith("\"")) || - (trimmedSourceDir.startsWith("'") && trimmedSourceDir.endsWith("'")))) { - trimmedSourceDir = trimmedSourceDir.substring(1, trimmedSourceDir.length() - 1); - } - final Path sourceBasePath = Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); - LOG.debug("Applying remediations from {} to source directory {}", remediationPath, sourceBasePath); - final FvdlMetadataResult fvdlMetadataResult = loadFvdlMetadata(); - - try (InputStream remediationStream = Files.newInputStream(remediationPath)) { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - DocumentBuilder builder = factory.newDocumentBuilder(); - remediationDoc = builder.parse(remediationStream); - - NodeList remediationNodes = remediationDoc.getElementsByTagNameNS(NAMESPACE_URI, "Remediation"); - totalRemediations = remediationNodes.getLength(); - LOG.debug("Loaded {} remediation entries from {}", totalRemediations, remediationPath); - appliedRemediations = 0; - for (int i = 0; i < remediationNodes.getLength(); i++) { - Element remediation = (Element) remediationNodes.item(i); - if (processRemediation(remediation, sourceBasePath, fvdlMetadataResult, modifiedFiles, skippedByReason)) { - appliedRemediations++; - } - } - - } catch (ParserConfigurationException | SAXException | IOException e) { - LOG.error("Error parsing remediations.xml file: {}", remediationPath, e); - throw new AviatorTechnicalException("Error processing remediation.xml file.", e); - } catch (AviatorTechnicalException e) { - throw e; - } catch (Exception e) { - LOG.error("Unexpected error processing remediation.xml: {}", remediationPath, e); - throw new AviatorTechnicalException("Unexpected error processing remediations.xml.", e); - } - int skippedRemediations = totalRemediations - appliedRemediations; - LOG.info("Auto-remediation summary: total={}, applied={}, skipped={}", totalRemediations, appliedRemediations, skippedRemediations); - if (!skippedByReason.isEmpty()) { - LOG.info("Skipped remediations by reason: {}", formatSkippedReasons(skippedByReason)); - } - return new RemediationMetric(totalRemediations, appliedRemediations, skippedRemediations, modifiedFiles, skippedByReason); - } - - private boolean processRemediation(Element remediation, Path sourceBasePath, FvdlMetadataResult fvdlMetadataResult, - Set modifiedFiles, Map skippedByReason) { - String instanceId = remediation.getAttribute("instanceId"); - try { - Map pendingWrites = prepareFileChanges(remediation, sourceBasePath, fvdlMetadataResult); - if (pendingWrites.isEmpty()) { - recordSkipped(skippedByReason, SkipReason.NO_CHANGES); - return false; - } - try { - commitRemediationWrites(instanceId, pendingWrites, modifiedFiles); - return true; - } catch (RemediationCommitException e) { - rollbackRemediationWrites(instanceId, e.getRollbacks()); - throw new SkipRemediationException(SkipReason.SOURCE_WRITE_FAILED, e.getMessage(), e); - } - } catch (SkipRemediationException e) { - recordSkipped(skippedByReason, e.reason); - LOG.info("Skipping remediation {}: {}", instanceId, e.getMessage()); - LOG.debug("Skip reason for remediation {}: {}", instanceId, e.reason.displayName, e); - return false; - } catch (RollbackRemediationException e) { - throw e; - } catch (Exception e) { - recordSkipped(skippedByReason, SkipReason.UNEXPECTED_ERROR); - LOG.info("Skipping remediation {} due to an unexpected processing error", instanceId); - LOG.debug("Unexpected error while processing remediation {}", instanceId, e); - return false; - } - } - - private Map prepareFileChanges(Element remediation, Path sourceBasePath, - FvdlMetadataResult fvdlMetadataResult) { - NodeList fileChangesNodes = remediation.getElementsByTagNameNS(NAMESPACE_URI, "FileChanges"); - if (fileChangesNodes.getLength() == 0) { - throw new SkipRemediationException(SkipReason.NO_CHANGES, "No file changes found"); - } - - Map pendingWrites = new LinkedHashMap<>(); - for (int j = 0; j < fileChangesNodes.getLength(); j++) { - processFileChanges(remediation, (Element) fileChangesNodes.item(j), sourceBasePath, fvdlMetadataResult, pendingWrites); - } - return pendingWrites; - } - - private boolean processFileChanges(Element remediation, Element fileChanges, Path sourceBasePath, FvdlMetadataResult fvdlMetadataResult, - Map pendingWrites) { - String instanceId = remediation.getAttribute("instanceId"); - String filename = getRequiredElementText(fileChanges, "Filename"); - Path filePath = sourceBasePath.resolve(filename).normalize(); - LOG.debug("Processing remediation {} file change for '{}' resolved to '{}'", instanceId, filename, filePath); - - if (!filePath.startsWith(sourceBasePath)) { - throw new SkipRemediationException(SkipReason.SOURCE_FILE_OUTSIDE_SOURCE_DIR, - "Source file resolves outside source directory: " + filename); - } - - if (!isFilePresent(filePath)) { - throw new SkipRemediationException(SkipReason.SOURCE_FILE_MISSING, "Source code file not present at: " + filePath); - } - - String fileHash = getRequiredElementText(fileChanges, "Hash"); - Charset sourceEncoding = getRequiredSourceEncoding(filename, fvdlMetadataResult); - NodeList changesNodes = fileChanges.getElementsByTagNameNS(NAMESPACE_URI, "Change"); - if (changesNodes.getLength() == 0) { - throw new SkipRemediationException(SkipReason.NO_CHANGES, "No changes found for file: " + filename); - } - LOG.debug("Remediation {} has {} change(s) for '{}' using FVDL encoding {}", instanceId, changesNodes.getLength(), filename, - sourceEncoding.name()); - - String updatedContent = getPendingOrSourceContent(filePath, filename, sourceEncoding, pendingWrites); - for (int k = 0; k < changesNodes.getLength(); k++) { - updatedContent = applyChange(instanceId, filename, fileHash, sourceEncoding, updatedContent, - (Element) changesNodes.item(k), k + 1); - } - byte[] updatedBytes = encodeStrict(updatedContent, sourceEncoding, filename); - pendingWrites.put(filePath, new PendingFileWrite(filename, filePath, updatedContent, updatedBytes)); - LOG.debug("Staged remediation {} for '{}' using FVDL encoding {}; changes={}, encodedBytes={}", instanceId, filename, - sourceEncoding.name(), changesNodes.getLength(), updatedBytes.length); - return true; - } - - private String applyChange(String instanceId, String filename, String fileHash, Charset sourceEncoding, String originalContent, - Element change, int changeIndex) { - String lineSeparator = detectLineSeparator(originalContent); - String content = normalizeLineEndings(originalContent); - - List originalLines = Arrays.asList(content.split("\n", -1)); - LOG.debug("Decoded '{}' using {}; lineSeparator={}, normalizedLines={}", filename, sourceEncoding.name(), - describeLineSeparator(lineSeparator), originalLines.size()); - - int lineFrom = parseRequiredInt(change, "LineFrom"); - int lineTo = parseRequiredInt(change, "LineTo"); - LOG.debug("Remediation {} change {} for '{}' targets lines {}-{}", instanceId, changeIndex, filename, lineFrom, lineTo); - - String calculatedHash = calculateHashBase64(content, "SHA-256"); - boolean fileHashMatches = calculatedHash.equals(fileHash); - LOG.debug("Remediation {} hash check for '{}': {}", instanceId, filename, fileHashMatches ? "matched" : "mismatched"); - if (!fileHashMatches) { - LOG.debug("File hash mismatch for remediation {} in {}; searching changed source content", instanceId, filename); - String contextText = getRequiredElementText(change, "Context"); - List contextLine = Arrays.asList(contextText.split("\\r?\\n")); - int contextLineFrom = fuzzySearchContext(instanceId, filename, originalLines, contextLine); - if (contextLineFrom == -1) { - LOG.debug("Context search failed for remediation {} in {}; context lines={}, source lines={}", instanceId, filename, - contextLine.size(), originalLines.size()); - throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_NOT_FOUND, "Source context not found for file '" + filename + - "'; file may have changed or remediation may overlap a previous change"); - } - LOG.debug("Context for remediation {} in {} matched at line {}", instanceId, filename, contextLineFrom + 1); - - String originalCodeText = getRequiredElementText(change, "OriginalCode"); - List originalCodeLine = Arrays.asList(originalCodeText.split("\\r?\\n")); - int[] lineFromTo = fuzzySearchOriginalCode(instanceId, filename, originalLines, originalCodeLine, contextLineFrom); - if (lineFromTo[0] == -1 || lineFromTo[1] == -1) { - LOG.debug("Original code search failed for remediation {} in {}; context line={}, original code lines={}, source lines={}", - instanceId, filename, contextLineFrom + 1, originalCodeLine.size(), originalLines.size()); - throw new SkipRemediationException(SkipReason.ORIGINAL_CODE_NOT_FOUND, "Original code not found for file '" + filename + - "'; file may have changed or remediation may overlap a previous change"); - } - lineFrom = lineFromTo[0] + 1; - lineTo = lineFromTo[1] + 1; - LOG.debug("Original code for remediation {} in {} matched at lines {}-{}", instanceId, filename, lineFrom, lineTo); - } - - validateLineRange(lineFrom, lineTo, originalLines.size(), filename); - List newCodeLines = Arrays.asList(getRequiredElementText(change, "NewCode").split("\n")); - List updatedLines = new ArrayList<>(); - updatedLines.addAll(originalLines.subList(0, lineFrom - 1)); - updatedLines.addAll(newCodeLines); - updatedLines.addAll(originalLines.subList(lineTo, originalLines.size())); - LOG.debug("Staged remediation {} change {} for '{}' using FVDL encoding {}; updatedLines={}", instanceId, changeIndex, - filename, sourceEncoding.name(), updatedLines.size()); - return String.join(lineSeparator, updatedLines); - } - - private String getPendingOrSourceContent(Path filePath, String filename, Charset sourceEncoding, - Map pendingWrites) { - PendingFileWrite pendingWrite = pendingWrites.get(filePath); - return pendingWrite == null ? readSourceFile(filePath, filename, sourceEncoding) : pendingWrite.content(); - } - - private void commitRemediationWrites(String instanceId, Map pendingWrites, Set modifiedFiles) - throws RemediationCommitException { - List rollbacks = new ArrayList<>(); - for (PendingFileWrite pendingWrite : pendingWrites.values()) { - try { - byte[] originalBytes = Files.readAllBytes(pendingWrite.filePath()); - rollbacks.add(new RollbackFileWrite(pendingWrite.filename(), pendingWrite.filePath(), originalBytes)); - LOG.debug("Writing remediation {} to '{}' using staged bytes; encodedBytes={}", instanceId, pendingWrite.filename(), - pendingWrite.updatedBytes().length); - Files.write(pendingWrite.filePath(), pendingWrite.updatedBytes()); - } catch (Exception e) { - throw new RemediationCommitException("Error writing source code file '" + pendingWrite.filename() + "'", e, rollbacks); - } - } - - for (PendingFileWrite pendingWrite : pendingWrites.values()) { - modifiedFiles.add(pendingWrite.filename()); - LOG.info("Remediation applied for {} in file {}", instanceId, pendingWrite.filename()); - } - } - - private void rollbackRemediationWrites(String instanceId, List rollbacks) { - for (RollbackFileWrite rollback : rollbacks) { - try { - Files.write(rollback.filePath(), rollback.originalBytes()); - LOG.warn("Rolled back remediation {} changes for '{}' after write failure", instanceId, rollback.filename()); - } catch (IOException rollbackException) { - LOG.error("Failed to roll back remediation {} changes for '{}'", instanceId, rollback.filename(), rollbackException); - throw new RollbackRemediationException("Failed to roll back remediation changes for '" + rollback.filename() + - "'. Source files may be partially modified; inspect the source tree before retrying", rollbackException); - } - } - } - - private int fuzzySearchContext(String instanceId, String filename, List originalLines, List contextLine) { - try { - return FuzzyContextSearcher.fuzzySearchContext(originalLines, contextLine, 0); - } catch (IOException e) { - throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_NOT_FOUND, - "Error searching source context for remediation '" + instanceId + "' in file '" + filename + "'", e); - } - } - - private int[] fuzzySearchOriginalCode(String instanceId, String filename, List originalLines, List originalCodeLine, - int contextLineFrom) { - return FuzzyContextSearcher.fuzzySearchOriginalCode(originalLines, originalCodeLine, 0, contextLineFrom); - } - - private boolean isFilePresent(Path path) { - return Files.exists(path) && Files.isRegularFile(path); - } - - private FvdlMetadataResult loadFvdlMetadata() { - if (!Files.exists(fprHandle.getPath("/audit.fvdl"))) { - LOG.warn("FVDL file '/audit.fvdl' is missing; source remediations will be skipped"); - return new FvdlMetadataResult(null, SkipReason.FVDL_METADATA_UNAVAILABLE); - } - - try (ZipFile zipFile = new ZipFile(fprHandle.getFprPath().toFile())) { - LOG.debug("Loading FVDL build metadata from '{}' to resolve source encodings", fprHandle.getFprPath()); - StreamingFVDLProcessor processor = new StreamingFVDLProcessor(fprHandle); - processor.parseBuildMetadata(zipFile, "audit.fvdl"); - LOG.debug("Loaded FVDL build metadata from '{}'", fprHandle.getFprPath()); - return new FvdlMetadataResult(processor.getFvdlMetadata(), null); - } catch (Exception e) { - LOG.warn("Error reading source file encodings from audit.fvdl; source remediations will be skipped", e); - return new FvdlMetadataResult(null, SkipReason.FVDL_METADATA_UNAVAILABLE); - } - } - - private Charset getRequiredSourceEncoding(String filename, FvdlMetadataResult fvdlMetadataResult) { - if (fvdlMetadataResult.skipReason() != null || fvdlMetadataResult.metadata() == null) { - throw new SkipRemediationException(SkipReason.FVDL_METADATA_UNAVAILABLE, - "FVDL metadata is unavailable; cannot determine source encoding for file '" + filename + "'"); - } - - String encoding = fvdlMetadataResult.metadata().findSourceFileEncodingForFileName(filename); - if (encoding == null || encoding.isBlank()) { - LOG.debug("FVDL source encoding lookup failed for '{}'", filename); - throw new SkipRemediationException(SkipReason.FVDL_ENCODING_MISSING, - "FVDL does not declare a source encoding for file '" + filename + "'"); - } - - try { - Charset charset = Charset.forName(encoding); - LOG.debug("FVDL source encoding for '{}' resolved to '{}'", filename, charset.name()); - return charset; - } catch (Exception e) { - throw new SkipRemediationException(SkipReason.FVDL_ENCODING_UNSUPPORTED, - "FVDL declares unsupported source encoding '" + encoding + "' for file '" + filename + "'", e); - } - } - - private String readSourceFile(Path filePath, String filename, Charset sourceEncoding) { - try { - byte[] sourceBytes = Files.readAllBytes(filePath); - String decodedContent = decodeStrict(sourceBytes, sourceEncoding); - LOG.debug("Strict decoded '{}' using {}; sourceBytes={}, decodedChars={}", filename, sourceEncoding.name(), sourceBytes.length, - decodedContent.length()); - return decodedContent; - } catch (CharacterCodingException e) { - throw new SkipRemediationException(SkipReason.SOURCE_DECODE_FAILED, - "FVDL declares source encoding '" + sourceEncoding.name() + "' for file '" + filename + - "', but the source file cannot be decoded using that encoding", e); - } catch (IOException e) { - throw new SkipRemediationException(SkipReason.SOURCE_READ_FAILED, "Error reading source code file '" + filePath + "'", e); - } - } - - private String decodeStrict(byte[] bytes, Charset charset) throws CharacterCodingException { - return charset.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString(); - } - - private byte[] encodeStrict(String content, Charset charset, String filename) { - try { - ByteBuffer buffer = charset.newEncoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .encode(CharBuffer.wrap(content)); - byte[] result = new byte[buffer.remaining()]; - buffer.get(result); - return result; - } catch (CharacterCodingException e) { - throw new SkipRemediationException(SkipReason.REMEDIATION_ENCODE_FAILED, - "Remediation content for file '" + filename + "' cannot be encoded using FVDL source encoding '" + - charset.name() + "'", e); - } - } - - private String getRequiredElementText(Element parent, String elementName) { - NodeList nodes = parent.getElementsByTagNameNS(NAMESPACE_URI, elementName); - if (nodes.getLength() == 0 || nodes.item(0) == null) { - throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, - "Missing required remediation element '" + elementName + "'"); - } - return nodes.item(0).getTextContent(); - } - - private int parseRequiredInt(Element parent, String elementName) { - String value = getRequiredElementText(parent, elementName); - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, - "Invalid integer value for remediation element '" + elementName + "': " + value, e); - } - } - - private void validateLineRange(int lineFrom, int lineTo, int sourceLineCount, String filename) { - if (lineFrom < 1 || lineTo < lineFrom || lineTo > sourceLineCount) { - throw new SkipRemediationException(SkipReason.REMEDIATION_LINE_RANGE_INVALID, - "Invalid remediation line range " + lineFrom + "-" + lineTo + " for file '" + filename + "'"); - } - } - - private String detectLineSeparator(String content) { - int crlfIndex = content.indexOf("\r\n"); - int lfIndex = content.indexOf('\n'); - int crIndex = content.indexOf('\r'); - - if (crlfIndex >= 0 && (lfIndex == crlfIndex + 1 || lfIndex < 0) && (crIndex == crlfIndex || crIndex < 0)) { - return "\r\n"; - } - if (lfIndex >= 0 && (crIndex < 0 || lfIndex < crIndex)) { - return "\n"; - } - if (crIndex >= 0) { - return "\r"; - } - return System.lineSeparator(); - } - - private String normalizeLineEndings(String content) { - return content.replace("\r\n", "\n").replace('\r', '\n'); - } - - private String describeLineSeparator(String lineSeparator) { - return switch (lineSeparator) { - case "\r\n" -> "CRLF"; - case "\n" -> "LF"; - case "\r" -> "CR"; - default -> "system"; - }; - } - - private String calculateHashBase64(String content, String algorithm) { - String hash; - if (content == null) { - return ""; - } - try { - MessageDigest md = MessageDigest.getInstance(algorithm); - byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); - hash = Base64.getEncoder().encodeToString(digest); - return hash; - } catch (NoSuchAlgorithmException e) { - throw new AviatorTechnicalException("Hashing algorithm not available: " + algorithm, e); - } - } - - private void recordSkipped(Map skippedByReason, SkipReason reason) { - skippedByReason.merge(reason.displayName, 1, Integer::sum); - } - - private String formatSkippedReasons(Map skippedByReason) { - List parts = new ArrayList<>(); - skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); - return String.join(", ", parts); - } -} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java index 9a3f8ffa9e8..c09dbae3599 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/StreamingFVDLProcessor.java @@ -32,6 +32,8 @@ import com.fortify.cli.aviator.fpr.filter.AnalyzerType; import com.fortify.cli.aviator.fpr.model.*; import com.fortify.cli.aviator.fpr.utils.FileUtils; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; import com.fortify.cli.aviator.fpr.utils.XmlUtils; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.StringUtil; @@ -70,17 +72,26 @@ public class StreamingFVDLProcessor { private long peakMemoryPass2 = 0; private long peakMemoryPostProcessing = 0; - public StreamingFVDLProcessor(FprHandle fprHandle){ + public StreamingFVDLProcessor(FprHandle fprHandle) { + this(fprHandle, SourceDecoders.defaults()); + } + + /** + * @param sourceDecoder used for stack-trace line/fragment source reads; shares {@link #fvdlMetadata} + * so FPR encoding candidates resolve after build metadata is parsed. + */ + public StreamingFVDLProcessor(FprHandle fprHandle, ISourceDecoder sourceDecoder) { this.vulnFinalizer = new VulnFinalizer(); - this.fileUtils = new FileUtils(); this.fprHandle = fprHandle; this.sourceFileMap = fprHandle.getSourceFileMap(); this.xmlInputFactory = XMLInputFactory.newInstance(); // Security: Disable external entity processing xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); - //this.parsingMetadata = new ParsingMetadata(); this.fvdlMetadata = new FVDLMetadata(); + // Same metadata instance FileUtils will see once encodings are registered during parse. + this.fileUtils = new FileUtils( + Objects.requireNonNull(sourceDecoder, "sourceDecoder"), this.fvdlMetadata); this.rawVulnerabilities = new ArrayList<>(); this.vulnerabilities = new ArrayList<>(); this.descriptionProcessor = new DescriptionProcessor(); @@ -92,8 +103,6 @@ public StreamingFVDLProcessor(FprHandle fprHandle){ this.traceParser.setNodeParser(nodeParser); // Circular dependency for Reason parsing this.descriptionParser = new DescriptionParser(); this.metadataParser = new MetadataParser(); - /*this.extractedPath = extractedPath; - this.indexXMLProcessor = new IndexXMLProcessor(extractedPath, sourceFileMap);*/ } @@ -416,7 +425,7 @@ public void parseBuildMetadata(ZipFile zipFile, String entryName) throws Excepti logger.debug("Parsed FVDL build metadata entry '{}'", entryName); } - private void parseBuildMetadata(InputStream inputStream) throws XMLStreamException { + public void parseBuildMetadata(InputStream inputStream) throws XMLStreamException { XMLStreamReader reader = xmlInputFactory.createXMLStreamReader(inputStream); try { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationExecutionMode.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationExecutionMode.java new file mode 100644 index 00000000000..f9c8647ad61 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationExecutionMode.java @@ -0,0 +1,18 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation; + +public enum RemediationExecutionMode { + APPLY, + PREVIEW +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingOptions.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingOptions.java new file mode 100644 index 00000000000..eb617a3bf07 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingOptions.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; + +public record RemediationProcessingOptions(Set issueIdFilter, RemediationExecutionMode executionMode, + ISourceDecoder sourceDecoder) { + public RemediationProcessingOptions { + issueIdFilter = issueIdFilter == null + ? Set.of() + : Collections.unmodifiableSet(new LinkedHashSet<>(issueIdFilter)); + executionMode = Objects.requireNonNull(executionMode, "executionMode"); + sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); + } + + public boolean isFiltered() { + return !issueIdFilter.isEmpty(); + } + + public boolean isPreview() { + return executionMode == RemediationExecutionMode.PREVIEW; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingState.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingState.java new file mode 100644 index 00000000000..c609f655c69 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessingState.java @@ -0,0 +1,181 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.FilePreview; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + +final class RemediationProcessingState { + private static final String POSSIBLY_REMEDIATED_REASON = "Possibly remediated by a sibling fix"; + private static final String REQUESTED_ISSUE_NOT_FOUND = "Requested issue not found in remediations"; + + private final RemediationProcessingOptions options; + private final Set seenIssueIds = new LinkedHashSet<>(); + private final Set satisfiedIssueIds = new LinkedHashSet<>(); + private final Set appliedIssueIds = new LinkedHashSet<>(); + private final Set identicalIssueIds = new LinkedHashSet<>(); + private final Set supersededIssueIds = new LinkedHashSet<>(); + private final Set possiblyRemediatedIssueIds = new LinkedHashSet<>(); + private final Set modifiedFiles = new LinkedHashSet<>(); + private final Map skippedByReason = new LinkedHashMap<>(); + private final Map issueSkipReasons = new LinkedHashMap<>(); + private final Map descriptionsByIssue = new LinkedHashMap<>(); + private final Map previewDetailsByIssue = new LinkedHashMap<>(); + private int xmlEntryCount; + private int appliedRemediations; + private int identicalRemediations; + private int supersededRemediations; + private int possiblyRemediatedRemediations; + + RemediationProcessingState(RemediationProcessingOptions options) { + this.options = options; + } + + void setXmlEntryCount(int xmlEntryCount) { + this.xmlEntryCount = xmlEntryCount; + } + + boolean shouldProcess(String instanceId) { + return !options.isFiltered() || options.issueIdFilter().contains(instanceId); + } + + void recordSeen(String instanceId) { + if (options.isFiltered() && instanceId != null) { + seenIssueIds.add(instanceId); + } + } + + void recordDescription(String instanceId, String description) { + if (instanceId != null && !instanceId.isBlank() && description != null) { + descriptionsByIssue.put(instanceId, description); + } + } + + void recordApplied(String instanceId) { + appliedRemediations++; + recordSatisfied(instanceId); + appliedIssueIds.add(instanceId); + issueSkipReasons.remove(instanceId); + } + + void recordPreviewAvailable(String instanceId, Map files) { + recordApplied(instanceId); + for (FilePreview filePreview : files.values()) { + modifiedFiles.add(filePreview.path()); + } + previewDetailsByIssue.put(instanceId, + PreviewDetail.available(instanceId, descriptionsByIssue.get(instanceId), files)); + } + + void recordIdentical(String instanceId) { + identicalRemediations++; + identicalIssueIds.add(instanceId); + recordSatisfied(instanceId); + } + + void recordSuperseded(String instanceId) { + supersededRemediations++; + supersededIssueIds.add(instanceId); + recordSatisfied(instanceId); + } + + void recordPossiblyRemediated(String instanceId) { + possiblyRemediatedRemediations++; + possiblyRemediatedIssueIds.add(instanceId); + issueSkipReasons.put(instanceId, POSSIBLY_REMEDIATED_REASON); + } + + void recordSkipped(String instanceId, SkipReason reason) { + recordSkipped(instanceId, reason.displayName()); + } + + void recordSkipped(String instanceId, String reason) { + skippedByReason.merge(reason, 1, Integer::sum); + if (instanceId != null && !instanceId.isBlank()) { + issueSkipReasons.put(instanceId, reason); + if (options.isPreview()) { + previewDetailsByIssue.put(instanceId, + PreviewDetail.skipped(instanceId, descriptionsByIssue.get(instanceId))); + } + } + } + + Set modifiedFiles() { + return modifiedFiles; + } + + Map skippedByReason() { + return skippedByReason; + } + + RemediationMetric toMetric() { + recordMissingRequestedIssues(); + int totalRemediations = options.isFiltered() ? options.issueIdFilter().size() : xmlEntryCount; + int applied = options.isFiltered() ? appliedIssueIds.size() : appliedRemediations; + int skipped = options.isFiltered() + ? totalRemediations - satisfiedIssueIds.size() + : totalRemediations - appliedRemediations - identicalRemediations - supersededRemediations + - possiblyRemediatedRemediations; + return RemediationMetric.builder() + .totalRemediations(totalRemediations) + .appliedRemediations(applied) + .identicalRemediations(identicalRemediations) + .supersededRemediations(supersededRemediations) + .possiblyRemediatedRemediations(possiblyRemediatedRemediations) + .skippedRemediations(skipped) + .modifiedFiles(modifiedFiles) + .skippedByReason(skippedByReason) + .executionMode(options.executionMode()) + .requestedIssueIds(options.issueIdFilter()) + .seenIssueIds(seenIssueIds) + .satisfiedIssueIds(satisfiedIssueIds) + .appliedIssueIds(appliedIssueIds) + .identicalIssueIds(identicalIssueIds) + .supersededIssueIds(supersededIssueIds) + .possiblyRemediatedIssueIds(possiblyRemediatedIssueIds) + .issueSkipReasons(issueSkipReasons) + .previewDetails(new ArrayList<>(previewDetailsByIssue.values())) + .build(); + } + + private void recordMissingRequestedIssues() { + if (!options.isFiltered()) { + return; + } + for (String requestedIssueId : options.issueIdFilter()) { + if (seenIssueIds.contains(requestedIssueId) || satisfiedIssueIds.contains(requestedIssueId) + || issueSkipReasons.putIfAbsent(requestedIssueId, REQUESTED_ISSUE_NOT_FOUND) != null) { + continue; + } + skippedByReason.merge(REQUESTED_ISSUE_NOT_FOUND, 1, Integer::sum); + if (options.isPreview()) { + previewDetailsByIssue.put(requestedIssueId, + PreviewDetail.skipped(requestedIssueId, null)); + } + } + } + + private void recordSatisfied(String instanceId) { + if (instanceId != null && !instanceId.isBlank()) { + satisfiedIssueIds.add(instanceId); + issueSkipReasons.remove(instanceId); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java new file mode 100644 index 00000000000..0b1f0ccf031 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/RemediationProcessor.java @@ -0,0 +1,433 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import com.fortify.cli.aviator._common.config.AviatorConfigManager; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.processor.StreamingFVDLProcessor; +import com.fortify.cli.aviator.fpr.remediation.applier.RemediationApplier; +import com.fortify.cli.aviator.fpr.remediation.classifier.AppliedChangeLedger; +import com.fortify.cli.aviator.fpr.remediation.classifier.HunkClassifier; +import com.fortify.cli.aviator.fpr.remediation.exception.RemediationCommitException; +import com.fortify.cli.aviator.fpr.remediation.exception.RollbackRemediationException; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.HunkOutcome; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationDocument; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationKey; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.ChangeDetail; +import com.fortify.cli.aviator.fpr.remediation.preview.FilePreview; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewFileChange; +import com.fortify.cli.aviator.fpr.remediation.writer.FileWriteCoordinator; +import com.fortify.cli.aviator.fpr.remediation.writer.PendingFileWrite; +import com.fortify.cli.aviator.fpr.remediation.writer.PreparedFileChanges; +import com.fortify.cli.aviator.fpr.remediation.xmlprocessor.RemediationDocumentMapper; +import com.fortify.cli.aviator.fpr.remediation.xmlprocessor.RemediationXmlReader; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; +import com.fortify.cli.aviator.util.FprHandle; + +/** + * Orchestrator. {@link #processRemediationXML()} runs the three phases in sequence: parse + * XML into a DOM {@link Document} ({@link RemediationXmlReader}), map the document into the + * domain model with zero business logic ({@link RemediationDocumentMapper}), then classify + * and apply each remediation ({@link #classifyAndApply}). Preview lists declared remediations.xml + * fields without running the applier. + * + *

Per-FPR isolation: Each processor instance handles exactly one FPR. In multi-FPR + * scenarios (e.g., --all-open-issues), each artifact gets its own processor with a fresh + * {@link AppliedChangeLedger}. Ledgers are NOT shared across artifacts because line numbers + * are relative to pristine-file coordinates, and different artifacts are scans of potentially + * different source revisions. Correctness across FPRs is maintained by anchor verification + * (hash checking): once an earlier FPR has touched a file, the declared hash no longer matches, + * so later hunks apply only where their OriginalCode still literally matches. + */ +public class RemediationProcessor { + private static final String NAMESPACE_URI = "xmlns://www.fortify.com/schema/remediations"; + private static final Logger LOG = LoggerFactory.getLogger(RemediationProcessor.class); + + private final FprHandle fprHandle; + private final String sourceCodeDirectory; + private final RemediationProcessingOptions options; + private final ISourceDecoder sourceDecoder; + + private final RemediationXmlReader xmlReader = new RemediationXmlReader(); + private final RemediationDocumentMapper documentMapper = new RemediationDocumentMapper(); + private final HunkClassifier hunkClassifier = new HunkClassifier(); + private final RemediationApplier remediationApplier = new RemediationApplier(); + private final FileWriteCoordinator fileWriteCoordinator; + + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory) { + this(fprHandle, sourceCodeDirectory, SourceDecoders.defaults()); + } + + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, ISourceDecoder sourceDecoder) { + this(fprHandle, sourceCodeDirectory, + new RemediationProcessingOptions(Set.of(), RemediationExecutionMode.APPLY, sourceDecoder)); + } + + public RemediationProcessor(FprHandle fprHandle, String sourceCodeDirectory, RemediationProcessingOptions options) { + this.fprHandle = fprHandle; + this.sourceCodeDirectory = sourceCodeDirectory; + this.options = Objects.requireNonNull(options, "options"); + this.sourceDecoder = options.sourceDecoder(); + this.fileWriteCoordinator = new FileWriteCoordinator(sourceDecoder, remediationApplier); + // Ensures FileTypeLanguageMapperUtil (used by Hunk during classification) is initialized + // up front instead of falling through its implicit-init warning path on first use. + AviatorConfigManager.getInstance(); + } + + public RemediationMetric processRemediationXML() { + Path remediationPath = fprHandle.getPath("/remediations.xml"); + Path sourceBasePath = resolveSourceBasePath(); + LOG.debug("{} remediations from {} to source directory {}", + options.isPreview() ? "Previewing" : "Applying", remediationPath, sourceBasePath); + FVDLMetadata fvdlMetadata = loadFvdlMetadata(); + + try { + Document remediationDoc = xmlReader.read(remediationPath); + RemediationDocument remediations = documentMapper.map(remediationDoc); + RemediationProcessingState state = new RemediationProcessingState(options); + recordDescriptions(remediationDoc, state); + if (options.isPreview()) { + return previewRemediations(remediations, sourceBasePath, fvdlMetadata, state); + } + AppliedChangeLedger ledger = new AppliedChangeLedger(); + return classifyAndApply(remediations, sourceBasePath, fvdlMetadata, ledger, state); + } catch (AviatorTechnicalException e) { + throw e; + } catch (Exception e) { + LOG.error("Unexpected error processing remediation.xml: {}", remediationPath, e); + throw new AviatorTechnicalException("Unexpected error processing remediations.xml.", e); + } + } + + private Path resolveSourceBasePath() { + String trimmedSourceDir = sourceCodeDirectory.trim(); + if (trimmedSourceDir.length() > 1 && + ((trimmedSourceDir.startsWith("\"") && trimmedSourceDir.endsWith("\"")) || + (trimmedSourceDir.startsWith("'") && trimmedSourceDir.endsWith("'")))) { + trimmedSourceDir = trimmedSourceDir.substring(1, trimmedSourceDir.length() - 1); + } + return Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); + } + + private RemediationMetric previewRemediations(RemediationDocument remediationDocument, Path sourceBasePath, + FVDLMetadata fvdlMetadata, RemediationProcessingState state) { + List remediations = remediationDocument.remediations(); + state.setXmlEntryCount(remediations.size()); + LOG.debug("Previewing {} remediation entries from remediations.xml", remediations.size()); + for (Remediation remediation : remediations) { + String instanceId = remediation.instanceId(); + if (!state.shouldProcess(instanceId)) { + continue; + } + state.recordSeen(instanceId); + try { + Map files = xmlFilePreviews(remediation, sourceBasePath, fvdlMetadata); + state.recordPreviewAvailable(instanceId, files); + } catch (SkipRemediationException e) { + state.recordSkipped(instanceId, skipReasonLabel(e)); + LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); + LOG.debug("Skip reason for remediation {}: {}", instanceId, e.getReason().displayName(), e); + } catch (Exception e) { + state.recordSkipped(instanceId, SkipReason.UNEXPECTED_ERROR); + LOG.warn("Skipping remediation {} due to an unexpected processing error", instanceId); + LOG.debug("Unexpected error while previewing remediation {}", instanceId, e); + } + } + return finish(state); + } + + private Map xmlFilePreviews(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata) { + List fileChanges = remediation.fileChanges(); + if (fileChanges.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No file changes found"); + } + Map files = new LinkedHashMap<>(); + for (FileChange fileChange : fileChanges) { + String filename = fileChange.requiredFilename(); + Path filePath = fileChange.resolve(sourceBasePath); + if (!filePath.startsWith(sourceBasePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_OUTSIDE_SOURCE_DIR, + "Source file resolves outside source directory: " + filename); + } + if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_MISSING, + "Source code file not present at: " + filePath); + } + List hunks = fileChange.hunks(); + if (hunks.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No changes found for file: " + filename); + } + String encoding = fileWriteCoordinator.encodingFor(filePath, filename, fvdlMetadata).name(); + List changes = new ArrayList<>(); + FilePreview existing = files.get(filename); + if (existing != null) { + changes.addAll(existing.changes()); + } + int changeIndex = changes.size(); + for (Hunk hunk : hunks) { + changes.add(ChangeDetail.builder() + .changeIndex(++changeIndex) + .lineFrom(hunk.lineFrom()) + .lineTo(hunk.lineTo()) + .originalCode(hunk.requiredOriginalCode()) + .newCode(hunk.requiredNewCode()) + .contextLinesBefore(hunk.contextBeforeOrZero()) + .contextLinesAfter(hunk.contextAfterOrZero()) + .contextContent(hunk.contextTextOrEmpty()) + .build() + .toPreviewFileChange()); + } + files.put(filename, new FilePreview(filename, encoding, List.copyOf(changes))); + } + return files; + } + + private RemediationMetric classifyAndApply(RemediationDocument remediationDocument, Path sourceBasePath, FVDLMetadata fvdlMetadata, + AppliedChangeLedger ledger, RemediationProcessingState state) { + List orderedRemediations = new ArrayList<>(remediationDocument.remediations()); + int totalRemediations = orderedRemediations.size(); + state.setXmlEntryCount(totalRemediations); + LOG.debug("Loaded {} remediation entries", totalRemediations); + try { + // Widest-first ordering: broader fixes land first so narrower nested ones classify as SUPERSEDED. + orderedRemediations.sort((a, b) -> Integer.compare(b.maxHunkWidth(), a.maxHunkWidth())); + } catch (SkipRemediationException e) { + LOG.debug("Unable to sort remediations by width; proceeding with original order", e); + } + Map remediationLookup = new LinkedHashMap<>(); + + for (Remediation remediation : orderedRemediations) { + String instanceId = remediation.instanceId(); + if (!state.shouldProcess(instanceId)) { + continue; + } + state.recordSeen(instanceId); + List remediationKeys; + try { + remediationKeys = remediation.createRemediationKeys(sourceBasePath); + } catch (SkipRemediationException e) { + state.recordSkipped(instanceId, skipReasonLabel(e)); + LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); + LOG.debug("Skip reason for remediation {}: {}", instanceId, e.getReason().displayName(), e); + continue; + } + + // Hunk-level identity: partition keys into already-satisfied vs to-apply. + Set satisfiedKeys = new LinkedHashSet<>(); + Set toApplyKeys = new LinkedHashSet<>(); + Set satisfiedByInstances = new LinkedHashSet<>(); + for (RemediationKey key : remediationKeys) { + String owner = remediationLookup.get(key); + if (owner != null) { + satisfiedKeys.add(key); + satisfiedByInstances.add(owner); + } else { + toApplyKeys.add(key); + } + } + + // Fully identical: every hunk was already applied by an earlier remediation with same content. + if (!remediationKeys.isEmpty() && toApplyKeys.isEmpty()) { + state.recordIdentical(instanceId); + LOG.info("Remediation {} is fully identical to prior remediation(s) {}; {} hunk(s) already applied", + instanceId, satisfiedByInstances, satisfiedKeys.size()); + continue; + } + + // SUPERSEDED / CONFLICTS pre-check: classify each unsatisfied hunk against the ledger. + List preClass = hunkClassifier.classifyRemediationHunks(remediation, sourceBasePath, ledger); + boolean anyApplyCandidate = preClass.stream().anyMatch(o -> o == HunkOutcome.APPLIED); + boolean allSuperseded = !preClass.isEmpty() && preClass.stream().allMatch(o -> o == HunkOutcome.SUPERSEDED); + boolean anyConflicts = preClass.stream().anyMatch(o -> o == HunkOutcome.CONFLICTS); + boolean allPossiblyRemediated = !preClass.isEmpty() + && preClass.stream().noneMatch(o -> o == HunkOutcome.APPLIED || o == HunkOutcome.CONFLICTS) + && preClass.stream().anyMatch(o -> o == HunkOutcome.POSSIBLY_REMEDIATED); + + if (!anyApplyCandidate && allSuperseded) { + state.recordSuperseded(instanceId); + LOG.info("Remediation {} is superseded by a broader prior fix for all {} hunk(s); no write needed", + instanceId, preClass.size()); + continue; + } + // Remediations are applied atomically: even one CONFLICTS hunk means this remediation + // cannot be fully/correctly applied, so reject it now rather than let the applier's + // best-effort offset/fuzzy-anchor recovery (meant for non-conflicting shifts) decide. + if (anyConflicts) { + state.recordSkipped(instanceId, SkipReason.CONFLICTS_WITH_ANOTHER_FIX); + LOG.info("Remediation {} conflicts with prior fix(es) on {} of {} hunk(s); skipping", + instanceId, preClass.stream().filter(o -> o == HunkOutcome.CONFLICTS).count(), preClass.size()); + continue; + } + if (!anyApplyCandidate && allPossiblyRemediated) { + state.recordPossiblyRemediated(instanceId); + LOG.info("Remediation {} possibly remediated by a sibling fix with different content for all {} hunk(s)", + instanceId, preClass.size()); + continue; + } + + // Partial identity: some hunks already applied; apply only the rest. + if (!satisfiedKeys.isEmpty()) { + LOG.info("Remediation {} is partially identical to prior remediation(s) {}; {} of {} hunk(s) already applied, {} still to apply", + instanceId, satisfiedByInstances, satisfiedKeys.size(), remediationKeys.size(), toApplyKeys.size()); + } + + // Mixed classification: a hunk already covered by a broader prior fix (SUPERSEDED or + // POSSIBLY_REMEDIATED) must not be re-attempted alongside a genuinely-applicable + // sibling hunk, or the covered hunk's stale anchor drags the whole remediation down. + // preClass and remediationKeys iterate the same fileChanges/hunks in the same order. + boolean classifierNarrowed = false; + for (int i = 0; i < preClass.size(); i++) { + HunkOutcome outcome = preClass.get(i); + if (outcome == HunkOutcome.SUPERSEDED || outcome == HunkOutcome.POSSIBLY_REMEDIATED) { + if (toApplyKeys.remove(remediationKeys.get(i))) { + classifierNarrowed = true; + } + } + } + if (classifierNarrowed) { + LOG.info("Remediation {} has {} hunk(s) already covered by a broader prior fix; applying only the rest", + instanceId, remediationKeys.size() - toApplyKeys.size() - satisfiedKeys.size()); + } + + Set filter = (satisfiedKeys.isEmpty() && !classifierNarrowed) ? null : toApplyKeys; + PreparedFileChanges prepared = processRemediation(remediation, sourceBasePath, fvdlMetadata, state, filter, ledger); + if (prepared != null && !prepared.appliedKeys().isEmpty()) { + state.recordApplied(instanceId); + for (RemediationKey key : prepared.appliedKeys()) { + LOG.debug("putting {}", instanceId); + remediationLookup.put(key, instanceId); + } + } + } + + return finish(state); + } + + private RemediationMetric finish(RemediationProcessingState state) { + RemediationMetric metric = state.toMetric(); + LOG.info("Auto-remediation summary: total={}, applied={}, identical={}, superseded={}, possiblyRemediated={}, skipped={}", + metric.totalRemediations(), metric.appliedRemediations(), metric.identicalRemediations(), metric.supersededRemediations(), + metric.possiblyRemediatedRemediations(), metric.skippedRemediations()); + if (!metric.skippedByReason().isEmpty()) { + LOG.info("Skipped remediations by reason: {}", formatSkippedReasons(metric.skippedByReason())); + } + return metric; + } + + private PreparedFileChanges processRemediation(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata, + RemediationProcessingState state, Set keysToApply, AppliedChangeLedger ledger) { + String instanceId = remediation.instanceId(); + ledger.discardStaged(); + try { + PreparedFileChanges prepared = fileWriteCoordinator.prepareFileChanges(remediation, sourceBasePath, fvdlMetadata, keysToApply, ledger); + Map pendingWrites = prepared.pendingWrites(); + + if (pendingWrites.isEmpty()) { + state.recordSkipped(instanceId, SkipReason.NO_CHANGES); + return null; + } + try { + fileWriteCoordinator.commitRemediationWrites(instanceId, pendingWrites, state.modifiedFiles()); + ledger.commitStaged(); + return prepared; + } catch (RemediationCommitException e) { + ledger.discardStaged(); + fileWriteCoordinator.rollbackRemediationWrites(instanceId, e.getRollbacks()); + throw new SkipRemediationException(SkipReason.SOURCE_WRITE_FAILED, e.getMessage(), e); + } + } catch (SkipRemediationException e) { + ledger.discardStaged(); + state.recordSkipped(instanceId, skipReasonLabel(e)); + LOG.warn("Skipping remediation {}: {}", instanceId, e.getMessage()); + LOG.debug("Skip reason for remediation {}: {}", instanceId, e.getReason().displayName(), e); + return null; + } catch (RollbackRemediationException e) { + throw e; + } catch (Exception e) { + ledger.discardStaged(); + state.recordSkipped(instanceId, SkipReason.UNEXPECTED_ERROR); + LOG.warn("Skipping remediation {} due to an unexpected processing error", instanceId); + LOG.debug("Unexpected error while processing remediation {}", instanceId, e); + return null; + } + } + + private String skipReasonLabel(SkipRemediationException exception) { + return exception.getReason().displayName(); + } + + private String formatSkippedReasons(Map skippedByReason) { + List parts = new ArrayList<>(); + skippedByReason.forEach((reason, count) -> parts.add(reason + "=" + count)); + return String.join(", ", parts); + } + + private void recordDescriptions(Document remediationDoc, RemediationProcessingState state) { + if (!options.isPreview()) { + return; + } + NodeList remediationNodes = remediationDoc.getElementsByTagNameNS(NAMESPACE_URI, "Remediation"); + for (int i = 0; i < remediationNodes.getLength(); i++) { + Element remediation = (Element) remediationNodes.item(i); + String instanceId = remediation.getAttribute("instanceId"); + NodeList comments = remediation.getElementsByTagNameNS(NAMESPACE_URI, "AuditComment"); + String description = comments.getLength() == 0 ? null : comments.item(0).getTextContent(); + state.recordDescription(instanceId, description); + } + } + + /** Nullable: missing/unreadable FVDL means FPR encoding candidate is skipped. */ + private FVDLMetadata loadFvdlMetadata() { + if (!Files.exists(fprHandle.getPath("/audit.fvdl"))) { + LOG.warn("FVDL file '/audit.fvdl' is missing; FPR encoding candidate will be skipped"); + return null; + } + + try (InputStream inputStream = Files.newInputStream(fprHandle.getPath("/audit.fvdl"))) { + LOG.debug("Loading FVDL build metadata from '{}' to resolve source encodings", fprHandle.getFprPath()); + // Decoder unused for metadata-only parse; ctor requires one for FileUtils wiring. + StreamingFVDLProcessor processor = new StreamingFVDLProcessor(fprHandle, sourceDecoder); + processor.parseBuildMetadata(inputStream); + LOG.debug("Loaded FVDL build metadata from '{}'", fprHandle.getFprPath()); + return processor.getFvdlMetadata(); + } catch (Exception e) { + LOG.warn("Error reading source file encodings from audit.fvdl; FPR encoding candidate will be skipped", e); + return null; + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/SkipReason.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/SkipReason.java new file mode 100644 index 00000000000..563ed813be4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/SkipReason.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation; + + public enum SkipReason { + SOURCE_FILE_MISSING("Source file missing"), + SOURCE_FILE_OUTSIDE_SOURCE_DIR("Source file outside source directory"), + SOURCE_READ_FAILED("Source file read failed"), + SOURCE_DECODE_FAILED("Source file decode failed"), + REMEDIATION_DATA_INVALID("Remediation data invalid"), + REMEDIATION_LINE_RANGE_INVALID("Remediation line range invalid"), + SOURCE_CONTEXT_NOT_FOUND("Source context not found"), + SOURCE_CONTEXT_AMBIGUOUS("Source context matched multiple locations"), + ORIGINAL_CODE_NOT_FOUND("Original code not found"), + ORIGINAL_CODE_AMBIGUOUS("Original code matched multiple locations"), + CONFLICTS_WITH_ANOTHER_FIX("Conflicts with another fix"), + ANCHOR_DOES_NOT_MATCH("Anchor does not match"), + REMEDIATION_ENCODE_FAILED("Remediation encode failed"), + SOURCE_WRITE_FAILED("Source file write failed"), + NO_CHANGES("No file changes found"), + UNEXPECTED_ERROR("Unexpected remediation processing error"); + + final String displayName; + + SkipReason(String displayName) { + this.displayName = displayName; + } + + final String displayName() { + return displayName; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/AppliedChangeResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/AppliedChangeResult.java new file mode 100644 index 00000000000..98753e08f6b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/AppliedChangeResult.java @@ -0,0 +1,15 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.applier; + +public record AppliedChangeResult(String updatedContent, int actualLineFrom, int actualLineTo) {} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/FuzzyAnchorLocator.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/FuzzyAnchorLocator.java new file mode 100644 index 00000000000..54010d285e5 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/FuzzyAnchorLocator.java @@ -0,0 +1,81 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.applier; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import com.fortify.cli.aviator.fpr.remediation.SkipReason; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.util.FuzzyContextSearcher; + +/** Wraps the FuzzyContextSearcher utility's context/original-code matching, unmodified from the original. */ +public final class FuzzyAnchorLocator { + + public int searchContext(String instanceId, String filename, List originalLines, List contextLine, + int projectedDeclaredFrom, int contextBefore) { + try { + List matches = FuzzyContextSearcher.fuzzySearchContextMatches(originalLines, contextLine, 0); + if (matches.size() > 1) { + int expectedContextLineFrom = projectedDeclaredFrom - 1 - contextBefore; + List exact = matches.stream().filter(m -> m == expectedContextLineFrom).toList(); + if (exact.size() == 1) { + return exact.get(0); + } + String candidateLines = matches.stream() + .map(line -> String.valueOf(line + 1)) + .collect(Collectors.joining(", ")); + throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_AMBIGUOUS, + "Source context matched multiple locations in file '" + filename + "'; candidate lines: " + candidateLines); + } + return matches.isEmpty() ? -1 : matches.get(0); + } catch (IOException e) { + throw new SkipRemediationException(SkipReason.SOURCE_CONTEXT_NOT_FOUND, + "Error searching source context for remediation '" + instanceId + "' in file '" + filename + "'", e); + } + } + + public Optional searchOriginalCode(String instanceId, String filename, List originalLines, List originalCodeLine, + int contextLineFrom, int contextLineCount, int contextBefore, int contextAfter, + int projectedDeclaredFrom, int projectedDeclaredTo) { + int contextStart = contextLineFrom + contextBefore; + int contextEnd = contextLineFrom + contextLineCount - contextAfter; + if (contextStart < 0 || contextStart >= contextEnd || contextEnd > originalLines.size()) { + return Optional.empty(); + } + + List matches = FuzzyContextSearcher.fuzzySearchOriginalCodeMatches( + originalLines.subList(contextStart, contextEnd), originalCodeLine, 0, 0); + if (matches.size() > 1) { + int expectedFrom = projectedDeclaredFrom - 1 - contextStart; + int expectedTo = projectedDeclaredTo - 1 - contextStart; + List exact = matches.stream().filter(m -> m.from() == expectedFrom && m.to() == expectedTo).toList(); + if (exact.size() == 1) { + LineRange m = exact.get(0); + return Optional.of(new LineRange(m.from() + contextStart, m.to() + contextStart)); + } + String candidateLines = matches.stream() + .map(m -> String.valueOf(m.from() + contextStart + 1)) + .collect(Collectors.joining(", ")); + throw new SkipRemediationException(SkipReason.ORIGINAL_CODE_AMBIGUOUS, + "Original code matched multiple locations in file '" + filename + "'; candidate lines: " + candidateLines); + } + if (matches.isEmpty()) { + return Optional.empty(); + } + LineRange lineFromTo = matches.get(0); + return Optional.of(new LineRange(lineFromTo.from() + contextStart, lineFromTo.to() + contextStart)); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/LineRange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/LineRange.java new file mode 100644 index 00000000000..5a75c69f77c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/LineRange.java @@ -0,0 +1,17 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.applier; + +/** Inclusive 0-based [from, to] line range located by fuzzy anchor search. */ +public record LineRange(int from, int to) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/RemediationApplier.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/RemediationApplier.java new file mode 100644 index 00000000000..71655ebef77 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/applier/RemediationApplier.java @@ -0,0 +1,308 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.applier; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.fpr.remediation.SkipReason; +import com.fortify.cli.aviator.fpr.remediation.classifier.AppliedChangeLedger; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.AppliedChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.util.FileUtil; + +/** Houses the original {@code applyChange}, split into the same steps it always tried, just named. */ +public final class RemediationApplier { + private static final Logger LOG = LoggerFactory.getLogger(RemediationApplier.class); + + private final FuzzyAnchorLocator fuzzyAnchorLocator = new FuzzyAnchorLocator(); + + public AppliedChangeResult applyChange(String instanceId, String filename, Path filePath, String fileHash, Charset sourceEncoding, + String originalContent, Hunk hunk, int changeIndex, AppliedChangeLedger ledger) { + String lineSeparator = detectLineSeparator(originalContent); + String content = normalizeLineEndings(originalContent); + + List originalLines = Arrays.asList(content.split("\n", -1)); + LOG.debug("Decoded '{}' using {}; lineSeparator={}, normalizedLines={}", filename, sourceEncoding.name(), + describeLineSeparator(lineSeparator), originalLines.size()); + + int lineFrom = hunk.lineFrom(); + int lineTo = hunk.lineTo(); + LOG.debug("Remediation {} change {} for '{}' targets lines {}-{}", instanceId, changeIndex, filename, lineFrom, lineTo); + + boolean fileHashMatches = tryHashMatch(instanceId, filename, fileHash, sourceEncoding, content, originalContent); + if (!fileHashMatches) { + LOG.debug("File hash mismatch for remediation {} in {}; searching changed source content", instanceId, filename); + List priorApplied = ledger.changesFor(filePath); + + Optional projected = tryOffsetProjection(instanceId, filename, hunk, originalLines, lineFrom, lineTo, priorApplied, ledger, filePath); + if (projected.isPresent()) { + lineFrom = projected.get().from(); + lineTo = projected.get().to(); + } else { + LineRange anchored = tryFuzzyAnchor(instanceId, filename, hunk, originalLines, priorApplied, + lineFrom, lineTo, filePath, ledger); + lineFrom = anchored.from(); + lineTo = anchored.to(); + } + } + + validateLineRange(lineFrom, lineTo, originalLines.size(), filename); + // Normalize line endings the same way the file content already was (line 46): unmapped + // extensions get no such normalization from stripSyntheticLineMarkers, so a CRLF NewCode + // would otherwise leave a stray CR on every line once rejoined with the file's own separator. + String normalizedNewCode = normalizeLineEndings(FileUtil.stripSyntheticLineMarkers(hunk.requiredNewCode(), filename)); + List newCodeLines = new ArrayList<>(Arrays.asList(normalizedNewCode.split("\n"))); + dropDuplicatedBoundaryTokens(newCodeLines, originalLines, lineFrom, lineTo, instanceId, filename); + List updatedLines = new ArrayList<>(); + updatedLines.addAll(originalLines.subList(0, lineFrom - 1)); + updatedLines.addAll(newCodeLines); + updatedLines.addAll(originalLines.subList(lineTo, originalLines.size())); + LOG.debug("Staged remediation {} change {} for '{}' using FVDL encoding {}; updatedLines={}", instanceId, changeIndex, + filename, sourceEncoding.name(), updatedLines.size()); + String updatedContent = String.join(lineSeparator, updatedLines); + return new AppliedChangeResult(updatedContent, lineFrom, lineTo); + } + + /** + * Try canonical hash first (matches the new AuditProcessor form), then legacy raw-content + * hash so pre-fix FPRs still match. Try each with BOTH UTF-8 and the file's declared source + * encoding — the doc's "5 of 53 files not valid UTF-8" case fails when audit and apply disagree + * on the encoding used for the getBytes step; accepting the source-encoding form covers it. + */ + private boolean tryHashMatch(String instanceId, String filename, String fileHash, Charset sourceEncoding, String content, + String originalContent) { + String canonicalStr = FileUtil.canonicalizeForHash(content); + String legacyStr = originalContent; + String canonicalHashUtf8 = calculateHashBase64Bytes(canonicalStr.getBytes(StandardCharsets.UTF_8), "SHA-256"); + String legacyHashUtf8 = calculateHashBase64Bytes(legacyStr.getBytes(StandardCharsets.UTF_8), "SHA-256"); + String canonicalHashSrc = calculateHashBase64Bytes(canonicalStr.getBytes(sourceEncoding), "SHA-256"); + String legacyHashSrc = calculateHashBase64Bytes(legacyStr.getBytes(sourceEncoding), "SHA-256"); + boolean fileHashMatches; + String matchedForm; + if (canonicalHashUtf8.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "canonical"; + } else if (legacyHashUtf8.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "legacy"; + } else if (canonicalHashSrc.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "canonical/" + sourceEncoding.name(); + } else if (legacyHashSrc.equals(fileHash)) { + fileHashMatches = true; + matchedForm = "legacy/" + sourceEncoding.name(); + } else { + fileHashMatches = false; + matchedForm = "none"; + } + LOG.debug("Remediation {} hash check for '{}': {}", + instanceId, filename, fileHashMatches ? ("matched (" + matchedForm + ")") : "mismatched"); + return fileHashMatches; + } + + /** + * If a prior remediation this run modified this file, project the declared range through + * the accumulated line-delta of every AppliedChange whose original range sits strictly + * before this hunk's declared start. Verify the projected position holds the expected + * OriginalCode (whitespace-insensitive). Returns {@code null} (try the fuzzy fallback + * instead) if there is no prior history, the projected range is out of bounds, or the + * anchor at the projected position doesn't match. + */ + private Optional tryOffsetProjection(String instanceId, String filename, Hunk hunk, List originalLines, + int lineFrom, int lineTo, List priorApplied, AppliedChangeLedger ledger, Path filePath) { + if (priorApplied.isEmpty()) { + return Optional.empty(); + } + int shift = ledger.projectOffset(filePath, lineFrom); + int projectedFrom = lineFrom + shift; + int projectedTo = lineTo + shift; + if (projectedFrom >= 1 && projectedTo >= projectedFrom && projectedTo <= originalLines.size()) { + String originalCodeText = hunk.requiredOriginalCode(); + List originalCodeLines = Arrays.asList(originalCodeText.split("\\r?\\n")); + if (linesEqualNormalized(originalLines, projectedFrom - 1, projectedTo - 1, originalCodeLines)) { + LOG.debug("Remediation {} projected via offset map for '{}': declared {}-{} shifted by {} to {}-{}", + instanceId, filename, lineFrom, lineTo, shift, projectedFrom, projectedTo); + return Optional.of(new LineRange(projectedFrom, projectedTo)); + } else { + LOG.debug("Remediation {} projection anchor mismatch for '{}' at projected {}-{}; falling back", + instanceId, filename, projectedFrom, projectedTo); + } + } + return Optional.empty(); + } + + /** Context search first, then a whole-file OriginalCode fallback if no context match was found. */ + private LineRange tryFuzzyAnchor(String instanceId, String filename, Hunk hunk, List originalLines, + List priorApplied, int lineFrom, int lineTo, Path filePath, AppliedChangeLedger ledger) { + int shift = ledger.projectOffset(filePath, lineFrom); + int projectedFrom = lineFrom + shift; + int projectedTo = lineTo + shift; + String contextText = hunk.requiredContextText(); + List contextLine = Arrays.asList(contextText.split("\\r?\\n")); + int contextBefore = hunk.contextBefore(); + int contextAfter = hunk.contextAfter(); + int contextLineFrom = fuzzyAnchorLocator.searchContext(instanceId, filename, originalLines, contextLine, + projectedFrom, contextBefore); + if (contextLineFrom == -1) { + LOG.debug("Context search failed for remediation {} in {}; trying whole-file OriginalCode fallback", + instanceId, filename); + String fallbackOriginalCodeText = hunk.requiredOriginalCode(); + List fallbackOriginalCodeLine = Arrays.asList(fallbackOriginalCodeText.split("\\r?\\n")); + Optional wholeFile = fuzzyAnchorLocator.searchOriginalCode(instanceId, filename, originalLines, fallbackOriginalCodeLine, + 0, originalLines.size(), 0, 0, projectedFrom, projectedTo); + if (wholeFile.isPresent()) { + LOG.debug("Whole-file OriginalCode fallback matched remediation {} in {} at lines {}-{}", + instanceId, filename, wholeFile.get().from() + 1, wholeFile.get().to() + 1); + return new LineRange(wholeFile.get().from() + 1, wholeFile.get().to() + 1); + } else { + LOG.debug("Whole-file OriginalCode fallback failed for remediation {} in {}", instanceId, filename); + SkipReason failureReason = priorApplied.isEmpty() + ? SkipReason.SOURCE_CONTEXT_NOT_FOUND + : SkipReason.ANCHOR_DOES_NOT_MATCH; + throw new SkipRemediationException(failureReason, "Anchor not found for file '" + filename + + "'; " + (priorApplied.isEmpty() + ? "file may have changed on disk or context is missing" + : "prior remediation shifted or rewrote the anchor lines this run")); + } + } else { + LOG.debug("Context for remediation {} in {} matched at line {}", instanceId, filename, contextLineFrom + 1); + String originalCodeText = hunk.requiredOriginalCode(); + List originalCodeLine = Arrays.asList(originalCodeText.split("\\r?\\n")); + Optional lineFromTo = fuzzyAnchorLocator.searchOriginalCode(instanceId, filename, originalLines, originalCodeLine, + contextLineFrom, contextLine.size(), contextBefore, contextAfter, projectedFrom, projectedTo); + if (lineFromTo.isEmpty()) { + LOG.debug("Original code search failed for remediation {} in {}; context line={}, original code lines={}, source lines={}", + instanceId, filename, contextLineFrom + 1, originalCodeLine.size(), originalLines.size()); + SkipReason failureReason = priorApplied.isEmpty() + ? SkipReason.ORIGINAL_CODE_NOT_FOUND + : SkipReason.ANCHOR_DOES_NOT_MATCH; + throw new SkipRemediationException(failureReason, "Original code not found for file '" + filename + + "'; " + (priorApplied.isEmpty() + ? "file may have changed on disk" + : "prior remediation altered lines inside this hunk's context window")); + } + int resultLineFrom = lineFromTo.get().from() + 1; + int resultLineTo = lineFromTo.get().to() + 1; + LOG.debug("Original code for remediation {} in {} matched at lines {}-{}", instanceId, filename, resultLineFrom, resultLineTo); + return new LineRange(resultLineFrom, resultLineTo); + } + } + + private void dropDuplicatedBoundaryTokens(List newCodeLines, List originalLines, + int lineFrom, int lineTo, String instanceId, String filename) { + if (newCodeLines.isEmpty()) return; + if (lineFrom > 1 && newCodeLines.size() > 1) { + String lineBefore = originalLines.get(lineFrom - 2); + if (boundaryLinesMatch(lineBefore, newCodeLines.get(0))) { + LOG.debug("Remediation {} for '{}': dropping duplicated leading boundary token in NewCode (matches line {})", + instanceId, filename, lineFrom - 1); + newCodeLines.remove(0); + } + } + if (lineTo < originalLines.size() && newCodeLines.size() > 1) { + String lineAfter = originalLines.get(lineTo); + if (boundaryLinesMatch(lineAfter, newCodeLines.get(newCodeLines.size() - 1))) { + LOG.debug("Remediation {} for '{}': dropping duplicated trailing boundary token in NewCode (matches line {})", + instanceId, filename, lineTo + 1); + newCodeLines.remove(newCodeLines.size() - 1); + } + } + } + + private boolean boundaryLinesMatch(String a, String b) { + if (a == null || b == null) return false; + String normA = a.trim().replaceAll("\\s+", " "); + String normB = b.trim().replaceAll("\\s+", " "); + return !normA.isEmpty() && normA.equals(normB); + } + + private void validateLineRange(int lineFrom, int lineTo, int sourceLineCount, String filename) { + if (lineFrom < 1 || lineTo < lineFrom || lineTo > sourceLineCount) { + throw new SkipRemediationException(SkipReason.REMEDIATION_LINE_RANGE_INVALID, + "Invalid remediation line range " + lineFrom + "-" + lineTo + " for file '" + filename + "'"); + } + } + + /** + * Anchor verification: line-by-line whitespace-insensitive, case-insensitive comparison + * between a slice of the current file and the expected OriginalCode. Matches the same + * normalization ({@code trim().replaceAll("\\s+", " ")}, {@code equalsIgnoreCase}) that + * {@link com.fortify.cli.aviator.util.FuzzyContextSearcher} uses so behaviour is consistent + * between the fast projection path and the fuzzy fallback. + */ + private boolean linesEqualNormalized(List source, int startInclusive, int endInclusive, List expected) { + int len = endInclusive - startInclusive + 1; + if (len != expected.size()) return false; + for (int i = 0; i < len; i++) { + String a = source.get(startInclusive + i).trim().replaceAll("\\s+", " "); + String b = expected.get(i).trim().replaceAll("\\s+", " "); + if (!a.equalsIgnoreCase(b)) return false; + } + return true; + } + + private String detectLineSeparator(String content) { + int crlfIndex = content.indexOf("\r\n"); + int lfIndex = content.indexOf('\n'); + int crIndex = content.indexOf('\r'); + + if (crlfIndex >= 0 && (lfIndex == crlfIndex + 1 || lfIndex < 0) && (crIndex == crlfIndex || crIndex < 0)) { + return "\r\n"; + } + if (lfIndex >= 0 && (crIndex < 0 || lfIndex < crIndex)) { + return "\n"; + } + if (crIndex >= 0) { + return "\r"; + } + return System.lineSeparator(); + } + + private String normalizeLineEndings(String content) { + return content.replace("\r\n", "\n").replace('\r', '\n'); + } + + private String describeLineSeparator(String lineSeparator) { + return switch (lineSeparator) { + case "\r\n" -> "CRLF"; + case "\n" -> "LF"; + case "\r" -> "CR"; + default -> "system"; + }; + } + + /** Encoding-agnostic hash — caller supplies the already-encoded bytes. */ + private String calculateHashBase64Bytes(byte[] bytes, String algorithm) { + if (bytes == null) return ""; + try { + MessageDigest md = MessageDigest.getInstance(algorithm); + return Base64.getEncoder().encodeToString(md.digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new AviatorTechnicalException("Hashing algorithm not available: " + algorithm, e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java new file mode 100644 index 00000000000..05fe0e949e8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/AppliedChangeLedger.java @@ -0,0 +1,108 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.classifier; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fortify.cli.aviator.fpr.remediation.model.AppliedChange; + +/** + * Per-FPR offset ledger, NOT shared across artifacts in multi-FPR runs (e.g., --all-open-issues). + * Each RemediationProcessor gets a fresh instance isolated to that artifact's scan. + * + *

Line numbers in AppliedChange are expressed in pristine-file coordinates relative to that + * FPR's audit. Different artifacts are scans of potentially different source revisions, so + * sharing a ledger across artifacts would incorrectly project deltas from one coordinate system + * onto another. Anchor verification (hash checking) is the safety mechanism for multi-FPR runs: + * once an earlier FPR has touched a file, the declared hash no longer matches, so every later + * hunk is written only where its OriginalCode still literally matches. Phase 3 (server-side + * re-audit on code change) is the fundamental fix for multi-FPR consistency. + * + *

Populated when a hunk is written to disk; consulted before applying subsequent hunks to + * detect SUPERSEDED/CONFLICTS and to project declared line ranges through prior edits within + * the same FPR. + */ +public final class AppliedChangeLedger { + private final Map> appliedByFile = new LinkedHashMap<>(); + private final List pendingAppliedChanges = new ArrayList<>(); + + /** + * Committed changes for a file, plus any changes staged so far by the remediation currently + * being processed (not yet committed). Hunks of the same remediation are applied one at a + * time in a single pass ({@code FileWriteCoordinator.processFileChanges}), so a later hunk's + * offset projection must be able to see the shift an earlier hunk in that same remediation + * already staged, not just changes committed by prior remediations. + */ + public List changesFor(Path filePath) { + List committed = appliedByFile.getOrDefault(filePath, List.of()); + List staged = pendingAppliedChanges.stream() + .filter(pac -> pac.filePath().equals(filePath)) + .map(AppliedChangeLedger::fromPending) + .toList(); + if (staged.isEmpty()) { + return committed; + } + List combined = new ArrayList<>(committed); + combined.addAll(staged); + return combined; + } + + /** Stage a hunk this remediation intends to apply. Merged into the ledger on {@link #commitStaged()}. */ + public void stage(PendingAppliedChange pendingAppliedChange) { + pendingAppliedChanges.add(pendingAppliedChange); + } + + /** Discards all currently staged entries (skip/rollback of the whole remediation). */ + public void discardStaged() { + pendingAppliedChanges.clear(); + } + + /** Moves all staged entries into the committed ledger (called only after a successful write) and clears staging. */ + public void commitStaged() { + for (PendingAppliedChange pac : pendingAppliedChanges) { + appliedByFile.computeIfAbsent(pac.filePath(), k -> new ArrayList<>()) + .add(fromPending(pac)); + } + pendingAppliedChanges.clear(); + } + + private static AppliedChange fromPending(PendingAppliedChange pac) { + return AppliedChange.builder() + .originalLineFrom(pac.lineFrom()) + .originalLineTo(pac.lineTo()) + .declaredLineFrom(pac.declaredLineFrom()) + .declaredLineTo(pac.declaredLineTo()) + .deltaLines(pac.deltaLines()) + .comparisonCode(pac.comparisonCode()) + .lineNormalizedCode(pac.lineNormalizedCode()) + .build(); + } + + /** + * Accumulated line-delta shift for {@code filePath} from every committed change whose + * original range sits strictly before {@code lineFrom}. + */ + public int projectOffset(Path filePath, int lineFrom) { + int shift = 0; + for (AppliedChange ac : changesFor(filePath)) { + if (ac.originalLineTo() < lineFrom) { + shift += ac.deltaLines(); + } + } + return shift; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java new file mode 100644 index 00000000000..24f0a32f300 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/HunkClassifier.java @@ -0,0 +1,95 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.classifier; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.AppliedChange; +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.HunkOutcome; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; + +/** + * Pre-classifies each hunk of a {@link Remediation} against the per-run {@link AppliedChangeLedger}. + * Returns {@link HunkOutcome#SUPERSEDED} if a prior applied hunk fully contains the range AND + * its fix content actually covers this hunk's proposed change (normalized, comment/whitespace- + * insensitive substring match); {@link HunkOutcome#POSSIBLY_REMEDIATED} for a fully-nested + * range whose content does NOT match what was actually written (a different fix hidden behind + * a broader one, but the location is still covered); {@link HunkOutcome#CONFLICTS} for a + * partial, non-nested overlap (coverage is genuinely ambiguous); {@link HunkOutcome#APPLIED} + * for no overlap (candidate to attempt). Identity-satisfied hunks whose exact range was written + * by a prior remediation naturally classify as SUPERSEDED, which is semantically correct. + */ +public final class HunkClassifier { + + public List classifyRemediationHunks(Remediation remediation, Path sourceBasePath, AppliedChangeLedger ledger) { + List outcomes = new ArrayList<>(); + for (FileChange fileChange : remediation.fileChanges()) { + Path filePath; + try { + filePath = fileChange.resolve(sourceBasePath); + } catch (SkipRemediationException e) { + outcomes.add(HunkOutcome.APPLIED); + continue; + } + List applied = ledger.changesFor(filePath); + for (Hunk hunk : fileChange.hunks()) { + int from; + int to; + try { + from = hunk.lineFrom(); + to = hunk.lineTo(); + } catch (SkipRemediationException e) { + outcomes.add(HunkOutcome.APPLIED); + continue; + } + String candidateComparisonCode = null; + try { + candidateComparisonCode = hunk.comparisonCode(fileChange.requiredFilename()); + } catch (SkipRemediationException e) { + // Content unavailable for comparison; classifyRange falls back to range-only classification. + } + outcomes.add(classifyRange(from, to, applied, candidateComparisonCode)); + } + } + return outcomes; + } + + /** + * SUPERSEDED if nested in an AppliedChange whose written content actually covers this + * hunk's proposed fix (normalized substring match); POSSIBLY_REMEDIATED if nested but the + * content differs (the sibling fully covers this location, just not proven identical); + * CONFLICTS if there is only a partial, non-nested line overlap (neither range contains + * the other, so coverage is genuinely ambiguous); APPLIED otherwise. When either side's + * content is unavailable ({@code null}) or blank to prove anything, a nested range + * falls back to POSSIBLY_REMEDIATED rather than being assumed SUPERSEDED. + */ + private HunkOutcome classifyRange(int lineFrom, int lineTo, List applied, String candidateComparisonCode) { + for (AppliedChange ac : applied) { + if (ac.coversDeclaredRange(lineFrom, lineTo)){ + if (ac.contentCovers(candidateComparisonCode, lineFrom, lineTo)) { + return HunkOutcome.SUPERSEDED; + } + return HunkOutcome.POSSIBLY_REMEDIATED; + } + if (ac.overlapsDeclaredRangePartially(lineFrom, lineTo)) { + return HunkOutcome.CONFLICTS; + } + } + return HunkOutcome.APPLIED; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java new file mode 100644 index 00000000000..9fd2a171671 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/classifier/PendingAppliedChange.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.classifier; + +import java.nio.file.Path; + +import lombok.Builder; + +@Builder +public record PendingAppliedChange( + Path filePath, + int lineFrom, + int lineTo, + int declaredLineFrom, + int declaredLineTo, + int deltaLines, + String comparisonCode, + String lineNormalizedCode) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RemediationCommitException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RemediationCommitException.java new file mode 100644 index 00000000000..5d1232c61c0 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RemediationCommitException.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.exception; + +import java.util.List; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.fpr.remediation.writer.RollbackFileWrite; + +public class RemediationCommitException extends AviatorTechnicalException { + private static final long serialVersionUID = 1L; + + private final List rollbacks; + + public RemediationCommitException(String message, Throwable cause, List rollbacks) { + super(message, cause); + this.rollbacks = rollbacks; + } + + public List getRollbacks() { + return rollbacks; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RollbackRemediationException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RollbackRemediationException.java new file mode 100644 index 00000000000..1dc98c2dc76 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/RollbackRemediationException.java @@ -0,0 +1,23 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.exception; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; + +public class RollbackRemediationException extends AviatorTechnicalException { + private static final long serialVersionUID = 1L; + + public RollbackRemediationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/SkipRemediationException.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/SkipRemediationException.java new file mode 100644 index 00000000000..ab379b6516d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/exception/SkipRemediationException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.exception; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator.fpr.remediation.SkipReason; + +public class SkipRemediationException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + private final SkipReason reason; + + public SkipRemediationException(SkipReason reason, String message) { + super(message); + this.reason = reason; + } + + public SkipRemediationException(SkipReason reason, String message, Throwable cause) { + super(message, cause); + this.reason = reason; + } + + public SkipReason getReason() { + + return reason; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java new file mode 100644 index 00000000000..89e5b71bde6 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChange.java @@ -0,0 +1,139 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import lombok.Builder; + +/** + * Offset-map entry: records a hunk that was actually written to a file this run, + * in terms of the PRISTINE file's line numbers. {@code deltaLines} is + * (newLineCount - originalLineCount); positive means the file grew, negative means it shrunk. + * + *

Was a record; promoted to a class because the line-range/content comparisons in + * {@code classifyRange} are logic that reads these fields and belongs on the object that owns + * them, not as free functions in the caller. + */ +public final class AppliedChange { + private final int originalLineFrom; + private final int originalLineTo; + private final int declaredLineFrom; + private final int declaredLineTo; + private final int deltaLines; + private final String comparisonCode; + private final String[] lineNormalizedContent; + + public AppliedChange(int originalLineFrom, int originalLineTo, int deltaLines, String comparisonCode) { + this(originalLineFrom, originalLineTo, originalLineFrom, originalLineTo, deltaLines, comparisonCode, null); + + } + + @Builder + public AppliedChange(int originalLineFrom, int originalLineTo, int declaredLineFrom, int declaredLineTo, int deltaLines, String comparisonCode, String lineNormalizedCode) + { + this.originalLineFrom = originalLineFrom; + this.originalLineTo = originalLineTo; + this.declaredLineFrom = declaredLineFrom; + this.declaredLineTo = declaredLineTo; + this.deltaLines = deltaLines; + this.comparisonCode = comparisonCode; + // Store line-by-line normalized content for offset-anchored comparison (newlines preserved, each line normalized) + if (lineNormalizedCode != null && !lineNormalizedCode.isEmpty()) { + this.lineNormalizedContent = lineNormalizedCode.split("\n", -1); + } else { + this.lineNormalizedContent = null; + } + } + + public int originalLineTo() { + return originalLineTo; + } + + public int deltaLines() { + return deltaLines; + } + + /** True if this change's original range fully contains [lineFrom, lineTo]. */ + public boolean coversFully(int lineFrom, int lineTo) { + return originalLineFrom <= lineFrom && lineTo <= originalLineTo; + } + + /** True if [lineFrom, lineTo] overlaps this change's original range without either side fully containing the other. */ + public boolean overlapsPartially(int lineFrom, int lineTo) { + boolean overlaps = lineFrom <= originalLineTo && originalLineFrom <= lineTo; + boolean candidateContainsThis = lineFrom <= originalLineFrom && originalLineTo <= lineTo; + return overlaps && !coversFully(lineFrom, lineTo) && !candidateContainsThis; + } + + + /** True if this change's declared range fully contains [lineFrom, lineTo]. */ + public boolean coversDeclaredRange(int lineFrom, int lineTo) { + return declaredLineFrom <= lineFrom && lineTo <= declaredLineTo; + } + + /** True if [lineFrom, lineTo] overlaps this change's declared range without either side fully containing the other. */ + public boolean overlapsDeclaredRangePartially(int lineFrom, int lineTo) { + boolean overlaps = lineFrom <= declaredLineTo && declaredLineFrom <= lineTo; + boolean candidateContainsThis = lineFrom <= declaredLineFrom && declaredLineTo <= lineTo; + return overlaps && !coversDeclaredRange(lineFrom, lineTo) && !candidateContainsThis; + } + + + /** + * Below this length a normalized comparison code (e.g. {@code "return;"}) is too short and + * generic to prove that a substring hit inside a broader fix's content is the same fix, + * rather than an incidental match. + */ + private static final int MIN_PROVEN_COVERAGE_LENGTH = 8; + + /** + * True if this change's comparison code contains the candidate's comparison code at the + * expected offset (offset-anchored matching). Calculates where the candidate hunk is + * located relative to this broader fix (as an offset of lines), then checks if the candidate + * content appears at that offset within this fix's content. Falls back to false (POSSIBLY_REMEDIATED) + * if the substring appears but not at the expected offset (incidental match, not proof of coverage). + * + *

Unavailable content (either side {@code null}), blank candidates, and too-short + * candidates prove nothing, so coverage is NOT assumed — callers fall back to + * {@code POSSIBLY_REMEDIATED} rather than {@code SUPERSEDED}. + */ + public boolean contentCovers(String candidateComparisonCode, int candidateLineFrom, int candidateLineTo) { + if (candidateComparisonCode == null || comparisonCode == null || lineNormalizedContent == null) { + return false; + } + if (candidateComparisonCode.isBlank() || candidateComparisonCode.length() < MIN_PROVEN_COVERAGE_LENGTH) { + return false; + } + + // Offset-anchored comparison: check if candidate appears at the expected offset within this fix's content + // Calculate the offset: where does candidateLineFrom sit relative to originalLineFrom? + int offsetFromAppliedStart = candidateLineFrom - originalLineFrom; + int candidateLineCount = candidateLineTo - candidateLineFrom + 1; + + // Check if candidate fits at expected offset within applied content + if (offsetFromAppliedStart < 0 || offsetFromAppliedStart + candidateLineCount > lineNormalizedContent.length) { + return false; + } + + // Reconstruct what we expect to see at the candidate's offset + StringBuilder expectedAtOffset = new StringBuilder(); + for (int i = offsetFromAppliedStart; i < offsetFromAppliedStart + candidateLineCount; i++) { + if (i > offsetFromAppliedStart) { + expectedAtOffset.append("\n"); + } + expectedAtOffset.append(lineNormalizedContent[i]); + } + + // Exact match at expected offset (not just substring anywhere) + return expectedAtOffset.toString().equals(candidateComparisonCode); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/FileChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/FileChange.java new file mode 100644 index 00000000000..2929eb3b1c7 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/FileChange.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.List; + +import com.fortify.cli.aviator.fpr.remediation.SkipReason; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; + + +public final class FileChange { + private final String filenameRaw; + private final String hashRaw; + private final List hunks; + + public FileChange(String filenameRaw, String hashRaw, List hunks) { + this.filenameRaw = filenameRaw; + this.hashRaw = hashRaw; + this.hunks = hunks; + } + + public String requiredFilename() { + return RequiredFields.requireText(filenameRaw, "Filename"); + } + + public String requiredHash() { + return RequiredFields.requireText(hashRaw, "Hash"); + } + + public List hunks() { + return hunks; + } + + public Path resolve(Path sourceBasePath) { + try { + return sourceBasePath.resolve(requiredFilename()).normalize(); + } catch (InvalidPathException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Invalid filename in remediation: " + requiredFilename(), e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java new file mode 100644 index 00000000000..33791a8af51 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Hunk.java @@ -0,0 +1,200 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import java.util.Arrays; +import java.util.regex.Pattern; + +import com.fortify.cli.aviator.util.FileTypeLanguageMapperUtil; +import com.fortify.cli.aviator.util.FileUtil; +import com.fortify.cli.aviator.util.LanguageCommentMapperUtil; + +import lombok.Builder; + + +public final class Hunk { + private final String lineFromRaw; + private final String lineToRaw; + private final String contextTextRaw; + private final String contextBeforeRaw; + private final String contextAfterRaw; + private final String originalCodeRaw; + private final String newCodeRaw; + + @Builder + public Hunk(String lineFromRaw, String lineToRaw, String contextTextRaw, String contextBeforeRaw, + String contextAfterRaw, String originalCodeRaw, String newCodeRaw) { + this.lineFromRaw = lineFromRaw; + this.lineToRaw = lineToRaw; + this.contextTextRaw = contextTextRaw; + this.contextBeforeRaw = contextBeforeRaw; + this.contextAfterRaw = contextAfterRaw; + this.originalCodeRaw = originalCodeRaw; + this.newCodeRaw = newCodeRaw; + } + + public int lineFrom() { + return RequiredFields.requireInt(lineFromRaw, "LineFrom"); + } + + public int lineTo() { + return RequiredFields.requireInt(lineToRaw, "LineTo"); + } + + public String requiredContextText() { + return RequiredFields.requireText(contextTextRaw, "Context"); + } + + public String contextTextOrEmpty() { + return contextTextRaw == null ? "" : contextTextRaw; + } + + public int contextBefore() { + return RequiredFields.requireContextAttribute(contextBeforeRaw, "before"); + } + + public int contextAfter() { + return RequiredFields.requireContextAttribute(contextAfterRaw, "after"); + } + + public int contextBeforeOrZero() { + return parseContextAttributeOrZero(contextBeforeRaw); + } + + public int contextAfterOrZero() { + return parseContextAttributeOrZero(contextAfterRaw); + } + + public String requiredOriginalCode() { + return RequiredFields.requireText(originalCodeRaw, "OriginalCode"); + } + + public String requiredNewCode() { + return RequiredFields.requireText(newCodeRaw, "NewCode"); + } + + private static int parseContextAttributeOrZero(String value) { + if (value == null || value.isBlank()) { + return 0; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return 0; + } + } + + + public String comparisonCode(String filename) { + String normalizedCode = normalizeProposedCode(requiredNewCode(), filename); + return createComparisonCode(normalizedCode, filename); + } + + /** Normalized code with each line normalized but newlines preserved (for offset-anchored comparison). */ + public String lineNormalizedCode(String filename) { + String normalizedCode = normalizeProposedCode(requiredNewCode(), filename); + return createLineNormalizedCode(normalizedCode, filename); + } + + private static String normalizeProposedCode(String content, String fileName) { + if (content == null) return null; + return trimBlankLines(FileUtil.stripSyntheticLineMarkers(content, fileName, System.lineSeparator())); + } + + private static String createComparisonCode(String normalizedCode, String fileName) { + if (normalizedCode == null) return null; + + String language = FileTypeLanguageMapperUtil.getProgrammingLanguage( + FileUtil.getFileExtension(fileName)); + String commentSymbol = LanguageCommentMapperUtil.getProgrammingLanguageComment(language); + + if ("Unknown".equals(commentSymbol)) { + return normalizeLiteralAliases(normalizedCode).replaceAll("\\s+", ""); + } + + String comparisonCode = normalizedCode; + String closingToken = commentSymbol.equals("" + : commentSymbol.equals("<%--") ? "--%>" : null; + + if (closingToken != null) { + comparisonCode = comparisonCode.replaceAll( + "(?s)" + Pattern.quote(commentSymbol) + ".*?" + Pattern.quote(closingToken), ""); + } else if ("//".equals(commentSymbol)) { + comparisonCode = comparisonCode.replaceAll("(?m)" + Pattern.quote(commentSymbol) + ".*$", "") + .replaceAll("(?s)/\\*.*?\\*/", ""); + } else if ("#".equals(commentSymbol)) { + comparisonCode = comparisonCode.replaceAll("(?m)" + Pattern.quote(commentSymbol) + ".*$", ""); + } + + return normalizeLiteralAliases(comparisonCode).replaceAll("\\s+", ""); + } + + private static String createLineNormalizedCode(String normalizedCode, String fileName) { + if (normalizedCode == null) return null; + + String language = FileTypeLanguageMapperUtil.getProgrammingLanguage( + FileUtil.getFileExtension(fileName)); + String commentSymbol = LanguageCommentMapperUtil.getProgrammingLanguageComment(language); + + if ("Unknown".equals(commentSymbol)) { + return normalizeLinesByPreservingNewlines(normalizeLiteralAliases(normalizedCode)); + } + + String lineNormalizedCode = normalizedCode; + String closingToken = commentSymbol.equals("" + : commentSymbol.equals("<%--") ? "--%>" : null; + + if (closingToken != null) { + lineNormalizedCode = lineNormalizedCode.replaceAll( + "(?s)" + Pattern.quote(commentSymbol) + ".*?" + Pattern.quote(closingToken), ""); + } else if ("//".equals(commentSymbol)) { + lineNormalizedCode = lineNormalizedCode.replaceAll("(?m)" + Pattern.quote(commentSymbol) + ".*$", "") + .replaceAll("(?s)/\\*.*?\\*/", ""); + } else if ("#".equals(commentSymbol)) { + lineNormalizedCode = lineNormalizedCode.replaceAll("(?m)" + Pattern.quote(commentSymbol) + ".*$", ""); + } + + return normalizeLinesByPreservingNewlines(normalizeLiteralAliases(lineNormalizedCode)); + } + + private static String normalizeLinesByPreservingNewlines(String code) { + if (code == null) return null; + String[] lines = code.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + lines[i] = lines[i].replaceAll("\\s+", ""); + } + return String.join("\n", lines); + } + + /** + * Treats semantically-equivalent literal forms as identical for near-identical-fix + * comparison only; never applied to code actually written to source files. + */ + private static String normalizeLiteralAliases(String code) { + if (code == null) return null; + String normalized = code.replaceAll("'\\\\0'", "0"); + normalized = normalized.replaceAll("\\bnullptr\\b", "NULL"); + return normalized; + } + + private static String trimBlankLines(String content) { + String[] lines = content.split("\\R", -1); + int start = 0, end = lines.length - 1; + + while (start <= end && lines[start].isBlank()) start++; + while (end >= start && lines[end].isBlank()) end--; + + return start > end ? "" : + String.join(System.lineSeparator(), Arrays.copyOfRange(lines, start, end + 1)); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/HunkOutcome.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/HunkOutcome.java new file mode 100644 index 00000000000..78186224bbc --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/HunkOutcome.java @@ -0,0 +1,18 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +/** Per-hunk classification for the state machine. */ +public enum HunkOutcome { + APPLIED, SUPERSEDED, CONFLICTS, POSSIBLY_REMEDIATED +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Remediation.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Remediation.java new file mode 100644 index 00000000000..5bda4f830f0 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/Remediation.java @@ -0,0 +1,69 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; + +public final class Remediation { + private final String instanceId; + private final List fileChanges; + + public Remediation(String instanceId, List fileChanges) { + this.instanceId = instanceId; + this.fileChanges = fileChanges; + } + + public String instanceId() { + return instanceId; + } + + public List fileChanges() { + return fileChanges; + } + + /** + * Widest hunk (lineTo - lineFrom) across all FileChanges/Hunks in this remediation. Used to + * order remediations broader-first so nested narrower fixes classify as SUPERSEDED. + */ + public int maxHunkWidth() { + int max = 0; + for (FileChange fileChange : fileChanges) { + for (Hunk hunk : fileChange.hunks()) { + try { + int from = hunk.lineFrom(); + int to = hunk.lineTo(); + max = Math.max(max, to - from); + } catch (SkipRemediationException ignore) { + // best-effort ordering; malformed hunks fall to the back + } + } + } + return max; + } + + public List createRemediationKeys(Path sourceBasePath) { + List keys = new ArrayList<>(); + for (FileChange fileChange : fileChanges) { + for (Hunk hunk : fileChange.hunks()) { + String filename = fileChange.requiredFilename(); + String comparisonCode = hunk.comparisonCode(filename); + keys.add(RemediationKey.of(fileChange, hunk, sourceBasePath, comparisonCode)); + } + } + return keys; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationDocument.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationDocument.java new file mode 100644 index 00000000000..b3b044282c3 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationDocument.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import java.util.List; + +public final class RemediationDocument { + private final List remediations; + + public RemediationDocument(List remediations) { + this.remediations = remediations; + } + + public List remediations() { + return remediations; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationKey.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationKey.java new file mode 100644 index 00000000000..fddeabcdf6b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationKey.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import java.nio.file.Path; + +public record RemediationKey(Path filePath, int lineFrom, int lineTo, String comparisonCode) { + + public static RemediationKey of(FileChange fileChange, Hunk hunk, Path sourceBasePath, String comparisonCode) { + Path filePath = fileChange.resolve(sourceBasePath); + int lineFrom = hunk.lineFrom(); + int lineTo = hunk.lineTo(); + return new RemediationKey(filePath, lineFrom, lineTo, comparisonCode); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java new file mode 100644 index 00000000000..5e44927d5c9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetric.java @@ -0,0 +1,152 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + +import lombok.Builder; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +public final class RemediationMetric { + private final int totalRemediations; + private final int appliedRemediations; + private final int identicalRemediations; + private final int supersededRemediations; + private final int possiblyRemediatedRemediations; + private final int skippedRemediations; + private final Set modifiedFiles; + private final Map skippedByReason; + private final RemediationExecutionMode executionMode; + private final Set requestedIssueIds; + private final Set seenIssueIds; + private final Set satisfiedIssueIds; + private final Set appliedIssueIds; + private final Set identicalIssueIds; + private final Set supersededIssueIds; + private final Set possiblyRemediatedIssueIds; + private final Map issueSkipReasons; + private final List previewDetails; + + @Builder + private RemediationMetric( + int totalRemediations, + int appliedRemediations, + int identicalRemediations, + int supersededRemediations, + int possiblyRemediatedRemediations, + int skippedRemediations, + Set modifiedFiles, + Map skippedByReason, + RemediationExecutionMode executionMode, + Set requestedIssueIds, + Set seenIssueIds, + Set satisfiedIssueIds, + Set appliedIssueIds, + Set identicalIssueIds, + Set supersededIssueIds, + Set possiblyRemediatedIssueIds, + Map issueSkipReasons, + List previewDetails) { + this.totalRemediations = totalRemediations; + this.appliedRemediations = appliedRemediations; + this.identicalRemediations = identicalRemediations; + this.supersededRemediations = supersededRemediations; + this.possiblyRemediatedRemediations = possiblyRemediatedRemediations; + this.skippedRemediations = skippedRemediations; + this.modifiedFiles = immutableSet(modifiedFiles); + this.skippedByReason = immutableMap(skippedByReason); + this.executionMode = executionMode == null ? RemediationExecutionMode.APPLY : executionMode; + this.requestedIssueIds = immutableSet(requestedIssueIds); + this.seenIssueIds = immutableSet(seenIssueIds); + this.satisfiedIssueIds = immutableSet(satisfiedIssueIds); + this.appliedIssueIds = immutableSet(appliedIssueIds); + this.identicalIssueIds = immutableSet(identicalIssueIds); + this.supersededIssueIds = immutableSet(supersededIssueIds); + this.possiblyRemediatedIssueIds = immutableSet(possiblyRemediatedIssueIds); + this.issueSkipReasons = immutableStringMap(issueSkipReasons); + this.previewDetails = previewDetails == null ? List.of() : List.copyOf(previewDetails); + } + + public boolean isPreview() { + return executionMode == RemediationExecutionMode.PREVIEW; + } + + public boolean isFiltered() { + return !requestedIssueIds.isEmpty(); + } + + private static Set immutableSet(Set values) { + return values == null ? Set.of() : Collections.unmodifiableSet(new LinkedHashSet<>(values)); + } + + private static Map immutableMap(Map values) { + return values == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + private static Map immutableStringMap(Map values) { + return values == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + public static final class RemediationMetricBuilder { + public RemediationMetricBuilder() { + this.modifiedFiles = new LinkedHashSet<>(); + this.skippedByReason = new LinkedHashMap<>(); + this.requestedIssueIds = new LinkedHashSet<>(); + this.seenIssueIds = new LinkedHashSet<>(); + this.satisfiedIssueIds = new LinkedHashSet<>(); + this.appliedIssueIds = new LinkedHashSet<>(); + this.identicalIssueIds = new LinkedHashSet<>(); + this.supersededIssueIds = new LinkedHashSet<>(); + this.possiblyRemediatedIssueIds = new LinkedHashSet<>(); + this.issueSkipReasons = new LinkedHashMap<>(); + this.previewDetails = new ArrayList<>(); + } + + public RemediationMetricBuilder add(RemediationMetric metric) { + this.totalRemediations += metric.totalRemediations; + this.appliedRemediations += metric.appliedRemediations; + this.identicalRemediations += metric.identicalRemediations; + this.supersededRemediations += metric.supersededRemediations; + this.possiblyRemediatedRemediations += metric.possiblyRemediatedRemediations; + this.skippedRemediations += metric.skippedRemediations; + this.modifiedFiles.addAll(metric.modifiedFiles()); + metric.skippedByReason().forEach((reason, count) -> + this.skippedByReason.merge(reason, count, Integer::sum)); + this.requestedIssueIds.addAll(metric.requestedIssueIds()); + this.seenIssueIds.addAll(metric.seenIssueIds()); + this.satisfiedIssueIds.addAll(metric.satisfiedIssueIds()); + this.appliedIssueIds.addAll(metric.appliedIssueIds()); + this.identicalIssueIds.addAll(metric.identicalIssueIds()); + this.supersededIssueIds.addAll(metric.supersededIssueIds()); + this.possiblyRemediatedIssueIds.addAll(metric.possiblyRemediatedIssueIds()); + this.issueSkipReasons.putAll(metric.issueSkipReasons()); + this.previewDetails.addAll(metric.previewDetails()); + if (metric.isPreview()) { + this.executionMode = RemediationExecutionMode.PREVIEW; + } + return this; + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RequiredFields.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RequiredFields.java new file mode 100644 index 00000000000..97c8c3fa2db --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/model/RequiredFields.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import com.fortify.cli.aviator.fpr.remediation.*; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; + + +public final class RequiredFields { + + private RequiredFields() { + } + + public static String requireText(String value, String elementName) { + if (value == null) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Missing required remediation element '" + elementName + "'"); + } + return value; + } + + public static int requireInt(String value, String elementName) { + String text = requireText(value, elementName); + try { + return Integer.parseInt(text); + } catch (NumberFormatException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Invalid integer value for remediation element '" + elementName + "': " + text, e); + } + } + + public static int requireContextAttribute(String value, String attributeName) { + if (value == null || value.isBlank()) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Missing required remediation context attribute '" + attributeName + "'"); + } + try { + int parsedValue = Integer.parseInt(value); + if (parsedValue < 0) { + throw new NumberFormatException("negative value"); + } + return parsedValue; + } catch (NumberFormatException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_DATA_INVALID, + "Invalid remediation context attribute '" + attributeName + "': " + value, e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ChangeDetail.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ChangeDetail.java new file mode 100644 index 00000000000..58fe17dfaf9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ChangeDetail.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Reflectable +@Builder +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class ChangeDetail { + private final int changeIndex; + private final int lineFrom; + private final int lineTo; + private final String originalCode; + private final String newCode; + private final int contextLinesBefore; + private final int contextLinesAfter; + private final String contextContent; + + public PreviewFileChange toPreviewFileChange() { + ContextMetadata context = new ContextMetadata(contextLinesBefore, contextLinesAfter, contextContent); + return PreviewFileChange.builder() + .changeIndex(changeIndex) + .lineFrom(lineFrom) + .lineTo(lineTo) + .originalCode(originalCode) + .newCode(newCode) + .context(context) + .build(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ContextMetadata.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ContextMetadata.java new file mode 100644 index 00000000000..7bda8c28b8b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/ContextMetadata.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +@Reflectable +@JsonPropertyOrder({"linesBefore", "linesAfter", "content"}) +public record ContextMetadata(int linesBefore, int linesAfter, String content) { + public ContextMetadata { + if (linesBefore < 0) { + throw new AviatorBugException("ContextMetadata linesBefore must be non-negative"); + } + if (linesAfter < 0) { + throw new AviatorBugException("ContextMetadata linesAfter must be non-negative"); + } + content = content == null ? "" : content; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/FilePreview.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/FilePreview.java new file mode 100644 index 00000000000..251bed62d82 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/FilePreview.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +@Reflectable +@JsonPropertyOrder({"path", "encoding", "changes"}) +public record FilePreview(String path, String encoding, List changes) { + public FilePreview { + if (path == null || path.isBlank()) { + throw new AviatorBugException("FilePreview path is required"); + } + changes = changes == null ? List.of() : Collections.unmodifiableList(List.copyOf(changes)); + } + + public int totalChanges() { + return changes.size(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDetail.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDetail.java new file mode 100644 index 00000000000..fc7602d8626 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDetail.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +@Reflectable +@JsonPropertyOrder({"issueId", "status", "description", "files"}) +public record PreviewDetail(String issueId, String status, String description, Map files) { + public PreviewDetail { + if (issueId == null || issueId.isBlank()) { + throw new AviatorBugException("PreviewDetail issueId is required"); + } + if (status == null || status.isBlank()) { + throw new AviatorBugException("PreviewDetail status is required"); + } + files = files == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(files)); + } + + public static PreviewDetail available(String issueId, String description, Map files) { + return new PreviewDetail(issueId, "available", description, files); + } + + public static PreviewDetail skipped(String issueId, String description) { + return new PreviewDetail(issueId, "skipped", description, Map.of()); + } + + @JsonIgnore + public boolean isAvailable() { + return "available".equals(status); + } + + @JsonIgnore + public boolean isSkipped() { + return "skipped".equals(status); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewFileChange.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewFileChange.java new file mode 100644 index 00000000000..03708149852 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewFileChange.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +import lombok.Builder; + +@Reflectable +@Builder +@JsonPropertyOrder({"changeIndex", "lineFrom", "lineTo", "originalCode", "newCode", "context"}) +public record PreviewFileChange( + int changeIndex, + int lineFrom, + int lineTo, + String originalCode, + String newCode, + ContextMetadata context) { + public PreviewFileChange { + if (changeIndex < 1) { + throw new AviatorBugException("PreviewFileChange changeIndex must be positive"); + } + if (lineFrom < 1 || lineTo < lineFrom) { + throw new AviatorBugException("PreviewFileChange invalid line range: " + lineFrom + "-" + lineTo); + } + if (context == null) { + throw new AviatorBugException("PreviewFileChange context is required"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java new file mode 100644 index 00000000000..2a7f5cefec9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/FileWriteCoordinator.java @@ -0,0 +1,245 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.writer; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.remediation.SkipReason; +import com.fortify.cli.aviator.fpr.remediation.applier.AppliedChangeResult; +import com.fortify.cli.aviator.fpr.remediation.applier.RemediationApplier; +import com.fortify.cli.aviator.fpr.remediation.classifier.AppliedChangeLedger; +import com.fortify.cli.aviator.fpr.remediation.classifier.PendingAppliedChange; +import com.fortify.cli.aviator.fpr.remediation.exception.RemediationCommitException; +import com.fortify.cli.aviator.fpr.remediation.exception.SkipRemediationException; +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationKey; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.DecodeResult; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.SourceDecodeException; +import com.fortify.cli.aviator.fpr.utils.SourceEncoder; +import com.fortify.cli.aviator.fpr.utils.SourceEncoder.SourceEncodeException; + +/** Houses the original prepareFileChanges/processFileChanges/commit/rollback/read/encode logic, unmodified. */ +public final class FileWriteCoordinator { + private static final Logger LOG = LoggerFactory.getLogger(FileWriteCoordinator.class); + + private final ISourceDecoder sourceDecoder; + private final RemediationApplier remediationApplier; + + public FileWriteCoordinator(ISourceDecoder sourceDecoder, RemediationApplier remediationApplier) { + this.sourceDecoder = sourceDecoder; + this.remediationApplier = remediationApplier; + } + + public PreparedFileChanges prepareFileChanges(Remediation remediation, Path sourceBasePath, FVDLMetadata fvdlMetadata, + Set keysToApply, AppliedChangeLedger ledger) { + List fileChanges = remediation.fileChanges(); + if (fileChanges.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No file changes found"); + } + + Map pendingWrites = new LinkedHashMap<>(); + Set appliedKeys = new LinkedHashSet<>(); + // All-or-nothing: if any file's hunk(s) in this remediation can't be applied, the whole + // remediation is skipped rather than partially written to some files but not others. + for (FileChange fileChange : fileChanges) { + processFileChanges(remediation, fileChange, sourceBasePath, fvdlMetadata, pendingWrites, keysToApply, + appliedKeys, ledger); + } + return new PreparedFileChanges(pendingWrites, appliedKeys); + } + + public Charset encodingFor(Path filePath, String filename, FVDLMetadata fvdlMetadata) { + return readSourceFile(filePath, filename, fvdlMetadata).charset(); + } + + private void processFileChanges(Remediation remediation, FileChange fileChange, Path sourceBasePath, FVDLMetadata fvdlMetadata, + Map pendingWrites, Set keysToApply, Set appliedKeysOut, + AppliedChangeLedger ledger) { + + String instanceId = remediation.instanceId(); + String filename = fileChange.requiredFilename(); + Path filePath = fileChange.resolve(sourceBasePath); + LOG.debug("Processing remediation {} file change for '{}' resolved to '{}'", instanceId, filename, filePath); + + if (!filePath.startsWith(sourceBasePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_OUTSIDE_SOURCE_DIR, + "Source file resolves outside source directory: " + filename); + } + + if (!isFilePresent(filePath)) { + throw new SkipRemediationException(SkipReason.SOURCE_FILE_MISSING, "Source code file not present at: " + filePath); + } + + String fileHash = fileChange.requiredHash(); + List hunks = fileChange.hunks(); + if (hunks.isEmpty()) { + throw new SkipRemediationException(SkipReason.NO_CHANGES, "No changes found for file: " + filename); + } + SourceFileContent sourceFileContent = getPendingOrSourceContent(filePath, filename, fvdlMetadata, pendingWrites); + Charset sourceEncoding = sourceFileContent.charset(); + LOG.debug("Remediation {} has {} change(s) for '{}' using source encoding {}", instanceId, hunks.size(), filename, + sourceFileContent.encodingSource()); + + String updatedContent = sourceFileContent.content(); + int appliedInThisFile = 0; + int skippedAlreadySatisfied = 0; + for (int k = 0; k < hunks.size(); k++) { + Hunk hunk = hunks.get(k); + String comparisonCode = hunk.comparisonCode(filename); + RemediationKey key = RemediationKey.of(fileChange, hunk, sourceBasePath, comparisonCode); + if (keysToApply != null && !keysToApply.contains(key)) { + LOG.info("Skipping hunk {} of remediation {} in '{}': already applied by prior identical hunk", + k + 1, instanceId, filename); + skippedAlreadySatisfied++; + continue; + } + int declaredLineFrom = hunk.lineFrom(); + int declaredLineTo = hunk.lineTo(); + int linesBeforeChange = updatedContent.split("\n", -1).length; + AppliedChangeResult applyResult = remediationApplier.applyChange(instanceId, filename, filePath, fileHash, sourceEncoding, updatedContent, + hunk, k + 1, ledger); + updatedContent = applyResult.updatedContent(); + // Stage this hunk into the per-run offset map using the *actual* coordinates where it was written, + // not the declared ones (which may differ if the applier relocated it via offset projection or + // fuzzy anchor). The ledger must reflect reality for later hunks to correctly project offsets + // and for the classifier to compare against the right region. + int actualLineFrom = applyResult.actualLineFrom(); + int actualLineTo = applyResult.actualLineTo(); + int linesAfterChange = updatedContent.split("\n", -1).length; + int delta = linesAfterChange - linesBeforeChange; + String lineNormalizedCode = hunk.lineNormalizedCode(filename); + ledger.stage(PendingAppliedChange.builder() + .filePath(filePath) + .lineFrom(actualLineFrom) + .lineTo(actualLineTo) + .declaredLineFrom(declaredLineFrom) + .declaredLineTo(declaredLineTo) + .deltaLines(delta) + .comparisonCode(comparisonCode) + .lineNormalizedCode(lineNormalizedCode) + .build()); + appliedKeysOut.add(key); + appliedInThisFile++; + } + if (appliedInThisFile == 0) { + LOG.debug("Remediation {} produced no new hunks for '{}' ({} already satisfied); no write staged", + instanceId, filename, skippedAlreadySatisfied); + return; + } + byte[] updatedBytes = encodeSourceFile(updatedContent, sourceEncoding, filename); + + pendingWrites.put(filePath, new PendingFileWrite(filename, filePath, updatedContent, sourceEncoding, + sourceFileContent.encodingSource(), updatedBytes)); + LOG.debug("Staged remediation {} for '{}' using source encoding {}; changes={}, encodedBytes={}", instanceId, filename, + sourceFileContent.encodingSource(), hunks.size(), updatedBytes.length); + } + + public void commitRemediationWrites(String instanceId, Map pendingWrites, Set modifiedFiles) + throws RemediationCommitException { + List rollbacks = new ArrayList<>(); + for (PendingFileWrite pendingWrite : pendingWrites.values()) { + Path filePath = pendingWrite.filePath(); + // A permission failure is rejected atomically before any bytes are written, so a file + // that's already known unwritable needs no rollback entry at all: attempting one would + // just retry the same failing write. Checking this upfront (rather than relying on + // Files.write's exception to prove the file was untouched) matters once a write CAN + // start: e.g. running out of disk space mid-write can truncate/partially overwrite the + // file before failing, so a write that's confirmed writable must be recorded for + // rollback before attempting it, not after. + if (!Files.isWritable(filePath)) { + throw new RemediationCommitException( + "Source code file is not writable: '" + pendingWrite.filename() + "'", + new IOException("File not writable: " + filePath), rollbacks); + } + try { + byte[] originalBytes = Files.readAllBytes(filePath); + rollbacks.add(new RollbackFileWrite(pendingWrite.filename(), filePath, originalBytes)); + LOG.debug("Writing remediation {} to '{}' using staged bytes; encodedBytes={}", instanceId, pendingWrite.filename(), + pendingWrite.updatedBytes().length); + Files.write(filePath, pendingWrite.updatedBytes()); + } catch (Exception e) { + throw new RemediationCommitException("Error writing source code file '" + pendingWrite.filename() + "'", e, rollbacks); + } + } + + for (PendingFileWrite pendingWrite : pendingWrites.values()) { + modifiedFiles.add(pendingWrite.filename()); + LOG.info("Remediation applied for {} in file {}", instanceId, pendingWrite.filename()); + } + } + + public void rollbackRemediationWrites(String instanceId, List rollbacks) { + for (RollbackFileWrite rollback : rollbacks) { + try { + Files.write(rollback.filePath(), rollback.originalBytes()); + LOG.warn("Rolled back remediation {} changes for '{}' after write failure", instanceId, rollback.filename()); + } catch (IOException rollbackException) { + LOG.error("Failed to roll back remediation {} changes for '{}'", instanceId, rollback.filename(), rollbackException); + throw new com.fortify.cli.aviator.fpr.remediation.exception.RollbackRemediationException( + "Failed to roll back remediation changes for '" + rollback.filename() + + "'. Source files may be partially modified; inspect the source tree before retrying", rollbackException); + } + } + } + + private SourceFileContent getPendingOrSourceContent(Path filePath, String filename, FVDLMetadata fvdlMetadata, + Map pendingWrites) { + PendingFileWrite pendingWrite = pendingWrites.get(filePath); + return pendingWrite == null + ? readSourceFile(filePath, filename, fvdlMetadata) + : new SourceFileContent(pendingWrite.content(), pendingWrite.charset(), pendingWrite.encodingSource()); + } + + private boolean isFilePresent(Path path) { + return Files.exists(path) && Files.isRegularFile(path); + } + + private SourceFileContent readSourceFile(Path filePath, String filename, FVDLMetadata fvdlMetadata) { + try { + byte[] sourceBytes = Files.readAllBytes(filePath); + // Metadata may be null (FVDL missing); FPR candidate fails and other encodings are tried. + DecodeResult decodeResult = sourceDecoder.decode(sourceBytes, filename, fvdlMetadata); + LOG.debug("Strict decoded '{}' using {}; sourceBytes={}, decodedChars={}", filename, decodeResult.source(), sourceBytes.length, + decodeResult.content().length()); + return new SourceFileContent(decodeResult.content(), decodeResult.charset(), decodeResult.source()); + } catch (SourceDecodeException e) { + throw new SkipRemediationException(SkipReason.SOURCE_DECODE_FAILED, e.getMessage(), e); + } catch (IOException e) { + throw new SkipRemediationException(SkipReason.SOURCE_READ_FAILED, "Error reading source code file '" + filePath + "'", e); + } + } + + private byte[] encodeSourceFile(String content, Charset charset, String filename) { + try { + return SourceEncoder.encode(content, charset, filename); + } catch (SourceEncodeException e) { + throw new SkipRemediationException(SkipReason.REMEDIATION_ENCODE_FAILED, e.getMessage(), e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PendingFileWrite.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PendingFileWrite.java new file mode 100644 index 00000000000..de21b337298 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PendingFileWrite.java @@ -0,0 +1,20 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.writer; + +import java.nio.charset.Charset; +import java.nio.file.Path; + +public record PendingFileWrite(String filename, Path filePath, String content, Charset charset, String encodingSource, + byte[] updatedBytes) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PreparedFileChanges.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PreparedFileChanges.java new file mode 100644 index 00000000000..da58b649519 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/PreparedFileChanges.java @@ -0,0 +1,23 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.writer; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import com.fortify.cli.aviator.fpr.remediation.model.RemediationKey; + + +public record PreparedFileChanges(Map pendingWrites, Set appliedKeys) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/RollbackFileWrite.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/RollbackFileWrite.java new file mode 100644 index 00000000000..c304ee8aa60 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/RollbackFileWrite.java @@ -0,0 +1,18 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.writer; + +import java.nio.file.Path; + +public record RollbackFileWrite(String filename, Path filePath, byte[] originalBytes) { +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/SourceFileContent.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/SourceFileContent.java new file mode 100644 index 00000000000..abc50e7d604 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/writer/SourceFileContent.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.writer; + +import java.nio.charset.Charset; + + +public final class SourceFileContent { + private final String content; + private final Charset charset; + private final String encodingSource; + + public SourceFileContent(String content, Charset charset, String encodingSource) { + this.content = content; + this.charset = charset; + this.encodingSource = encodingSource; + } + + public String content() { + return content; + } + + public Charset charset() { + return charset; + } + + public String encodingSource() { + return encodingSource; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationDocumentMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationDocumentMapper.java new file mode 100644 index 00000000000..7e7d1a75167 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationDocumentMapper.java @@ -0,0 +1,96 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.xmlprocessor; + +import java.util.ArrayList; +import java.util.List; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import com.fortify.cli.aviator.fpr.remediation.model.FileChange; +import com.fortify.cli.aviator.fpr.remediation.model.Hunk; +import com.fortify.cli.aviator.fpr.remediation.model.Remediation; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationDocument; + +/** + * Phase 2: maps a DOM {@link Document} into the domain model, without any additional logic — + * no normalization, no comparison codes, no classification, and (deliberately) no validation: + * fields whose element/attribute is absent are stored as {@code null}/blank rather than + * throwing, so that phase 3 code can validate lazily at the exact point of use, exactly as the + * original single-class implementation did. + */ +public final class RemediationDocumentMapper { + private static final String NAMESPACE_URI = "xmlns://www.fortify.com/schema/remediations"; + + public RemediationDocument map(Document remediationDoc) { + NodeList remediationNodes = remediationDoc.getElementsByTagNameNS(NAMESPACE_URI, "Remediation"); + List remediations = new ArrayList<>(); + for (int i = 0; i < remediationNodes.getLength(); i++) { + remediations.add(mapRemediation((Element) remediationNodes.item(i))); + } + return new RemediationDocument(remediations); + } + + private Remediation mapRemediation(Element remediationElement) { + String instanceId = remediationElement.getAttribute("instanceId"); + NodeList fileChangesNodes = remediationElement.getElementsByTagNameNS(NAMESPACE_URI, "FileChanges"); + List fileChanges = new ArrayList<>(); + for (int i = 0; i < fileChangesNodes.getLength(); i++) { + fileChanges.add(mapFileChange((Element) fileChangesNodes.item(i))); + } + return new Remediation(instanceId, fileChanges); + } + + private FileChange mapFileChange(Element fileChangesElement) { + String filename = optionalElementText(fileChangesElement, "Filename"); + String hash = optionalElementText(fileChangesElement, "Hash"); + NodeList changeNodes = fileChangesElement.getElementsByTagNameNS(NAMESPACE_URI, "Change"); + List hunks = new ArrayList<>(); + for (int i = 0; i < changeNodes.getLength(); i++) { + hunks.add(mapHunk((Element) changeNodes.item(i))); + } + return new FileChange(filename, hash, hunks); + } + + private Hunk mapHunk(Element changeElement) { + String lineFrom = optionalElementText(changeElement, "LineFrom"); + String lineTo = optionalElementText(changeElement, "LineTo"); + Element contextElement = optionalElement(changeElement, "Context"); + String contextText = contextElement == null ? null : contextElement.getTextContent(); + String contextBefore = contextElement == null ? null : contextElement.getAttribute("before"); + String contextAfter = contextElement == null ? null : contextElement.getAttribute("after"); + String originalCode = optionalElementText(changeElement, "OriginalCode"); + String newCode = optionalElementText(changeElement, "NewCode"); + return Hunk.builder() + .lineFromRaw(lineFrom) + .lineToRaw(lineTo) + .contextTextRaw(contextText) + .contextBeforeRaw(contextBefore) + .contextAfterRaw(contextAfter) + .originalCodeRaw(originalCode) + .newCodeRaw(newCode) + .build(); + } + + private String optionalElementText(Element parent, String elementName) { + Element element = optionalElement(parent, elementName); + return element == null ? null : element.getTextContent(); + } + + private Element optionalElement(Element parent, String elementName) { + NodeList nodes = parent.getElementsByTagNameNS(NAMESPACE_URI, elementName); + return nodes.getLength() == 0 ? null : (Element) nodes.item(0); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java new file mode 100644 index 00000000000..b81cf5ba87c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/remediation/xmlprocessor/RemediationXmlReader.java @@ -0,0 +1,51 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.xmlprocessor; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; + +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; + +/** Phase 1: parses {@code remediations.xml} into a DOM {@link Document}. No mapping, no business logic. */ +public final class RemediationXmlReader { + private static final Logger LOG = LoggerFactory.getLogger(RemediationXmlReader.class); + + public Document read(Path remediationPath) { + try (InputStream remediationStream = Files.newInputStream(remediationPath)) { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(remediationStream); + } catch (ParserConfigurationException | SAXException | IOException e) { + LOG.error("Error parsing remediations.xml file: {}", remediationPath, e); + throw new AviatorTechnicalException("Error processing remediation.xml file.", e); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/CharsetSourceDecoder.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/CharsetSourceDecoder.java new file mode 100644 index 00000000000..b0cf5d06621 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/CharsetSourceDecoder.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.util.Objects; + +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; + +/** Fixed-{@link Charset} decoder. Created via {@link SourceDecoders}. */ +final class CharsetSourceDecoder implements ISourceDecoder { + private final Charset charset; + private final String label; + + CharsetSourceDecoder(String charsetName) { + this.charset = Charset.forName(charsetName); + this.label = charsetName; + } + + @Override + public DecodeResult decode(byte[] bytes, String filename, FVDLMetadata fvdlMetadata) { + Objects.requireNonNull(bytes, "bytes must not be null"); + try { + return new DecodeResult(SourceEncoder.decodeStrict(bytes, charset), charset, label); + } catch (CharacterCodingException e) { + throw new SourceDecodeException(label + " failed to decode source bytes", e); + } + } + + @Override + public String describe() { + return label; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/CompositeSourceDecoder.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/CompositeSourceDecoder.java new file mode 100644 index 00000000000..9c335cb550f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/CompositeSourceDecoder.java @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; + +/** Tries candidates in order. Created via {@link SourceDecoders#of}. */ +final class CompositeSourceDecoder implements ISourceDecoder { + private final List decoders; + + CompositeSourceDecoder(List decoders) { + Objects.requireNonNull(decoders, "decoders must not be null"); + if (decoders.isEmpty()) { + throw new IllegalArgumentException("decoders must not be empty"); + } + this.decoders = List.copyOf(decoders); + } + + @Override + public DecodeResult decode(byte[] bytes, String filename, FVDLMetadata fvdlMetadata) { + Objects.requireNonNull(bytes, "bytes must not be null"); + List failures = new ArrayList<>(); + for (ISourceDecoder decoder : decoders) { + try { + return decoder.decode(bytes, filename, fvdlMetadata); + } catch (SourceDecodeException e) { + failures.add(e.getMessage() != null ? e.getMessage() : decoder.describe() + " failed"); + } + } + throw new SourceDecodeException("Could not decode source file '" + filename + "' using source encodings " + + describe() + "; attempted: " + String.join("; ", failures)); + } + + @Override + public String describe() { + return decoders.stream().map(ISourceDecoder::describe).collect(Collectors.joining(",")); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/FileUtils.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/FileUtils.java index 6ecd49aed6c..cfa0d4e41a2 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/FileUtils.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/FileUtils.java @@ -14,13 +14,13 @@ import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -28,15 +28,32 @@ import org.slf4j.LoggerFactory; import com.fortify.cli.aviator.audit.model.Fragment; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.FileTypeLanguageMapperUtil; import com.fortify.cli.aviator.util.FileUtil; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.LanguageCommentMapperUtil; import com.fortify.cli.aviator.util.StringUtil; +/** + * Source file helpers. Decode failures are soft (empty result + log): snippets/lines are best-effort. + * Callers that must fail or skip with metrics (e.g. remediation apply) should decode themselves. + */ public class FileUtils { private static final Logger logger = LoggerFactory.getLogger(FileUtils.class); private final Map> fileContentCache = new ConcurrentHashMap<>(); + private final ISourceDecoder sourceDecoder; + private final FVDLMetadata fvdlMetadata; + + public FileUtils() { + this(SourceDecoders.defaults(), null); + } + + public FileUtils(ISourceDecoder sourceDecoder, FVDLMetadata fvdlMetadata) { + this.sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); + this.fvdlMetadata = fvdlMetadata; + } /** * Reads all lines from a file, caching the result to avoid repeated reads. @@ -46,13 +63,23 @@ public class FileUtils { * @return List of lines, or empty list if file not found or error occurs */ public List readFileWithFallback(Path filePath) { + return readFileWithFallback(filePath, filePath.getFileName().toString()); + } + + private List readFileWithFallback(Path filePath, String filename) { return fileContentCache.computeIfAbsent(filePath, path -> { try { + long fileSize = Files.size(path); + if (fileSize > Constants.MAX_SOURCE_FILE_SIZE) { + logger.warn("Source file exceeds maximum allowed size ({} bytes): {} (actual size: {} bytes)", + Constants.MAX_SOURCE_FILE_SIZE, path, fileSize); + return Collections.emptyList(); + } byte[] fileBytes = Files.readAllBytes(path); - String content = new String(fileBytes, StandardCharsets.UTF_8); + String content = sourceDecoder.decode(fileBytes, filename, fvdlMetadata).content(); return Arrays.asList(content.split("\\r?\\n")); - } catch (IOException e) { - logger.error("Failed to read file: {}", path, e); + } catch (IOException | ISourceDecoder.SourceDecodeException e) { + logger.warn("Could not read or decode source file {}: {}", path, e.getMessage()); return Collections.emptyList(); } }); @@ -80,7 +107,7 @@ public String getLineFromFile(FprHandle fprHandle, String relativePath, int line Path fullSourcePath = resolveFullPath(fprHandle, relativePath); if (fullSourcePath == null) return ""; - List lines = readFileWithFallback(fullSourcePath); + List lines = readFileWithFallback(fullSourcePath, relativePath); if (lineNumber > 0 && lines.size() >= lineNumber) { return appendLineNumbers(lines.get(lineNumber - 1), relativePath, lineNumber - 1); } @@ -97,7 +124,7 @@ public Fragment getFragmentFromFile(FprHandle fprHandle, String relativePath, in return new Fragment("", 0, 0); } - List lines = readFileWithFallback(fullSourcePath); + List lines = readFileWithFallback(fullSourcePath, relativePath); if (lines.isEmpty() || lineNumber <= 0) { return new Fragment("", 0, 0); } @@ -127,20 +154,30 @@ private Path resolveFullPath(FprHandle fprHandle, String relativePath) { return fprHandle.getPath(internalPath); } - - // Assumes you have already updated the signature to accept extractedPath public Optional getSourceFileContent(FprHandle fprHandle, String relativePath) { + try { + return Optional.of(readSourceFileContentStrict(fprHandle, relativePath)); + } catch (IOException | ISourceDecoder.SourceDecodeException e) { + logger.warn("Could not read source file content for path {}: {}", relativePath, e.getMessage()); + return Optional.empty(); + } + } + + /** Reads and decodes source content while preserving read or decode failures for the caller. */ + String readSourceFileContentStrict(FprHandle fprHandle, String relativePath) throws IOException { Path actualSourcePath = resolveFullPath(fprHandle, relativePath); if (actualSourcePath == null) { - return Optional.empty(); + throw new IOException("Source file key not found in sourceFileMap: " + relativePath); } - try { - return Optional.of(String.join(System.lineSeparator(), readFileWithFallback(actualSourcePath))); - } catch (Exception e) { - logger.warn("Could not read source file content for path: {}", relativePath, e); - return Optional.empty(); + long fileSize = Files.size(actualSourcePath); + if (fileSize > Constants.MAX_SOURCE_FILE_SIZE) { + throw new IOException("Source file exceeds maximum allowed size (" + Constants.MAX_SOURCE_FILE_SIZE + + " bytes): " + relativePath + " (actual size: " + fileSize + " bytes)"); } + + byte[] fileBytes = Files.readAllBytes(actualSourcePath); + return sourceDecoder.decode(fileBytes, relativePath, fvdlMetadata).content(); } public String appendLineNumbers(String content, String fileName, int startLineNo) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/FprSourceDecoder.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/FprSourceDecoder.java new file mode 100644 index 00000000000..f7241d3ed73 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/FprSourceDecoder.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.util.Objects; + +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; + +/** Resolves encoding from FVDL metadata, then strict-decodes. Created via {@link SourceDecoders}. */ +final class FprSourceDecoder implements ISourceDecoder { + static final String TOKEN = "FPR"; + + @Override + public DecodeResult decode(byte[] bytes, String filename, FVDLMetadata fvdlMetadata) { + Objects.requireNonNull(bytes, "bytes must not be null"); + Charset charset = resolveCharset(filename, fvdlMetadata); + try { + String content = SourceEncoder.decodeStrict(bytes, charset); + return new DecodeResult(content, charset, TOKEN + "(" + charset.name() + ")"); + } catch (CharacterCodingException e) { + throw new SourceDecodeException(TOKEN + "(" + charset.name() + ") failed to decode source bytes", e); + } + } + + private static Charset resolveCharset(String filename, FVDLMetadata fvdlMetadata) { + if (fvdlMetadata == null) { + throw new SourceDecodeException("FPR metadata unavailable"); + } + String encodingName = fvdlMetadata.findSourceFileEncodingForFileName(filename); + if (encodingName == null || encodingName.isBlank()) { + throw new SourceDecodeException("FPR encoding missing for '" + filename + "'"); + } + try { + return Charset.forName(encodingName); + } catch (Exception e) { + throw new SourceDecodeException( + TOKEN + " resolved to unsupported encoding '" + encodingName + "'", e); + } + } + + @Override + public String describe() { + return TOKEN; + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/ISourceDecoder.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/ISourceDecoder.java new file mode 100644 index 00000000000..96e76154a8b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/ISourceDecoder.java @@ -0,0 +1,55 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import java.nio.charset.Charset; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; + +/** + * Decodes source file bytes using a specific encoding strategy. + * Implementations may resolve the encoding from FVDL metadata, use a fixed + * {@link Charset}, or try multiple candidate decoders in order. + */ +public interface ISourceDecoder { + /** + * Decode source file bytes into text. + * + * @param bytes raw source file bytes + * @param filename source file name (used for FPR encoding lookup and error messages) + * @param fvdlMetadata optional FVDL metadata; required only by FPR-based decoders + * @return decoded content together with the charset that produced it + * @throws SourceDecodeException if this decoder cannot decode the bytes + */ + DecodeResult decode(byte[] bytes, String filename, FVDLMetadata fvdlMetadata); + + /** + * Human-readable description of this decoder (used in CLI help and error messages). + */ + String describe(); + + record DecodeResult(String content, Charset charset, String source) {} + + class SourceDecodeException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + public SourceDecodeException(String message) { + super(message); + } + + public SourceDecodeException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceCodeEnricher.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceCodeEnricher.java index 57e28043765..873a5a82fec 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceCodeEnricher.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceCodeEnricher.java @@ -13,17 +13,21 @@ package com.fortify.cli.aviator.fpr.utils; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; import com.fortify.cli.aviator.audit.model.File; import com.fortify.cli.aviator.audit.model.StackTraceElement; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.cli.aviator.util.StringUtil; @@ -39,24 +43,17 @@ public class SourceCodeEnricher { private static final Logger logger = LoggerFactory.getLogger(SourceCodeEnricher.class); - /*private final Path extractedPath; - private final Map sourceFileMap;*/ private final FprHandle fprHandle; private final FileUtils fileUtils; + private final Map sourceFileCache = new ConcurrentHashMap<>(); - /** - * Creates a new SourceCodeEnricher with the required dependencies. - * @param fprHandle Utility for file operations (line numbering, line counting) - */ - /*public SourceCodeEnricher(Path extractedPath, Map sourceFileMap, FileUtils fileUtils) { - this.extractedPath = extractedPath; - this.sourceFileMap = sourceFileMap; - this.fileUtils = fileUtils; - }*/ + public SourceCodeEnricher(FprHandle fprHandle) { + this(fprHandle, SourceDecoders.defaults(), null); + } - public SourceCodeEnricher(FprHandle fprHandle){ + public SourceCodeEnricher(FprHandle fprHandle, ISourceDecoder sourceDecoder, FVDLMetadata fvdlMetadata) { this.fprHandle = fprHandle; - this.fileUtils = new FileUtils(); + this.fileUtils = new FileUtils(Objects.requireNonNull(sourceDecoder, "sourceDecoder"), fvdlMetadata); } /** @@ -74,36 +71,41 @@ public SourceCodeEnricher(FprHandle fprHandle){ * @return Map of filename → File objects with content loaded */ public Map enrichWithSourceCode(List> stackTraces) { - Map uniqueFiles = new HashMap<>(); + return new HashMap<>(enrichWithSourceCodeDetailed(stackTraces).files()); + } + + public EnrichmentResult enrichWithSourceCodeDetailed(List> stackTraces) { + Map uniqueFiles = new LinkedHashMap<>(); + Map failuresByFilename = new LinkedHashMap<>(); if (stackTraces == null || stackTraces.isEmpty()) { logger.debug("No stack traces to enrich"); - return uniqueFiles; + return new EnrichmentResult(uniqueFiles, List.of()); } - processStackTraces(stackTraces, uniqueFiles); + processStackTraces(stackTraces, uniqueFiles, failuresByFilename); logger.debug("Enriched {} unique source files from {} stack traces", uniqueFiles.size(), stackTraces.size()); - return uniqueFiles; + return new EnrichmentResult(uniqueFiles, new ArrayList<>(failuresByFilename.values())); } /** * Processes all stack traces to extract and load unique source files. * Replicates FVDLProcessor.processStackTraceElements() logic. */ - private void processStackTraces(List> stackTraces, Map uniqueFiles) { + private void processStackTraces(List> stackTraces, Map uniqueFiles, + Map failuresByFilename) { for (List stackTrace : stackTraces) { if (stackTrace == null) continue; for (StackTraceElement element : stackTrace) { - processFileForElement(element, uniqueFiles); + processFileForElement(element, uniqueFiles, failuresByFilename); - // Process inner stack traces recursively if (element.getInnerStackTrace() != null) { for (StackTraceElement innerElement : element.getInnerStackTrace()) { - processFileForElement(innerElement, uniqueFiles); + processFileForElement(innerElement, uniqueFiles, failuresByFilename); } } } @@ -117,40 +119,63 @@ private void processStackTraces(List> stackTraces, Map uniqueFiles) { + private void processFileForElement(StackTraceElement element, Map uniqueFiles, + Map failuresByFilename) { if (element == null) return; String filename = element.getFilename(); - if (!StringUtil.isEmpty(filename) && fprHandle.getSourceFileMap().containsKey(filename) && !uniqueFiles.containsKey(filename)) { - String internalPath = fprHandle.getSourceFileMap().get(filename); - if (internalPath == null) { return; } // Should not happen due to containsKey check, but safe. + if (!StringUtil.isEmpty(filename) && fprHandle.getSourceFileMap().containsKey(filename) + && !uniqueFiles.containsKey(filename) && !failuresByFilename.containsKey(filename)) { + CachedSourceResult result = sourceFileCache.computeIfAbsent(filename, this::loadSourceFile); + if (result.failure() != null) { + failuresByFilename.put(filename, result.failure()); + } else { + uniqueFiles.put(filename, result.sourceFile().toFile(filename)); + } + } + } + + private CachedSourceResult loadSourceFile(String filename) { + try { + String content = fileUtils.readSourceFileContentStrict(fprHandle, filename); + CachedSourceFile sourceFile = new CachedSourceFile( + fileUtils.appendLineNumbers(content, filename, 0), content.split("\\R", -1).length); + return new CachedSourceResult(sourceFile, null); + } catch (ISourceDecoder.SourceDecodeException e) { + return failedSourceFile(filename, e, AuditSkipReason.SOURCE_FILE_DECODE_FAILED); + } catch (IOException e) { + return failedSourceFile(filename, e, AuditSkipReason.SOURCE_FILE_READ_FAILED); + } + } - Path actualSourcePath = fprHandle.getPath("/" + internalPath); + private CachedSourceResult failedSourceFile(String filename, Exception exception, AuditSkipReason reason) { + logger.warn("Could not read source file content for path {}: {}", filename, exception.getMessage()); + return new CachedSourceResult(null, new SourceFileFailure(filename, exception.getMessage(), reason)); + } + private record CachedSourceFile(String content, int endLine) { + private File toFile(String filename) { File file = new File(); file.setName(filename); file.setSegment(false); file.setStartLine(1); + file.setContent(content); + file.setEndLine(endLine); + return file; + } + } - try { - if (Files.exists(actualSourcePath)) { - byte[] encodedBytes = Files.readAllBytes(actualSourcePath); - String content = new String(encodedBytes); - // Keep line markers in prompt file content; downstream gRPC/template rendering is pass-through. - file.setContent(fileUtils.appendLineNumbers(content, filename, 0)); - file.setEndLine(fileUtils.countLines(actualSourcePath)); - } else { - // This warning is now more accurate. - logger.warn("Source file not found at internal path: {}. This may indicate a corrupt FPR.", actualSourcePath); - file.setContent(""); - file.setEndLine(0); - } - } catch (IOException e) { - logger.warn("Error processing file: {}", filename, e); - file.setContent(""); - file.setEndLine(0); - } - uniqueFiles.put(filename, file); + private record CachedSourceResult(CachedSourceFile sourceFile, SourceFileFailure failure) {} + + public record EnrichmentResult(Map files, List failures) { + public boolean hasFailures() { + return !failures.isEmpty(); + } + } + + public record SourceFileFailure(String filename, String message, AuditSkipReason reason) { + public SourceFileFailure { + Objects.requireNonNull(reason, "reason"); } } } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceDecoders.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceDecoders.java new file mode 100644 index 00000000000..7b231926127 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceDecoders.java @@ -0,0 +1,82 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Domain factory for {@link ISourceDecoder} instances. Single source of truth for + * token/CSV parsing and default candidate order. CLI converters should delegate here. + */ +public final class SourceDecoders { + public static final String DEFAULT_SOURCE_ENCODINGS = "FPR,UTF-8,ISO-8859-1"; + + private SourceDecoders() {} + + /** + * Map a single token to a decoder: {@code FPR} or a charset name. + * + * @throws IllegalArgumentException if token is blank + * @throws java.nio.charset.IllegalCharsetNameException if charset name is illegal + * @throws java.nio.charset.UnsupportedCharsetException if charset is unsupported + */ + public static ISourceDecoder fromToken(String token) { + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("Source encoding must not be blank"); + } + String trimmed = token.trim(); + if (FprSourceDecoder.TOKEN.equalsIgnoreCase(trimmed)) { + return new FprSourceDecoder(); + } + return new CharsetSourceDecoder(trimmed); + } + + /** + * Parse a comma-separated list of encoding tokens into a single decoder + * (composite when more than one candidate). + */ + public static ISourceDecoder fromCsv(String csv) { + String effective = csv == null || csv.isBlank() ? DEFAULT_SOURCE_ENCODINGS : csv; + List decoders = new ArrayList<>(); + for (String part : effective.split(",")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + decoders.add(fromToken(trimmed)); + } + } + if (decoders.isEmpty()) { + return defaults(); + } + return of(decoders); + } + + /** + * Compose an ordered candidate list into one decoder. + */ + public static ISourceDecoder of(List decoders) { + Objects.requireNonNull(decoders, "decoders must not be null"); + if (decoders.isEmpty()) { + return defaults(); + } + if (decoders.size() == 1) { + return decoders.get(0); + } + return new CompositeSourceDecoder(decoders); + } + + public static ISourceDecoder defaults() { + return fromCsv(DEFAULT_SOURCE_ENCODINGS); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceEncoder.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceEncoder.java new file mode 100644 index 00000000000..93668218dd1 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/utils/SourceEncoder.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CodingErrorAction; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +/** + * Strict source encode/decode helpers ({@link CodingErrorAction#REPORT}). + * Public {@link #encode} is for write-back; package-private decode is shared by decoders. + */ +public final class SourceEncoder { + private SourceEncoder() {} + + public static byte[] encode(String content, Charset charset, String filename) { + try { + return encodeStrict(content, charset); + } catch (CharacterCodingException e) { + throw new SourceEncodeException( + "Source file '" + filename + "' cannot be encoded using " + charset.name(), e); + } + } + + static String decodeStrict(byte[] bytes, Charset charset) throws CharacterCodingException { + return charset.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } + + private static byte[] encodeStrict(String content, Charset charset) throws CharacterCodingException { + ByteBuffer buffer = charset.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .encode(CharBuffer.wrap(content)); + byte[] result = new byte[buffer.remaining()]; + buffer.get(result); + return result; + } + + public static class SourceEncodeException extends AviatorSimpleException { + private static final long serialVersionUID = 1L; + + public SourceEncodeException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java index 874e192e1f2..9903b9a72b6 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClient.java @@ -37,6 +37,7 @@ import com.fortify.aviator.application.GetDefaultQuotaResponse; import com.fortify.aviator.application.UpdateApplicationRequest; import com.fortify.aviator.application.ValidateAdminSessionRequest; +import com.fortify.aviator.dastaudit.DastAuditServiceGrpc; import com.fortify.aviator.dastentitlement.DastEntitlement; import com.fortify.aviator.dastentitlement.DastEntitlementServiceGrpc; import com.fortify.aviator.dastentitlement.ListDastEntitlementsByTenantRequest; @@ -52,6 +53,8 @@ import com.fortify.cli.aviator.audit.model.AuditResponse; import com.fortify.cli.aviator.audit.model.UserPrompt; import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.FprHandle; import com.fortify.grpc.token.DeleteTokenRequest; @@ -84,6 +87,7 @@ public class AviatorGrpcClient implements AutoCloseable { private final EntitlementServiceGrpc.EntitlementServiceBlockingStub entitlementServiceBlockingStub; private final DastEntitlementServiceGrpc.DastEntitlementServiceBlockingStub dastEntitlementServiceBlockingStub; private final CorrelationServiceGrpc.CorrelationServiceStub correlationAsyncStub; + private final DastAuditServiceGrpc.DastAuditServiceStub dastAuditAsyncStub; private final long defaultTimeoutSeconds; private final java.util.concurrent.ExecutorService processingExecutor; private final long pingIntervalSeconds; @@ -91,7 +95,7 @@ public class AviatorGrpcClient implements AutoCloseable { final AtomicBoolean isShutdown = new AtomicBoolean(false); public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { - LOG.info("Initializing AviatorGrpcClient with ManagedChannel"); + LOG.debug("Initializing AviatorGrpcClient with ManagedChannel"); this.logger = logger; this.channel = channel; this.asyncStub = AuditorServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); @@ -100,6 +104,11 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv this.entitlementServiceBlockingStub = EntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.dastEntitlementServiceBlockingStub = DastEntitlementServiceGrpc.newBlockingStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); this.correlationAsyncStub = CorrelationServiceGrpc.newStub(channel).withCompression("gzip").withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE).withWaitForReady(); + this.dastAuditAsyncStub = DastAuditServiceGrpc.newStub(channel) + .withCompression("gzip") + .withMaxInboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withMaxOutboundMessageSize(Constants.MAX_MESSAGE_SIZE) + .withWaitForReady(); this.defaultTimeoutSeconds = defaultTimeoutSeconds; this.processingExecutor = Executors.newFixedThreadPool(4, r -> { Thread t = new Thread(r, "aviator-client-processing-" + r.hashCode()); @@ -116,15 +125,18 @@ public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAv public AviatorGrpcClient(String host, int port, long defaultTimeoutSeconds, IAviatorLogger logger, long pingIntervalSeconds) { this(ManagedChannelBuilder.forAddress(host, port).useTransportSecurity().maxInboundMessageSize(Constants.MAX_MESSAGE_SIZE).keepAliveTime(30, TimeUnit.SECONDS).keepAliveTimeout(10, TimeUnit.SECONDS).keepAliveWithoutCalls(true).enableRetry().compressorRegistry(CompressorRegistry.getDefaultInstance()).decompressorRegistry(DecompressorRegistry.getDefaultInstance()).build(), defaultTimeoutSeconds, logger, pingIntervalSeconds); - LOG.info("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port); + LOG.debug("Initialized AviatorGrpcClient - Host: {}, Port: {}", host, port); } public AviatorGrpcClient(ManagedChannel channel, long defaultTimeoutSeconds, IAviatorLogger logger) { this(channel, defaultTimeoutSeconds, logger, 30); } - public CompletableFuture> processBatchRequests(Queue requests, String projectName, String FPRBuildId, String SSCApplicationName, String SSCApplicationVersion, String token, FprHandle fprHandle, List customPriorityOrder) { - AviatorStreamProcessor processor = new AviatorStreamProcessor(this, logger, asyncStub, processingExecutor, pingScheduler, pingIntervalSeconds, defaultTimeoutSeconds, fprHandle); + public CompletableFuture> processBatchRequests(Queue requests, String projectName, + String FPRBuildId, String SSCApplicationName, String SSCApplicationVersion, String token, FprHandle fprHandle, + List customPriorityOrder, ISourceDecoder sourceDecoder, FVDLMetadata fvdlMetadata) { + AviatorStreamProcessor processor = new AviatorStreamProcessor(this, logger, asyncStub, processingExecutor, pingScheduler, + pingIntervalSeconds, defaultTimeoutSeconds, fprHandle, sourceDecoder, fvdlMetadata); CompletableFuture> future = processor.processBatchRequests(requests, projectName, FPRBuildId, SSCApplicationName, SSCApplicationVersion, token, customPriorityOrder); future.whenComplete((res, th) -> processor.close()); return future.exceptionally(ex -> { @@ -172,7 +184,7 @@ public void close() { } } - LOG.info("Client closed"); + LOG.debug("Client closed"); } public Application createApplication(String name, String tenantName, String signature, String message) { @@ -218,6 +230,11 @@ public long getDefaultQuota(String token) { return response.getDefaultQuota(); } + public void probeGetDefaultQuota(long timeoutSeconds) { + blockingStub.withDeadlineAfter(timeoutSeconds, TimeUnit.SECONDS) + .getDefaultQuota(GetDefaultQuotaRequest.getDefaultInstance()); + } + public void validateAdminSession(String tenantName, String signature, String message) { ValidateAdminSessionRequest request = ValidateAdminSessionRequest.newBuilder() .setTenantName(tenantName) @@ -292,6 +309,10 @@ public CorrelationServiceGrpc.CorrelationServiceStub getCorrelationAsyncStub() { return correlationAsyncStub; } + public DastAuditServiceGrpc.DastAuditServiceStub getDastAuditAsyncStub() { + return dastAuditAsyncStub; + } + public java.util.concurrent.ScheduledExecutorService getPingScheduler() { return pingScheduler; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java index 8ad633818c4..d01c9d04586 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelper.java @@ -15,22 +15,30 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.net.URI; +import java.util.Optional; import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLException; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.common.http.UrlSchemes; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; import com.fortify.cli.common.http.proxy.helper.ProxyHelper; +import com.fortify.cli.common.http.ssl.trust.FcliTrustManager; import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.ProxiedSocketAddress; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; public class AviatorGrpcClientHelper { private static final Logger LOG = LoggerFactory.getLogger(AviatorGrpcClientHelper.class); @@ -41,8 +49,9 @@ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, throw new AviatorSimpleException("Aviator URL cannot be null or empty."); } - var proxyDescriptor = ProxyHelper.getProxyDescriptorOrEnv(AVIATOR_MODULE, url); - var target = parseTarget(url); + var connectionPlan = createConnectionPlan(url); + var proxyDescriptor = connectionPlan.proxyDescriptor(); + var target = connectionPlan.target(); var builder = target.port() == null ? NettyChannelBuilder.forTarget(target.host()) : NettyChannelBuilder.forAddress(target.host(), target.port()); @@ -57,7 +66,18 @@ public static AviatorGrpcClient createClient(String url, IAviatorLogger logger, return new AviatorGrpcClient(channel, Constants.DEFAULT_TIMEOUT_SECONDS, logger, pingIntervalSeconds); } - static ParsedTarget parseTarget(String url) { + public static AviatorConnectionPlan createConnectionPlan(String url) { + if (url == null || url.trim().isEmpty()) { + throw new AviatorSimpleException("Aviator URL cannot be null or empty."); + } + var normalizedUrl = normalizeUrl(url); + var target = parseTarget(url); + var proxyDescriptor = ProxyHelper.getProxyDescriptorOrEnv(AVIATOR_MODULE, normalizedUrl); + var effectivePort = target.port() == null ? 443 : target.port(); + return new AviatorConnectionPlan(url, normalizedUrl, target, effectivePort, proxyDescriptor); + } + + public static ParsedTarget parseTarget(String url) { var normalizedUrl = normalizeUrl(url); URI uri; try { @@ -85,17 +105,34 @@ static ParsedTarget parseTarget(String url) { } } - private static String normalizeUrl(String url) { + /** + * Normalizes an Aviator target to an https URL. + *

    + *
  • Scheme-less input ({@code host} or {@code host:port}) gets {@code https://} prepended.
  • + *
  • {@code https} (any casing) is accepted and canonicalized to lowercase {@code https}.
  • + *
  • Any other scheme ({@code http}, {@code ftp}, typos like {@code mttp}, …) is rejected.
  • + *
+ */ + public static String normalizeUrl(String url) { var trimmed = url.trim(); - if ( trimmed.matches("^[a-zA-Z][a-zA-Z0-9+\\-.]*://.*$") ) { + if ( !UrlSchemes.hasScheme(trimmed) ) { + return "https://"+trimmed; + } + var schemeSeparator = trimmed.indexOf("://"); + var scheme = trimmed.substring(0, schemeSeparator); + if ( !"https".equalsIgnoreCase(scheme) ) { + throw new UnsupportedAviatorUrlSchemeException(scheme, url); + } + if ( "https".equals(scheme) ) { return trimmed; } - return "https://"+trimmed; + // Canonicalize scheme casing (HTTPS://host → https://host) + return "https"+trimmed.substring(schemeSeparator); } - private static NettyChannelBuilder configureBuilder(NettyChannelBuilder builder, java.util.Optional proxyDescriptor) { + private static NettyChannelBuilder configureBuilder(NettyChannelBuilder builder, Optional proxyDescriptor) { var configuredBuilder = builder - .useTransportSecurity() + .sslContext(createSslContext()) .maxInboundMessageSize(16 * 1024 * 1024) .keepAliveTime(30, TimeUnit.SECONDS) .keepAliveTimeout(10, TimeUnit.SECONDS) @@ -108,6 +145,17 @@ private static NettyChannelBuilder configureBuilder(NettyChannelBuilder builder, return configuredBuilder; } + private static SslContext createSslContext() { + try { + FcliTrustManager.refreshIfChanged(); + return GrpcSslContexts.forClient() + .trustManager(FcliTrustManager.getInstance()) + .build(); + } catch (SSLException e) { + throw new AviatorSimpleException("Unable to initialize Aviator gRPC TLS context", e); + } + } + static ProxiedSocketAddress toProxiedSocketAddress(SocketAddress targetAddress, ProxyDescriptor proxyDescriptor) { if ( !(targetAddress instanceof InetSocketAddress inetSocketAddress) ) { return null; @@ -127,5 +175,12 @@ public static AviatorGrpcClient createClient(String url) throws AviatorSimpleExc return createClient(url, null, Constants.DEFAULT_PING_INTERVAL_SECONDS); } - record ParsedTarget(String host, Integer port) {} + public record AviatorConnectionPlan( + String originalUrl, + String normalizedUrl, + ParsedTarget target, + int effectivePort, + Optional proxyDescriptor) {} + + public record ParsedTarget(String host, Integer port) {} } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java index 89d376801f5..959c68a53f2 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/AviatorStreamProcessor.java @@ -17,6 +17,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Queue; import java.util.Set; import java.util.UUID; @@ -36,6 +37,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,8 +48,11 @@ import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.QuotaBasedFilter; import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; import com.fortify.cli.aviator.audit.model.UserPrompt; import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder; import com.fortify.cli.aviator.fpr.utils.SourceCodeEnricher; import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.FprHandle; @@ -92,8 +97,16 @@ class AviatorStreamProcessor implements AutoCloseable { private volatile Future processingTask; private final Object retryLock = new Object(); private final FprHandle fprHandle; - - public AviatorStreamProcessor(AviatorGrpcClient client, IAviatorLogger logger, AuditorServiceGrpc.AuditorServiceStub asyncStub, ExecutorService processingExecutor, ScheduledExecutorService pingScheduler, long pingIntervalSeconds, long defaultTimeoutSeconds, FprHandle fprHandle) { + private final ISourceDecoder sourceDecoder; + private final FVDLMetadata fvdlMetadata; + private final SourceCodeEnricher sourceCodeEnricher; + private final AtomicInteger skippedRequests = new AtomicInteger(); + private final AtomicInteger failedRequests = new AtomicInteger(); + + public AviatorStreamProcessor(AviatorGrpcClient client, IAviatorLogger logger, AuditorServiceGrpc.AuditorServiceStub asyncStub, + ExecutorService processingExecutor, ScheduledExecutorService pingScheduler, long pingIntervalSeconds, + long defaultTimeoutSeconds, FprHandle fprHandle, ISourceDecoder sourceDecoder, + FVDLMetadata fvdlMetadata) { this.client = client; this.logger = logger; this.asyncStub = asyncStub; @@ -102,6 +115,9 @@ public AviatorStreamProcessor(AviatorGrpcClient client, IAviatorLogger logger, A this.pingIntervalSeconds = pingIntervalSeconds; this.defaultTimeoutSeconds = defaultTimeoutSeconds; this.fprHandle = fprHandle; + this.sourceDecoder = Objects.requireNonNull(sourceDecoder, "sourceDecoder"); + this.fvdlMetadata = fvdlMetadata; + this.sourceCodeEnricher = new SourceCodeEnricher(fprHandle, this.sourceDecoder, fvdlMetadata); } public CompletableFuture> processBatchRequests(Queue requests, String projectName, String FPRBuildId, String SSCApplicationName, String SSCApplicationVersion, String token, List customPriorityOrder) { @@ -389,7 +405,7 @@ public void onNext(AuditorResponse response) { response.getRequestId(), instanceId, response.getStatus(), metrics.getDuration()); } - outstandingRequests.decrementAndGet(); + decrementOutstanding(completedWrapper); requestSemaphore.release(); AuditResponse auditResponse = GrpcUtil.convertToAuditResponse(response); @@ -436,11 +452,14 @@ public void onError(Throwable t) { if (infinite || currentStreamState.streamRetryCount < Constants.MAX_STREAM_RETRIES) { LOG.debug("Stream encountered retryable error: {}. Will retry...", t.getMessage()); - int reAdded = inflightRequests.size(); - inflightRequests.values().forEach(processingQueue::addFirst); + int requeuedRequestCount = inflightRequests.size(); + inflightRequests.values().forEach(wrapper -> { + wrapper.outstandingTracked = false; + processingQueue.addFirst(wrapper); + }); inflightRequests.clear(); - outstandingRequests.addAndGet(-reAdded); - requestSemaphore.release(reAdded); + outstandingRequests.addAndGet(-requeuedRequestCount); + requestSemaphore.release(requeuedRequestCount); if (outstandingRequests.get() < 0) { outstandingRequests.set(0); } @@ -707,14 +726,12 @@ private void processRequestQueue(int totalRequests, AtomicInteger processedReque String instanceId = wrapper.userPrompt.getIssueData().getInstanceID(); - // Lazy Loading of source code files for individual issue - SourceCodeEnricher sourceCodeEnricher = new SourceCodeEnricher(fprHandle); - - Map enrichedFiles = - sourceCodeEnricher.enrichWithSourceCode(wrapper.userPrompt.getStackTrace()); - List sourceCodeFiles = new ArrayList<>(enrichedFiles.values()); - - wrapper.userPrompt.getFiles().addAll(sourceCodeFiles); + SourceCodeEnricher.EnrichmentResult enrichmentResult = enrichSourceCode(wrapper); + if (enrichmentResult != null && enrichmentResult.hasFailures()) { + completeSkippedRequest(wrapper, enrichmentResult, responses, processedRequests, totalRequests, + resultFuture, streamLatch); + continue; + } logger.info("Size of files {}", wrapper.userPrompt.getFiles().size()); logger.info("Size of programming language {}", wrapper.userPrompt.getProgrammingLanguages().size()); @@ -724,8 +741,8 @@ private void processRequestQueue(int totalRequests, AtomicInteger processedReque continue; } - if (wrapper.attemptCount == 0) { - outstandingRequests.incrementAndGet(); + if (!wrapper.outstandingTracked) { + incrementOutstanding(wrapper); } if (wrapper.attemptCount > 0) { @@ -761,10 +778,11 @@ private void processRequestQueue(int totalRequests, AtomicInteger processedReque currentStreamState.pendingIssueIds.remove(instanceId); int completed = processedRequests.incrementAndGet(); - outstandingRequests.decrementAndGet(); + int failed = failedRequests.incrementAndGet(); + decrementOutstanding(wrapper); requestSemaphore.release(); - logger.progress("Processed " + completed + " out of " + totalRequests + " issues (1 failed)."); + logger.progress("Processed " + completed + " out of " + totalRequests + " issues (" + failed + " failed)."); } else { LOG.error("Caught AviatorSimpleException but the request wrapper was null.", e); } @@ -792,6 +810,96 @@ private void processRequestQueue(int totalRequests, AtomicInteger processedReque processingQueue.size(), processedRequests.get(), totalRequests, outstandingRequests.get()); } + private SourceCodeEnricher.EnrichmentResult enrichSourceCode(RequestWrapper wrapper) { + synchronized (wrapper) { + if (wrapper.sourceCodeEnriched) { + return null; + } + + SourceCodeEnricher.EnrichmentResult enrichmentResult = + sourceCodeEnricher.enrichWithSourceCodeDetailed(wrapper.userPrompt.getStackTrace()); + if (!enrichmentResult.hasFailures()) { + List sourceCodeFiles = + new ArrayList<>(enrichmentResult.files().values()); + wrapper.userPrompt.getFiles().addAll(sourceCodeFiles); + wrapper.sourceCodeEnriched = true; + } + return enrichmentResult; + } + } + + private void incrementOutstanding(RequestWrapper wrapper) { + if (!wrapper.outstandingTracked) { + outstandingRequests.incrementAndGet(); + wrapper.outstandingTracked = true; + } + } + + private void decrementOutstanding(RequestWrapper wrapper) { + if (wrapper != null && wrapper.outstandingTracked) { + wrapper.outstandingTracked = false; + if (outstandingRequests.decrementAndGet() < 0) { + outstandingRequests.set(0); + } + } + } + + private void completeSkippedRequest(RequestWrapper wrapper, SourceCodeEnricher.EnrichmentResult enrichmentResult, + Map responses, AtomicInteger processedRequests, + int totalRequests, CompletableFuture> resultFuture, + CountDownLatch streamLatch) { + String instanceId = wrapper.userPrompt.getIssueData().getInstanceID(); + AuditSkipReason skipReason = enrichmentResult.failures().get(0).reason(); + String failureMessage = formatSourceFailureMessage(enrichmentResult, skipReason); + AuditResponse skippedResponse = new AuditResponse(); + skippedResponse.setIssueId(instanceId); + skippedResponse.setStatus("SKIPPED"); + skippedResponse.setAuditSkipReason(skipReason); + skippedResponse.setStatusMessage(failureMessage); + responses.put(instanceId, skippedResponse); + + currentStreamState.processedIssueIds.add(instanceId); + currentStreamState.pendingIssueIds.remove(instanceId); + decrementOutstanding(wrapper); + requestSemaphore.release(); + + int completed = processedRequests.incrementAndGet(); + int skipped = skippedRequests.incrementAndGet(); + LOG.warn("Skipping issue {}: {}", instanceId, failureMessage); + logger.progress("Processed " + completed + " out of " + totalRequests + " issues (" + skipped + " skipped)."); + + if (completed >= totalRequests) { + logger.info("All requests accounted for, completing stream."); + if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.complete(); + } + streamLatch.countDown(); + if (!resultFuture.isDone()) { + resultFuture.complete(responses); + } + } + } + + private String formatSourceFailureMessage(SourceCodeEnricher.EnrichmentResult enrichmentResult, + AuditSkipReason skipReason) { + List filenames = enrichmentResult.failures().stream() + .map(SourceCodeEnricher.SourceFileFailure::filename) + .distinct() + .collect(Collectors.toList()); + String details = enrichmentResult.failures().stream() + .map(SourceCodeEnricher.SourceFileFailure::message) + .filter(message -> message != null && !message.isBlank()) + .distinct() + .collect(Collectors.joining("; ")); + String detailSuffix = details.isBlank() ? "" : " (" + details + ")"; + return switch (skipReason) { + case SOURCE_FILE_DECODE_FAILED -> skipReason.format( + filenames.size() == 1 ? "" : "s", String.join(", ", filenames), detailSuffix); + case SOURCE_FILE_READ_FAILED -> skipReason.format(String.join(", ", filenames), detailSuffix); + case SKIPPED_BY_AVIATOR -> details.isBlank() ? skipReason.getDisplayMessage() : details; + }; + } + private void handleServerBusy(String requestId, int totalRequests, AtomicInteger processedRequests, Map responses, CompletableFuture> resultFuture, CountDownLatch streamLatch) { RequestWrapper wrapperToRetry = inflightRequests.remove(requestId); if (wrapperToRetry == null) { @@ -811,11 +919,13 @@ private void handleServerBusy(String requestId, int totalRequests, AtomicInteger failedResponse.setIssueId(wrapperToRetry.userPrompt.getIssueData().getInstanceID()); failedResponse.setStatus("RETRY_LIMIT_EXCEEDED"); failedResponse.setStatusMessage("Request failed after " + Constants.MAX_RETRIES + " retries due to server overload."); + failedResponse.setSubmittedToAviator(true); responses.put(wrapperToRetry.userPrompt.getIssueData().getInstanceID(), failedResponse); int completed = processedRequests.incrementAndGet(); logger.progress("Request permanently failed due to server busy - Processed " + completed + " out of " + totalRequests + " issues"); - int stillOutstanding = outstandingRequests.decrementAndGet(); + decrementOutstanding(wrapperToRetry); + int stillOutstanding = outstandingRequests.get(); LOG.warn("WARN: Request for instance {} permanently failed. Remaining outstanding requests: {}", wrapperToRetry.userPrompt.getIssueData().getInstanceID(), stillOutstanding); if (completed >= totalRequests) { logger.info("All requests accounted for after permanent failure, completing stream."); @@ -848,7 +958,7 @@ private void submitUserPrompt(RequestWrapper wrapper) { LOG.error("Failed to send request for instance {} after all retries. Re-queueing for later attempt.", wrapper.userPrompt.getIssueData().getInstanceID()); inflightRequests.remove(requestId); requestMetricsMap.remove(requestId); - outstandingRequests.decrementAndGet(); + decrementOutstanding(wrapper); requestSemaphore.release(); wrapper.attemptCount++; processingQueue.addLast(wrapper); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java index df66621f213..3b3cdb1bf81 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationResult.java @@ -14,17 +14,35 @@ import java.util.List; +import lombok.Builder; + /** * Holds the outcome of a full correlation stream run — both confirmed - * and rejected SAST–DAST pairs, plus the count of correlation requests - * that received a successful response from the server. + * and rejected SAST–DAST pairs, plus request statistics for the correlation phase. * * @param confirmedPairs pairs where Phase 2 validation returned confirmed=true * @param rejectedPairs pairs where Phase 2 validation returned confirmed=false - * @param receivedCorrelationResponses number of Phase 1 correlation requests that received a response + * @param submittedCorrelationRequests number of Phase 1 correlation requests sent to the server + * @param successfulCorrelationResponses number of Phase 1 requests with a successful server response + * @param skippedCorrelationResponses number of Phase 1 requests skipped by the server + * @param failedCorrelationResponses number of Phase 1 requests that failed */ +@Builder public record CorrelationResult( List confirmedPairs, List rejectedPairs, - int receivedCorrelationResponses -) {} + int submittedCorrelationRequests, + int successfulCorrelationResponses, + int skippedCorrelationResponses, + int failedCorrelationResponses +) { + public CorrelationResult { + confirmedPairs = confirmedPairs == null ? List.of() : List.copyOf(confirmedPairs); + rejectedPairs = rejectedPairs == null ? List.of() : List.copyOf(rejectedPairs); + } + + public static CorrelationResult empty() { + return CorrelationResult.builder() + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java index 5c4effc8c6f..4b2b27fb3e5 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessor.java @@ -20,6 +20,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -94,16 +95,15 @@ public class CorrelationStreamProcessor implements AutoCloseable { // Input data retained for building validation requests private List correlationWorkItems; private Map> urlToDastIssues; - private final java.util.concurrent.ConcurrentHashMap validationRequestToDastId = - new java.util.concurrent.ConcurrentHashMap<>(); + private final Set pendingCorrelationRequestIds = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap validationRequestToDastId = new ConcurrentHashMap<>(); private volatile CompletableFuture resultFuture; /** - * Keys of SAST–DAST pairs that were confirmed in a previous run and should - * be skipped during both Phase 1 (correlation) and Phase 2 (validation). + * Keys of SAST–DAST pairs that were confirmed or rejected in a previous run. * Each key is formatted as {@code "sastInstanceId::dastIssueId"}. */ - private Set previouslyCorrelatedPairKeys = Set.of(); + private Set previouslyTriedPairKeys = Set.of(); public CorrelationStreamProcessor( AviatorGrpcClient client, @@ -122,23 +122,25 @@ public CorrelationStreamProcessor( /** * Entry point: run correlation on the provided mixed-category buckets. - * Previously confirmed pairs (from prior runs) are not re-processed. + * Previously tried pairs (from prior runs) are not re-processed. * * @param config stream init configuration (token, app name, etc.) * @param mixedBuckets category buckets containing both SAST and DAST findings * @param scanGuid SAST scan UUID for building CorrelatedPair results - * @param previouslyCorrelatedPairKeys keys of already-confirmed pairs to skip, each formatted as - * {@code "sastInstanceId::dastIssueId"}; may be {@code null} + * @param previouslyTriedPairKeys keys of already-tried pairs to skip, each formatted as + * {@code "sastInstanceId::dastIssueId"}; may be {@code null} * @return future that completes with the list of confirmed correlated pairs */ public CompletableFuture processCorrelation( CorrelationStreamConfig config, List mixedBuckets, String scanGuid, - Set previouslyCorrelatedPairKeys) { + Set previouslyTriedPairKeys) { - this.previouslyCorrelatedPairKeys = - previouslyCorrelatedPairKeys != null ? previouslyCorrelatedPairKeys : Set.of(); + this.previouslyTriedPairKeys = + previouslyTriedPairKeys != null ? previouslyTriedPairKeys : Set.of(); + pendingCorrelationRequestIds.clear(); + validationRequestToDastId.clear(); // Build URL→DAST map first; needed to evaluate Phase 1 skip eligibility this.urlToDastIssues = buildUrlToDastMap(mixedBuckets); @@ -149,7 +151,7 @@ public CompletableFuture processCorrelation( urlToDastIssues.forEach((k,v)->LOG.debug(" For url {} no. of dast issues {}", k, v.size())); if (workItems.isEmpty()) { LOG.info("No SAST findings in mixed buckets; skipping correlation stream."); - return CompletableFuture.completedFuture(new CorrelationResult(List.of(), List.of(), 0)); + return CompletableFuture.completedFuture(CorrelationResult.empty()); } String streamId = UUID.randomUUID().toString(); @@ -167,6 +169,17 @@ public CompletableFuture processCorrelation( return resultFuture; } + private CorrelationResult createResultSnapshot() { + return CorrelationResult.builder() + .confirmedPairs(new ArrayList<>(state.confirmedPairs)) + .rejectedPairs(new ArrayList<>(state.rejectedPairs)) + .submittedCorrelationRequests(state.totalCorrelationRequests) + .successfulCorrelationResponses(state.successfulCorrelations.get()) + .skippedCorrelationResponses(state.skippedCorrelations.get()) + .failedCorrelationResponses(state.failedCorrelations.get()) + .build(); + } + /** * Convenience overload for callers that have no previously confirmed pairs to skip. */ @@ -220,6 +233,7 @@ private void sendCorrelationRequests() { for (var item : correlationWorkItems) { var req = buildCorrelationRequest(state.streamId, item); + pendingCorrelationRequestIds.add(req.getRequestId()); requestHandler.sendRequest( CorrelationClientMessage.newBuilder().setCorrelation(req).build() ); @@ -238,11 +252,7 @@ private void transitionToValidation(String scanGuid) { if (validationItems.isEmpty()) { logger.info("No candidates to validate. Completing stream."); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } requestHandler.complete(); streamLatch.countDown(); @@ -312,11 +322,7 @@ public void onCompleted() { state.currentPhase = CorrelationStreamState.Phase.COMPLETE; logger.info("Correlation stream completed — " + state.confirmedPairs.size() + " confirmed pairs"); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } streamLatch.countDown(); } @@ -340,6 +346,10 @@ private void handleInitResponse(CorrelationInitResponse resp) { } private void handleCorrelationResponse(CorrelationResponse resp) { + if (!pendingCorrelationRequestIds.remove(resp.getRequestId())) { + LOG.warn("Ignoring correlation response for unknown or completed request {}", resp.getRequestId()); + return; + } int received = state.receivedCorrelations.incrementAndGet(); LOG.debug("Correlation response {}/{} for SAST {}: status={}", received, state.totalCorrelationRequests, resp.getSastId(), resp.getStatus()); @@ -356,7 +366,11 @@ private void handleCorrelationResponse(CorrelationResponse resp) { match.getRationale() )); } + } else if ("SKIPPED".equalsIgnoreCase(resp.getStatus())) { + state.skippedCorrelations.incrementAndGet(); + LOG.debug("Skipped correlation for SAST {}: {}", resp.getSastId(), resp.getNoCorrelationReason()); } else { + state.failedCorrelations.incrementAndGet(); LOG.debug("Non-OK correlation for SAST {}: {} — {}", resp.getSastId(), resp.getStatus(), resp.getNoCorrelationReason()); } @@ -408,11 +422,7 @@ private void handleValidationResponse(CorrelationValidationResponse resp, String state.confirmedPairs.size() + " confirmed pairs, " + state.rejectedPairs.size() + " rejected pairs."); if (!resultFuture.isDone()) { - resultFuture.complete(new CorrelationResult( - new ArrayList<>(state.confirmedPairs), - new ArrayList<>(state.rejectedPairs), - state.successfulCorrelations.get() - )); + resultFuture.complete(createResultSnapshot()); } requestHandler.complete(); streamLatch.countDown(); @@ -568,10 +578,10 @@ private List buildCorrelationWorkItems(List urlList = new ArrayList<>(dastUrls); for (Vulnerability vuln : data.sastFindings()) { - // Strip URLs where every mapped DAST issue is already confirmed with this SAST finding + // Strip URLs where every mapped DAST issue was already tried with this SAST finding. List newUrls = filterNewUrls(vuln.getInstanceID(), urlList); if (newUrls.isEmpty()) { - LOG.debug("Skipping SAST finding {} from Phase 1 — all reachable DAST issues already confirmed", + LOG.debug("Skipping SAST finding {} from Phase 1 — all reachable DAST issues already tried", vuln.getInstanceID()); continue; } @@ -585,16 +595,16 @@ private List buildCorrelationWorkItems(List *
  • URLs with no mapped DAST issues are kept (the server may resolve them).
  • *
  • URLs where every mapped DAST issue is already in - * {@link #previouslyCorrelatedPairKeys} are excluded — they add no new work.
  • + * {@link #previouslyTriedPairKeys} are excluded — they add no new work. * */ private List filterNewUrls(String sastInstanceId, List urls) { - if (previouslyCorrelatedPairKeys.isEmpty()) return urls; // fast path: nothing confirmed yet + if (previouslyTriedPairKeys.isEmpty()) return urls; List result = new ArrayList<>(); for (String url : urls) { List issues = urlToDastIssues.getOrDefault(url, List.of()); @@ -604,7 +614,7 @@ private List filterNewUrls(String sastInstanceId, List urls) { } boolean hasUncorrelated = issues.stream() .filter(d -> d.getId() != null && !d.getId().isEmpty()) - .anyMatch(d -> !previouslyCorrelatedPairKeys.contains(sastInstanceId + "::" + d.getId())); + .anyMatch(d -> !previouslyTriedPairKeys.contains(sastInstanceId + "::" + d.getId())); if (hasUncorrelated) { result.add(url); } @@ -645,8 +655,8 @@ private List buildValidationWorkItems() { for (DastIssue dastIssue : issues) { String pairKey = match.sastInstanceId() + "::" + dastIssue.getId(); - if (previouslyCorrelatedPairKeys.contains(pairKey)) { - LOG.debug("Skipping already confirmed pair sast={} dast={} from Phase 2 validation", + if (previouslyTriedPairKeys.contains(pairKey)) { + LOG.debug("Skipping already-tried pair sast={} dast={} from Phase 2 validation", match.sastInstanceId(), dastIssue.getId()); continue; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java index d8ec3e2b199..b6ccdce3f2c 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/CorrelationStreamState.java @@ -39,6 +39,8 @@ enum Phase { INIT, CORRELATING, VALIDATING, COMPLETE } final AtomicInteger sentCorrelations = new AtomicInteger(0); final AtomicInteger receivedCorrelations = new AtomicInteger(0); final AtomicInteger successfulCorrelations = new AtomicInteger(0); + final AtomicInteger skippedCorrelations = new AtomicInteger(0); + final AtomicInteger failedCorrelations = new AtomicInteger(0); // Validation phase counters int totalValidationRequests; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java new file mode 100644 index 00000000000..56ca5765eb1 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapper.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.aviator.dastaudit.DastClassification; +import com.fortify.aviator.dastaudit.DastFindingContext; +import com.fortify.aviator.dastaudit.DastReproStep; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +/** + * Maps parsed WebInspect data to the DAST audit wire contract. + */ +public final class DastAuditRequestMapper { + private DastAuditRequestMapper() {} + + public static DastFindingContext toFindingContext(DastSession session, DastIssue issue) { + var builder = DastFindingContext.newBuilder() + .setIssueId(value(issue.getId())) + .setCheckTypeId(value(issue.getCheckTypeId())) + .setEngineType(value(issue.getEngineType())) + .setVulnerabilityId(value(issue.getVulnerabilityId())) + .setSeverity(issue.getSeverity()) + .setName(value(issue.getName())) + .setCategory(value(issue.getCategory())) + .setCweId(value(issue.getCweId())) + .setCweDescription(value(issue.getCweDescription())) + .setSessionUrl(value(session.getUrl() != null ? session.getUrl() : issue.getSessionUrl())) + .setSummary(value(issue.getSummary())) + .setImplication(value(issue.getImplication())) + .setExecution(value(issue.getExecution())) + .setFix(value(issue.getFix())) + .setReferenceInfo(value(issue.getReferenceInfo())) + .setRequestId(value(session.getRequestId())) + .setScheme(value(session.getScheme())) + .setHost(value(session.getHost())) + .setPort(session.getPort()) + .setAttackParamDescriptor(value(session.getAttackParamDescriptor())) + .setRawRequest(value(session.getRawRequest())) + .setRawResponse(value(session.getRawResponse())); + + issue.getClassifications().forEach((kind, classificationValue) -> builder.addClassifications( + DastClassification.newBuilder().setKind(value(kind)).setValue(value(classificationValue)).build())); + builder.addAllReproStepUrls(issue.getReproStepUrls()); + issue.getReproSteps().forEach(step -> builder.addReproSteps(DastReproStep.newBuilder() + .setSource(value(step.getSource())) + .setUrl(value(step.getUrl())) + .setPostParams(value(step.getPostParams())) + .build())); + return builder.build(); + } + + private static String value(String value) { + return value != null ? value : ""; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java new file mode 100644 index 00000000000..27f06ddda9c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapper.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.aviator.dastaudit.DastAuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; + +/** + * Maps a DAST audit response to the issue associated with its request ID. + */ +final class DastAuditResponseMapper { + private DastAuditResponseMapper() {} + + static DastAuditResult map(DastAuditResponse response, String expectedIssueId) { + String responseIssueId = response.getDastIssueId(); + if (!responseIssueId.isBlank() && !expectedIssueId.equals(responseIssueId)) { + return DastAuditResult.Failure.builder() + .issueId(expectedIssueId) + .status("FAILED") + .statusMessage("DAST audit response issue ID mismatch: expected '" + expectedIssueId + + "' but received '" + responseIssueId + "'") + .build(); + } + + if ("SKIPPED".equalsIgnoreCase(response.getStatus())) { + return DastAuditResult.Skipped.builder() + .issueId(expectedIssueId) + .statusMessage(response.getStatusMessage()) + .build(); + } + if (!"SUCCESS".equalsIgnoreCase(response.getStatus())) { + return DastAuditResult.Failure.builder() + .issueId(expectedIssueId) + .status(response.getStatus()) + .statusMessage(response.getStatusMessage()) + .build(); + } + if (!response.hasDecision()) { + return DastAuditResult.Failure.builder() + .issueId(expectedIssueId) + .status("FAILED") + .statusMessage("Successful DAST audit response did not contain a decision") + .build(); + } + + var decision = response.getDecision(); + return DastAuditResult.Success.builder() + .issueId(expectedIssueId) + .truePositive(decision.getTruePositive()) + .confidence(decision.getConfidence()) + .reasoning(decision.getReasoning()) + .remediationAdvice(decision.getRemediationAdvice()) + .finalComment(decision.getFinalComment()) + .tagValue(decision.getTagValue()) + .tier(AuditTier.fromServerValue(decision.getTier())) + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java new file mode 100644 index 00000000000..0c36e052e2f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditResult.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator.audit.model.AuditTier; + +import lombok.Builder; + +/** Domain representation of one terminal DAST audit response. */ +public sealed interface DastAuditResult permits DastAuditResult.Success, DastAuditResult.Skipped, DastAuditResult.Failure { + String issueId(); + String status(); + String statusMessage(); + + @Builder + record Success( + String issueId, + boolean truePositive, + String confidence, + String reasoning, + String remediationAdvice, + String finalComment, + String tagValue, + AuditTier tier + ) implements DastAuditResult { + public Success { + tier = tier == null ? AuditTier.SILVER : tier; + } + + @Override + public String status() { + return "SUCCESS"; + } + + @Override + public String statusMessage() { + return ""; + } + } + + @Builder + record Skipped(String issueId, String statusMessage) implements DastAuditResult { + @Override + public String status() { + return "SKIPPED"; + } + } + + @Builder + record Failure(String issueId, String status, String statusMessage) implements DastAuditResult {} +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java new file mode 100644 index 00000000000..83c21da10d4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfig.java @@ -0,0 +1,38 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +import lombok.Builder; + +/** + * Configuration for one DAST audit gRPC stream. + */ +@Builder +public record DastAuditStreamConfig( + String token, + String applicationName, + String sscApplicationName, + String sscApplicationVersion, + String fprBuildId +) { + public DastAuditStreamConfig { + if (token == null || token.isBlank()) { + throw new AviatorSimpleException("Aviator token must be specified for DAST audit"); + } + if (applicationName == null || applicationName.isBlank()) { + throw new AviatorSimpleException("Aviator application name must be specified for DAST audit"); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java new file mode 100644 index 00000000000..c830ce7949c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessor.java @@ -0,0 +1,493 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fortify.aviator.dastaudit.DastAuditClientMessage; +import com.fortify.aviator.dastaudit.DastAuditErrorResponse; +import com.fortify.aviator.dastaudit.DastAuditInitResponse; +import com.fortify.aviator.dastaudit.DastAuditPingRequest; +import com.fortify.aviator.dastaudit.DastAuditPongResponse; +import com.fortify.aviator.dastaudit.DastAuditRequest; +import com.fortify.aviator.dastaudit.DastAuditResponse; +import com.fortify.aviator.dastaudit.DastAuditServerMessage; +import com.fortify.aviator.dastaudit.DastAuditServiceGrpc; +import com.fortify.aviator.dastaudit.DastAuditStreamInitRequest; +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.config.IAviatorLogger; +import com.fortify.cli.aviator.util.Constants; + +import io.grpc.Status; +import io.grpc.stub.ClientCallStreamObserver; +import io.grpc.stub.ClientResponseObserver; +import io.grpc.stub.StreamObserver; + +/** + * Processes DAST findings through the Aviator DAST audit bidirectional stream. + */ +public class DastAuditStreamProcessor implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(DastAuditStreamProcessor.class); + + private final IAviatorLogger logger; + private final DastAuditServiceGrpc.DastAuditServiceStub asyncStub; + private final ScheduledExecutorService pingScheduler; + private final long pingIntervalSeconds; + private final AtomicBoolean isPinging = new AtomicBoolean(false); + private final AtomicBoolean isClosed = new AtomicBoolean(false); + private final AtomicBoolean isRpcCompleted = new AtomicBoolean(false); + private final AtomicBoolean isRetryScheduled = new AtomicBoolean(false); + private final AtomicInteger streamRetryCount = new AtomicInteger(); + private final AtomicInteger stagnantRetryCount = new AtomicInteger(); + private final AtomicLong streamGeneration = new AtomicLong(); + + private RequestHandler requestHandler; + private ScheduledFuture pingTask; + private ScheduledFuture retryTask; + private ClientCallStreamObserver activeRequestStream; + private String streamId; + private DastAuditStreamConfig config; + private List workItems; + private int totalReportedIssues; + private int lastRetryCompletedCount; + private CompletableFuture resultFuture; + private final List results = Collections.synchronizedList(new ArrayList<>()); + private final Map requestIssueIds = new ConcurrentHashMap<>(); + private final Map requestIdsByIssue = new ConcurrentHashMap<>(); + private final Set completedRequestIds = ConcurrentHashMap.newKeySet(); + private final Set pendingIssueIds = ConcurrentHashMap.newKeySet(); + private int reservedQuota; + private int exceededCount; + private boolean unlimitedQuota; + private boolean quotaMetadataInitialized; + private String quotaLastUpdated; + private String nextQuotaUpdateMessage; + + public DastAuditStreamProcessor( + IAviatorLogger logger, + DastAuditServiceGrpc.DastAuditServiceStub asyncStub, + ScheduledExecutorService pingScheduler, + long pingIntervalSeconds) { + this.logger = logger; + this.asyncStub = asyncStub; + this.pingScheduler = pingScheduler; + this.pingIntervalSeconds = pingIntervalSeconds; + } + + public CompletableFuture process( + DastAuditStreamConfig config, + List workItems, + int totalReportedIssues) { + this.config = config; + this.totalReportedIssues = totalReportedIssues; + this.resultFuture = new CompletableFuture<>(); + this.requestIdsByIssue.clear(); + this.completedRequestIds.clear(); + this.results.clear(); + this.streamRetryCount.set(0); + this.stagnantRetryCount.set(0); + this.lastRetryCompletedCount = 0; + this.isClosed.set(false); + this.quotaMetadataInitialized = false; + initializeWorkItems(workItems); + startStream(); + return resultFuture; + } + + void initializeWorkItems(List items) { + this.workItems = List.copyOf(items); + this.pendingIssueIds.clear(); + items.forEach(item -> pendingIssueIds.add(item.issue().getId())); + } + + private void startStream() { + if (resultFuture.isDone() || isClosed.get()) return; + this.streamId = UUID.randomUUID().toString(); + this.requestIssueIds.clear(); + this.requestHandler = new RequestHandler<>(streamId); + this.activeRequestStream = null; + this.isRpcCompleted.set(false); + this.isRetryScheduled.set(false); + long generation = streamGeneration.incrementAndGet(); + LOG.debug("Starting DAST audit stream {} with {} pending findings and {} reported findings", + streamId, pendingIssueIds.size(), totalReportedIssues); + + try { + StreamObserver requestObserver = + asyncStub.processDastAuditStream(new ResponseObserver(generation)); + requestHandler.initialize(requestObserver); + startPingPong(); + sendInit(); + } catch (RuntimeException exception) { + handleStreamError(exception); + } + } + + private void sendInit() { + var init = DastAuditStreamInitRequest.newBuilder() + .setToken(value(config.token())) + .setApplicationName(value(config.applicationName())) + .setStreamId(streamId) + .setRequestId(UUID.randomUUID().toString()) + .setTotalReportedIssues(totalReportedIssues) + .setTotalIssuesToAudit(pendingIssueIds.size()); + if (config.fprBuildId() != null) init.setFprBuildId(config.fprBuildId()); + if (config.sscApplicationName() != null) init.setSscApplicationName(config.sscApplicationName()); + if (config.sscApplicationVersion() != null) init.setSscApplicationVersion(config.sscApplicationVersion()); + sendRequest(DastAuditClientMessage.newBuilder().setInit(init).build()); + } + + private void handleInit(DastAuditInitResponse response) { + if (!isSuccess(response.getStatus())) { + fail(new AviatorSimpleException("DAST audit initialization failed: " + response.getStatusMessage())); + return; + } + if (!quotaMetadataInitialized) { + reservedQuota = response.getReservedQuota(); + exceededCount = response.getExceededCount(); + unlimitedQuota = response.getUnlimitedQuota(); + quotaLastUpdated = response.hasQuotaLastUpdated() ? response.getQuotaLastUpdated() : null; + nextQuotaUpdateMessage = response.hasNextQuotaUpdateMessage() ? response.getNextQuotaUpdateMessage() : null; + quotaMetadataInitialized = true; + } + List remainingWorkItems = remainingWorkItems(); + logger.info("DAST audit stream initialized; submitting " + remainingWorkItems.size() + " findings"); + for (DastAuditClientMessage request : prepareAuditRequests(remainingWorkItems, streamId)) { + sendRequest(request); + } + } + + List prepareAuditRequests(List items, String requestStreamId) { + List requests = new ArrayList<>(items.size()); + for (DastAuditWorkItem item : items) { + String issueId = item.issue().getId(); + String requestId = requestIdsByIssue.computeIfAbsent(issueId, ignored -> UUID.randomUUID().toString()); + var request = DastAuditRequest.newBuilder() + .setRequestId(requestId) + .setStreamId(requestStreamId) + .setFinding(DastAuditRequestMapper.toFindingContext(item.session(), item.issue())) + .build(); + pendingIssueIds.add(issueId); + requestIssueIds.put(requestId, issueId); + LOG.debug("Submitting DAST issue {} with request {} on stream {}", + issueId, requestId, requestStreamId); + requests.add(DastAuditClientMessage.newBuilder().setAudit(request).build()); + } + return List.copyOf(requests); + } + + int pendingRequestCount() { + return pendingIssueIds.size(); + } + + List remainingWorkItems() { + return workItems.stream() + .filter(item -> pendingIssueIds.contains(item.issue().getId())) + .toList(); + } + + String completeRequest(String requestId) { + String issueId = requestIssueIds.remove(requestId); + if (issueId != null) { + pendingIssueIds.remove(issueId); + completedRequestIds.add(requestId); + } + return issueId; + } + + private void handleAudit(DastAuditResponse response) { + String issueId = completeRequest(response.getRequestId()); + if (issueId == null) { + LOG.warn("Ignoring DAST audit response for unknown or completed request {}", response.getRequestId()); + return; + } + var decision = response.hasDecision() ? response.getDecision() : null; + LOG.debug("Received DAST audit response: issueId={}, requestId={}, status={}, confidence={}, tier={}, hasDecision={}", + issueId, response.getRequestId(), response.getStatus(), + decision != null ? decision.getConfidence() : null, + decision != null ? decision.getTier() : null, decision != null); + results.add(DastAuditResponseMapper.map(response, issueId)); + logger.progress("Audited %d of %d DAST findings", results.size(), workItems.size()); + completeRequestsIfDone(); + } + + private void handleError(DastAuditErrorResponse response) { + String issueId = completeRequest(response.getRequestId()); + if (issueId != null) { + LOG.debug("Received DAST audit error response: issueId={}, requestId={}, status={}, statusMessage={}", + issueId, response.getRequestId(), response.getStatus(), response.getStatusMessage()); + DastAuditResult result = "SKIPPED".equalsIgnoreCase(response.getStatus()) + ? DastAuditResult.Skipped.builder() + .issueId(issueId) + .statusMessage(response.getStatusMessage()) + .build() + : DastAuditResult.Failure.builder() + .issueId(issueId) + .status(response.getStatus()) + .statusMessage(response.getStatusMessage()) + .build(); + results.add(result); + logger.progress("Audited %d of %d DAST findings", results.size(), workItems.size()); + completeRequestsIfDone(); + } else { + if (completedRequestIds.contains(response.getRequestId())) { + LOG.debug("Ignoring duplicate DAST audit error for completed request {}", response.getRequestId()); + return; + } + fail(new AviatorSimpleException("DAST audit error: " + response.getStatusMessage())); + } + } + + private void handlePong(DastAuditPongResponse response) { + LOG.debug("DAST audit pong received in {} ms", System.currentTimeMillis() - response.getClientTimestamp()); + } + + private void completeRequestsIfDone() { + if (pendingIssueIds.isEmpty() && requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.complete(); + } + } + + private class ResponseObserver implements ClientResponseObserver { + private final long generation; + + private ResponseObserver(long generation) { + this.generation = generation; + } + + private boolean isCurrent() { + return generation == streamGeneration.get(); + } + + @Override + public void beforeStart(ClientCallStreamObserver requestStream) { + if (isCurrent()) activeRequestStream = requestStream; + } + + @Override + public void onNext(DastAuditServerMessage message) { + if (!isCurrent()) return; + switch (message.getResponseTypeCase()) { + case INIT -> handleInit(message.getInit()); + case AUDIT -> handleAudit(message.getAudit()); + case ERROR -> handleError(message.getError()); + case PONG -> handlePong(message.getPong()); + default -> LOG.warn("Unknown DAST audit response type: {}", message.getResponseTypeCase()); + } + } + + @Override + public void onError(Throwable throwable) { + if (!isCurrent()) return; + isRpcCompleted.set(true); + handleStreamError(throwable); + } + + @Override + public void onCompleted() { + if (!isCurrent()) return; + isRpcCompleted.set(true); + stopPingPong(); + LOG.debug("DAST audit stream {} completed with {} terminal responses for {} submitted findings", + streamId, results.size(), workItems.size()); + if (resultFuture.isDone()) return; + if (!pendingIssueIds.isEmpty()) { + handleStreamError(Status.UNAVAILABLE + .withDescription("DAST audit stream completed before all findings received terminal responses") + .asRuntimeException()); + return; + } + completeSuccessfully(); + } + } + + private void startPingPong() { + if (pingScheduler == null || pingIntervalSeconds <= 0) return; + pingTask = pingScheduler.scheduleAtFixedRate(() -> { + if (isPinging.compareAndSet(false, true)) { + try { + if (requestHandler != null && requestHandler.isReady()) { + var ping = DastAuditPingRequest.newBuilder() + .setStreamId(streamId) + .setTimestamp(System.currentTimeMillis()) + .build(); + sendRequest(DastAuditClientMessage.newBuilder().setPing(ping).build()); + } + } finally { + isPinging.set(false); + } + } + }, pingIntervalSeconds, pingIntervalSeconds, TimeUnit.SECONDS); + } + + private void handleStreamError(Throwable throwable) { + stopPingPong(); + if (resultFuture.isDone() || isClosed.get() || isRetryScheduled.get()) return; + + if (pendingIssueIds.isEmpty()) { + completeSuccessfully(); + return; + } + + if (isRetryableError(throwable) && canRetry(throwable)) { + if (!isRetryScheduled.compareAndSet(false, true)) return; + int retryAttempt = streamRetryCount.incrementAndGet(); + long delay = calculateStreamRetryDelay(retryAttempt); + String maxAttempts = isInfiniteRetryError(throwable) + ? "infinite" : String.valueOf(Constants.MAX_STREAM_RETRIES); + logger.info("Retrying DAST audit stream (attempt %d/%s) after %d ms; %d findings remain", + retryAttempt, maxAttempts, delay, pendingIssueIds.size()); + scheduleRetry(delay); + return; + } + + Status status = Status.fromThrowable(throwable); + completeExceptionally(new AviatorTechnicalException( + "DAST audit stream failed: " + status.getDescription(), throwable)); + } + + private boolean canRetry(Throwable throwable) { + int completedCount = workItems.size() - pendingIssueIds.size(); + if (completedCount == lastRetryCompletedCount) { + if (stagnantRetryCount.incrementAndGet() >= 3) { + LOG.error("DAST audit stream made no progress after multiple retries"); + return false; + } + } else { + stagnantRetryCount.set(0); + } + lastRetryCompletedCount = completedCount; + return isInfiniteRetryError(throwable) || streamRetryCount.get() < Constants.MAX_STREAM_RETRIES; + } + + private void scheduleRetry(long delay) { + Runnable retry = () -> { + if (!resultFuture.isDone() && !isClosed.get()) { + isRetryScheduled.set(false); + startStream(); + } + }; + if (!isRpcCompleted.get() && activeRequestStream != null) { + activeRequestStream.cancel("Retrying DAST audit stream", null); + } + if (pingScheduler == null) { + CompletableFuture.delayedExecutor(delay, TimeUnit.MILLISECONDS).execute(retry); + } else { + retryTask = pingScheduler.schedule(retry, delay, TimeUnit.MILLISECONDS); + } + } + + static boolean isRetryableError(Throwable throwable) { + Status status = Status.fromThrowable(throwable); + String description = status.getDescription(); + return status.getCode() == Status.Code.UNAVAILABLE || + status.getCode() == Status.Code.INTERNAL && description != null && + (description.contains("RST_STREAM") || description.contains("PROTOCOL_ERROR")); + } + + static boolean isInfiniteRetryError(Throwable throwable) { + Status status = Status.fromThrowable(throwable); + String description = status.getDescription(); + return status.getCode() == Status.Code.INTERNAL && description != null && + description.contains("PROTOCOL_ERROR"); + } + + static long calculateStreamRetryDelay(int retryCount) { + long delay = (long) (Constants.STREAM_RETRY_BASE_DELAY_MS * Math.pow(2, retryCount - 1)); + return Math.min(delay, Constants.STREAM_RETRY_MAX_DELAY_MS) + + ThreadLocalRandom.current().nextLong(1000); + } + + private void sendRequest(DastAuditClientMessage request) { + RequestHandler currentHandler = requestHandler; + currentHandler.sendRequest(request).whenComplete((sent, throwable) -> { + if (currentHandler != requestHandler || resultFuture.isDone() || isClosed.get()) return; + if (throwable != null || !Boolean.TRUE.equals(sent)) { + Throwable cause = throwable != null ? throwable : Status.UNAVAILABLE + .withDescription("Unable to send DAST audit stream request") + .asRuntimeException(); + handleStreamError(cause); + } + }); + } + + private void fail(RuntimeException exception) { + stopPingPong(); + if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.sendError(exception); + } + completeExceptionally(exception); + } + + private void completeExceptionally(RuntimeException exception) { + if (!resultFuture.isDone()) resultFuture.completeExceptionally(exception); + } + + private void completeSuccessfully() { + if (!resultFuture.isDone()) { + resultFuture.complete(DastAuditStreamResult.builder() + .results(List.copyOf(results)) + .reservedQuota(reservedQuota) + .exceededCount(exceededCount) + .unlimitedQuota(unlimitedQuota) + .quotaLastUpdated(quotaLastUpdated) + .nextQuotaUpdateMessage(nextQuotaUpdateMessage) + .build()); + } + } + + private void stopPingPong() { + if (pingTask != null) pingTask.cancel(false); + } + + private boolean isSuccess(String status) { + return "SUCCESS".equalsIgnoreCase(status) || "OK".equalsIgnoreCase(status); + } + + private String value(String value) { + return value != null ? value : ""; + } + + @Override + public void close() { + isClosed.set(true); + stopPingPong(); + if (retryTask != null) retryTask.cancel(false); + if (!isRpcCompleted.get() && activeRequestStream != null) { + activeRequestStream.cancel("DAST audit stream processor closed", null); + } else if (requestHandler != null && !requestHandler.isCompleted()) { + requestHandler.sendError(new AviatorTechnicalException("DAST audit stream processor closed")); + } + if (resultFuture != null && !resultFuture.isDone()) { + completeExceptionally(new AviatorTechnicalException("DAST audit stream processor closed")); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java new file mode 100644 index 00000000000..f114a495cb9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditStreamResult.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import java.util.List; + +import lombok.Builder; + +/** + * Results and quota metadata returned by one DAST audit stream. + */ +@Builder +public record DastAuditStreamResult( + List results, + int reservedQuota, + int exceededCount, + boolean unlimitedQuota, + String quotaLastUpdated, + String nextQuotaUpdateMessage +) { + public DastAuditStreamResult { + results = results == null ? List.of() : List.copyOf(results); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java new file mode 100644 index 00000000000..1aca385b831 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/DastAuditWorkItem.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +/** + * Associates a DAST finding with the WebInspect session that contains its HTTP evidence. + */ +public record DastAuditWorkItem(DastSession session, DastIssue issue) {} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java index 2d44b56f599..1fc5785ca33 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/GrpcUtil.java @@ -29,6 +29,7 @@ import com.fortify.cli.aviator._common.exception.AviatorSimpleException; import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; import com.fortify.cli.aviator.audit.model.Autoremediation; import com.fortify.cli.aviator.audit.model.Change; import com.fortify.cli.aviator.audit.model.StackTraceElement; @@ -195,7 +196,11 @@ static AuditResponse convertToAuditResponse(AuditorResponse response) { auditResponse.setOutputToken(response.getOutputToken()); auditResponse.setStatus(response.getStatus()); auditResponse.setStatusMessage(response.getStatusMessage()); + if ("SKIPPED".equalsIgnoreCase(response.getStatus())) { + auditResponse.setAuditSkipReason(AuditSkipReason.SKIPPED_BY_AVIATOR); + } auditResponse.setIssueId(response.getIssueId()); + auditResponse.setSubmittedToAviator(true); auditResponse.setTier(response.getTier()); auditResponse.setAviatorPredictionTag(response.getAviatorPredictionTag()); auditResponse.setIsAviatorProcessed(response.getIsAviatorProcessed()); diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java index 12dcd9550a7..62f1231eea4 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestHandler.java @@ -134,15 +134,25 @@ public boolean flush() { * Complete the stream and send any remaining requests. */ public CompletableFuture complete() { - if (!isCompleted.compareAndSet(false, true)) { + if (isCompleted.get()) { return CompletableFuture.completedFuture(null); } return CompletableFuture.runAsync(() -> { sendLock.lock(); try { - // Flush any remaining requests - flush(); + if (!isCompleted.compareAndSet(false, true)) { + return; + } + + T request; + while ((request = requestQueue.poll()) != null) { + if (requestObserver != null) { + requestObserver.onNext(request); + totalSent++; + pendingRequests.decrementAndGet(); + } + } // Complete the stream if (requestObserver != null) { diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestWrapper.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestWrapper.java index 954f048f4b2..65ddee600c5 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestWrapper.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/grpc/RequestWrapper.java @@ -17,6 +17,8 @@ class RequestWrapper { final UserPrompt userPrompt; int attemptCount = 0; + volatile boolean outstandingTracked; + volatile boolean sourceCodeEnriched; RequestWrapper(UserPrompt userPrompt) { this.userPrompt = userPrompt; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java index 756789a0883..15fdf606f1d 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/Constants.java @@ -12,6 +12,9 @@ */ package com.fortify.cli.aviator.util; +import java.util.Locale; +import java.util.Set; + public class Constants { // Audit Result Values @@ -45,8 +48,19 @@ public class Constants { public static final String AUDITOR_STATUS_TAG_ID = "ACB05E55-E74D-468C-8501-52E1FDC27D71"; public static final String FOD_TAG_ID = "604f0fbe-b5fe-47cd-a9cb-587ad8ebe93a"; - // User Names + // User Names written into audit.xml TagHistory / comments public static final String USER_NAME = "Fortify Remediation Aviator"; + public static final String USER_NAME_LEGACY_FORTIFY_AVIATOR = "Fortify Aviator"; + public static final String USER_NAME_LEGACY_CORE_SAST_AVIATOR = "Core SAST Aviator"; + private static final Set AVIATOR_AUDIT_USERNAMES = Set.of( + USER_NAME.toLowerCase(Locale.ROOT), + USER_NAME_LEGACY_FORTIFY_AVIATOR.toLowerCase(Locale.ROOT), + USER_NAME_LEGACY_CORE_SAST_AVIATOR.toLowerCase(Locale.ROOT) + ); + + public static boolean isAviatorAuditUsername(String username) { + return username != null && AVIATOR_AUDIT_USERNAMES.contains(username.trim().toLowerCase(Locale.ROOT)); + } // Other Constants public static final String AUDIT_NAMESPACE_URI = "xmlns://www.fortify.com/schema/audit"; @@ -59,6 +73,9 @@ public class Constants { public static final String MAX_PER_CATEGORY_EXCEEDED = "Fortify detected {issues_new_in_category} new issues in this (sub)category. Fortify Remediation Aviator auditing was limited to the first {MAX_PER_CATEGORY}."; public static final String MAX_TOTAL_EXCEEDED = "Fortify detected {issues_new_total} new issues. Fortify Remediation Aviator auditing was limited to {MAX_TOTAL} issues in total, while ensuring that representative issues in each category were audited."; + // File size protection — prevent zip bomb and decompression DOS attacks + public static final long MAX_SOURCE_FILE_SIZE = 50L * 1024L * 1024L; // 50 MB + // Operation constants for error messages public static final String OP_CREATE_APP = "application creation"; public static final String OP_ADD_APP_ENTITLEMENT = "application entitlement increment"; diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java index 5a50574d370..09a3526126e 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FileUtil.java @@ -21,6 +21,7 @@ import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.Comparator; +import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.ZipInputStream; @@ -30,6 +31,7 @@ import com.fortify.cli.common.exception.FcliTechnicalException; + public final class FileUtil { private static final Logger LOG = LoggerFactory.getLogger(FileTypeLanguageMapperUtil.class); @@ -130,4 +132,55 @@ public static void writeStringToFile(Path filePath, String content, boolean over throw new FcliTechnicalException("Error writing to file " + absolutePath, e); } } -} \ No newline at end of file + + /** + * Canonical form for file hashing. Normalises line endings to LF and strips a single + * trailing newline. Both the audit side (writing the hash into remediations.xml) and the + * apply side (verifying it) must call this before hashing so the two sides agree + * byte-for-byte regardless of the OS that ran the audit or whether the file had a + * trailing newline on disk. Callers hash the UTF-8 bytes of the returned string. + */ + public static String canonicalizeForHash(String content) { + if (content == null) return ""; + String normalized = content.replace("\r\n", "\n").replace('\r', '\n'); + if (normalized.endsWith("\n")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + public static String stripSyntheticLineMarkers(String content, String fileName) { + return stripSyntheticLineMarkers(content, fileName, "\n"); + } + + /** Same as {@link #stripSyntheticLineMarkers(String, String)}, but joins with a caller-chosen line separator. */ + public static String stripSyntheticLineMarkers(String content, String fileName, String lineSeparator) { + if (content == null || content.isEmpty()) { + return content; + } + String language = FileTypeLanguageMapperUtil.getProgrammingLanguage(getFileExtension(fileName)); + String commentSymbol = LanguageCommentMapperUtil.getProgrammingLanguageComment(language); + String stripped = content; + if (!"Unknown".equals(commentSymbol)) { + String closingToken = commentSymbol.equals("" + : commentSymbol.equals("<%--") ? "--%>" + : null; + Pattern markerPattern = Pattern.compile( + "[ \\t]*" + Pattern.quote(commentSymbol) + " L\\d+" + + (closingToken != null ? "[ \\t]*" + Pattern.quote(closingToken) : "") + + "[ \\t]*$"); + String[] lines = content.split("\\R", -1); + StringBuilder result = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + Matcher matcher = markerPattern.matcher(lines[i]); + result.append(matcher.find() ? lines[i].substring(0, matcher.start()) : lines[i]); + if (i < lines.length - 1) { + result.append(lineSeparator); + } + } + stripped = result.toString(); + } + return stripped; + } + +} diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java index 0cca7e377dc..4b10fcab4b7 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FprHandle.java @@ -153,9 +153,13 @@ public void close() throws IOException { */ public synchronized Map getSourceFileMap() { if (sourceFileMap == null) { - sourceFileMap = Files.exists(zipfs.getPath("/webinspect.xml")) + //03358915/OCTCR11A2429097 fix for audit issue + boolean dastOnly = Files.exists(zipfs.getPath("/webinspect.xml")) + && !Files.exists(zipfs.getPath("/audit.fvdl")); + sourceFileMap = dastOnly ? new ConcurrentHashMap<>() : loadSourceFileMap(); + //03358915/OCTCR11A2429097 fix for audit issue } return sourceFileMap; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java index 5b3ff717364..7c698e8d860 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/FuzzyContextSearcher.java @@ -16,6 +16,8 @@ import java.util.ArrayList; import java.util.List; +import com.fortify.cli.aviator.fpr.remediation.applier.LineRange; + public class FuzzyContextSearcher { /** @@ -27,64 +29,71 @@ public class FuzzyContextSearcher { * @return The line number (0-based) in sourceFile where context starts, or -1 if not found. */ - public static int fuzzySearchContext(List sourceLines, List contextLines, int maxMismatches) throws IOException { + public static List fuzzySearchContextMatches(List sourceLines, List contextLines, + int maxMismatches) throws IOException { List normalizedSource = normalizeLines(sourceLines); List normalizedContext = normalizeLines(contextLines); + List matches = new ArrayList<>(); + boolean contextStartsWithBlank = !normalizedContext.isEmpty() && normalizedContext.get(0).isEmpty(); for (int i = 0; i < normalizedSource.size(); i++) { - int mismatchCount = 0; - int sourceIndex = i; - int contextIndex = 0; - boolean similar = false; + if (isUnusableContextStart(normalizedSource, i, contextStartsWithBlank)) { + continue; + } + Integer matchStart = findContextMatchStart(normalizedSource, normalizedContext, maxMismatches, i); + if (matchStart != null && !matches.contains(matchStart)) { + matches.add(matchStart); + } + } - while (contextIndex < normalizedContext.size() && sourceIndex < normalizedSource.size()) { - String contextLine = normalizedContext.get(contextIndex).trim(); + return List.copyOf(matches); + } - if (contextLine.isEmpty()) { - contextIndex++; // Skip empty context lines - continue; - } + private static boolean isUnusableContextStart(List normalizedSource, int index, boolean contextStartsWithBlank) { + boolean sourceStartsWithBlank = normalizedSource.get(index).isEmpty(); + if (!contextStartsWithBlank) { + return sourceStartsWithBlank; + } + return !sourceStartsWithBlank || (index > 0 && normalizedSource.get(index - 1).isEmpty()); + } - // Skip empty source lines too - String sourceLine = normalizedSource.get(sourceIndex).trim(); - while (sourceLine.isEmpty()) { - sourceIndex++; - if (sourceIndex >= normalizedSource.size()) { - break; - } - sourceLine = normalizedSource.get(sourceIndex).trim(); - if(!similar) - i = sourceIndex; - } + private static Integer findContextMatchStart(List normalizedSource, List normalizedContext, + int maxMismatches, int startIndex) { + int mismatchCount = 0; + int sourceIndex = startIndex; + int contextIndex = 0; - if (sourceIndex >= normalizedSource.size()) { - break; // No more source lines to match - } + while (contextIndex < normalizedContext.size() && sourceIndex < normalizedSource.size()) { + String contextLine = normalizedContext.get(contextIndex); + if (contextLine.isEmpty()) { + contextIndex++; + continue; + } - similar = linesSimilar(sourceLine, contextLine); + sourceIndex = skipEmptySourceLines(normalizedSource, sourceIndex); + if (sourceIndex >= normalizedSource.size()) { + break; + } - if (!similar) { - mismatchCount++; - if (mismatchCount > maxMismatches) { - break; - } + if (!linesSimilar(normalizedSource.get(sourceIndex), contextLine)) { + mismatchCount++; + if (mismatchCount > maxMismatches) { + break; } - - sourceIndex++; - contextIndex++; } - if (contextIndex == normalizedContext.size() && mismatchCount <= maxMismatches) { - return i; // Found approximate match starting at i (ignoring blanks) - } + sourceIndex++; + contextIndex++; } - return -1; // Not found + return contextIndex == normalizedContext.size() && mismatchCount <= maxMismatches ? startIndex : null; } - public static int[] fuzzySearchOriginalCode(List sourceLines, List originalCodeLine, int maxMismatches, int startIndex) { + /** Returns every {@code LineRange} match found, so callers can detect ambiguous (multiple-candidate) matches. */ + public static List fuzzySearchOriginalCodeMatches(List sourceLines, List originalCodeLine, int maxMismatches, int startIndex) { List normalizedSource = normalizeLines(sourceLines); List normalizedOriginalCode = normalizeLines(originalCodeLine); + List matches = new ArrayList<>(); for (int i = Math.max(0, startIndex); i < normalizedSource.size(); i++) { if (normalizedSource.get(i).isEmpty()) { @@ -93,11 +102,11 @@ public static int[] fuzzySearchOriginalCode(List sourceLines, List normalizedSource, List normalizedOriginalCode, int maxMismatches, @@ -143,7 +152,7 @@ private static int skipEmptySourceLines(List normalizedSource, int sourc private static List normalizeLines(List lines) { List result = new ArrayList<>(); for (String line : lines) { - result.add(line.trim().replaceAll("\\s+", " ")); + result.add(line.trim().replaceAll("\\s+", " ")); } return result; } diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java index adf83ec46e9..e92f131f91c 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/util/StringUtil.java @@ -23,6 +23,17 @@ public static boolean isEmpty(String test) { return test == null || test.length() == 0; } + public static boolean isPendingReviewValue(String value) { + if (value == null) { + return true; + } + String normalizedValue = value.trim(); + return normalizedValue.isEmpty() + || "Pending Review".equalsIgnoreCase(normalizedValue) + || "Not Set".equalsIgnoreCase(normalizedValue) + || Constants.PENDING_REVIEW.equalsIgnoreCase(normalizedValue); + } + /** * Strips HTML-like tags from a string to clean it up for display. * diff --git a/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto b/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto new file mode 100644 index 00000000000..724d0f470e4 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/main/proto/dast_audit.proto @@ -0,0 +1,136 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "com.fortify.aviator.dastaudit"; + +package dastaudit; + +service DastAuditService { + rpc ProcessDastAuditStream(stream DastAuditClientMessage) returns (stream DastAuditServerMessage); +} + +message DastAuditStreamInitRequest { + string token = 1; + string applicationName = 2; + string streamId = 3; + string requestId = 4; + int32 totalReportedIssues = 5; + int32 totalIssuesToAudit = 6; + optional string fprBuildId = 7; + optional string sscApplicationName = 8; + optional string sscApplicationVersion = 9; +} + +message DastAuditClientMessage { + oneof request_type { + DastAuditStreamInitRequest init = 1; + DastAuditRequest audit = 2; + DastAuditPingRequest ping = 3; + } +} + +message DastAuditServerMessage { + oneof response_type { + DastAuditInitResponse init = 1; + DastAuditResponse audit = 2; + DastAuditErrorResponse error = 3; + DastAuditPongResponse pong = 4; + } +} + +message DastAuditInitResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; + int32 reservedQuota = 6; + int32 exceededCount = 7; + bool unlimitedQuota = 8; + optional string quotaLastUpdated = 9; + optional string nextQuotaUpdateMessage = 10; + optional string reassignedEntitlementId = 11; +} + +message DastAuditRequest { + string requestId = 1; + string streamId = 2; + DastFindingContext finding = 3; +} + +message DastFindingContext { + string issueId = 1; + string checkTypeId = 2; + string engineType = 3; + string vulnerabilityId = 4; + int32 severity = 5; + string name = 6; + string category = 7; + string cweId = 8; + string cweDescription = 9; + string sessionUrl = 10; + repeated string reproStepUrls = 11; + string summary = 12; + string implication = 13; + string execution = 14; + string fix = 15; + string referenceInfo = 16; + repeated DastClassification classifications = 17; + string requestId = 18; + string scheme = 19; + string host = 20; + int32 port = 21; + string attackParamDescriptor = 22; + string rawRequest = 23; + string rawResponse = 24; + repeated DastReproStep reproSteps = 25; +} + +message DastClassification { + string kind = 1; + string value = 2; +} + +message DastReproStep { + string source = 1; + string url = 2; + string postParams = 3; +} + +message DastAuditResponse { + string requestId = 1; + string streamId = 2; + string dastIssueId = 3; + string status = 4; + string statusMessage = 5; + DastAuditDecision decision = 6; +} + +message DastAuditDecision { + bool truePositive = 1; + string confidence = 2; + string reasoning = 3; + string remediationAdvice = 4; + string finalComment = 5; + string tagValue = 6; + string tier = 7; +} + +message DastAuditErrorResponse { + string requestId = 1; + string clientStreamId = 2; + string serverStreamId = 3; + string status = 4; + string statusMessage = 5; +} + +message DastAuditPingRequest { + string streamId = 1; + int64 timestamp = 2; +} + +message DastAuditPongResponse { + string streamId = 1; + int64 serverTimestamp = 2; + int64 clientTimestamp = 3; +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml b/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml index cbd39912eb4..320b96d6d63 100644 --- a/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml +++ b/fcli-core/fcli-aviator-common/src/main/resources/default_tag_mapping.yaml @@ -10,6 +10,15 @@ tag_id: "87f2364f-dcd4-49e6-861d-f8d3f351686b" # suppression_exclusions: # - categories: # - "Privacy Violation" +# Optional sast and dast sections may override tag_id, mapping, or +# suppression_exclusions for each product. Existing flat files remain valid. +# sast: +# suppression_exclusions: +# - categories: +# - "Privacy Violation" +# dast: +# tag_id: "dast-specific-tag-id" +# mapping: ... mapping: tier_1: fp: diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/cli/converter/SourceDecoderConverterTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/cli/converter/SourceDecoderConverterTest.java new file mode 100644 index 00000000000..0ad64fab89d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/cli/converter/SourceDecoderConverterTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.cli.converter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import picocli.CommandLine.TypeConversionException; + +/** + * Only the CLI adapter layer: domain factory exceptions become {@link TypeConversionException}. + * Happy-path token semantics are covered by {@code SourceDecodersTest}. + */ +class SourceDecoderConverterTest { + + private final SourceDecoderConverter converter = new SourceDecoderConverter(); + + @Test + void convert_delegatesHappyPathToFactory() { + assertEquals("FPR", converter.convert("FPR").describe()); + assertEquals("UTF-8", converter.convert("UTF-8").describe()); + } + + @Test + void convert_mapsFactoryFailuresToTypeConversionException() { + assertThrows(TypeConversionException.class, () -> converter.convert(" ")); + assertThrows(TypeConversionException.class, () -> converter.convert("NOT-A-REAL-CHARSET")); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java new file mode 100644 index 00000000000..be89754108e --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/remediations_cache/RemediationsCacheRoundTripTest.java @@ -0,0 +1,294 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.remediations_cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.exception.FcliTechnicalException; + +class RemediationsCacheRoundTripTest { + @TempDir Path tempDir; + + @Test + void writeAndReadRoundTripPreservesOrderAndHashes() throws Exception { + Path fpr1 = tempDir.resolve("one.fpr"); + Path fpr2 = tempDir.resolve("two.fpr"); + Files.writeString(fpr1, "fpr-content-1"); + Files.writeString(fpr2, "fpr-content-2"); + Path zip = tempDir.resolve("cache.zip"); + + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "all", "appVersionId", "10001"), + List.of( + new RemediationsCacheWriter.SSCFpr(fpr1, "123", "2026-07-10T08:00:00Z"), + new RemediationsCacheWriter.SSCFpr(fpr2, "456", "2026-07-11T09:30:00Z"))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + assertEquals(RemediationsCacheConstants.PRODUCT_SSC, reader.getManifest().getProduct()); + assertEquals(2, reader.getOrderedFprPaths().size()); + assertEquals("fpr-content-1", Files.readString(reader.getOrderedFprPaths().get(0))); + assertEquals("fpr-content-2", Files.readString(reader.getOrderedFprPaths().get(1))); + assertEquals("123", reader.getManifest().getEntries().get(0).getSscData().getArtifactId()); + assertEquals("456", reader.getManifest().getEntries().get(1).getSscData().getArtifactId()); + assertEquals(null, reader.getManifest().getEntries().get(0).getFodData()); + } + } + + @Test + void streamAddFprWritesReadableCacheForFoD() throws Exception { + Path zip = tempDir.resolve("direct.zip"); + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_FOD, Map.of("mode", "release"))) { + writer.addFodFpr("rel-1", path -> { + try { + Files.writeString(path, "streamed-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + writer.commit(); + } + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); + assertEquals("streamed-fpr", Files.readString(reader.getOrderedFprPaths().get(0))); + assertEquals("rel-1", reader.getOrderedEntries().get(0).getFodData().getReleaseId()); + } + } + + @Test + void emptySourcesRejected() { + Path zip = tempDir.resolve("empty.zip"); + assertThrows(FcliSimpleException.class, () -> + RemediationsCacheWriter.write(zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of(), List.of())); + } + + @Test + void closeWithoutEntriesDoesNotReplaceExistingDestination() throws Exception { + Path zip = tempDir.resolve("existing.zip"); + Files.writeString(zip, "prior-cache-bytes"); + // open + close with no addFpr: work file discarded; destination untouched. + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + // intentionally empty + } + assertEquals("prior-cache-bytes", Files.readString(zip)); + assertTrue(Files.notExists(zip.resolveSibling("existing.zip.partial"))); + } + + @Test + void closeWithEntriesPublishesAndReplacesDestination() throws Exception { + Path zip = tempDir.resolve("existing.zip"); + Files.writeString(zip, "prior-cache-bytes"); + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + writer.addSscFpr("1", null, path -> { + try { + Files.writeString(path, "new-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + writer.commit(); + } + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + assertEquals("new-fpr", Files.readString(reader.getOrderedFprPaths().get(0))); + } + assertTrue(Files.notExists(zip.resolveSibling("existing.zip.partial"))); + } + + @Test + void failedEntryDoesNotPublishPartialCache() throws Exception { + Path zip = tempDir.resolve("failed.zip"); + Files.writeString(zip, "prior-cache-bytes"); + + assertThrows(FcliTechnicalException.class, () -> { + try (RemediationsCacheWriter writer = RemediationsCacheWriter.create( + zip, RemediationsCacheConstants.PRODUCT_SSC, Map.of("mode", "all"))) { + writer.addSscFpr("1", null, path -> { + try { + Files.writeString(path, "first-fpr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + writer.addSscFpr("2", null, path -> { + throw new RuntimeException("download failed"); + }); + writer.commit(); + } + }); + + assertEquals("prior-cache-bytes", Files.readString(zip)); + assertTrue(Files.notExists(zip.resolveSibling("failed.zip.partial"))); + } + + @Test + void cachedFprCanBeOpenedAsNestedZipFileSystem() throws Exception { + Path fpr = tempDir.resolve("nested.fpr"); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(fpr))) { + zos.putNextEntry(new ZipEntry("audit.fvdl")); + zos.write("".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "1", null))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip); + FprHandle fprHandle = new FprHandle(reader.getOrderedFprPaths().get(0))) { + assertTrue(Files.exists(fprHandle.getPath("/audit.fvdl"))); + } + } + + @Test + void badSha256RejectedOnLazyPathLoad() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "original"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "1", null))); + + Path corruptZip = tempDir.resolve("corrupt.zip"); + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip)); + ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(corruptZip))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + zos.putNextEntry(new ZipEntry(entry.getName())); + if (entry.getName().endsWith(".fpr")) { + zos.write("corrupted".getBytes(StandardCharsets.UTF_8)); + } else { + zis.transferTo(zos); + } + zos.closeEntry(); + } + } + + assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(corruptZip)) { + reader.getOrderedFprPaths(); + } + }); + } + + @Test + void missingManifestRejectedOnLazyManifestLoad() throws Exception { + Path zip = tempDir.resolve("no-manifest.zip"); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) { + zos.putNextEntry(new ZipEntry("fprs/001.fpr")); + zos.write("x".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + FcliSimpleException ex = assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.getManifest(); + } + }); + assertTrue(ex.getMessage().contains("manifest.json")); + } + + @Test + void requireProductRejectsMismatch() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "x"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "1", null))); + + assertThrows(FcliSimpleException.class, () -> { + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + reader.requireProduct(RemediationsCacheConstants.PRODUCT_FOD); + } + }); + } + + @Test + void entryValidateRejectsMissingProductBlockAndDualProduct() { + RemediationsCacheEntry bare = new RemediationsCacheEntry(); + bare.setOrder(1); + bare.setPath("fprs/001.fpr"); + bare.setSha256("abc"); + assertThrows(FcliSimpleException.class, bare::validate); + + RemediationsCacheEntry dual = RemediationsCacheEntry.forSsc( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.SSCData.of("1", null)); + dual.setFodData(RemediationsCacheEntry.FoDData.of("r1")); + assertThrows(FcliSimpleException.class, dual::validate); + + RemediationsCacheEntry ssc = RemediationsCacheEntry.forSsc( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.SSCData.of("1", null)); + ssc.validate(); + } + + @Test + void manifestValidateRejectsProductEntryMismatch() { + RemediationsCacheManifest manifest = new RemediationsCacheManifest(); + manifest.setSchemaVersion(RemediationsCacheConstants.SCHEMA_VERSION); + manifest.setKind(RemediationsCacheConstants.KIND); + manifest.setProduct(RemediationsCacheConstants.PRODUCT_SSC); + manifest.getEntries().add(RemediationsCacheEntry.forFod( + 1, "fprs/001.fpr", "abc", RemediationsCacheEntry.FoDData.of("rel-1"))); + assertThrows(FcliSimpleException.class, manifest::validate); + } + + @Test + void entryPathSanitizesUnsafeIdCharacters() throws Exception { + Path fpr = tempDir.resolve("one.fpr"); + Files.writeString(fpr, "content"); + Path zip = tempDir.resolve("cache.zip"); + RemediationsCacheWriter.write( + zip, + RemediationsCacheConstants.PRODUCT_SSC, + Map.of("mode", "artifact-id"), + List.of(new RemediationsCacheWriter.SSCFpr(fpr, "12/../evil", null))); + + try (RemediationsCacheReader reader = RemediationsCacheReader.open(zip)) { + var resolved = reader.getOrderedResolvedFprs(); + assertEquals(1, resolved.size()); + // Manifest keeps the original id; zip entry path must not contain path separators or "..". + assertEquals("12/../evil", resolved.get(0).entry().getSscData().getArtifactId()); + String entryPath = resolved.get(0).entry().getPath(); + assertTrue(entryPath.startsWith(RemediationsCacheConstants.FPRS_DIR + "/")); + assertTrue(!entryPath.contains("..")); + assertTrue(!entryPath.contains("/evil")); + assertEquals("content", Files.readString(resolved.get(0).fprPath())); + } + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java new file mode 100644 index 00000000000..dc44a2b077c --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorIssueIdFilterUtilsTest.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.exception.FcliSimpleException; + +class AviatorIssueIdFilterUtilsTest { + + @Test + void normalizeTrimsAndDeduplicates() { + assertEquals( + Set.of("ISSUE-1", "ISSUE-2"), + AviatorIssueIdFilterUtils.normalizeIssueIds(List.of(" ISSUE-1 ", "", "ISSUE-2", "ISSUE-1"))); + } + + @Test + void normalizeRejectsOnlyBlankValues() { + assertThrows(FcliSimpleException.class, + () -> AviatorIssueIdFilterUtils.normalizeIssueIds(List.of(" ", "", " "))); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelperTest.java new file mode 100644 index 00000000000..d33f2469d55 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorLocalFprHelperTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.common.exception.FcliSimpleException; + +class AviatorLocalFprHelperTest { + @TempDir private Path tempDir; + + @Test + void testMissingFprThrowsException() { + Path missingFpr = tempDir.resolve("missing.fpr"); + + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of(missingFpr))); + } + + @Test + void testDirectoryFprThrowsException() { + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of(tempDir))); + } + + @Test + void testEmptyFprListThrowsException() { + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of())); + } + + @Test + void testInvalidFprThrowsException() throws Exception { + Path invalidFpr = tempDir.resolve("invalid.fpr"); + Files.writeString(invalidFpr, "not a zip"); + + assertThrows(FcliSimpleException.class, () -> AviatorLocalFprHelper.validateLocalFprs(List.of(invalidFpr))); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java new file mode 100644 index 00000000000..ef53da2d4ca --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/_common/util/AviatorRemediationMetricsHelperTest.java @@ -0,0 +1,181 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator._common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + +class AviatorRemediationMetricsHelperTest { + + @Test + void aggregateFilteredMetricsDeduplicatesIssueIdsAcrossEntries() { + RemediationMetric metricOne = filteredMetric(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + RemediationMetric metricTwo = filteredMetric(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-2"), Set.of("B.java")); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + Set.of("ISSUE-1", "ISSUE-2"), List.of(metricOne, metricTwo)); + + assertTrue(aggregated.isFiltered()); + assertEquals(2, aggregated.totalRemediations()); + assertEquals(2, aggregated.appliedRemediations()); + assertEquals(0, aggregated.skippedRemediations()); + assertEquals(Set.of("ISSUE-1", "ISSUE-2"), aggregated.appliedIssueIds()); + assertEquals(Set.of("A.java", "B.java"), aggregated.modifiedFiles()); + } + + @Test + void aggregateUnfilteredMergesSkippedByReason() { + Map reasonsOne = new LinkedHashMap<>(); + reasonsOne.put("Source file missing", 1); + Map reasonsTwo = new LinkedHashMap<>(); + reasonsTwo.put("Source file missing", 1); + reasonsTwo.put("No file changes found", 1); + RemediationMetric metricOne = unfilteredMetric(2, 1, Set.of("A.java"), reasonsOne); + RemediationMetric metricTwo = unfilteredMetric(1, 0, Set.of(), reasonsTwo); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(metricOne, metricTwo)); + + assertFalse(aggregated.isFiltered()); + assertEquals(3, aggregated.totalRemediations()); + assertEquals(1, aggregated.appliedRemediations()); + assertEquals(2, aggregated.skippedRemediations()); + assertEquals(2, aggregated.skippedByReason().get("Source file missing")); + assertEquals(1, aggregated.skippedByReason().get("No file changes found")); + assertEquals("Source file missing=2, No file changes found=1", + AviatorRemediationMetricsHelper.formatSkippedReasons(aggregated.skippedByReason())); + } + + @Test + void groupIssueIdsByReasonInvertsIssueIdToReasonMap() { + Map issueSkipReasons = new LinkedHashMap<>(); + issueSkipReasons.put("ISSUE-1", "Source file missing"); + issueSkipReasons.put("ISSUE-2", "Source file missing"); + issueSkipReasons.put("ISSUE-3", "No file changes found"); + + Map> grouped = AviatorRemediationMetricsHelper.groupIssueIdsByReason(issueSkipReasons); + + assertEquals(List.of("ISSUE-1", "ISSUE-2"), grouped.get("Source file missing")); + assertEquals(List.of("ISSUE-3"), grouped.get("No file changes found")); + } + + @Test + void skippedByReasonNodeIncludesCountAndIssueIdsPerReason() { + Map skippedByReason = new LinkedHashMap<>(); + skippedByReason.put("Source file missing", 2); + Map issueSkipReasons = new LinkedHashMap<>(); + issueSkipReasons.put("ISSUE-1", "Source file missing"); + issueSkipReasons.put("ISSUE-2", "Source file missing"); + + ObjectNode node = AviatorRemediationMetricsHelper.toSkippedByReasonNode(skippedByReason, issueSkipReasons); + + ObjectNode reasonNode = (ObjectNode) node.get("Source file missing"); + assertEquals(2, reasonNode.get("count").asInt()); + assertEquals(2, reasonNode.get("issueIds").size()); + assertEquals("ISSUE-1", reasonNode.get("issueIds").get(0).asText()); + assertEquals("ISSUE-2", reasonNode.get("issueIds").get(1).asText()); + } + + @Test + void remainingIssueIdsDropsAlreadyApplied() { + RemediationMetric metric = filteredMetric(Set.of("ISSUE-1", "ISSUE-2"), Set.of("ISSUE-1"), Set.of("A.java")); + + assertEquals( + Set.of("ISSUE-2"), + AviatorRemediationMetricsHelper.getRemainingIssueIds(Set.of("ISSUE-1", "ISSUE-2"), metric)); + } + + @Test + void aggregatingAnyPreviewMetricYieldsPreviewResultWithMergedDetails() { + RemediationMetric applied = unfilteredMetric(1, 1, Set.of("A.java"), Map.of()); + RemediationMetric preview = RemediationMetric.builder() + .totalRemediations(1) + .skippedRemediations(1) + .executionMode(RemediationExecutionMode.PREVIEW) + .previewDetails(List.of(PreviewDetail.skipped("ISSUE-2", null))) + .build(); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(applied, preview)); + + assertTrue(aggregated.isPreview()); + assertEquals(1, aggregated.previewDetails().size()); + } + + @Test + void skippedPreviewRunWithNoMetricsStaysPreview() { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(), RemediationExecutionMode.PREVIEW); + + assertTrue(aggregated.isPreview()); + assertEquals(0, aggregated.appliedRemediations()); + assertEquals("No-Remediation-Previewed", AviatorRemediationMetricsHelper.actionLabel(aggregated)); + } + + @Test + void skippedApplyRunWithNoMetricsStaysApply() { + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(), RemediationExecutionMode.APPLY); + + assertFalse(aggregated.isPreview()); + assertEquals("No-Remediation-Applied", AviatorRemediationMetricsHelper.actionLabel(aggregated)); + } + + @Test + void aggregatingOnlyAppliedMetricsYieldsApplyResult() { + RemediationMetric metricOne = unfilteredMetric(1, 1, Set.of("A.java"), Map.of()); + RemediationMetric metricTwo = unfilteredMetric(1, 0, Set.of(), Map.of()); + + RemediationMetric aggregated = AviatorRemediationMetricsHelper.aggregateMetrics( + null, List.of(metricOne, metricTwo)); + + assertFalse(aggregated.isPreview()); + } + + private RemediationMetric filteredMetric(Set requestedIds, Set appliedIds, Set modifiedFiles) { + return RemediationMetric.builder() + .totalRemediations(requestedIds.size()) + .appliedRemediations(appliedIds.size()) + .skippedRemediations(requestedIds.size() - appliedIds.size()) + .requestedIssueIds(requestedIds) + .seenIssueIds(appliedIds) + .satisfiedIssueIds(appliedIds) + .appliedIssueIds(appliedIds) + .modifiedFiles(modifiedFiles) + .build(); + } + + private RemediationMetric unfilteredMetric(int total, int applied, Set modifiedFiles, + Map skippedByReason) { + return RemediationMetric.builder() + .totalRemediations(total) + .appliedRemediations(applied) + .skippedRemediations(total - applied) + .modifiedFiles(modifiedFiles) + .skippedByReason(skippedByReason) + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/AuditFprStatusTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/AuditFprStatusTest.java new file mode 100644 index 00000000000..0dc06bc834f --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/AuditFprStatusTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; + +class AuditFprStatusTest { + + @Test + void determinesAuditStatusForSuccessSkipAndFailureCombinations() { + assertEquals("AUDITED", AuditFPR.determineAuditStatus(3, 0, 3, 3)); + assertEquals("PARTIALLY_AUDITED", AuditFPR.determineAuditStatus(2, 1, 3, 3)); + assertEquals("SKIPPED", AuditFPR.determineAuditStatus(0, 3, 3, 3)); + assertEquals("FAILED", AuditFPR.determineAuditStatus(0, 2, 3, 3)); + assertEquals("FAILED", AuditFPR.determineAuditStatus(0, 3, 3, 2)); + } + + @Test + void countsOnlyExplicitSkippedResponsesAsSkipped() { + AuditResponse skipped = AuditResponse.builder() + .status("SKIPPED") + .auditSkipReason(AuditSkipReason.SOURCE_FILE_DECODE_FAILED) + .statusMessage("The source decoder wording can change") + .build(); + AuditResponse failed = AuditResponse.builder() + .status("FAILED") + .statusMessage("backend error") + .build(); + AuditResponse success = AuditResponse.builder().status("SUCCESS").build(); + + assertEquals(Map.of("Source file decode failed", 1), + AuditFPR.getSkippedAuditReasons(Map.of( + "skipped", skipped, + "failed", failed, + "success", success), 3)); + } + + @Test + void preservesMissingResponseAccountingForFilteredIssues() { + AuditResponse success = AuditResponse.builder().status("SUCCESS").build(); + + assertEquals(Map.of("No audit response received", 1), + AuditFPR.getSkippedAuditReasons(Map.of("success", success), 2)); + } + + @Test + void countsOnlyResponsesOriginatingFromAviatorAsSubmitted() { + AuditResponse localSkip = AuditResponse.builder().status("SKIPPED").build(); + AuditResponse localFailure = AuditResponse.builder() + .status("FAILED") + .statusMessage("Request validation failed") + .build(); + AuditResponse serverSkip = AuditResponse.builder() + .status("SKIPPED") + .submittedToAviator(true) + .build(); + AuditResponse serverFailure = AuditResponse.builder() + .status("FAILED") + .statusMessage("Aviator processing failed") + .submittedToAviator(true) + .build(); + + assertEquals(2, AuditFPR.getSubmittedAuditCount(Map.of( + "local", localSkip, + "localFailure", localFailure, + "server", serverSkip, + "serverFailure", serverFailure))); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java new file mode 100644 index 00000000000..424612fb8b9 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditDecisionMapperTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.util.Constants; + +class DastAuditDecisionMapperTest { + @Test + void serverGoldTierControlsFalsePositiveMappingRegardlessOfConfidence() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .confidence("UNKNOWN") + .reasoning("reason") + .finalComment("comment") + .tagValue("bad") + .tier(AuditTier.GOLD) + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("GOLD", response.getTier()); + assertEquals(Constants.AVIATOR_NOT_AN_ISSUE, response.getAviatorPredictionTag()); + } + + @Test + void missingServerTierDefaultsToSilverRegardlessOfConfidence() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .confidence("HIGH") + .reasoning("reason") + .finalComment("comment") + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("SILVER", response.getTier()); + assertEquals(Constants.AVIATOR_LIKELY_FP, response.getAviatorPredictionTag()); + } + + @Test + void missingDomainTierDefaultsToSilver() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("SILVER", response.getTier()); + assertEquals(Constants.AVIATOR_LIKELY_FP, response.getAviatorPredictionTag()); + } + + @Test + void serverSilverTierControlsTruePositivePrediction() { + var result = DastAuditResult.Success.builder() + .issueId("DAST-1") + .truePositive(true) + .confidence("HIGH") + .tier(AuditTier.SILVER) + .build(); + + var response = DastAuditDecisionMapper.toAuditResponse(result); + + assertEquals("SILVER", response.getTier()); + assertEquals(Constants.AVIATOR_LIKELY_TP, response.getAviatorPredictionTag()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java new file mode 100644 index 00000000000..5349cd79774 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/DastAuditFPRTest.java @@ -0,0 +1,294 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.audit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator._common.config.AviatorConfigManager; +import com.fortify.cli.aviator._common.exception.AviatorTechnicalException; +import com.fortify.cli.aviator.audit.model.AuditTier; +import com.fortify.cli.aviator.config.TagMappingConfig; +import com.fortify.cli.aviator.grpc.DastAuditResult; +import com.fortify.cli.aviator.grpc.DastAuditStreamConfig; +import com.fortify.cli.aviator.grpc.DastAuditStreamResult; +import com.fortify.cli.aviator.util.FprHandle; +import com.fortify.cli.aviator.util.ResourceUtil; + +class DastAuditFPRTest { + @TempDir Path tempDir; + + @Test + void auditsEligibleFindingAndWritesConservativeXml() throws Exception { + Path fpr = createFpr(); + var config = streamConfig(); + + DastAuditFprResult result; + try (FprHandle handle = new FprHandle(fpr)) { + result = DastAuditFPR.audit(handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult(false, "HIGH", AuditTier.GOLD))) + .reservedQuota(1) + .build())); + } + + assertEquals(DastAuditFprStatus.AUDITED, result.status()); + assertEquals(1, result.falsePositivesSuppressed()); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("instanceId=\"DAST-1\"")); + assertTrue(auditXml.contains("suppressed=\"true\"")); + assertTrue(auditXml.contains("PROCESSED_BY_AVIATOR")); + assertFalse(Files.exists(zip.getPath("/remediations.xml"))); + } + } + + @Test + void customTagMappingControlsFinalTagAndSuppression() throws Exception { + Path fpr = createFpr(); + Path tagMapping = tempDir.resolve("dast-tag-mapping.yaml"); + Files.writeString(tagMapping, """ + tag_id: "custom-analysis-tag" + mapping: + tier_1: + fp: { value: "Confirmed FP", suppress: false } + tp: { value: "Confirmed TP", suppress: false } + unsure: { suppress: false } + tier_2: + fp: { value: "Review FP", suppress: true } + tp: { value: "Review TP", suppress: false } + unsure: { suppress: false } + """); + var config = streamConfig(); + + DastAuditFprResult result; + try (FprHandle handle = new FprHandle(fpr)) { + result = DastAuditFPR.audit(handle, config, + ResourceUtil.loadYamlFile(tagMapping.toFile(), TagMappingConfig.class), + (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult(false, "MEDIUM", AuditTier.SILVER))) + .reservedQuota(1) + .build())); + } + + assertEquals(1, result.falsePositivesSuppressed()); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("suppressed=\"true\"")); + assertTrue(auditXml.contains("id=\"custom-analysis-tag\"")); + assertTrue(auditXml.contains(">Review FP<")); + } + } + + @Test + void writesOnlyIssuesUpdatedByCurrentAudit() throws Exception { + Path fpr = createFpr(); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + Files.writeString(zip.getPath("/audit.xml"), """ + + + + + """); + } + var config = streamConfig(); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFPR.audit(handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult(true, "HIGH", AuditTier.GOLD))) + .reservedQuota(1) + .build())); + } + + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + String auditXml = Files.readString(zip.getPath("/audit.xml")); + assertTrue(auditXml.contains("instanceId=\"DAST-1\"")); + assertFalse(auditXml.contains("instanceId=\"DAST-2\"")); + } + } + + @Test + void missingTerminalResponseIsCountedAsFailure() throws Exception { + Path fpr = createFpr(); + var config = streamConfig(); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFprResult result = DastAuditFPR.audit( + handle, config, defaultTagMapping(), (ignoredConfig, items, total) -> + CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of()) + .reservedQuota(1) + .build())); + + assertEquals(DastAuditFprStatus.FAILED, result.status()); + assertEquals(1, result.failed()); + } + } + + @Test + void excludesSuppressedAviatorProcessedAndHumanAuditedFindings() throws Exception { + Path fpr = createEligibilityFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFprResult result = DastAuditFPR.audit( + handle, streamConfig(), defaultTagMapping(), (ignoredConfig, items, total) -> { + assertEquals(List.of("DAST-5"), items.stream().map(item -> item.issue().getId()).toList()); + return CompletableFuture.completedFuture(DastAuditStreamResult.builder() + .results(List.of(successResult("DAST-5", true, "HIGH", AuditTier.GOLD))) + .reservedQuota(1) + .build()); + }); + + assertEquals(5, result.totalReported()); + assertEquals(1, result.submitted()); + assertEquals(4, result.skipped()); + } + } + + @Test + void allExcludedFindingsReturnSkippedWithoutStartingStream() throws Exception { + Path fpr = createFpr(); + try (FileSystem zip = FileSystems.newFileSystem(fpr)) { + Files.writeString(zip.getPath("/audit.xml"), """ + + + + """); + } + + try (FprHandle handle = new FprHandle(fpr)) { + DastAuditFprResult result = DastAuditFPR.audit( + handle, streamConfig(), defaultTagMapping(), (ignoredConfig, items, total) -> { + throw new AssertionError("Stream must not start when all findings are excluded"); + }); + + assertEquals(DastAuditFprStatus.SKIPPED, result.status()); + assertEquals(1, result.totalReported()); + assertEquals(1, result.skipped()); + assertEquals(0, result.submitted()); + } + } + + @Test + void missingStreamFutureProducesTechnicalError() throws Exception { + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + AviatorTechnicalException exception = assertThrows(AviatorTechnicalException.class, + () -> DastAuditFPR.audit(handle, streamConfig(), defaultTagMapping(), + (ignoredConfig, items, total) -> null)); + + assertEquals("DAST audit stream did not return a completion future", exception.getMessage()); + } + } + + @Test + void missingStreamResultProducesTechnicalError() throws Exception { + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + AviatorTechnicalException exception = assertThrows(AviatorTechnicalException.class, + () -> DastAuditFPR.audit(handle, streamConfig(), defaultTagMapping(), + (ignoredConfig, items, total) -> CompletableFuture.completedFuture(null))); + + assertEquals("DAST audit stream completed without a result", exception.getMessage()); + } + } + + private DastAuditResult.Success successResult(boolean truePositive, String confidence, AuditTier tier) { + return successResult("DAST-1", truePositive, confidence, tier); + } + + private DastAuditResult.Success successResult( + String issueId, boolean truePositive, String confidence, AuditTier tier) { + return DastAuditResult.Success.builder() + .issueId(issueId) + .truePositive(truePositive) + .confidence(confidence) + .tier(tier) + .reasoning("reason") + .finalComment("comment") + .build(); + } + + private TagMappingConfig defaultTagMapping() { + return AviatorConfigManager.getInstance().getDefaultDastTagMappingConfig(); + } + + private DastAuditStreamConfig streamConfig() { + return DastAuditStreamConfig.builder() + .token("token") + .applicationName("app") + .sscApplicationName("ssc") + .sscApplicationVersion("1") + .build(); + } + + private Path createFpr() throws Exception { + Path fpr = tempDir.resolve("dast.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), """ + https://example.test + SQL Injection4 + + """, StandardCharsets.UTF_8); + } + return fpr; + } + + private Path createEligibilityFpr() throws Exception { + Path fpr = tempDir.resolve("dast-eligibility.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), """ + https://example.test + Suppressed4 + Aviator status4 + Legacy Aviator outcome4 + Human audited4 + Eligible4 + + """, StandardCharsets.UTF_8); + Files.writeString(zip.getPath("/audit.xml"), """ + + + + PROCESSED_BY_AVIATOR + + + Not an Issue + + + Exploitable + + + """, StandardCharsets.UTF_8); + } + return fpr; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java index 023d2d5bc5c..cf6184971c3 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/audit/IssueAuditorTest.java @@ -25,6 +25,8 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -33,19 +35,31 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.fortify.cli.aviator.audit.model.AuditFprOptions; import com.fortify.cli.aviator.audit.model.FilterSelection; +import com.fortify.cli.aviator.audit.model.UserPrompt; import com.fortify.cli.aviator.config.IAviatorLogger; import com.fortify.cli.aviator.fpr.Vulnerability; import com.fortify.cli.aviator.fpr.filter.Filter; import com.fortify.cli.aviator.fpr.filter.FilterSet; import com.fortify.cli.aviator.fpr.filter.FilterTemplate; +import com.fortify.cli.aviator.fpr.model.AuditIssue; import com.fortify.cli.aviator.fpr.model.FPRInfo; import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.util.Constants; import com.fortify.cli.aviator.util.FprHandle; class IssueAuditorTest { + private static final String TEST_ISSUE_ID = "PROCESSED_ISSUE"; + private static final IAviatorLogger NO_OP_LOGGER = new IAviatorLogger() { + @Override public void progress(String format, Object... args) {} + @Override public void info(String format, Object... args) {} + @Override public void warn(String format, Object... args) {} + @Override public void error(String format, Object... args) {} + }; + private Path tempFprFile; private FprHandle fprHandle; @@ -113,6 +127,220 @@ void testFprInfoMissingBuildIdDefaultsToEmptyString() throws Exception { assertEquals("", fprInfo.getBuildId()); } + @Test + void skipsPreviouslyProcessedAviatorIssueWithoutForceReaudit() throws Exception { + IssueAuditor auditor = createIssueAuditor(false, false, + Map.of(Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR)); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesProcessedAviatorIssueAndIgnoresAviatorOutcomeTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.AVIATOR_EXPECTED_OUTCOME_TAG_ID, Constants.EXPLOITABLE)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditIncludesProcessedAviatorIssueWithAnalysisTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditStillSkipsSuppressedIssue() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, true, + Map.of(Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR)); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditStillSkipsHumanTriagedIssue() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.FOD_TAG_ID, Constants.EXPLOITABLE)); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesIssueWithPendingReviewState() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.FOD_TAG_ID, "Pending Review", + Constants.AUDITOR_STATUS_TAG_ID, Constants.PENDING_REVIEW)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void treatsAllPendingAnalysisValuesAsUnaudited() throws Exception { + for (String pendingValue : List.of("Pending Review", "Not Set", "Pending Review/Not Set", " pending review ")) { + IssueAuditor auditor = createIssueAuditor(false, false, Map.of(Constants.ANALYSIS_TAG_ID, pendingValue)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor), pendingValue); + } + } + + @Test + void forceReauditStillSkipsIssueWithManualAuditorStatus() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.AUDITOR_STATUS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.AUDITOR_STATUS_TAG_ID, "analyst.user")); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesAviatorWrittenAuditorStatus() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.AUDITOR_STATUS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.AUDITOR_STATUS_TAG_ID, Constants.USER_NAME)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditSkipsHumanAnalysisOverrideAfterAviator() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, "analyst.user")); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesLegacyFortifyAviatorUsername() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, Map.of( + Constants.AVIATOR_STATUS_TAG_ID, Constants.PROCESSED_BY_AVIATOR, + Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditIncludesLegacyAnalysisTagWrittenByAviatorWithoutStatusTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of(Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR)); + + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } + + @Test + void forceReauditSkipsLegacyAnalysisTagWrittenByHumanWithoutStatusTag() throws Exception { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of(Constants.ANALYSIS_TAG_ID, Constants.EXPLOITABLE), + Map.of(Constants.ANALYSIS_TAG_ID, "analyst.user")); + + assertTrue(prepareIssueIds(auditor).isEmpty()); + } + + @Test + void forceReauditIncludesMappedTagWrittenByAviatorWithoutStatusTag() throws Exception { + Path mappingFile = writeMappedTagFile(); + try { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Constants.EXPLOITABLE), + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Constants.USER_NAME), + mappingFile.toString()); + assertEquals(List.of(TEST_ISSUE_ID), prepareIssueIds(auditor)); + } finally { + Files.deleteIfExists(mappingFile); + } + } + + @Test + void forceReauditSkipsHumanWriterOnMappedTag() throws Exception { + Path mappingFile = writeMappedTagFile(); + try { + IssueAuditor auditor = createIssueAuditor(true, false, + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", Constants.EXPLOITABLE), + Map.of("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "analyst.user"), + mappingFile.toString()); + assertTrue(prepareIssueIds(auditor).isEmpty()); + } finally { + Files.deleteIfExists(mappingFile); + } + } + + private Path writeMappedTagFile() throws IOException { + Path mappingFile = Files.createTempFile("tag-mapping", ".yaml"); + Files.writeString(mappingFile, "tag_id: \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"\n"); + return mappingFile; + } + + private IssueAuditor createIssueAuditor(boolean forceReaudit, boolean suppressed, Map tags) { + return createIssueAuditor(forceReaudit, suppressed, tags, Map.of(), null); + } + + private IssueAuditor createIssueAuditor(boolean forceReaudit, boolean suppressed, Map tags, + Map lastTagUsernames) { + return createIssueAuditor(forceReaudit, suppressed, tags, lastTagUsernames, null); + } + + private IssueAuditor createIssueAuditor(boolean forceReaudit, boolean suppressed, Map tags, + Map lastTagUsernames, String tagMappingPath) { + FPRInfo fprInfo = new FPRInfo(fprHandle); + FilterTemplate filterTemplate = new FilterTemplate(); + filterTemplate.setTagDefinitions(new ArrayList<>()); + fprInfo.setFilterTemplate(filterTemplate); + + Vulnerability vulnerability = new Vulnerability(); + vulnerability.setInstanceID(TEST_ISSUE_ID); + AuditIssue auditIssue = AuditIssue.builder() + .instanceId(TEST_ISSUE_ID) + .suppressed(suppressed) + .tags(new HashMap<>(tags)) + .lastTagUsernames(new HashMap<>(lastTagUsernames)) + .build(); + + return IssueAuditor.builder() + .vulnerabilities(List.of(vulnerability)) + .auditIssueMap(Map.of(TEST_ISSUE_ID, auditIssue)) + .fprInfo(fprInfo) + .filterSelection(new FilterSelection(null, null)) + .sourceLanguageResolver(new SourceLanguageResolver(new FVDLMetadata())) + .options(auditOptions(forceReaudit, NO_OP_LOGGER, tagMappingPath)) + .build(); + } + + private AuditFprOptions auditOptions(boolean forceReaudit, IAviatorLogger logger) { + return auditOptions(forceReaudit, logger, null); + } + + private AuditFprOptions auditOptions(boolean forceReaudit, IAviatorLogger logger, String tagMappingPath) { + return AuditFprOptions.builder() + .fprHandle(fprHandle) + .logger(logger) + .sscAppName("TestApp") + .sscAppVersion("1.0") + .tagMappingPath(tagMappingPath) + .forceReaudit(forceReaudit) + .build(); + } + + @SuppressWarnings("unchecked") + private List prepareIssueIds(IssueAuditor auditor) throws Exception { + Method prepareMethod = IssueAuditor.class.getDeclaredMethod("prepareAndFilterPrompts"); + prepareMethod.setAccessible(true); + ConcurrentLinkedDeque prompts = (ConcurrentLinkedDeque) prepareMethod.invoke(auditor); + return prompts.stream() + .map(prompt -> prompt.getIssueData().getInstanceID()) + .collect(Collectors.toList()); + } + @Test void testFilterVulnerabilities_LegacySyntaxWithSpaces() throws Exception { @@ -159,11 +387,14 @@ void testFilterVulnerabilities_LegacySyntaxWithSpaces() throws Exception { List inputList = Arrays.asList(targetVuln, hiddenVuln); - IssueAuditor auditor = new IssueAuditor( - inputList, null, new HashMap<>(), fprInfo, - "TestApp", "1.0", selection, dummyLogger, null, - new SourceLanguageResolver(new FVDLMetadata()) - ); + IssueAuditor auditor = IssueAuditor.builder() + .vulnerabilities(inputList) + .auditIssueMap(new HashMap<>()) + .fprInfo(fprInfo) + .filterSelection(selection) + .sourceLanguageResolver(new SourceLanguageResolver(new FVDLMetadata())) + .options(auditOptions(false, dummyLogger)) + .build(); Method filterMethod = IssueAuditor.class.getDeclaredMethod("filterVulnerabilities", List.class, FilterSet.class); filterMethod.setAccessible(true); @@ -172,8 +403,8 @@ void testFilterVulnerabilities_LegacySyntaxWithSpaces() throws Exception { List results = (List) filterMethod.invoke(auditor, inputList, filterSet); List remainingIds = results.stream() - .map(Vulnerability::getInstanceID) - .collect(Collectors.toList()); + .map(Vulnerability::getInstanceID) + .collect(Collectors.toList()); assertEquals(1, remainingIds.size(), "Should verify that exactly one issue remains"); assertTrue(remainingIds.contains("TARGET_ISSUE"), diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java index 43aeb69ab9b..f68cd65f334 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/config/TagMappingConfigTest.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -135,6 +136,100 @@ void testLoadYamlFileBindsSuppressionExclusionsAcrossEntries() throws Exception assertTrue(config.isSuppressionExcluded(new TagMappingConfig.SuppressionExclusionContext("privacy violation"))); } + @Test + void testCombinedYamlResolvesProductOverridesAndSharedDefaults() throws Exception { + Path yamlFile = tempDir.resolve("combined-tag-mapping.yaml"); + Files.writeString(yamlFile, """ + tag_id: "shared-tag" + mapping: + tier_1: + fp: { value: "Shared FP", suppress: true } + tp: { value: "Shared TP", suppress: false } + unsure: { suppress: false } + tier_2: + fp: { value: "Shared FP", suppress: false } + tp: { value: "Shared TP", suppress: false } + unsure: { suppress: false } + sast: + suppression_exclusions: + - categories: ["Privacy Violation"] + dast: + tag_id: "dast-tag" + mapping: + tier_1: + fp: { value: "DAST FP", suppress: true } + tp: { value: "DAST TP", suppress: false } + unsure: { suppress: false } + tier_2: + fp: { value: "DAST FP", suppress: false } + tp: { value: "DAST TP", suppress: false } + unsure: { suppress: false } + """); + + TagMappingConfig config = ResourceUtil.loadYamlFile(yamlFile.toFile(), TagMappingConfig.class); + TagMappingConfig sastConfig = config.resolveForSast(); + TagMappingConfig dastConfig = config.resolveForDast(); + + assertEquals("shared-tag", sastConfig.getTag_id()); + assertEquals(Set.of("Shared FP", "Shared TP"), sastConfig.getMappedValues()); + assertTrue(sastConfig.isSuppressionExcluded( + new TagMappingConfig.SuppressionExclusionContext("Privacy Violation"))); + assertEquals("dast-tag", dastConfig.getTag_id()); + assertEquals(Set.of("DAST FP", "DAST TP"), dastConfig.getMappedValues()); + assertFalse(dastConfig.hasSuppressionExclusions()); + } + + @Test + void testLegacyFlatConfigResolvesForBothProducts() { + TagMappingConfig config = createValidConfig(); + + assertEquals(config.getMapping(), config.resolveForSast().getMapping()); + assertEquals(config.getMapping(), config.resolveForDast().getMapping()); + } + + @Test + void testNullSuppressionExclusionsResolveAsEmpty() throws Exception { + Path yamlFile = tempDir.resolve("null-exclusions-tag-mapping.yaml"); + Files.writeString(yamlFile, """ + suppression_exclusions: null + mapping: + tier_1: + fp: { suppress: true } + tp: { suppress: false } + unsure: { suppress: false } + tier_2: + fp: { suppress: false } + tp: { suppress: false } + unsure: { suppress: false } + """); + + TagMappingConfig config = ResourceUtil.loadYamlFile(yamlFile.toFile(), TagMappingConfig.class); + + assertFalse(config.resolveForSast().hasSuppressionExclusions()); + assertFalse(config.resolveForDast().hasSuppressionExclusions()); + } + + @Test + void testResolvesResultsAndMappedValues() { + TagMappingConfig config = createValidConfig(); + + assertTrue(config.getResult(true, TagMappingConfig.ResultType.FP).getSuppress()); + assertFalse(config.getResult(false, TagMappingConfig.ResultType.FP).getSuppress()); + assertEquals(Set.of("Not an Issue", "Exploitable"), config.getMappedValues()); + } + + @Test + void testDastValidationRejectsSuppressionExclusions() { + TagMappingConfig config = createValidConfig(); + config.setSuppression_exclusions(new ArrayList<>(List.of(createSuppressionExclusion("Privacy Violation")))); + + AviatorSimpleException exception = assertThrows(AviatorSimpleException.class, config::validateForDast); + + assertEquals( + "Invalid DAST tag mapping configuration: suppression_exclusions are not supported", + exception.getMessage()); + } + private TagMappingConfig createValidConfig() { TagMappingConfig config = new TagMappingConfig(); TagMappingConfig.Mapping mapping = new TagMappingConfig.Mapping(); diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java new file mode 100644 index 00000000000..582b6641154 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/dast/StreamingWebInspectParserTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.dast; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.util.FprHandle; + +class StreamingWebInspectParserTest { + @TempDir + Path tempDir; + + @Test + void parseSessionsPreservesCompleteAuditContext() throws Exception { + Path fpr = createFpr(); + + try (FprHandle handle = new FprHandle(fpr)) { + var sessions = new StreamingWebInspectParser(handle).parseSessions(); + + assertEquals(1, sessions.size()); + DastSession session = sessions.get(0); + assertEquals("POST /login HTTP/1.1", session.getRawRequest()); + assertEquals("HTTP/1.1 200 OK", session.getRawResponse()); + assertEquals("parameter=username", session.getAttackParamDescriptor()); + + DastIssue issue = session.getIssues().get(0); + assertEquals("Injection", issue.getCategory()); + assertEquals("Improper Neutralization", issue.getCweDescription()); + assertEquals("Summary text", issue.getSummary()); + assertEquals("Fix text", issue.getFix()); + assertEquals(2, issue.getReproSteps().size()); + assertEquals("Macro", issue.getReproSteps().get(0).getSource()); + assertEquals("Attack", issue.getReproSteps().get(1).getSource()); + assertEquals("username=test%27", issue.getReproSteps().get(1).getPostParams()); + assertEquals(issue.getReproSteps().stream().map(DastReproStep::getUrl).toList(), issue.getReproStepUrls()); + } + } + + private Path createFpr() throws Exception { + Path fpr = tempDir.resolve("dast.fpr"); + try (FileSystem zip = FileSystems.newFileSystem(fpr, Map.of("create", "true"))) { + Files.writeString(zip.getPath("/webinspect.xml"), webInspectXml(), StandardCharsets.UTF_8); + } + return fpr; + } + + private String webInspectXml() { + String request = Base64.getEncoder().encodeToString("POST /login HTTP/1.1".getBytes(StandardCharsets.UTF_8)); + String response = Base64.getEncoder().encodeToString("HTTP/1.1 200 OK".getBytes(StandardCharsets.UTF_8)); + return """ + + + https://example.test/login + httpsexample.test443 + parameter=username + %s%s + + 1001WebInspect + WI-10014SQL Injection + + Injection + Improper Neutralization + + + Macrohttps://example.test/login + Attackhttps://example.test/login?user=test%%27 + username=test%%27 + + SummarySummary text

    ]]>
    + FixFix text

    ]]>
    +
    +
    +
    + """.formatted(request, response); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java new file mode 100644 index 00000000000..50fb7bdf946 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorConnectionDiagnosticsTest.java @@ -0,0 +1,265 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.UnknownHostException; + +import javax.net.ssl.SSLHandshakeException; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; +import com.fortify.cli.aviator.diagnose.support.ConfigurableDiagnosticProbe; +import com.fortify.cli.aviator.diagnose.support.OfflineConnectionPlan; + +class AviatorConnectionDiagnosticsTest { + @Test + void shouldSkipDependentStagesWhenEndpointIsInvalid() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose(" ", 5, "url"); + var results = report.stages(); + + assertEquals(5, results.size()); + assertStage(results.get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals("Endpoint is invalid", results.get(0).summary()); + assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.WARN); + assertTrue(results.get(1).summary().contains("endpoint validation failed")); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldFailEndpointForUnsupportedUrlScheme() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose("mttp://vacant", 5, "url"); + var results = report.stages(); + + assertEquals(5, results.size()); + assertStage(results.get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, results.get(0).summary()); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_GUIDANCE, results.get(0).guidance()); + assertEquals("mttp", results.get(0).evidence().path("scheme").asText()); + assertEquals("mttp://vacant", results.get(0).evidence().path("providedUrl").asText()); + assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.WARN); + assertEquals("Skipped because endpoint validation failed", results.get(1).summary()); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + assertFalse(probe.grpcCalled); + } + + @Test + void shouldFailEndpointForFtpSchemeEvenWithValidLookingHost() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe) + .diagnose("ftp://aviator-qa01.example.com", 5, "url"); + + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, report.stages().get(0).summary()); + assertEquals("ftp", report.stages().get(0).evidence().path("scheme").asText()); + assertTrue(report.hasRequiredFailure()); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldFailEndpointForHttpScheme() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose("http://aviator.invalid", 5, "url"); + + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.FAIL); + assertEquals(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY, report.stages().get(0).summary()); + assertEquals("http", report.stages().get(0).evidence().path("scheme").asText()); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldSkipTransportStagesWhenDnsFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.resolveException = new UnknownHostException("host not found"); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + var results = report.stages(); + + assertEquals(5, results.size()); + assertStage(results.get(1), AviatorDiagnosticStage.DNS, AviatorDiagnosticStatus.FAIL); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.WARN); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.tunnelCalled); + assertFalse(probe.grpcCalled); + } + + @Test + void shouldReportGrpcNoResponsePattern() { + var probe = new ConfigurableDiagnosticProbe( + AviatorGrpcReachabilityResult.noResponse("DEADLINE_EXCEEDED", "deadline exceeded")); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + var results = report.stages(); + + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.PASS); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.FAIL); + assertEquals(AviatorGrpcPattern.GRPC_NO_RESPONSE.wireId(), + results.get(4).evidence().path("pattern").asText()); + assertFalse(report.hasGrpcStagePass()); + } + + @Test + void shouldContinueAfterTlsAlpnWarningWhenGrpcResponds() { + var probe = new ConfigurableDiagnosticProbe( + AviatorGrpcReachabilityResult.responseReceived("UNAUTHENTICATED", "token required")); + probe.tunnelResult = new AviatorTunnelResult.TlsSucceeded(false, "not-used", + "TLSv1.3", "TLS_AES_128_GCM_SHA256", "CN=aviator.invalid", ""); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + var results = report.stages(); + + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertTrue(report.hasGrpcStagePass()); + } + + @Test + void shouldReportNonGrpcHttpResponsePattern() { + var probe = new ConfigurableDiagnosticProbe(AviatorGrpcReachabilityResult.nonGrpcHttp( + "UNAVAILABLE", "503", "text/html", "HTTP status code 503 invalid content-type: text/html")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy(), 5, "url").stages(); + + assertEquals(AviatorGrpcPattern.HTTP_RESPONSE_NOT_GRPC.wireId(), + results.get(4).evidence().path("pattern").asText()); + assertEquals("503", results.get(4).evidence().path("httpStatusCode").asText()); + } + + @Test + void shouldPassAllTransportStagesWithoutProxy() { + var probe = new ConfigurableDiagnosticProbe(); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + + assertEquals(5, report.stages().size()); + assertStage(report.stages().get(0), AviatorDiagnosticStage.ENDPOINT, AviatorDiagnosticStatus.PASS); + assertStage(report.stages().get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertTrue(probe.tunnelCalled); + assertFalse(report.hasRequiredFailure()); + } + + @Test + void shouldSkipTlsAndGrpcWhenTcpFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.connectException = new IOException("Connection refused"); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy("127.0.0.1", 1), 3, "url").stages(); + + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.FAIL); + assertEquals("aviator", results.get(2).evidence().path("nextHopType").asText()); + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldSkipGrpcWhenTlsFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.TlsFailed(false, "not-used", AviatorTlsPhase.HANDSHAKE, + new SSLHandshakeException("PKIX path building failed")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy(), 5, "url").stages(); + + assertStage(results.get(3), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.FAIL); + assertEquals(AviatorTlsPhase.HANDSHAKE.id(), results.get(3).evidence().path("tlsPhase").asText()); + assertStage(results.get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.grpcCalled); + } + + @Test + void shouldLabelConnectPhaseWhenOpenFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.TlsFailed(false, "not-used", AviatorTlsPhase.CONNECT, + new IOException("Connection refused")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.noProxy(), 5, "url").stages(); + + assertEquals(AviatorTlsPhase.CONNECT.id(), results.get(3).evidence().path("tlsPhase").asText()); + assertTrue(results.get(3).summary().toLowerCase().contains("connection")); + } + + @Test + void shouldIncludeProxyStageFromSingleTunnelSession() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.TlsSucceeded(true, "HTTP/1.1 200 Connection established", + "TLSv1.3", "TLS_AES_128_GCM_SHA256", "CN=aviator.invalid", "h2"); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.withProxy(), 5, "url").stages(); + + assertEquals(6, results.size()); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.PASS); + assertEquals("proxy", results.get(2).evidence().path("nextHopType").asText()); + assertStage(results.get(3), AviatorDiagnosticStage.PROXY, AviatorDiagnosticStatus.PASS); + assertStage(results.get(4), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.PASS); + assertStage(results.get(5), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertEquals(1, probe.tunnelCallCount); + } + + @Test + void shouldSkipTlsAndGrpcWhenProxyConnectFailsInTunnel() { + var probe = new ConfigurableDiagnosticProbe(); + probe.tunnelResult = new AviatorTunnelResult.ProxyConnectFailed( + new AviatorProxyConnectException("Proxy CONNECT failed: HTTP/1.1 403")); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.withProxy(), 5, "url").stages(); + + assertEquals(6, results.size()); + assertStage(results.get(3), AviatorDiagnosticStage.PROXY, AviatorDiagnosticStatus.FAIL); + assertStage(results.get(4), AviatorDiagnosticStage.TLS, AviatorDiagnosticStatus.WARN); + assertStage(results.get(5), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.WARN); + assertFalse(probe.grpcCalled); + assertEquals(1, probe.tunnelCallCount); + } + + @Test + void shouldSkipProxyWhenTcpToProxyFails() { + var probe = new ConfigurableDiagnosticProbe(); + probe.connectException = new IOException("Connection refused"); + var results = new AviatorConnectionDiagnostics(probe) + .diagnose(OfflineConnectionPlan.withProxy(), 3, "url").stages(); + + assertEquals(6, results.size()); + assertStage(results.get(2), AviatorDiagnosticStage.TCP, AviatorDiagnosticStatus.FAIL); + assertEquals("proxy", results.get(2).evidence().path("nextHopType").asText()); + assertStage(results.get(3), AviatorDiagnosticStage.PROXY, AviatorDiagnosticStatus.WARN); + assertFalse(probe.tunnelCalled); + } + + @Test + void shouldTreatApplicationGrpcErrorAsResponseReceived() { + var probe = new ConfigurableDiagnosticProbe( + AviatorGrpcReachabilityResult.responseReceived("INVALID_ARGUMENT", "invalid argument")); + var report = new AviatorConnectionDiagnostics(probe).diagnose(OfflineConnectionPlan.noProxy(), 5, "url"); + + assertStage(report.stages().get(4), AviatorDiagnosticStage.GRPC, AviatorDiagnosticStatus.PASS); + assertTrue(report.hasGrpcStagePass()); + } + + private static void assertStage(AviatorDiagnosticStageResult result, AviatorDiagnosticStage stage, + AviatorDiagnosticStatus status) { + assertTrue(result.isStage(stage), "expected stage " + stage.id() + " but was " + result.stage()); + assertEquals(status, result.status()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbeConnectTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbeConnectTest.java new file mode 100644 index 00000000000..64a3aed811a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorDefaultDiagnosticProbeConnectTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +class AviatorDefaultDiagnosticProbeConnectTest { + @Test + void shouldReadOnlyStatusLineFromConnectHeaders() throws IOException { + var payload = "HTTP/1.1 200 Connection established\r\nProxy-Agent: test\r\n\r\nEXTRA"; + var status = AviatorDefaultDiagnosticProbe.readHttpHeaders( + new ByteArrayInputStream(payload.getBytes(StandardCharsets.ISO_8859_1))); + + assertEquals("HTTP/1.1 200 Connection established", status); + } + + @Test + void shouldPreserveBytesAfterHeadersForTls() throws IOException { + var payload = "HTTP/1.1 200 Connection established\r\n\r\nTLS-BYTES"; + var stream = new ByteArrayInputStream(payload.getBytes(StandardCharsets.ISO_8859_1)); + AviatorDefaultDiagnosticProbe.readHttpHeaders(stream); + + var remaining = new String(stream.readAllBytes(), StandardCharsets.ISO_8859_1); + assertEquals("TLS-BYTES", remaining); + } + + @Test + void shouldRejectNonHttpConnectStatusLine() { + assertThrows(AviatorProxyConnectException.class, + () -> AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine("Not an HTTP response")); + assertThrows(AviatorProxyConnectException.class, + () -> AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine(null)); + } + + @Test + void shouldRejectNon2xxConnectStatusLine() { + assertThrows(AviatorProxyConnectException.class, + () -> AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine( + "HTTP/1.1 407 Proxy Authentication Required")); + } + + @Test + void shouldAccept2xxConnectStatusLine() throws AviatorProxyConnectException { + AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine("HTTP/1.1 200 Connection established"); + AviatorDefaultDiagnosticProbe.validateProxyConnectStatusLine("HTTP/1.0 200 OK"); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResultTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResultTest.java new file mode 100644 index 00000000000..41a8b6b8b36 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/diagnose/AviatorGrpcReachabilityResultTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.diagnose; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.net.ssl.SSLHandshakeException; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.json.JsonHelper; + +/** + * Focused on non-obvious sealed-result behavior: pattern choice + evidence shape on + * exception paths. Pipeline wiring of patterns is covered by {@link AviatorConnectionDiagnosticsTest}. + */ +class AviatorGrpcReachabilityResultTest { + @Test + void putEvidenceShapesAndPatternsForStatusVariants() { + var response = JsonHelper.getObjectMapper().createObjectNode(); + var ok = AviatorGrpcReachabilityResult.responseReceived("OK", "ok"); + assertTrue(ok.stagePass()); + assertNull(ok.pattern()); + ok.putEvidence(response); + assertTrue(response.path("grpcResponseReceived").asBoolean()); + assertEquals("OK", response.path("grpcStatusCode").asText()); + assertFalse(response.path("httpResponseReceived").asBoolean()); + + var http = JsonHelper.getObjectMapper().createObjectNode(); + var nonGrpc = AviatorGrpcReachabilityResult.nonGrpcHttp("UNAVAILABLE", "503", "text/html", "page"); + assertEquals(AviatorGrpcPattern.HTTP_RESPONSE_NOT_GRPC, nonGrpc.pattern()); + nonGrpc.putEvidence(http); + assertFalse(http.path("grpcResponseReceived").asBoolean()); + assertTrue(http.path("httpResponseReceived").asBoolean()); + assertEquals("503", http.path("httpStatusCode").asText()); + + var tls = JsonHelper.getObjectMapper().createObjectNode(); + var tlsFailed = AviatorGrpcReachabilityResult.tlsFailed("UNAVAILABLE", "ssl"); + assertEquals(AviatorGrpcPattern.GRPC_TLS_FAILED, tlsFailed.pattern()); + tlsFailed.putEvidence(tls); + assertFalse(tls.has("exceptionType")); + + var noResponse = JsonHelper.getObjectMapper().createObjectNode(); + var none = AviatorGrpcReachabilityResult.noResponse("DEADLINE_EXCEEDED", "deadline"); + assertEquals(AviatorGrpcPattern.GRPC_NO_RESPONSE, none.pattern()); + none.putEvidence(noResponse); + assertEquals("DEADLINE_EXCEEDED", noResponse.path("grpcStatusCode").asText()); + } + + @Test + void probeErrorTlsKeepsExceptionCauseEvidence() { + var wrapped = new Exception("io exception", new SSLHandshakeException("PKIX path building failed")); + var result = AviatorGrpcReachabilityResult.probeError(wrapped); + + assertEquals(AviatorGrpcPattern.GRPC_TLS_FAILED, result.pattern()); + assertEquals("gRPC TLS handshake failed", result.stageSummary()); + + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + result.putEvidence(evidence); + assertEquals("java.lang.Exception", evidence.path("exceptionType").asText()); + assertEquals(SSLHandshakeException.class.getName(), evidence.path("causeType").asText()); + assertFalse(evidence.path("grpcResponseReceived").asBoolean()); + assertEquals("EXCEPTION", evidence.path("grpcStatusCode").asText()); + } + + @Test + void probeErrorGenericKeepsExceptionEvidence() { + var result = AviatorGrpcReachabilityResult.probeError(new RuntimeException("boom")); + + assertEquals(AviatorGrpcPattern.GRPC_PROBE_ERROR, result.pattern()); + var evidence = JsonHelper.getObjectMapper().createObjectNode(); + result.putEvidence(evidence); + assertEquals("java.lang.RuntimeException", evidence.path("exceptionType").asText()); + assertEquals("boom", evidence.path("exceptionMessage").asText()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java index 7b7a94f007d..61b5734f54d 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/FPRProcessorTest.java @@ -128,6 +128,20 @@ void testProcessDoesNotMarkPendingReviewDefaultAuditorStatusAsAudited() throws E assertFalse(vulnerability.isAudited()); } + @Test + void testProcessTreatsAllPendingAnalysisValuesAsUnaudited() throws Exception { + createTestFpr(minimalAuditFvdl()); + + for (String pendingValue : List.of("Pending Review", "Not Set", "Pending Review/Not Set", " pending review ")) { + AuditIssue auditIssue = AuditIssue.builder() + .instanceId("instance-1") + .tags(Map.of(Constants.ANALYSIS_TAG_ID, pendingValue)) + .build(); + + assertFalse(processSingleVulnerability(auditIssue).isAudited(), pendingValue); + } + } + private String minimalAuditFvdl() { return """ diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorLastTagUsernamesTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorLastTagUsernamesTest.java new file mode 100644 index 00000000000..53a830524cd --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/AuditProcessorLastTagUsernamesTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.fpr.model.AuditIssue; +import com.fortify.cli.aviator.util.Constants; +import com.fortify.cli.aviator.util.FprHandle; + +class AuditProcessorLastTagUsernamesTest { + private static final String INSTANCE_ID = "ISSUE-1"; + + @TempDir + Path tempDir; + + private FprHandle fprHandle; + + @AfterEach + void tearDown() throws Exception { + if (fprHandle != null) { + fprHandle.close(); + } + } + + @Test + void lastDocumentOrderTagHistoryWinsPerTag() throws Exception { + fprHandle = new FprHandle(createTestFpr(""" + + + + + + Exploitable + + + Exploitable + + + + Not an Issue + + %s + + + + Exploitable + + analyst.user + + + + Exploitable + + %s + + + + + """.formatted( + Constants.ANALYSIS_TAG_ID, Constants.AUDITOR_STATUS_TAG_ID, + Constants.ANALYSIS_TAG_ID, Constants.USER_NAME, + Constants.ANALYSIS_TAG_ID, + Constants.AUDITOR_STATUS_TAG_ID, Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR))); + + Map issues = new AuditProcessor(fprHandle).processAuditXML(); + Map lastTagUsernames = issues.get(INSTANCE_ID).getLastTagUsernames(); + + assertEquals("analyst.user", lastTagUsernames.get(Constants.ANALYSIS_TAG_ID)); + assertEquals(Constants.USER_NAME_LEGACY_FORTIFY_AVIATOR, lastTagUsernames.get(Constants.AUDITOR_STATUS_TAG_ID)); + } + + @Test + void missingUsernameIsStoredAsEmptyString() throws Exception { + fprHandle = new FprHandle(createTestFpr(""" + + + + + + + Exploitable + + + + + + """.formatted(Constants.ANALYSIS_TAG_ID))); + + Map issues = new AuditProcessor(fprHandle).processAuditXML(); + + assertEquals("", issues.get(INSTANCE_ID).getLastTagUsernames().get(Constants.ANALYSIS_TAG_ID)); + } + + private Path createTestFpr(String auditXml) throws Exception { + Path fprPath = Files.createTempFile(tempDir, "audit-processor", ".fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("audit.xml")); + zipOutputStream.write(auditXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + + zipOutputStream.putNextEntry(new ZipEntry("src-archive/index.xml")); + zipOutputStream.write("".getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java new file mode 100644 index 00000000000..0b9e6c2c916 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorEdgeCasesTest.java @@ -0,0 +1,130 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; + +/** + * Tests for edge cases in RemediationProcessor and RemediationMetric. + */ +class RemediationProcessorEdgeCasesTest { + + @Test + void emptyRemediationsReturnsEmptyPreviewDetails() { + RemediationMetric metric = RemediationMetric.builder() + .executionMode(RemediationExecutionMode.PREVIEW) + .build(); + + assertNotNull(metric); + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertTrue(metric.isPreview()); + assertEquals(0, metric.previewDetails().size()); + } + + @Test + void nonExistentIssueIdIsTrackedAsRequested() { + Set requestedIds = Set.of("ISSUE-1", "NONEXISTENT-123"); + Set appliedIds = Set.of("ISSUE-1"); + + RemediationMetric metric = filteredMetric(requestedIds, appliedIds, Set.of("file.java")); + + assertNotNull(metric); + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertTrue(metric.requestedIssueIds().contains("NONEXISTENT-123")); + } + + @Test + void filteredMetricWithAllIdsAppliedHasNoSkips() { + Set requestedIds = Set.of("ISSUE-1", "ISSUE-2"); + Set appliedIds = Set.of("ISSUE-1", "ISSUE-2"); + + RemediationMetric metric = filteredMetric(requestedIds, appliedIds, Set.of("file.java")); + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + } + + @Test + void unfilteredMetricWithNoRemediationsHasZeroTotals() { + RemediationMetric metric = unfilteredMetric(0, 0, Set.of()); + + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(0, metric.modifiedFiles().size()); + } + + @Test + void filteredModeDoesNotIncludeUnfilteredFields() { + Set requestedIds = Set.of("ISSUE-1"); + Set appliedIds = Set.of("ISSUE-1"); + + RemediationMetric metric = filteredMetric(requestedIds, appliedIds, Set.of()); + + assertTrue(metric.isFiltered()); + assertNotNull(metric.requestedIssueIds()); + assertNotNull(metric.appliedIssueIds()); + } + + @Test + void unfilteredModeHasEmptyIssueIdSets() { + RemediationMetric metric = unfilteredMetric(5, 3, Set.of("file.java")); + + assertEquals(false, metric.isFiltered()); + assertEquals(0, metric.requestedIssueIds().size()); + assertEquals(0, metric.appliedIssueIds().size()); + } + + @Test + void unfilteredMetricIsApplyModeWithNoPreviewData() { + RemediationMetric metric = unfilteredMetric(5, 3, Set.of()); + + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertTrue(metric.previewDetails().isEmpty()); + } + + private RemediationMetric filteredMetric(Set requestedIds, Set appliedIds, Set modifiedFiles) { + return RemediationMetric.builder() + .totalRemediations(requestedIds.size()) + .appliedRemediations(appliedIds.size()) + .skippedRemediations(requestedIds.size() - appliedIds.size()) + .requestedIssueIds(requestedIds) + .seenIssueIds(appliedIds) + .satisfiedIssueIds(appliedIds) + .appliedIssueIds(appliedIds) + .modifiedFiles(modifiedFiles) + .build(); + } + + private RemediationMetric unfilteredMetric(int total, int applied, Set modifiedFiles) { + return RemediationMetric.builder() + .totalRemediations(total) + .appliedRemediations(applied) + .skippedRemediations(total - applied) + .modifiedFiles(modifiedFiles) + .build(); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java new file mode 100644 index 00000000000..18424ba44f3 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessorTest.java @@ -0,0 +1,1757 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.applyRemediation.ApplyAutoRemediationOnSource; +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessingOptions; +import com.fortify.cli.aviator.fpr.remediation.RemediationProcessor; +import com.fortify.cli.aviator.fpr.remediation.model.RemediationMetric; +import com.fortify.cli.aviator.fpr.utils.SourceDecoders; +import com.fortify.cli.aviator.util.FileUtil; +import com.fortify.cli.aviator.util.FprHandle; + +class RemediationProcessorTest { + private static final String REMEDIATIONS_NAMESPACE = "xmlns://www.fortify.com/schema/remediations"; + + @TempDir + Path tempDir; + + @Test + void testIssueIdFilterAppliesOnlyRequestedRemediations() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " oldTwo();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(remediationsXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of("ISSUE-2", "ISSUE-404"), RemediationExecutionMode.APPLY)); + var metric = processor.processRemediationXML(); + + assertTrue(metric.isFiltered()); + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Set.of("ISSUE-2"), metric.appliedIssueIds()); + assertEquals(Set.of("Example.java"), metric.modifiedFiles()); + assertEquals(1, metric.skippedByReason().get("Requested issue not found in remediations")); + String updatedContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertTrue(updatedContent.contains(" oldOne();")); + assertTrue(updatedContent.contains(" newTwo();")); + assertFalse(updatedContent.contains(" newOne();")); + } + } + + @Test + void testIssueIdFilterWithNoMatchesCountsRequestedIdsAsSkipped() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-no-match")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(singleRemediationXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(new LinkedHashSet<>(Set.of("ISSUE-404", "ISSUE-405")), RemediationExecutionMode.APPLY)); + var metric = processor.processRemediationXML(); + + assertTrue(metric.isFiltered()); + assertEquals(2, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(2, metric.skippedRemediations()); + assertEquals(Set.of(), metric.appliedIssueIds()); + assertEquals(Set.of(), metric.modifiedFiles()); + assertEquals(2, metric.skippedByReason().get("Requested issue not found in remediations")); + assertEquals(originalContent, Files.readString(sourceFile, StandardCharsets.UTF_8)); + } + } + + @Test + void testUnfilteredPathTraversalCandidateIsSkippedWithoutAborting() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-path-traversal")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(pathTraversalAndValidRemediationsXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString()); + var metric = processor.processRemediationXML(); + + assertFalse(metric.isFiltered()); + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(1, metric.skippedByReason().get("Source file outside source directory")); + String updatedContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertTrue(updatedContent.contains(" newOne();")); + } + } + + @Test + void testLoadsFvdlMetadataFromZipBackedFprPath() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-zip-backed")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + Path fprPath = createFpr(singleRemediationXml(TestHashUtil.sha256Base64Unix(originalContent))); + Path cachePath = tempDir.resolve("remediations-cache.zip"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(cachePath))) { + zipOutputStream.putNextEntry(new ZipEntry("fprs/001.fpr")); + Files.copy(fprPath, zipOutputStream); + zipOutputStream.closeEntry(); + } + + try (FileSystem cacheFileSystem = FileSystems.newFileSystem(cachePath, (ClassLoader) null); + FprHandle fprHandle = new FprHandle(cacheFileSystem.getPath("/fprs/001.fpr"))) { + var metric = new RemediationProcessor(fprHandle, sourceDir.toString()).processRemediationXML(); + + assertEquals(1, metric.appliedRemediations()); + assertEquals(Set.of("Example.java"), metric.modifiedFiles()); + assertTrue(Files.readString(sourceFile, StandardCharsets.UTF_8).contains(" newOne();")); + } + } + + @Test + void testLoadsDeclaredEncodingFromZipBackedFprPath() throws Exception { + Charset sourceCharset = Charset.forName("windows-1252"); + String originalLine = "String price = \"€100\";"; + String replacementLine = "String price = \"EUR100\";"; + String originalContent = originalLine + "\n"; + Path sourceDir = Files.createDirectory(tempDir.resolve("src-zip-encoding")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.write(sourceFile, originalContent.getBytes(sourceCharset)); + + String remediationsXml = """ + + + + + Example.java + %s + + 1 + 1 + %s + %s + %s + + + + + """.formatted(TestHashUtil.sha256Base64Unix(originalContent), originalLine, originalLine, replacementLine); + Path fprPath = createFpr(remediationsXml, sourceCharset.name()); + Path cachePath = tempDir.resolve("remediations-encoding-cache.zip"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(cachePath))) { + zipOutputStream.putNextEntry(new ZipEntry("fprs/001.fpr")); + Files.copy(fprPath, zipOutputStream); + zipOutputStream.closeEntry(); + } + + try (FileSystem cacheFileSystem = FileSystems.newFileSystem(cachePath, (ClassLoader) null); + FprHandle fprHandle = new FprHandle(cacheFileSystem.getPath("/fprs/001.fpr"))) { + var metric = new RemediationProcessor(fprHandle, sourceDir.toString()).processRemediationXML(); + + assertEquals(1, metric.appliedRemediations()); + assertEquals(replacementLine + "\n", new String(Files.readAllBytes(sourceFile), sourceCharset)); + } + } + + private Path createFpr(String remediationsXml) throws IOException { + return createFpr(remediationsXml, "UTF-8"); + } + + private Path createFpr(String remediationsXml, String sourceEncoding) throws IOException { + Path fprPath = tempDir.resolve("test.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + // Encoding metadata is required by RemediationProcessor (FVDL Build/SourceFiles). + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Example.java + + + + + """.formatted(sourceEncoding)); + writeEntry(zipOutputStream, "remediations.xml", remediationsXml); + } + return fprPath; + } + + /** + * Fix #2 (exact-line disambiguation): two identical context blocks are ambiguous on their + * own, but the declared/projected LineFrom lands exactly on the first occurrence, so it + * resolves deterministically instead of being skipped as ambiguous. + */ + @Test + void resolvesAmbiguousContextByExactDeclaredPosition() throws Exception { + String originalSource = "before\nTARGET\nafter\nbefore\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("before\nREPLACED\nafter\nbefore\nTARGET\nafter\n", Files.readString(sourceFile)); + } + + /** + * Fix #2 must not guess: when the declared/projected position doesn't exactly match any of + * the ambiguous candidates, it still throws SOURCE_CONTEXT_AMBIGUOUS rather than picking one. + */ + @Test + void skipsAmbiguousContextWhenDeclaredPositionMatchesNoCandidate() throws Exception { + String originalSource = "before\nTARGET\nafter\nbefore\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(99, 99, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of( + "Source context matched multiple locations", 1), + metric.skippedByReason()); + assertEquals(originalSource, Files.readString(sourceFile)); + } + @Test + void appliesRemediationWhenContextMatchesOnce() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + @Test + void appliesOriginalCodeAfterLeadingContextLines() throws Exception { + Path sourceFile = writeSourceFile("TARGET\nkeep\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(3, 3, 2, 1, "TARGET\nkeep\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals("TARGET\nkeep\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + @Test + void appliesRemediationWhenContextStartsWithBlankLine() throws Exception { + Path sourceFile = writeSourceFile("header\n\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(3, 3, 1, 1, "\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals("header\n\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + @Test + void nestedRemediationWithDifferentContentIsPossiblyRemediated() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("wide-fix", 1, 3, 0, 0, "before\nTARGET\nafter", + "before\nTARGET\nafter", "wideline1\nwideline2\nwideline3"), + new RemediationSpec("narrow-fix", 2, 2, 1, 1, "before\ntarget\nafter", + "TARGET", "NARROW_DIFFERENT"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.possiblyRemediatedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("wideline1\nwideline2\nwideline3\n", Files.readString(sourceFile)); + } + + /** + * Fix #1 (boundary-token duplication): NewCode repeats the line immediately before LineFrom + * verbatim as its first line; that duplicate must be dropped rather than doubling the line. + */ + @Test + void dropsDuplicatedLeadingBoundaryLineInNewCode() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "line1\nline2\nline3", "line2", "line1\nreplaced2"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("line1\nreplaced2\nline3\n", Files.readString(sourceFile)); + } + + /** + * Fix #1 (boundary-token duplication): NewCode repeats the line immediately after LineTo + * verbatim as its last line; that duplicate must be dropped rather than doubling the line. + */ + @Test + void dropsDuplicatedTrailingBoundaryLineInNewCode() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "line1\nline2\nline3", "line2", "replaced2\nline3"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("line1\nreplaced2\nline3\n", Files.readString(sourceFile)); + } + + /** + * Regression test: the offset ledger must record the delta actually spliced into the file + * (post boundary-dedup), not the raw NewCode line count. A prior hunk in this file drops a + * duplicated boundary line (fix #1), shrinking the file by one more line than NewCode's raw + * length implies. A later hunk in the same file targets the second of two identical "TARGET" + * blocks; only a correctly-shifted projection lands exactly on it. With the pre-fix (buggy) + * delta, the projected position misses, the fuzzy fallback's context match is ambiguous + * between the two blocks, and neither lands on the declared/projected position either - + * causing an incorrect skip instead of resolving to the second occurrence. + */ + @Test + void offsetLedgerAccountsForDedupWhenProjectingLaterHunkInSameFile() throws Exception { + String originalSource = "line0\nhead1\nhead2\nhead3\nbefore\nTARGET\nafter\nbefore\nTARGET\nafter\ntail\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("hunkA", 2, 4, 1, 1, "line0\nhead1\nhead2\nhead3\nbefore", + "head1\nhead2\nhead3", "line0\nreplacedHead"), + new RemediationSpec("hunkB", 9, 9, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("line0\nreplacedHead\nbefore\nTARGET\nafter\nbefore\nREPLACED\nafter\ntail\n", + Files.readString(sourceFile)); + } + + /** + * Fix #1 (boundary-token duplication): NewCode repeats BOTH the line before LineFrom and the + * line after LineTo verbatim in the same hunk. Both duplicates must be dropped, not just one - + * dropDuplicatedBoundaryTokens checks leading and trailing independently, so this proves + * neither check clobbers or is skipped because of the other having already mutated the list. + */ + @Test + void dropsBothDuplicatedBoundaryLinesWhenNewCodeRepeatsBothNeighbors() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\n"); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "line1\nline2\nline3", "line2", + "line1\nreplaced2\nline3"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("line1\nreplaced2\nline3\n", Files.readString(sourceFile)); + } + + /** + * Fix #2 (exact-line disambiguation) must also cover OriginalCode search, not just Context + * search: two identical single-line OriginalCode matches inside the context window are + * ambiguous on their own, but the declared/projected LineFrom lands exactly on the second + * occurrence, so it resolves deterministically instead of being skipped as ambiguous. + */ + @Test + void resolvesAmbiguousOriginalCodeByExactDeclaredPosition() throws Exception { + String originalSource = "before\nTARGET\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(3, 3, 1, 1, "before\nTARGET\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nTARGET\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * Companion to the above: when the declared/projected position matches none of the ambiguous + * OriginalCode candidates, it must throw ORIGINAL_CODE_AMBIGUOUS rather than guessing one. + */ + @Test + void skipsAmbiguousOriginalCodeWhenDeclaredPositionMatchesNoCandidate() throws Exception { + String originalSource = "before\nTARGET\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + Path fprPath = createRemediationFpr(99, 99, 1, 1, "before\nTARGET\nTARGET\nafter", "TARGET", "REPLACED"); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(0, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Original code matched multiple locations", 1), metric.skippedByReason()); + assertEquals(originalSource, Files.readString(sourceFile)); + } + + /** + * Phase 2 "make the hash check work": when the declared Hash matches the canonical form of + * the file content, the change is applied directly at the declared line range with NO context + * or OriginalCode search at all. Context and OriginalCode here are deliberately garbage text + * that appears nowhere in the source - if the hash-match fast path were not actually short- + * circuiting the fuzzy search, this remediation would be skipped as not-found. + */ + @Test + void appliesRemediationViaCanonicalHashMatchWithoutContextOrOriginalCodeSearch() throws Exception { + String originalSource = "before\nTARGET\nafter\n"; + Path sourceFile = writeSourceFile(originalSource); + String canonicalHash = sha256Base64(FileUtil.canonicalizeForHash(originalSource) + .getBytes(StandardCharsets.UTF_8)); + Path fprPath = createRemediationFprWithHash(2, 2, 1, 1, + "garbage-context-not-in-file\nmore-garbage\nstill-garbage", "GARBAGE-CODE-NOT-IN-FILE", + "REPLACED", canonicalHash); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * SUPERSEDED: a broader prior fix's actually-written content already contains the narrower + * candidate's proposed replacement (normalized). The narrower remediation must be recognized + * as superseded and not re-applied/re-searched at all. + */ + @Test + void supersededRemediationWithMatchingContentIsNotReapplied() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("wide-fix", 1, 3, 0, 0, "before\nTARGET\nafter", + "before\nTARGET\nafter", "before\nREPLACED\nafter"), + new RemediationSpec("narrow-fix", 2, 2, 1, 1, "before\ntarget\nafter", + "TARGET", "REPLACED"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.supersededRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * A nested multi-line candidate whose NewCode matches the wide fix still does not prove + * SUPERSEDED: the classifier passes {@code comparisonCode} (all whitespace removed), and + * {@code contentCovers} equals that against a newline-preserving slice. The narrow hunk is + * POSSIBLY_REMEDIATED and is not re-applied. + */ + @Test + void multilineNestedMatchingContentIsPossiblyRemediatedNotReapplied() throws Exception { + Path sourceFile = writeSourceFile("before\nline2\nline3\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("wide-fix", 1, 4, 0, 0, "before\nline2\nline3\nafter", + "before\nline2\nline3\nafter", "before\nREPLACED2\nREPLACED3\nafter"), + new RemediationSpec("narrow-fix", 2, 3, 1, 1, "before\nline2\nline3\nafter", + "line2\nline3", "REPLACED2\nREPLACED3"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.supersededRemediations()); + assertEquals(1, metric.possiblyRemediatedRemediations()); + assertEquals("before\nREPLACED2\nREPLACED3\nafter\n", Files.readString(sourceFile)); + } + + /** + * CONFLICTS: two remediations with a partial, non-nested line overlap (neither range contains + * the other) is genuinely ambiguous coverage. Fix #2's "no heuristic guessing" philosophy + * extends here too: the later, overlapping remediation must be skipped with + * CONFLICTS_WITH_ANOTHER_FIX rather than guessed at. + */ + @Test + void conflictingOverlappingRemediationIsSkippedNotGuessed() throws Exception { + Path sourceFile = writeSourceFile("line1\nline2\nline3\nline4\nline5\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("fix-A", 2, 3, 1, 1, "line1\nline2\nline3\nline4", + "line2\nline3", "A2\nA3"), + new RemediationSpec("fix-B", 3, 4, 1, 1, "line2\nline3\nline4\nline5", + "line3\nline4", "B3\nB4"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Conflicts with another fix", 1), metric.skippedByReason()); + assertEquals("line1\nA2\nA3\nline4\nline5\n", Files.readString(sourceFile)); + } + + /** + * Mixed outcome within a single remediation: one file-change is a valid APPLY candidate + * while a sibling file-change in the SAME remediation conflicts with an already-applied + * prior fix. Remediations apply atomically, so the whole remediation must be rejected as + * CONFLICTS_WITH_ANOTHER_FIX as soon as any hunk is CONFLICTS, rather than falling through + * to the applier's best-effort offset/fuzzy-anchor fallback for the conflicting hunk while + * silently applying the valid one. + */ + @Test + void mixedOutcomeRemediationWithOneConflictingHunkIsSkippedEntirely() throws Exception { + Path sharedFile = writeSourceFile("Shared.java", "s1\ns2\ns3\ns4\ns5\n"); + Path exampleFile = writeSourceFile("Example.java", "before\nTARGET\nafter\n"); + + LinkedHashMap> remediations = new LinkedHashMap<>(); + remediations.put("fix-prior", List.of( + new FileChangeSpec("Shared.java", 2, 3, 1, 1, "s1\ns2\ns3\ns4", "s2\ns3", "P2\nP3"))); + remediations.put("fix-mixed", List.of( + new FileChangeSpec("Example.java", 2, 2, 1, 1, "before\nTARGET\nafter", "TARGET", "REPLACED"), + new FileChangeSpec("Shared.java", 3, 4, 1, 1, "s2\ns3\ns4\ns5", "s3\ns4", "M3\nM4"))); + Path fprPath = createRemediationFprWithRemediations(remediations); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Conflicts with another fix", 1), metric.skippedByReason()); + assertEquals("s1\nP2\nP3\ns4\ns5\n", Files.readString(sharedFile)); + assertEquals("before\nTARGET\nafter\n", Files.readString(exampleFile)); + } + + /** + * Near-identical recognition (Phase 2): two remediations at the same location whose NewCode + * differs only by a trailing Java comment normalize to the same comparisonCode. The second + * must be recognized as fully identical to the first and counted once, not re-applied and not + * leaving any trace of its own NewCode text in the file. + */ + @Test + void fullyIdenticalNearIdenticalRemediationIsCountedOnceNotReapplied() throws Exception { + Path sourceFile = writeSourceFile("before\nTARGET\nafter\n"); + Path fprPath = createRemediationFpr(List.of( + new RemediationSpec("fix-1", 2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"), + new RemediationSpec("fix-2", 2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED // note"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.identicalRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * All-or-nothing multi-file remediations: one remediation touching two files where one + * file's hunk cannot be located must skip the WHOLE remediation, not just that file. Neither + * file may end up written - a half-applied remediation is worse than one left unapplied. + */ + @Test + void multiFileRemediationSkipsEntirelyWhenAnyFileFails() throws Exception { + Path badFile = writeSourceFile("Bad.java", "one\ntwo\nthree\n"); + Path goodFile = writeSourceFile("Good.java", "before\nTARGET\nafter\n"); + Path fprPath = createMultiFileRemediationFpr("multi-file-fix", List.of( + new FileChangeSpec("Bad.java", 2, 2, 1, 1, "nomatch-a\nnomatch-b\nnomatch-c", "NOMATCH", "X"), + new FileChangeSpec("Good.java", 2, 2, 1, 1, "before\nTARGET\nafter", "TARGET", "REPLACED"))); + + RemediationMetric metric; + try (FprHandle fprHandle = new FprHandle(fprPath)) { + metric = new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + + assertEquals(1, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Source context not found", 1), metric.skippedByReason()); + assertEquals(Set.of(), metric.modifiedFiles()); + assertEquals("before\nTARGET\nafter\n", Files.readString(goodFile)); + assertEquals("one\ntwo\nthree\n", Files.readString(badFile)); + } + + // ------------------------------------------------------------------------------------------ + // Folded in from the former RemediationProcessorKnownIssuesTest (REVIEW_2 §2.1 - §2.5) once + // every scenario there turned green. Uses its own multi-hunk/multi-file harness below since + // the builders above only support one hunk per file. + // ------------------------------------------------------------------------------------------ + + /** + * A malformed remediation entry (missing {@code }) is skipped on its own via + * {@code REMEDIATION_DATA_INVALID} rather than aborting the whole batch; the well-formed + * remediation alongside it still applies. + */ + @Test + void malformedRemediationIsSkippedAndValidRemediationsStillApply() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "before\nTARGET\nafter\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("malformed", List.of(new FileSpec(null, List.of( + new HunkSpec(1, 1, 0, 0, "before", "before", "BROKEN"))))), + new MultiHunkRemediationSpec("valid", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations(), "the valid remediation must still be applied"); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Remediation data invalid", 1), metric.skippedByReason()); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * Hunks staged earlier in the same remediation must be visible to the offset projection of a + * later hunk in the same file: hunk 1 replaces lines 2-3 with a single line (delta -1) and + * hunk 2 targets the second of two identical {@code before/TARGET/after} blocks. With the + * staged delta visible, the projected start lands exactly on one of the two context + * candidates instead of being ambiguous. + * + *

    Note {@link #offsetLedgerAccountsForDedupWhenProjectingLaterHunkInSameFile} looks similar + * but does not cover this - it uses two separate remediations, so the first is committed + * before the second is classified. + */ + @Test + void secondHunkOfSameRemediationProjectsThroughFirstHunksLineDelta() throws Exception { + Path sourceFile = writeSourceFile("Example.java", + "line1\nline2\nline3\nbefore\nTARGET\nafter\nbefore\nTARGET\nafter\ntail\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("two-hunks", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 3, 1, 1, "line1\nline2\nline3\nbefore", "line2\nline3", "X"), + new HunkSpec(8, 8, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("line1\nX\nbefore\nTARGET\nafter\nbefore\nREPLACED\nafter\ntail\n", + Files.readString(sourceFile)); + } + + /** + * A remediation mixing an already-covered hunk and a still-applicable hunk must apply the + * applicable one rather than being rejected wholesale: {@code wide-fix} covers lines 1-3, + * {@code mixed-fix}'s first hunk (line 2) is covered by it, but its second hunk (line 5) is + * untouched and must still land. + */ + @Test + void remediationMixingCoveredAndApplicableHunksStillAppliesTheApplicableOne() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "before\nTARGET\nafter\nkeep\nOTHER\ntail\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("wide-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 3, 0, 0, "before\nTARGET\nafter", "before\nTARGET\nafter", "W1\nW2\nW3"))))), + new MultiHunkRemediationSpec("mixed-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "NARROW"), + new HunkSpec(5, 5, 1, 1, "keep\nOTHER\ntail", "OTHER", "FIXED"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals("W1\nW2\nW3\nkeep\nFIXED\ntail\n", Files.readString(sourceFile)); + } + + /** + * A remediation with any conflicting hunk must not be applied at all, even if its other + * hunks would individually be POSSIBLY_REMEDIATED: {@code mixed-fix}'s hunk 1 (line 2) is + * POSSIBLY_REMEDIATED against {@code wide-a}, and hunk 2 (lines 7-8) CONFLICTS with + * {@code wide-b}'s declared range. The whole remediation must be skipped as conflicting, + * not partially applied. + */ + @Test + void remediationWithAnyConflictingHunkIsSkippedAsConflicting() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("wide-a", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 3, 0, 0, "l1\nl2\nl3", "l1\nl2\nl3", "A1\nl2\nA3"))))), + new MultiHunkRemediationSpec("wide-b", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(5, 7, 1, 1, "l4\nl5\nl6\nl7\nl8", "l5\nl6\nl7", "B5\nB6\nl7"))))), + new MultiHunkRemediationSpec("mixed-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "l1\nl2\nl3", "l2", "M2"), + new HunkSpec(7, 8, 1, 1, "l6\nl7\nl8", "l7\nl8", "M7\nM8"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(3, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Conflicts with another fix", 1), metric.skippedByReason()); + assertEquals("A1\nl2\nA3\nl4\nB5\nB6\nl7\nl8\n", Files.readString(sourceFile), + "the conflicting hunk must not overwrite wide-b's region"); + } + + /** + * NewCode line endings must follow the file's own separator rather than an unrelated + * comment-marker-stripping side effect: for an extension unmapped by + * {@code FileTypeLanguageMapperUtil}, a CRLF NewCode (the CR here is a literal + * {@code } character reference) must not leave a stray CR in an LF-only source file. + */ + @Test + void newCodeWithCrlfDoesNotLeaveStrayCarriageReturnsInLfSourceFile() throws Exception { + Path sourceFile = writeSourceFile("payload.zzz", "before\nTARGET\nafter\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("crlf-newcode", List.of(new FileSpec("payload.zzz", List.of( + new HunkSpec(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "NEW1 \nNEW2"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(1, metric.appliedRemediations()); + assertEquals("before\nNEW1\nNEW2\nafter\n", Files.readString(sourceFile), + "NewCode line endings must follow the file, not the XML payload"); + } + + /** + * {@code contentCovers} must not treat an incidental substring hit as proof of coverage: the + * broader fix's replacement happens to contain {@code return;} inside a null guard, while the + * narrower fix proposes {@code return;} at a different line - they are not the same fix, so + * the narrower remediation must classify as POSSIBLY_REMEDIATED, not SUPERSEDED. + */ + @Test + void incidentalSubstringMatchIsNotTreatedAsProvenSupersession() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "value = getInput();\nprocess(value);\nfinish();\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("wide-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 3, 0, 0, + "value = getInput();\nprocess(value);\nfinish();", + "value = getInput();\nprocess(value);\nfinish();", + "value = sanitize(getInput());\nif (value == null) { return; }\nprocess(value);"))))), + new MultiHunkRemediationSpec("narrow-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "value = getInput();\nprocess(value);\nfinish();", + "process(value);", "return;"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.supersededRemediations(), + "an incidental substring hit is not proof that the broader fix covers this hunk"); + assertEquals(1, metric.possiblyRemediatedRemediations()); + assertEquals("value = sanitize(getInput());\nif (value == null) { return; }\nprocess(value);\n", + Files.readString(sourceFile), "only the broader fix may be written"); + } + + /** + * {@code contentCovers} must not treat a blank candidate comparison code as proof of + * coverage: a comment-only NewCode normalises to {@code ""}, and {@code anything.contains("")} + * is trivially true, so this must not be misread as SUPERSEDED. + */ + @Test + void blankCandidateComparisonCodeIsNotTreatedAsProvenSupersession() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "before\nTARGET\nafter\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("wide-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 3, 0, 0, "before\nTARGET\nafter", "before\nTARGET\nafter", + "W1\nW2\nW3"))))), + // NewCode is a single line comment, so its comparison code normalises to "". + new MultiHunkRemediationSpec("comment-only-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", + "// nothing to change here"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.supersededRemediations(), + "an empty candidate comparison code is not proof of coverage"); + assertEquals(1, metric.possiblyRemediatedRemediations()); + assertEquals("W1\nW2\nW3\n", Files.readString(sourceFile)); + } + + // ------------------------------------------------------------------------------------------ + // Folded in from the former RemediationProcessorRemainingIssuesTest (REVIEW_3 points 1-4) once + // every scenario there turned green. + // ------------------------------------------------------------------------------------------ + + /** + * A filename that is not a legal path on this OS ({@code FileChange.resolve} throwing + * {@code InvalidPathException}) must be translated to {@code REMEDIATION_DATA_INVALID} and + * skipped on its own, rather than escaping the per-remediation loop and aborting the whole + * batch; the well-formed remediation alongside it must still apply. + * + *

    Windows only: {@code ':'} is illegal in a Windows path but legal on Linux, so the + * scenario cannot be reproduced there (the only character a Unix path rejects is NUL, which + * XML 1.0 cannot carry). + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void illegalFilenameIsSkippedAndValidRemediationsStillApply() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "before\nTARGET\nafter\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("illegal-filename", List.of(new FileSpec("src/bad:name.java", List.of( + new HunkSpec(1, 1, 0, 0, "before", "before", "BROKEN"))))), + new MultiHunkRemediationSpec("valid", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations(), "the valid remediation must still be applied"); + assertEquals(1, metric.skippedRemediations()); + assertEquals(1, metric.skippedByReason().values().stream().mapToInt(Integer::intValue).sum(), + "the illegal filename must be recorded as exactly one skip"); + assertEquals("before\nREPLACED\nafter\n", Files.readString(sourceFile)); + } + + /** + * A source file whose write fails (e.g., made read-only) must be skipped via + * {@code SOURCE_WRITE_FAILED} on its own, not abort the whole batch: the rollback attempt for + * that failed write must not itself retry writing to the same permission-denied file, which + * previously escalated into a batch-aborting {@code RollbackRemediationException} and caused + * every other remediation in the run - even ones touching unrelated, writable files - to fail. + */ + @Test + @EnabledOnOs(OS.WINDOWS) + void readOnlyFileWriteFailureIsSkippedAndOtherRemediationsStillApply() throws Exception { + Path readOnlyFile = writeSourceFile("ReadOnly.java", "before\nTARGET\nafter\n"); + Path goodFile = writeSourceFile("Good.java", "before\nTARGET\nafter\n"); + readOnlyFile.toFile().setReadOnly(); + try { + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("blocked", List.of(new FileSpec("ReadOnly.java", List.of( + new HunkSpec(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"))))), + new MultiHunkRemediationSpec("valid", List.of(new FileSpec("Good.java", List.of( + new HunkSpec(2, 2, 1, 1, "before\ntarget\nafter", "TARGET", "REPLACED"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations(), "the remediation for the writable file must still be applied"); + assertEquals(1, metric.skippedRemediations()); + assertEquals(Map.of("Source file write failed", 1), metric.skippedByReason()); + assertEquals("before\nTARGET\nafter\n", Files.readString(readOnlyFile)); + assertEquals("before\nREPLACED\nafter\n", Files.readString(goodFile)); + } finally { + readOnlyFile.toFile().setWritable(true); + } + } + + /** + * The offset ledger must record where a hunk actually landed via the fuzzy anchor, not where + * it was declared: {@code relocated} declares line 12 but its context/OriginalCode only exist + * at line 3, so it lands there with delta +2 and the ledger must stage {@code (3, 3, +2)} - + * not {@code (12, 12, +2)}. {@code later} genuinely targets the second {@code DUP} block at + * declared line 11, which needs that correctly-staged shift to disambiguate between the two + * identical context blocks; a stale staged range causes the shift to compute as 0 and the + * exact-position disambiguation to match neither candidate. + */ + @Test + void offsetLedgerRecordsWhereHunkActuallyLandedNotWhereItWasDeclared() throws Exception { + Path sourceFile = writeSourceFile("Example.java", + "aa\nHEAD\nTARGET\nTAIL\nbb\nctx\nDUP\nctx2\ncc\nctx\nDUP\nctx2\ndd\n"); + Path fprPath = buildFpr(List.of( + // Declared line 12 is stale; the anchor really sits at line 3. + new MultiHunkRemediationSpec("relocated", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(12, 12, 1, 1, "HEAD\nTARGET\nTAIL", "TARGET", "R1\nR2\nR3"))))), + // Genuinely targets the SECOND DUP block, declared (correctly) at line 11. + new MultiHunkRemediationSpec("later", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(11, 11, 1, 1, "ctx\nDUP\nctx2", "DUP", "FIXED"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.appliedRemediations()); + assertEquals(0, metric.skippedRemediations()); + assertEquals(Map.of(), metric.skippedByReason()); + assertEquals("aa\nHEAD\nR1\nR2\nR3\nTAIL\nbb\nctx\nDUP\nctx2\ncc\nctx\nFIXED\nctx2\ndd\n", + Files.readString(sourceFile)); + } + + /** + * {@code contentCovers} must not treat an incidental substring hit as proof of coverage even + * above the minimum-length guard: {@code return null;} normalises to 11 characters and occurs + * in the wide fix's replacement only incidentally, inside a null guard several lines away from + * where the narrow fix targets - they are not the same fix, so this must classify as + * POSSIBLY_REMEDIATED, not SUPERSEDED. + */ + @Test + void incidentalSubstringAboveTheLengthGuardIsNotProvenSupersession() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "value = getInput();\nprocess(value);\nfinish();\n"); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("wide-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 3, 0, 0, + "value = getInput();\nprocess(value);\nfinish();", + "value = getInput();\nprocess(value);\nfinish();", + "value = sanitize(getInput());\nif (value == null) { return null; }\nprocess(value);"))))), + new MultiHunkRemediationSpec("narrow-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "value = getInput();\nprocess(value);\nfinish();", + "process(value);", "return null;"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(0, metric.supersededRemediations(), + "an incidental substring hit is not proof that the broader fix covers this hunk, " + + "however long the candidate happens to be"); + assertEquals(1, metric.possiblyRemediatedRemediations()); + assertEquals("value = sanitize(getInput());\nif (value == null) { return null; }\nprocess(value);\n", + Files.readString(sourceFile), "only the broader fix may be written"); + } + + /** + * Safety property that per-FPR ledgers depend on: {@code apply-remediations --all-open-issues} + * uses a fresh {@code RemediationProcessor} (and therefore a fresh ledger) per artifact, since + * different artifacts may be scans of different source revisions. What keeps a multi-FPR run + * safe is not the ledger but anchor verification - once an earlier FPR has rewritten a file, + * the declared hash no longer matches, so a later FPR's hunk goes through the projection/fuzzy + * path and is written only where its OriginalCode still literally matches. Here the narrow + * fix's OriginalCode was consumed by the wide fix from the first FPR, so the second FPR must + * skip it rather than write it against stale text. + */ + @Test + void secondFprDoesNotApplyOverAFixTheFirstFprAlreadyRewrote() throws Exception { + Path sourceFile = writeSourceFile("Example.java", "value = getInput();\nprocess(value);\nfinish();\n"); + Path firstFpr = buildFpr("artifact-1.fpr", List.of( + new MultiHunkRemediationSpec("wide-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 3, 0, 0, + "value = getInput();\nprocess(value);\nfinish();", + "value = getInput();\nprocess(value);\nfinish();", + "value = sanitize(getInput());\nprocess(sanitized);\nfinish();"))))))); + Path secondFpr = buildFpr("artifact-2.fpr", List.of( + new MultiHunkRemediationSpec("narrow-fix", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(2, 2, 1, 1, "value = getInput();\nprocess(value);\nfinish();", + "process(value);", "process(escape(value));"))))))); + + RemediationMetric first = apply(firstFpr); + assertEquals(1, first.appliedRemediations()); + String afterFirst = "value = sanitize(getInput());\nprocess(sanitized);\nfinish();\n"; + assertEquals(afterFirst, Files.readString(sourceFile)); + + RemediationMetric second = apply(secondFpr); + + assertEquals(0, second.appliedRemediations(), + "the narrower fix's OriginalCode no longer exists, so it must not be written anywhere"); + assertEquals(1, second.skippedRemediations()); + assertEquals(afterFirst, Files.readString(sourceFile), "the second FPR must leave the file untouched"); + } + /** + * Regression test for coordinate-space mismatch bug: HunkClassifier must compare declared + * (pristine-file) line numbers against declared ranges, not actual (post-shift) ranges, when + * pre-classifying narrower hunks nested inside a broader fix that shifted them. The broader + * fix (wide-deletes) declares lines 1-5 and deletes them, replacing with 2 lines (delta -3). + * The narrower fix (narrow-inside) declares line 3, which sits inside 1-5's declared range. + * Before the fix, classifyRange would compare declared-3 against the broader hunk's ACTUAL + * range (which is now at a shifted position), missing the nesting; after the fix, it compares + * declared-against-declared and correctly classifies as POSSIBLY_REMEDIATED. + */ + @Test + void narrowerRemediationInsideBroaderDeleteIsClassifiedAsPossiblyRemediated() throws Exception { + String originalSource = "line1\nline2\nline3\nline4\nline5\nline6\n"; + writeSourceFile("Example.java", originalSource); + Path fprPath = buildFpr(List.of( + new MultiHunkRemediationSpec("wide-deletes", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(1, 5, 0, 0, + "line1\nline2\nline3\nline4\nline5", + "line1\nline2\nline3\nline4\nline5", + "replacement1\nreplacement2"))))), + new MultiHunkRemediationSpec("narrow-inside", List.of(new FileSpec("Example.java", List.of( + new HunkSpec(3, 3, 1, 1, "line2\nline3\nline4", + "line3", "line3-modified"))))))); + + RemediationMetric metric = apply(fprPath); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations(), "the broader fix must be applied"); + assertEquals(1, metric.possiblyRemediatedRemediations(), + "the narrower fix's declared line 3 sits inside the broader fix's declared 1-5, so it must be " + + "pre-classified as POSSIBLY_REMEDIATED even though its actual position shifted"); + assertEquals(0, metric.skippedRemediations(), + "no remediation should be skipped; the narrower one must not fail with ANCHOR_DOES_NOT_MATCH"); + } + + + + private record HunkSpec(int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode) {} + + /** A {@code } block; a {@code null} filename omits the element entirely. */ + private record FileSpec(String filename, List hunks) {} + + private record MultiHunkRemediationSpec(String instanceId, List files) {} + + private RemediationMetric apply(Path fprPath) throws Exception { + try (FprHandle fprHandle = new FprHandle(fprPath)) { + return new RemediationProcessor(fprHandle, tempDir.toString()).processRemediationXML(); + } + } + + private Path buildFpr(List specs) throws Exception { + return buildFpr("remediation.fpr", specs); + } + + private Path buildFpr(String fprName, List specs) throws Exception { + StringBuilder remediations = new StringBuilder(); + for (MultiHunkRemediationSpec spec : specs) { + remediations.append("\n") + .append(" test\n"); + for (FileSpec file : spec.files()) { + remediations.append(" \n"); + if (file.filename() != null) { + remediations.append(" ").append(file.filename()).append("\n"); + } + remediations.append(" not-the-source-hash\n"); + for (HunkSpec hunk : file.hunks()) { + remediations.append(""" + + %d + %d + %s + %s + %s + + """.formatted(hunk.lineFrom(), hunk.lineTo(), hunk.contextBefore(), + hunk.contextAfter(), hunk.context(), hunk.originalCode(), hunk.newCode())); + } + remediations.append(" \n"); + } + remediations.append("\n"); + } + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + %s + + + """.formatted(REMEDIATIONS_NAMESPACE, remediations); + + Path fprPath = tempDir.resolve(fprName); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } + + private void writeEntry(ZipOutputStream zipOutputStream, String entryName, String content) throws IOException { + zipOutputStream.putNextEntry(new ZipEntry(entryName)); + zipOutputStream.write(content.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + + private String remediationsXml(String hash) { + return """ + + + + + Example.java + %s + + 3 + 3 + void run() {\n oldOne();\n oldTwo(); + oldOne(); + newOne(); + + + + + + Example.java + %s + + 4 + 4 + oldOne();\n oldTwo();\n } + oldTwo(); + newTwo(); + + + + + """.formatted(hash, hash); + } + + private String singleRemediationXml(String hash) { + return """ + + + + + Example.java + %s + + 3 + 3 + void run() {\n oldOne();\n } + oldOne(); + newOne(); + + + + + """.formatted(hash); + } + + private String pathTraversalAndValidRemediationsXml(String hash) { + return """ + + + + + ../outside.java + %s + + 3 + 3 + void run() {\n oldOne();\n } + oldOne(); + ignored(); + + + + + + Example.java + %s + + 3 + 3 + void run() {\n oldOne();\n } + oldOne(); + newOne(); + + + + + """.formatted(hash, hash); + } + + @Test + void previewModeDoesNotModifySourceFiles() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-unchanged")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " oldTwo();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(remediationsXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(2, metric.appliedRemediations()); + assertTrue(metric.isPreview()); + + String actualContent = Files.readString(sourceFile, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertEquals(originalContent, actualContent); + assertTrue(actualContent.contains(" oldOne();")); + assertTrue(actualContent.contains(" oldTwo();")); + assertFalse(actualContent.contains("newOne")); + assertFalse(actualContent.contains("newTwo")); + } + } + + @Test + void publicPreviewAdapterDoesNotModifySourceFiles() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-adapter")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.writeString(sourceFile, "before\nTARGET\nafter\n", StandardCharsets.UTF_8); + String originalContent = Files.readString(sourceFile, StandardCharsets.UTF_8); + Path fprPath = createRemediationFpr(2, 2, 1, 1, "before\nTARGET\nafter", "TARGET", "REPLACED"); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + RemediationMetric metric = ApplyAutoRemediationOnSource.applyRemediations( + fprHandle, sourceDir.toString(), null, Set.of(), true); + + assertTrue(metric.isPreview()); + assertEquals(1, metric.appliedRemediations()); + } + + assertEquals(originalContent, Files.readString(sourceFile, StandardCharsets.UTF_8)); + } + + @Test + void previewModePopulatesPreviewDetailsWithChanges() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-details")); + Path sourceFile = sourceDir.resolve("Example.java"); + String originalContent = String.join("\n", + "class Example {", + " void run() {", + " oldOne();", + " }", + "}", + ""); + Files.writeString(sourceFile, originalContent, StandardCharsets.UTF_8); + + String hash = TestHashUtil.sha256Base64Unix(originalContent); + Path fprPath = createFpr(singleRemediationXml(hash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertTrue(metric.isPreview()); + assertEquals(1, metric.previewDetails().size()); + + var detail = metric.previewDetails().get(0); + assertEquals("ISSUE-1", detail.issueId()); + assertEquals("available", detail.status()); + assertNotNull(detail.files()); + assertEquals(1, detail.files().size()); + + var filePreview = detail.files().get("Example.java"); + assertNotNull(filePreview); + assertEquals("UTF-8", filePreview.encoding()); + assertEquals(1, filePreview.changes().size()); + + var change = filePreview.changes().get(0); + assertEquals(1, change.changeIndex()); + assertEquals(3, change.lineFrom()); + assertEquals(3, change.lineTo()); + assertTrue(change.originalCode().contains("oldOne")); + assertTrue(change.newCode().contains("newOne")); + } + } + + @Test + void previewShowsDeclaredXmlFieldsWhenSourceHashDoesNotMatch() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-xml-only")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.writeString(sourceFile, "class Example {\n void run() {\n drifted();\n }\n}\n", + StandardCharsets.UTF_8); + + String xmlHash = TestHashUtil.sha256Base64Unix("class Example {\n void run() {\n oldOne();\n }\n}\n"); + Path fprPath = createFpr(singleRemediationXml(xmlHash)); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var metric = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)).processRemediationXML(); + + assertTrue(metric.isPreview()); + assertEquals(1, metric.appliedRemediations()); + var change = metric.previewDetails().get(0).files().get("Example.java").changes().get(0); + assertEquals(3, change.lineFrom()); + assertEquals(3, change.lineTo()); + assertTrue(change.originalCode().contains("oldOne")); + assertTrue(change.newCode().contains("newOne")); + } + assertTrue(Files.readString(sourceFile, StandardCharsets.UTF_8).contains("drifted();")); + } + + @Test + void previewModeCapturesSkipReasonsInPreviewDetails() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-skip")); + // Create file for one remediation, but not the other + Path validFile = sourceDir.resolve("Valid.java"); + Files.writeString(validFile, "class Valid { void run() { old(); } }", StandardCharsets.UTF_8); + + String validHash = TestHashUtil.sha256Base64Unix("class Valid { void run() { old(); } }"); + String missingHash = "dGVzdGhhc2g="; // arbitrary hash for missing file + + String xml = """ + + + + + Valid.java + %s + + 1 + 1 + class Valid { void run() { old(); } } + old(); + new(); + + + + + + Missing.java + %s + + 1 + 1 + ignored + ignored + ignored + + + + + """.formatted(validHash, missingHash); + + Path fprPath = tempDir.resolve("test-skip.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Valid.java + + + Missing.java + + + + + """); + writeEntry(zipOutputStream, "remediations.xml", xml); + } + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertEquals(2, metric.totalRemediations()); + assertEquals(1, metric.appliedRemediations()); + assertEquals(1, metric.skippedRemediations()); + + assertTrue(metric.isPreview()); + assertEquals(2, metric.previewDetails().size()); + + var available = metric.previewDetails().stream() + .filter(d -> "available".equals(d.status())) + .findFirst().orElseThrow(); + assertEquals("ISSUE-VALID", available.issueId()); + assertNotNull(available.files().get("Valid.java")); + + var skipped = metric.previewDetails().stream() + .filter(d -> "skipped".equals(d.status())) + .findFirst().orElseThrow(); + assertEquals("ISSUE-MISSING", skipped.issueId()); + assertTrue(skipped.files().isEmpty()); + } + } + + @Test + void previewModeWithEmptyRemediationsXmlReturnsEmptyList() throws Exception { + Path sourceDir = Files.createDirectory(tempDir.resolve("src-preview-empty")); + Path sourceFile = sourceDir.resolve("Example.java"); + Files.writeString(sourceFile, "class Example { }", StandardCharsets.UTF_8); + + String emptyXml = """ + + + + """; + + Path fprPath = tempDir.resolve("test-empty.fpr"); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zipOutputStream, "audit.fvdl", """ + + + + + + Example.java + + + + + """); + writeEntry(zipOutputStream, "remediations.xml", emptyXml); + } + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + var processor = new RemediationProcessor(fprHandle, sourceDir.toString(), + options(Set.of(), RemediationExecutionMode.PREVIEW)); + var metric = processor.processRemediationXML(); + + assertEquals(0, metric.totalRemediations()); + assertEquals(0, metric.appliedRemediations()); + + assertTrue(metric.isPreview()); + assertEquals(List.of(), metric.previewDetails()); + } + } + + private RemediationProcessingOptions options(Set issueIds, RemediationExecutionMode executionMode) { + return new RemediationProcessingOptions(issueIds, executionMode, SourceDecoders.defaults()); + } + + private static final class TestHashUtil { + private static String sha256Base64Unix(String content) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(content.replace("\r\n", "\n").getBytes(StandardCharsets.UTF_8)); + return java.util.Base64.getEncoder().encodeToString(digest); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + } + + private static String sha256Base64(byte[] bytes) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return Base64.getEncoder().encodeToString(digest.digest(bytes)); + } + + private Path writeSourceFile(String content) throws Exception { + return writeSourceFile("Example.java", content); + } + + private Path writeSourceFile(String filename, String content) throws Exception { + Path sourceFile = tempDir.resolve(filename); + Files.writeString(sourceFile, content, StandardCharsets.UTF_8); + return sourceFile; + } + + private record RemediationSpec(String instanceId, int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode) {} + + private Path createRemediationFpr(List specs) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + StringBuilder remediations = new StringBuilder(); + for (RemediationSpec spec : specs) { + remediations.append(""" + + test + + Example.java + not-the-source-hash + + %d + %d + %s + %s + %s + + + + """.formatted(spec.instanceId(), spec.lineFrom(), spec.lineTo(), spec.contextBefore(), + spec.contextAfter(), spec.context(), spec.originalCode(), spec.newCode())); + } + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + %s + + + """.formatted(REMEDIATIONS_NAMESPACE, remediations); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } + + private Path createRemediationFpr(int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + + test + + Example.java + not-the-source-hash + + %d + %d + %s + %s + %s + + + + + + """.formatted(REMEDIATIONS_NAMESPACE, lineFrom, lineTo, contextBefore, contextAfter, + context, originalCode, newCode); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } + + private Path createRemediationFprWithHash(int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode, String hash) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + + test + + Example.java + %s + + %d + %d + %s + %s + %s + + + + + + """.formatted(REMEDIATIONS_NAMESPACE, hash, lineFrom, lineTo, contextBefore, contextAfter, + context, originalCode, newCode); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } + + private record FileChangeSpec(String filename, int lineFrom, int lineTo, int contextBefore, int contextAfter, + String context, String originalCode, String newCode) {} + + private Path createMultiFileRemediationFpr(String instanceId, List fileChangeSpecs) throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + StringBuilder fileChanges = new StringBuilder(); + for (FileChangeSpec spec : fileChangeSpecs) { + fileChanges.append(""" + + %s + not-the-source-hash + + %d + %d + %s + %s + %s + + + """.formatted(spec.filename(), spec.lineFrom(), spec.lineTo(), spec.contextBefore(), + spec.contextAfter(), spec.context(), spec.originalCode(), spec.newCode())); + } + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + + test + %s + + + + """.formatted(REMEDIATIONS_NAMESPACE, instanceId, fileChanges); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } + + /** + * Builds an FPR with multiple remediations (insertion order preserved), each of which may + * itself contain multiple file changes/hunks - used to construct mixed-outcome scenarios + * where hunks within the same remediation classify differently against the ledger. + */ + private Path createRemediationFprWithRemediations(LinkedHashMap> remediationFileChanges) + throws Exception { + Path fprPath = tempDir.resolve("remediation.fpr"); + StringBuilder remediationsXml = new StringBuilder(); + for (Map.Entry> entry : remediationFileChanges.entrySet()) { + StringBuilder fileChanges = new StringBuilder(); + for (FileChangeSpec spec : entry.getValue()) { + fileChanges.append(""" + + %s + not-the-source-hash + + %d + %d + %s + %s + %s + + + """.formatted(spec.filename(), spec.lineFrom(), spec.lineTo(), spec.contextBefore(), + spec.contextAfter(), spec.context(), spec.originalCode(), spec.newCode())); + } + remediationsXml.append(""" + + test + %s + + """.formatted(entry.getKey(), fileChanges)); + } + String remediationXml = """ + + + + test + 2026-08-26T00:00:00Z + + + %s + + + """.formatted(REMEDIATIONS_NAMESPACE, remediationsXml); + + try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(fprPath))) { + zipOutputStream.putNextEntry(new ZipEntry("remediations.xml")); + zipOutputStream.write(remediationXml.getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + return fprPath; + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChangeTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChangeTest.java new file mode 100644 index 00000000000..71cb8f40249 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/AppliedChangeTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.Test; + +class AppliedChangeTest { + + /** + * {@code contentCovers} must return {@code false}, not {@code true}, when either side's + * comparison code is unavailable ({@code null}) or blank - unavailable content is not proof + * of coverage. These branches are unreachable end-to-end through + * {@code RemediationProcessor.processRemediationXML} + * (see {@link RemediationProcessorKnownIssuesTest#malformedRemediationIsSkippedAndValidRemediationsStillApply}: + * a missing comparison code causes the remediation to be skipped before the classifier is + * ever reached for it), so this is asserted directly against {@link AppliedChange}. + */ + @Test + void unavailableComparisonCodeIsNotTreatedAsProvenCoverage() { + assertFalse( + AppliedChange.builder() + .originalLineFrom(1) + .originalLineTo(3) + .deltaLines(0) + .comparisonCode("W1W2W3") + .build() + .contentCovers(null, 2, 2), + "a candidate whose content could not be computed has not been proven covered"); + + assertFalse( + AppliedChange.builder() + .originalLineFrom(1) + .originalLineTo(3) + .deltaLines(0) + .comparisonCode(null) + .build() + .contentCovers("M2", 2, 2), + "an applied change whose content is unknown cannot prove it covers anything"); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetricTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetricTest.java new file mode 100644 index 00000000000..842e7aeeda0 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/model/RemediationMetricTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.remediation.RemediationExecutionMode; +import com.fortify.cli.aviator.fpr.remediation.preview.PreviewDetail; + +class RemediationMetricTest { + @Test + void builderDefaultsToApplyModeAndEmptyCollections() { + RemediationMetric metric = RemediationMetric.builder().build(); + + assertFalse(metric.isPreview()); + assertFalse(metric.isFiltered()); + assertEquals(RemediationExecutionMode.APPLY, metric.executionMode()); + assertEquals(Set.of(), metric.modifiedFiles()); + assertEquals(List.of(), metric.previewDetails()); + } + + @Test + void filteredMetricPreservesRequestedAndAppliedIssueIds() { + RemediationMetric metric = RemediationMetric.builder() + .totalRemediations(2) + .appliedRemediations(1) + .skippedRemediations(1) + .requestedIssueIds(Set.of("ISSUE-1", "ISSUE-404")) + .appliedIssueIds(Set.of("ISSUE-1")) + .modifiedFiles(Set.of("Example.java")) + .build(); + + assertTrue(metric.isFiltered()); + assertEquals(Set.of("ISSUE-1", "ISSUE-404"), metric.requestedIssueIds()); + assertEquals(Set.of("ISSUE-1"), metric.appliedIssueIds()); + assertEquals(1, metric.skippedRemediations()); + } + + @Test + void previewMetricCarriesTopLevelPreviewDetails() { + PreviewDetail detail = PreviewDetail.skipped("ISSUE-1", null); + RemediationMetric metric = RemediationMetric.builder() + .executionMode(RemediationExecutionMode.PREVIEW) + .previewDetails(List.of(detail)) + .build(); + + assertTrue(metric.isPreview()); + assertEquals(List.of(detail), metric.previewDetails()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDtoTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDtoTest.java new file mode 100644 index 00000000000..a580c0d4e6a --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/remediation/preview/PreviewDtoTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.remediation.preview; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.exception.AviatorBugException; + +class PreviewDtoTest { + @Test + void contextMetadataValidatesCountsAndNormalizesNullContent() { + ContextMetadata metadata = new ContextMetadata(2, 3, "context content"); + assertEquals(2, metadata.linesBefore()); + assertEquals(3, metadata.linesAfter()); + assertEquals("context content", metadata.content()); + assertEquals("", new ContextMetadata(0, 0, null).content()); + assertThrows(AviatorBugException.class, () -> new ContextMetadata(-1, 0, "context")); + assertThrows(AviatorBugException.class, () -> new ContextMetadata(0, -1, "context")); + } + + @Test + void previewFileChangeValidatesIdentityRangeAndContext() { + ContextMetadata context = new ContextMetadata(1, 1, "context"); + + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(0, 10, 12, "old", "new", context)); + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(1, 0, 12, "old", "new", context)); + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(1, 12, 10, "old", "new", context)); + assertThrows(AviatorBugException.class, + () -> new PreviewFileChange(1, 10, 12, "old", "new", null)); + assertEquals(10, new PreviewFileChange(1, 10, 10, "old", "new", context).lineTo()); + } + + @Test + void previewFileChangeBuilderPreservesValues() { + ContextMetadata context = new ContextMetadata(2, 2, "context line"); + PreviewFileChange change = PreviewFileChange.builder() + .changeIndex(1) + .lineFrom(10) + .lineTo(12) + .originalCode("old code") + .newCode("new code") + .context(context) + .build(); + + assertEquals(1, change.changeIndex()); + assertEquals(10, change.lineFrom()); + assertEquals(12, change.lineTo()); + assertEquals("old code", change.originalCode()); + assertEquals("new code", change.newCode()); + assertEquals(context, change.context()); + } + + @Test + void filePreviewNormalizesAndProtectsChanges() { + assertThrows(AviatorBugException.class, () -> new FilePreview(null, "UTF-8", List.of())); + assertThrows(AviatorBugException.class, () -> new FilePreview(" ", "UTF-8", List.of())); + FilePreview empty = new FilePreview("Example.java", "UTF-8", null); + assertEquals("Example.java", empty.path()); + assertEquals("UTF-8", empty.encoding()); + assertEquals(List.of(), empty.changes()); + + PreviewFileChange change = new PreviewFileChange(1, 1, 1, "old", "new", + new ContextMetadata(0, 0, "old")); + FilePreview preview = new FilePreview("Example.java", "UTF-8", List.of(change)); + assertEquals(1, preview.totalChanges()); + assertThrows(UnsupportedOperationException.class, () -> preview.changes().add(change)); + } + + @Test + void previewDetailFactoriesAndFilesAreStable() { + Map files = new LinkedHashMap<>(); + files.put("Example.java", new FilePreview("Example.java", "UTF-8", List.of())); + + PreviewDetail available = PreviewDetail.available("ISSUE-1", "description", files); + PreviewDetail skipped = PreviewDetail.skipped("ISSUE-2", null); + + assertEquals("ISSUE-1", available.issueId()); + assertEquals("available", available.status()); + assertEquals("description", available.description()); + assertTrue(available.isAvailable()); + assertEquals("skipped", skipped.status()); + assertTrue(skipped.isSkipped()); + assertEquals(Map.of(), skipped.files()); + assertEquals(0, new PreviewDetail("ISSUE-1", "available", null, null).files().size()); + assertThrows(UnsupportedOperationException.class, + () -> available.files().put("Other.java", new FilePreview("Other.java", "UTF-8", List.of()))); + assertThrows(AviatorBugException.class, () -> new PreviewDetail(null, "available", null, Map.of())); + assertThrows(AviatorBugException.class, () -> new PreviewDetail("", "available", null, Map.of())); + assertThrows(AviatorBugException.class, () -> new PreviewDetail("ISSUE-1", null, null, Map.of())); + assertThrows(AviatorBugException.class, () -> new PreviewDetail("ISSUE-1", " ", null, Map.of())); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/utils/SourceCodeEnricherTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/utils/SourceCodeEnricherTest.java new file mode 100644 index 00000000000..3e42be508d8 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/utils/SourceCodeEnricherTest.java @@ -0,0 +1,267 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; +import com.fortify.cli.aviator.audit.model.StackTraceElement; +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.util.FprHandle; + +class SourceCodeEnricherTest { + + @TempDir + Path tempDir; + + @Test + void reportsEveryUniqueDecodeFailureAndDoesNotHideSuccessfulFiles() throws Exception { + Map sourceFiles = new LinkedHashMap<>(); + sourceFiles.put("good.java", "class Good {}".getBytes(StandardCharsets.UTF_8)); + sourceFiles.put("bad-one.java", new byte[] {(byte) 0xFF}); + sourceFiles.put("bad-two.java", new byte[] {(byte) 0xFE}); + Path fprPath = createFpr(sourceFiles); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + SourceCodeEnricher.EnrichmentResult result = new SourceCodeEnricher( + fprHandle, SourceDecoders.fromToken("UTF-8"), null) + .enrichWithSourceCodeDetailed(List.of(List.of( + element("good.java"), + element("bad-one.java"), + element("bad-two.java"), + element("bad-one.java")))); + + assertTrue(result.hasFailures()); + assertEquals(List.of("bad-one.java", "bad-two.java"), + result.failures().stream().map(SourceCodeEnricher.SourceFileFailure::filename).toList()); + assertEquals(List.of(AuditSkipReason.SOURCE_FILE_DECODE_FAILED, AuditSkipReason.SOURCE_FILE_DECODE_FAILED), + result.failures().stream().map(SourceCodeEnricher.SourceFileFailure::reason).toList()); + assertEquals(List.of("good.java"), result.files().keySet().stream().toList()); + } + } + + @Test + void classifiesReadFailuresSeparatelyFromDecodeFailures() throws Exception { + Path fprPath = createFprWithMissingSource("missing.java"); + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + SourceCodeEnricher.EnrichmentResult result = new SourceCodeEnricher( + fprHandle, SourceDecoders.fromToken("UTF-8"), null) + .enrichWithSourceCodeDetailed(List.of(List.of(element("missing.java")))); + + assertEquals(AuditSkipReason.SOURCE_FILE_READ_FAILED, result.failures().get(0).reason()); + } + } + + @Test + void cachesSuccessfulAndFailedLoadsAcrossEnrichmentCalls() throws Exception { + Map sourceFiles = new LinkedHashMap<>(); + sourceFiles.put("good.java", "class Good {}".getBytes(StandardCharsets.UTF_8)); + sourceFiles.put("bad.java", new byte[] {(byte) 0xFF}); + Path fprPath = createFpr(sourceFiles); + AtomicInteger decodeCalls = new AtomicInteger(); + ISourceDecoder decoder = new ISourceDecoder() { + @Override + public DecodeResult decode(byte[] bytes, String filename, FVDLMetadata metadata) { + decodeCalls.incrementAndGet(); + if (bytes.length > 0 && bytes[0] == (byte) 0xFF) { + throw new SourceDecodeException("decode failed"); + } + return new DecodeResult(new String(bytes, StandardCharsets.UTF_8), StandardCharsets.UTF_8, "test"); + } + + @Override + public String describe() { + return "test"; + } + }; + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + SourceCodeEnricher enricher = new SourceCodeEnricher(fprHandle, decoder, null); + List> stackTraces = List.of(List.of(element("good.java"), element("bad.java"))); + + SourceCodeEnricher.EnrichmentResult firstResult = enricher.enrichWithSourceCodeDetailed(stackTraces); + SourceCodeEnricher.EnrichmentResult secondResult = enricher.enrichWithSourceCodeDetailed(stackTraces); + + assertEquals(2, decodeCalls.get()); + assertEquals(List.of("good.java"), secondResult.files().keySet().stream().toList()); + assertEquals(List.of("bad.java"), secondResult.failures().stream() + .map(SourceCodeEnricher.SourceFileFailure::filename).toList()); + assertEquals(firstResult.files().get("good.java").getContent(), secondResult.files().get("good.java").getContent()); + } + } + + @Test + void loadsAFileOnceWhenEnrichmentCallsAreConcurrent() throws Exception { + Map sourceFiles = Map.of("good.java", "class Good {}".getBytes(StandardCharsets.UTF_8)); + Path fprPath = createFpr(sourceFiles); + AtomicInteger decodeCalls = new AtomicInteger(); + CountDownLatch decodeStarted = new CountDownLatch(1); + CountDownLatch allowDecode = new CountDownLatch(1); + ISourceDecoder decoder = new ISourceDecoder() { + @Override + public DecodeResult decode(byte[] bytes, String filename, FVDLMetadata metadata) { + decodeCalls.incrementAndGet(); + decodeStarted.countDown(); + try { + if (!allowDecode.await(5, TimeUnit.SECONDS)) { + throw new SourceDecodeException("Timed out waiting to decode"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SourceDecodeException("Interrupted while decoding", e); + } + return new DecodeResult(new String(bytes, StandardCharsets.UTF_8), StandardCharsets.UTF_8, "test"); + } + + @Override + public String describe() { + return "test"; + } + }; + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + SourceCodeEnricher enricher = new SourceCodeEnricher(fprHandle, decoder, null); + List> stackTraces = List.of(List.of(element("good.java"))); + Future first = executor.submit( + () -> enricher.enrichWithSourceCodeDetailed(stackTraces)); + Future second = executor.submit( + () -> enricher.enrichWithSourceCodeDetailed(stackTraces)); + + assertTrue(decodeStarted.await(5, TimeUnit.SECONDS)); + allowDecode.countDown(); + + assertEquals(List.of("good.java"), first.get(5, TimeUnit.SECONDS).files().keySet().stream().toList()); + assertEquals(List.of("good.java"), second.get(5, TimeUnit.SECONDS).files().keySet().stream().toList()); + assertEquals(1, decodeCalls.get()); + } finally { + executor.shutdownNow(); + } + } + } + + @Test + void cachesConcurrentDecodeFailureOnce() throws Exception { + Map sourceFiles = Map.of("bad.java", new byte[] {(byte) 0xFF}); + Path fprPath = createFpr(sourceFiles); + AtomicInteger decodeCalls = new AtomicInteger(); + CountDownLatch decodeStarted = new CountDownLatch(1); + CountDownLatch allowDecode = new CountDownLatch(1); + ISourceDecoder decoder = new ISourceDecoder() { + @Override + public DecodeResult decode(byte[] bytes, String filename, FVDLMetadata metadata) { + decodeCalls.incrementAndGet(); + decodeStarted.countDown(); + try { + if (!allowDecode.await(5, TimeUnit.SECONDS)) { + throw new SourceDecodeException("Timed out waiting to decode"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SourceDecodeException("Interrupted while decoding", e); + } + throw new SourceDecodeException("decode failed"); + } + + @Override + public String describe() { + return "test"; + } + }; + + try (FprHandle fprHandle = new FprHandle(fprPath)) { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + SourceCodeEnricher enricher = new SourceCodeEnricher(fprHandle, decoder, null); + List> stackTraces = List.of(List.of(element("bad.java"))); + Future first = executor.submit( + () -> enricher.enrichWithSourceCodeDetailed(stackTraces)); + Future second = executor.submit( + () -> enricher.enrichWithSourceCodeDetailed(stackTraces)); + + assertTrue(decodeStarted.await(5, TimeUnit.SECONDS)); + allowDecode.countDown(); + + assertEquals(List.of("bad.java"), first.get(5, TimeUnit.SECONDS).failures().stream() + .map(SourceCodeEnricher.SourceFileFailure::filename).toList()); + assertEquals(List.of("bad.java"), second.get(5, TimeUnit.SECONDS).failures().stream() + .map(SourceCodeEnricher.SourceFileFailure::filename).toList()); + assertEquals(1, decodeCalls.get()); + } finally { + executor.shutdownNow(); + } + } + } + + private static StackTraceElement element(String filename) { + return new StackTraceElement(filename, 1, "", "", null, null, null); + } + + private Path createFpr(Map sourceFiles) throws IOException { + Path fprPath = tempDir.resolve("source-enricher-test.fpr"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zip, "src-archive/index.xml", createIndex(sourceFiles.keySet()).getBytes(StandardCharsets.UTF_8)); + int index = 1; + for (Map.Entry sourceFile : sourceFiles.entrySet()) { + writeEntry(zip, "src-archive/" + index++, sourceFile.getValue()); + } + } + return fprPath; + } + + private Path createFprWithMissingSource(String filename) throws IOException { + Path fprPath = tempDir.resolve("source-enricher-missing-source.fpr"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(fprPath))) { + writeEntry(zip, "src-archive/index.xml", createIndex(List.of(filename)).getBytes(StandardCharsets.UTF_8)); + } + return fprPath; + } + + private static String createIndex(Iterable filenames) { + StringBuilder index = new StringBuilder(""); + int entry = 1; + for (String filename : filenames) { + index.append("src-archive/") + .append(entry++).append(""); + } + return index.append("").toString(); + } + + private static void writeEntry(ZipOutputStream zip, String name, byte[] bytes) throws IOException { + zip.putNextEntry(new ZipEntry(name)); + zip.write(bytes); + zip.closeEntry(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/utils/SourceDecodersTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/utils/SourceDecodersTest.java new file mode 100644 index 00000000000..bdc8f254957 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/fpr/utils/SourceDecodersTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.fpr.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.fpr.model.FVDLMetadata; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.DecodeResult; +import com.fortify.cli.aviator.fpr.utils.ISourceDecoder.SourceDecodeException; +import com.fortify.cli.aviator.fpr.utils.SourceEncoder.SourceEncodeException; + +/** + * Behavior tests for source encoding factory + decode/encode. + * Prefer real byte decode outcomes over describe()-only checks. + */ +class SourceDecodersTest { + + @Test + void fromToken_rejectsBlankAndUnknownCharset() { + assertThrows(IllegalArgumentException.class, () -> SourceDecoders.fromToken(" ")); + assertThrows(UnsupportedCharsetException.class, () -> SourceDecoders.fromToken("NOT-A-CHARSET")); + } + + @Test + void defaults_matchDocumentedCandidateOrder() { + assertEquals("FPR,UTF-8,ISO-8859-1", SourceDecoders.DEFAULT_SOURCE_ENCODINGS); + assertEquals(SourceDecoders.DEFAULT_SOURCE_ENCODINGS, SourceDecoders.defaults().describe()); + } + + @Test + void decode_fallsThroughFprWhenMetadataMissing() { + byte[] utf8 = "hello".getBytes(StandardCharsets.UTF_8); + DecodeResult result = SourceDecoders.defaults().decode(utf8, "Main.java", null); + assertEquals("hello", result.content()); + assertEquals(StandardCharsets.UTF_8, result.charset()); + assertEquals("UTF-8", result.source()); + } + + @Test + void decode_fallsThroughUtf8ToIso88591ByDefault() { + byte[] iso88591 = {(byte) 0xE9}; + + DecodeResult result = SourceDecoders.defaults().decode(iso88591, "Main.java", null); + + assertEquals("\u00e9", result.content()); + assertEquals(StandardCharsets.ISO_8859_1, result.charset()); + assertEquals("ISO-8859-1", result.source()); + } + + @Test + void decode_usesFprEncodingWhenMetadataPresent() { + FVDLMetadata metadata = new FVDLMetadata(); + metadata.registerSourceFileEncoding("src/Main.java", "ISO-8859-1"); + byte[] latin1 = "caf\u00e9".getBytes(StandardCharsets.ISO_8859_1); + + DecodeResult result = SourceDecoders.defaults().decode(latin1, "src/Main.java", metadata); + + assertEquals("caf\u00e9", result.content()); + assertEquals(StandardCharsets.ISO_8859_1, result.charset()); + assertTrue(result.source().startsWith("FPR("), result.source()); + } + + @Test + void decode_usesWindows1252FromFprMetadata() { + FVDLMetadata metadata = new FVDLMetadata(); + metadata.registerSourceFileEncoding("payments.c", "windows-1252"); + byte[] windows1252 = new byte[] {(byte) 0x93, 'p', 'r', 'e', 'm', 'i', 'u', 'm', (byte) 0x94}; + + DecodeResult result = SourceDecoders.defaults().decode(windows1252, "payments.c", metadata); + + assertEquals("\u201cpremium\u201d", result.content()); + assertEquals("windows-1252", result.charset().name()); + assertEquals("FPR(windows-1252)", result.source()); + } + + @Test + void decode_allCandidatesFail_messageListsAttempts() { + ISourceDecoder decoder = SourceDecoders.fromCsv("UTF-8,US-ASCII"); + byte[] invalid = new byte[] {(byte) 0xFF, (byte) 0xFE, (byte) 0x00}; + + SourceDecodeException ex = assertThrows(SourceDecodeException.class, + () -> decoder.decode(invalid, "bad.bin", null)); + + assertTrue(ex.getMessage().contains("bad.bin"), ex.getMessage()); + assertTrue(ex.getMessage().contains("UTF-8"), ex.getMessage()); + assertTrue(ex.getMessage().contains("US-ASCII"), ex.getMessage()); + } + + @Test + void encode_roundTripAndRejectsUnmappable() { + String content = "secure code"; + byte[] encoded = SourceEncoder.encode(content, StandardCharsets.UTF_8, "a.java"); + assertEquals(content, SourceDecoders.fromToken("UTF-8").decode(encoded, "a.java", null).content()); + + assertThrows(SourceEncodeException.class, + () -> SourceEncoder.encode("caf\u00e9", StandardCharsets.US_ASCII, "a.java")); + } + + @Test + void fromToken_fprIsCaseInsensitive() { + assertEquals("FPR", SourceDecoders.fromToken("fpr").describe()); + } + + @Test + void fromCsv_compositesMultipleCandidates() { + assertEquals("UTF-8,CP850", SourceDecoders.fromCsv("UTF-8,CP850").describe()); + assertEquals("UTF-8", SourceDecoders.fromCsv("UTF-8").describe()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java index 638b73fdb4b..91ceb215f87 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/AviatorGrpcClientHelperTest.java @@ -15,12 +15,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.InetSocketAddress; import java.net.SocketAddress; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import com.fortify.cli.aviator._common.exception.UnsupportedAviatorUrlSchemeException; import com.fortify.cli.common.http.proxy.helper.ProxyDescriptor; import io.grpc.HttpConnectProxiedSocketAddress; @@ -28,20 +33,62 @@ class AviatorGrpcClientHelperTest { @Test void shouldParseSchemeLessHostAndPort() { - var parsed = AviatorGrpcClientHelper.parseTarget("aviator.example.com:443"); + var parsed = AviatorGrpcClientHelper.parseTarget("aviator.invalid:443"); - assertEquals("aviator.example.com", parsed.host()); + assertEquals("aviator.invalid", parsed.host()); assertEquals(443, parsed.port()); } @Test void shouldParseTargetWithoutPort() { - var parsed = AviatorGrpcClientHelper.parseTarget("aviator.example.com"); + var parsed = AviatorGrpcClientHelper.parseTarget("aviator.invalid"); - assertEquals("aviator.example.com", parsed.host()); + assertEquals("aviator.invalid", parsed.host()); assertNull(parsed.port()); } + @Test + void shouldCreateConnectionPlanWithDefaultPortAndNormalizedUrl() { + var plan = AviatorGrpcClientHelper.createConnectionPlan("aviator.invalid"); + + assertEquals("aviator.invalid", plan.originalUrl()); + assertEquals("https://aviator.invalid", plan.normalizedUrl()); + assertEquals("aviator.invalid", plan.target().host()); + assertNull(plan.target().port()); + assertEquals(443, plan.effectivePort()); + } + + @Test + void shouldCreateConnectionPlanWithExplicitPort() { + var plan = AviatorGrpcClientHelper.createConnectionPlan("https://aviator.invalid:8443/"); + + assertEquals("https://aviator.invalid:8443/", plan.originalUrl()); + assertEquals("https://aviator.invalid:8443/", plan.normalizedUrl()); + assertEquals("aviator.invalid", plan.target().host()); + assertEquals(8443, plan.target().port()); + assertEquals(8443, plan.effectivePort()); + } + + @Test + void shouldCanonicalizeHttpsSchemeCasing() { + assertEquals("https://aviator.invalid", AviatorGrpcClientHelper.normalizeUrl("HTTPS://aviator.invalid")); + } + + @ParameterizedTest + @ValueSource(strings = { + "mttp://vacant", + "ftp://aviator.invalid", + "http://aviator.invalid", + "grpc://aviator.invalid" + }) + void shouldRejectNonHttpsSchemes(String url) { + var ex = assertThrows(UnsupportedAviatorUrlSchemeException.class, + () -> AviatorGrpcClientHelper.createConnectionPlan(url)); + assertEquals(url.substring(0, url.indexOf("://")), ex.getScheme()); + assertEquals(url, ex.getProvidedUrl()); + assertTrue(ex.getMessage().contains(UnsupportedAviatorUrlSchemeException.STAGE_SUMMARY)); + } + @Test void shouldBuildHttpConnectProxyAddressWithCredentials() { var proxy = ProxyDescriptor.builder() @@ -50,7 +97,7 @@ void shouldBuildHttpConnectProxyAddressWithCredentials() { .proxyUser("user") .proxyPassword("pwd".toCharArray()) .build(); - var target = new InetSocketAddress("aviator.example.com", 443); + var target = new InetSocketAddress("aviator.invalid", 443); var proxied = AviatorGrpcClientHelper.toProxiedSocketAddress(target, proxy); @@ -64,7 +111,7 @@ void shouldBuildHttpConnectProxyAddressWithCredentials() { @Test void shouldIgnoreUnsupportedSocketAddressTypes() { - var proxy = ProxyDescriptor.builder().proxyHost("proxy.example.com").proxyPort(8443).build(); + var proxy = ProxyDescriptor.builder().proxyHost("proxy.invalid").proxyPort(8443).build(); SocketAddress unsupportedTarget = new SocketAddress() { private static final long serialVersionUID = 1L; }; var proxied = AviatorGrpcClientHelper.toProxiedSocketAddress(unsupportedTarget, proxy); diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java index b0207c6e577..00ca9dc474b 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/CorrelationStreamProcessorTest.java @@ -15,9 +15,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; @@ -59,6 +61,25 @@ void buildUrlToDastMapPreservesUrlEncounterOrder() throws Exception { ); } + @Test + void buildCorrelationWorkItemsSkipsFindingWhenAllPairsWereTried() throws Exception { + CorrelationStreamProcessor processor = new CorrelationStreamProcessor(null, null, null, null, 0, 0); + CorrelationStreamProcessor.CorrelationBucketData bucket = createBucket( + "SQL Injection", + List.of("https://example.com/login", "https://example.com/admin") + ); + Map> urlMap = invokeBuildUrlToDastMap(processor, List.of(bucket)); + setField(processor, "urlToDastIssues", urlMap); + setField(processor, "previouslyTriedPairKeys", Set.of( + "SAST-1::https://example.com/login", + "SAST-1::https://example.com/admin" + )); + + List items = invokeBuildCorrelationWorkItems(processor, List.of(bucket)); + + assertEquals(0, items.size()); + } + private CorrelationStreamProcessor.CorrelationBucketData createBucket(String category, List sessionUrls) { List dastIssues = sessionUrls.stream() .map(this::createDastIssue) @@ -96,4 +117,10 @@ private List getDastUrls(Object workItem) throws Exception { method.setAccessible(true); return (List) method.invoke(workItem); } + + private void setField(CorrelationStreamProcessor processor, String fieldName, Object value) throws Exception { + Field field = CorrelationStreamProcessor.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(processor, value); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java new file mode 100644 index 00000000000..8f37a595127 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditRequestMapperTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastReproStep; +import com.fortify.cli.aviator.dast.DastSession; + +class DastAuditRequestMapperTest { + @Test + void mapsCompleteFindingContext() { + var session = new DastSession(); + session.setRequestId("request-1"); + session.setUrl("https://example.test"); + session.setRawRequest("GET / HTTP/1.1"); + session.setRawResponse("HTTP/1.1 200 OK"); + var issue = new DastIssue(); + issue.setId("DAST-1"); + issue.setName("SQL Injection"); + issue.getClassifications().put("CWE", "Improper Neutralization"); + var step = new DastReproStep(); + step.setSource("Attack"); + step.setUrl("https://example.test?id=1"); + step.setPostParams("id=1"); + issue.getReproSteps().add(step); + issue.getReproStepUrls().add(step.getUrl()); + + var context = DastAuditRequestMapper.toFindingContext(session, issue); + + assertEquals("DAST-1", context.getIssueId()); + assertEquals("GET / HTTP/1.1", context.getRawRequest()); + assertEquals("CWE", context.getClassifications(0).getKind()); + assertEquals("Attack", context.getReproSteps(0).getSource()); + assertEquals("id=1", context.getReproSteps(0).getPostParams()); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java new file mode 100644 index 00000000000..54bc40c9591 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditResponseMapperTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import org.junit.jupiter.api.Test; + +import com.fortify.aviator.dastaudit.DastAuditDecision; +import com.fortify.aviator.dastaudit.DastAuditResponse; +import com.fortify.cli.aviator.audit.model.AuditTier; + +class DastAuditResponseMapperTest { + @Test + void usesIssueIdAssociatedWithRequest() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-1") + .setStatus("SUCCESS") + .setDecision(DastAuditDecision.newBuilder().setTruePositive(true).setConfidence("HIGH")) + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + var success = assertInstanceOf(DastAuditResult.Success.class, result); + assertEquals("DAST-1", result.issueId()); + assertEquals("SUCCESS", result.status()); + assertEquals(true, success.truePositive()); + assertEquals(AuditTier.SILVER, success.tier()); + } + + @Test + void parsesKnownTierAndDefaultsUnknownTierToSilver() { + assertEquals(AuditTier.GOLD, mapTier("gold")); + assertEquals(AuditTier.SILVER, mapTier("BRONZE")); + } + + @Test + void rejectsMismatchedServerIssueId() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-WRONG") + .setStatus("SUCCESS") + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + assertInstanceOf(DastAuditResult.Failure.class, result); + assertEquals("DAST-1", result.issueId()); + assertEquals("FAILED", result.status()); + } + + @Test + void preservesSkippedResponseAsDistinctVariant() { + var response = DastAuditResponse.newBuilder() + .setRequestId("request-1") + .setDastIssueId("DAST-1") + .setStatus("SKIPPED") + .setStatusMessage("Quota exceeded") + .build(); + + DastAuditResult result = DastAuditResponseMapper.map(response, "DAST-1"); + + assertInstanceOf(DastAuditResult.Skipped.class, result); + assertEquals("SKIPPED", result.status()); + assertEquals("Quota exceeded", result.statusMessage()); + } + + private AuditTier mapTier(String tier) { + var response = DastAuditResponse.newBuilder() + .setDastIssueId("DAST-1") + .setStatus("SUCCESS") + .setDecision(DastAuditDecision.newBuilder().setTier(tier)) + .build(); + return ((DastAuditResult.Success) DastAuditResponseMapper.map(response, "DAST-1")).tier(); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java new file mode 100644 index 00000000000..b3eefdb47f6 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamConfigTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator._common.exception.AviatorSimpleException; + +class DastAuditStreamConfigTest { + @Test + void rejectsBlankToken() { + assertThrows(AviatorSimpleException.class, + () -> validConfigBuilder().token(" ").build()); + } + + @Test + void rejectsBlankApplicationName() { + assertThrows(AviatorSimpleException.class, + () -> validConfigBuilder().applicationName(" ").build()); + } + + private DastAuditStreamConfig.DastAuditStreamConfigBuilder validConfigBuilder() { + return DastAuditStreamConfig.builder() + .token("token") + .applicationName("app") + .sscApplicationName("ssc") + .sscApplicationVersion("1"); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java new file mode 100644 index 00000000000..b0a8952b977 --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamProcessorTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fortify.aviator.dastaudit.DastAuditClientMessage; +import com.fortify.cli.aviator.dast.DastIssue; +import com.fortify.cli.aviator.dast.DastSession; + +import io.grpc.Status; + +class DastAuditStreamProcessorTest { + @Test + void registersAllRequestsBeforeReturningMessagesForSending() { + var processor = new DastAuditStreamProcessor(null, null, null, 0); + + List requests = processor.prepareAuditRequests( + List.of(workItem("DAST-1"), workItem("DAST-2")), "stream-1"); + + assertEquals(List.of("DAST-1", "DAST-2"), requests.stream() + .map(request -> request.getAudit().getFinding().getIssueId()) + .toList()); + assertEquals(2, processor.pendingRequestCount()); + } + + @Test + void reusesRequestIdsAndRequeuesOnlyUnfinishedWork() { + var processor = new DastAuditStreamProcessor(null, null, null, 0); + List workItems = List.of(workItem("DAST-1"), workItem("DAST-2")); + processor.initializeWorkItems(workItems); + List firstAttempt = processor.prepareAuditRequests(workItems, "stream-1"); + + processor.completeRequest(firstAttempt.get(0).getAudit().getRequestId()); + List retry = processor.prepareAuditRequests( + processor.remainingWorkItems(), "stream-2"); + + assertEquals(1, retry.size()); + assertEquals("DAST-2", retry.get(0).getAudit().getFinding().getIssueId()); + assertEquals(firstAttempt.get(1).getAudit().getRequestId(), retry.get(0).getAudit().getRequestId()); + assertEquals("stream-2", retry.get(0).getAudit().getStreamId()); + } + + @Test + void retriesOnlyTransportDisconnections() { + assertTrue(DastAuditStreamProcessor.isRetryableError(Status.UNAVAILABLE.asRuntimeException())); + assertTrue(DastAuditStreamProcessor.isRetryableError( + Status.INTERNAL.withDescription("RST_STREAM closed").asRuntimeException())); + assertTrue(DastAuditStreamProcessor.isInfiniteRetryError( + Status.INTERNAL.withDescription("PROTOCOL_ERROR").asRuntimeException())); + assertFalse(DastAuditStreamProcessor.isRetryableError(Status.INVALID_ARGUMENT.asRuntimeException())); + } + + private DastAuditWorkItem workItem(String issueId) { + var issue = new DastIssue(); + issue.setId(issueId); + return new DastAuditWorkItem(new DastSession(), issue); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamResultTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamResultTest.java new file mode 100644 index 00000000000..c5e88e4414b --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/DastAuditStreamResultTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class DastAuditStreamResultTest { + @Test + void defensivelyCopiesResults() { + var source = new ArrayList(); + DastAuditStreamResult result = DastAuditStreamResult.builder().results(source).build(); + + source.add(DastAuditResult.Skipped.builder().issueId("DAST-1").build()); + + assertEquals(List.of(), result.results()); + assertThrows(UnsupportedOperationException.class, + () -> result.results().add(DastAuditResult.Skipped.builder().issueId("DAST-2").build())); + } + + @Test + void normalizesNullResultsToEmptyList() { + DastAuditStreamResult result = DastAuditStreamResult.builder().build(); + + assertEquals(List.of(), result.results()); + } +} diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java index 2f503d3d0a5..09285bae9e9 100644 --- a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/GrpcUtilTest.java @@ -19,6 +19,9 @@ import org.junit.jupiter.api.Test; +import com.fortify.aviator.grpc.AuditorResponse; +import com.fortify.cli.aviator.audit.model.AuditResponse; +import com.fortify.cli.aviator.audit.model.AuditResponse.AuditSkipReason; import com.fortify.cli.aviator.audit.model.File; import com.fortify.cli.aviator.audit.model.UserPrompt; @@ -43,4 +46,16 @@ void shouldPreserveResolvedProgrammingLanguages() { assertEquals(List.of("PLSQL"), auditRequest.getProgrammingLanguagesList()); assertEquals("PLSQL", auditRequest.getLanguage()); } + + @Test + void shouldClassifySkippedServerResponseWithoutInspectingMessage() { + AuditorResponse response = AuditorResponse.newBuilder() + .setStatus("SKIPPED") + .setStatusMessage("example could not be read from the FPR") + .build(); + + AuditResponse auditResponse = GrpcUtil.convertToAuditResponse(response); + + assertEquals(AuditSkipReason.SKIPPED_BY_AVIATOR, auditResponse.getAuditSkipReason()); + } } \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java new file mode 100644 index 00000000000..d720d653aed --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/grpc/RequestHandlerTest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.grpc.stub.StreamObserver; + +class RequestHandlerTest { + @Test + void completeFlushesQueuedRequestsBeforeHalfClose() { + var events = new ArrayList(); + var handler = new RequestHandler("stream"); + handler.sendRequest("one"); + handler.sendRequest("two"); + handler.initialize(observer(events)); + + handler.complete().join(); + + assertEquals(List.of("one", "two", "completed"), events); + assertTrue(handler.isCompleted()); + } + + private StreamObserver observer(List events) { + return new StreamObserver<>() { + @Override public void onNext(String value) { events.add(value); } + @Override public void onError(Throwable throwable) { events.add("error"); } + @Override public void onCompleted() { events.add("completed"); } + }; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FileUtilTest.java b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FileUtilTest.java new file mode 100644 index 00000000000..ef6f959fe7d --- /dev/null +++ b/fcli-core/fcli-aviator-common/src/test/java/com/fortify/cli/aviator/util/FileUtilTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.aviator.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fortify.cli.aviator.config.LanguagesCommentConfig; + +class FileUtilTest { + + @BeforeAll + static void initializeCommentConfig() { + LanguagesCommentConfig commentConfig = new LanguagesCommentConfig(); + commentConfig.setLineCommentSymbols(Map.of( + "HTML", "