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:
blink-so[bot] 2026-03-06 07:45:33 -06:00 committed by GitHub
parent f1748c80f7
commit 40c2916fa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 464 additions and 0 deletions

10
.icons/jfrog-xray.svg Normal file
View 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

View 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
}
```

View 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);
});
});

View 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)
}