diff --git a/.github/workflows/publish.azurepipelineextension.yml b/.github/workflows/publish.azurepipelineextension.yml index 1895b9012..4416f4988 100644 --- a/.github/workflows/publish.azurepipelineextension.yml +++ b/.github/workflows/publish.azurepipelineextension.yml @@ -1,3 +1,5 @@ +permissions: + contents: read name: Publish to Azure Pipeline Extension on: workflow_dispatch: diff --git a/.github/workflows/publish.npm.yml b/.github/workflows/publish.npm.yml index 6e7a225fd..cb7f0fa21 100644 --- a/.github/workflows/publish.npm.yml +++ b/.github/workflows/publish.npm.yml @@ -8,18 +8,18 @@ jobs: steps: - name: Get the source code uses: actions/checkout@v3 - + - name: Install Syft run: | echo "Installing Syft v1.18.1..." curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /tmp/bin v1.18.1 echo "/tmp/bin" >> $GITHUB_PATH - + - name: Install Manifest CLI run: | echo "Installing Manifest CLI v0.18.3..." curl -sSfL https://raw.githubusercontent.com/manifest-cyber/cli/main/install.sh | sh -s -- -b /tmp/bin v0.18.3 - + - name: Create Syft configuration run: | cat > syft-config.yaml << 'EOF' @@ -35,13 +35,13 @@ jobs: nodejs: enabled: true EOF - + - name: Generate and upload SBOM env: MANIFEST_API_KEY: ${{ secrets.MANIFEST_TOKEN }} run: | JAVASCRIPT_SDK_DIR="./sdk/javascript" - + # Get version from package.json echo "Detecting JavaScript SDK version..." if [ -f "${JAVASCRIPT_SDK_DIR}/packages/core/package.json" ]; then @@ -51,7 +51,7 @@ jobs: VERSION="1.0.0" echo "Could not detect version, using default: ${VERSION}" fi - + echo "Generating SBOM with Manifest CLI..." /tmp/bin/manifest sbom "${JAVASCRIPT_SDK_DIR}" \ --generator=syft \ @@ -63,7 +63,7 @@ jobs: --publish=true \ --asset-label=application,sbom-generated,nodejs \ --generator-config=syft-config.yaml - + echo "SBOM generated and uploaded successfully: js-sdk-sbom.json" echo "---------- SBOM Preview (first 20 lines) ----------" head -n 20 js-sdk-sbom.json diff --git a/.github/workflows/reusable.maven.central.publish.yml b/.github/workflows/reusable.maven.central.publish.yml index 6929bfacc..5c48c7b5b 100644 --- a/.github/workflows/reusable.maven.central.publish.yml +++ b/.github/workflows/reusable.maven.central.publish.yml @@ -38,8 +38,21 @@ jobs: echo "Version retrieved: $VERSION" echo "version=$VERSION" >> $GITHUB_OUTPUT - publish-to-maven-central: + generate-and-upload-sbom: needs: get-version + uses: ./.github/workflows/reusable.sbom.workflow.yml + with: + working-directory: ${{ inputs.working-directory }} + project-name: ${{ inputs.project-name }} + project-type: java + project-version: ${{ needs.get-version.outputs.version }} + sbom-format: spdx-json + additional-labels: java,sdk,maven,security + secrets: + MANIFEST_TOKEN: ${{ secrets.MANIFEST_TOKEN }} + + publish-to-maven-central: + needs: [get-version, generate-and-upload-sbom] environment: prod runs-on: ubuntu-latest diff --git a/.github/workflows/reusable.sbom.workflow.yml b/.github/workflows/reusable.sbom.workflow.yml index 0395abc8e..176f093e4 100644 --- a/.github/workflows/reusable.sbom.workflow.yml +++ b/.github/workflows/reusable.sbom.workflow.yml @@ -274,7 +274,7 @@ jobs: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Debug Java dependencies + - name: Build Java project and resolve dependencies if: inputs.project-type == 'java' working-directory: ${{ inputs.working-directory }} run: | @@ -285,9 +285,15 @@ jobs: echo "Gradle version and info:" ./gradlew --version + echo "Building project to ensure all dependencies are downloaded:" + ./gradlew build --no-daemon --refresh-dependencies || true + echo "Project dependencies:" ./gradlew dependencies --configuration runtimeClasspath + echo "Generating dependency report for SBOM:" + ./gradlew dependencies --configuration runtimeClasspath > dependencies.txt + echo "Project structure:" find . -type f -name "*.jar" @@ -300,45 +306,48 @@ jobs: ls -la $GRADLE_USER_HOME/caches || true fi - - name: Debug Syft scanning + - name: Prepare Java dependencies for SBOM scanning if: inputs.project-type == 'java' working-directory: ${{ inputs.working-directory }} run: | - echo "Installing Syft" - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin + echo "Installing Syft v1.18.1 (matching krouter configuration)" + curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.18.1 - echo "Creating Syft config" + echo "Creating enhanced Syft config for Kotlin/Gradle projects" cat > syft-config.yaml << 'EOF' package: search: scope: all-layers + unindexed-archives: true + indexed-archives: true cataloger: enabled: true java: search-unindexed-archives: true search-indexed-archives: true resolve-dependencies: true + use-network: false + maven-url: "" + max-parent-recursive-depth: 5 EOF - echo "Building project to download dependencies" - ./gradlew build --no-daemon --refresh-dependencies - - + echo "Creating a lib directory with all runtime dependencies" + mkdir -p build/sbom-deps + ./gradlew copyDependencies --configuration runtimeClasspath || true + + # Fallback: manually copy dependencies if custom task doesn't exist + echo "Copying runtime dependencies for SBOM..." + ./gradlew dependencies --configuration runtimeClasspath | grep -E "^\+---|\\---" | grep -v "{" | sed 's/[+\\]---//g' | awk '{print $1}' | grep ":" > deps-list.txt || true + cat syft-config.yaml echo "Syft version:" syft version - echo "Full project scan with verbose output:" - SYFT_LOG_LEVEL=debug syft packages . -c syft-config.yaml -o json | tee syft-scan.json - - echo "Scanning Gradle cache:" - find ~/.gradle/caches/modules-2 -type f -name "*.jar" | while read -r jar; do - echo "Found JAR: $jar" - syft packages "$jar" -c syft-config.yaml -o json - done - - echo "Full project scan with verbose output:" - SYFT_LOG_LEVEL=debug syft packages . -c syft-config.yaml -o json | tee syft-scan.json + echo "Running comprehensive SBOM scan:" + SYFT_LOG_LEVEL=info syft packages . -c syft-config.yaml -o json > syft-scan.json + + echo "Dependencies found in scan:" + cat syft-scan.json | jq '.artifacts[].name' | sort -u || true - name: Verify Syft Java scanning if: inputs.project-type == 'java' @@ -357,22 +366,32 @@ jobs: SYFT_SCOPE: "all-layers" SYFT_JAVA_USE_MAVEN_LOCAL_REPOSITORY: "true" SYFT_JAVA_RESOLVE_TRANSITIVE_DEPENDENCIES: "true" + SYFT_PACKAGE_CATALOGER_ENABLED: "true" + # Disable all catalogers except Java (following krouter pattern) + SYFT_PACKAGE_CATALOGER_RUBY_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_PYTHON_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_NODEJS_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_DARTLANG_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_DOTNET_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_GOLANG_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_LINUX_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_PHP_ENABLED: "false" + SYFT_PACKAGE_CATALOGER_RUST_ENABLED: "false" run: | - echo "Installing Syft" - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin + echo "Installing Syft v1.18.1 (matching krouter configuration)" + curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.18.1 - # Install Manifest CLI - echo "Installing Manifest CLI..." - curl -L https://github.com/manifest-cyber/cli/releases/download/v0.11.0/manifest_linux_x86_64.tar.gz > manifest.tar.gz - tar -xzf manifest.tar.gz - chmod +x manifest + # Install Manifest CLI using their installation script (same as our Docker test) + echo "Installing Manifest CLI v0.18.3..." + curl -sSfL https://raw.githubusercontent.com/manifest-cyber/cli/main/install.sh | sh -s -- -b . v0.18.3 + chmod +x ./manifest # Create a clean working directory for scanning SCAN_DIR="${{ runner.temp }}/scan-workspace" mkdir -p "$SCAN_DIR" - # Store the manifest binary path + # Store the manifest binary path (installed in current directory) MANIFEST_PATH="${{ github.workspace }}/manifest" # Copy only the project files to scan directory @@ -408,66 +427,141 @@ jobs: fi if [ "${{ inputs.project-type }}" = "java" ]; then - echo "Running Gradle dependencies resolution..." - - # Debug: Show Gradle home and cache locations - echo "Debug: Gradle locations" - ./gradlew --version - echo "GRADLE_USER_HOME=${GRADLE_USER_HOME:-~/.gradle}" - - # Run dependencies task - ./gradlew dependencies --configuration runtimeClasspath - - # Debug: Show locations and structure - echo "Debug: Directory structure before dependency collection:" - pwd - find . -type d - - echo "Debug: Gradle cache contents:" - ls -R "${GRADLE_USER_HOME:-~/.gradle}/caches/modules-2" || true - - # Create a directory for gathered dependencies - mkdir -p deps - - # Try to gather dependencies from Gradle cache - echo "Debug: Attempting to gather dependencies from Gradle cache" - find "${GRADLE_USER_HOME:-~/.gradle}/caches/modules-2" -name "*.jar" -exec cp {} deps/ \; || true - - echo "Debug: Contents of deps directory:" - ls -la deps/ - - # Debug: Show what syft would scan - echo "Debug: All JAR files in scope:" - find . -type f -name "*.jar" - - echo "Debug: Syft config if exists:" - cat /tmp/syft-*.yaml || echo "No Syft config file found" + echo "Preparing Java/Kotlin project for SBOM generation..." + + # Build the project first to ensure dependencies are resolved (skip tests to avoid failures) + echo "Building project to resolve all dependencies:" + ./gradlew build -x test --no-daemon --refresh-dependencies --info || true + + # Generate a comprehensive dependency list + echo "Generating dependency list for SBOM:" + ./gradlew dependencies --configuration runtimeClasspath > runtime-dependencies.txt + + # Create a directory with all runtime dependencies + mkdir -p build/sbom-deps + + # Use the built-in copyDependencies task we added to build.gradle.kts + ./gradlew copyDependencies || true + + # If custom task fails, try alternative approach + if [ ! -d "build/sbom-deps" ] || [ -z "$(ls -A build/sbom-deps)" ]; then + echo "Attempting alternative dependency collection..." + mkdir -p build/sbom-deps + # Extract dependency coordinates and try to locate them + ./gradlew dependencies --configuration runtimeClasspath | grep -E "^\+---|\\---" | grep -v "{" | sed 's/[+\\]---//g' | awk '{print $1}' | grep ":" | while read dep; do + group=$(echo $dep | cut -d: -f1 | tr '.' '/') + name=$(echo $dep | cut -d: -f2) + version=$(echo $dep | cut -d: -f3) + jar_path="${GRADLE_USER_HOME:-~/.gradle}/caches/modules-2/files-2.1/$group/$name/$version" + if [ -d "$jar_path" ]; then + find "$jar_path" -name "*.jar" -exec cp {} build/sbom-deps/ \; 2>/dev/null || true + fi + done + fi - echo "Debug: Running syft directly to see output:" - syft packages . -o json || echo "Direct syft scan failed" + echo "Dependencies collected in build/sbom-deps:" + ls -la build/sbom-deps/ || true + + # Also scan the build/libs directory for the compiled JAR + echo "JAR files in build directory:" + find build -name "*.jar" -type f || true fi - echo "Creating Syft config for Manifest" + echo "Creating enhanced Syft config for comprehensive scanning" cat > syft-config.yaml << 'EOF' package: search: scope: all-layers + unindexed-archives: true + indexed-archives: true cataloger: enabled: true + # Disable all catalogers except Java to avoid conflicts + ruby: + enabled: false + python: + enabled: false + nodejs: + enabled: false + dartlang: + enabled: false + dotnet: + enabled: false + golang: + enabled: false + linux: + enabled: false + php: + enabled: false + rust: + enabled: false java: + enabled: true search-unindexed-archives: true search-indexed-archives: true resolve-dependencies: true + use-network: false + max-parent-recursive-depth: 10 EOF + # For Java projects, ensure Syft scans both the project and dependencies + if [ "${{ inputs.project-type }}" = "java" ]; then + # Create a temporary syft config with custom settings + echo "Configuring Syft for comprehensive Java/Kotlin scanning..." + export SYFT_JAVA_USE_NETWORK=false + export SYFT_JAVA_MAX_PARENT_RECURSIVE_DEPTH=10 + + # Run Syft directly first to generate comprehensive SBOM (using v1.18.1 syntax) + syft scan . \ + --config syft-config.yaml \ + --output spdx-json=project-sbom.json \ + --override-default-catalogers java || true + + # Also scan the dependencies directory if it exists + if [ -d "build/sbom-deps" ] && [ -n "$(ls -A build/sbom-deps)" ]; then + echo "Scanning dependencies directory..." + syft scan build/sbom-deps \ + --config syft-config.yaml \ + --output spdx-json=deps-sbom.json \ + --override-default-catalogers java || true + fi + fi + + # For Java, scan the dependencies directory for better detection + if [ "${{ inputs.project-type }}" = "java" ] && [ -d "build/sbom-deps" ]; then + SCAN_TARGET="build/sbom-deps" + echo "Scanning dependencies directory: $SCAN_TARGET" + else + SCAN_TARGET="." + echo "Scanning current directory: $SCAN_TARGET" + fi + # Generate SBOM using Manifest CLI - "$MANIFEST_PATH" sbom . \ + # Important: We do NOT use --generator-config as it causes issues with Manifest CLI + # Instead, we rely on Syft's environment variables and pre-scanning + # Note: --label was deprecated in favor of --asset-label in newer versions + echo "Generating SBOM with Manifest CLI..." + echo "Debug: Running command: $MANIFEST_PATH sbom $SCAN_TARGET --generator=syft --name=${{ inputs.project-name }} --version=${PROJECT_VERSION} --output=${{ inputs.sbom-format }} --file=generated-sbom.json --api-key=*** --publish=true --asset-label=${FINAL_LABELS}" + + "$MANIFEST_PATH" sbom "$SCAN_TARGET" \ --generator=syft \ --name=${{ inputs.project-name }} \ --version=${PROJECT_VERSION} \ --output=${{ inputs.sbom-format }} \ + --file=generated-sbom.json \ --api-key=${MANIFEST_TOKEN} \ --publish=true \ - --label=${FINAL_LABELS} + --asset-label=${FINAL_LABELS} 2>&1 || { + echo "Trying with --label flag for compatibility..." + "$MANIFEST_PATH" sbom "$SCAN_TARGET" \ + --generator=syft \ + --name=${{ inputs.project-name }} \ + --version=${PROJECT_VERSION} \ + --output=${{ inputs.sbom-format }} \ + --file=generated-sbom.json \ + --api-key=${MANIFEST_TOKEN} \ + --publish=true \ + --label=${FINAL_LABELS} + } # Copy generated SBOM back to original directory for artifact upload cp *.json "${{ github.workspace }}/" diff --git a/.github/workflows/test.java.yml b/.github/workflows/test.java.yml index c0ad13bd5..76fcbe458 100644 --- a/.github/workflows/test.java.yml +++ b/.github/workflows/test.java.yml @@ -16,17 +16,17 @@ jobs: run: working-directory: ./sdk/java/core steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Java ${{ matrix.java-version }} - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: ${{ matrix.java-version }} - name: Setup, Build and Test - uses: gradle/gradle-build-action@v2 + uses: gradle/actions/setup-gradle@v3 with: - gradle-version: 8.4 + gradle-version: '8.14' arguments: build test build-root-directory: ./sdk/java/core diff --git a/sdk/java/core/README.md b/sdk/java/core/README.md index d58986a90..16f5b0f2d 100644 --- a/sdk/java/core/README.md +++ b/sdk/java/core/README.md @@ -4,6 +4,14 @@ For more information see our official documentation page https://docs.keeper.io/ # Change Log +## 17.1.1 +- KSM-581 - Add GraphSync library to KSM SDK +- KSM-582 - fix NPE use safe cast in KeeperRecordData.getField() + -KSM-586 - Add recordingIncludeKeys to data classes +- KSM-587 - Add logging option +- KSM-627 - Java SDK Add GraphSync links +- KSM-634 - Added links2Remove parameter for files removal + ## 17.0.0 - KSM-580 - Added new PAM fields diff --git a/sdk/java/core/build.gradle.kts b/sdk/java/core/build.gradle.kts index 2dcf84bed..27775f0a6 100644 --- a/sdk/java/core/build.gradle.kts +++ b/sdk/java/core/build.gradle.kts @@ -7,15 +7,16 @@ import java.util.* group = "com.keepersecurity.secrets-manager" // During publishing, If version ends with '-SNAPSHOT' then it will be published to Maven snapshot repository -version = "17.0.0" +version = "17.1.1" plugins { `java-library` - kotlin("jvm") version "2.0.20" - kotlin("plugin.serialization") version "2.0.20" + kotlin("jvm") version "2.2.0" + kotlin("plugin.serialization") version "2.2.0" `maven-publish` signing id("io.github.gradle-nexus.publish-plugin") version "2.0.0" + id("org.jreleaser") version "1.18.0" } java { @@ -43,20 +44,20 @@ repositories { dependencies { // Align versions of all Kotlin components - implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.0.20")) + implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.2.0")) // Use the Kotlin JDK 8 standard library. - api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.0.20") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2") - implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20") + api("org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlin:kotlin-reflect:2.2.0") // Use the Kotlin test library. - testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.20") + testImplementation("org.jetbrains.kotlin:kotlin-test:2.2.0") // Use the Kotlin JUnit integration. - testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.0.20") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.2.0") - testImplementation("org.bouncycastle:bc-fips:2.0.0") + testImplementation("org.bouncycastle:bc-fips:2.1.1") // testImplementation("org.bouncycastle:bcprov-jdk15on:1.70") } @@ -162,7 +163,7 @@ publishing { repositories { maven { - name = "Sonatype" + name = "Staging" if (project.version.toString().endsWith("SNAPSHOT")) { @@ -188,3 +189,56 @@ tasks.javadoc { (options as StandardJavadocDocletOptions).addBooleanOption("html5", true) } } + +// Task to copy all runtime dependencies for SBOM generation +tasks.register("copyDependencies") { + from(configurations.runtimeClasspath) + into(layout.buildDirectory.dir("sbom-deps")) +} + +// Configure nexusPublishing for staging repository +nexusPublishing { + repositories { + create("sonatype") { + nexusUrl = uri("https://s01.oss.sonatype.org/service/local/") + snapshotRepositoryUrl = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") + + username = getExtraString("ossrhUsername") + password = getExtraString("ossrhPassword") + } + } +} + +// Configure JReleaser for Central Portal publishing +configure { + project { + copyright = "Keeper Security Inc." + description = "Keeper Secrets Manager Core SDK for Java" + inceptionYear = "2022" + authors.add("Keeper Security Inc.") + license = "MIT" + links { + homepage = "https://github.com/Keeper-Security/secrets-manager" + } + } + + gitRootSearch = true + + signing { + active = org.jreleaser.model.Active.ALWAYS + armored = true + mode = org.jreleaser.model.Signing.Mode.FILE + } + + deploy { + maven { + mavenCentral { + create("sonatype") { + active = org.jreleaser.model.Active.ALWAYS + url = "https://central.sonatype.com/api/v1/publisher" + stagingRepository(layout.buildDirectory.dir("staging-deploy").get().asFile.path) + } + } + } + } +} diff --git a/sdk/java/core/gradle.properties b/sdk/java/core/gradle.properties new file mode 100644 index 000000000..3a3b0f87e --- /dev/null +++ b/sdk/java/core/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=true diff --git a/sdk/java/core/gradle/wrapper/gradle-wrapper.properties b/sdk/java/core/gradle/wrapper/gradle-wrapper.properties index 793ed65e1..089a96bd3 100644 --- a/sdk/java/core/gradle/wrapper/gradle-wrapper.properties +++ b/sdk/java/core/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,8 @@ +# Gradle 8.x is the last version that supports Java 11 +# Gradle 9.0+ requires Java 17 or higher distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/sdk/java/core/settings.gradle.kts b/sdk/java/core/settings.gradle.kts index 7c2709aac..f705909ce 100644 --- a/sdk/java/core/settings.gradle.kts +++ b/sdk/java/core/settings.gradle.kts @@ -1,7 +1,10 @@ rootProject.name = "core" plugins { - id("org.gradle.toolchains.foojay-resolver") version "0.8.0" + // Note: Versions prior to 1.0.0 require Java 8 or later and Gradle 7.6 or later. + // Versions 1.0.0 and after require Java 17 or later and Gradle 7.6 or later. + // Upgrade to 1.0.0+ entails moving java-version matrix out of GHA and implementing in Gradle + id("org.gradle.toolchains.foojay-resolver") version "0.9.0" } @Suppress("UnstableApiUsage") diff --git a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt index fc49c3b4b..faaca9554 100644 --- a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt +++ b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt @@ -15,7 +15,7 @@ data class KeeperRecordData @JvmOverloads constructor( var notes: String? = null ) { inline fun getField(): T? { - return (fields + custom).find { x -> x is T } as T + return (fields + (custom ?: listOf())).find { x -> x is T } as? T } fun getField(clazz: Class): KeeperRecordField? { @@ -346,6 +346,8 @@ data class KeyPairs @JvmOverloads constructor( data class Host @JvmOverloads constructor( val hostName: String? = null, val port: String? = null, + val allowSupplyUser: Boolean? = null, + val allowSupplyHost: Boolean? = null, ) @Serializable @@ -402,6 +404,7 @@ data class PamResource @JvmOverloads constructor( val controllerUid: String? = null, val folderUid: String? = null, val resourceRef: MutableList? = null, + val adminCredentialRef: String? = null, val allowedSettings: AllowedSettings? = null ) @@ -588,6 +591,7 @@ data class AppFillers @JvmOverloads constructor( data class PamRbiConnection @JvmOverloads constructor( val protocol: String? = null, val userRecords: MutableList? = null, + val recordingIncludeKeys: Boolean? = null, val allowUrlManipulation: Boolean? = null, val allowedUrlPatterns: String? = null, val allowedResourceUrlPatterns: String? = null, @@ -632,6 +636,9 @@ data class PamSettingsConnection @JvmOverloads constructor( val protocol: String? = null, val userRecords: MutableList? = null, val port: String? = null, + val allowSupplyUser: Boolean? = null, + val allowSupplyHost: Boolean? = null, + val recordingIncludeKeys: Boolean? = null, // Common display and security settings val colorScheme: String? = null, @@ -650,6 +657,8 @@ data class PamSettingsConnection @JvmOverloads constructor( // SSH/Connection verification val hostKey: String? = null, + val command: String? = null, + val fontSize: String? = null, // VNC/Proxy settings val destHost: String? = null, @@ -661,6 +670,8 @@ data class PamSettingsConnection @JvmOverloads constructor( val preconnectionId: String? = null, val preconnectionBlob: String? = null, val disableAudio: Boolean? = null, + val enableWallpaper: Boolean? = null, + val enableFullWindowDrag: Boolean? = null, val sftp: SFTPConnection? = null, // Telnet specific fields @@ -687,7 +698,8 @@ data class PamSettingsPortForward @JvmOverloads constructor( @Serializable data class PamSetting @JvmOverloads constructor( val connection: PamSettingsConnection? = null, - val portForward: PamSettingsPortForward? = null + val portForward: PamSettingsPortForward? = null, + val allowSupplyHost: Boolean? = null ) @Serializable diff --git a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt index b65260ff3..2ef3bf37f 100644 --- a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt +++ b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt @@ -5,6 +5,7 @@ package com.keepersecurity.secretsManager.core import kotlinx.serialization.* import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.net.HttpURLConnection.HTTP_OK @@ -18,7 +19,7 @@ import java.util.* import java.util.concurrent.* import javax.net.ssl.* -const val KEEPER_CLIENT_VERSION = "mj17.0.0" +const val KEEPER_CLIENT_VERSION = "mj17.1.1" const val KEY_HOSTNAME = "hostname" // base url for the Secrets Manager service const val KEY_SERVER_PUBIC_KEY_ID = "serverPublicKeyId" @@ -42,7 +43,8 @@ interface KeyValueStorage { data class SecretsManagerOptions @JvmOverloads constructor( val storage: KeyValueStorage, val queryFunction: QueryFunction? = null, - val allowUnverifiedCertificate: Boolean = false + val allowUnverifiedCertificate: Boolean = false, + val loggingEnabled: Boolean = true ) { init { testSecureRandom() @@ -52,6 +54,7 @@ data class SecretsManagerOptions @JvmOverloads constructor( data class QueryOptions @JvmOverloads constructor( val recordsFilter: List = emptyList(), val foldersFilter: List = emptyList(), + val requestLinks: Boolean? = null ) data class CreateOptions @JvmOverloads constructor( @@ -59,6 +62,11 @@ data class CreateOptions @JvmOverloads constructor( val subFolderUid: String? = null, ) +data class UpdateOptions @JvmOverloads constructor( + val transactionType: UpdateTransactionType? = null, + val linksToRemove: List? = null, +) + typealias QueryFunction = (url: String, transmissionKey: TransmissionKey, payload: EncryptedPayload) -> KeeperHttpResponse data class TransmissionKey(var publicKeyId: Int, var key: ByteArray, val encryptedKey: ByteArray) @@ -78,7 +86,8 @@ private data class GetPayload( override val clientId: String, var publicKey: String? = null, var requestedRecords: List? = null, - var requestedFolders: List? = null + var requestedFolders: List? = null, + var requestLinks: Boolean? = null ): CommonPayload() @Serializable @@ -94,7 +103,8 @@ private data class UpdatePayload( val recordUid: String, val data: String, val revision: Long, - val transactionType: UpdateTransactionType? = null + val transactionType: UpdateTransactionType? = null, + val links2Remove: List? = null ): CommonPayload() @Serializable @@ -159,6 +169,7 @@ private data class FileUploadPayload( val fileRecordData: String, val ownerRecordUid: String, val ownerRecordData: String, + val ownerRecordRevision: Long, val linkKey: String, val fileSize: Int, // we will not allow upload size > 2GB due to memory constraints ): CommonPayload() @@ -184,9 +195,376 @@ private data class SecretsManagerResponseRecord( val revision: Long, val isEditable: Boolean, val files: List?, - val innerFolderUid: String? + val innerFolderUid: String?, + val links: List? = null ) +@Serializable +data class KeeperRecordLink( + val recordUid: String, + val data: String? = null, + val path: String? = null +) { + + /** + * Parse the link data as a JSON object, handling errors gracefully + */ + private fun parseJsonData(): Map? { + if (data == null) return null + + return try { + val decodedData = String(java.util.Base64.getDecoder().decode(data)) + + // Check if the decoded data looks like JSON (starts with { or [) + // If not, it's likely encrypted/binary data, so return null silently + if (!decodedData.startsWith("{") && !decodedData.startsWith("[")) { + return null + } + + val jsonElement = Json.parseToJsonElement(decodedData) + if (jsonElement is JsonObject) { + jsonElement.entries.associate { (key, value) -> + key to when { + value is JsonPrimitive && value.isString -> value.content + value is JsonPrimitive -> { + // Try to parse as different types + when { + value.content == "true" -> true + value.content == "false" -> false + value.content.toIntOrNull() != null -> value.content.toInt() + value.content.toLongOrNull() != null -> value.content.toLong() + else -> value.content + } + } + else -> value.toString() + } + } + } else { + // Only log if it looked like JSON but wasn't a JSON object + System.err.println("KeeperRecordLink: Link data is not a JSON object (was JSON array or primitive)") + null + } + } catch (e: IllegalArgumentException) { + // Base64 decoding failed - likely not base64 encoded + null + } catch (e: Exception) { + // Only log parsing errors for data that looks like it should be JSON + val decodedData = try { + String(java.util.Base64.getDecoder().decode(data)) + } catch (_: Exception) { + return null + } + + if (decodedData.startsWith("{") || decodedData.startsWith("[")) { + System.err.println("KeeperRecordLink: Failed to parse JSON data - ${e.message}") + } + null + } + } + + /** + * Get a boolean value from the parsed JSON data + */ + private fun getBooleanValue(key: String): Boolean { + return parseJsonData()?.get(key) as? Boolean ?: false + } + + /** + * Get an integer value from the parsed JSON data + */ + private fun getIntValue(key: String): Int? { + return parseJsonData()?.get(key) as? Int + } + + /** + * Get a string value from the parsed JSON data + */ + private fun getStringValue(key: String): String? { + return parseJsonData()?.get(key) as? String + } + + /** + * Check if the link data indicates admin status for a user + */ + fun isAdminUser(): Boolean = getBooleanValue("is_admin") + + /** + * Check if this is a launch credential link + */ + fun isLaunchCredential(): Boolean = getBooleanValue("is_launch_credential") + + /** + * Check if rotation is allowed based on link settings + */ + fun allowsRotation(): Boolean = getBooleanValue("rotation") + + /** + * Check if connections are allowed based on link settings + */ + fun allowsConnections(): Boolean = getBooleanValue("connections") + + /** + * Check if port forwards are allowed based on link settings + */ + fun allowsPortForwards(): Boolean = getBooleanValue("portForwards") + + /** + * Check if session recording is enabled + */ + fun allowsSessionRecording(): Boolean = getBooleanValue("sessionRecording") + + /** + * Check if TypeScript recording is enabled + */ + fun allowsTypescriptRecording(): Boolean = getBooleanValue("typescriptRecording") + + /** + * Check if remote browser isolation is enabled + */ + fun allowsRemoteBrowserIsolation(): Boolean = getBooleanValue("remoteBrowserIsolation") + + /** + * Check if rotation on termination is enabled + */ + fun rotatesOnTermination(): Boolean = getBooleanValue("rotateOnTermination") + + /** + * Get the link data version (if available) + */ + fun getLinkDataVersion(): Int? = getIntValue("version") + + /** + * Get the decoded JSON data as a string (for debugging/advanced use) + */ + fun getDecodedData(): String? { + if (data == null) return null + return try { + String(java.util.Base64.getDecoder().decode(data)) + } catch (e: Exception) { + System.err.println("KeeperRecordLink: Failed to decode Base64 data - ${e.message}") + null + } + } + + /** + * Check if the link has readable JSON data (vs. encrypted/binary data) + */ + fun hasReadableData(): Boolean { + val decoded = getDecodedData() + return decoded != null && (decoded.startsWith("{") || decoded.startsWith("[")) + } + + /** + * Check if this link's path indicates potentially encrypted data + * Currently known encrypted paths: ai_settings, jit_settings + * @return true if the path is known to potentially contain encrypted data + */ + fun mightBeEncrypted(): Boolean { + // Only return true for known encrypted paths + // Don't assume all *_settings are encrypted + return path != null && (path == "ai_settings" || path == "jit_settings") + } + + /** + * Check if this link contains encrypted data by examining the actual content + * This method inspects the data to determine if it's encrypted, rather than + * relying on path naming conventions + * @return true if the data appears to be encrypted + */ + fun hasEncryptedData(): Boolean { + if (data == null) return false + + return try { + val decodedData = String(java.util.Base64.getDecoder().decode(data)) + // If it doesn't start with JSON markers and isn't mostly printable, it's likely encrypted + !decodedData.startsWith("{") && !decodedData.startsWith("[") && !isPrintableText(decodedData) + } catch (e: Exception) { + false + } + } + + /** + * Decrypt the link data using the provided record key + * This method attempts decryption only if the data appears to be encrypted + * + * @param recordKey The record's encryption key + * @return Decrypted string data, or null if decryption fails + */ + @JvmOverloads + fun getDecryptedData(recordKey: ByteArray? = null): String? { + if (data == null || recordKey == null) return null + + return try { + // Decode Base64 to get encrypted bytes + val encryptedData = java.util.Base64.getDecoder().decode(data) + + // Decrypt using AES-256-GCM (false = use GCM mode) + val decryptedBytes = decrypt(encryptedData, recordKey, false) + + // Convert to string + String(decryptedBytes, Charsets.UTF_8) + } catch (e: Exception) { + // Decryption failed - could be wrong key or not encrypted + null + } + } + + /** + * Get link data - automatically handles both encrypted and plain JSON + * + * This method is designed to be forward-compatible as Keeper evolves + * the data structures. Returns a Map to preserve all fields, even ones + * this SDK version doesn't know about yet. + * + * @param recordKey Optional key for decrypting encrypted link data + * @return Parsed data as a Map, or null if parsing fails + */ + @JvmOverloads + fun getLinkData(recordKey: ByteArray? = null): Map? { + if (data == null) return null + + // First, try to decode and check if it's plain JSON + val decodedData = try { + String(java.util.Base64.getDecoder().decode(data)) + } catch (e: Exception) { + return null + } + + // If it looks like JSON, parse it directly + if (decodedData.startsWith("{") || decodedData.startsWith("[")) { + return parseJsonToMap(decodedData) + } + + // If not JSON and we have a key, try decryption + if (recordKey != null) { + val decryptedData = getDecryptedData(recordKey) ?: return null + return parseJsonToMap(decryptedData) + } + + // Can't parse - not JSON and no key for decryption + return null + } + + /** + * Helper method to parse JSON string to Map + */ + private fun parseJsonToMap(jsonString: String): Map? { + return try { + val jsonElement = Json.parseToJsonElement(jsonString) + when (jsonElement) { + is JsonObject -> { + jsonElement.entries.associate { (key, value) -> + key to when (value) { + is JsonPrimitive -> { + when { + value.isString -> value.content + else -> { + // Try to parse as different types + when { + value.content == "true" -> true + value.content == "false" -> false + value.content.toIntOrNull() != null -> value.content.toInt() + value.content.toLongOrNull() != null -> value.content.toLong() + value.content.toDoubleOrNull() != null -> value.content.toDouble() + else -> value.content + } + } + } + } + is JsonObject -> value.toString() // Nested objects as strings + is kotlinx.serialization.json.JsonArray -> value.toString() // Arrays as strings + } + } + } + else -> null + } + } catch (e: Exception) { + null + } + } + + /** + * Helper method to check if a string is mostly printable text + */ + private fun isPrintableText(str: String): Boolean { + if (str.isEmpty()) return false + + val printableCount = str.take(100).count { c -> + c in ' '..'~' || c == '\n' || c == '\r' || c == '\t' + } + + val sampleSize = minOf(str.length, 100) + return (printableCount.toFloat() / sampleSize) > 0.9f + } + + // ============================================================================ + // Convenience Methods for Settings Access + // ============================================================================ + + /** + * Get AI settings data from this link + * + * Known fields for ai_settings (as of SDK v17.1.0): + * - aiEnabled: Boolean - Whether AI features are enabled + * - aiModel: String - The AI model being used + * - aiProvider: String - The AI provider (e.g., "openai", "anthropic") + * - aiSessionTerminate: Boolean - Whether to terminate AI session + * - allowedSettings: Map - Nested settings for allowed operations + * + * Note: Additional fields may be present in newer versions. + * The returned Map will preserve all fields sent by the server. + * + * @param recordKey The record's encryption key + * @return Settings data as a Map, or null if not available + */ + fun getAiSettingsData(recordKey: ByteArray): Map? { + if (path != "ai_settings") return null + return getLinkData(recordKey) + } + + /** + * Get JIT (Just-In-Time) settings data from this link + * + * Known fields for jit_settings (as of SDK v17.1.0): + * - enabled: Boolean - Whether JIT access is enabled + * - ttl: Int - Time-to-live in seconds + * - maxUses: Int - Maximum number of uses allowed + * - expiresAt: Long - Expiration timestamp in milliseconds + * - allowedSettings: Map - Nested settings for allowed operations: + * - rotation: Boolean - Allow credential rotation + * - connections: Boolean - Allow connections + * - portForwards: Boolean - Allow port forwarding + * - sessionRecording: Boolean - Enable session recording + * - typescriptRecording: Boolean - Enable TypeScript recording + * + * Note: Additional fields may be present in newer versions. + * The returned Map will preserve all fields sent by the server. + * + * @param recordKey The record's encryption key + * @return Settings data as a Map, or null if not available + */ + fun getJitSettingsData(recordKey: ByteArray): Map? { + if (path != "jit_settings") return null + return getLinkData(recordKey) + } + + /** + * Get settings data for any path + * + * This method works for current and future settings paths. + * It automatically detects whether the data is encrypted and + * handles it appropriately. + * + * @param settingsPath The path to check (e.g., "ai_settings", "security_settings") + * @param recordKey The record's encryption key (required for encrypted data) + * @return Settings data as a Map, or null if path doesn't match or parsing fails + */ + fun getSettingsForPath(settingsPath: String, recordKey: ByteArray? = null): Map? { + if (path != settingsPath) return null + return getLinkData(recordKey) + } +} + @Serializable private data class SecretsManagerResponseFile( val fileUid: String, @@ -251,7 +629,8 @@ data class KeeperRecord( var innerFolderUid: String? = null, val data: KeeperRecordData, val revision: Long, - val files: List? = null + val files: List? = null, + val links: List? = null ) { fun getPassword(): String? { val passwordField = data.getField() ?: return null @@ -398,7 +777,9 @@ fun getSecrets(options: SecretsManagerOptions, recordsFilter: List = emp try { fetchAndDecryptSecrets(options, queryOptions) } catch (e: Exception) { - println(e) + if (options.loggingEnabled) { + println(e) + } } } return secrets @@ -412,7 +793,9 @@ fun getSecrets2(options: SecretsManagerOptions, queryOptions: QueryOptions? = nu try { fetchAndDecryptSecrets(options, queryOptions) } catch (e: Exception) { - println(e) + if (options.loggingEnabled) { + println(e) + } } } return secrets @@ -430,7 +813,9 @@ fun tryGetNotationResults(options: SecretsManagerOptions, notation: String): Lis try { return getNotationResults(options, notation) } catch (e: Exception) { - println(e.message) + if (options.loggingEnabled) { + println(e.message) + } } return emptyList() } @@ -559,7 +944,7 @@ fun getNotationResults(options: SecretsManagerOptions, notation: String): List= 0) 1 else valuesCount - if (res.size != expectedSize) + if (res.size != expectedSize && options.loggingEnabled) println("Notation warning - extracted ${res.size} out of $valuesCount values for '$objPropertyName' property.") if (res.isNotEmpty()) result.addAll(res) @@ -585,7 +970,12 @@ fun deleteFolder(options: SecretsManagerOptions, folderUids: List, force @ExperimentalSerializationApi fun updateSecret(options: SecretsManagerOptions, record: KeeperRecord, transactionType: UpdateTransactionType? = null) { - val payload = prepareUpdatePayload(options.storage, record, transactionType) + updateSecretWithOptions(options, record, UpdateOptions(transactionType, null)) +} + +@ExperimentalSerializationApi +fun updateSecretWithOptions(options: SecretsManagerOptions, record: KeeperRecord, updateOptions: UpdateOptions? = null) { + val payload = prepareUpdatePayload(options.storage, record, updateOptions) postQuery(options, "update_secret", payload) } @@ -745,7 +1135,7 @@ private fun fetchAndDecryptSecrets( if (response.records != null) { response.records.forEach { val recordKey = decrypt(it.recordKey, appKey) - val decryptedRecord = decryptRecord(it, recordKey) + val decryptedRecord = decryptRecord(it, recordKey, options) if (decryptedRecord != null) { records.add(decryptedRecord) } @@ -756,7 +1146,7 @@ private fun fetchAndDecryptSecrets( val folderKey = decrypt(folder.folderKey, appKey) folder.records!!.forEach { record -> val recordKey = decrypt(record.recordKey, folderKey) - val decryptedRecord = decryptRecord(record, recordKey) + val decryptedRecord = decryptRecord(record, recordKey, options) if (decryptedRecord != null) { decryptedRecord.folderUid = folder.folderUid decryptedRecord.folderKey = folderKey @@ -778,7 +1168,7 @@ private fun fetchAndDecryptSecrets( } @ExperimentalSerializationApi -private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteArray): KeeperRecord? { +private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteArray, options: SecretsManagerOptions): KeeperRecord? { val decryptedRecord = decrypt(record.data, recordKey) val files: MutableList = mutableListOf() @@ -802,7 +1192,7 @@ private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteA // When SDK is behind/ahead of record/field type definitions then // strict mapping between JSON attributes and object properties // will fail on any unknown field/key - currently just log the error - // and continue without the field - nb! field will be lost on save + // and continue without the field - NB! field will be lost on save var recordData: KeeperRecordData? = null try { recordData = Json.decodeFromString(bytesToString(decryptedRecord)) @@ -833,13 +1223,15 @@ private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteA else -> "Unexpected error: ${e.message}" } - println(""" - Record ${record.recordUid} (type: $recordType) parsing error: - Error: $errorDetails - This may occur if the Keeper Secrets Manager (KSM) SDK version you're using is not compatible with the record's data schema. - Please ensure that you are using the latest version of the KSM SDK. If the issue persists, contact support@keepersecurity.com for assistance. - """.trimIndent()) - + if (options.loggingEnabled) { + println(""" + Record ${record.recordUid} (type: $recordType) parsing error: + Error: $errorDetails + This may occur if the Keeper Secrets Manager (KSM) SDK version you're using is not compatible with the record's data schema. + Please ensure that you are using the latest version of the KSM SDK. If the issue persists, contact support@keepersecurity.com for assistance. + """.trimIndent() + ) + } try { // Attempt to parse with non-strict JSON parser for recovery recordData = nonStrictJson.decodeFromString(bytesToString(decryptedRecord)) @@ -850,15 +1242,28 @@ private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteA } else -> "Unexpected error during non-strict parsing: ${e2.message}" } - println(""" - Failed to parse record ${record.recordUid} (type: $recordType) even with non-strict parser. - Error: $secondaryError - Record will be skipped. - """.trimIndent()) + if (options.loggingEnabled) { + println(""" + Failed to parse record ${record.recordUid} (type: $recordType) even with non-strict parser. + Error: $secondaryError + Record will be skipped. + """.trimIndent() + ) + } } } - return if (recordData != null) KeeperRecord(recordKey, record.recordUid, null, null, record.innerFolderUid, recordData, record.revision, files) else null + return if (recordData != null) KeeperRecord( + recordKey, + record.recordUid, + null, + null, + record.innerFolderUid, + recordData, + record.revision, + files, + record.links + ) else null } @ExperimentalSerializationApi @@ -919,7 +1324,10 @@ private fun prepareGetPayload( payload.requestedRecords = queryOptions.recordsFilter } if (queryOptions.foldersFilter.isNotEmpty()) { - payload.requestedRecords = queryOptions.foldersFilter + payload.requestedFolders = queryOptions.foldersFilter + } + if (queryOptions.requestLinks != null) { + payload.requestLinks = queryOptions.requestLinks } } return payload @@ -948,12 +1356,29 @@ private fun prepareDeleteFolderPayload( private fun prepareUpdatePayload( storage: KeyValueStorage, record: KeeperRecord, - transactionType: UpdateTransactionType? = null + updateOptions: UpdateOptions? = null ): UpdatePayload { val clientId = storage.getString(KEY_CLIENT_ID) ?: throw Exception("Client Id is missing from the configuration") + + updateOptions?.linksToRemove?.takeIf { it.isNotEmpty() }?.let { + val frefs = record.data.getField() + if (frefs?.value?.isNotEmpty() == true){ + frefs.value.removeAll(it) + } + } + val recordBytes = stringToBytes(Json.encodeToString(record.data)) val encryptedRecord = encrypt(recordBytes, record.recordKey) - return UpdatePayload(KEEPER_CLIENT_VERSION, clientId, record.recordUid, webSafe64FromBytes(encryptedRecord), record.revision, transactionType) + + return UpdatePayload( + clientVersion = KEEPER_CLIENT_VERSION, + clientId = clientId, + recordUid = record.recordUid, + data = webSafe64FromBytes(encryptedRecord), + revision = record.revision, + transactionType = updateOptions?.transactionType, + links2Remove = updateOptions?.linksToRemove + ) } @ExperimentalSerializationApi @@ -1066,6 +1491,7 @@ private fun prepareFileUploadPayload( webSafe64FromBytes(encryptedFileRecord), ownerRecord.recordUid, webSafe64FromBytes(encryptedOwnerRecord), + ownerRecord.revision, bytesToBase64(encryptedLinkKey), encryptedFileData.size ),