feat: add JFrog Xray vulnerability scanning module (#410)
This PR adds a new Terraform module that fetches JFrog Xray
vulnerability scanning results for container images stored in
Artifactory.
## Features
- Fetches vulnerability scan results from JFrog Xray
- Outputs vulnerability counts (Critical, High, Medium, Low, Total)
- Supports flexible image path formats
- Works with any workspace type using container images
- Provides secure token handling
## Design Decisions
During testing, we found two issues with the original approach of
defining the `xray` provider and `coder_metadata` inside the module:
1. **`coder_metadata` defined inside modules does not display in the
Coder dashboard** — this is a known limitation
2. **Inline provider blocks prevent using `count`/`for_each` on the
module** — which is needed when attaching metadata to resources like
`docker_container` that use `start_count`
The module now **outputs** vulnerability counts instead, and the caller
creates the `coder_metadata` and configures the `xray` provider in their
root template. This matches the pattern used by other registry modules.
## Usage
```hcl
provider "xray" {
url = "${var.jfrog_url}/xray"
access_token = var.artifactory_access_token
skip_xray_version_check = true
}
module "jfrog_xray" {
source = "registry.coder.com/coder/jfrog-xray/coder"
version = "1.0.0"
xray_url = "${var.jfrog_url}/xray"
xray_token = var.artifactory_access_token
image = "docker-local/codercom/enterprise-base:latest"
}
resource "coder_metadata" "xray_vulnerabilities" {
count = data.coder_workspace.me.start_count
resource_id = docker_container.workspace[0].id
icon = "/icon/shield.svg"
item {
key = "Total Vulnerabilities"
value = module.jfrog_xray.total
}
item {
key = "Critical"
value = module.jfrog_xray.critical
}
item {
key = "High"
value = module.jfrog_xray.high
}
item {
key = "Medium"
value = module.jfrog_xray.medium
}
item {
key = "Low"
value = module.jfrog_xray.low
}
}
```
## Related Issues
- Resolves coder/coder#12838
- Addresses coder/registry#65
Tested with a JFrog Cloud trial instance using Docker remote repository
and Xray scanning.
---------
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: matifali <10648092+matifali@users.noreply.github.com>
Co-authored-by: DevelopmentCats <christofer@coder.com>
This commit is contained in:
parent
f1748c80f7
commit
40c2916fa9
10
.icons/jfrog-xray.svg
Normal file
10
.icons/jfrog-xray.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
||||
<g fill="#40BE46">
|
||||
<!-- Eye shape -->
|
||||
<path d="M100 40C55 40 20 80 10 100c10 20 45 60 90 60s80-40 90-60c-10-20-45-60-90-60zm0 100c-35 0-63-28-75-40 12-12 40-40 75-40s63 28 75 40c-12 12-40 40-75 40z"/>
|
||||
<!-- Inner circle (magnifying glass lens) -->
|
||||
<path d="M100 72a28 28 0 1 0 0 56 28 28 0 0 0 0-56zm0 44a16 16 0 1 1 0-32 16 16 0 0 1 0 32z"/>
|
||||
<!-- Horizontal line below -->
|
||||
<rect x="25" y="170" width="150" height="12" rx="6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
75
registry/coder/modules/jfrog-xray/README.md
Normal file
75
registry/coder/modules/jfrog-xray/README.md
Normal file
@ -0,0 +1,75 @@
|
||||
---
|
||||
display_name: JFrog Xray
|
||||
description: Fetch container image vulnerability scan results from JFrog Xray
|
||||
icon: ../../../../.icons/jfrog-xray.svg
|
||||
verified: true
|
||||
tags: [jfrog, xray]
|
||||
---
|
||||
|
||||
# JFrog Xray
|
||||
|
||||
This module fetches vulnerability scan results from JFrog Xray for container images stored in Artifactory. Use the outputs to display security information as workspace metadata.
|
||||
|
||||
```tf
|
||||
module "jfrog_xray" {
|
||||
source = "registry.coder.com/coder/jfrog-xray/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
xray_url = "https://example.jfrog.io/xray"
|
||||
xray_token = var.artifactory_access_token
|
||||
image = "docker-local/myapp/backend:v1.0.0"
|
||||
}
|
||||
|
||||
resource "coder_metadata" "xray_scan" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
resource_id = docker_container.workspace[0].id
|
||||
icon = "/icon/shield.svg"
|
||||
|
||||
item {
|
||||
key = "Image"
|
||||
value = "docker-local/myapp/backend:v1.0.0"
|
||||
}
|
||||
item {
|
||||
key = "Total Vulnerabilities"
|
||||
value = module.jfrog_xray.total
|
||||
}
|
||||
item {
|
||||
key = "Critical"
|
||||
value = module.jfrog_xray.critical
|
||||
}
|
||||
item {
|
||||
key = "High"
|
||||
value = module.jfrog_xray.high
|
||||
}
|
||||
item {
|
||||
key = "Medium"
|
||||
value = module.jfrog_xray.medium
|
||||
}
|
||||
item {
|
||||
key = "Low"
|
||||
value = module.jfrog_xray.low
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Container images must be stored in JFrog Artifactory
|
||||
2. JFrog Xray must be configured to scan your repositories
|
||||
3. A valid JFrog access token with Xray read permissions
|
||||
|
||||
## Remote Repositories
|
||||
|
||||
When scanning images from remote (proxy) repositories, set `use_cache_repo = true`. This is because Artifactory stores cached images in a companion `-cache` repository where Xray indexes the scan results.
|
||||
|
||||
```tf
|
||||
module "jfrog_xray" {
|
||||
source = "registry.coder.com/coder/jfrog-xray/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
xray_url = "https://example.jfrog.io/xray"
|
||||
xray_token = var.artifactory_access_token
|
||||
image = "docker-remote/library/nginx:latest"
|
||||
use_cache_repo = true
|
||||
}
|
||||
```
|
||||
244
registry/coder/modules/jfrog-xray/main.test.ts
Normal file
244
registry/coder/modules/jfrog-xray/main.test.ts
Normal file
@ -0,0 +1,244 @@
|
||||
import { serve } from "bun";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createJSONResponse, runTerraformInit, runTerraformApply } from "~test";
|
||||
|
||||
describe("jfrog-xray", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
// Mock server simulating a local repo with direct scan results
|
||||
const mockLocalRepo = serve({
|
||||
fetch: (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/xray/api/v1/system/version")
|
||||
return createJSONResponse({
|
||||
xray_version: "3.80.0",
|
||||
xray_revision: "abc123",
|
||||
});
|
||||
if (url.pathname === "/xray/api/v1/artifacts")
|
||||
return createJSONResponse({
|
||||
data: [
|
||||
{
|
||||
name: "myapp/backend/v1.0.0",
|
||||
repo_path: "/myapp/backend/v1.0.0/manifest.json",
|
||||
size: "50.00 MB",
|
||||
sec_issues: {
|
||||
critical: 1,
|
||||
high: 3,
|
||||
medium: 5,
|
||||
low: 10,
|
||||
total: 19,
|
||||
},
|
||||
scans_status: {
|
||||
overall: {
|
||||
status: "DONE",
|
||||
time: "2026-03-04T22:00:02Z",
|
||||
},
|
||||
},
|
||||
violations: 0,
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
});
|
||||
return createJSONResponse({});
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
|
||||
// Mock server simulating a remote repo with cache behavior
|
||||
// Returns both tag manifest (0 vulns, 0 size) and SHA manifest (real vulns, real size)
|
||||
const mockRemoteRepo = serve({
|
||||
fetch: (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/xray/api/v1/system/version")
|
||||
return createJSONResponse({
|
||||
xray_version: "3.80.0",
|
||||
xray_revision: "abc123",
|
||||
});
|
||||
if (url.pathname === "/xray/api/v1/artifacts")
|
||||
return createJSONResponse({
|
||||
data: [
|
||||
{
|
||||
name: "codercom/enterprise-base/ubuntu",
|
||||
repo_path: "/codercom/enterprise-base/ubuntu/list.manifest.json",
|
||||
size: "0.00 B",
|
||||
sec_issues: { total: 0 },
|
||||
scans_status: {
|
||||
overall: { status: "DONE" },
|
||||
},
|
||||
violations: 0,
|
||||
},
|
||||
{
|
||||
name: "codercom/enterprise-base/sha256__abc123def456",
|
||||
repo_path:
|
||||
"/codercom/enterprise-base/sha256__abc123def456/manifest.json",
|
||||
size: "359.33 MB",
|
||||
sec_issues: {
|
||||
critical: 2,
|
||||
high: 6,
|
||||
medium: 20,
|
||||
low: 23,
|
||||
total: 51,
|
||||
},
|
||||
scans_status: {
|
||||
overall: { status: "DONE" },
|
||||
},
|
||||
violations: 2,
|
||||
},
|
||||
],
|
||||
offset: 0,
|
||||
});
|
||||
return createJSONResponse({});
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
|
||||
// Mock server returning empty results (image not scanned)
|
||||
const mockEmptyResults = serve({
|
||||
fetch: (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/xray/api/v1/system/version")
|
||||
return createJSONResponse({
|
||||
xray_version: "3.80.0",
|
||||
xray_revision: "abc123",
|
||||
});
|
||||
if (url.pathname === "/xray/api/v1/artifacts")
|
||||
return createJSONResponse({ data: [], offset: -1 });
|
||||
return createJSONResponse({});
|
||||
},
|
||||
port: 0,
|
||||
});
|
||||
|
||||
const localRepoUrl = `http://${mockLocalRepo.hostname}:${mockLocalRepo.port}`;
|
||||
const remoteRepoUrl = `http://${mockRemoteRepo.hostname}:${mockRemoteRepo.port}`;
|
||||
const emptyResultsUrl = `http://${mockEmptyResults.hostname}:${mockEmptyResults.port}`;
|
||||
|
||||
const getProviderEnv = (url: string) => ({
|
||||
XRAY_URL: url,
|
||||
XRAY_ACCESS_TOKEN: "test-token",
|
||||
});
|
||||
|
||||
it("validates required variable: xray_url", async () => {
|
||||
try {
|
||||
await runTerraformApply(
|
||||
import.meta.dir,
|
||||
{
|
||||
xray_token: "test-token",
|
||||
image: "docker-local/test/image:latest",
|
||||
},
|
||||
getProviderEnv(localRepoUrl),
|
||||
);
|
||||
throw new Error("Expected apply to fail without xray_url");
|
||||
} catch (ex) {
|
||||
if (!(ex instanceof Error)) throw new Error("Unknown error");
|
||||
expect(ex.message).toContain('input variable "xray_url" is not set');
|
||||
}
|
||||
});
|
||||
|
||||
it("validates required variable: xray_token", async () => {
|
||||
try {
|
||||
await runTerraformApply(
|
||||
import.meta.dir,
|
||||
{
|
||||
xray_url: localRepoUrl,
|
||||
image: "docker-local/test/image:latest",
|
||||
},
|
||||
getProviderEnv(localRepoUrl),
|
||||
);
|
||||
throw new Error("Expected apply to fail without xray_token");
|
||||
} catch (ex) {
|
||||
if (!(ex instanceof Error)) throw new Error("Unknown error");
|
||||
expect(ex.message).toContain('input variable "xray_token" is not set');
|
||||
}
|
||||
});
|
||||
|
||||
it("validates required variable: image", async () => {
|
||||
try {
|
||||
await runTerraformApply(
|
||||
import.meta.dir,
|
||||
{
|
||||
xray_url: localRepoUrl,
|
||||
xray_token: "test-token",
|
||||
},
|
||||
getProviderEnv(localRepoUrl),
|
||||
);
|
||||
throw new Error("Expected apply to fail without image");
|
||||
} catch (ex) {
|
||||
if (!(ex instanceof Error)) throw new Error("Unknown error");
|
||||
expect(ex.message).toContain('input variable "image" is not set');
|
||||
}
|
||||
});
|
||||
|
||||
it("returns vulnerability counts for local repository", async () => {
|
||||
const state = await runTerraformApply(
|
||||
import.meta.dir,
|
||||
{
|
||||
xray_url: localRepoUrl,
|
||||
xray_token: "test-token",
|
||||
image: "docker-local/myapp/backend:v1.0.0",
|
||||
},
|
||||
getProviderEnv(localRepoUrl),
|
||||
);
|
||||
|
||||
expect(state.outputs.critical.value).toBe(1);
|
||||
expect(state.outputs.high.value).toBe(3);
|
||||
expect(state.outputs.medium.value).toBe(5);
|
||||
expect(state.outputs.low.value).toBe(10);
|
||||
expect(state.outputs.total.value).toBe(19);
|
||||
});
|
||||
|
||||
it("returns zero counts when image has no scan results", async () => {
|
||||
const state = await runTerraformApply(
|
||||
import.meta.dir,
|
||||
{
|
||||
xray_url: emptyResultsUrl,
|
||||
xray_token: "test-token",
|
||||
image: "docker-local/unscanned/image:latest",
|
||||
},
|
||||
getProviderEnv(emptyResultsUrl),
|
||||
);
|
||||
|
||||
expect(state.outputs.critical.value).toBe(0);
|
||||
expect(state.outputs.high.value).toBe(0);
|
||||
expect(state.outputs.medium.value).toBe(0);
|
||||
expect(state.outputs.low.value).toBe(0);
|
||||
expect(state.outputs.total.value).toBe(0);
|
||||
});
|
||||
|
||||
it("uses cache repo when use_cache_repo is enabled", async () => {
|
||||
const state = await runTerraformApply(
|
||||
import.meta.dir,
|
||||
{
|
||||
xray_url: remoteRepoUrl,
|
||||
xray_token: "test-token",
|
||||
image: "docker-remote/codercom/enterprise-base:ubuntu",
|
||||
use_cache_repo: true,
|
||||
},
|
||||
getProviderEnv(remoteRepoUrl),
|
||||
);
|
||||
|
||||
// Should find the SHA artifact with actual vulnerabilities
|
||||
expect(state.outputs.critical.value).toBe(2);
|
||||
expect(state.outputs.high.value).toBe(6);
|
||||
expect(state.outputs.medium.value).toBe(20);
|
||||
expect(state.outputs.low.value).toBe(23);
|
||||
expect(state.outputs.total.value).toBe(51);
|
||||
expect(state.outputs.violations.value).toBe(2);
|
||||
expect(state.outputs.artifact_name.value).toContain("sha256__");
|
||||
});
|
||||
|
||||
it("allows custom repo and repo_path override", async () => {
|
||||
const state = await runTerraformApply(
|
||||
import.meta.dir,
|
||||
{
|
||||
xray_url: localRepoUrl,
|
||||
xray_token: "test-token",
|
||||
image: "ignored/path:tag",
|
||||
repo: "docker-local",
|
||||
repo_path: "/myapp/backend/v1.0.0",
|
||||
},
|
||||
getProviderEnv(localRepoUrl),
|
||||
);
|
||||
|
||||
expect(state.outputs.total.value).toBe(19);
|
||||
});
|
||||
});
|
||||
135
registry/coder/modules/jfrog-xray/main.tf
Normal file
135
registry/coder/modules/jfrog-xray/main.tf
Normal file
@ -0,0 +1,135 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
xray = {
|
||||
source = "jfrog/xray"
|
||||
version = ">= 2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "xray" {
|
||||
url = var.xray_url
|
||||
access_token = var.xray_token
|
||||
}
|
||||
|
||||
variable "xray_url" {
|
||||
description = "The URL of your JFrog Xray instance (e.g., https://mycompany.jfrog.io/xray). This should point to the Xray API endpoint, not Artifactory."
|
||||
type = string
|
||||
validation {
|
||||
condition = can(regex("^https?://", var.xray_url))
|
||||
error_message = "The xray_url must be a valid URL starting with http:// or https://."
|
||||
}
|
||||
}
|
||||
|
||||
variable "xray_token" {
|
||||
description = "The access token for authenticating with JFrog Xray. This token needs read permissions on Xray scan results. You can generate one in JFrog Platform under User Management > Access Tokens."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "image" {
|
||||
description = "The Docker image to check for vulnerabilities, in the format 'repo/path/image:tag'. For example: 'docker-local/myapp/backend:v1.0.0' or 'docker-remote/library/nginx:latest'. The repository name is extracted from the first path segment."
|
||||
type = string
|
||||
validation {
|
||||
condition = length(split("/", var.image)) >= 2
|
||||
error_message = "The image must include at least a repository and image name (e.g., 'docker-local/myimage:tag')."
|
||||
}
|
||||
}
|
||||
|
||||
variable "repo" {
|
||||
description = "Override the repository name extracted from the image path. Use this when your Artifactory repository name differs from the first segment of your image path."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "repo_path" {
|
||||
description = "Override the full Xray repository path. Use this for custom path structures that don't follow the standard 'repo/image:tag' format. When set, this takes precedence over automatic path construction."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "use_cache_repo" {
|
||||
description = "Set to true when scanning images from remote (proxy) repositories. Remote repositories in Artifactory store cached artifacts in a companion '-cache' repository (e.g., 'docker-remote-cache'), which is where Xray indexes the scan results."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
locals {
|
||||
# Parse the image string into components
|
||||
# Example: "docker-local/myapp/backend:v1.0.0"
|
||||
# -> repo: "docker-local", image_name: "myapp/backend", tag: "v1.0.0"
|
||||
image_parts = split("/", var.image)
|
||||
base_repo = var.repo != "" ? var.repo : local.image_parts[0]
|
||||
parsed_repo = var.use_cache_repo ? "${local.base_repo}-cache" : local.base_repo
|
||||
image_path = join("/", slice(local.image_parts, 1, length(local.image_parts)))
|
||||
image_name = split(":", local.image_path)[0]
|
||||
image_tag = length(split(":", local.image_path)) > 1 ? split(":", local.image_path)[1] : "latest"
|
||||
|
||||
# Construct the Xray query path based on repository type:
|
||||
# - Local repositories: Query the exact tag path (e.g., /myapp/backend/v1.0.0)
|
||||
# - Remote repositories: Query by image name only (e.g., /myapp/backend) because
|
||||
# the Terraform provider only returns the SHA manifest (with actual scan data)
|
||||
# when querying the broader path
|
||||
parsed_path = var.repo_path != "" ? var.repo_path : (
|
||||
var.use_cache_repo ? "/${local.image_name}" : "/${local.image_name}/${local.image_tag}"
|
||||
)
|
||||
|
||||
results = coalesce(try(data.xray_artifacts_scan.image_scan.results, []), [])
|
||||
|
||||
# For remote repositories, filter to find the actual scanned image (not tag pointers):
|
||||
# - Tag manifests have size "0.00 B" (they're just pointers to SHA manifests)
|
||||
# - SHA manifests have actual size (e.g., "359.33 MB") and contain the real scan data
|
||||
# For local repositories, there's typically only one result which is the actual image
|
||||
scanned_images = var.use_cache_repo ? [
|
||||
for r in local.results : r if r.size != "0.00 B"
|
||||
] : local.results
|
||||
|
||||
# The artifact we'll report scan results for
|
||||
scan_result = (
|
||||
length(local.scanned_images) > 0 ? local.scanned_images[0] :
|
||||
length(local.results) > 0 ? local.results[0] :
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
data "xray_artifacts_scan" "image_scan" {
|
||||
repo = local.parsed_repo
|
||||
repo_path = local.parsed_path
|
||||
}
|
||||
|
||||
output "critical" {
|
||||
description = "The number of critical severity vulnerabilities found in the image. Critical vulnerabilities typically require immediate attention."
|
||||
value = try(local.scan_result.sec_issues.critical, 0)
|
||||
}
|
||||
|
||||
output "high" {
|
||||
description = "The number of high severity vulnerabilities found in the image."
|
||||
value = try(local.scan_result.sec_issues.high, 0)
|
||||
}
|
||||
|
||||
output "medium" {
|
||||
description = "The number of medium severity vulnerabilities found in the image."
|
||||
value = try(local.scan_result.sec_issues.medium, 0)
|
||||
}
|
||||
|
||||
output "low" {
|
||||
description = "The number of low severity vulnerabilities found in the image."
|
||||
value = try(local.scan_result.sec_issues.low, 0)
|
||||
}
|
||||
|
||||
output "total" {
|
||||
description = "The total number of vulnerabilities found across all severity levels."
|
||||
value = try(local.scan_result.sec_issues.total, 0)
|
||||
}
|
||||
|
||||
output "artifact_name" {
|
||||
description = "The name of the artifact that was scanned, as reported by Xray. For remote repositories, this will be the SHA-based manifest name (e.g., 'myimage/sha256__abc123...')."
|
||||
value = try(local.scan_result.name, "")
|
||||
}
|
||||
|
||||
output "violations" {
|
||||
description = "The number of Xray policy violations detected. Violations are triggered when vulnerabilities match rules defined in your Xray security policies."
|
||||
value = try(local.scan_result.violations, 0)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user