Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,27 @@
import io.mixeway.mixewayflowapi.api.coderepo.service.CodeRepoApiService;
import io.mixeway.mixewayflowapi.api.gitlabcicd.dto.GitLabCICDRequestDto;
import io.mixeway.mixewayflowapi.db.entity.CodeRepo;
import io.mixeway.mixewayflowapi.db.entity.CodeRepoBranch;
import io.mixeway.mixewayflowapi.domain.coderepo.CreateCodeRepoService;
import io.mixeway.mixewayflowapi.domain.coderepo.DeleteCodeRepoService;
import io.mixeway.mixewayflowapi.exceptions.CodeRepoNotFoundException;
import io.mixeway.mixewayflowapi.exceptions.ScanThrottledException;
import io.mixeway.mixewayflowapi.exceptions.TeamNotFoundException;
import io.mixeway.mixewayflowapi.exceptions.UnauthorizedException;
import io.mixeway.mixewayflowapi.utils.StatusDTO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.security.Principal;
import java.util.List;
import java.util.NoSuchElementException;

@RestController
@Validated
Expand Down Expand Up @@ -136,6 +139,30 @@ public ResponseEntity<List<String>> getGitBranches(@PathVariable("id") Long id,
}
}

@PreAuthorize("hasAuthority('USER')")
@PostMapping(value = "/api/v1/coderepo/{id}/scan/sbom", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<StatusDTO> uploadSbomAndScan(
@PathVariable("id") Long id,
@RequestPart("file") MultipartFile file,
@RequestParam(value = "branch", required = false) String branch,
Principal principal) {
try {
codeRepoApiService.runScanFromUploadedSbom(id, file, branch, principal);
return new ResponseEntity<>(new StatusDTO("ok"), HttpStatus.ACCEPTED);
} catch (ScanThrottledException e) {
log.warn("[CodeRepo] SBOM upload throttled for repo {}: {}", id, e.getMessage());
return new ResponseEntity<>(new StatusDTO(e.getMessage()), HttpStatus.TOO_MANY_REQUESTS);
} catch (IllegalArgumentException e) {
log.warn("[CodeRepo] Invalid SBOM upload for {}: {}", id, e.getMessage());
return new ResponseEntity<>(new StatusDTO(e.getMessage()), HttpStatus.BAD_REQUEST);
} catch (NoSuchElementException e) {
return new ResponseEntity<>(new StatusDTO("Repository not found"), HttpStatus.NOT_FOUND);
} catch (Exception e) {
log.error("[CodeRepo] SBOM upload failed for {}: {}", id, e.getMessage(), e);
return new ResponseEntity<>(new StatusDTO("Not ok"), HttpStatus.BAD_REQUEST);
}
}

@PreAuthorize("hasAuthority('USER')")
@PostMapping(value= "/api/v1/coderepo/{id}/run/branch")
public ResponseEntity<StatusDTO> runScanForBranch(@PathVariable("id") Long id, @Valid @RequestBody RunScanBranchRequestDto request, Principal principal){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,32 @@
import io.mixeway.mixewayflowapi.domain.team.FindTeamService;
import io.mixeway.mixewayflowapi.integrations.repo.service.GitService;
import io.mixeway.mixewayflowapi.exceptions.CodeRepoNotFoundException;
import io.mixeway.mixewayflowapi.exceptions.ScanThrottledException;
import io.mixeway.mixewayflowapi.exceptions.TeamNotFoundException;
import io.mixeway.mixewayflowapi.scanmanager.service.ScanManagerService;
import io.mixeway.mixewayflowapi.utils.PermissionFactory;
import com.fasterxml.jackson.core.JacksonException;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.aspectj.apache.bcel.classfile.Code;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.Principal;
import java.util.Comparator;
import java.util.*;
import java.util.stream.Collectors;

@Service
@Log4j2
@RequiredArgsConstructor
public class CodeRepoApiService {

private static final long MAX_SBOM_UPLOAD_BYTES = 50L * 1024 * 1024;

private final FindCodeRepoService findCodeRepoService;
private final ScanManagerService scanManagerService;
private final PermissionFactory permissionFactory;
Expand All @@ -40,6 +50,7 @@ public class CodeRepoApiService {
private final FindFindingService findFindingService;
private final GitService gitService;
private final GetOrCreateCodeRepoBranchService getOrCreateCodeRepoBranchService;
private final ObjectMapper objectMapper;

public List<GetCodeReposResponseDto> getRepos(Principal principal) {
return findCodeRepoService.getCodeReposResponseDtos(principal);
Expand Down Expand Up @@ -92,6 +103,63 @@ public void runScanForBranch(Long id, String branchName, Principal principal) {
scanManagerService.scanRepository(repo, branch, null, null);
}

/**
* Validates and stores an uploaded CycloneDX JSON SBOM as {@code sbom.json}, then queues an SCA-only Grype scan.
*
* @param branchName optional branch to associate findings with; defaults to the repository default branch
*/
public void runScanFromUploadedSbom(Long repoId, MultipartFile file, String branchName, Principal principal)
throws IOException {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("SBOM file is required.");
}
if (file.getSize() > MAX_SBOM_UPLOAD_BYTES) {
throw new IllegalArgumentException("SBOM file exceeds maximum allowed size (50 MB).");
}

CodeRepo repo = findCodeRepoService.findById(repoId, principal);
CodeRepoBranch branch;
if (branchName != null && !branchName.isBlank()) {
branch = getOrCreateCodeRepoBranchService.getOrCreateCodeRepoBranch(branchName.trim(), repo);
} else {
branch = repo.getDefaultBranch();
}

Path workDir = Files.createTempDirectory("flow-sbom-upload-");
Path target = workDir.resolve("sbom.json");
try {
try (var in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
objectMapper.readTree(target.toFile());
scanManagerService.scanRepositoryFromUploadedSbom(repo, branch, workDir);
} catch (ScanThrottledException e) {
deleteSbomWorkDirQuietly(workDir);
throw e;
} catch (JacksonException e) {
deleteSbomWorkDirQuietly(workDir);
throw new IllegalArgumentException("Invalid JSON SBOM: " + e.getMessage(), e);
}
}

private void deleteSbomWorkDirQuietly(Path workDir) {
try {
if (Files.isDirectory(workDir)) {
try (var walk = Files.walk(workDir)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {
// best effort
}
});
}
}
} catch (IOException e) {
log.warn("[CodeRepo] Failed to delete SBOM work directory {}: {}", workDir, e.getMessage());
}
}

public void changeTeam(Long codeRepoId, Long newTeamId, Principal principal) {
// Find the code repo
CodeRepo codeRepo = findCodeRepoService.findById(codeRepoId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ public Page<TeamFindingsAndVulnsResponseDto> getCloudAndRepoFindingsAndVulns(Str
String severity = filters.getOrDefault("severity", null);
String source = filters.getOrDefault("source", null);
String status = filters.getOrDefault("status", null);
String name = filters.getOrDefault("name", null);
if (name != null && name.isBlank()) {
name = null;
}
String epssString = filters.getOrDefault("epss", null);
BigDecimal epss = (epssString != null) ? new BigDecimal(epssString) : null;
String kevStr = filters.getOrDefault("kev", null);
Expand All @@ -173,8 +177,8 @@ public Page<TeamFindingsAndVulnsResponseDto> getCloudAndRepoFindingsAndVulns(Str
else if ("f".equalsIgnoreCase(kevStr) || "false".equalsIgnoreCase(kevStr)) kev = false;
String urgencyFilter = filters.getOrDefault("urgency", null); // expected values: "urgent" | "notable"

Page<Finding> codeRepoFindingsPage = findingRepository.findByCodeReposPageable(codeRepos, pageable, severity, source, status, epss, kev);
Page<Finding> cloudSubscriptionFindingsPage = findingRepository.findByCloudSubscriptionsPageable(cloudSubscriptions, pageable, severity, source, status, epss, kev);
Page<Finding> codeRepoFindingsPage = findingRepository.findByCodeReposPageable(codeRepos, pageable, severity, source, status, epss, kev, name);
Page<Finding> cloudSubscriptionFindingsPage = findingRepository.findByCloudSubscriptionsPageable(cloudSubscriptions, pageable, severity, source, status, epss, kev, name);

List<Finding> combinedFindings = Stream.concat(
codeRepoFindingsPage.getContent().stream(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ public interface CodeRepoRepository extends CrudRepository<CodeRepo, Long> {

List<CodeRepo> findByType(CodeRepo.RepoType type);

/**
* Remote IDs already imported for this Git host (same {@code apiUrl} prefix as stored on {@link CodeRepo#getRepourl()}),
* so multiple providers of the same {@link CodeRepo.RepoType} on different hosts do not share deduplication.
*/
@Query("SELECT c.remoteId FROM CodeRepo c WHERE c.type = :type AND c.repourl LIKE CONCAT(:prefix, '%')")
List<Integer> findRemoteIdsByTypeAndRepourlPrefix(@Param("type") CodeRepo.RepoType type, @Param("prefix") String prefix);

@Query("SELECT count(c) FROM CodeRepo c WHERE c.repourl LIKE CONCAT(:gitHostUrl, '%')")
long countByRepoUrlStartingWith(@Param("gitHostUrl") String gitHostUrl);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ List<Finding> findByCodeRepoAndVulnerabilityNameAndBranchAndLocation(
"AND (COALESCE(:status, f.status) = f.status) " +
"AND (:epss IS NULL OR v.epss >= :epss)" +
"AND (COALESCE(:kev, v.exploitExists) = v.exploitExists)" +
"AND (:name IS NULL OR LOWER(v.name) LIKE LOWER(CONCAT('%', :name, '%')))" +
"AND b = cr.defaultBranch")
Page<Finding> findByCodeReposPageable(@Param("codeRepos") List<CodeRepo> codeRepos, Pageable pageable, @Param("severity") String severity, @Param("source") String source, @Param("status") String status, @Param("epss") BigDecimal epss, @Param("kev") Boolean exploitExists);
Page<Finding> findByCodeReposPageable(@Param("codeRepos") List<CodeRepo> codeRepos, Pageable pageable, @Param("severity") String severity, @Param("source") String source, @Param("status") String status, @Param("epss") BigDecimal epss, @Param("kev") Boolean exploitExists, @Param("name") String name);

@Query("SELECT f FROM Finding f " +
"JOIN f.vulnerability v " +
Expand All @@ -123,8 +124,9 @@ List<Finding> findByCodeRepoAndVulnerabilityNameAndBranchAndLocation(
"AND (COALESCE(:source, f.source) = f.source) " +
"AND (COALESCE(:status, f.status) = f.status) " +
"AND (:epss IS NULL OR v.epss >= :epss)" +
"AND (COALESCE(:kev, v.exploitExists) = v.exploitExists)")
Page<Finding> findByCloudSubscriptionsPageable(@Param("cloudSubscriptions") List<CloudSubscription> cloudSubscriptions, Pageable pageable, @Param("severity") String severity, @Param("source") String source, @Param("status") String status, @Param("epss") BigDecimal epss, @Param("kev") Boolean exploitExists);
"AND (COALESCE(:kev, v.exploitExists) = v.exploitExists)" +
"AND (:name IS NULL OR LOWER(v.name) LIKE LOWER(CONCAT('%', :name, '%')))")
Page<Finding> findByCloudSubscriptionsPageable(@Param("cloudSubscriptions") List<CloudSubscription> cloudSubscriptions, Pageable pageable, @Param("severity") String severity, @Param("source") String source, @Param("status") String status, @Param("epss") BigDecimal epss, @Param("kev") Boolean exploitExists, @Param("name") String name);

List<Finding> findAllByCodeRepoAndVulnerabilityAndLocation(CodeRepo repo,
Vulnerability vuln,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ public void setScanRunning(CodeRepo codeRepo) {
codeRepoRepository.save(codeRepo);
}

/**
* Marks only the SCA scan as running (e.g. SBOM upload flow without a full repository clone).
*/
@Transactional
public void setScaScanRunning(CodeRepo codeRepo) {
CodeRepo managed = findCodeRepoService.findById(codeRepo.getId()).orElse(codeRepo);
managed.updateScaScanStatus(CodeRepo.ScanStatus.RUNNING);
codeRepoRepository.save(managed);
}

@Modifying
@Transactional
public void setScaPending(CodeRepo codeRepo) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.mixeway.mixewayflowapi.exceptions;

/**
* Raised when a new scan cannot start because a recent scan for the same repository is still in the throttle window.
*/
public class ScanThrottledException extends RuntimeException {

public ScanThrottledException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.mixeway.mixewayflowapi.db.entity.CodeRepo;
import io.mixeway.mixewayflowapi.db.entity.RepositoryProvider;
import io.mixeway.mixewayflowapi.db.repository.CodeRepoRepository;
import io.mixeway.mixewayflowapi.db.repository.RepositoryProviderRepository;
import io.mixeway.mixewayflowapi.domain.coderepo.CreateCodeRepoService;
import io.mixeway.mixewayflowapi.integrations.repo.apiclient.BitbucketApiClientService;
import io.mixeway.mixewayflowapi.integrations.repo.apiclient.GitHubApiClientService;
Expand All @@ -17,7 +18,11 @@
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

Expand All @@ -27,6 +32,7 @@
public class RepositorySyncService {

private final CodeRepoRepository codeRepoRepository;
private final RepositoryProviderRepository repositoryProviderRepository;
private final CreateCodeRepoService createCodeRepoService;
private final GitLabApiClientService gitLabApiClientService;
private final GitHubApiClientService gitHubApiClientService;
Expand All @@ -38,9 +44,11 @@ public void syncProvider(RepositoryProvider provider) {
log.info("Starting repository sync for provider: {}", provider.getApiUrl());
// String accessToken = encryptionUtil.decrypt(provider.getEncryptedAccessToken());

Set<Integer> existingRemoteIds = codeRepoRepository.findByType(provider.getProviderType())
String repoUrlPrefix = normalizeProviderApiUrlPrefix(provider.getApiUrl());
Set<Integer> existingRemoteIds = codeRepoRepository
.findRemoteIdsByTypeAndRepourlPrefix(provider.getProviderType(), repoUrlPrefix)
.stream()
.map(CodeRepo::getRemoteId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());

Flux<?> repositoryFlux;
Expand All @@ -53,6 +61,7 @@ public void syncProvider(RepositoryProvider provider) {
} else if (provider.getProviderType().equals(CodeRepo.RepoType.BITBUCKET)) {
repositoryFlux = bitbucketApiClientService.fetchAllRepositories(provider.getApiUrl(), provider.getEncryptedAccessToken());
} else {
log.warn("Repository sync skipped: unsupported provider type {} for {}", provider.getProviderType(), provider.getApiUrl());
return;
}

Expand Down Expand Up @@ -83,12 +92,38 @@ public void syncProvider(RepositoryProvider provider) {
)
, 5) // The concurrency parameter limits how many imports run in parallel
.doOnComplete(() -> {
long repoCount = codeRepoRepository.countByRepoUrlStartingWith(provider.getApiUrl());
provider.setSyncedRepoCount((int) repoCount);
provider.setLastSyncDate(java.time.LocalDateTime.now());
log.info("Finished repository sync for provider: {}. Total synced repos: {}", provider.getApiUrl(), repoCount);
long repoCount = codeRepoRepository.countByRepoUrlStartingWith(repoUrlPrefix);
log.info("Finished repository sync for provider: {}. Repositories on this host in DB: {}", provider.getApiUrl(), repoCount);
Long providerId = provider.getId();
if (providerId == null) {
log.warn("Provider has no id; cannot persist sync metadata for {}", provider.getApiUrl());
return;
}
Mono.fromRunnable(() -> repositoryProviderRepository.findById(providerId).ifPresent(p -> {
p.setSyncedRepoCount((int) repoCount);
p.setLastSyncDate(LocalDateTime.now());
repositoryProviderRepository.save(p);
}))
.subscribeOn(Schedulers.boundedElastic())
.subscribe(
unused -> {},
e -> log.error("Failed to persist sync metadata for provider {}: {}", provider.getApiUrl(), e.getMessage(), e));
})
.doOnError(error -> log.error("Synchronization failed for provider {}: {}", provider.getApiUrl(), error.getMessage()))
.doOnError(error -> log.error("Synchronization failed for provider {}: {}", provider.getApiUrl(), error.getMessage(), error))
.subscribe();
}

/**
* Aligns {@code apiUrl} with how {@link CodeRepo#getRepourl()} is stored (web URL sharing the same origin prefix).
*/
private static String normalizeProviderApiUrlPrefix(String apiUrl) {
if (apiUrl == null) {
return "";
}
String t = apiUrl.trim();
while (t.endsWith("/")) {
t = t.substring(0, t.length() - 1);
}
return t;
}
}
Loading