Skip to content

Commit 40c2916

Browse files
blink-so[bot]matifaliDevelopmentCats
authored
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 #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>
1 parent f1748c8 commit 40c2916

4 files changed

Lines changed: 464 additions & 0 deletions

File tree

.icons/jfrog-xray.svg

Lines changed: 10 additions & 0 deletions
Loading
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
display_name: JFrog Xray
3+
description: Fetch container image vulnerability scan results from JFrog Xray
4+
icon: ../../../../.icons/jfrog-xray.svg
5+
verified: true
6+
tags: [jfrog, xray]
7+
---
8+
9+
# JFrog Xray
10+
11+
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.
12+
13+
```tf
14+
module "jfrog_xray" {
15+
source = "registry.coder.com/coder/jfrog-xray/coder"
16+
version = "1.0.0"
17+
18+
xray_url = "https://example.jfrog.io/xray"
19+
xray_token = var.artifactory_access_token
20+
image = "docker-local/myapp/backend:v1.0.0"
21+
}
22+
23+
resource "coder_metadata" "xray_scan" {
24+
count = data.coder_workspace.me.start_count
25+
resource_id = docker_container.workspace[0].id
26+
icon = "/icon/shield.svg"
27+
28+
item {
29+
key = "Image"
30+
value = "docker-local/myapp/backend:v1.0.0"
31+
}
32+
item {
33+
key = "Total Vulnerabilities"
34+
value = module.jfrog_xray.total
35+
}
36+
item {
37+
key = "Critical"
38+
value = module.jfrog_xray.critical
39+
}
40+
item {
41+
key = "High"
42+
value = module.jfrog_xray.high
43+
}
44+
item {
45+
key = "Medium"
46+
value = module.jfrog_xray.medium
47+
}
48+
item {
49+
key = "Low"
50+
value = module.jfrog_xray.low
51+
}
52+
}
53+
```
54+
55+
## Prerequisites
56+
57+
1. Container images must be stored in JFrog Artifactory
58+
2. JFrog Xray must be configured to scan your repositories
59+
3. A valid JFrog access token with Xray read permissions
60+
61+
## Remote Repositories
62+
63+
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.
64+
65+
```tf
66+
module "jfrog_xray" {
67+
source = "registry.coder.com/coder/jfrog-xray/coder"
68+
version = "1.0.0"
69+
70+
xray_url = "https://example.jfrog.io/xray"
71+
xray_token = var.artifactory_access_token
72+
image = "docker-remote/library/nginx:latest"
73+
use_cache_repo = true
74+
}
75+
```
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { serve } from "bun";
2+
import { describe, expect, it } from "bun:test";
3+
import { createJSONResponse, runTerraformInit, runTerraformApply } from "~test";
4+
5+
describe("jfrog-xray", async () => {
6+
await runTerraformInit(import.meta.dir);
7+
8+
// Mock server simulating a local repo with direct scan results
9+
const mockLocalRepo = serve({
10+
fetch: (req) => {
11+
const url = new URL(req.url);
12+
if (url.pathname === "/xray/api/v1/system/version")
13+
return createJSONResponse({
14+
xray_version: "3.80.0",
15+
xray_revision: "abc123",
16+
});
17+
if (url.pathname === "/xray/api/v1/artifacts")
18+
return createJSONResponse({
19+
data: [
20+
{
21+
name: "myapp/backend/v1.0.0",
22+
repo_path: "/myapp/backend/v1.0.0/manifest.json",
23+
size: "50.00 MB",
24+
sec_issues: {
25+
critical: 1,
26+
high: 3,
27+
medium: 5,
28+
low: 10,
29+
total: 19,
30+
},
31+
scans_status: {
32+
overall: {
33+
status: "DONE",
34+
time: "2026-03-04T22:00:02Z",
35+
},
36+
},
37+
violations: 0,
38+
},
39+
],
40+
offset: 0,
41+
});
42+
return createJSONResponse({});
43+
},
44+
port: 0,
45+
});
46+
47+
// Mock server simulating a remote repo with cache behavior
48+
// Returns both tag manifest (0 vulns, 0 size) and SHA manifest (real vulns, real size)
49+
const mockRemoteRepo = serve({
50+
fetch: (req) => {
51+
const url = new URL(req.url);
52+
if (url.pathname === "/xray/api/v1/system/version")
53+
return createJSONResponse({
54+
xray_version: "3.80.0",
55+
xray_revision: "abc123",
56+
});
57+
if (url.pathname === "/xray/api/v1/artifacts")
58+
return createJSONResponse({
59+
data: [
60+
{
61+
name: "codercom/enterprise-base/ubuntu",
62+
repo_path: "/codercom/enterprise-base/ubuntu/list.manifest.json",
63+
size: "0.00 B",
64+
sec_issues: { total: 0 },
65+
scans_status: {
66+
overall: { status: "DONE" },
67+
},
68+
violations: 0,
69+
},
70+
{
71+
name: "codercom/enterprise-base/sha256__abc123def456",
72+
repo_path:
73+
"/codercom/enterprise-base/sha256__abc123def456/manifest.json",
74+
size: "359.33 MB",
75+
sec_issues: {
76+
critical: 2,
77+
high: 6,
78+
medium: 20,
79+
low: 23,
80+
total: 51,
81+
},
82+
scans_status: {
83+
overall: { status: "DONE" },
84+
},
85+
violations: 2,
86+
},
87+
],
88+
offset: 0,
89+
});
90+
return createJSONResponse({});
91+
},
92+
port: 0,
93+
});
94+
95+
// Mock server returning empty results (image not scanned)
96+
const mockEmptyResults = serve({
97+
fetch: (req) => {
98+
const url = new URL(req.url);
99+
if (url.pathname === "/xray/api/v1/system/version")
100+
return createJSONResponse({
101+
xray_version: "3.80.0",
102+
xray_revision: "abc123",
103+
});
104+
if (url.pathname === "/xray/api/v1/artifacts")
105+
return createJSONResponse({ data: [], offset: -1 });
106+
return createJSONResponse({});
107+
},
108+
port: 0,
109+
});
110+
111+
const localRepoUrl = `http://${mockLocalRepo.hostname}:${mockLocalRepo.port}`;
112+
const remoteRepoUrl = `http://${mockRemoteRepo.hostname}:${mockRemoteRepo.port}`;
113+
const emptyResultsUrl = `http://${mockEmptyResults.hostname}:${mockEmptyResults.port}`;
114+
115+
const getProviderEnv = (url: string) => ({
116+
XRAY_URL: url,
117+
XRAY_ACCESS_TOKEN: "test-token",
118+
});
119+
120+
it("validates required variable: xray_url", async () => {
121+
try {
122+
await runTerraformApply(
123+
import.meta.dir,
124+
{
125+
xray_token: "test-token",
126+
image: "docker-local/test/image:latest",
127+
},
128+
getProviderEnv(localRepoUrl),
129+
);
130+
throw new Error("Expected apply to fail without xray_url");
131+
} catch (ex) {
132+
if (!(ex instanceof Error)) throw new Error("Unknown error");
133+
expect(ex.message).toContain('input variable "xray_url" is not set');
134+
}
135+
});
136+
137+
it("validates required variable: xray_token", async () => {
138+
try {
139+
await runTerraformApply(
140+
import.meta.dir,
141+
{
142+
xray_url: localRepoUrl,
143+
image: "docker-local/test/image:latest",
144+
},
145+
getProviderEnv(localRepoUrl),
146+
);
147+
throw new Error("Expected apply to fail without xray_token");
148+
} catch (ex) {
149+
if (!(ex instanceof Error)) throw new Error("Unknown error");
150+
expect(ex.message).toContain('input variable "xray_token" is not set');
151+
}
152+
});
153+
154+
it("validates required variable: image", async () => {
155+
try {
156+
await runTerraformApply(
157+
import.meta.dir,
158+
{
159+
xray_url: localRepoUrl,
160+
xray_token: "test-token",
161+
},
162+
getProviderEnv(localRepoUrl),
163+
);
164+
throw new Error("Expected apply to fail without image");
165+
} catch (ex) {
166+
if (!(ex instanceof Error)) throw new Error("Unknown error");
167+
expect(ex.message).toContain('input variable "image" is not set');
168+
}
169+
});
170+
171+
it("returns vulnerability counts for local repository", async () => {
172+
const state = await runTerraformApply(
173+
import.meta.dir,
174+
{
175+
xray_url: localRepoUrl,
176+
xray_token: "test-token",
177+
image: "docker-local/myapp/backend:v1.0.0",
178+
},
179+
getProviderEnv(localRepoUrl),
180+
);
181+
182+
expect(state.outputs.critical.value).toBe(1);
183+
expect(state.outputs.high.value).toBe(3);
184+
expect(state.outputs.medium.value).toBe(5);
185+
expect(state.outputs.low.value).toBe(10);
186+
expect(state.outputs.total.value).toBe(19);
187+
});
188+
189+
it("returns zero counts when image has no scan results", async () => {
190+
const state = await runTerraformApply(
191+
import.meta.dir,
192+
{
193+
xray_url: emptyResultsUrl,
194+
xray_token: "test-token",
195+
image: "docker-local/unscanned/image:latest",
196+
},
197+
getProviderEnv(emptyResultsUrl),
198+
);
199+
200+
expect(state.outputs.critical.value).toBe(0);
201+
expect(state.outputs.high.value).toBe(0);
202+
expect(state.outputs.medium.value).toBe(0);
203+
expect(state.outputs.low.value).toBe(0);
204+
expect(state.outputs.total.value).toBe(0);
205+
});
206+
207+
it("uses cache repo when use_cache_repo is enabled", async () => {
208+
const state = await runTerraformApply(
209+
import.meta.dir,
210+
{
211+
xray_url: remoteRepoUrl,
212+
xray_token: "test-token",
213+
image: "docker-remote/codercom/enterprise-base:ubuntu",
214+
use_cache_repo: true,
215+
},
216+
getProviderEnv(remoteRepoUrl),
217+
);
218+
219+
// Should find the SHA artifact with actual vulnerabilities
220+
expect(state.outputs.critical.value).toBe(2);
221+
expect(state.outputs.high.value).toBe(6);
222+
expect(state.outputs.medium.value).toBe(20);
223+
expect(state.outputs.low.value).toBe(23);
224+
expect(state.outputs.total.value).toBe(51);
225+
expect(state.outputs.violations.value).toBe(2);
226+
expect(state.outputs.artifact_name.value).toContain("sha256__");
227+
});
228+
229+
it("allows custom repo and repo_path override", async () => {
230+
const state = await runTerraformApply(
231+
import.meta.dir,
232+
{
233+
xray_url: localRepoUrl,
234+
xray_token: "test-token",
235+
image: "ignored/path:tag",
236+
repo: "docker-local",
237+
repo_path: "/myapp/backend/v1.0.0",
238+
},
239+
getProviderEnv(localRepoUrl),
240+
);
241+
242+
expect(state.outputs.total.value).toBe(19);
243+
});
244+
});

0 commit comments

Comments
 (0)