diff --git a/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml index b00284a1d95..5644e08c5f7 100644 --- a/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml +++ b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml @@ -95,7 +95,7 @@ steps: global.ci.workspaceDir: ${#ifBlank(global.ci.workspaceDir,'.')} global.ci.sourceDir: ${#ifBlank(global.ci.sourceDir,'.')} - var.set: - global.ci.localRepo: ${#localRepo(global.ci.sourceDir)} + global.ci.localRepo: ${#git.localRepo(global.ci.sourceDir)} # Generic local repository fallback (run if previous CI-specific steps didn't set these) - if: ${#isBlank(global.ci.id) && global.ci.localRepo!=null} @@ -120,6 +120,11 @@ steps: global.ci.ssc_dastExportAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'dast-report'))} global.ci.fod_scaExportAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, 'debricked-report'))} # TODO 'debricked' or more generic 'sca' or 'composition (analysis)' global.ci.ssc_scaExportAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'debricked-report'))} # TODO 'debricked' or more generic 'sca' or 'composition (analysis)' + # Aviator remediations action: use a CI-specific -remediations- action if it exists + # (e.g. github-remediations-pr for GitHub PR creation), otherwise fall back to the generic + # push-remediations action that only pushes changes to a new branch. + global.ci.fod_aviatorRemediationsAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, 'remediations', global.ci.prKeyword))?:'push-remediations'} + global.ci.ssc_aviatorRemediationsAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'remediations', global.ci.prKeyword))?:'push-remediations'} # Set PR-related skip reason if not active - if: "${global.ci.prActive!=true}" var.set: diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json index 15a37b3ea4d..f055ce35015 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json @@ -7,9 +7,7 @@ "methods": [ { "name": "", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -28,9 +26,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -42,9 +38,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -56,9 +50,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -70,9 +62,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -84,9 +74,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -98,9 +86,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -112,9 +98,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -126,9 +110,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -140,9 +122,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -154,248 +134,492 @@ "methods": [ { "name": "values", + "parameterTypes": [] + } + ] + }, + { + "name": "com.github.chirontt.gitserver.LfsBatchServlet", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", "parameterTypes": [ - + "org.eclipse.jgit.lfs.server.fs.FileLfsRepository", + "java.nio.file.Path" ] } ] }, { - "name":"com.github.chirontt.gitserver.LfsBatchServlet", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":["org.eclipse.jgit.lfs.server.fs.FileLfsRepository","java.nio.file.Path"] }] + "name": "com.github.chirontt.lfs.server.LfsProtocolServletV2$LfsRequestV2", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.LfsProtocolServletV2$LfsRequestV2", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.LfsRef", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.LfsRef", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$CreateLock", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$CreateLock", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$DeleteLock", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$DeleteLock", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$ListLocksToVerify", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$ListLocksToVerify", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$CreatedOrDeletedLock", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock" + ] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$CreatedOrDeletedLock", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[ - {"name":"","parameterTypes":[] }, - {"name":"","parameterTypes":["com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock"] } + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Error", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock", + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LockExistsError", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String", + "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock" + ] + } + ] + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Locks", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "java.util.List", + "java.lang.String" + ] + } ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Error", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":["java.lang.String"] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LocksToVerify", + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Owner", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "java.lang.String" + ] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock", - "allDeclaredFields":true, - "allDeclaredMethods":true + "name": "com.github.chirontt.lfs.server.locks.lm.PersistentLock", + "allDeclaredFields": true, + "allDeclaredMethods": true }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LockExistsError", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":["java.lang.String","com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock"] }] + "name": "com.github.chirontt.lfs.server.locks.internal.LfsFileLockingText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Locks", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[ - {"name":"","parameterTypes":[] }, - {"name":"","parameterTypes":["java.util.List","java.lang.String"] } + "name": "org.eclipse.jgit.diff.DiffAlgorithm$SupportedAlgorithm", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LocksToVerify", - "allDeclaredFields":true, - "allDeclaredMethods":true + "name": "org.eclipse.jgit.dircache.DirCache$DirCacheVersion", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Owner", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[ - {"name":"","parameterTypes":[] }, - {"name":"","parameterTypes":["java.lang.String"] } + "name": "org.eclipse.jgit.gitrepo.internal.RepoText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } ] }, { - "name":"com.github.chirontt.lfs.server.locks.lm.PersistentLock", - "allDeclaredFields":true, - "allDeclaredMethods":true + "name": "org.eclipse.jgit.http.server.GitServlet", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.internal.LfsFileLockingText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.http.server.HttpServerText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.diff.DiffAlgorithm$SupportedAlgorithm", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.internal.JGitText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.dircache.DirCache$DirCacheVersion", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.internal.storage.dfs.DfsText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.gitrepo.internal.RepoText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.internal.LfsText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.http.server.GitServlet", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.LfsObject", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.http.server.HttpServerText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.LfsProtocolServlet$LfsRequest", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.internal.JGitText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$Action", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.internal.storage.dfs.DfsText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$Body", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.lfs.internal.LfsText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$Error", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.lfs.server.LfsObject", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$ObjectInfo", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.lfs.server.LfsProtocolServlet$LfsRequest", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.fs.FileLfsServlet", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "org.eclipse.jgit.lfs.server.fs.FileLfsRepository", + "long" + ] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$Action", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lfs.server.internal.LfsServerText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$Body", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lib.CoreConfig$AutoCRLF", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$Error", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lib.CoreConfig$CheckStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$ObjectInfo", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lib.CoreConfig$EOL", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.fs.FileLfsServlet", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":["org.eclipse.jgit.lfs.server.fs.FileLfsRepository","long"] }] + "name": "org.eclipse.jgit.lib.CoreConfig$HideDotFiles", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.internal.LfsServerText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$LogRefUpdates", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$AutoCRLF", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$SymLinks", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$CheckStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$TrustLooseRefStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$EOL", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$TrustPackedRefsStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$HideDotFiles", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$TrustStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$LogRefUpdates", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GpgConfig$GpgFormat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$SymLinks", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.transport.HttpConfig$HttpRedirectMode", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$TrustLooseRefStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.transport.http.apache.internal.HttpApacheText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$TrustPackedRefsStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$PackRefsMode", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$TrustStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$AggressiveWindow", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.GpgConfig$GpgFormat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$Autodetach", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.transport.HttpConfig$HttpRedirectMode", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$Prune", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.transport.http.apache.internal.HttpApacheText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$LogExpire", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] } -] +] \ No newline at end of file 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 506e05cec2c..a3d3f360c6d 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 @@ -269,6 +269,18 @@ formatters: specified through `SAST_EXPORT_ACTION`. Extra options for the fcli action can be specified through the `SAST_EXPORT_EXTRA_OPTS` environment variable, which may include fcli options to allow unsigned custom actions to be used. + - names: DO_AVIATOR_REMEDIATIONS\nAVIATOR_REMEDIATIONS_ACTION\nAVIATOR_REMEDIATIONS_EXTRA_OPTS\nGIT_PUSH_TOKEN + desc: >- + (PREVIEW) If `DO_AVIATOR_REMEDIATIONS` is set to true (implied if any of the other two + `AVIATOR_REMEDIATIONS_*` variables are set) and Aviator remediations are available, those remediations + will be applied to the local source code, committed, and pushed to a new branch. On GitHub, a Pull Request + is created from that branch using the fcli-provided actionRef:_:github-remediations-pr action; on other CI + systems the fcli-provided actionRef:_:push-remediations action is used to push the branch without creating a + Pull or Merge Request. A custom action can be specified through `AVIATOR_REMEDIATIONS_ACTION`, and extra + options for the fcli action can be specified through the `AVIATOR_REMEDIATIONS_EXTRA_OPTS` environment + variable, which may include fcli options to allow unsigned custom actions to be used. The branch push is + authenticated using the `GIT_PUSH_TOKEN` environment variable if set, falling back to the access token of + the current CI system and then to the local Git configuration. ssc: - title: Authentication & Connection fragment: session @@ -435,6 +447,18 @@ formatters: specified through `DEBRICKED_EXPORT_ACTION`. Extra options for the fcli action can be specified through the `DEBRICKED_EXPORT_EXTRA_OPTS` environment variable, which may include fcli options to allow unsigned custom actions to be used. + - names: DO_AVIATOR_REMEDIATIONS\nAVIATOR_REMEDIATIONS_ACTION\nAVIATOR_REMEDIATIONS_EXTRA_OPTS\nGIT_PUSH_TOKEN + desc: >- + (PREVIEW) If `DO_AVIATOR_REMEDIATIONS` is set to true (implied if any of the other two + `AVIATOR_REMEDIATIONS_*` variables are set) and Aviator remediations are available, those remediations + will be applied to the local source code, committed, and pushed to a new branch. On GitHub, a Pull Request + is created from that branch using the fcli-provided actionRef:_:github-remediations-pr action; on other CI + systems the fcli-provided actionRef:_:push-remediations action is used to push the branch without creating a + Pull or Merge Request. A custom action can be specified through `AVIATOR_REMEDIATIONS_ACTION`, and extra + options for the fcli action can be specified through the `AVIATOR_REMEDIATIONS_EXTRA_OPTS` environment + variable, which may include fcli options to allow unsigned custom actions to be used. The branch push is + authenticated using the `GIT_PUSH_TOKEN` environment variable if set, falling back to the access token of + the current CI system and then to the local Git configuration. # ============================================================================ # CI SYSTEM DEFINITIONS diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java index b215feca546..2b71e8053d9 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java @@ -73,6 +73,7 @@ public IActionSpelFunctions detect() { return ActionUnknownCiSpelFunctions.INSTANCE; } + /** * Unknown/unsupported CI system implementation. * Used when no known CI system is detected. diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java index 33f8e720fe5..9fde6dce06a 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java @@ -68,6 +68,16 @@ public ObjectNode addPrComment( return repo.createPullRequestComment(env.pullRequest().id(), body); } + @SpelFunction(cat=ci, desc="Creates a pull request in the repository detected from the current workflow run.", + returns="Created pull request object from GitHub API") + public ObjectNode createPullRequest( + @SpelFunctionParam(name="title", desc="pull request title") String title, + @SpelFunctionParam(name="head", desc="branch containing the changes") String head, + @SpelFunctionParam(name="base", desc="branch to merge into") String base, + @SpelFunctionParam(name="body", desc="pull request description (Markdown supported)") String body) { + return repo.createPullRequest(title, head, base, body); + } + @SpelFunction(cat=ci, desc="(PREVIEW) Adds a review comment on a specific file and line in the pull request detected from the workflow run. This function is not yet used by any built-in fcli actions; signature and implementation may change in future fcli versions based on new insights as to how to best integrate this functionality into fcli built-in actions.", returns="Created review comment object") public ObjectNode addReviewComment( diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java index 6a39e04140b..2970765ea4b 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java @@ -69,6 +69,16 @@ public ObjectNode addMrComment( return project.createMergeRequestNote(env.pullRequest().id(), body); } + @SpelFunction(cat=ci, desc="Creates a merge request in the project detected from the current pipeline run.", + returns="Created merge request object from GitLab API") + public ObjectNode createMergeRequest( + @SpelFunctionParam(name="title", desc="merge request title") String title, + @SpelFunctionParam(name="sourceBranch", desc="branch containing the changes") String sourceBranch, + @SpelFunctionParam(name="targetBranch", desc="branch to merge into") String targetBranch, + @SpelFunctionParam(name="description", desc="merge request description (Markdown supported)") String description) { + return project.createMergeRequest(title, sourceBranch, targetBranch, description); + } + private String requirePipelineId(String operation) { var pipelineId = env.pipelineId(); if (StringUtils.isBlank(pipelineId)) { diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/git/ActionGitSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/git/ActionGitSpelFunctions.java new file mode 100644 index 00000000000..487a993d522 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/git/ActionGitSpelFunctions.java @@ -0,0 +1,513 @@ +/* + * 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.common.action.helper.git; + +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.util; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.RefNotFoundException; +import org.eclipse.jgit.api.errors.TransportException; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.PushResult; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.ci.CiBranch; +import com.fortify.cli.common.ci.CiCommit; +import com.fortify.cli.common.ci.CiCommitId; +import com.fortify.cli.common.ci.CiCommitMessage; +import com.fortify.cli.common.ci.CiGitCredentials; +import com.fortify.cli.common.ci.CiGitCredentialsHelper; +import com.fortify.cli.common.ci.CiPerson; +import com.fortify.cli.common.ci.CiRepository; +import com.fortify.cli.common.ci.CiRepositoryName; +import com.fortify.cli.common.ci.LocalRepoInfo; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionParam; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; +import com.fortify.cli.common.util.EnvHelper; + +import lombok.extern.slf4j.Slf4j; + +/** + * SpEL functions for performing Git operations on a local repository. + * Provides functionality for checking working tree status, creating branches, + * staging files, committing, and pushing changes to a remote. + * + * Available via the {@code #git} SpEL variable in action YAML files. + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +@SpelFunctionPrefix("git.") +@Slf4j +public class ActionGitSpelFunctions { + public static final ActionGitSpelFunctions INSTANCE = new ActionGitSpelFunctions(); + + @SpelFunction(cat = util, desc = """ + Returns basic information about the local git repository for the given source directory, or null if the + directory is not inside a git working tree. Only constant-time lookups are performed (HEAD commit only). + Structure: + { + repository: { workspaceDir, remoteUrl?, name: { short, full? } }, + branch: { full?, short? }, + commit: { + headId: { full, short }, + mergeId: { full, short }, + message: { short, full }, + author: { name, email, when }, + committer: { name, email, when } + } + } + """, returns = "Git repository information or null if not a git work dir", returnType = LocalRepoInfo.class) + public ObjectNode localRepo( + @SpelFunctionParam(name = "sourceDir", desc = "directory assumed to be inside a git working tree") String sourceDir) { + if (StringUtils.isBlank(sourceDir)) { + return null; + } + var dir = Path.of(sourceDir).toAbsolutePath().normalize().toFile(); + if (!dir.exists()) { + return null; + } + FileRepositoryBuilder builder = new FileRepositoryBuilder().findGitDir(dir); + if (builder.getGitDir() == null) { + return null; + } + try (Repository repo = builder.build()) { + var mapper = JsonHelper.getObjectMapper(); + var remote = selectRemote(repo); + var remoteUrl = remote == null ? null : repo.getConfig().getString("remote", remote, "url"); + var names = deriveRepoNames(dir.getName(), remoteUrl); + var repository = CiRepository.builder() + .workspaceDir(repo.getWorkTree().getAbsolutePath()) + .remoteUrl(StringUtils.isBlank(remoteUrl) ? null : remoteUrl) + .name(CiRepositoryName.builder() + .short_(names[0]) + .full(names[1]) + .build()) + .build(); + + CiBranch branch = null; + try { + String fullBranch = repo.getFullBranch(); + if (fullBranch != null) { + branch = CiBranch.builder() + .full(fullBranch) + .short_(Repository.shortenRefName(fullBranch)) + .build(); + } + } catch (Exception e) { + /* ignore */ } + + CiCommit commit = null; + var headId = repo.resolve("HEAD"); + if (headId != null) { + try (var walk = new RevWalk(repo)) { + var gitCommit = walk.parseCommit(headId); + String shortId; + try { + var abbrev = repo.newObjectReader().abbreviate(gitCommit.getId(), 8); + shortId = abbrev.name(); + } catch (Exception ex) { + shortId = gitCommit.getId().getName().substring(0, 8); + } + + var authorIdent = gitCommit.getAuthorIdent(); + var committerIdent = gitCommit.getCommitterIdent(); + + var commitId = CiCommitId.builder() + .full(gitCommit.getId().getName()) + .short_(shortId) + .build(); + + commit = CiCommit.builder() + .headId(commitId) + .mergeId(commitId) + .message(CiCommitMessage.builder() + .short_(gitCommit.getShortMessage()) + .full(gitCommit.getFullMessage()) + .build()) + .author(authorIdent != null ? CiPerson.builder() + .name(authorIdent.getName()) + .email(authorIdent.getEmailAddress()) + .when(authorIdent.getWhenAsInstant().toString()) + .build() : null) + .committer(committerIdent != null ? CiPerson.builder() + .name(committerIdent.getName()) + .email(committerIdent.getEmailAddress()) + .when(committerIdent.getWhenAsInstant().toString()) + .build() : null) + .build(); + } catch (Exception e) { + /* ignore */ } + } + + var root = mapper.createObjectNode(); + root.set("repository", mapper.valueToTree(repository)); + if (branch != null) { + root.set("branch", mapper.valueToTree(branch)); + } + if (commit != null) { + root.set("commit", mapper.valueToTree(commit)); + } + return root; + } catch (Exception e) { + return null; + } + } + + @SpelFunction(cat = util, desc = """ + Captures a snapshot of the paths that currently have uncommitted changes in the working tree + (modified, added, removed, missing, changed, conflicting, or untracked). This snapshot can be + passed to #git.commitChangesSince to commit only the changes introduced afterwards, leaving + pre-existing changes (e.g. build output) untouched. Structure: { paths: [ "relative/path", ... ] } + """, returns = "Snapshot of currently dirty paths, or null if the directory is not a git working tree") + public ObjectNode status( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir) { + try (var git = openGit(sourceDir)) { + if (git == null) { + return null; + } + var root = JsonHelper.getObjectMapper().createObjectNode(); + var paths = root.putArray("paths"); + collectDirtyPaths(git.status().call()).forEach(paths::add); + return root; + } catch (GitAPIException e) { + throw new FcliSimpleException("Failed to determine git status: " + e.getMessage()); + } + } + + @SpelFunction(cat = util, desc = """ + Creates and checks out a new branch, stages only the changes introduced since the given snapshot (as + returned by #git.status), and commits them with the given author and message. Paths that were already + dirty in the snapshot (e.g. build artifacts) are left uncommitted. Returns null without creating a + branch or commit if there are no new changes to commit. + """, returns = "The commit SHA, or null if there were no new changes to commit") + public String commitChangesSince( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir, + @SpelFunctionParam(name = "snapshot", desc = "dirty-paths snapshot from #git.status") JsonNode snapshot, + @SpelFunctionParam(name = "branchName", desc = "full branch name to create and checkout") String branchName, + @SpelFunctionParam(name = "message", desc = "commit message") String message, + @SpelFunctionParam(name = "name", desc = "commit author name") String name, + @SpelFunctionParam(name = "email", desc = "commit author email") String email) { + try (var git = openGit(sourceDir)) { + if (git == null) { + throw new FcliSimpleException("Not a git repository: " + sourceDir); + } + var newPaths = collectDirtyPaths(git.status().call()); + newPaths.removeAll(snapshotPaths(snapshot)); + if (newPaths.isEmpty()) { + return null; + } + createAndCheckoutBranch(git, branchName); + stagePaths(git, newPaths); + var commit = git.commit() + .setMessage(message) + .setAuthor(name, email) + .setCommitter(name, email) + .call(); + return commit.getId().getName(); + } catch (GitAPIException | IOException e) { + throw new FcliSimpleException("Failed to commit changes: " + e.getMessage()); + } + } + + @SpelFunction(cat = util, desc = """ + Pushes the given branch to the remote repository. For HTTPS remotes, authentication uses the + GIT_PUSH_TOKEN environment variable if set, falling back to the token of the active CI system; if + neither is available or authentication fails, the push is retried using the local git configuration + (e.g. an http.extraHeader injected by the CI checkout step). For SSH remotes, the configured SSH keys + are used. Returns the pushed ref. + """, returns = "The name of the remote ref that was pushed") + public String push( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir, + @SpelFunctionParam(name = "branchName", desc = "name of the branch to push") String branchName) { + try (var git = openGit(sourceDir)) { + if (git == null) { + throw new FcliSimpleException("Not a git repository: " + sourceDir); + } + var repo = git.getRepository(); + var remote = StringUtils.defaultString(selectRemote(repo), "origin"); + ensureOnBranch(git, branchName); + var remoteUrl = repo.getConfig().getString("remote", remote, "url"); + var refSpec = new RefSpec("refs/heads/" + branchName + ":refs/heads/" + branchName); + var credentialsProvider = resolveCredentialsProvider(remoteUrl); + try { + push(git, remote, refSpec, credentialsProvider); + } catch (Exception e) { + if (credentialsProvider == null || !isLikelyAuthFailure(e)) { + throw e; + } + log.debug("Push using resolved credentials failed ({}); retrying with local git configuration", + rootMessage(e)); + push(git, remote, refSpec, null); + } + return "refs/heads/" + branchName; + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + throw new FcliSimpleException("Failed to push branch " + branchName + ": " + rootMessage(e), e); + } + } + + private void push(Git git, String remote, RefSpec refSpec, CredentialsProvider credentialsProvider) + throws GitAPIException { + var pushCmd = git.push().setRemote(remote).setRefSpecs(refSpec).setTimeout(300); + if (credentialsProvider != null) { + pushCmd.setCredentialsProvider(credentialsProvider); + } + verifyPushResults(pushCmd.call(), remote); + } + + @SpelFunction(cat = util, desc = "Detects the default branch of the remote repository. Checks CI environment variables (CI_DEFAULT_BRANCH), then falls back to reading refs/remotes//HEAD from the local git config. Returns null if detection fails.", returns = "The default branch name (e.g. 'main', 'master', 'develop') or null if not detectable") + public String defaultBranch( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir) { + var defaultBranch = EnvHelper.env("CI_DEFAULT_BRANCH"); + if (StringUtils.isNotBlank(defaultBranch)) { + return defaultBranch; + } + try (var git = openGit(sourceDir)) { + if (git == null) { + return null; + } + var repo = git.getRepository(); + var remoteDefaultBranch = detectDefaultBranchFromRemoteHeads(repo); + if (StringUtils.isNotBlank(remoteDefaultBranch)) { + return remoteDefaultBranch; + } + } catch (Exception e) { + log.debug("Error detecting default branch", e); + } + return null; + } + + private Git openGit(String sourceDir) { + if (StringUtils.isBlank(sourceDir)) { + return null; + } + try { + var dir = Path.of(sourceDir).toAbsolutePath().normalize().toFile(); + if (!dir.exists()) { + return null; + } + var builder = new FileRepositoryBuilder().findGitDir(dir); + if (builder.getGitDir() == null) { + return null; + } + return new Git(builder.build()); + } catch (Exception e) { + return null; + } + } + + private void ensureOnBranch(Git git, String branchName) throws GitAPIException, IOException { + if (branchName.equals(git.getRepository().getBranch())) { + return; + } + try { + git.checkout().setName(branchName).call(); + } catch (RefNotFoundException e) { + git.checkout().setCreateBranch(true).setName(branchName).setStartPoint("HEAD").call(); + } + } + + private void createAndCheckoutBranch(Git git, String branchName) throws GitAPIException, IOException { + git.checkout().setCreateBranch(true).setName(branchName).call(); + if (!branchName.equals(git.getRepository().getBranch())) { + throw new FcliSimpleException("Failed to checkout branch " + branchName); + } + } + + private void stagePaths(Git git, Set paths) throws GitAPIException { + var workTree = git.getRepository().getWorkTree(); + var addNew = git.add(); + var addDeleted = git.add().setUpdate(true); + var hasNew = false; + var hasDeleted = false; + for (var path : paths) { + if (new File(workTree, path).exists()) { + addNew.addFilepattern(path); + hasNew = true; + } else { + addDeleted.addFilepattern(path); + hasDeleted = true; + } + } + if (hasNew) { + addNew.call(); + } + if (hasDeleted) { + addDeleted.call(); + } + } + + private static Set collectDirtyPaths(Status status) { + var paths = new TreeSet(); + paths.addAll(status.getModified()); + paths.addAll(status.getChanged()); + paths.addAll(status.getAdded()); + paths.addAll(status.getRemoved()); + paths.addAll(status.getMissing()); + paths.addAll(status.getUntracked()); + paths.addAll(status.getConflicting()); + return paths; + } + + private static Set snapshotPaths(JsonNode snapshot) { + var paths = new TreeSet(); + if (snapshot != null && snapshot.get("paths") instanceof ArrayNode arr) { + arr.forEach(node -> paths.add(node.asText())); + } + return paths; + } + + private static CredentialsProvider resolveCredentialsProvider(String remoteUrl) { + CiGitCredentials credentials = CiGitCredentialsHelper.resolvePushCredentials(remoteUrl); + if (credentials == null) { + return null; + } + return new UsernamePasswordCredentialsProvider(credentials.username(), credentials.token()); + } + + private static void verifyPushResults(Iterable results, String remote) { + var updated = false; + for (var result : results) { + for (var update : result.getRemoteUpdates()) { + switch (update.getStatus()) { + case OK: + case UP_TO_DATE: + updated = true; + break; + default: + throw new FcliSimpleException("Push to " + remote + " rejected: status=" + update.getStatus() + + (update.getMessage() != null ? " (" + update.getMessage() + ")" : "")); + } + } + } + if (!updated) { + throw new FcliSimpleException("Push to " + remote + + " did not update any refs (likely an authentication or permission issue)"); + } + } + + private static boolean isLikelyAuthFailure(Throwable e) { + for (var cause = e; cause != null; cause = cause.getCause()) { + if (cause instanceof TransportException) { + return true; + } + var message = cause.getMessage(); + if (message != null) { + var lower = message.toLowerCase(); + if (lower.contains("401") || lower.contains("403") || lower.contains("auth") + || lower.contains("not authorized") || lower.contains("forbidden") + || lower.contains("permission")) { + return true; + } + } + } + return false; + } + + private static String detectDefaultBranchFromRemoteHeads(Repository repo) { + var remoteNames = repo.getRemoteNames(); + if (remoteNames == null || remoteNames.isEmpty()) { + return null; + } + try { + for (var remote : remoteNames) { + var refName = "refs/remotes/" + remote + "/HEAD"; + var ref = repo.exactRef(refName); + if (ref != null && ref.getTarget() != null) { + var target = ref.getTarget().getName(); + var prefix = "refs/remotes/" + remote + "/"; + if (target.startsWith(prefix)) { + return target.substring(prefix.length()); + } + } + } + } catch (IOException e) { + log.debug("Failed to resolve default branch from remote HEAD", e); + } + return null; + } + + private static String rootMessage(Throwable e) { + var root = e; + while (root.getCause() != null) { + root = root.getCause(); + } + return root.getMessage(); + } + + private static String selectRemote(Repository repo) { + try { + var remotes = repo.getRemoteNames(); + if (remotes == null || remotes.isEmpty()) + return null; + return remotes.contains("origin") ? "origin" : remotes.iterator().next(); + } catch (Exception e) { + return null; + } + } + + private static String[] deriveRepoNames(String fallbackShort, String remoteUrl) { + if (StringUtils.isBlank(remoteUrl)) { + return new String[] { fallbackShort, null }; + } + try { + var cleaned = remoteUrl.trim(); + if (cleaned.endsWith(".git")) { + cleaned = cleaned.substring(0, cleaned.length() - 4); + } + String pathPart; + if (cleaned.startsWith("git@")) { + int idx = cleaned.indexOf(":"); + pathPart = idx >= 0 ? cleaned.substring(idx + 1) : cleaned; + } else { + var uri = URI.create(cleaned); + pathPart = uri.getPath(); + if (pathPart.startsWith("/")) { + pathPart = pathPart.substring(1); + } + } + var parts = pathPart.split("/"); + if (parts.length >= 2) { + var shortName = parts[parts.length - 1]; + return new String[] { shortName, pathPart }; + } + return new String[] { parts[parts.length - 1], null }; + } catch (Exception e) { + return new String[] { fallbackShort, null }; + } + } + +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java index 415408921c8..4965b087c33 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.action.helper.ci.ActionCiSpelFunctionsRegistry; import com.fortify.cli.common.action.helper.fs.ActionFileSystemSpelFunctions; +import com.fortify.cli.common.action.helper.git.ActionGitSpelFunctions; import com.fortify.cli.common.action.model.ActionStepCheckEntry; import com.fortify.cli.common.action.model.ActionStepCheckEntry.CheckStatus; import com.fortify.cli.common.action.model.FcliActionValidationException; @@ -234,6 +235,7 @@ protected final void configureSpelContext(SimpleEvaluationContext spelContext) { ActionCiSpelFunctionsRegistry.registerInfoVariables(spelContext); } spelContext.setVariable("fs", ActionFileSystemSpelFunctions.INSTANCE); + spelContext.setVariable("git", ActionGitSpelFunctions.INSTANCE); spelContext.setVariable("fcli", FcliCommandsSpelFunctions.INSTANCE); } } diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java index 1d772109965..e7dd7ca4d04 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java @@ -20,7 +20,6 @@ import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.util; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.workflow; -import java.net.URI; import java.nio.file.Path; import java.time.LocalDateTime; import java.time.Year; @@ -34,10 +33,6 @@ import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevWalk; -import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -50,14 +45,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.action.helper.ActionLoaderHelper; +import com.fortify.cli.common.action.helper.git.ActionGitSpelFunctions; import com.fortify.cli.common.action.schema.ActionSchemaDescriptorFactory; -import com.fortify.cli.common.ci.CiBranch; -import com.fortify.cli.common.ci.CiCommit; -import com.fortify.cli.common.ci.CiCommitId; -import com.fortify.cli.common.ci.CiCommitMessage; -import com.fortify.cli.common.ci.CiPerson; -import com.fortify.cli.common.ci.CiRepository; -import com.fortify.cli.common.ci.CiRepositoryName; import com.fortify.cli.common.ci.LocalRepoInfo; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.FortifyTraceNodeHelper; @@ -547,6 +536,8 @@ public static final String copyright() { } @SpelFunction(cat=internal, desc=""" + (DEPRECATED) Use `#git.localRepo(...)` instead; this function is retained only for backward \ + compatibility and simply delegates to it. It may be removed in a future fcli release. Returns basic information about the local git repository for the given source directory, or null if the directory is not inside a git working tree. Only constant-time lookups are performed (HEAD commit only). Structure: @@ -562,96 +553,10 @@ public static final String copyright() { } """, returns="Git repository information or null if not a git work dir", returnType=LocalRepoInfo.class) + @Deprecated public static final ObjectNode localRepo( @SpelFunctionParam(name="sourceDir", desc="directory assumed to be inside a git working tree") String sourceDir) { - if (StringUtils.isBlank(sourceDir)) { return null; } - var dir = Path.of(sourceDir).toAbsolutePath().normalize().toFile(); - if (!dir.exists()) { return null; } - FileRepositoryBuilder builder = new FileRepositoryBuilder().findGitDir(dir); - if (builder.getGitDir()==null) { return null; } - try (Repository repo = builder.build()) { - var mapper = JsonHelper.getObjectMapper(); - - // Repository information - var remote = ActionSpelFunctionsJGitHelper.selectRemote(repo); - var remoteUrl = remote==null?null:repo.getConfig().getString("remote", remote, "url"); - var names = ActionSpelFunctionsJGitHelper.deriveRepoNames(dir.getName(), remoteUrl); - var repository = CiRepository.builder() - .workspaceDir(repo.getWorkTree().getAbsolutePath()) - .remoteUrl(StringUtils.isBlank(remoteUrl) ? null : remoteUrl) - .name(CiRepositoryName.builder() - .short_(names[0]) - .full(names[1]) - .build()) - .build(); - - // Branch information - CiBranch branch = null; - try { - String fullBranch = repo.getFullBranch(); - if (fullBranch != null) { - branch = CiBranch.builder() - .full(fullBranch) - .short_(Repository.shortenRefName(fullBranch)) - .build(); - } - } catch (Exception e) { } - - // Commit information - CiCommit commit = null; - var headId = repo.resolve("HEAD"); - if (headId != null) { - try (var walk = new RevWalk(repo)) { - RevCommit gitCommit = walk.parseCommit(headId); - String shortId; - try { - var abbrev = repo.newObjectReader().abbreviate(gitCommit.getId(), 8); - shortId = abbrev.name(); - } catch (Exception ex) { - shortId = gitCommit.getId().getName().substring(0, 8); - } - - var authorIdent = gitCommit.getAuthorIdent(); - var committerIdent = gitCommit.getCommitterIdent(); - - var commitId = CiCommitId.builder() - .full(gitCommit.getId().getName()) - .short_(shortId) - .build(); - - commit = CiCommit.builder() - .headId(commitId) - .mergeId(commitId) // Same as headId for local repos - .message(CiCommitMessage.builder() - .short_(gitCommit.getShortMessage()) - .full(gitCommit.getFullMessage()) - .build()) - .author(authorIdent != null ? CiPerson.builder() - .name(authorIdent.getName()) - .email(authorIdent.getEmailAddress()) - .when(authorIdent.getWhenAsInstant().toString()) - .build() : null) - .committer(committerIdent != null ? CiPerson.builder() - .name(committerIdent.getName()) - .email(committerIdent.getEmailAddress()) - .when(committerIdent.getWhenAsInstant().toString()) - .build() : null) - .build(); - } catch (Exception e) { } - } - - // Build root object - var root = mapper.createObjectNode(); - root.set("repository", mapper.valueToTree(repository)); - if (branch != null) { - root.set("branch", mapper.valueToTree(branch)); - } - if (commit != null) { - root.set("commit", mapper.valueToTree(commit)); - } - - return root; - } catch (Exception e) { return null; } + return ActionGitSpelFunctions.INSTANCE.localRepo(sourceDir); } private static final class ActionSpelFunctionsJsoupHelper { @@ -691,46 +596,6 @@ private static String documentToPlainText(Document document) { } } - private static final class ActionSpelFunctionsJGitHelper { - private static String selectRemote(Repository repo) { - try { - var remotes = repo.getRemoteNames(); - if (remotes==null || remotes.isEmpty()) { return null; } - if (remotes.contains("origin")) { return "origin"; } - return remotes.iterator().next(); - } catch (Exception e) { return null; } - } - - private static String[] deriveRepoNames(String fallbackShort, String remoteUrl) { - if (StringUtils.isBlank(remoteUrl)) { return new String[]{fallbackShort, null}; } - try { - var cleaned = remoteUrl.trim(); - if (cleaned.endsWith(".git")) { cleaned = cleaned.substring(0, cleaned.length()-4); } - String pathPart; - if (cleaned.startsWith("git@")) { - int idx = cleaned.indexOf(":"); - pathPart = idx>=0 ? cleaned.substring(idx+1) : cleaned; - } else { - try { - var uri = URI.create(cleaned); - pathPart = uri.getPath(); - if (pathPart==null) { pathPart = cleaned; } - } catch (Exception ex) { pathPart = cleaned; } - } - if (pathPart.startsWith("/")) { pathPart = pathPart.substring(1); } - if (pathPart.endsWith("/")) { pathPart = pathPart.substring(0, pathPart.length()-1); } - if (pathPart.contains("/")) { - var shortName = pathPart.substring(pathPart.lastIndexOf('/')+1); - return new String[]{shortName, pathPart}; - } - return new String[]{pathPart, pathPart}; - } catch (Exception e) { return new String[]{fallbackShort, null}; } - } - - } - - - private static final class ActionSpelFunctionsHelper { private static final String envOrDefault(String prefix, String suffix, String defaultValue) { var envName = String.format("%s_%s", prefix, suffix).toUpperCase().replace('-', '_'); diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentials.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentials.java new file mode 100644 index 00000000000..369d147faa1 --- /dev/null +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentials.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.common.ci; + +import org.apache.commons.lang3.StringUtils; + +/** + * Basic authentication credentials for pushing to a git remote. + * + * @param username basic-auth username + * @param token basic-auth token or password + */ +public record CiGitCredentials(String username, String token) { + public boolean isPresent() { + return StringUtils.isNotBlank(username) && StringUtils.isNotBlank(token); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentialsHelper.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentialsHelper.java new file mode 100644 index 00000000000..246cd7e2ce4 --- /dev/null +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentialsHelper.java @@ -0,0 +1,159 @@ +/* + * 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.common.ci; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.ci.ado.AdoEnvironment; +import com.fortify.cli.common.ci.bitbucket.BitbucketEnvironment; +import com.fortify.cli.common.ci.github.GitHubEnvironment; +import com.fortify.cli.common.ci.gitlab.GitLabEnvironment; +import com.fortify.cli.common.util.EnvHelper; + +/** + * Resolves basic-auth credentials for authenticated Git operations (in particular {@code push}). + * The generic {@code GIT_PUSH_TOKEN} environment variable takes precedence, as CI-provided + * tokens may not carry push permissions. If not set, this falls back to the credentials detected + * for the currently active CI system, reusing the token detection already implemented in the + * individual {@code *Environment} classes. + */ +public final class CiGitCredentialsHelper { + /** Generic, CI-agnostic push token override. */ + public static final String ENV_GIT_PUSH_TOKEN = "GIT_PUSH_TOKEN"; + + private static final String GITHUB_USERNAME = "x-access-token"; + private static final String GITLAB_PAT_USERNAME = "oauth2"; + private static final String GITLAB_JOB_TOKEN_USERNAME = "gitlab-ci-token"; + private static final String ADO_USERNAME = "AzureDevOps"; + private static final String BITBUCKET_TOKEN_USERNAME = "x-token-auth"; + + private CiGitCredentialsHelper() {} + + /** + * Resolve the basic-auth credentials to use for pushing to the remote repository. + * + * @param remoteUrl remote URL of the git repository, used to infer a username when only a + * generic push token override is available + * @return credentials for push operations, or {@code null} if no credentials are available + */ + public static CiGitCredentials resolvePushCredentials(String remoteUrl) { + if (isSshUrl(remoteUrl)) { + return null; + } + + var explicitToken = EnvHelper.env(ENV_GIT_PUSH_TOKEN); + if (StringUtils.isNotBlank(explicitToken)) { + return new CiGitCredentials(resolveUsername(remoteUrl), explicitToken); + } + return resolveActiveCiCredentials(); + } + + private static CiGitCredentials resolveActiveCiCredentials() { + if (GitHubEnvironment.detect() != null) { + return credentials(GITHUB_USERNAME, EnvHelper.env(GitHubEnvironment.ENV_TOKEN)); + } + if (GitLabEnvironment.detect() != null) { + var token = EnvHelper.env(GitLabEnvironment.ENV_TOKEN); + if (StringUtils.isBlank(token)) { + return null; + } + var jobToken = EnvHelper.env(GitLabEnvironment.ENV_JOB_TOKEN); + var username = StringUtils.isNotBlank(jobToken) && jobToken.equals(token) + ? GITLAB_JOB_TOKEN_USERNAME : GITLAB_PAT_USERNAME; + return credentials(username, token); + } + if (AdoEnvironment.detect() != null) { + return credentials(ADO_USERNAME, EnvHelper.env(AdoEnvironment.ENV_TOKEN)); + } + if (BitbucketEnvironment.detect() != null) { + var username = EnvHelper.env(BitbucketEnvironment.ENV_USERNAME); + var token = EnvHelper.env(BitbucketEnvironment.ENV_APP_PASSWORD); + if (StringUtils.isNotBlank(username) && StringUtils.isNotBlank(token)) { + return credentials(username, token); + } + token = EnvHelper.env(BitbucketEnvironment.ENV_STEP_OAUTH_TOKEN); + if (StringUtils.isBlank(token)) { + token = EnvHelper.env(BitbucketEnvironment.ENV_TOKEN); + } + return credentials(BITBUCKET_TOKEN_USERNAME, token); + } + return null; + } + + private static CiGitCredentials credentials(String username, String token) { + return StringUtils.isNotBlank(token) ? new CiGitCredentials(username, token) : null; + } + + private static String resolveUsername(String remoteUrl) { + var activeCredentials = resolveActiveCiCredentials(); + if (activeCredentials != null && StringUtils.isNotBlank(activeCredentials.username())) { + return activeCredentials.username(); + } + switch (detectPlatformFromUrl(remoteUrl)) { + case "github": + return GITHUB_USERNAME; + case "gitlab": + return GITLAB_PAT_USERNAME; + case "ado": + return ADO_USERNAME; + case "bitbucket": + return BITBUCKET_TOKEN_USERNAME; + default: + return "git"; + } + } + + private static boolean isSshUrl(String remoteUrl) { + if (StringUtils.isBlank(remoteUrl)) { + return false; + } + var url = remoteUrl.trim(); + return url.startsWith("git@") || url.startsWith("ssh://"); + } + + private static String detectPlatformFromUrl(String remoteUrl) { + if (StringUtils.isBlank(remoteUrl)) { + return "unknown"; + } + try { + String host; + var cleaned = remoteUrl.trim(); + if (cleaned.startsWith("git@")) { + int colon = cleaned.indexOf(':'); + int at = cleaned.indexOf('@'); + host = (at >= 0 && colon > at) ? cleaned.substring(at + 1, colon) : null; + } else { + host = java.net.URI.create(cleaned).getHost(); + } + if (host == null) { + return "unknown"; + } + host = host.toLowerCase(); + if (host.equals("github.com") || host.endsWith(".github.com")) { + return "github"; + } + if (host.equals("gitlab.com") || host.contains("gitlab")) { + return "gitlab"; + } + if (host.contains("dev.azure.com") || host.contains("visualstudio.com") || host.contains("azure")) { + return "ado"; + } + if (host.contains("bitbucket")) { + return "bitbucket"; + } + } catch (Exception e) { + return "unknown"; + } + return "unknown"; + } +} diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java index ee7159fd2d9..b9d0a3810c3 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java @@ -293,6 +293,30 @@ public ObjectNode createReviewComment(String pullNumber, String commitId, .getBody(); } + /** + * Create a pull request. + * + * @param title Pull request title + * @param head The name of the branch where your changes are implemented + * @param base The name of the branch you want the changes pulled into + * @param body Pull request description (Markdown supported) + * @return Created pull request object + */ + public ObjectNode createPullRequest(String title, String head, String base, String body) { + var requestBody = JsonHelper.getObjectMapper().createObjectNode() + .put("title", title) + .put("head", head) + .put("base", base) + .put("body", body); + + return unirest + .post("/repos/{owner}/{repo}/pulls") + .routeParam("owner", owner) + .routeParam("repo", repo) + .body(requestBody) + .asObject(ObjectNode.class) + .getBody(); + } // === Branch and Commit Operations === /** diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java index 895765256fd..7b1d0c4283c 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java @@ -134,6 +134,29 @@ public ObjectNode createMergeRequestNote(String mergeRequestIid, String body) { .getBody(); } + /** + * Create a merge request. + * + * @param title Merge request title + * @param sourceBranch The source branch + * @param targetBranch The target branch + * @param description Merge request description (Markdown supported) + * @return Created merge request object + */ + public ObjectNode createMergeRequest(String title, String sourceBranch, String targetBranch, String description) { + var requestBody = JsonHelper.getObjectMapper().createObjectNode() + .put("title", title) + .put("source_branch", sourceBranch) + .put("target_branch", targetBranch) + .put("description", description); + + return unirest + .post("/projects/{id}/merge_requests") + .routeParam("id", projectId) + .body(requestBody) + .asObject(ObjectNode.class) + .getBody(); + } // === Branch and Commit Operations === /** diff --git a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/CiGitCredentialsHelperTest.java b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/CiGitCredentialsHelperTest.java new file mode 100644 index 00000000000..8026d5d4ad3 --- /dev/null +++ b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/CiGitCredentialsHelperTest.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.common.ci; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class CiGitCredentialsHelperTest { + @AfterEach + void clearEnvironment() { + CiEnvironmentTestHelper.clearAllCiEnvironmentVariables(); + System.clearProperty("fcli.env." + CiGitCredentialsHelper.ENV_GIT_PUSH_TOKEN); + } + + @Test + void resolvePushCredentialsUsesGitHubToken() { + System.setProperty("fcli.env.GITHUB_REPOSITORY", "owner/repo"); + System.setProperty("fcli.env.GITHUB_TOKEN", "gh-token"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://github.com/fortify/fcli.git"); + + assertNotNull(credentials); + assertEquals("x-access-token", credentials.username()); + assertEquals("gh-token", credentials.token()); + } + + @Test + void resolvePushCredentialsUsesGitLabJobTokenUsername() { + System.setProperty("fcli.env.GITLAB_CI", "true"); + System.setProperty("fcli.env.CI_PROJECT_ID", "123"); + System.setProperty("fcli.env.CI_JOB_TOKEN", "gl-job-token"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://gitlab.example.com/group/repo.git"); + + assertNotNull(credentials); + assertEquals("gitlab-ci-token", credentials.username()); + assertEquals("gl-job-token", credentials.token()); + } + + @Test + void resolvePushCredentialsUsesGitLabPatUsername() { + System.setProperty("fcli.env.GITLAB_CI", "true"); + System.setProperty("fcli.env.CI_PROJECT_ID", "123"); + System.setProperty("fcli.env.GITLAB_TOKEN", "gl-pat"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://gitlab.example.com/group/repo.git"); + + assertNotNull(credentials); + assertEquals("oauth2", credentials.username()); + assertEquals("gl-pat", credentials.token()); + } + + @Test + void resolvePushCredentialsUsesExplicitOverrideWithDetectedUsername() { + System.setProperty("fcli.env.GITLAB_CI", "true"); + System.setProperty("fcli.env.CI_PROJECT_ID", "123"); + System.setProperty("fcli.env.CI_JOB_TOKEN", "gl-job-token"); + System.setProperty("fcli.env.GIT_PUSH_TOKEN", "override-token"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://gitlab.example.com/group/repo.git"); + + assertNotNull(credentials); + assertEquals("gitlab-ci-token", credentials.username()); + assertEquals("override-token", credentials.token()); + } + + @Test + void resolvePushCredentialsReturnsNullForSshRemote() { + System.setProperty("fcli.env.GITHUB_REPOSITORY", "owner/repo"); + System.setProperty("fcli.env.GITHUB_TOKEN", "gh-token"); + + assertNull(CiGitCredentialsHelper.resolvePushCredentials("git@github.com:fortify/fcli.git")); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml index 3a48f95349c..e02feb6f9b5 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml @@ -135,6 +135,15 @@ steps: on.success: - var.set: { postScan.skipReason: } # Reset postScan.skipReason to allow post-scan tasks to run + AVIATOR_REMEDIATIONS: + cmd: ${#actionCmd('AVIATOR_REMEDIATIONS', 'fod', global.ci.fod_aviatorRemediationsAction)} "--rel=${global.ci.rel}" "--source-dir=${global.ci.sourceDir}" "--progress=none" + skip.if-reason: + - ${#actionCmdSkipFromEnvReason('AVIATOR_REMEDIATIONS', true)} # Skip unless DO_AVIATOR_REMEDIATIONS==true or AVIATOR_REMEDIATIONS_ACTION/EXTRA_OPTS defined + - ${#actionCmdSkipNoActionReason('AVIATOR_REMEDIATIONS', 'fod', global.ci.fod_aviatorRemediationsAction)} + - ${postScan.skipReason} # Skip if no scans were run + - ${#env('DO_AVIATOR_AUDIT')!='true'?'Aviator audit not enabled (DO_AVIATOR_AUDIT!=true), no remediations available':''} # Skip if Aviator audit was not enabled + - ${SAST_WAIT.dependencySkipReason} # Skip if SAST scan/wait was skipped or failed + CHECK_POLICY: cmd: ${#actionCmd('CHECK_POLICY', 'fod', 'check-policy')} "--rel=${global.ci.rel}" "--progress=none" stdout: collect diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-remediations-pr.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-remediations-pr.yaml new file mode 100644 index 00000000000..8555287bf65 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-remediations-pr.yaml @@ -0,0 +1,86 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and raise a GitHub pull request + description: | + This action runs the `push-remediations` action to apply Aviator-generated remediations + and push them to a new branch, then creates a GitHub pull request for that branch. If the + remediation step produced no changes, no branch is pushed and no pull request is created. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--rel` -- FoD release to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--base-branch` / `BASE_BRANCH` -- Target branch for the pull request (default: current branch) + - `--title` / `PR_TITLE` -- Pull request title + - `--body` / `PR_BODY` -- Pull request description + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + rel: + names: --rel + description: "FoD release to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + baseBranch: + names: --base-branch + description: "Target branch for the pull request. Defaults to the currently checked-out branch, falling back to the repository default branch or 'main'." + required: false + default: "${#env('BASE_BRANCH')?:''}" + title: + names: --title, -t + description: "Pull request title." + required: false + default: "${#env('PR_TITLE')?:'fix: Fortify auto-remediation fixes [Generated by fcli aviator]'}" + body: + names: --body + description: "Pull request description." + required: false + default: "${#env('PR_BODY')?:'This pull request contains changes applied by fcli aviator.'}" + +steps: + # Capture the currently checked-out branch to use as the pull request base, before + # push-remediations checks out the new remediation branch. + - var.set: + baseBranch: "${#ifBlank(cli.baseBranch, #ifBlank(#git.localRepo(cli.sourceDir)?.branch?.short, #ifBlank(#git.defaultBranch(cli.sourceDir), 'main')))}" + + # Apply remediations and push them to the new branch + - run.fcli: + PUSH_REMEDIATIONS: + cmd: fod action run push-remediations "--source-dir=${cli.sourceDir}" "--rel=${cli.rel}" "--branch-name=${cli.branchName}" "--progress=none" + status.check: true + + # Only create a pull request if push-remediations actually created and checked out the + # remediation branch (i.e. there were remediation changes to push). + - var.set: + currentBranch: ${#git.localRepo(cli.sourceDir)?.branch?.short} + - if: ${currentBranch!=cli.branchName} + do: + - log.info: "No remediation changes were pushed; skipping pull request creation." + - exit: 0 + + # Create the GitHub pull request + - var.set: + pr: ${#github.repo().createPullRequest(cli.title, cli.branchName, baseBranch, cli.body)} + on.fail: + - throw: + msg: "Failed to create GitHub pull request from '${cli.branchName}' to '${baseBranch}'" + cause: ${lastException} + - if: "${#isBlank(pr) || #isBlank(pr.html_url)}" + throw: "GitHub pull request creation did not return the expected response." + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.prUrl: ${pr.html_url} + - log.info: "Created GitHub pull request #${pr.number}: ${pr.html_url}" diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/push-remediations.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/push-remediations.yaml new file mode 100644 index 00000000000..a5b75b336c1 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/push-remediations.yaml @@ -0,0 +1,103 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and push them to a new branch + description: | + This action applies Aviator-generated remediations to the local source code, then + commits and pushes only those changes to a new branch on the remote repository. + + Change detection is snapshot-based: any files that were already modified before the + remediations were applied (for example build output) are left untouched; only the + files changed by the remediation step are committed. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--rel` -- FoD release to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--commit-message` / `COMMIT_MESSAGE` + - `--author-name` / `GIT_AUTHOR_NAME` + - `--author-email` / `GIT_AUTHOR_EMAIL` + + Authentication for the push uses the `GIT_PUSH_TOKEN` environment variable if set, + falling back to the token of the active CI system, and finally to the local git + configuration (for example an http.extraHeader injected by the CI checkout step). + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + rel: + names: --rel + description: "FoD release to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + commitMessage: + names: --commit-message, -m + description: "Git commit message." + required: false + default: "${#env('COMMIT_MESSAGE')?:'fix: apply automated fixes [generated by fcli aviator]'}" + authorName: + names: --author-name + description: "Git author name for the commit." + required: false + default: "${#env('GIT_AUTHOR_NAME')?:'fcli-aviator[bot]'}" + authorEmail: + names: --author-email + description: "Git author email for the commit." + required: false + default: "${#env('GIT_AUTHOR_EMAIL')?:'fcli-aviator@opentext.com'}" + +steps: + # Validate that the source directory is a git working tree with a configured remote + - var.set: + gitRepoInfo: ${#git.localRepo(cli.sourceDir)} + - if: ${#isBlank(gitRepoInfo)} + throw: "Source directory '${cli.sourceDir}' is not a git repository." + - if: ${#isBlank(gitRepoInfo.repository.remoteUrl)} + throw: "Git repository has no remote URL configured; a remote is required to push remediations." + + # Snapshot the paths that are already dirty before remediations are applied, so that + # pre-existing changes (e.g. build output) are excluded from the remediation commit. + - var.set: + snapshot: ${#git.status(cli.sourceDir)} + + # Apply the Aviator remediations to the local source code + - run.fcli: + APPLY_REMEDIATIONS: + cmd: fod aviator apply-remediations "--rel=${cli.rel}" "--source-dir=${cli.sourceDir}" "--progress=none" + status.check: true + + # Commit only the changes introduced by the remediation step to a new branch + - var.set: + commitSha: ${#git.commitChangesSince(cli.sourceDir, snapshot, cli.branchName, cli.commitMessage, cli.authorName, cli.authorEmail)} + on.fail: + - throw: + msg: "Failed to commit Aviator remediations" + cause: ${lastException} + - if: ${#isBlank(commitSha)} + do: + - log.info: "No remediation changes to commit; nothing was pushed." + - exit: 0 + - log.info: "Committed Aviator remediations to branch '${cli.branchName}' (${commitSha})" + + # Push the new branch to the remote repository + - var.set: + pushedRef: ${#git.push(cli.sourceDir, cli.branchName)} + on.fail: + - throw: + msg: "Failed to push branch '${cli.branchName}'" + cause: ${lastException} + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.pushed: true + - log.info: "Pushed Aviator remediations to branch '${cli.branchName}' (${pushedRef})" diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml index f6aa05d32ba..a6d989daf4b 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml @@ -195,15 +195,27 @@ steps: skip.if-reason: - ${postScan.skipReason} # Skip if no scans were run - ${aviator.skipReason} # Skip if Aviator audit is to be skipped - - + on.success: + - var.set: + aviator.artifactId: "${#var('aviator_audit').hasNonNull('artifactId') ? #var('aviator_audit').get('artifactId').asText() : ''}" + AVIATOR_WAIT: - cmd: "${#fcliCmd('AVIATOR_WAIT', 'ssc artifact wait-for')} ::aviator_audit::" + cmd: "${#fcliCmd('AVIATOR_WAIT', 'ssc artifact wait-for')} ${aviator.artifactId}" skip.if-reason: - ${postScan.skipReason} # Skip if no scans were run - ${aviator.skipReason} # Skip if Aviator audit is to be skipped - ${AVIATOR_AUDIT.dependencySkipReason} # Skip if AVIATOR_AUDIT was skipped or failed + - "${#isBlank(aviator.artifactId) ? 'No artifact produced by Aviator audit, nothing to wait for' : ''}" + AVIATOR_REMEDIATIONS: + cmd: ${#actionCmd('AVIATOR_REMEDIATIONS', 'ssc', global.ci.ssc_aviatorRemediationsAction)} "--source-dir=${global.ci.sourceDir}" "--artifact-id=${aviator.artifactId}" "--progress=none" + skip.if-reason: + - ${#actionCmdSkipFromEnvReason('AVIATOR_REMEDIATIONS', true)} # Skip unless DO_AVIATOR_REMEDIATIONS==true or AVIATOR_REMEDIATIONS_ACTION/EXTRA_OPTS defined + - ${#actionCmdSkipNoActionReason('AVIATOR_REMEDIATIONS', 'ssc', global.ci.ssc_aviatorRemediationsAction)} + - ${postScan.skipReason} # Skip if no scans were run + - ${aviator.skipReason} # Skip if Aviator is not configured + - ${AVIATOR_WAIT.dependencySkipReason} # Skip if AVIATOR_WAIT was skipped or failed (no remediations available) + CHECK_POLICY: cmd: ${#actionCmd('CHECK_POLICY', 'ssc', 'check-policy')} "--av=${global.ci.av}" "--progress=none" stdout: collect @@ -283,4 +295,3 @@ formatters: ${CHECK_POLICY.exitCode==0||CHECK_POLICY.exitCode==100?CHECK_POLICY.stdout:CHECK_POLICY.dependencySkipReason} ${APPVERSION_SUMMARY.stdout} - \ No newline at end of file diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml index ee5fd339896..a096fe4cb16 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml @@ -79,7 +79,7 @@ steps: - if: ${#isBlank(cli.repository)||#isBlank(cli.branch)} do: - var.set: - localRepository: ${#localRepo(cli.sourceDir)} + localRepository: ${#git.localRepo(cli.sourceDir)} - var.set: repo: ${#ifBlank(cli.repository, localRepository?.repository?.name?.full)} branch: ${#ifBlank(cli.branch, localRepository?.branch?.short)} diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-remediations-pr.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-remediations-pr.yaml new file mode 100644 index 00000000000..34359123947 --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-remediations-pr.yaml @@ -0,0 +1,86 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and raise a GitHub pull request + description: | + This action runs the `push-remediations` action to apply Aviator-generated remediations + and push them to a new branch, then creates a GitHub pull request for that branch. If the + remediation step produced no changes, no branch is pushed and no pull request is created. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--artifact-id` -- SSC artifact id to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--base-branch` / `BASE_BRANCH` -- Target branch for the pull request (default: current branch) + - `--title` / `PR_TITLE` -- Pull request title + - `--body` / `PR_BODY` -- Pull request description + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + artifactId: + names: --artifact-id + description: "SSC artifact id to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + baseBranch: + names: --base-branch + description: "Target branch for the pull request. Defaults to the currently checked-out branch, falling back to the repository default branch or 'main'." + required: false + default: "${#env('BASE_BRANCH')?:''}" + title: + names: --title, -t + description: "Pull request title." + required: false + default: "${#env('PR_TITLE')?:'fix: Fortify auto-remediation fixes [Generated by fcli aviator]'}" + body: + names: --body + description: "Pull request description." + required: false + default: "${#env('PR_BODY')?:'This pull request contains changes applied by fcli aviator.'}" + +steps: + # Capture the currently checked-out branch to use as the pull request base, before + # push-remediations checks out the new remediation branch. + - var.set: + baseBranch: "${#ifBlank(cli.baseBranch, #ifBlank(#git.localRepo(cli.sourceDir)?.branch?.short, #ifBlank(#git.defaultBranch(cli.sourceDir), 'main')))}" + + # Apply remediations and push them to the new branch + - run.fcli: + PUSH_REMEDIATIONS: + cmd: ssc action run push-remediations "--source-dir=${cli.sourceDir}" "--artifact-id=${cli.artifactId}" "--branch-name=${cli.branchName}" "--progress=none" + status.check: true + + # Only create a pull request if push-remediations actually created and checked out the + # remediation branch (i.e. there were remediation changes to push). + - var.set: + currentBranch: ${#git.localRepo(cli.sourceDir)?.branch?.short} + - if: ${currentBranch!=cli.branchName} + do: + - log.info: "No remediation changes were pushed; skipping pull request creation." + - exit: 0 + + # Create the GitHub pull request + - var.set: + pr: ${#github.repo().createPullRequest(cli.title, cli.branchName, baseBranch, cli.body)} + on.fail: + - throw: + msg: "Failed to create GitHub pull request from '${cli.branchName}' to '${baseBranch}'" + cause: ${lastException} + - if: "${#isBlank(pr) || #isBlank(pr.html_url)}" + throw: "GitHub pull request creation did not return the expected response." + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.prUrl: ${pr.html_url} + - log.info: "Created GitHub pull request #${pr.number}: ${pr.html_url}" diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/push-remediations.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/push-remediations.yaml new file mode 100644 index 00000000000..21780226faf --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/push-remediations.yaml @@ -0,0 +1,103 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and push them to a new branch + description: | + This action applies Aviator-generated remediations to the local source code, then + commits and pushes only those changes to a new branch on the remote repository. + + Change detection is snapshot-based: any files that were already modified before the + remediations were applied (for example build output) are left untouched; only the + files changed by the remediation step are committed. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--artifact-id` -- SSC artifact id to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--commit-message` / `COMMIT_MESSAGE` + - `--author-name` / `GIT_AUTHOR_NAME` + - `--author-email` / `GIT_AUTHOR_EMAIL` + + Authentication for the push uses the `GIT_PUSH_TOKEN` environment variable if set, + falling back to the token of the active CI system, and finally to the local git + configuration (for example an http.extraHeader injected by the CI checkout step). + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + artifactId: + names: --artifact-id + description: "SSC artifact id to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + commitMessage: + names: --commit-message, -m + description: "Git commit message." + required: false + default: "${#env('COMMIT_MESSAGE')?:'fix: apply automated fixes [generated by fcli aviator]'}" + authorName: + names: --author-name + description: "Git author name for the commit." + required: false + default: "${#env('GIT_AUTHOR_NAME')?:'fcli-aviator[bot]'}" + authorEmail: + names: --author-email + description: "Git author email for the commit." + required: false + default: "${#env('GIT_AUTHOR_EMAIL')?:'fcli-aviator@opentext.com'}" + +steps: + # Validate that the source directory is a git working tree with a configured remote + - var.set: + gitRepoInfo: ${#git.localRepo(cli.sourceDir)} + - if: ${#isBlank(gitRepoInfo)} + throw: "Source directory '${cli.sourceDir}' is not a git repository." + - if: ${#isBlank(gitRepoInfo.repository.remoteUrl)} + throw: "Git repository has no remote URL configured; a remote is required to push remediations." + + # Snapshot the paths that are already dirty before remediations are applied, so that + # pre-existing changes (e.g. build output) are excluded from the remediation commit. + - var.set: + snapshot: ${#git.status(cli.sourceDir)} + + # Apply the Aviator remediations to the local source code + - run.fcli: + APPLY_REMEDIATIONS: + cmd: aviator ssc apply-remediations "--source-dir=${cli.sourceDir}" "--artifact-id=${cli.artifactId}" "--progress=none" + status.check: true + + # Commit only the changes introduced by the remediation step to a new branch + - var.set: + commitSha: ${#git.commitChangesSince(cli.sourceDir, snapshot, cli.branchName, cli.commitMessage, cli.authorName, cli.authorEmail)} + on.fail: + - throw: + msg: "Failed to commit Aviator remediations" + cause: ${lastException} + - if: ${#isBlank(commitSha)} + do: + - log.info: "No remediation changes to commit; nothing was pushed." + - exit: 0 + - log.info: "Committed Aviator remediations to branch '${cli.branchName}' (${commitSha})" + + # Push the new branch to the remote repository + - var.set: + pushedRef: ${#git.push(cli.sourceDir, cli.branchName)} + on.fail: + - throw: + msg: "Failed to push branch '${cli.branchName}'" + cause: ${lastException} + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.pushed: true + - log.info: "Pushed Aviator remediations to branch '${cli.branchName}' (${pushedRef})" diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy index 49bc1d2f760..1984b402cd0 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy @@ -37,6 +37,8 @@ class DetectEnvSpec extends FcliBaseSpec { it.any { it.contains("prActive: false") } it.any { it.contains("prNotActiveSkipReason: Not a Pull Request") } it.any { it.contains("fod_prCommentAction: github-pr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: github-remediations-pr") } + it.any { it.contains("ssc_aviatorRemediationsAction: github-remediations-pr") } } } @@ -66,6 +68,8 @@ class DetectEnvSpec extends FcliBaseSpec { it.any { it.contains("prActive: false") } it.any { it.contains("prNotActiveSkipReason: Not a Merge Request") } it.any { it.contains("fod_prCommentAction: gitlab-mr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: push-remediations") } + it.any { it.contains("ssc_aviatorRemediationsAction: push-remediations") } } } @@ -153,6 +157,8 @@ class DetectEnvSpec extends FcliBaseSpec { it.any { it.contains("prTarget: main") } it.any { it.contains("prTerminology: Pull Request") } it.any { it.contains("fod_prCommentAction: github-pr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: github-remediations-pr") } + it.any { it.contains("ssc_aviatorRemediationsAction: github-remediations-pr") } } } @@ -181,6 +187,8 @@ class DetectEnvSpec extends FcliBaseSpec { it.any { it.contains("prTarget: main") } it.any { it.contains("prTerminology: Merge Request") } it.any { it.contains("fod_prCommentAction: gitlab-mr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: push-remediations") } + it.any { it.contains("ssc_aviatorRemediationsAction: push-remediations") } } }