diff --git a/backend/apps/api/build.gradle.kts b/backend/apps/api/build.gradle.kts index 2d4da1d0..f1c13f33 100644 --- a/backend/apps/api/build.gradle.kts +++ b/backend/apps/api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { implementation(project(":libs:persistence")) implementation(project(":libs:github-client")) implementation(project(":libs:scanning")) + implementation(project(":libs:enrichment")) implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") diff --git a/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java b/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java index b5f0ddab..41794b2d 100644 --- a/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java +++ b/backend/apps/api/src/main/java/dev/cleat/api/CleatApiApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication( scanBasePackages = { @@ -11,10 +12,12 @@ "dev.cleat.persistence", "dev.cleat.domain", "dev.cleat.common", - "dev.cleat.scanning" + "dev.cleat.scanning", + "dev.cleat.enrichment" }) @EnableJpaRepositories(basePackages = "dev.cleat.persistence.repository") @EntityScan(basePackages = "dev.cleat.persistence.entity") +@EnableScheduling public class CleatApiApplication { public static void main(String[] args) { SpringApplication.run(CleatApiApplication.class); diff --git a/backend/apps/api/src/main/resources/application.yml b/backend/apps/api/src/main/resources/application.yml index 06bbefc3..7398d716 100644 --- a/backend/apps/api/src/main/resources/application.yml +++ b/backend/apps/api/src/main/resources/application.yml @@ -29,3 +29,7 @@ management: enabled: true redis: enabled: true + +feed: + sync: + delay: 3600000 diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java index 8ebbdb3b..66bf2374 100644 --- a/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/CleatWorkerApplication.java @@ -12,7 +12,8 @@ "dev.cleat.scanning", "dev.cleat.worker", "dev.cleat.domain", - "dev.cleat.common" + "dev.cleat.common", + "dev.cleat.enrichment" }) @EnableJpaRepositories(basePackages = "dev.cleat.persistence.repository") @EntityScan(basePackages = "dev.cleat.persistence.entity") diff --git a/backend/apps/worker/src/main/java/dev/cleat/worker/config/SchedulingConfig.java b/backend/apps/worker/src/main/java/dev/cleat/worker/config/SchedulingConfig.java new file mode 100644 index 00000000..981d5045 --- /dev/null +++ b/backend/apps/worker/src/main/java/dev/cleat/worker/config/SchedulingConfig.java @@ -0,0 +1,19 @@ +package dev.cleat.worker.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulingConfig { + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(4); + scheduler.setThreadNamePrefix("scheduler-"); + scheduler.initialize(); + return scheduler; + } +} diff --git a/backend/apps/worker/src/main/resources/application.yml b/backend/apps/worker/src/main/resources/application.yml index 2fc5ab65..cf8836dd 100644 --- a/backend/apps/worker/src/main/resources/application.yml +++ b/backend/apps/worker/src/main/resources/application.yml @@ -32,4 +32,8 @@ management: app: security: - scan-interval: 600000 \ No newline at end of file + scan-interval: 600000 + +feed: + sync: + delay: 3600000 \ No newline at end of file diff --git a/backend/libs/common/src/main/java/dev/cleat/common/dto/request/VulnerabilityRequestDto.java b/backend/libs/common/src/main/java/dev/cleat/common/dto/request/VulnerabilityRequestDto.java index ebb420fe..fb64f5dc 100644 --- a/backend/libs/common/src/main/java/dev/cleat/common/dto/request/VulnerabilityRequestDto.java +++ b/backend/libs/common/src/main/java/dev/cleat/common/dto/request/VulnerabilityRequestDto.java @@ -18,6 +18,7 @@ public class VulnerabilityRequestDto { private Reachable reachable; private UUID advisoryId; private String cwe; + private String cve; private String title; private List affectedRepos; private Boolean hasFixPr; @@ -36,6 +37,7 @@ public VulnerabilityRequestDto( Reachable reachable, UUID advisoryId, String cwe, + String cve, String title, List affectedRepos, Boolean hasFixPr) { @@ -51,6 +53,7 @@ public VulnerabilityRequestDto( this.reachable = reachable; this.advisoryId = advisoryId; this.cwe = cwe; + this.cve = cve; this.title = title; this.affectedRepos = affectedRepos; this.hasFixPr = hasFixPr; @@ -155,6 +158,15 @@ public VulnerabilityRequestDto setCwe(String cwe) { return this; } + public String getCve() { + return cve; + } + + public VulnerabilityRequestDto setCve(String cve) { + this.cve = cve; + return this; + } + public String getTitle() { return title; } diff --git a/backend/libs/common/src/main/java/dev/cleat/common/dto/response/VulnerabilityResponseDto.java b/backend/libs/common/src/main/java/dev/cleat/common/dto/response/VulnerabilityResponseDto.java index ed3f8b0b..a5c7c412 100644 --- a/backend/libs/common/src/main/java/dev/cleat/common/dto/response/VulnerabilityResponseDto.java +++ b/backend/libs/common/src/main/java/dev/cleat/common/dto/response/VulnerabilityResponseDto.java @@ -22,11 +22,55 @@ public class VulnerabilityResponseDto { private Reachable reachable; private UUID advisoryId; private String cwe; + private String cve; private String title; private List affectedRepos; private Boolean hasFixPr; private OffsetDateTime publishedAt; + public VulnerabilityResponseDto() {} + + public VulnerabilityResponseDto( + UUID id, + UUID accountId, + String packageName, + String ecosystem, + String currentVersion, + String fixedVersion, + Double cvss, + Severity severity, + Priority priority, + Double epss, + Boolean kev, + Reachable reachable, + UUID advisoryId, + String cwe, + String cve, + String title, + List affectedRepos, + Boolean hasFixPr, + OffsetDateTime publishedAt) { + this.id = id; + this.accountId = accountId; + this.packageName = packageName; + this.ecosystem = ecosystem; + this.currentVersion = currentVersion; + this.fixedVersion = fixedVersion; + this.cvss = cvss; + this.severity = severity; + this.priority = priority; + this.epss = epss; + this.kev = kev; + this.reachable = reachable; + this.advisoryId = advisoryId; + this.cwe = cwe; + this.cve = cve; + this.title = title; + this.affectedRepos = affectedRepos; + this.hasFixPr = hasFixPr; + this.publishedAt = publishedAt; + } + public UUID getId() { return id; } @@ -153,6 +197,15 @@ public VulnerabilityResponseDto setCwe(String cwe) { return this; } + public String getCve() { + return cve; + } + + public VulnerabilityResponseDto setCve(String cve) { + this.cve = cve; + return this; + } + public String getTitle() { return title; } diff --git a/backend/libs/common/src/main/java/dev/cleat/common/exception/FeedSyncException.java b/backend/libs/common/src/main/java/dev/cleat/common/exception/FeedSyncException.java new file mode 100644 index 00000000..ffc2fc6c --- /dev/null +++ b/backend/libs/common/src/main/java/dev/cleat/common/exception/FeedSyncException.java @@ -0,0 +1,11 @@ +package dev.cleat.common.exception; + +public class FeedSyncException extends RuntimeException { + public FeedSyncException(String msg) { + super(msg); + } + + public FeedSyncException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/backend/libs/domain/src/main/java/dev/cleat/domain/PriorityCalculator.java b/backend/libs/domain/src/main/java/dev/cleat/domain/PriorityCalculator.java index 95018fa3..3b8cac79 100644 --- a/backend/libs/domain/src/main/java/dev/cleat/domain/PriorityCalculator.java +++ b/backend/libs/domain/src/main/java/dev/cleat/domain/PriorityCalculator.java @@ -1,6 +1,8 @@ package dev.cleat.domain; import dev.cleat.common.enums.Priority; +import dev.cleat.common.enums.Reachable; +import dev.cleat.common.enums.Severity; import dev.cleat.domain.model.Vulnerability; import org.springframework.stereotype.Component; @@ -11,14 +13,18 @@ public Priority calculate(Vulnerability vulnerability) { if (vulnerability.cvss() < 0 || vulnerability.cvss() > 10) { throw new IllegalArgumentException("CVSS must be between 0 and 10"); } - if (vulnerability.kev() || vulnerability.cvss() >= 9.0) { + if (vulnerability.kev() || vulnerability.cvss() >= 9.0 && vulnerability.reachable() == Reachable.REACHABLE) { return Priority.URGENT; } - return switch (vulnerability.severity()) { - case CRITICAL -> Priority.HIGH; - case HIGH -> Priority.MEDIUM; - default -> Priority.LOW; - }; + if (vulnerability.epss() > 0.1 || vulnerability.severity() == Severity.CRITICAL) { + return Priority.HIGH; + } + + if (vulnerability.cvss() >= 7.0) { + return Priority.MEDIUM; + } + + return Priority.LOW; } } diff --git a/backend/libs/domain/src/main/java/dev/cleat/domain/model/Vulnerability.java b/backend/libs/domain/src/main/java/dev/cleat/domain/model/Vulnerability.java index ee40c004..b3291350 100644 --- a/backend/libs/domain/src/main/java/dev/cleat/domain/model/Vulnerability.java +++ b/backend/libs/domain/src/main/java/dev/cleat/domain/model/Vulnerability.java @@ -1,12 +1,16 @@ package dev.cleat.domain.model; +import dev.cleat.common.enums.Reachable; import dev.cleat.common.enums.Severity; import java.util.Objects; -public record Vulnerability(Boolean kev, Double cvss, Severity severity) { +public record Vulnerability(Boolean kev, Double cvss, Severity severity, Double epss, Reachable reachable) { public Vulnerability { Objects.requireNonNull(cvss, "cvss cannot be null"); Objects.requireNonNull(severity, "severity cannot be null"); + kev = (kev != null) ? kev : false; + epss = (epss != null) ? epss : 0.0; + reachable = (reachable != null) ? reachable : Reachable.UNKNOWN; } } diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/EpssClient.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/EpssClient.java new file mode 100644 index 00000000..e358e6bd --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/EpssClient.java @@ -0,0 +1,29 @@ +package dev.cleat.enrichment.client; + +import dev.cleat.enrichment.dto.EpssResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class EpssClient { + private final RestTemplate restTemplate; + + public EpssClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + private static final String URL = "https://api.first.org/data/v1/epss?cve={cve}"; + + public Double fetchScore(String cve) { + + EpssResponse response = restTemplate.getForObject(URL, EpssResponse.class, cve); + if (response == null || response.getData() == null || response.getData().isEmpty()) { + return null; + } + return response.getData().getFirst().getEpss(); + } + + public EpssResponse fetchFeed(String cve) { + return restTemplate.getForObject(URL, EpssResponse.class, cve); + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/KevClient.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/KevClient.java new file mode 100644 index 00000000..6628eb2b --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/KevClient.java @@ -0,0 +1,45 @@ +package dev.cleat.enrichment.client; + +import dev.cleat.enrichment.dto.KevResponse; +import dev.cleat.enrichment.dto.KevVulnerability; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class KevClient { + private final RestTemplate restTemplate; + private Set kevCache; + private Long cacheTimestamp; + private static final long CACHE_TTL_MS = 3600_000L; + + public KevClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + private static final String URL = + "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"; + + public synchronized Set getKevCache() { + + if (kevCache == null || System.currentTimeMillis() - cacheTimestamp > CACHE_TTL_MS) { + KevResponse response = restTemplate.getForObject(URL, KevResponse.class); + kevCache = (response == null || response.getVulnerabilities() == null) + ? Set.of() + : response.getVulnerabilities().stream() + .map(KevVulnerability::getCveId) + .collect(Collectors.toSet()); + cacheTimestamp = System.currentTimeMillis(); + } + return kevCache; + } + + public boolean isKev(String cve) { + return getKevCache().contains(cve); + } + + public KevResponse fetchFeed() { + return restTemplate.getForObject(URL, KevResponse.class); + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/OsvClient.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/OsvClient.java new file mode 100644 index 00000000..623b2c0e --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/client/OsvClient.java @@ -0,0 +1,26 @@ +package dev.cleat.enrichment.client; + +import dev.cleat.enrichment.dto.OsvPackage; +import dev.cleat.enrichment.dto.OsvRequest; +import dev.cleat.enrichment.dto.OsvResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class OsvClient { + private final RestTemplate restTemplate; + + public OsvClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + private static final String URL = "https://api.osv.dev/v1/query"; + + public OsvResponse query(String packageName, String ecosystem) { + OsvPackage osvPackage = new OsvPackage().setName(packageName).setEcosystem(ecosystem); + + OsvRequest osvRequest = new OsvRequest().setPkg(osvPackage); + + return restTemplate.postForObject(URL, osvRequest, OsvResponse.class); + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/config/RestTemplateConfig.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/config/RestTemplateConfig.java new file mode 100644 index 00000000..e6177679 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/config/RestTemplateConfig.java @@ -0,0 +1,18 @@ +package dev.cleat.enrichment.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(4000); + factory.setReadTimeout(10000); + return new RestTemplate(factory); + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/EpssData.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/EpssData.java new file mode 100644 index 00000000..0285d2d9 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/EpssData.java @@ -0,0 +1,27 @@ +package dev.cleat.enrichment.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class EpssData { + private String cve; + private Double epss; + + public String getCve() { + return cve; + } + + public EpssData setCve(String cve) { + this.cve = cve; + return this; + } + + public Double getEpss() { + return epss; + } + + public EpssData setEpss(Double epss) { + this.epss = epss; + return this; + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/EpssResponse.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/EpssResponse.java new file mode 100644 index 00000000..faeaa913 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/EpssResponse.java @@ -0,0 +1,18 @@ +package dev.cleat.enrichment.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties() +public class EpssResponse { + private List data; + + public List getData() { + return data; + } + + public EpssResponse setData(List data) { + this.data = data; + return this; + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/FeedResult.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/FeedResult.java new file mode 100644 index 00000000..9013b593 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/FeedResult.java @@ -0,0 +1,3 @@ +package dev.cleat.enrichment.dto; + +public record FeedResult(boolean kev, Double epss, OsvResponse osv) {} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/KevResponse.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/KevResponse.java new file mode 100644 index 00000000..95843135 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/KevResponse.java @@ -0,0 +1,18 @@ +package dev.cleat.enrichment.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class KevResponse { + private List vulnerabilities; + + public List getVulnerabilities() { + return vulnerabilities; + } + + public KevResponse setVulnerabilities(List vulnerabilities) { + this.vulnerabilities = vulnerabilities; + return this; + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/KevVulnerability.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/KevVulnerability.java new file mode 100644 index 00000000..7b706c8e --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/KevVulnerability.java @@ -0,0 +1,19 @@ +package dev.cleat.enrichment.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class KevVulnerability { + @JsonProperty("cveID") + private String cveId; + + public String getCveId() { + return cveId; + } + + public KevVulnerability setCveId(String cveId) { + this.cveId = cveId; + return this; + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvPackage.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvPackage.java new file mode 100644 index 00000000..6775f276 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvPackage.java @@ -0,0 +1,25 @@ +package dev.cleat.enrichment.dto; + +public class OsvPackage { + + private String name; + private String ecosystem; + + public String getName() { + return name; + } + + public OsvPackage setName(String name) { + this.name = name; + return this; + } + + public String getEcosystem() { + return ecosystem; + } + + public OsvPackage setEcosystem(String ecosystem) { + this.ecosystem = ecosystem; + return this; + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvRequest.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvRequest.java new file mode 100644 index 00000000..9687ecff --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvRequest.java @@ -0,0 +1,17 @@ +package dev.cleat.enrichment.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class OsvRequest { + @JsonProperty("package") + private OsvPackage pkg; + + public OsvPackage getPkg() { + return pkg; + } + + public OsvRequest setPkg(OsvPackage pkg) { + this.pkg = pkg; + return this; + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvResponse.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvResponse.java new file mode 100644 index 00000000..3699ca99 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/dto/OsvResponse.java @@ -0,0 +1,28 @@ +package dev.cleat.enrichment.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class OsvResponse { + + private String id; + private String summary; + + public String getId() { + return id; + } + + public OsvResponse setId(String id) { + this.id = id; + return this; + } + + public String getSummary() { + return summary; + } + + public OsvResponse setSummary(String summary) { + this.summary = summary; + return this; + } +} diff --git a/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/service/FeedService.java b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/service/FeedService.java new file mode 100644 index 00000000..cb9a2000 --- /dev/null +++ b/backend/libs/enrichment/src/main/java/dev/cleat/enrichment/service/FeedService.java @@ -0,0 +1,52 @@ +package dev.cleat.enrichment.service; + +import dev.cleat.common.exception.FeedSyncException; +import dev.cleat.enrichment.client.EpssClient; +import dev.cleat.enrichment.client.KevClient; +import dev.cleat.enrichment.client.OsvClient; +import dev.cleat.enrichment.dto.FeedResult; +import dev.cleat.enrichment.dto.OsvResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class FeedService { + private final KevClient kevClient; + private final EpssClient epssClient; + private final OsvClient osvClient; + + public FeedService(KevClient kevClient, EpssClient epssClient, OsvClient osvClient) { + this.kevClient = kevClient; + this.epssClient = epssClient; + this.osvClient = osvClient; + } + + private static final Logger LOG = LoggerFactory.getLogger(FeedService.class); + + public boolean isKev(String cve) { + return kevClient.isKev(cve); + } + + public Double fetchScore(String cve) { + return epssClient.fetchScore(cve); + } + + public OsvResponse query(String packageName, String ecosystem) { + return osvClient.query(packageName, ecosystem); + } + + public dev.cleat.enrichment.dto.FeedResult fetchFeeds(String cve, String packageName, String ecosystem) { + + LOG.info("Fetching feeds for CVE: {}", cve); + try { + FeedResult result = new FeedResult( + kevClient.isKev(cve), epssClient.fetchScore(cve), osvClient.query(packageName, ecosystem)); + LOG.info("Successfully fetched feeds for CVE: {}", cve); + return result; + } catch (Exception e) { + LOG.error("Failed to fetch feeds for {}", cve, e); + throw new FeedSyncException("Failed to fetch enrichment feeds", e); + } + } +} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/VulnerabilityEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/VulnerabilityEntity.java index 4d8d16e8..3af295cc 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/VulnerabilityEntity.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/entity/VulnerabilityEntity.java @@ -14,6 +14,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; +import jakarta.persistence.Version; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; @@ -68,6 +69,9 @@ public class VulnerabilityEntity { @Column(name = "cwe") private String cwe; + @Column(name = "cve") + private String cve; + @Column(name = "title") private String title; @@ -82,6 +86,10 @@ public class VulnerabilityEntity { @Column(name = "published_at") private OffsetDateTime publishedAt; + @Version + @Column(name = "version", nullable = false) + private Long version; + public VulnerabilityEntity() {} public VulnerabilityEntity( @@ -99,10 +107,12 @@ public VulnerabilityEntity( Reachable reachable, UUID advisoryId, String cwe, + String cve, String title, List affectedRepos, Boolean hasFixPr, - OffsetDateTime publishedAt) { + OffsetDateTime publishedAt, + Long version) { this.id = id; this.accountId = accountId; this.packageName = packageName; @@ -117,10 +127,12 @@ public VulnerabilityEntity( this.reachable = reachable; this.advisoryId = advisoryId; this.cwe = cwe; + this.cve = cve; this.title = title; this.affectedRepos = affectedRepos; this.hasFixPr = hasFixPr; this.publishedAt = publishedAt; + this.version = version; } public UUID getId() { @@ -249,6 +261,15 @@ public VulnerabilityEntity setCwe(String cwe) { return this; } + public String getCve() { + return cve; + } + + public VulnerabilityEntity setCve(String cve) { + this.cve = cve; + return this; + } + public String getTitle() { return title; } @@ -284,4 +305,13 @@ public VulnerabilityEntity setPublishedAt(OffsetDateTime publishedAt) { this.publishedAt = publishedAt; return this; } + + public Long getVersion() { + return version; + } + + public VulnerabilityEntity setVersion(Long version) { + this.version = version; + return this; + } } diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/mapper/VulnerabilityMapper.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/mapper/VulnerabilityMapper.java index 52906cb0..12e8c0b0 100644 --- a/backend/libs/persistence/src/main/java/dev/cleat/persistence/mapper/VulnerabilityMapper.java +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/mapper/VulnerabilityMapper.java @@ -23,6 +23,7 @@ public VulnerabilityResponseDto toVulnerabilityDto(VulnerabilityEntity vulnerabi .setReachable(vulnerabilityEntity.getReachable()) .setAdvisoryId(vulnerabilityEntity.getAdvisoryId()) .setCwe(vulnerabilityEntity.getCwe()) + .setCve(vulnerabilityEntity.getCve()) .setTitle(vulnerabilityEntity.getTitle()) .setAffectedRepos(vulnerabilityEntity.getAffectedRepos()) .setHasFixPr(vulnerabilityEntity.getHasFixPr()) @@ -45,6 +46,7 @@ public VulnerabilityEntity toVulnerabilityEntity(VulnerabilityRequestDto vulnera .setReachable(vulnerabilityRequestDto.getReachable()) .setAdvisoryId(vulnerabilityRequestDto.getAdvisoryId()) .setCwe(vulnerabilityRequestDto.getCwe()) + .setCve(vulnerabilityRequestDto.getCve()) .setTitle(vulnerabilityRequestDto.getTitle()) .setAffectedRepos(vulnerabilityRequestDto.getAffectedRepos()) .setHasFixPr(vulnerabilityRequestDto.getHasFixPr()); diff --git a/backend/libs/persistence/src/main/resources/db/migration/V6__alter_vulnerability_table.sql b/backend/libs/persistence/src/main/resources/db/migration/V6__alter_vulnerability_table.sql new file mode 100644 index 00000000..5180a1f0 --- /dev/null +++ b/backend/libs/persistence/src/main/resources/db/migration/V6__alter_vulnerability_table.sql @@ -0,0 +1 @@ +ALTER TABLE vulnerability ADD COLUMN cve VARCHAR(50); \ No newline at end of file diff --git a/backend/libs/persistence/src/main/resources/db/migration/V7__alter_vulnerability_table.sql b/backend/libs/persistence/src/main/resources/db/migration/V7__alter_vulnerability_table.sql new file mode 100644 index 00000000..18f18c52 --- /dev/null +++ b/backend/libs/persistence/src/main/resources/db/migration/V7__alter_vulnerability_table.sql @@ -0,0 +1 @@ +ALTER TABLE vulnerability ADD COLUMN version BIGINT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/backend/libs/scanning/build.gradle.kts b/backend/libs/scanning/build.gradle.kts index 41a5c34d..99ae6d17 100644 --- a/backend/libs/scanning/build.gradle.kts +++ b/backend/libs/scanning/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { implementation(project(":libs:common")) implementation(project(":libs:persistence")) implementation(project(":libs:github-client")) + implementation(project(":libs:enrichment")) implementation("org.springframework.boot:spring-boot-starter") diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/VulnerabilityScanner.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/VulnerabilityScanner.java index 63a2fa61..6635ec64 100644 --- a/backend/libs/scanning/src/main/java/dev/cleat/scanning/VulnerabilityScanner.java +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/VulnerabilityScanner.java @@ -27,12 +27,18 @@ public void processAndSave(VulnerabilityEntity vulnerabilityEntity) { } if (vulnerabilityEntity.getCvss() == null || vulnerabilityEntity.getSeverity() == null - || vulnerabilityEntity.getKev() == null) { - throw new IllegalArgumentException("cvss,severity and kev are required"); + || vulnerabilityEntity.getKev() == null + || vulnerabilityEntity.getEpss() == null + || vulnerabilityEntity.getReachable() == null) { + throw new IllegalArgumentException("cvss,severity kev,epss,reachable are required"); } try { Vulnerability vulnerability = new Vulnerability( - vulnerabilityEntity.getKev(), vulnerabilityEntity.getCvss(), vulnerabilityEntity.getSeverity()); + vulnerabilityEntity.getKev(), + vulnerabilityEntity.getCvss(), + vulnerabilityEntity.getSeverity(), + vulnerabilityEntity.getEpss(), + vulnerabilityEntity.getReachable()); vulnerabilityEntity.setPriority(priorityCalculator.calculate(vulnerability)); vulnerabilityRepository.save(vulnerabilityEntity); LOG.info("Vulnerability processed successfully for ID: {}", vulnerabilityEntity.getId()); diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/service/FeedSyncService.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/service/FeedSyncService.java new file mode 100644 index 00000000..c6fa03a5 --- /dev/null +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/service/FeedSyncService.java @@ -0,0 +1,56 @@ +package dev.cleat.scanning.service; + +import dev.cleat.enrichment.dto.FeedResult; +import dev.cleat.enrichment.service.FeedService; +import dev.cleat.persistence.entity.VulnerabilityEntity; +import dev.cleat.persistence.repository.VulnerabilityRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +public class FeedSyncService { + + private final VulnerabilityRepository vulnerabilityRepository; + private final FeedService feedService; + private final VulnerabilityUpdateService vulnerabilityUpdateService; + + public FeedSyncService( + VulnerabilityRepository vulnerabilityRepository, + FeedService feedService, + VulnerabilityUpdateService vulnerabilityUpdateService) { + this.vulnerabilityRepository = vulnerabilityRepository; + this.feedService = feedService; + this.vulnerabilityUpdateService = vulnerabilityUpdateService; + } + + private static final Logger LOG = LoggerFactory.getLogger(FeedSyncService.class); + + @Scheduled(fixedDelayString = "${feed.sync.delay}") + public void syncFeeds() { + int page = 0; + Page vulnerabilities; + LOG.info("Feed synchronization started"); + do { + vulnerabilities = vulnerabilityRepository.findAll(PageRequest.of(page, 100)); + for (VulnerabilityEntity v : vulnerabilities.getContent()) { + if (v.getCve() == null) { + LOG.warn("Skipping vulnerability {} because CVE is missing", v.getId()); + continue; + } + try { + FeedResult feed = feedService.fetchFeeds(v.getCve(), v.getPackageName(), v.getEcosystem()); + vulnerabilityUpdateService.update(v, feed); + LOG.info("Updated vulnerability {}", v.getId()); + } catch (Exception e) { + LOG.error("Failed to update vulnerability {}", v.getId(), e); + } + } + page++; + } while (vulnerabilities.hasNext()); + LOG.info("Feed synchronization completed"); + } +} diff --git a/backend/libs/scanning/src/main/java/dev/cleat/scanning/service/VulnerabilityUpdateService.java b/backend/libs/scanning/src/main/java/dev/cleat/scanning/service/VulnerabilityUpdateService.java new file mode 100644 index 00000000..b8f6cc90 --- /dev/null +++ b/backend/libs/scanning/src/main/java/dev/cleat/scanning/service/VulnerabilityUpdateService.java @@ -0,0 +1,36 @@ +package dev.cleat.scanning.service; + +import dev.cleat.domain.PriorityCalculator; +import dev.cleat.domain.model.Vulnerability; +import dev.cleat.enrichment.dto.FeedResult; +import dev.cleat.persistence.entity.VulnerabilityEntity; +import dev.cleat.persistence.repository.VulnerabilityRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class VulnerabilityUpdateService { + + private final PriorityCalculator priorityCalculator; + private final VulnerabilityRepository vulnerabilityRepository; + + public VulnerabilityUpdateService( + PriorityCalculator priorityCalculator, VulnerabilityRepository vulnerabilityRepository) { + this.priorityCalculator = priorityCalculator; + this.vulnerabilityRepository = vulnerabilityRepository; + } + + public void update(VulnerabilityEntity v, FeedResult feed) { + v.setKev(feed.kev()); + v.setEpss(feed.epss()); + if (feed.osv() != null && feed.osv().getSummary() != null) { + v.setTitle(feed.osv().getSummary()); + } + Vulnerability vulnerability = + new Vulnerability(v.getKev(), v.getCvss(), v.getSeverity(), v.getEpss(), v.getReachable()); + v.setPriority(priorityCalculator.calculate(vulnerability)); + + vulnerabilityRepository.save(v); + } +} diff --git a/backend/libs/scanning/src/test/java/dev/cleat/scanning/FeedSyncServiceTest.java b/backend/libs/scanning/src/test/java/dev/cleat/scanning/FeedSyncServiceTest.java new file mode 100644 index 00000000..3f2fdb65 --- /dev/null +++ b/backend/libs/scanning/src/test/java/dev/cleat/scanning/FeedSyncServiceTest.java @@ -0,0 +1,75 @@ +package dev.cleat.scanning; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.cleat.common.enums.Reachable; +import dev.cleat.common.enums.Severity; +import dev.cleat.enrichment.dto.FeedResult; +import dev.cleat.enrichment.dto.OsvResponse; +import dev.cleat.enrichment.service.FeedService; +import dev.cleat.persistence.entity.VulnerabilityEntity; +import dev.cleat.persistence.repository.VulnerabilityRepository; +import dev.cleat.scanning.service.FeedSyncService; +import dev.cleat.scanning.service.VulnerabilityUpdateService; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +public class FeedSyncServiceTest { + + @Mock + VulnerabilityRepository vulnerabilityRepository; + + @Mock + FeedService feedService; + + @Mock + VulnerabilityUpdateService vulnerabilityUpdateService; + + @InjectMocks + FeedSyncService feedSyncService; + + @Test + void shouldRescoreAllVulnerabilities() { + + VulnerabilityEntity vulnerabilityEntity = new VulnerabilityEntity() + .setCve("CVE-2025-1234") + .setPackageName("spring-core") + .setEcosystem("maven") + .setCvss(9.5) + .setSeverity(Severity.HIGH) + .setReachable(Reachable.REACHABLE); + + FeedResult feed = new FeedResult(true, 0.95, new OsvResponse()); + Page page = new PageImpl<>(List.of(vulnerabilityEntity)); + + when(vulnerabilityRepository.findAll(any(Pageable.class))).thenReturn(page); + + when(feedService.fetchFeeds( + vulnerabilityEntity.getCve(), + vulnerabilityEntity.getPackageName(), + vulnerabilityEntity.getEcosystem())) + .thenReturn(feed); + + doNothing().when(vulnerabilityUpdateService).update(any(VulnerabilityEntity.class), any(FeedResult.class)); + + feedSyncService.syncFeeds(); + + verify(feedService) + .fetchFeeds( + vulnerabilityEntity.getCve(), + vulnerabilityEntity.getPackageName(), + vulnerabilityEntity.getEcosystem()); + verify(vulnerabilityUpdateService).update(any(VulnerabilityEntity.class), any(FeedResult.class)); + } +} diff --git a/backend/libs/scanning/src/test/java/dev/cleat/scanning/VulnerabilityScannerTest.java b/backend/libs/scanning/src/test/java/dev/cleat/scanning/VulnerabilityScannerTest.java index f090187e..eafba43d 100644 --- a/backend/libs/scanning/src/test/java/dev/cleat/scanning/VulnerabilityScannerTest.java +++ b/backend/libs/scanning/src/test/java/dev/cleat/scanning/VulnerabilityScannerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.when; import dev.cleat.common.enums.Priority; +import dev.cleat.common.enums.Reachable; import dev.cleat.common.enums.Severity; import dev.cleat.domain.PriorityCalculator; import dev.cleat.persistence.entity.VulnerabilityEntity; @@ -35,8 +36,13 @@ public class VulnerabilityScannerTest { void givenVulnerabilityWithKevAndHighCvssWhenProcessingThenPriorityShouldBeUrgent() { // given UUID uuid = UUID.randomUUID(); - VulnerabilityEntity vulnerabilityEntity = - new VulnerabilityEntity().setId(uuid).setKev(true).setCvss(9.5).setSeverity(Severity.HIGH); + VulnerabilityEntity vulnerabilityEntity = new VulnerabilityEntity() + .setId(uuid) + .setKev(true) + .setCvss(9.5) + .setSeverity(Severity.HIGH) + .setEpss(0.01) + .setReachable(Reachable.REACHABLE); when(priorityCalculator.calculate(any())).thenReturn(Priority.URGENT); @@ -54,8 +60,12 @@ void givenVulnerabilityWithKevAndHighCvssWhenProcessingThenPriorityShouldBeUrgen void whenCalculatorThrowsExceptionThenScannerWrapsItInRuntimeException() { // given - VulnerabilityEntity vulnerabilityEntity = - new VulnerabilityEntity().setKev(true).setCvss(9.0).setSeverity(Severity.HIGH); + VulnerabilityEntity vulnerabilityEntity = new VulnerabilityEntity() + .setKev(true) + .setCvss(9.0) + .setSeverity(Severity.HIGH) + .setEpss(0.01) + .setReachable(Reachable.REACHABLE); when(priorityCalculator.calculate(any())).thenThrow(new IllegalArgumentException("Invalid CVSS"));