version v7.2.
For up-to-date documentation, see the
latest version.
SCA with JFrog Xray and Release Bundles for Maven Projects
12 minute read
This page walks through building a Maven project using jf mvn instead of plain mvn generating
SBOM
with your packages, publishing a BuildInfo
to Artifactory, scanning
for vulnerabillities
with Xray and Policy
enforcement, and finally packaging everything into a Release Bundle
.
The pipeline is built incrementally — each section adds one capability on top of the previous one.
If you want to jump straight to the final working pipeline, go to Complete pipeline at the bottom.
The intermediate steps are kept because understanding why each piece exists makes the whole easier to understand and operate.
Prerequisites
Before running the pipeline, the following must be in place:
- Artifactory repositories in a Project where you have Project Admin rights
- The
cyclonedx-maven-pluginconfigured in yourpom.xml - Xray indexing configured for both repositories and builds
- GitLab CI variables and the custom Docker image
This section covers each in order.
Xray indexing is the gate for everything that follows. Without it, the --scan
flag on jf mvn does nothing, the Build Scans tab in Xray stays empty, and
Watches never fire. Configure indexing before running the pipeline for the
first time.
Artifactory repositories
If you do not have a JFrog Project, contact your platform for the creation procedure.
You need two Maven repositories to be accessible:
| Repository | Type | Purpose |
|---|---|---|
project-repo-snapshots | Local | Snapshot artifact storage |
maven | Virtual | Dependency resolution (proxies central + local repos) |
Configure the CycloneDX plugin in pom.xml
[...]
<build>
<plugins>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<version>2.7.9</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>makeAggregateBom</goal>
</goals>
</execution>
</executions>
<configuration>
<projectType>library</projectType>
<schemaVersion>1.4</schemaVersion>
<includeBomSerialNumber>true</includeBomSerialNumber>
<includeCompileScope>true</includeCompileScope>
<includeProvidedScope>true</includeProvidedScope>
<includeRuntimeScope>true</includeRuntimeScope>
<includeSystemScope>true</includeSystemScope>
<includeTestScope>false</includeTestScope>
<outputFormat>all</outputFormat>
<outputName>bom</outputName>
</configuration>
</plugin>
Enable Xray indexing on repositories
Xray must be told which repositories to index. Without this step, artifacts deployed to Artifactory are invisible to Xray: no scan, no vulnerability report, no policy evaluation.
- From your Project home page, go to Administration > Xray > Indexed Resources > Repositories
- Click Add Repositories
- Select
project-repo-snapshotsandproject-repo-releases - Click Save
From this point, every artifact pushed to those repositories will be automatically indexed by Xray on arrival.
Indexing an existing repository for the first time does not retroactively scan artifacts already present. Only newly pushed artifacts are scanned automatically. To scan existing artifacts, use Xray → Scans List → Scan Now on the repository.
Build indexing is a separate step configured after the first pipeline run, because the build name must exist in Artifactory before it can be selected in the UI. See Enable Xray indexing on builds below.
If you want Xray to index all builds rather than selecting them individually, navigate to Administration → Xray → Settings → Enable Indexing All Builds and toggle it on. This is convenient for new platforms but adds scan load — prefer explicit selection in production environments with many build names.
See the JFrog documentation for the full walkthrough: How to index and scan all builds in Xray
GitLab CI variables
The following variables must be defined in the GitLab project or group settings, with sensitive values masked:
| Variable | Masked | Description |
|---|---|---|
CICD_ARTIFACTORY_SERVER | No | Base URL of your Artifactory instance |
CICD_ARTIFACTORY_USERNAME | No | Service account username |
CICD_ARTIFACTORY_APIKEY | Yes | Access token |
Custom Docker image
The jobs use an image that bundles both the JFrog CLI and Maven 21. It can be built from a Dockerfile similar to the following:
FROM <artifactory.your-instance.example.com>/<repository>/maven/maven-java21/
USER root
RUN apk --no-cache add curl
WORKDIR $APP_TMP
ARG JFROG_CLI_VERSION=2.96.0
RUN curl -fSL "https://releases.jfrog.io/artifactory/jfrog-cli/v2-jf/${JFROG_CLI_VERSION}/jfrog-cli-linux-amd64/jf" \
-o /usr/local/bin/jf && \
chmod +x /usr/local/bin/jf
USER bob
RUN jf --version
WORKDIR /
The build_container job in the preparation stage builds this image from
Dockerfile.build using the nextgen-cicd .docker step.
This image must be built and pushed before the build-publish stage runs.
The GitLab pipeline
Configure the pipeline with NextGen and variables
The pipeline uses shared templates from nextgen-cicd and follows a three-stage structure.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
include:
- project: nextgen-cicd/config
ref: 'master'
file: config.yml
- project: nextgen-cicd/release
ref: 'master'
file:
- 'step/maven.yml'
- 'step/sonarqube.yml'
- 'step/artifactory.yml'
- 'step/hadolint.yml'
- 'step/docker.yml'
variables:
CICD_ARTIFACTORY_SERVER: https://artifactory.your-instance.example.com
CICD_MAVEN_JAVA_VERSION: "21"
CICD_MAVEN_TARGETS: "target/*"
IMAGE_NAME: "my-example-java"
MAVEN_CLI_OPTS: "-s .m2/settings.xml --batch-mode"
stages:
- preparation
- quality-security
- build-publish
The jf CLI configuration
All JFrog jobs share the same server registration pattern via a reusable hidden job:
.jfrog-configure:
script:
- jf --version
- jf c add sf-server-id \
--insecure-tls=false \
--url=$CICD_ARTIFACTORY_SERVER \
--user=$CICD_ARTIFACTORY_USERNAME \
--access-token=$CICD_ARTIFACTORY_APIKEY
- jf c use sf-server-id
Wire Maven to Artifactory
jf mvn-config replaces the server and repository references that would otherwise
live in settings.xml. It tells the JFrog CLI which Artifactory server and
repositories to use for dependency resolution and artifact deployment.
The equivalent commands for other ecosystems follow the same pattern:
jf gradle-config, jf pip-config, jf docker, and so on. The--build-name and --build-number
flags are available on all of them, and BuildInfo is recorded the same way.
before_script:
- !reference [.jfrog-configure, script]
- jf config show
- jf mvn-config \
--server-id-resolve=sf-server-id \
--repo-resolve-releases=maven \
--repo-resolve-snapshots=maven \
--server-id-deploy=sf-server-id \
--repo-deploy-releases=project-repo-releases \
--repo-deploy-snapshots=project-repo-snapshots
| Flag | Purpose |
|---|---|
--server-id-resolve | Server to use when downloading dependencies |
--repo-resolve-releases | Virtual repo for release dependency resolution |
--repo-resolve-snapshots | Virtual repo for snapshot dependency resolution |
--server-id-deploy | Server to use when uploading artifacts |
--repo-deploy-releases | Target repo for release artifacts |
--repo-deploy-snapshots | Target repo for snapshot artifacts |
jf mvn-config must run before jf mvn. If you skip this step, jf mvn falls
back to the distributionManagement block in pom.xml and BuildInfo recording
still works, but dependency resolution is not routed through Artifactory.
Run the pipeline
Build and deploy with BuildInfo recording
At this stage the goal is simple: replace mvn clean install with jf mvn clean install
and understand what changes.
mvn-build-with-jf:
image: $CI_REGISTRY_IMAGE/jf-cli-with-maven:latest
stage: build-publish
before_script:
- !reference [.jfrog-configure, script]
- jf mvn-config \
--server-id-resolve=sf-server-id \
--repo-resolve-releases=maven \
--repo-resolve-snapshots=maven \
--server-id-deploy=sf-server-id \
--repo-deploy-releases=project-repo-releases \
--repo-deploy-snapshots=project-repo-snapshots
script:
- jf mvn clean install \
--build-name=$CI_PROJECT_NAME \
--build-number=$CI_PIPELINE_IID \
--project=project-key
artifacts:
reports:
coverage_report:
coverage_format: jacoco
path: target/site/jacoco/jacoco.xml
What jf mvn does differently from mvn
jf mvn is a thin wrapper around Maven that intercepts the build lifecycle to:
- Route all dependency downloads through the configured Artifactory virtual repo
- Record every resolved dependency (including the actual resolved version, not just the declared one) into a local BuildInfo accumulator
- Record every deployed artifact (JAR, POM, SBOM files) into the same accumulator
The --build-name and --build-number flags are the BuildInfo identity key.
They must be consistent across all jf commands in the same pipeline run.
The CycloneDX SBOMs are uploaded automatically
Because pom.xml configures the cyclonedx-maven-plugin to run at the package
phase, jf mvn clean install produces target/bom.json and target/bom.xml before
the deploy phase. The JFrog CLI then deploys them to Artifactory alongside the JAR —
no extra upload step is needed.
In the pipeline log you will see the Xray indexer pick them up immediately:
[Info] [Thread 1] Indexing file: target/bom.json
[Info] [Thread 2] Indexing file: target/bom.xml
[Info] [Thread 0] Indexing file: target/greenarrow-1.8.0-SNAPSHOT.jar
[Info] Waiting for scan to complete on JFrog Xray...
[Info] Scan completed successfully.
{
"status": "success",
"totals": { "success": 4, "failure": 0 }
}
success: 4 means four artifacts were indexed: the JAR, both SBOM files, and the
POM. The scan result here is informational only — no policy is enforced yet. The
pipeline succeeds regardless of findings at this stage.
Publish BuildInfo
After the build, the accumulated BuildInfo exists only locally. jf rt bp publishes
it to Artifactory so it becomes queryable by Xray and visible in the UI.
Add the following line to the script section after jf mvn:
script:
- [...]
- jf mvn clean install \
--build-name=$CI_PROJECT_NAME \
--build-number=$CI_PIPELINE_IID \
--project=project-key
- jf rt bp $CI_PROJECT_NAME $CI_PIPELINE_IID \
--project=project-key \
--collect-env \
--collect-git-info \
--build-url=$CI_JOB_URL \
--env-exclude="*PASS*;*PAT*;*secret*;*key*;*TOKEN*;*AUTH*"
| Flag | What it records |
|---|---|
--collect-env | All CI environment variables (filtered by --env-exclude) |
--collect-git-info | Git commit SHA, branch, remote URL |
--build-url | Direct link back to the GitLab job log |
--env-exclude | Glob patterns for variables to strip before publishing |
--env-exclude is a security hygiene step. Without it, secrets injected as CI
variables (tokens, passwords, API keys) would be stored in plain text inside the
BuildInfo JSON in Artifactory. The pattern *PASS*;*PAT*;*secret*;*key*;*TOKEN*;*AUTH*
covers the most common naming conventions — review and extend it to match your
organisation’s variable naming.
Viewing BuildInfo in Artifactory
After jf rt bp completes, navigate to:
Artifactory → Builds → my-project-java → <pipeline IID>
The Build Info page shows:
- Modules — the Maven modules built in this run
- Artifacts — the JAR, POM, and SBOM files deployed, with their checksums
- Dependencies — every resolved dependency with its actual resolved version
- Properties — the environment variables collected (filtered)
- Git — commit SHA, branch, remote
The Dependencies tab is where the declared vs resolved version gap becomes
visible. If pom.xml declares jackson-databind:2.9.10.8 but Maven resolved
2.15.2 via a BOM, the resolved version is what appears here — not the declared one.
Enable Xray indexing on builds
Now that a BuildInfo has been published, build indexing can be configured.
Repository indexing (configured earlier) covers artifacts pushed to Artifactory.
Build indexing is a separate configuration that tells Xray to index and scan
BuildInfo records published by jf rt bp. Without it, the Build Scans tab
remains empty even after a successful pipeline run.
From your Project home page:
- Go to Administration > Xray > Indexed Resources > Builds
- Click Add Builds
- Select the build name — this matches
$CI_PROJECT_NAMEin the pipeline - Click Save
After this, every BuildInfo published under that build name will be automatically indexed and scanned by Xray.
Observe findings in Xray (no enforcement yet)
With BuildInfo published and build indexing enabled, Xray produces a build-level
security report. No pipeline change is needed at this stage — the scan already ran
during jf mvn thanks to the indexer. The goal here is to understand the exposure
before enforcing anything.
From your Project home page, navigate to Xray > Scans List > Builds > my-project-java
This is the Build Scans tab, which is different from the Binary Scans tab used
by jf scan. The build report shows vulnerabilities scoped to this specific build —
its dependencies, its artifacts, and the SBOMs produced during the build.
What to look for
- The severity breakdown — how many Critical, High, Medium and Low findings
- Which components carry the findings
- Whether the findings align with what
jf auditreported earlier — differences indicate version resolution gaps between declared and bundled dependencies
Spend time on this view before configuring any enforcement. Understanding the baseline — what is there, how severe, which components — makes it easier to set meaningful thresholds and handle exceptions deliberately rather than reactively.
Configure a Policy and Watch in Xray
With the baseline understood, the next step is to define what constitutes a violation and scope it to this build.
There is a generic policies/watches howto to explore more.
Create a Security Policy
From your Project home page, navigate to Xray > Policies > New Policy
| Field | Suggested value |
|---|---|
| Name | project-key-security-policy |
| Policy Type | Security |
| Rule name | block-critical |
| Rule Type | CVE |
| Rule Category | Minimal Severity |
| Minimum severity | Critical |
| Action (then) | Fail build / Block download |
Add a second rule if you want to warn on High without failing:
| Field | Suggested value |
|---|---|
| Rule name | warn-high |
| Minimum severity | High |
| Action | Generate violation (notify only) |
Save the policy.
Create a Watch scoped to the build
From your Project home page, navigate to Xray > Watches > New Watch
| Field | Suggested value |
|---|---|
| Name | my-project-build-watch |
| Resources type | Builds |
| Build name | my-project-java |
| Assigned policies | project-key-security-policy |
Save and activate the Watch.
Scoping the Watch to a specific build name rather than a whole repository means only artifacts produced by this build trigger the policy.
You can also select all builds, to include future builds.
Enforce the policy in CI
With the Watch active, add the --scan and --fail flags to jf mvn:
script:
- [...]
- jf mvn clean install \
--build-name=$CI_PROJECT_NAME \
--build-number=$CI_PIPELINE_IID \
--project=project-key \
--scan \
--fail
- jf rt bp $CI_PROJECT_NAME $CI_PIPELINE_IID \
--project=project-key \
--collect-env \
--collect-git-info \
--build-url=$CI_JOB_URL \
--env-exclude="*PASS*;*PAT*;*secret*;*key*;*TOKEN*;*AUTH*"
Incremental adoption with allow_failure
Introducing --fail on an existing project that already has known vulnerabilities
will immediately break every pipeline run.
mvn-build-with-jf:
script:
- jf mvn clean install \
--build-name=$CI_PROJECT_NAME \
--build-number=$CI_PIPELINE_IID \
--project=project-key \
--scan \
--fail
- jf rt bp ...
Once the team has addressed the Critical findings — or created deliberate Xray ignore
rules for accepted risks — set allow_failure: false to enforce the gate fully.
Use Xray ignore rules (scoped to a CVE + component + time-limited expiry) for vulnerabilities that have no available fix or have been assessed as not exploitable in your deployment context. This keeps the policy meaningful without blocking work indefinitely on unfixable CVEs.
Create a Release Bundle from BuildInfo
A Release Bundle is created manually after the build has passed the required verification and qualification phases — functional testing, integration testing, and promotion from snapshot to release. This deliberate human gate ensures that only validated builds become release candidates.
- Navigate to the Builds section in your Project under Artifactory
- Select Create Release Bundle at the top right corner of the screen
- In the New Release Bundle screen, fill in:
- Release Bundle Name — enter a name for your release bundle
- Release Bundle Version — specify the version using Semver convention
(e.g.,
1.0.0) - Signing Key — select an available signing key from the dropdown list
- Select a Build Name and Build Version from the list of available builds:
- Check Include Build Dependencies to include all dependencies associated with the build
- Select Add and repeat to include additional builds if needed
- Select Create to finalise the creation of the release bundle
Complete pipeline
The following is the full .gitlab-ci.yml incorporating all steps. Copy and adapt
it to your project — replace project-key, project-repo-releases,
project-repo-snapshots, and my-project-java with your actual values.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
include:
- project: nextgen-cicd/config
ref: 'master'
file: config.yml
- project: nextgen-cicd/release
ref: 'master'
file:
- 'step/maven.yml'
- 'step/sonarqube.yml'
- 'step/artifactory.yml'
- 'step/hadolint.yml'
- 'step/docker.yml'
variables:
CICD_SONAR_AUTH_TOKEN: $SONAR_TOKEN
CICD_SONAR_PROJECT_KEY: $CICD_MAVEN_SONAR_PROJECTKEY
CICD_ARTIFACTORY_SERVER: https://artifactory.your-instance.example.com
MAVEN_CLI_OPTS: "-s .m2/settings.xml --batch-mode"
CICD_MAVEN_JAVA_VERSION: "21"
CICD_MAVEN_TARGETS: "target/*"
IMAGE_NAME: "my-project-java"
stages:
- preparation
- quality-security
- build-publish
# ── Shared anchor ─────────────────────────────────────────────────────────────
.jfrog-configure:
script:
- jf --version
- jf c add sf-server-id \
--insecure-tls=false \
--url=$CICD_ARTIFACTORY_SERVER \
--user=$CICD_ARTIFACTORY_USERNAME \
--access-token=$CICD_ARTIFACTORY_APIKEY
- jf c use sf-server-id
# ── Preparation ───────────────────────────────────────────────────────────────
build_container:
variables:
CICD_DOCKER_TAG_NAME: $CI_REGISTRY_IMAGE/jf-cli-with-maven:latest
CICD_DOCKERFILE: Dockerfile.build
stage: preparation
extends: .docker
# ── Quality & Security ────────────────────────────────────────────────────────
code-quality-scan:
stage: quality-security
extends: .maven-sonar-scan
variables:
CICD_MAVEN_SONAR_AUTHTOKEN: $SONAR_TOKEN
CICD_MAVEN_SONAR_SOURCES: "src/main"
CICD_MAVEN_SONAR_PROJECTKEY: "sonar-project-key"
CICD_MAVEN_SONAR_URL: "https://sonarqube.your-instance.example.com/"
CICD_MAVEN_SONAR_GATECHECK: "true"
CICD_MAVEN_EXTRA_ARGS: 'verify'
artifacts:
paths:
- target/sonar/report-task.txt
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── Build & Publish ───────────────────────────────────────────────────────────
mvn-build-with-jf:
image: $CI_REGISTRY_IMAGE/jf-cli-with-maven:latest
stage: build-publish
before_script:
- !reference [.jfrog-configure, script]
- jf config show
- jf mvn-config \
--server-id-resolve=sf-server-id \
--repo-resolve-releases=maven \
--repo-resolve-snapshots=maven \
--server-id-deploy=sf-server-id \
--repo-deploy-releases=project-repo-releases \
--repo-deploy-snapshots=project-repo-snapshots
script:
# Build, deploy artifacts + SBOMs, record BuildInfo, scan on deploy
- jf mvn clean install \
--build-name=$CI_PROJECT_NAME \
--build-number=$CI_PIPELINE_IID \
--project=project-key \
--scan \
--fail
# Publish BuildInfo with environment and Git context
- jf rt bp $CI_PROJECT_NAME $CI_PIPELINE_IID \
--project=project-key \
--collect-env \
--collect-git-info \
--build-url=$CI_JOB_URL \
--env-exclude="*PASS*;*PAT*;*secret*;*key*;*TOKEN*;*AUTH*"
artifacts:
reports:
coverage_report:
coverage_format: jacoco
path: target/site/jacoco/jacoco.xml
allow_failure: true # set to false once Critical findings are resolved