diff --git a/pantera-main/Dockerfile b/pantera-main/Dockerfile index 00913abbf..39b7d2d6b 100644 --- a/pantera-main/Dockerfile +++ b/pantera-main/Dockerfile @@ -1,9 +1,30 @@ FROM eclipse-temurin:21-jre-alpine ARG JAR_FILE ARG APM_VERSION=1.55.4 +ARG TRIVY_VERSION=0.69.3 +ARG GRYPE_VERSION=0.110.0 -# Install curl for downloading Elastic APM agent + jattach for programmatic attachment (jmap + jstack + jcmd + jinfo) -RUN apk add --no-cache curl jattach +# Install curl, jattach, and vulnerability scanners (Trivy + Grype). +# Both are static Go binaries — no glibc required on Alpine. +# Trivy asset names use a hyphen (Linux-64bit), Grype uses underscore (linux_amd64). +RUN apk add --no-cache curl jattach && \ + ARCH="$(uname -m)" && \ + case "${ARCH}" in \ + x86_64) TRIVY_ARCH="Linux-64bit" ; GRYPE_ARCH="amd64" ;; \ + aarch64) TRIVY_ARCH="Linux-ARM64" ; GRYPE_ARCH="arm64" ;; \ + armv7l) TRIVY_ARCH="Linux-ARM" ; GRYPE_ARCH="armv6" ;; \ + *) echo "Unsupported arch: ${ARCH}" && exit 1 ;; \ + esac && \ + curl -fsSL "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" \ + -o /tmp/trivy.tar.gz && \ + tar -xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy && \ + rm /tmp/trivy.tar.gz && \ + chmod 755 /usr/local/bin/trivy && \ + curl -fsSL "https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_linux_${GRYPE_ARCH}.tar.gz" \ + -o /tmp/grype.tar.gz && \ + tar -xzf /tmp/grype.tar.gz -C /usr/local/bin grype && \ + rm /tmp/grype.tar.gz && \ + chmod 755 /usr/local/bin/grype ENV JVM_ARGS="-XX:+UseG1GC -XX:MaxGCPauseMillis=300 \ -XX:G1HeapRegionSize=16m \ @@ -20,7 +41,8 @@ ENV JVM_ARGS="-XX:+UseG1GC -XX:MaxGCPauseMillis=300 \ RUN addgroup -g 2020 -S pantera && \ adduser -u 2021 -S -G pantera -s /sbin/nologin pantera && \ - mkdir -p /etc/pantera /usr/lib/pantera /var/pantera/logs/dumps /var/pantera/cache/tmp /opt/apm && \ + mkdir -p /etc/pantera /usr/lib/pantera /var/pantera/logs/dumps /var/pantera/cache/tmp /opt/apm \ + /var/pantera/trivy/db /var/pantera/grype/db && \ chown -R pantera:pantera /etc/pantera /usr/lib/pantera /var/pantera && \ curl -L "https://repo1.maven.org/maven2/co/elastic/apm/elastic-apm-agent/${APM_VERSION}/elastic-apm-agent-${APM_VERSION}.jar" \ -o /opt/apm/elastic-apm-agent.jar && \ @@ -28,6 +50,10 @@ RUN addgroup -g 2020 -S pantera && \ ENV TMPDIR=/var/pantera/cache/tmp ENV PANTERA_VERSION=2.0.7 +# Vulnerability scanner DB directories — each scanner downloads its CVE database +# on first scan and caches it here. Mount named volumes for persistence. +ENV TRIVY_CACHE_DIR=/var/pantera/trivy/db +ENV GRYPE_DB_CACHE_DIR=/var/pantera/grype/db USER 2021:2020 diff --git a/pantera-main/docker-compose/.env.example b/pantera-main/docker-compose/.env.example index f91452e0a..8785220fd 100644 --- a/pantera-main/docker-compose/.env.example +++ b/pantera-main/docker-compose/.env.example @@ -8,6 +8,14 @@ # ----------------------------------------------------------------------------- PANTERA_VERSION=2.0.7 PANTERA_UI_VERSION=2.0.7 + +# ----------------------------------------------------------------------------- +# Vulnerability Scanners (versions pinned in the Dockerfile) +# Both binaries are installed in the pantera container and manage their own +# CVE databases. DBs are persisted in named volumes (trivy-db, grype-db). +# Trivy: https://github.com/aquasecurity/trivy/releases +# Grype: https://github.com/anchore/grype/releases +# ----------------------------------------------------------------------------- PANTERA_USER_NAME=PANTERA PANTERA_USER_PASS=changeme PANTERA_CONFIG=/etc/PANTERA/PANTERA.yml diff --git a/pantera-main/docker-compose/docker-compose.yaml b/pantera-main/docker-compose/docker-compose.yaml index 739a35aa4..21416b060 100644 --- a/pantera-main/docker-compose/docker-compose.yaml +++ b/pantera-main/docker-compose/docker-compose.yaml @@ -61,6 +61,9 @@ services: - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + # Vulnerability scanner DB directories — persisted across restarts + - TRIVY_CACHE_DIR=/var/pantera/trivy/db + - GRYPE_DB_CACHE_DIR=/var/pantera/grype/db volumes: - ./pantera/pantera.yml:/etc/pantera/pantera.yml - ./log4j2.xml:/etc/pantera/log4j2.xml @@ -70,6 +73,9 @@ services: - ./pantera/cache:/var/pantera/cache - ./pantera/cache/log:/var/pantera/logs/ - ~/.aws:/home/.aws + # Vulnerability scanner databases — downloaded on first scan + - trivy-db:/var/pantera/trivy/db + - grype-db:/var/pantera/grype/db networks: - pantera-net # - es @@ -236,4 +242,6 @@ networks: volumes: valkey-data: prometheus-data: - grafana-data: \ No newline at end of file + grafana-data: + trivy-db: + grype-db: \ No newline at end of file diff --git a/pantera-main/docker-compose/pantera/pantera.yml b/pantera-main/docker-compose/pantera/pantera.yml index 245c95a0f..8c8096ef9 100755 --- a/pantera-main/docker-compose/pantera/pantera.yml +++ b/pantera-main/docker-compose/pantera/pantera.yml @@ -53,7 +53,32 @@ meta: repo_types: npm-proxy: enabled: true - + + vulnerability: + # Set to true to enable on-demand vulnerability scanning. + # The scanner binary must be present in the container. + enabled: true + # Scanner backend type. Supported values: "trivy", "grype" + scanner_type: trivy + # Path to the scanner binary. Must be on PATH (both are installed in the container). + scanner_path: trivy + # Hours before a cached scan result is considered stale. + # Users will see a "stale" indicator and can click "Scan Now" to refresh. + cache_ttl_hours: 24 + # Maximum seconds to allow a single scanner subprocess to run before killing it. + scan_timeout_seconds: 300 + # Maximum number of artifacts scanned in parallel during a "Scan Repository" operation. + # Caps resource usage regardless of repository size. Rule of thumb: CPU cores / 2. + scan_concurrency: 4 + # Maximum concurrent scans across ALL repositories at one time. + # Prevents scan-all floods from overwhelming the system. Default: 8. + max_global_concurrency: 8 + # Optional cron expression for automatic full scans (Quartz format). + # If omitted, only on-demand scans are performed. + # Example: scan all repos every day at 2 AM + # cron: "0 0 2 * * ?" + cron: "0 0/2 * * * ?" + artifacts_database: postgres_host: "pantera-db" postgres_port: 5432 diff --git a/pantera-main/src/main/java/com/auto1/pantera/VertxMain.java b/pantera-main/src/main/java/com/auto1/pantera/VertxMain.java index 671ccc677..07718c37a 100644 --- a/pantera-main/src/main/java/com/auto1/pantera/VertxMain.java +++ b/pantera-main/src/main/java/com/auto1/pantera/VertxMain.java @@ -24,8 +24,19 @@ import com.auto1.pantera.jetty.http3.Http3Server; import com.auto1.pantera.jetty.http3.SslFactoryFromYaml; import com.auto1.pantera.misc.PanteraProperties; +import com.auto1.pantera.scheduling.JobDataRegistry; import com.auto1.pantera.scheduling.QuartzService; import com.auto1.pantera.scheduling.ScriptScheduler; +import com.auto1.pantera.vuln.DefaultVulnerabilityScanner; +import com.auto1.pantera.vuln.VulnerabilityDao; +import com.auto1.pantera.vuln.VulnerabilityScanJob; +import com.auto1.pantera.vuln.VulnerabilitySettings; +import com.auto1.pantera.vuln.backend.ScannerBackendFactory; +import com.auto1.pantera.vuln.preparer.ComposerPreparer; +import com.auto1.pantera.vuln.preparer.GoModulePreparer; +import com.auto1.pantera.vuln.preparer.MavenPomArtifactPreparer; +import com.auto1.pantera.vuln.preparer.NpmArtifactPreparer; +import com.auto1.pantera.vuln.preparer.PypiSdistArtifactPreparer; import com.auto1.pantera.settings.ConfigFile; import com.auto1.pantera.settings.MetricsContext; import com.auto1.pantera.settings.Settings; @@ -33,6 +44,11 @@ import com.auto1.pantera.settings.repo.DbRepositories; import com.auto1.pantera.settings.repo.MapRepositories; import com.auto1.pantera.settings.repo.RepoConfig; +import com.auto1.pantera.api.ManageRepoSettings; +import com.auto1.pantera.asto.blocking.BlockingStorage; +import com.auto1.pantera.db.dao.RepositoryDao; +import com.auto1.pantera.settings.RepoData; +import com.auto1.pantera.settings.repo.CrudRepoSettings; import com.auto1.pantera.http.log.EcsLogger; import com.auto1.pantera.settings.repo.Repositories; import com.auto1.pantera.db.DbManager; @@ -398,6 +414,51 @@ settings, repos, new JwtTokens(jwt, jwtSettings, userTokenDao) quartz.start(); new ScriptScheduler(quartz).loadCrontab(settings, repos); + final VulnerabilitySettings vsettings = settings.vulnerabilitySettings(); + if (vsettings.enabled() && vsettings.cronExpression() != null) { + final CrudRepoSettings vulnCrs = sharedDs.isPresent() + ? new RepositoryDao(sharedDs.get()) + : new ManageRepoSettings( + new BlockingStorage(settings.configStorage()) + ); + final VulnerabilityDao vulnDao = sharedDs + .map(VulnerabilityDao::new).orElse(null); + JobDataRegistry.register(VulnerabilityScanJob.KEY_SCANNER, + new DefaultVulnerabilityScanner( + ScannerBackendFactory.create(vsettings), + java.util.List.of( + new NpmArtifactPreparer(), + new MavenPomArtifactPreparer(), + new PypiSdistArtifactPreparer(), + new GoModulePreparer(), + new ComposerPreparer() + ), + vsettings + ) + ); + if (vulnDao != null) { + JobDataRegistry.register(VulnerabilityScanJob.KEY_DAO, vulnDao); + } + JobDataRegistry.register(VulnerabilityScanJob.KEY_CRS, vulnCrs); + JobDataRegistry.register(VulnerabilityScanJob.KEY_REPO_DATA, + new RepoData(settings.configStorage(), settings.caches().storagesCache()) + ); + JobDataRegistry.register(VulnerabilityScanJob.KEY_SETTINGS, vsettings); + try { + quartz.schedulePeriodicJob( + vsettings.cronExpression(), VulnerabilityScanJob.class, + new org.quartz.JobDataMap() + ); + EcsLogger.info("com.auto1.pantera") + .message("Scheduled vulnerability scan job with cron: " + vsettings.cronExpression()) + .eventCategory("security") + .eventAction("vulnerability_schedule") + .eventOutcome("success") + .log(); + } catch (final org.quartz.SchedulerException ex) { + throw new PanteraException(ex); + } + } // JIT warmup: fire lightweight requests through group code paths so the // first real client request doesn't pay ~140ms JIT compilation penalty. diff --git a/pantera-main/src/main/java/com/auto1/pantera/api/v1/AsyncApiVerticle.java b/pantera-main/src/main/java/com/auto1/pantera/api/v1/AsyncApiVerticle.java index 2a90917b0..fc5213ed5 100644 --- a/pantera-main/src/main/java/com/auto1/pantera/api/v1/AsyncApiVerticle.java +++ b/pantera-main/src/main/java/com/auto1/pantera/api/v1/AsyncApiVerticle.java @@ -20,6 +20,15 @@ import com.auto1.pantera.cooldown.CooldownService; import com.auto1.pantera.cooldown.CooldownSupport; import com.auto1.pantera.cooldown.metadata.CooldownMetadataService; +import com.auto1.pantera.vuln.DefaultVulnerabilityScanner; +import com.auto1.pantera.vuln.VulnerabilityScanner; +import com.auto1.pantera.vuln.VulnerabilitySettings; +import com.auto1.pantera.vuln.backend.ScannerBackendFactory; +import com.auto1.pantera.vuln.preparer.ComposerPreparer; +import com.auto1.pantera.vuln.preparer.GoModulePreparer; +import com.auto1.pantera.vuln.preparer.MavenPomArtifactPreparer; +import com.auto1.pantera.vuln.preparer.NpmArtifactPreparer; +import com.auto1.pantera.vuln.preparer.PypiSdistArtifactPreparer; import com.auto1.pantera.db.dao.AuthProviderDao; import com.auto1.pantera.db.dao.RoleDao; import com.auto1.pantera.db.dao.RepositoryDao; @@ -312,6 +321,29 @@ crs, new RepoData(this.configsStorage, this.caches.storagesCache()), this.security.policy() ).register(router); new SearchHandler(this.artifactIndex, this.security.policy()).register(router); + // Vulnerability scanning handler + final VulnerabilitySettings vsettings = this.settings.vulnerabilitySettings(); + final VulnerabilityScanner vulnScanner = vsettings.enabled() + ? new DefaultVulnerabilityScanner( + ScannerBackendFactory.create(vsettings), + java.util.List.of( + new NpmArtifactPreparer(), + new MavenPomArtifactPreparer(), + new PypiSdistArtifactPreparer(), + new GoModulePreparer(), + new ComposerPreparer() + ), + vsettings + ) + : VulnerabilityScanner.NOP; + new VulnerabilityHandler( + vulnScanner, + this.dataSource, + vsettings, + crs, + new RepoData(this.configsStorage, this.caches.storagesCache()), + this.security.policy() + ).register(router); // Start server final HttpServer server; final String schema; diff --git a/pantera-main/src/main/java/com/auto1/pantera/api/v1/VulnerabilityHandler.java b/pantera-main/src/main/java/com/auto1/pantera/api/v1/VulnerabilityHandler.java new file mode 100644 index 000000000..04feb1259 --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/api/v1/VulnerabilityHandler.java @@ -0,0 +1,604 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.api.v1; + +import com.auto1.pantera.api.AuthzHandler; +import com.auto1.pantera.api.RepositoryName; +import com.auto1.pantera.api.perms.ApiRepositoryPermission; +import com.auto1.pantera.asto.Key; +import com.auto1.pantera.asto.Storage; +import com.auto1.pantera.http.log.EcsLogger; +import com.auto1.pantera.http.log.MdcPropagatingCallable; +import com.auto1.pantera.security.policy.Policy; +import com.auto1.pantera.settings.RepoData; +import com.auto1.pantera.settings.repo.CrudRepoSettings; +import com.auto1.pantera.vuln.VulnerabilityDao; +import com.auto1.pantera.vuln.VulnerabilityReport; +import com.auto1.pantera.vuln.VulnerabilityScanner; +import com.auto1.pantera.vuln.VulnerabilitySettings; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; + +/** + * REST handler for vulnerability scanning endpoints. + * + *

Routes: + *

+ * + *

All read routes require {@code api_repository_permissions READ}. + * The POST scan route requires {@code api_repository_permissions WRITE}. + * + * @since 2.1.0 + * @checkstyle ClassDataAbstractionCouplingCheck (400 lines) + */ +public final class VulnerabilityHandler { + + /** + * Sort column allowlist for the findings endpoint. + */ + private static final java.util.Set SORT_COLS = java.util.Set.of( + "cve_id", "severity", "package_name", "repo_name", "scanned_at" + ); + + /** + * Tracks repositories with an active scan-all in progress. + * Maps repo name → epoch-millis when the scan started. + * The guard is cleared only when the background job finishes. + * Using a ConcurrentHashMap ensures the add/check is atomic. + */ + private final ConcurrentHashMap activeScanAll = new ConcurrentHashMap<>(); + + /** + * Global semaphore bounding the total number of concurrent scans across all + * repositories. Prevents scan-all floods from multiple repos at once. + * Permits = {@link VulnerabilitySettings#maxGlobalConcurrency()}. + */ + private final Semaphore globalScanSemaphore; + + /** + * Vulnerability scanner (Trivy or NOP). + */ + private final VulnerabilityScanner scanner; + + /** + * Vulnerability DAO (nullable — disabled when no DB). + */ + private final VulnerabilityDao dao; + + /** + * Vulnerability settings (cache TTL etc.). + */ + private final VulnerabilitySettings vsettings; + + /** + * Repository settings CRUD. + */ + private final CrudRepoSettings crs; + + /** + * Repository data (storage resolver). + */ + private final RepoData repoData; + + /** + * Security policy. + */ + private final Policy policy; + + /** + * Ctor. + * @param scanner Vulnerability scanner implementation + * @param dataSource Database data source (nullable) + * @param vsettings Vulnerability settings + * @param crs Repository settings CRUD + * @param repoData Repository data accessor + * @param policy Security policy + * @checkstyle ParameterNumberCheck (5 lines) + */ + public VulnerabilityHandler( + final VulnerabilityScanner scanner, + final DataSource dataSource, + final VulnerabilitySettings vsettings, + final CrudRepoSettings crs, + final RepoData repoData, + final Policy policy + ) { + this.scanner = scanner; + this.dao = dataSource != null ? new VulnerabilityDao(dataSource) : null; + this.vsettings = vsettings; + this.crs = crs; + this.repoData = repoData; + this.policy = policy; + this.globalScanSemaphore = new Semaphore(vsettings.maxGlobalConcurrency()); + } + + /** + * Register all vulnerability routes on the router. + * @param router Vert.x router + */ + public void register(final Router router) { + // Cross-repo summary + router.get("/api/v1/vulnerabilities/summary") + .handler(new AuthzHandler(this.policy, READ)) + .handler(this::handleSummary); + // Paginated findings across all repos + router.get("/api/v1/vulnerabilities/findings") + .handler(new AuthzHandler(this.policy, READ)) + .handler(this::handleFindings); + // Delete all findings (admin cleanup) + router.delete("/api/v1/vulnerabilities") + .handler(new AuthzHandler(this.policy, WRITE)) + .handler(this::handleDeleteAll); + // All cached reports for a specific repository + router.get("/api/v1/repositories/:name/vulnerabilities") + .handler(new AuthzHandler(this.policy, READ)) + .handler(this::handleRepoVulnerabilities); + // Cached report for a specific artifact (GET — returns from cache or 404) + router.get("/api/v1/repositories/:name/vulnerabilities/artifact") + .handler(new AuthzHandler(this.policy, READ)) + .handler(this::handleArtifactReport); + // Trigger / refresh a scan for a specific artifact (POST) + router.post("/api/v1/repositories/:name/vulnerabilities/scan") + .handler(new AuthzHandler(this.policy, WRITE)) + .handler(this::handleScan); + // Scan every artifact in a repository (POST — fires and forgets, returns immediately) + router.post("/api/v1/repositories/:name/vulnerabilities/scan-all") + .handler(new AuthzHandler(this.policy, WRITE)) + .handler(this::handleScanAll); + } + + // ------------------------------------------------------------------------- + // GET /api/v1/vulnerabilities/summary + // ------------------------------------------------------------------------- + + /** + * Return an aggregate summary per repository. + * @param ctx Routing context + */ + private void handleSummary(final RoutingContext ctx) { + if (!this.vsettings.enabled() || this.dao == null) { + ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(new JsonObject().put("items", new JsonArray()).encode()); + return; + } + ctx.vertx().executeBlocking( + MdcPropagatingCallable.wrap(() -> { + final List rows = this.dao.summarizeAll(); + final JsonArray arr = new JsonArray(); + rows.forEach(arr::add); + return new JsonObject().put("items", arr); + }), + false + ).onSuccess( + json -> ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(json.encode()) + ).onFailure( + err -> ApiResponse.sendError(ctx, 500, "INTERNAL_ERROR", err.getMessage()) + ); + } + + // ------------------------------------------------------------------------- + // GET /api/v1/vulnerabilities/findings + // ------------------------------------------------------------------------- + + /** + * Return paginated CVE findings across all repositories with optional search/sort. + * @param ctx Routing context + */ + private void handleFindings(final RoutingContext ctx) { + if (!this.vsettings.enabled() || this.dao == null) { + ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(ApiResponse.paginated(new JsonArray(), 0, 50, 0).encode()); + return; + } + final int page = ApiResponse.intParam( + ctx.queryParam("page").stream().findFirst().orElse(null), 0 + ); + final int size = ApiResponse.clampSize( + ApiResponse.intParam( + ctx.queryParam("size").stream().findFirst().orElse(null), 50 + ) + ); + final String search = ctx.queryParam("search").stream().findFirst().orElse(null); + final String repo = ctx.queryParam("repo").stream().findFirst().orElse(null); + final String severity = ctx.queryParam("severity").stream().findFirst().orElse(null); + final String sortBy = ctx.queryParam("sort_by").stream().findFirst().orElse("scanned_at"); + final String sortDir = ctx.queryParam("sort_dir").stream().findFirst().orElse("desc"); + final boolean sortAsc = "asc".equalsIgnoreCase(sortDir); + final String safeSort = SORT_COLS.contains(sortBy) ? sortBy : "scanned_at"; + ctx.vertx().executeBlocking( + MdcPropagatingCallable.wrap(() -> { + final long total = this.dao.countAllFindings(search, repo, severity); + final List rows = this.dao.findAllFindingsPaginated( + page * size, size, search, repo, severity, safeSort, sortAsc + ); + final JsonArray arr = new JsonArray(); + rows.forEach(arr::add); + return ApiResponse.paginated(arr, page, size, (int) Math.min(total, Integer.MAX_VALUE)); + }), + false + ).onSuccess( + json -> ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(json.encode()) + ).onFailure( + err -> ApiResponse.sendError(ctx, 500, "INTERNAL_ERROR", err.getMessage()) + ); + } + + // ------------------------------------------------------------------------- + // GET /api/v1/repositories/:name/vulnerabilities + // ------------------------------------------------------------------------- + + /** + * Return all cached scan reports for a specific repository. + * @param ctx Routing context + */ + private void handleRepoVulnerabilities(final RoutingContext ctx) { + final String repoName = ctx.pathParam("name"); + if (!this.vsettings.enabled() || this.dao == null) { + ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(new JsonObject().put("items", new JsonArray()).encode()); + return; + } + ctx.vertx().executeBlocking( + MdcPropagatingCallable.wrap(() -> { + final List reports = this.dao.findByRepo(repoName); + final JsonArray arr = new JsonArray(); + for (final VulnerabilityReport r : reports) { + arr.add(r.toJson(this.vsettings.cacheTtlHours())); + } + return new JsonObject() + .put("repo_name", repoName) + .put("items", arr) + .put("total", reports.size()); + }), + false + ).onSuccess( + json -> ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(json.encode()) + ).onFailure( + err -> ApiResponse.sendError(ctx, 500, "INTERNAL_ERROR", err.getMessage()) + ); + } + + // ------------------------------------------------------------------------- + // GET /api/v1/repositories/:name/vulnerabilities/artifact?path=… + // ------------------------------------------------------------------------- + + /** + * Return the cached vulnerability report for a specific artifact. + * Returns 404 if the artifact has never been scanned. + * Returns the report with {@code is_stale: true} if the cache has expired. + * @param ctx Routing context + */ + private void handleArtifactReport(final RoutingContext ctx) { + final String repoName = ctx.pathParam("name"); + final String path = ctx.queryParam("path").stream().findFirst().orElse(null); + if (path == null || path.isBlank()) { + ApiResponse.sendError(ctx, 400, "BAD_REQUEST", "Query parameter 'path' is required"); + return; + } + if (!this.vsettings.enabled() || this.dao == null) { + ApiResponse.sendError(ctx, 503, "SCANNING_DISABLED", + "Vulnerability scanning is not enabled"); + return; + } + ctx.vertx().>executeBlocking( + MdcPropagatingCallable.wrap(() -> this.dao.findByArtifact(repoName, path)), + false + ).onSuccess(opt -> { + if (opt.isEmpty()) { + ApiResponse.sendError(ctx, 404, "NOT_FOUND", + "No scan result found for this artifact. Use POST /scan to trigger a scan."); + } else { + ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(opt.get().toJson(this.vsettings.cacheTtlHours()).encode()); + } + }).onFailure( + err -> ApiResponse.sendError(ctx, 500, "INTERNAL_ERROR", err.getMessage()) + ); + } + + // ------------------------------------------------------------------------- + // POST /api/v1/repositories/:name/vulnerabilities/scan?path=… + // ------------------------------------------------------------------------- + + /** + * Trigger (or force-refresh) a vulnerability scan for a specific artifact. + * Downloads the artifact from storage, runs Trivy, persists and returns the result. + * @param ctx Routing context + */ + private void handleScan(final RoutingContext ctx) { + final String repoName = ctx.pathParam("name"); + final String path = ctx.queryParam("path").stream().findFirst().orElse(null); + if (path == null || path.isBlank()) { + ApiResponse.sendError(ctx, 400, "BAD_REQUEST", "Query parameter 'path' is required"); + return; + } + if (!this.vsettings.enabled()) { + ApiResponse.sendError(ctx, 503, "SCANNING_DISABLED", + "Vulnerability scanning is not enabled. Set vulnerability.enabled: true in pantera.yml"); + return; + } + if (!this.globalScanSemaphore.tryAcquire()) { + ApiResponse.sendError(ctx, 429, "TOO_MANY_SCANS", + String.format( + "Maximum concurrent scans (%d) reached. Try again later.", + this.vsettings.maxGlobalConcurrency())); + return; + } + final RepositoryName rname = new RepositoryName.Simple(repoName); + this.repoData.repoStorage(rname, this.crs) + .thenCompose(storage -> this.scanner.scan(repoName, path, storage)) + .thenAccept(report -> { + if (this.dao != null) { + this.dao.upsert(report); + } + ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(report.toJson(this.vsettings.cacheTtlHours()).encode()); + }) + .exceptionally(err -> { + ApiResponse.sendError(ctx, 500, "SCAN_FAILED", + "Scan failed: " + err.getMessage()); + return null; + }) + .whenComplete((v, t) -> this.globalScanSemaphore.release()); + } + + // ------------------------------------------------------------------------- + // POST /api/v1/repositories/:name/vulnerabilities/scan-all + // ------------------------------------------------------------------------- + + /** + * Scan every artifact in a repository with bounded concurrency. + * + *

Safety guarantees: + *

+ * + * @param ctx Routing context + */ + private void handleScanAll(final RoutingContext ctx) { + final String repoName = ctx.pathParam("name"); + if (!this.vsettings.enabled()) { + ApiResponse.sendError(ctx, 503, "SCANNING_DISABLED", + "Vulnerability scanning is not enabled."); + return; + } + // Reject duplicate scan requests for the same repo. + // putIfAbsent is atomic: only one concurrent caller gets null back (the winner). + // The guard is removed only when the background job's whenComplete fires. + final Long existing = this.activeScanAll.putIfAbsent(repoName, System.currentTimeMillis()); + if (existing != null) { + ApiResponse.sendError(ctx, 409, "SCAN_ALREADY_RUNNING", + String.format( + "A scan-all is already in progress for repository '%s'. " + + "Poll GET /repositories/%s/vulnerabilities for progress.", + repoName, repoName)); + return; + } + final RepositoryName rname = new RepositoryName.Simple(repoName); + this.repoData.repoStorage(rname, this.crs) + .thenCompose(storage -> + listAllFiles(storage, new Key.From(repoName), repoName) + .thenApply(paths -> { + final int total = paths.size(); + ctx.response().setStatusCode(202) + .putHeader("Content-Type", "application/json") + .end(new JsonObject() + .put("enqueued", total) + .put("repo_name", repoName) + .put("concurrency", this.vsettings.scanConcurrency()) + .put("message", String.format( + "Scanning %d artifact(s) in background " + + "(%d at a time). " + + "Poll GET /repositories/%s/vulnerabilities for progress.", + total, this.vsettings.scanConcurrency(), repoName)) + .encode()); + // Run background pipeline in a single virtual thread. + // The semaphore limits how many Trivy subprocesses run at once. + final int concurrency = this.vsettings.scanConcurrency(); + final VulnerabilityDao daoRef = this.dao; + final VulnerabilityScanner scannerRef = this.scanner; + final VulnerabilitySettings vsRef = this.vsettings; + final ConcurrentHashMap activeRef = this.activeScanAll; + final Semaphore globalSem = this.globalScanSemaphore; + Thread.ofVirtual().start(() -> { + // Per-repo semaphore limits how many artifacts scan in parallel + // for this specific repo. + final Semaphore sem = new Semaphore(concurrency); + final AtomicInteger done = new AtomicInteger(0); + final AtomicInteger failed = new AtomicInteger(0); + final List> futures = new ArrayList<>( + Math.min(total, concurrency * 2) + ); + for (final String artifactPath : paths) { + try { + // Acquire both semaphores: per-repo AND global. + // Per-repo limits concurrency within this scan-all. + // Global limits total scans across all repos. + globalSem.acquire(); + sem.acquire(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + break; + } + final CompletableFuture fut = scannerRef + .scan(repoName, artifactPath, storage) + .thenAccept(report -> { + if (daoRef != null) { + daoRef.upsert(report); + } + EcsLogger.info("com.auto1.pantera.vuln") + .message("Repo scan: artifact complete") + .field("repo_name", repoName) + .field("artifact_path", artifactPath) + .field("vuln_count", report.vulnCount()) + .field("done", done.incrementAndGet()) + .field("total", total) + .log(); + }) + .exceptionally(err -> { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Repo scan: artifact failed") + .field("repo_name", repoName) + .field("artifact_path", artifactPath) + .field("failed", failed.incrementAndGet()) + .error(err) + .log(); + return null; + }) + .whenComplete((v, t) -> { + sem.release(); + globalSem.release(); + }); + futures.add(fut); + } + // Wait for all scans to finish, then release the guard + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .whenComplete((v, t) -> { + activeRef.remove(repoName); + // ^ remove is atomic — safe to call from virtual thread + EcsLogger.info("com.auto1.pantera.vuln") + .message("Repo scan-all complete") + .field("repo_name", repoName) + .field("total", total) + .field("done", done.get()) + .field("failed", failed.get()) + .log(); + }); + }); + return null; + }) + ) + .exceptionally(err -> { + activeScanAll.remove(repoName); + ApiResponse.sendError(ctx, 500, "SCAN_FAILED", + "Failed to list repository artifacts: " + err.getMessage()); + return null; + }); + } + + // ------------------------------------------------------------------------- + // DELETE /api/v1/vulnerabilities + // ------------------------------------------------------------------------- + + /** + * Delete all vulnerability findings from the database. + * Intended for admin use — clears both real findings and scan-marker rows. + * @param ctx Routing context + */ + private void handleDeleteAll(final RoutingContext ctx) { + if (this.dao == null) { + ApiResponse.sendError(ctx, 503, "NO_DATABASE", "Database not available"); + return; + } + ctx.vertx().executeBlocking( + MdcPropagatingCallable.wrap(() -> { + this.dao.deleteAll(); + return null; + }), + false + ).onSuccess( + ignored -> ctx.response().setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(new JsonObject().put("deleted", true).encode()) + ).onFailure( + err -> ApiResponse.sendError(ctx, 500, "INTERNAL_ERROR", err.getMessage()) + ); + } + + /** + * Recursively list all file paths under a storage prefix. + * Returns paths relative to the repo root (repo name prefix stripped). + * + *

Note: this collects all paths in memory before returning. For very + * large repositories (100k+ artifacts) a streaming approach would be + * preferable, but the memory cost here is minimal — a list of strings, + * ~100 bytes each, is ~10MB for 100k artifacts. + * + * @param storage Storage backend + * @param prefix Current key prefix to list + * @param repoName Repository name (for stripping from returned paths) + * @return Future resolving to all file paths found + */ + private static CompletableFuture> listAllFiles( + final Storage storage, final Key prefix, final String repoName + ) { + return storage.list(prefix, "/").thenCompose(listing -> { + final List>> subdirFutures = new ArrayList<>(); + for (final Key dir : listing.directories()) { + subdirFutures.add(listAllFiles(storage, dir, repoName)); + } + final String repoPrefix = repoName + "/"; + final List files = new ArrayList<>(); + for (final Key file : listing.files()) { + final String raw = file.string(); + files.add(raw.startsWith(repoPrefix) ? raw.substring(repoPrefix.length()) : raw); + } + if (subdirFutures.isEmpty()) { + return CompletableFuture.completedFuture(files); + } + return CompletableFuture.allOf(subdirFutures.toArray(new CompletableFuture[0])) + .thenApply(ignored -> { + final List all = new ArrayList<>(files); + for (final CompletableFuture> f : subdirFutures) { + all.addAll(f.join()); + } + return all; + }); + }).toCompletableFuture(); + } + + /** + * Convenience READ permission constant (reuses api_repository_permissions). + */ + private static final ApiRepositoryPermission READ = + new ApiRepositoryPermission(ApiRepositoryPermission.RepositoryAction.READ); + + /** + * Convenience CREATE permission constant used for triggering scans (write-like). + * Reuses api_repository_permissions — users who can create repos can trigger scans. + */ + private static final ApiRepositoryPermission WRITE = + new ApiRepositoryPermission(ApiRepositoryPermission.RepositoryAction.CREATE); +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/settings/Settings.java b/pantera-main/src/main/java/com/auto1/pantera/settings/Settings.java index 89c1f623f..0845c726b 100644 --- a/pantera-main/src/main/java/com/auto1/pantera/settings/Settings.java +++ b/pantera-main/src/main/java/com/auto1/pantera/settings/Settings.java @@ -16,6 +16,7 @@ import com.auto1.pantera.asto.Storage; import com.auto1.pantera.cache.ValkeyConnection; import com.auto1.pantera.cooldown.CooldownSettings; +import com.auto1.pantera.vuln.VulnerabilitySettings; import com.auto1.pantera.http.client.HttpClientSettings; import com.auto1.pantera.index.ArtifactIndex; import com.auto1.pantera.scheduling.MetadataEventQueues; @@ -168,4 +169,13 @@ default ArtifactIndex artifactIndex() { default Optional valkeyConnection() { return Optional.empty(); } + + /** + * Vulnerability scanning configuration. + * Returns disabled settings by default when not configured. + * @return Vulnerability settings + */ + default VulnerabilitySettings vulnerabilitySettings() { + return VulnerabilitySettings.disabled(); + } } diff --git a/pantera-main/src/main/java/com/auto1/pantera/settings/YamlSettings.java b/pantera-main/src/main/java/com/auto1/pantera/settings/YamlSettings.java index 77d7f0d73..43aa26633 100644 --- a/pantera-main/src/main/java/com/auto1/pantera/settings/YamlSettings.java +++ b/pantera-main/src/main/java/com/auto1/pantera/settings/YamlSettings.java @@ -32,6 +32,7 @@ import com.auto1.pantera.cache.ValkeyConnection; import com.auto1.pantera.cooldown.CooldownSettings; import com.auto1.pantera.cooldown.YamlCooldownSettings; +import com.auto1.pantera.vuln.VulnerabilitySettings; import com.auto1.pantera.cooldown.metadata.FilteredMetadataCacheConfig; import com.auto1.pantera.db.ArtifactDbFactory; import com.auto1.pantera.db.DbConsumer; @@ -144,6 +145,11 @@ public final class YamlSettings implements Settings { */ private final CooldownSettings cooldown; + /** + * Vulnerability scanning settings. + */ + private final VulnerabilitySettings vulnerability; + /** * Artifacts database data source if configured. */ @@ -301,6 +307,7 @@ auth, new StoragesCache(), this.security.policy(), new GuavaFiltersCache() this.mctx = new MetricsContext(this.meta()); this.lctx = new LoggingContext(this.meta()); this.cooldown = YamlCooldownSettings.fromMeta(this.meta()); + this.vulnerability = VulnerabilitySettings.fromMeta(this.meta()); // Initialize artifact index final YamlMapping indexConfig = this.meta.yamlMapping("artifact_index"); final boolean indexEnabled = indexConfig != null @@ -407,6 +414,11 @@ public CooldownSettings cooldown() { return this.cooldown; } + @Override + public VulnerabilitySettings vulnerabilitySettings() { + return this.vulnerability; + } + @Override public Optional artifactsDatabase() { return this.artifactsDb; diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/ArtifactPreparer.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/ArtifactPreparer.java new file mode 100644 index 000000000..80b706f7e --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/ArtifactPreparer.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Extracts dependency manifest file(s) from a downloaded artifact so the + * scanner backend can find them in a flat directory. + * + *

Each implementation handles one artifact format (npm tgz, Maven pom, + * PyPI sdist, Go module, PHP Composer, etc.). Only the minimal files required + * by the scanner are extracted — full artifact contents are never written to disk. + * + *

Implementations should be stateless and reusable across scans. + * + * @since 2.2.0 + */ +public interface ArtifactPreparer { + + /** + * Returns {@code true} if this preparer handles the given artifact path. + * Matching is typically done on the file extension or path pattern. + * + * @param artifactPath Storage path of the artifact, + * e.g. {@code lodash/-/lodash-4.17.21.tgz} + * @return True if this preparer supports the artifact format + */ + boolean supports(String artifactPath); + + /** + * Extract dependency manifest file(s) from the artifact bytes into + * {@code scanDir}. + * + *

On success, writes one or more manifest files into the flat + * {@code scanDir} directory. Returns {@code false} if no recognisable + * manifest was found (e.g. an npm tarball shipped without a lock file), + * in which case the caller skips the scan and records an empty report. + * + * @param artifactBytes Raw artifact bytes (already read from storage) + * @param scanDir Empty temporary directory to write manifest file(s) into + * @return {@code true} if at least one manifest file was written; + * {@code false} to skip the scan + * @throws IOException On I/O failure during extraction + */ + boolean prepare(byte[] artifactBytes, Path scanDir) throws IOException; +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/DefaultVulnerabilityScanner.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/DefaultVulnerabilityScanner.java new file mode 100644 index 000000000..98bf03dcd --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/DefaultVulnerabilityScanner.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import com.auto1.pantera.asto.Key; +import com.auto1.pantera.asto.Storage; +import com.auto1.pantera.asto.blocking.BlockingStorage; +import com.auto1.pantera.http.log.EcsLogger; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.stream.Stream; + +/** + * Default {@link VulnerabilityScanner} that wires together an + * {@link ArtifactPreparer} list and a {@link ScannerBackend}. + * + *

For each scan request: + *

    + *
  1. Finds the first {@link ArtifactPreparer} that supports the artifact type. + * If none matches, returns an empty report without downloading.
  2. + *
  3. Downloads the artifact bytes from storage — no temp artifact file is + * written to disk; bytes flow directly to the preparer.
  4. + *
  5. The preparer extracts dependency manifests into a temporary directory.
  6. + *
  7. The {@link ScannerBackend} scans the manifest directory.
  8. + *
  9. Returns a {@link VulnerabilityReport} and cleans up the temp directory.
  10. + *
+ * + *

All blocking work (download, prepare, scan) runs on a virtual-thread executor. + * + * @since 2.2.0 + * @checkstyle ClassDataAbstractionCouplingCheck (200 lines) + */ +public final class DefaultVulnerabilityScanner implements VulnerabilityScanner { + + /** + * Ordered list of artifact preparers. + */ + private final List preparers; + + /** + * CVE scanner backend. + */ + private final ScannerBackend backend; + + /** + * Scan configuration. + */ + private final VulnerabilitySettings settings; + + /** + * Executor for blocking work (download + prepare + scan). + */ + private final Executor executor; + + /** + * Ctor for production use — creates a virtual-thread-per-task executor. + * @param backend CVE scanner backend + * @param preparers Artifact preparers tried in order + * @param settings Scan configuration + */ + public DefaultVulnerabilityScanner( + final ScannerBackend backend, + final List preparers, + final VulnerabilitySettings settings + ) { + this(backend, preparers, settings, Executors.newVirtualThreadPerTaskExecutor()); + } + + /** + * Ctor with explicit executor (for testing). + * @param backend CVE scanner backend + * @param preparers Artifact preparers tried in order + * @param settings Scan configuration + * @param executor Executor for blocking work + * @checkstyle ParameterNumberCheck (5 lines) + */ + public DefaultVulnerabilityScanner( + final ScannerBackend backend, + final List preparers, + final VulnerabilitySettings settings, + final Executor executor + ) { + this.backend = backend; + this.preparers = List.copyOf(preparers); + this.settings = settings; + this.executor = executor; + } + + @Override + public CompletableFuture scan( + final String repoName, + final String artifactPath, + final Storage storage + ) { + return CompletableFuture.supplyAsync( + () -> this.doScan(repoName, artifactPath, storage), + this.executor + ); + } + + /** + * Perform the blocking scan synchronously. + * @param repoName Repository name + * @param artifactPath Artifact storage path + * @param storage Storage backend + * @return Scan report + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private VulnerabilityReport doScan( + final String repoName, + final String artifactPath, + final Storage storage + ) { + final Optional preparer = this.preparers.stream() + .filter(p -> p.supports(artifactPath)) + .findFirst(); + if (preparer.isEmpty()) { + EcsLogger.debug("com.auto1.pantera.vuln") + .message("No preparer for artifact type — skipping scan") + .field("artifact_path", artifactPath) + .log(); + return emptyReport(repoName, artifactPath); + } + final Path tmpDir = Path.of(System.getProperty("java.io.tmpdir"), "pantera-vuln"); + final String scanId = UUID.randomUUID().toString(); + Path scanDir = null; + try { + Files.createDirectories(tmpDir); + // Download bytes — no temp artifact file written to disk. + // ByteArrayInputStream in the preparer streams directly from these bytes. + final String cleanPath = artifactPath.startsWith("/") + ? artifactPath.substring(1) : artifactPath; + final byte[] bytes = new BlockingStorage(storage) + .value(new Key.From(repoName, cleanPath)); + // Prepare the scan directory with manifest files only + scanDir = tmpDir.resolve(scanId + "-scan"); + Files.createDirectories(scanDir); + if (!preparer.get().prepare(bytes, scanDir)) { + // No manifest found in this artifact — nothing to scan + return emptyReport(repoName, artifactPath); + } + // Invoke the backend against the prepared directory + final List findings = this.backend.scan( + scanDir, this.settings.scanTimeoutSeconds() + ); + return new VulnerabilityReport( + repoName, artifactPath, Instant.now(), this.backend.name(), findings + ); + } catch (final Exception ex) { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Vulnerability scan failed") + .eventCategory("security") + .eventAction("vulnerability_scan") + .eventOutcome("failure") + .field("repo_name", repoName) + .field("artifact_path", artifactPath) + .error(ex) + .log(); + return emptyReport(repoName, artifactPath); + } finally { + if (scanDir != null) { + deleteRecursively(scanDir); + } + } + } + + /** + * Return an empty report (no findings) for an artifact. + * @param repoName Repository name + * @param artifactPath Artifact path + * @return Empty report using the backend's name as the scanner identifier + */ + private VulnerabilityReport emptyReport( + final String repoName, final String artifactPath + ) { + return new VulnerabilityReport( + repoName, artifactPath, Instant.now(), this.backend.name(), List.of() + ); + } + + /** + * Recursively delete a directory tree. Best-effort — errors are ignored. + * @param dir Root directory to delete + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private static void deleteRecursively(final Path dir) { + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { Files.deleteIfExists(p); } catch (final IOException ignore) { } + }); + } catch (final Exception ignore) { } + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/ScannerBackend.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/ScannerBackend.java new file mode 100644 index 000000000..184121b2d --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/ScannerBackend.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/** + * Low-level CVE scanner backend contract. + * + *

Implementations wrap specific scanning tools (Trivy, Grype, OSV-Scanner, etc.) + * and are responsible for invoking the tool against a prepared directory and + * parsing the tool-specific output into {@link VulnerabilityFinding} objects. + * + *

Backend instances are called from a virtual-thread executor — they may + * perform blocking I/O (spawning subprocesses, reading stdout) without blocking + * the Vert.x event loop. + * + * @since 2.2.0 + */ +public interface ScannerBackend { + + /** + * Short identifier for this scanner, written into scan reports. + * Examples: {@code "trivy"}, {@code "grype"}, {@code "osv"}. + * + * @return Scanner name + */ + String name(); + + /** + * Run the scanner against a directory containing dependency manifests and + * return parsed findings. + * + *

Implementations MUST: + *

    + *
  • Only read files from {@code scanDir}.
  • + *
  • Honour the {@code timeoutSeconds} limit; return empty list on timeout.
  • + *
  • Never throw for scanner-level errors — log and return empty list.
  • + *
+ * + * @param scanDir Directory containing one or more dependency manifest files + * @param timeoutSeconds Maximum seconds to allow the scanner to run + * @return Parsed vulnerability findings (never null; may be empty) + * @throws IOException If subprocess I/O fails at the OS level + * @throws InterruptedException If the calling thread is interrupted + */ + List scan(Path scanDir, int timeoutSeconds) + throws IOException, InterruptedException; +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/TrivyVulnerabilityScanner.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/TrivyVulnerabilityScanner.java new file mode 100644 index 000000000..d4e8883af --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/TrivyVulnerabilityScanner.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import com.auto1.pantera.asto.Storage; +import com.auto1.pantera.vuln.backend.TrivyScannerBackend; +import com.auto1.pantera.vuln.preparer.MavenPomArtifactPreparer; +import com.auto1.pantera.vuln.preparer.NpmArtifactPreparer; +import com.auto1.pantera.vuln.preparer.PypiSdistArtifactPreparer; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Trivy-backed vulnerability scanner. + * + * @deprecated Use {@link DefaultVulnerabilityScanner} with a {@link TrivyScannerBackend} + * and the standard preparers instead. This class is kept only for + * backward compatibility and will be removed in a future release. + * + * @since 2.1.0 + */ +@Deprecated +public final class TrivyVulnerabilityScanner implements VulnerabilityScanner { + + /** + * Delegate scanner. + */ + private final DefaultVulnerabilityScanner delegate; + + /** + * Ctor. + * @param settings Vulnerability settings + */ + public TrivyVulnerabilityScanner(final VulnerabilitySettings settings) { + this.delegate = new DefaultVulnerabilityScanner( + new TrivyScannerBackend(settings.scannerPath()), + List.of( + new NpmArtifactPreparer(), + new MavenPomArtifactPreparer(), + new PypiSdistArtifactPreparer() + ), + settings + ); + } + + @Override + public CompletableFuture scan( + final String repoName, + final String artifactPath, + final Storage storage + ) { + return this.delegate.scan(repoName, artifactPath, storage); + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityDao.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityDao.java new file mode 100644 index 000000000..c9f16dc9c --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityDao.java @@ -0,0 +1,471 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import io.vertx.core.json.JsonObject; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.sql.DataSource; + +/** + * JDBC-backed DAO for vulnerability scan results. + * + *

All data lives in the single {@code vulnerability_findings} table (V107). + * One row per CVE finding; clean scans (zero findings) insert a sentinel row + * with {@code cve_id = ''} so that the scan timestamp is always recorded. + * + * @since 2.1.0 + */ +public final class VulnerabilityDao { + + /** + * Sentinel value used as {@code cve_id} when a scan found no vulnerabilities. + * Allows the scan timestamp to be persisted even for clean artifacts. + */ + private static final String SENTINEL = ""; + + /** + * Database data source. + */ + private final DataSource dataSource; + + /** + * Ctor. + * @param dataSource JDBC data source + */ + public VulnerabilityDao(final DataSource dataSource) { + this.dataSource = dataSource; + } + + /** + * Upsert a vulnerability report. + * + *

Deletes all existing rows for {@code (repo_name, artifact_path)}, then + * inserts one row per finding. If there are no findings a single sentinel row + * ({@code cve_id = ''}) is inserted so the scan timestamp is preserved. + * + * @param report Report to persist + */ + public void upsert(final VulnerabilityReport report) { + final String delete = + "DELETE FROM vulnerability_findings WHERE repo_name = ? AND artifact_path = ?"; + final String insert = String.join(" ", + "INSERT INTO vulnerability_findings", + "(repo_name, artifact_path, scanned_at, scanner, cve_id,", + " severity, package_name, installed_version, fixed_version, title)", + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + try (Connection conn = this.dataSource.getConnection()) { + conn.setAutoCommit(false); + try { + try (PreparedStatement ps = conn.prepareStatement(delete)) { + ps.setString(1, report.repoName()); + ps.setString(2, report.artifactPath()); + ps.executeUpdate(); + } + try (PreparedStatement ps = conn.prepareStatement(insert)) { + final Timestamp ts = Timestamp.from(report.scannedAt()); + if (report.findings().isEmpty()) { + // Sentinel row — records that this artifact was scanned (with no findings) + ps.setString(1, report.repoName()); + ps.setString(2, report.artifactPath()); + ps.setTimestamp(3, ts); + ps.setString(4, report.scanner()); + ps.setString(5, SENTINEL); + ps.setString(6, "UNKNOWN"); + ps.setString(7, ""); + ps.setString(8, ""); + ps.setString(9, ""); + ps.setString(10, ""); + ps.executeUpdate(); + } else { + for (final VulnerabilityFinding f : report.findings()) { + ps.setString(1, report.repoName()); + ps.setString(2, report.artifactPath()); + ps.setTimestamp(3, ts); + ps.setString(4, report.scanner()); + ps.setString(5, f.cveId()); + ps.setString(6, f.severity()); + ps.setString(7, f.packageName()); + ps.setString(8, f.installedVersion()); + ps.setString(9, f.fixedVersion()); + ps.setString(10, f.title()); + ps.addBatch(); + } + ps.executeBatch(); + } + } + conn.commit(); + } catch (final SQLException ex) { + conn.rollback(); + throw ex; + } finally { + conn.setAutoCommit(true); + } + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to upsert vulnerability report", ex); + } + } + + /** + * Find the cached scan report for a specific artifact. + * Returns empty if the artifact has never been scanned. + * @param repoName Repository name + * @param artifactPath Artifact path + * @return Optional report, empty if never scanned + */ + public Optional findByArtifact( + final String repoName, final String artifactPath + ) { + final String sql = String.join(" ", + "SELECT scanned_at, scanner, cve_id, severity,", + " package_name, installed_version, fixed_version, title", + "FROM vulnerability_findings", + "WHERE repo_name = ? AND artifact_path = ?", + "ORDER BY scanned_at DESC" + ); + try (Connection conn = this.dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, repoName); + ps.setString(2, artifactPath); + try (ResultSet rs = ps.executeQuery()) { + Instant scannedAt = null; + String scanner = "trivy"; + final List findings = new ArrayList<>(); + while (rs.next()) { + if (scannedAt == null) { + scannedAt = rs.getTimestamp("scanned_at").toInstant(); + scanner = rs.getString("scanner"); + } + final String cveId = rs.getString("cve_id"); + if (!SENTINEL.equals(cveId)) { + findings.add(readFinding(rs, cveId)); + } + } + if (scannedAt == null) { + return Optional.empty(); + } + return Optional.of( + new VulnerabilityReport(repoName, artifactPath, scannedAt, scanner, findings) + ); + } + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to find vulnerability report", ex); + } + } + + /** + * Find all cached reports for a given repository, ordered by severity descending. + * @param repoName Repository name + * @return List of per-artifact reports (may be empty) + */ + public List findByRepo(final String repoName) { + final String sql = String.join(" ", + "SELECT artifact_path, scanner, scanned_at, cve_id, severity,", + " package_name, installed_version, fixed_version, title", + "FROM vulnerability_findings", + "WHERE repo_name = ?", + "ORDER BY artifact_path, scanned_at DESC" + ); + // Use LinkedHashMap to preserve artifact_path insertion order while grouping + final Map> grouped = new LinkedHashMap<>(); + try (Connection conn = this.dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, repoName); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + final String artifactPath = rs.getString("artifact_path"); + grouped.computeIfAbsent(artifactPath, k -> new ArrayList<>()) + .add(new Object[]{ + rs.getTimestamp("scanned_at").toInstant(), + rs.getString("scanner"), + rs.getString("cve_id"), + rs.getString("severity"), + rs.getString("package_name"), + rs.getString("installed_version"), + rs.getString("fixed_version"), + rs.getString("title") + }); + } + } + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to list vulnerability reports for repo", ex); + } + final List reports = new ArrayList<>(grouped.size()); + for (final Map.Entry> entry : grouped.entrySet()) { + final String artifactPath = entry.getKey(); + final List rows = entry.getValue(); + final Instant scannedAt = (Instant) rows.get(0)[0]; + final String scanner = (String) rows.get(0)[1]; + final List findings = new ArrayList<>(); + for (final Object[] row : rows) { + final String cveId = (String) row[2]; + if (!SENTINEL.equals(cveId)) { + findings.add(new VulnerabilityFinding( + cveId, + (String) row[3], + (String) row[4], + (String) row[5], + (String) row[6], + (String) row[7] + )); + } + } + reports.add( + new VulnerabilityReport(repoName, artifactPath, scannedAt, scanner, findings) + ); + } + // Sort by critical count descending (mirrors old vulnerability_reports ORDER BY) + reports.sort((a, b) -> { + final int diff = b.critical() - a.critical(); + return diff != 0 ? diff : b.high() - a.high(); + }); + return reports; + } + + /** + * Return a cross-repository summary: one row per repository. + * @return List of per-repo summary objects + */ + public List summarizeAll() { + final String sql = String.join(" ", + "SELECT repo_name,", + " COUNT(DISTINCT artifact_path) AS scanned_artifacts,", + " COUNT(*) FILTER (WHERE cve_id != '') AS vuln_count,", + " COUNT(*) FILTER (WHERE severity = 'CRITICAL') AS critical,", + " COUNT(*) FILTER (WHERE severity = 'HIGH') AS high,", + " COUNT(*) FILTER (WHERE severity = 'MEDIUM') AS medium,", + " COUNT(*) FILTER (WHERE severity = 'LOW') AS low,", + " COUNT(*) FILTER (WHERE severity = 'UNKNOWN'", + " AND cve_id != '') AS unknown,", + " MAX(scanned_at) AS last_scanned", + "FROM vulnerability_findings", + "GROUP BY repo_name", + "ORDER BY COUNT(*) FILTER (WHERE severity = 'CRITICAL') DESC,", + " COUNT(*) FILTER (WHERE severity = 'HIGH') DESC,", + " repo_name" + ); + final List result = new ArrayList<>(); + try (Connection conn = this.dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + final Timestamp lastScanned = rs.getTimestamp("last_scanned"); + result.add(new JsonObject() + .put("repo_name", rs.getString("repo_name")) + .put("scanned_artifacts", rs.getLong("scanned_artifacts")) + .put("vuln_count", rs.getLong("vuln_count")) + .put("critical", rs.getLong("critical")) + .put("high", rs.getLong("high")) + .put("medium", rs.getLong("medium")) + .put("low", rs.getLong("low")) + .put("unknown", rs.getLong("unknown")) + .put("last_scanned", + lastScanned != null ? lastScanned.toInstant().toString() : null) + ); + } + } + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to summarize vulnerability reports", ex); + } + return result; + } + + /** + * Count total CRITICAL findings across all repositories (excludes sentinels). + * @return Total critical count + */ + public long countTotalCritical() { + final String sql = + "SELECT COUNT(*) FROM vulnerability_findings WHERE severity = 'CRITICAL'"; + try (Connection conn = this.dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getLong(1) : 0L; + } + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to count critical vulnerabilities", ex); + } + } + + /** + * Retrieve paginated findings with optional search, filters, and sort. + * Sentinel rows (cve_id = '') are excluded from results. + * + * @param offset Row offset + * @param limit Max rows + * @param search Optional search term (filters cve_id, package_name, repo_name) + * @param repo Optional exact repo_name filter + * @param severity Optional exact severity filter + * @param sortBy Column to sort by (allowlisted) + * @param sortAsc True for ascending order + * @return List of finding JSON objects + * @checkstyle ParameterNumberCheck (5 lines) + */ + public List findAllFindingsPaginated( + final int offset, final int limit, + final String search, final String repo, final String severity, + final String sortBy, final boolean sortAsc + ) { + final java.util.Set sortable = java.util.Set.of( + "cve_id", "severity", "package_name", "repo_name", "scanned_at" + ); + final String col = sortable.contains(sortBy) ? sortBy : "scanned_at"; + final String dir = sortAsc ? "ASC" : "DESC"; + final boolean hasSearch = search != null && !search.isBlank(); + final boolean hasRepo = repo != null && !repo.isBlank(); + final boolean hasSeverity = severity != null && !severity.isBlank(); + final StringBuilder where = new StringBuilder("WHERE cve_id != ''"); + if (hasSearch) { + where.append(" AND (cve_id ILIKE ? OR package_name ILIKE ? OR repo_name ILIKE ?)"); + } + if (hasRepo) { + where.append(" AND repo_name = ?"); + } + if (hasSeverity) { + where.append(" AND severity = ?"); + } + final String sql = String.join(" ", + "SELECT repo_name, artifact_path, scanned_at, scanner, cve_id, severity,", + " package_name, installed_version, fixed_version, title", + "FROM vulnerability_findings" + ) + " " + where + " ORDER BY " + col + " " + dir + " LIMIT ? OFFSET ?"; + final List result = new ArrayList<>(); + try (Connection conn = this.dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + int idx = 1; + if (hasSearch) { + final String pattern = "%" + search.trim() + "%"; + ps.setString(idx++, pattern); + ps.setString(idx++, pattern); + ps.setString(idx++, pattern); + } + if (hasRepo) { + ps.setString(idx++, repo.trim()); + } + if (hasSeverity) { + ps.setString(idx++, severity.trim().toUpperCase(java.util.Locale.US)); + } + ps.setInt(idx++, limit); + ps.setInt(idx, offset); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + final Timestamp scannedAt = rs.getTimestamp("scanned_at"); + result.add(new JsonObject() + .put("repo_name", rs.getString("repo_name")) + .put("artifact_path", rs.getString("artifact_path")) + .put("scanned_at", + scannedAt != null ? scannedAt.toInstant().toString() : null) + .put("scanner", rs.getString("scanner")) + .put("cve_id", rs.getString("cve_id")) + .put("severity", rs.getString("severity")) + .put("package_name", rs.getString("package_name")) + .put("installed_version", rs.getString("installed_version")) + .put("fixed_version", rs.getString("fixed_version")) + .put("title", rs.getString("title")) + ); + } + } + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to query findings", ex); + } + return result; + } + + /** + * Count total findings with optional search and filters (excludes sentinels). + * @param search Optional search term + * @param repo Optional exact repo_name filter + * @param severity Optional exact severity filter + * @return Total count + */ + public long countAllFindings(final String search, final String repo, final String severity) { + final boolean hasSearch = search != null && !search.isBlank(); + final boolean hasRepo = repo != null && !repo.isBlank(); + final boolean hasSeverity = severity != null && !severity.isBlank(); + final StringBuilder where = new StringBuilder("WHERE cve_id != ''"); + if (hasSearch) { + where.append(" AND (cve_id ILIKE ? OR package_name ILIKE ? OR repo_name ILIKE ?)"); + } + if (hasRepo) { + where.append(" AND repo_name = ?"); + } + if (hasSeverity) { + where.append(" AND severity = ?"); + } + final String sql = "SELECT COUNT(*) FROM vulnerability_findings " + where; + try (Connection conn = this.dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + int idx = 1; + if (hasSearch) { + final String pattern = "%" + search.trim() + "%"; + ps.setString(idx++, pattern); + ps.setString(idx++, pattern); + ps.setString(idx++, pattern); + } + if (hasRepo) { + ps.setString(idx++, repo.trim()); + } + if (hasSeverity) { + ps.setString(idx++, severity.trim().toUpperCase(java.util.Locale.US)); + } + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getLong(1) : 0L; + } + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to count findings", ex); + } + } + + /** + * Delete all vulnerability findings (both real findings and sentinels). + * Used by the admin cleanup endpoint. + */ + public void deleteAll() { + try (Connection conn = this.dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement( + "DELETE FROM vulnerability_findings" + )) { + ps.executeUpdate(); + } catch (final SQLException ex) { + throw new IllegalStateException("Failed to delete all vulnerability findings", ex); + } + } + + /** + * Reconstruct a {@link VulnerabilityFinding} from a {@link ResultSet} row. + * @param rs Result set positioned on a row + * @param cveId Already-read CVE ID value + * @return Finding object + * @throws SQLException on DB error + */ + private static VulnerabilityFinding readFinding( + final ResultSet rs, final String cveId + ) throws SQLException { + return new VulnerabilityFinding( + cveId, + rs.getString("severity"), + rs.getString("package_name"), + rs.getString("installed_version"), + rs.getString("fixed_version"), + rs.getString("title") + ); + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityFinding.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityFinding.java new file mode 100644 index 000000000..e34b5d69f --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityFinding.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import io.vertx.core.json.JsonObject; + +/** + * A single CVE / vulnerability finding from a scan. + * + * @since 2.1.0 + */ +public final class VulnerabilityFinding { + + /** + * CVE / advisory identifier, e.g. "CVE-2021-44228". + */ + private final String cveId; + + /** + * Severity level: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN. + */ + private final String severity; + + /** + * Name of the affected package / library. + */ + private final String packageName; + + /** + * Installed (vulnerable) version. + */ + private final String installedVersion; + + /** + * Fixed version, or empty string if no fix is available yet. + */ + private final String fixedVersion; + + /** + * Short title / summary of the vulnerability. + */ + private final String title; + + /** + * Ctor. + * @param cveId CVE identifier + * @param severity Severity string + * @param packageName Package name + * @param installedVersion Installed version + * @param fixedVersion Fixed version (may be empty) + * @param title Short title + * @checkstyle ParameterNumberCheck (5 lines) + */ + public VulnerabilityFinding(final String cveId, final String severity, + final String packageName, final String installedVersion, + final String fixedVersion, final String title) { + this.cveId = cveId; + this.severity = severity; + this.packageName = packageName; + this.installedVersion = installedVersion; + this.fixedVersion = fixedVersion; + this.title = title; + } + + /** + * CVE / advisory ID. + * @return CVE ID string + */ + public String cveId() { + return this.cveId; + } + + /** + * Severity string. + * @return Severity + */ + public String severity() { + return this.severity; + } + + /** + * Package name. + * @return Package name + */ + public String packageName() { + return this.packageName; + } + + /** + * Installed (vulnerable) version. + * @return Installed version + */ + public String installedVersion() { + return this.installedVersion; + } + + /** + * Version that fixes the vulnerability. + * @return Fixed version or empty string + */ + public String fixedVersion() { + return this.fixedVersion; + } + + /** + * Short human-readable title. + * @return Title + */ + public String title() { + return this.title; + } + + /** + * Serialize this finding to a Vert.x {@link JsonObject}. + * @return JSON representation + */ + public JsonObject toJson() { + return new JsonObject() + .put("cve_id", this.cveId) + .put("severity", this.severity) + .put("package_name", this.packageName) + .put("installed_version", this.installedVersion) + .put("fixed_version", this.fixedVersion) + .put("title", this.title); + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityReport.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityReport.java new file mode 100644 index 000000000..49fd01ba2 --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityReport.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import java.time.Instant; +import java.util.List; + +/** + * Full vulnerability scan report for a single artifact. + * Contains aggregated severity counts and the individual findings list. + * + * @since 2.1.0 + */ +public final class VulnerabilityReport { + + /** + * Repository name. + */ + private final String repoName; + + /** + * Artifact path inside the repository. + */ + private final String artifactPath; + + /** + * When the scan was executed. + */ + private final Instant scannedAt; + + /** + * Scanner name (always "trivy"). + */ + private final String scanner; + + /** + * Individual CVE findings. + */ + private final List findings; + + /** + * Ctor. + * @param repoName Repository name + * @param artifactPath Artifact path + * @param scannedAt Scan timestamp + * @param scanner Scanner identifier + * @param findings Individual findings + * @checkstyle ParameterNumberCheck (5 lines) + */ + public VulnerabilityReport(final String repoName, final String artifactPath, + final Instant scannedAt, final String scanner, + final List findings) { + this.repoName = repoName; + this.artifactPath = artifactPath; + this.scannedAt = scannedAt; + this.scanner = scanner; + this.findings = List.copyOf(findings); + } + + /** + * Repository name. + * @return Repo name + */ + public String repoName() { + return this.repoName; + } + + /** + * Artifact path. + * @return Path + */ + public String artifactPath() { + return this.artifactPath; + } + + /** + * Scan timestamp. + * @return Instant + */ + public Instant scannedAt() { + return this.scannedAt; + } + + /** + * Scanner identifier. + * @return Scanner name + */ + public String scanner() { + return this.scanner; + } + + /** + * All individual CVE findings. + * @return Unmodifiable list of findings + */ + public List findings() { + return this.findings; + } + + /** + * Total vulnerability count. + * @return Count + */ + public int vulnCount() { + return this.findings.size(); + } + + /** + * Count of CRITICAL findings. + * @return Count + */ + public int critical() { + return countBySeverity("CRITICAL"); + } + + /** + * Count of HIGH findings. + * @return Count + */ + public int high() { + return countBySeverity("HIGH"); + } + + /** + * Count of MEDIUM findings. + * @return Count + */ + public int medium() { + return countBySeverity("MEDIUM"); + } + + /** + * Count of LOW findings. + * @return Count + */ + public int low() { + return countBySeverity("LOW"); + } + + /** + * Count of UNKNOWN severity findings. + * @return Count + */ + public int unknown() { + return countBySeverity("UNKNOWN"); + } + + /** + * Serialize the findings list to a Vert.x {@link JsonArray}. + * @return JSON array of findings + */ + public JsonArray findingsJson() { + final JsonArray arr = new JsonArray(); + for (final VulnerabilityFinding f : this.findings) { + arr.add(f.toJson()); + } + return arr; + } + + /** + * Serialize the full report to a Vert.x {@link JsonObject} for the API response. + * @param cacheTtlHours Cache TTL to compute {@code is_stale} + * @return JSON representation + */ + public JsonObject toJson(final int cacheTtlHours) { + final boolean stale = this.scannedAt + .plusSeconds((long) cacheTtlHours * 3600) + .isBefore(Instant.now()); + return new JsonObject() + .put("repo_name", this.repoName) + .put("artifact_path", this.artifactPath) + .put("scanned_at", this.scannedAt.toString()) + .put("scanner", this.scanner) + .put("is_stale", stale) + .put("vuln_count", this.vulnCount()) + .put("critical", this.critical()) + .put("high", this.high()) + .put("medium", this.medium()) + .put("low", this.low()) + .put("unknown", this.unknown()) + .put("findings", this.findingsJson()); + } + + private int countBySeverity(final String sev) { + return (int) this.findings.stream() + .filter(f -> sev.equalsIgnoreCase(f.severity())) + .count(); + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityScanJob.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityScanJob.java new file mode 100644 index 000000000..cad7f9399 --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityScanJob.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import com.auto1.pantera.api.RepositoryName; +import com.auto1.pantera.asto.Key; +import com.auto1.pantera.http.log.EcsLogger; +import com.auto1.pantera.scheduling.JobDataRegistry; +import com.auto1.pantera.scheduling.QuartzJob; +import com.auto1.pantera.settings.RepoData; +import com.auto1.pantera.settings.repo.CrudRepoSettings; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; +import org.quartz.JobExecutionContext; + +/** + * Quartz job that periodically scans all repositories for vulnerability findings. + * + *

Scheduled using the cron expression from {@code vulnerability.cron} in + * {@code pantera.yml}. Only active when vulnerability scanning is enabled and + * a cron expression is configured. + * + *

Already-fresh reports (within {@code cache_ttl_hours}) are skipped so + * the cron schedule and the TTL can be tuned independently. + * + *

Non-serializable dependencies are stored in {@link JobDataRegistry} + * (required for Quartz JDBC/clustered mode where {@code JobDataMap} values + * must be serializable). + * + * @since 2.2.0 + */ +public final class VulnerabilityScanJob extends QuartzJob { + + /** + * {@link JobDataRegistry} key for the {@link VulnerabilityScanner}. + */ + public static final String KEY_SCANNER = "vuln-scan-job-scanner"; + + /** + * {@link JobDataRegistry} key for the {@link VulnerabilityDao}. + */ + public static final String KEY_DAO = "vuln-scan-job-dao"; + + /** + * {@link JobDataRegistry} key for the {@link CrudRepoSettings}. + */ + public static final String KEY_CRS = "vuln-scan-job-crs"; + + /** + * {@link JobDataRegistry} key for the {@link RepoData}. + */ + public static final String KEY_REPO_DATA = "vuln-scan-job-repodata"; + + /** + * {@link JobDataRegistry} key for the {@link VulnerabilitySettings}. + */ + public static final String KEY_SETTINGS = "vuln-scan-job-settings"; + + @Override + @SuppressWarnings("PMD.AvoidCatchingGenericException") + public void execute(final JobExecutionContext ctx) { + final VulnerabilityScanner scanner = JobDataRegistry.lookup(KEY_SCANNER); + final VulnerabilityDao dao = JobDataRegistry.lookup(KEY_DAO); + final CrudRepoSettings crs = JobDataRegistry.lookup(KEY_CRS); + final RepoData repoData = JobDataRegistry.lookup(KEY_REPO_DATA); + final VulnerabilitySettings vsettings = JobDataRegistry.lookup(KEY_SETTINGS); + if (scanner == null || crs == null || repoData == null || vsettings == null) { + EcsLogger.error("com.auto1.pantera.vuln") + .message("VulnerabilityScanJob missing required dependencies — stopping job") + .eventCategory("security") + .eventAction("scheduled_scan") + .eventOutcome("failure") + .log(); + this.stopJob(ctx); + return; + } + final Collection repoNames = crs.listAll(); + EcsLogger.info("com.auto1.pantera.vuln") + .message("Scheduled vulnerability scan starting") + .eventCategory("security") + .eventAction("scheduled_scan") + .field("repo_count", repoNames.size()) + .log(); + final Semaphore sem = new Semaphore(vsettings.scanConcurrency()); + final AtomicInteger done = new AtomicInteger(0); + final AtomicInteger skipped = new AtomicInteger(0); + final AtomicInteger failed = new AtomicInteger(0); + final List> futures = new ArrayList<>(); + for (final String repoName : repoNames) { + try { + final RepositoryName rname = new RepositoryName.Simple(repoName); + final com.auto1.pantera.asto.Storage repoStorage = + repoData.repoStorage(rname, crs).toCompletableFuture().join(); + final List artifactPaths = repoStorage + .list(new Key.From(repoName)) + .thenApply(keys -> { + final String prefix = repoName + "/"; + return keys.stream() + .map(Key::string) + .map(s -> s.startsWith(prefix) ? s.substring(prefix.length()) : s) + .filter(s -> !s.isBlank()) + .toList(); + }) + .join(); + for (final String artifactPath : artifactPaths) { + if (isFresh(dao, repoName, artifactPath, vsettings)) { + skipped.incrementAndGet(); + continue; + } + try { + sem.acquire(); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + break; + } + final CompletableFuture fut = scanner + .scan(repoName, artifactPath, repoStorage) + .thenAccept(report -> { + if (dao != null) { + dao.upsert(report); + } + done.incrementAndGet(); + }) + .exceptionally(err -> { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Scheduled scan: artifact failed") + .field("repo_name", repoName) + .field("artifact_path", artifactPath) + .error(err) + .log(); + failed.incrementAndGet(); + return null; + }) + .whenComplete((v, t) -> sem.release()); + futures.add(fut); + } + } catch (final Exception ex) { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Scheduled scan: failed to list artifacts for repo") + .field("repo_name", repoName) + .error(ex) + .log(); + } + } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + EcsLogger.info("com.auto1.pantera.vuln") + .message("Scheduled vulnerability scan complete") + .eventCategory("security") + .eventAction("scheduled_scan") + .eventOutcome("success") + .field("done", done.get()) + .field("skipped", skipped.get()) + .field("failed", failed.get()) + .log(); + } + + /** + * Check whether the cached scan for this artifact is still fresh. + * @param dao DAO instance (nullable — returns false if null) + * @param repoName Repository name + * @param artifactPath Artifact path + * @param vsettings Vulnerability settings (for TTL) + * @return True if a fresh (non-stale) report exists + */ + private static boolean isFresh( + final VulnerabilityDao dao, + final String repoName, + final String artifactPath, + final VulnerabilitySettings vsettings + ) { + if (dao == null) { + return false; + } + return dao.findByArtifact(repoName, artifactPath) + .map(report -> !report.toJson(vsettings.cacheTtlHours()).getBoolean("is_stale", true)) + .orElse(false); + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityScanner.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityScanner.java new file mode 100644 index 000000000..ecb684a75 --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilityScanner.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import com.auto1.pantera.asto.Storage; +import java.util.concurrent.CompletableFuture; + +/** + * Vulnerability scanner abstraction. + * Implementations download the artifact from the provided storage, run a + * local scanner (e.g. Trivy CLI), and return the resulting report. + * + * @since 2.1.0 + */ +public interface VulnerabilityScanner { + + /** + * Scan an artifact and return a vulnerability report. + * + *

Implementations MUST: + *

    + *
  • Download the artifact to a local temporary file when needed.
  • + *
  • Clean up the temporary file after scanning.
  • + *
  • Not block the calling thread — wrap blocking work in a separate executor.
  • + *
+ * + * @param repoName Repository name (for the report) + * @param artifactPath Artifact path within the repository + * @param storage Storage backend from which to read the artifact + * @return Future resolving to a {@link VulnerabilityReport} + */ + CompletableFuture scan( + String repoName, String artifactPath, Storage storage + ); + + /** + * No-op implementation used when vulnerability scanning is disabled. + */ + VulnerabilityScanner NOP = (repoName, artifactPath, storage) -> + CompletableFuture.completedFuture( + new VulnerabilityReport( + repoName, + artifactPath, + java.time.Instant.now(), + "nop", + java.util.List.of() + ) + ); +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilitySettings.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilitySettings.java new file mode 100644 index 000000000..4a3c5bb6c --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/VulnerabilitySettings.java @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln; + +import com.amihaiemil.eoyaml.YamlMapping; + +/** + * Vulnerability scanning configuration. + * + *

Read from the {@code vulnerability} section of {@code pantera.yml}: + *

+ * meta:
+ *   vulnerability:
+ *     enabled: true
+ *     scanner_type: trivy           # backend to use (default: "trivy")
+ *     scanner_path: trivy           # path or binary name (default: "trivy")
+ *     cache_ttl_hours: 24           # hours before cached result is stale (default: 24)
+ *     scan_timeout_seconds: 300     # max seconds per scan subprocess (default: 300)
+ *     scan_concurrency: 4           # max parallel artifact scans per repo (default: 4)
+ *     max_global_concurrency: 8     # max concurrent scans across all repos (default: 8)
+ *     cron: "0 0 2 * * ?"          # cron schedule for automatic full scan (optional)
+ * 
+ * + * @since 2.1.0 + */ +public final class VulnerabilitySettings { + + /** + * YAML node name. + */ + private static final String NODE = "vulnerability"; + + /** + * Default scanner backend type. + */ + public static final String DEFAULT_SCANNER_TYPE = "trivy"; + + /** + * Default scanner binary name (expected on PATH). + */ + public static final String DEFAULT_SCANNER_PATH = "trivy"; + + /** + * Default cache TTL in hours (24 hours). + */ + public static final int DEFAULT_CACHE_TTL_HOURS = 24; + + /** + * Default scan subprocess timeout in seconds (5 minutes). + */ + public static final int DEFAULT_SCAN_TIMEOUT_SECONDS = 300; + + /** + * Default number of artifacts scanned concurrently during a repo scan-all. + */ + public static final int DEFAULT_SCAN_CONCURRENCY = 4; + + /** + * Default maximum concurrent scans across all repositories. + */ + public static final int DEFAULT_MAX_GLOBAL_CONCURRENCY = 8; + + /** + * Whether vulnerability scanning is enabled. + */ + private final boolean enabled; + + /** + * Scanner backend type identifier (e.g. "trivy", "grype"). + */ + private final String scannerType; + + /** + * Path to the scanner binary (name or full path). + */ + private final String scannerPath; + + /** + * Number of hours a cached scan result is considered fresh. + */ + private final int cacheTtlHours; + + /** + * Maximum seconds to wait for a single scanner subprocess. + */ + private final int scanTimeoutSeconds; + + /** + * Max number of artifacts scanned in parallel during a scan-all. + */ + private final int scanConcurrency; + + /** + * Max concurrent scans across ALL repositories at one time. + */ + private final int maxGlobalConcurrency; + + /** + * Quartz cron expression for scheduled full scans (null = no automatic scan). + */ + private final String cronExpression; + + /** + * Ctor. + * @param enabled Whether scanning is enabled + * @param scannerType Scanner backend type identifier + * @param scannerPath Path or name of the scanner binary + * @param cacheTtlHours Cache TTL in hours + * @param scanTimeoutSeconds Max subprocess wait time in seconds + * @param scanConcurrency Max parallel artifact scans during scan-all + * @param maxGlobalConcurrency Max concurrent scans across all repos + * @param cronExpression Quartz cron expression for scheduled scans, or null + * @checkstyle ParameterNumberCheck (10 lines) + */ + public VulnerabilitySettings( + final boolean enabled, final String scannerType, final String scannerPath, + final int cacheTtlHours, final int scanTimeoutSeconds, + final int scanConcurrency, final int maxGlobalConcurrency, + final String cronExpression + ) { + this.enabled = enabled; + this.scannerType = scannerType; + this.scannerPath = scannerPath; + this.cacheTtlHours = cacheTtlHours; + this.scanTimeoutSeconds = scanTimeoutSeconds; + this.scanConcurrency = scanConcurrency; + this.maxGlobalConcurrency = maxGlobalConcurrency; + this.cronExpression = cronExpression; + } + + /** + * Disabled settings (no-op defaults). + * @return Disabled settings instance + */ + public static VulnerabilitySettings disabled() { + return new VulnerabilitySettings( + false, + DEFAULT_SCANNER_TYPE, + DEFAULT_SCANNER_PATH, + DEFAULT_CACHE_TTL_HOURS, + DEFAULT_SCAN_TIMEOUT_SECONDS, + DEFAULT_SCAN_CONCURRENCY, + DEFAULT_MAX_GLOBAL_CONCURRENCY, + null + ); + } + + /** + * Parse settings from a YAML meta mapping. + * Returns {@link #disabled()} when the vulnerability node is absent. + * @param meta Meta section of pantera.yml + * @return Parsed settings + */ + public static VulnerabilitySettings fromMeta(final YamlMapping meta) { + if (meta == null) { + return disabled(); + } + final YamlMapping node = meta.yamlMapping(NODE); + if (node == null) { + return disabled(); + } + final boolean enabled = parseBool(node.string("enabled"), false); + final String scannerType = node.string("scanner_type") != null + ? node.string("scanner_type") : DEFAULT_SCANNER_TYPE; + final String scannerPath = node.string("scanner_path") != null + ? node.string("scanner_path") : DEFAULT_SCANNER_PATH; + final int ttl = parseInt(node.string("cache_ttl_hours"), DEFAULT_CACHE_TTL_HOURS); + final int timeout = parseInt( + node.string("scan_timeout_seconds"), DEFAULT_SCAN_TIMEOUT_SECONDS + ); + final int concurrency = parseInt( + node.string("scan_concurrency"), DEFAULT_SCAN_CONCURRENCY + ); + final int globalConcurrency = parseInt( + node.string("max_global_concurrency"), DEFAULT_MAX_GLOBAL_CONCURRENCY + ); + final String cron = node.string("cron"); + return new VulnerabilitySettings( + enabled, scannerType, scannerPath, ttl, timeout, + concurrency, globalConcurrency, + (cron != null && !cron.isBlank()) ? cron.trim() : null + ); + } + + /** + * Whether vulnerability scanning is enabled. + * @return True if enabled + */ + public boolean enabled() { + return this.enabled; + } + + /** + * Scanner backend type identifier. + * @return Scanner type (e.g. "trivy", "grype") + */ + public String scannerType() { + return this.scannerType; + } + + /** + * Path or binary name of the scanner CLI. + * @return Scanner path + */ + public String scannerPath() { + return this.scannerPath; + } + + /** + * Number of hours a cached scan result is considered fresh. + * @return Cache TTL hours + */ + public int cacheTtlHours() { + return this.cacheTtlHours; + } + + /** + * Maximum seconds to allow a single scanner subprocess to run. + * @return Timeout in seconds + */ + public int scanTimeoutSeconds() { + return this.scanTimeoutSeconds; + } + + /** + * Max number of artifacts scanned concurrently during a scan-all. + * @return Concurrency limit per repository + */ + public int scanConcurrency() { + return this.scanConcurrency; + } + + /** + * Max number of concurrent scans across all repositories at one time. + * @return Global concurrency limit + */ + public int maxGlobalConcurrency() { + return this.maxGlobalConcurrency; + } + + /** + * Quartz cron expression for automatic full scans, or null if not configured. + * @return Cron expression string, or null + */ + public String cronExpression() { + return this.cronExpression; + } + + private static boolean parseBool(final String value, final boolean fallback) { + if (value == null) { + return fallback; + } + final String normalized = value.trim().toLowerCase(java.util.Locale.US); + if ("true".equals(normalized) || "yes".equals(normalized) || "on".equals(normalized)) { + return true; + } + if ("false".equals(normalized) || "no".equals(normalized) || "off".equals(normalized)) { + return false; + } + return fallback; + } + + private static int parseInt(final String value, final int fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + try { + return Integer.parseInt(value.trim()); + } catch (final NumberFormatException ex) { + return fallback; + } + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/GrypeScannerBackend.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/GrypeScannerBackend.java new file mode 100644 index 000000000..d350fcd3b --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/GrypeScannerBackend.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.backend; + +import com.auto1.pantera.http.log.EcsLogger; +import com.auto1.pantera.vuln.ScannerBackend; +import com.auto1.pantera.vuln.VulnerabilityFinding; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * {@link ScannerBackend} backed by the Grype CLI. + * + *

Runs {@code grype dir: -o json --quiet} against the prepared + * scan directory and parses the JSON findings into {@link VulnerabilityFinding} objects. + * + *

Grype JSON output structure: + *

+ * {
+ *   "matches": [{
+ *     "vulnerability": {
+ *       "id": "CVE-2021-44228",
+ *       "severity": "Critical",
+ *       "description": "Apache Log4j2...",
+ *       "fix": { "versions": ["2.17.1"] }
+ *     },
+ *     "artifact": {
+ *       "name": "log4j-core",
+ *       "version": "2.14.1"
+ *     }
+ *   }]
+ * }
+ * 
+ * + * @since 2.2.0 + */ +public final class GrypeScannerBackend implements ScannerBackend { + + /** + * Scanner identifier written into scan reports. + */ + private static final String NAME = "grype"; + + /** + * Path or binary name of the Grype CLI. + */ + private final String scannerPath; + + /** + * Ctor. + * @param scannerPath Path or binary name for Grype (e.g. {@code "grype"} or + * {@code "/usr/local/bin/grype"}) + */ + public GrypeScannerBackend(final String scannerPath) { + this.scannerPath = scannerPath; + } + + @Override + public String name() { + return NAME; + } + + @Override + public List scan( + final Path scanDir, final int timeoutSeconds + ) throws IOException, InterruptedException { + final ProcessBuilder pb = new ProcessBuilder( + this.scannerPath, + String.format("dir:%s", scanDir.toAbsolutePath()), + "-o", "json", + "--quiet" + ); + pb.redirectErrorStream(false); + final Process proc = pb.start(); + // Drain stdout/stderr concurrently to prevent pipe-buffer deadlock on large output. + final java.util.concurrent.Future stdoutFuture = + java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor().submit( + () -> proc.getInputStream().readAllBytes() + ); + final java.util.concurrent.Future stderrFuture = + java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor().submit( + () -> proc.getErrorStream().readAllBytes() + ); + final boolean finished = proc.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + proc.destroyForcibly(); + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Grype scan timed out") + .eventCategory("security") + .eventAction("vulnerability_scan") + .eventOutcome("timeout") + .field("target", scanDir.toString()) + .field("timeout_seconds", timeoutSeconds) + .log(); + return List.of(); + } + final String stdout; + final String stderr; + try { + stdout = new String(stdoutFuture.get(), StandardCharsets.UTF_8); + stderr = new String(stderrFuture.get(), StandardCharsets.UTF_8); + } catch (final java.util.concurrent.ExecutionException ex) { + throw new IOException("Failed to read scanner output", ex); + } + if (proc.exitValue() != 0) { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Grype exited with non-zero code") + .eventCategory("security") + .eventAction("vulnerability_scan") + .eventOutcome("failure") + .field("exit_code", proc.exitValue()) + .field("stderr", stderr.length() > 500 ? stderr.substring(0, 500) : stderr) + .log(); + } + return stdout.isBlank() ? List.of() : parseFindings(stdout); + } + + /** + * Parse Grype JSON output into a list of findings. + * @param json Raw Grype stdout JSON + * @return Parsed findings (may be empty) + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private static List parseFindings(final String json) { + final List findings = new ArrayList<>(); + try { + final JsonObject root = new JsonObject(json); + final JsonArray matches = root.getJsonArray("matches"); + if (matches == null) { + return findings; + } + for (int i = 0; i < matches.size(); i++) { + final JsonObject match = matches.getJsonObject(i); + if (match == null) { + continue; + } + final JsonObject vuln = match.getJsonObject("vulnerability"); + final JsonObject artifact = match.getJsonObject("artifact"); + if (vuln == null || artifact == null) { + continue; + } + final String cveId = vuln.getString("id", "UNKNOWN"); + final String severity = normalizeSeverity(vuln.getString("severity", "UNKNOWN")); + final String pkgName = artifact.getString("name", ""); + final String installed = artifact.getString("version", ""); + final String fixed = extractFixedVersion(vuln.getJsonObject("fix")); + final String title = vuln.getString("description", ""); + findings.add(new VulnerabilityFinding( + cveId, severity, pkgName, installed, fixed, + title.length() > 500 ? title.substring(0, 500) : title + )); + } + } catch (final Exception ex) { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Failed to parse Grype JSON output") + .eventCategory("security") + .eventAction("vulnerability_parse") + .eventOutcome("failure") + .error(ex) + .log(); + } + return findings; + } + + /** + * Extract the first fixed version from the Grype fix object. + * @param fix The {@code vulnerability.fix} JSON object (may be null) + * @return First fixed version string, or empty string if unavailable + */ + private static String extractFixedVersion(final JsonObject fix) { + if (fix == null) { + return ""; + } + final JsonArray versions = fix.getJsonArray("versions"); + if (versions == null || versions.isEmpty()) { + return ""; + } + final String first = versions.getString(0); + return first != null ? first : ""; + } + + /** + * Normalize a raw severity string from Grype into one of the canonical values. + * Grype uses title-case (e.g. "Critical") while our canonical form is upper-case. + * @param raw Raw severity from Grype (e.g. "Critical", "HIGH") + * @return Normalized severity string + */ + private static String normalizeSeverity(final String raw) { + if (raw == null) { + return "UNKNOWN"; + } + return switch (raw.toUpperCase(Locale.US)) { + case "CRITICAL" -> "CRITICAL"; + case "HIGH" -> "HIGH"; + case "MEDIUM" -> "MEDIUM"; + case "LOW" -> "LOW"; + case "NEGLIGIBLE" -> "LOW"; + default -> "UNKNOWN"; + }; + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/ScannerBackendFactory.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/ScannerBackendFactory.java new file mode 100644 index 000000000..74babec05 --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/ScannerBackendFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.backend; + +import com.auto1.pantera.vuln.ScannerBackend; +import com.auto1.pantera.vuln.VulnerabilitySettings; + +/** + * Factory that selects the correct {@link ScannerBackend} based on + * the {@code scanner_type} field in {@link VulnerabilitySettings}. + * + *

Supported types: + *

    + *
  • {@code trivy} — {@link TrivyScannerBackend}
  • + *
  • {@code grype} — {@link GrypeScannerBackend}
  • + *
+ * + * @since 2.2.0 + */ +public final class ScannerBackendFactory { + + /** + * Private ctor — utility class, not instantiable. + */ + private ScannerBackendFactory() { + } + + /** + * Create the appropriate {@link ScannerBackend} for the given settings. + * @param settings Vulnerability settings containing {@code scanner_type} and + * {@code scanner_path} + * @return Matching backend implementation + * @throws IllegalArgumentException if {@code scanner_type} is not recognised + */ + public static ScannerBackend create(final VulnerabilitySettings settings) { + return switch (settings.scannerType()) { + case "trivy" -> new TrivyScannerBackend(settings.scannerPath()); + case "grype" -> new GrypeScannerBackend(settings.scannerPath()); + default -> throw new IllegalArgumentException( + String.format( + "Unknown scanner_type '%s'. Supported values: trivy, grype", + settings.scannerType() + ) + ); + }; + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/TrivyScannerBackend.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/TrivyScannerBackend.java new file mode 100644 index 000000000..f281cce2b --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/backend/TrivyScannerBackend.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.backend; + +import com.auto1.pantera.http.log.EcsLogger; +import com.auto1.pantera.vuln.ScannerBackend; +import com.auto1.pantera.vuln.VulnerabilityFinding; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * {@link ScannerBackend} backed by the Trivy CLI. + * + *

Runs {@code trivy fs --scanners vuln --format json --quiet} against the + * prepared scan directory and parses the JSON findings into + * {@link VulnerabilityFinding} objects. + * + *

To switch to a different scanner (Grype, OSV-Scanner, etc.), implement + * {@link ScannerBackend} and wire the new backend in {@code AsyncApiVerticle}. + * + * @since 2.2.0 + */ +public final class TrivyScannerBackend implements ScannerBackend { + + /** + * Scanner identifier written into scan reports. + */ + private static final String NAME = "trivy"; + + /** + * Path or binary name of the Trivy CLI. + */ + private final String scannerPath; + + /** + * Ctor. + * @param scannerPath Path or binary name for Trivy (e.g. {@code "trivy"} or + * {@code "/usr/local/bin/trivy"}) + */ + public TrivyScannerBackend(final String scannerPath) { + this.scannerPath = scannerPath; + } + + @Override + public String name() { + return NAME; + } + + @Override + public List scan( + final Path scanDir, final int timeoutSeconds + ) throws IOException, InterruptedException { + final ProcessBuilder pb = new ProcessBuilder( + this.scannerPath, + "fs", + "--scanners", "vuln", + "--format", "json", + "--quiet", + "--exit-code", "0", + scanDir.toAbsolutePath().toString() + ); + pb.redirectErrorStream(false); + final Process proc = pb.start(); + // Drain stdout and stderr in background threads to prevent pipe-buffer deadlock. + // If the scanner produces large output (e.g. 200KB+ of CVE JSON), the OS pipe + // buffer fills and the scanner process blocks waiting for Java to read — while + // Java is blocked in waitFor(). Draining concurrently breaks the deadlock. + final java.util.concurrent.Future stdoutFuture = + java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor().submit( + () -> proc.getInputStream().readAllBytes() + ); + final java.util.concurrent.Future stderrFuture = + java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor().submit( + () -> proc.getErrorStream().readAllBytes() + ); + final boolean finished = proc.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + proc.destroyForcibly(); + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Trivy scan timed out") + .eventCategory("security") + .eventAction("vulnerability_scan") + .eventOutcome("timeout") + .field("target", scanDir.toString()) + .field("timeout_seconds", timeoutSeconds) + .log(); + return List.of(); + } + final String stdout; + final String stderr; + try { + stdout = new String(stdoutFuture.get(), StandardCharsets.UTF_8); + stderr = new String(stderrFuture.get(), StandardCharsets.UTF_8); + } catch (final java.util.concurrent.ExecutionException ex) { + throw new IOException("Failed to read scanner output", ex); + } + if (proc.exitValue() != 0) { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Trivy exited with non-zero code") + .eventCategory("security") + .eventAction("vulnerability_scan") + .eventOutcome("failure") + .field("exit_code", proc.exitValue()) + .field("stderr", stderr.length() > 500 ? stderr.substring(0, 500) : stderr) + .log(); + } + return stdout.isBlank() ? List.of() : parseFindings(stdout); + } + + /** + * Parse Trivy JSON output into a list of findings. + * @param json Raw Trivy stdout JSON + * @return Parsed findings (may be empty) + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private static List parseFindings(final String json) { + final List findings = new ArrayList<>(); + try { + final JsonObject root = new JsonObject(json); + final JsonArray results = root.getJsonArray("Results"); + if (results == null) { + return findings; + } + for (int ri = 0; ri < results.size(); ri++) { + final JsonObject result = results.getJsonObject(ri); + if (result == null) { + continue; + } + final JsonArray vulns = result.getJsonArray("Vulnerabilities"); + if (vulns == null) { + continue; + } + for (int vi = 0; vi < vulns.size(); vi++) { + final JsonObject vuln = vulns.getJsonObject(vi); + if (vuln == null) { + continue; + } + findings.add(new VulnerabilityFinding( + vuln.getString("VulnerabilityID", "UNKNOWN"), + normalizeSeverity(vuln.getString("Severity", "UNKNOWN")), + vuln.getString("PkgName", ""), + vuln.getString("InstalledVersion", ""), + vuln.getString("FixedVersion", ""), + vuln.getString("Title", "") + )); + } + } + } catch (final Exception ex) { + EcsLogger.warn("com.auto1.pantera.vuln") + .message("Failed to parse Trivy JSON output") + .eventCategory("security") + .eventAction("vulnerability_parse") + .eventOutcome("failure") + .error(ex) + .log(); + } + return findings; + } + + /** + * Normalize a raw severity string from Trivy into one of the canonical values. + * @param raw Raw severity from Trivy (e.g. "CRITICAL", "medium") + * @return Normalized severity string + */ + private static String normalizeSeverity(final String raw) { + if (raw == null) { + return "UNKNOWN"; + } + return switch (raw.toUpperCase(Locale.US)) { + case "CRITICAL" -> "CRITICAL"; + case "HIGH" -> "HIGH"; + case "MEDIUM" -> "MEDIUM"; + case "LOW" -> "LOW"; + default -> "UNKNOWN"; + }; + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/ComposerPreparer.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/ComposerPreparer.java new file mode 100644 index 000000000..74721a318 --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/ComposerPreparer.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.preparer; + +import com.auto1.pantera.vuln.ArtifactPreparer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * {@link ArtifactPreparer} for PHP Composer packages ({@code .zip}). + * + *

Composer packages are distributed as zip archives containing the package + * source code. The preparer extracts {@code composer.json} and + * {@code composer.lock} (if present) from the archive for scanning. + * + *

Both Trivy and Grype recognise these files and can identify CVEs in + * declared PHP dependencies. + * + * @since 2.2.0 + */ +public final class ComposerPreparer implements ArtifactPreparer { + + /** + * PHP dependency manifest filenames recognised by Trivy and Grype. + */ + private static final Set MANIFESTS = Set.of( + "composer.json", "composer.lock" + ); + + @Override + public boolean supports(final String artifactPath) { + final String lower = artifactPath.toLowerCase(Locale.US); + // Composer archives are .zip files; exclude Go module zips (contain /@v/) + return lower.endsWith(".zip") && !lower.contains("/@v/"); + } + + @Override + public boolean prepare(final byte[] artifactBytes, final Path scanDir) throws IOException { + boolean found = false; + try ( + InputStream bais = new ByteArrayInputStream(artifactBytes); + ZipInputStream zis = new ZipInputStream(bais) + ) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + final String entryName = entry.getName(); + final String base = entryName.contains("/") + ? entryName.substring(entryName.lastIndexOf('/') + 1) + : entryName; + if (!MANIFESTS.contains(base)) { + continue; + } + // Path-traversal guard: write flat into scanDir only + final Path dest = scanDir.resolve(base).normalize(); + if (!dest.startsWith(scanDir)) { + continue; + } + Files.copy(zis, dest, StandardCopyOption.REPLACE_EXISTING); + found = true; + } + } + return found; + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/GoModulePreparer.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/GoModulePreparer.java new file mode 100644 index 000000000..752d4cd9e --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/GoModulePreparer.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.preparer; + +import com.auto1.pantera.vuln.ArtifactPreparer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * {@link ArtifactPreparer} for Go module artifacts. + * + *

Go module proxies store artifacts as: + *

    + *
  • {@code @v/vX.Y.Z.mod} — the go.mod file (dependency manifest)
  • + *
  • {@code @v/vX.Y.Z.zip} — source archive containing go.mod and go.sum
  • + *
+ * + *

For {@code .mod} files, the bytes are written directly as {@code go.mod}. + * For {@code .zip} archives, the preparer extracts {@code go.mod} and + * {@code go.sum} from inside the zip. + * + * @since 2.2.0 + */ +public final class GoModulePreparer implements ArtifactPreparer { + + /** + * Go dependency manifest filenames. + */ + private static final Set MANIFESTS = Set.of("go.mod", "go.sum"); + + @Override + public boolean supports(final String artifactPath) { + final String lower = artifactPath.toLowerCase(Locale.US); + return lower.endsWith(".mod") || (lower.endsWith(".zip") && lower.contains("/@v/")); + } + + @Override + public boolean prepare(final byte[] artifactBytes, final Path scanDir) throws IOException { + final String name = "detect"; + // .mod files are the go.mod content directly + if (artifactBytes.length > 0 && artifactBytes[0] != 'P') { + // Not a ZIP (ZIP magic = PK). Treat as raw go.mod content. + // go.mod files start with "module" keyword + final String start = new String( + artifactBytes, 0, Math.min(artifactBytes.length, 20), + java.nio.charset.StandardCharsets.UTF_8 + ); + if (start.startsWith("module ") || start.startsWith("//")) { + Files.write(scanDir.resolve("go.mod"), artifactBytes); + return true; + } + } + // ZIP archive: extract go.mod and go.sum + return extractFromZip(artifactBytes, scanDir); + } + + /** + * Extract go.mod and go.sum from a Go module zip archive. + * Go module zips have entries like {@code module@version/go.mod}. + */ + private static boolean extractFromZip( + final byte[] bytes, final Path destDir + ) throws IOException { + boolean found = false; + try ( + InputStream bais = new ByteArrayInputStream(bytes); + ZipInputStream zis = new ZipInputStream(bais) + ) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + final String entryName = entry.getName(); + final String base = entryName.contains("/") + ? entryName.substring(entryName.lastIndexOf('/') + 1) + : entryName; + if (!MANIFESTS.contains(base)) { + continue; + } + final Path dest = destDir.resolve(base).normalize(); + if (!dest.startsWith(destDir)) { + continue; + } + Files.copy(zis, dest, StandardCopyOption.REPLACE_EXISTING); + found = true; + } + } + return found; + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/MavenPomArtifactPreparer.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/MavenPomArtifactPreparer.java new file mode 100644 index 000000000..a53c41f46 --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/MavenPomArtifactPreparer.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.preparer; + +import com.auto1.pantera.vuln.ArtifactPreparer; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +/** + * {@link ArtifactPreparer} for Maven POM files ({@code .pom}). + * + *

Writes the POM bytes directly to {@code pom.xml} in the scan directory. + * Trivy recognises the file by name ({@code pom.xml}), not by the + * {@code .pom} extension used in Maven repositories. + * + *

No tarball extraction needed — the POM itself is the manifest. + * + * @since 2.2.0 + */ +public final class MavenPomArtifactPreparer implements ArtifactPreparer { + + @Override + public boolean supports(final String artifactPath) { + return artifactPath.toLowerCase(Locale.US).endsWith(".pom"); + } + + @Override + public boolean prepare(final byte[] artifactBytes, final Path scanDir) throws IOException { + Files.write(scanDir.resolve("pom.xml"), artifactBytes); + return true; + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/NpmArtifactPreparer.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/NpmArtifactPreparer.java new file mode 100644 index 000000000..34d39316a --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/NpmArtifactPreparer.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.preparer; + +import com.auto1.pantera.vuln.ArtifactPreparer; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; + +/** + * {@link ArtifactPreparer} for npm tarballs ({@code .tgz}). + * + *

Streams through the tarball (without writing a temp artifact file) and + * extracts the first matching dependency manifest — {@code package-lock.json}, + * {@code yarn.lock}, or {@code npm-shrinkwrap.json} — into the scan directory. + * That file is the only input Trivy (or any equivalent scanner) needs. + * + * @since 2.2.0 + */ +public final class NpmArtifactPreparer implements ArtifactPreparer { + + /** + * npm manifest filenames recognised by Trivy and Grype. + * Published npm packages contain only package.json (not lock files); + * lock files may exist when scanning a full project tarball. + */ + private static final Set MANIFESTS = Set.of( + "package-lock.json", "yarn.lock", "npm-shrinkwrap.json", "package.json" + ); + + @Override + public boolean supports(final String artifactPath) { + return artifactPath.toLowerCase(Locale.US).endsWith(".tgz"); + } + + @Override + public boolean prepare(final byte[] artifactBytes, final Path scanDir) throws IOException { + return extractFirstMatchingEntry(artifactBytes, scanDir, MANIFESTS).isPresent(); + } + + /** + * Stream through a gzipped tarball and copy the first entry whose base filename + * matches one of the given {@code names} into {@code destDir}. + * + *

The artifact bytes are read via a {@link ByteArrayInputStream} — no + * temporary artifact file is written to disk. + * + * @param bytes Artifact bytes + * @param destDir Flat destination directory for the extracted manifest + * @param names Set of base filenames to look for + * @return Base filename of the first match found, or empty + * @throws IOException On I/O failure + */ + static Optional extractFirstMatchingEntry( + final byte[] bytes, final Path destDir, final Set names + ) throws IOException { + try ( + InputStream bais = new ByteArrayInputStream(bytes); + GzipCompressorInputStream gzis = new GzipCompressorInputStream(bais); + TarArchiveInputStream tis = new TarArchiveInputStream(gzis) + ) { + TarArchiveEntry entry; + while ((entry = tis.getNextEntry()) != null) { + if (entry.isDirectory() || !tis.canReadEntryData(entry)) { + continue; + } + final String entryName = entry.getName(); + final String base = entryName.contains("/") + ? entryName.substring(entryName.lastIndexOf('/') + 1) + : entryName; + if (!names.contains(base)) { + continue; + } + // Path-traversal guard: write flat into destDir only + final Path dest = destDir.resolve(base).normalize(); + if (!dest.startsWith(destDir)) { + continue; + } + Files.copy(tis, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return Optional.of(base); + } + } + return Optional.empty(); + } +} diff --git a/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/PypiSdistArtifactPreparer.java b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/PypiSdistArtifactPreparer.java new file mode 100644 index 000000000..3509ee46c --- /dev/null +++ b/pantera-main/src/main/java/com/auto1/pantera/vuln/preparer/PypiSdistArtifactPreparer.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025-2026 Auto1 Group + * Maintainers: Auto1 DevOps Team + * Lead Maintainer: Ayd Asraf + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License v3.0. + * + * Originally based on Artipie (https://github.com/artipie/artipie), MIT License. + */ +package com.auto1.pantera.vuln.preparer; + +import com.auto1.pantera.vuln.ArtifactPreparer; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Set; + +/** + * {@link ArtifactPreparer} for PyPI source distributions ({@code .tar.gz}). + * + *

Streams through the sdist tarball and extracts the first recognised + * Python dependency manifest ({@code requirements.txt}, {@code pyproject.toml}, + * etc.) into the scan directory. + * + * @since 2.2.0 + */ +public final class PypiSdistArtifactPreparer implements ArtifactPreparer { + + /** + * Python dependency manifest filenames recognised by Trivy and equivalent scanners. + */ + private static final Set MANIFESTS = Set.of( + "requirements.txt", "requirements-dev.txt", "requirements-prod.txt", + "setup.cfg", "pyproject.toml", "Pipfile.lock", "poetry.lock" + ); + + @Override + public boolean supports(final String artifactPath) { + return artifactPath.toLowerCase(Locale.US).endsWith(".tar.gz"); + } + + @Override + public boolean prepare(final byte[] artifactBytes, final Path scanDir) throws IOException { + return NpmArtifactPreparer.extractFirstMatchingEntry( + artifactBytes, scanDir, MANIFESTS + ).isPresent(); + } +} diff --git a/pantera-main/src/main/resources/db/migration/V105__create_vulnerability_findings_table.sql b/pantera-main/src/main/resources/db/migration/V105__create_vulnerability_findings_table.sql new file mode 100644 index 000000000..3ce0cbe08 --- /dev/null +++ b/pantera-main/src/main/resources/db/migration/V105__create_vulnerability_findings_table.sql @@ -0,0 +1,38 @@ +-- V105__create_vulnerability_findings_table.sql +-- Stores vulnerability scan findings. One row per CVE finding per artifact. +-- Clean scans (no findings) insert a sentinel row with cve_id = '' to preserve +-- the scan timestamp. + +CREATE TABLE vulnerability_findings ( + id BIGSERIAL PRIMARY KEY, + repo_name VARCHAR(255) NOT NULL, + artifact_path TEXT NOT NULL, + scanned_at TIMESTAMPTZ NOT NULL, + scanner VARCHAR(50) NOT NULL DEFAULT 'trivy', + cve_id VARCHAR(100) NOT NULL DEFAULT '', + severity VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN', + package_name TEXT NOT NULL DEFAULT '', + installed_version TEXT NOT NULL DEFAULT '', + fixed_version TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '' +); + +-- Per-repo listing +CREATE INDEX idx_vf_repo + ON vulnerability_findings (repo_name); + +-- Per-artifact lookup (used by findByArtifact, upsert delete-before-insert) +CREATE INDEX idx_vf_repo_artifact + ON vulnerability_findings (repo_name, artifact_path); + +-- Severity-based filtering and sorting +CREATE INDEX idx_vf_severity + ON vulnerability_findings (severity, repo_name); + +-- CVE ID search +CREATE INDEX idx_vf_cve + ON vulnerability_findings (cve_id); + +-- Recency sorting +CREATE INDEX idx_vf_scanned_at + ON vulnerability_findings (scanned_at DESC); diff --git a/pantera-ui/src/api/vulns.ts b/pantera-ui/src/api/vulns.ts new file mode 100644 index 000000000..b0ba52b08 --- /dev/null +++ b/pantera-ui/src/api/vulns.ts @@ -0,0 +1,111 @@ +import { getApiClient } from './client' +import type { + PaginatedResponse, + VulnerabilitySummary, + VulnerabilityReport, + VulnerabilityFindingRow, +} from '@/types' + +/** + * GET /api/v1/vulnerabilities/summary + * Returns one aggregated row per repository. + */ +export async function getVulnerabilitySummary(): Promise { + const { data } = await getApiClient().get<{ items: VulnerabilitySummary[] }>( + '/vulnerabilities/summary', + ) + return data.items ?? [] +} + +/** + * GET /api/v1/vulnerabilities/findings + * Paginated list of individual CVE findings across all repositories. + */ +export async function getVulnerabilityFindings( + params: { + page?: number + size?: number + search?: string + repo?: string + severity?: string + sort_by?: string + sort_dir?: 'asc' | 'desc' + } = {}, + signal?: AbortSignal, +): Promise> { + const { data } = await getApiClient().get>( + '/vulnerabilities/findings', + { params, signal }, + ) + return data +} + +/** + * GET /api/v1/repositories/:name/vulnerabilities + * All cached scan reports for a specific repository. + */ +export async function getRepoVulnerabilities(name: string): Promise { + const { data } = await getApiClient().get<{ items: VulnerabilityReport[] }>( + `/repositories/${name}/vulnerabilities`, + ) + return data.items ?? [] +} + +/** + * GET /api/v1/repositories/:name/vulnerabilities/artifact?path=… + * Cached scan report for a specific artifact. + * Returns null when the artifact has not been scanned yet (404). + */ +export async function getArtifactVulnerabilities( + name: string, + path: string, +): Promise { + try { + const { data } = await getApiClient().get( + `/repositories/${name}/vulnerabilities/artifact`, + { params: { path } }, + ) + return data + } catch (err: unknown) { + if ((err as { response?: { status?: number } })?.response?.status === 404) { + return null + } + throw err + } +} + +/** + * POST /api/v1/repositories/:name/vulnerabilities/scan?path=… + * Trigger (or force-refresh) a vulnerability scan for a specific artifact. + * Returns the fresh scan report. + */ +export async function triggerVulnerabilityScan( + name: string, + path: string, +): Promise { + const { data } = await getApiClient().post( + `/repositories/${name}/vulnerabilities/scan`, + null, + { params: { path } }, + ) + return data +} + +export interface ScanAllResponse { + repo_name: string + enqueued: number + message: string +} + +/** + * POST /api/v1/repositories/:name/vulnerabilities/scan-all + * Enqueue a background scan for every artifact in the repository. + * Returns 202 immediately with { enqueued, message }. + * Poll getRepoVulnerabilities() or getVulnerabilitySummary() for progress. + */ +export async function triggerRepoScan(name: string): Promise { + const { data } = await getApiClient().post( + `/repositories/${name}/vulnerabilities/scan-all`, + ) + return data +} diff --git a/pantera-ui/src/components/layout/AppSidebar.vue b/pantera-ui/src/components/layout/AppSidebar.vue index 0a1d3e139..d0fb0de33 100644 --- a/pantera-ui/src/components/layout/AppSidebar.vue +++ b/pantera-ui/src/components/layout/AppSidebar.vue @@ -35,6 +35,9 @@ const userItems = computed(() => { if (canRead('api_cooldown_permissions')) { items.push({ label: 'Cooldown', icon: 'pi pi-clock', to: '/cooldown' }) } + if (canRead('api_repository_permissions')) { + items.push({ label: 'Vulnerabilities', icon: 'pi pi-shield', to: '/vulnerabilities' }) + } items.push({ label: 'Quick Setup', icon: 'pi pi-bolt', to: '/setup' }) return items }) diff --git a/pantera-ui/src/router/index.ts b/pantera-ui/src/router/index.ts index a6568b563..ed70fff60 100644 --- a/pantera-ui/src/router/index.ts +++ b/pantera-ui/src/router/index.ts @@ -109,6 +109,12 @@ export const routes: RouteRecordRaw[] = [ component: () => import('@/views/admin/CooldownView.vue'), meta: { requiredPermission: 'api_cooldown_permissions' }, }, + { + path: '/vulnerabilities', + name: 'vulnerabilities', + component: () => import('@/views/vulns/VulnerabilityView.vue'), + meta: { requiredPermission: 'api_repository_permissions' }, + }, { path: '/admin/settings', name: 'admin-settings', diff --git a/pantera-ui/src/types/index.ts b/pantera-ui/src/types/index.ts index d71a7b8ad..e63c1800a 100644 --- a/pantera-ui/src/types/index.ts +++ b/pantera-ui/src/types/index.ts @@ -234,6 +234,53 @@ export interface HealthResponse { status: string } +// Vulnerability scanning +export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN' + +export interface VulnerabilityFinding { + cve_id: string + severity: VulnSeverity + package_name: string + installed_version: string + fixed_version: string + title: string +} + +export interface VulnerabilityReport { + repo_name: string + artifact_path: string + scanned_at: string + scanner: string + is_stale: boolean + vuln_count: number + critical: number + high: number + medium: number + low: number + unknown: number + findings: VulnerabilityFinding[] +} + +export interface VulnerabilitySummary { + repo_name: string + scanned_artifacts: number + vuln_count: number + critical: number + high: number + medium: number + low: number + unknown: number + last_scanned: string | null +} + +// A single finding row as returned by the /findings paginated endpoint +// (includes repo_name, artifact_path, scanned_at in addition to finding fields) +export interface VulnerabilityFindingRow extends VulnerabilityFinding { + repo_name: string + artifact_path: string + scanned_at: string +} + // Runtime config (config.json) export interface RuntimeConfig { apiBaseUrl: string diff --git a/pantera-ui/src/views/dashboard/DashboardView.vue b/pantera-ui/src/views/dashboard/DashboardView.vue index beb2537b7..7847b8633 100644 --- a/pantera-ui/src/views/dashboard/DashboardView.vue +++ b/pantera-ui/src/views/dashboard/DashboardView.vue @@ -1,6 +1,7 @@ @@ -150,7 +172,7 @@ const statCards = computed(() => [ -

+
() const route = useRoute() @@ -56,6 +59,20 @@ const selectedArtifact = ref(null) const deleting = ref(false) const downloading = ref(false) +// Vulnerability state for the detail dialog +const vulnReport = ref(null) +const vulnLoading = ref(false) +const vulnScanning = ref(false) +const vulnNeverScanned = ref(false) +const canScan = auth.hasAction('api_repository_permissions', 'create') + +// Repo-level scan state +const repoScanning = ref(false) +const repoScanProgress = ref<{ enqueued: number } | null>(null) + +// Repo-level vulnerability summary +const repoVulnSummary = ref(null) + let treeAbortCtrl: AbortController | null = null let repoAbortCtrl: AbortController | null = null @@ -72,6 +89,10 @@ async function loadRepo() { try { repoConfig.value = await getRepo(props.name) if (ctrl.signal.aborted) return + // Load vulnerability summary for this repo + getVulnerabilitySummary().then(all => { + repoVulnSummary.value = all.find(s => s.repo_name === props.name) ?? null + }).catch(() => {}) // Group repos show members, not artifact tree if (!isGroup.value) { const qpath = route.query.path as string | undefined @@ -139,11 +160,90 @@ async function showArtifactDetail(path: string) { try { selectedArtifact.value = await getArtifactDetail(props.name, path) detailVisible.value = true + // Load vulnerability report in background (don't block dialog opening) + loadVulnReport(path) } catch { // ignore } } +async function loadVulnReport(path: string) { + vulnReport.value = null + vulnNeverScanned.value = false + vulnLoading.value = true + try { + const report = await getArtifactVulnerabilities(props.name, path) + if (report === null) { + vulnNeverScanned.value = true + } else { + vulnReport.value = report + } + } catch { + // Scanning might not be enabled — silently skip + } finally { + vulnLoading.value = false + } +} + +async function handleScanNow() { + if (!selectedArtifact.value) return + vulnScanning.value = true + try { + vulnReport.value = await triggerVulnerabilityScan(props.name, selectedArtifact.value.path) + vulnNeverScanned.value = false + notify.success('Scan complete', `${vulnReport.value.vuln_count} finding(s) found`) + } catch { + notify.error('Scan failed', 'Could not scan this artifact') + } finally { + vulnScanning.value = false + } +} + +async function handleScanRepo() { + if (repoScanning.value) return + repoScanning.value = true + repoScanProgress.value = null + try { + const resp = await triggerRepoScan(props.name) + repoScanProgress.value = { enqueued: resp.enqueued } + notify.info(`Scanning ${props.name}`, `${resp.enqueued} artifact(s) queued — results will appear in the Vulnerabilities page`) + } catch { + notify.error('Scan failed', `Could not start repository scan`) + } finally { + // Keep the spinner a few seconds so user sees feedback, then reset + setTimeout(() => { + repoScanning.value = false + repoScanProgress.value = null + }, 4000) + } +} + +function severityClass(sev: string): string { + switch (sev) { + case 'CRITICAL': return 'text-red-600 dark:text-red-400 font-bold' + case 'HIGH': return 'text-orange-500 dark:text-orange-400 font-semibold' + case 'MEDIUM': return 'text-yellow-500 dark:text-yellow-400' + default: return 'text-gray-400' + } +} + +function vulnSeverityTagType(sev: string): 'danger' | 'warn' | 'info' | 'secondary' { + switch (sev) { + case 'CRITICAL': return 'danger' + case 'HIGH': return 'warn' + case 'MEDIUM': return 'info' + default: return 'secondary' + } +} + +function formatScannedAt(iso: string): string { + const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + if (secs < 60) return 'just now' + if (secs < 3600) return `${Math.floor(secs / 60)}m ago` + if (secs < 86400) return `${Math.floor(secs / 3600)}h ago` + return `${Math.floor(secs / 86400)}d ago` +} + async function handleDeleteArtifact(path: string) { deleting.value = true try { @@ -220,9 +320,37 @@ function formatSize(bytes?: number): string {
-
-

{{ name }}

- +
+
+

{{ name }}

+ +
+
+ + +
+ + + {{ repoVulnSummary.scanned_artifacts }} artifact{{ repoVulnSummary.scanned_artifacts !== 1 ? 's' : '' }} scanned + · last {{ formatScannedAt(repoVulnSummary.last_scanned) }} + + + ✓ No vulnerabilities found
@@ -317,12 +445,17 @@ function formatSize(bytes?: number): string { - -
-
Path: {{ selectedArtifact.path }}
-
Size: {{ selectedArtifact.size > 0 ? formatSize(selectedArtifact.size) : '—' }}
-
Modified: {{ selectedArtifact.modified }}
-
+ +
+ +
+
Path: {{ selectedArtifact.path }}
+
Size: {{ selectedArtifact.size > 0 ? formatSize(selectedArtifact.size) : '—' }}
+
Modified: {{ selectedArtifact.modified }}
+
+ + +
+ + +
+
+
+ + + Security Vulnerabilities + + + +
+
+ + + ⚠ Stale + + + + Scanned {{ formatScannedAt(vulnReport.scanned_at) }} + + +
+
+ + +
+ Loading scan results... +
+ + +
+ + Not yet scanned. +
+ + + + + + + + + + + + + + + + +
diff --git a/pantera-ui/src/views/vulns/VulnerabilityView.vue b/pantera-ui/src/views/vulns/VulnerabilityView.vue new file mode 100644 index 000000000..092db5092 --- /dev/null +++ b/pantera-ui/src/views/vulns/VulnerabilityView.vue @@ -0,0 +1,260 @@ + + +