diff --git a/.icons/jfrog-xray.svg b/.icons/jfrog-xray.svg new file mode 100644 index 00000000..e507de13 --- /dev/null +++ b/.icons/jfrog-xray.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/registry/coder/modules/jfrog-xray/README.md b/registry/coder/modules/jfrog-xray/README.md new file mode 100644 index 00000000..f97b1966 --- /dev/null +++ b/registry/coder/modules/jfrog-xray/README.md @@ -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 +} +``` diff --git a/registry/coder/modules/jfrog-xray/main.test.ts b/registry/coder/modules/jfrog-xray/main.test.ts new file mode 100644 index 00000000..34af7f6e --- /dev/null +++ b/registry/coder/modules/jfrog-xray/main.test.ts @@ -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); + }); +}); diff --git a/registry/coder/modules/jfrog-xray/main.tf b/registry/coder/modules/jfrog-xray/main.tf new file mode 100644 index 00000000..b90f6bc2 --- /dev/null +++ b/registry/coder/modules/jfrog-xray/main.tf @@ -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) +}