diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java index 41e42393e..010bc7a3b 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/GitHubConstants.java @@ -19,15 +19,19 @@ public static class Json { public static final String CLIENT_ID = "client_id"; public static final String CLIENT_SECRET = "client_secret"; public static final String CODE = "code"; - public static final String USER_ID = "id"; - public static final String USER_NAME = "name"; - public static final String USER_AVATAR_URL = "avatar_url"; - public static final String USER_LOGIN_NAME = "login"; + public static final String SEVERITY = "severity"; + public static final String SECURITY_SEVERITY_LEVEL = "security_severity_level"; + public static final String SEVERITY_ADVISORY = "security_advisory"; + public static final String RULE = "rule"; } @NoArgsConstructor(access = AccessLevel.PRIVATE) public static class Url { private static final String BASE_URL = "https://api.github.com"; - public static final String USER = BASE_URL + "/user"; + public static final String REPO_DEPENDABOT_ALERTS_OPEN = BASE_URL + "/repos/%s/%s/dependabot/alerts?state=open"; + public static final String REPO_SECRET_SCANNING_ALERTS_OPEN = + BASE_URL + "/repos/%s/%s/secret-scanning/alerts?state=open"; + public static final String REPO_CODE_SCANNING_ALERTS_OPEN = + BASE_URL + "/repos/%s/%s/code-scanning/alerts?state=open"; } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java index 66b14b6d4..f65c2034d 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java @@ -32,4 +32,5 @@ public class RequestMappingConstants { public static final String LATEST_ARTIFACT_DOWNLOAD_URL_BY_ID = "/{id}/artifact"; public static final String EXTERNAL_DOCUMENT = API + "/externaldocument"; public static final String PRODUCT_MARKETPLACE_DATA = API + "/product-marketplace-data"; + public static final String SECURITY_MONITOR = API + "/security-monitor"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/SecurityMonitorController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/SecurityMonitorController.java new file mode 100644 index 000000000..40fd28b0a --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/SecurityMonitorController.java @@ -0,0 +1,39 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.github.model.ProductSecurityInfo; +import com.axonivy.market.util.AuthorizationUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static com.axonivy.market.constants.RequestMappingConstants.SECURITY_MONITOR; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@RestController +@RequestMapping(SECURITY_MONITOR) +@Tag(name = "Security Monitor Controllers", description = "API collection to get Github Marketplace security's detail.") +@AllArgsConstructor +public class SecurityMonitorController { + private final GitHubService gitHubService; + + @GetMapping + @Operation(hidden = true) + public ResponseEntity getGitHubMarketplaceSecurity( + @RequestHeader(value = AUTHORIZATION) String authorizationHeader) { + String token = AuthorizationUtils.getBearerToken(authorizationHeader); + gitHubService.validateUserInOrganizationAndTeam(token, GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME, + GitHubConstants.AXONIVY_MARKET_TEAM_NAME); + List securityInfoList = gitHubService.getSecurityDetailsForAllProducts(token, + GitHubConstants.AXONIVY_MARKET_ORGANIZATION_NAME); + return ResponseEntity.ok(securityInfoList); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/enums/AccessLevel.java b/marketplace-service/src/main/java/com/axonivy/market/enums/AccessLevel.java new file mode 100644 index 000000000..8a382314d --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/enums/AccessLevel.java @@ -0,0 +1,10 @@ +package com.axonivy.market.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AccessLevel { + NO_PERMISSION, ENABLED, DISABLED +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/CodeScanning.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/CodeScanning.java new file mode 100644 index 000000000..3d74354fe --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/CodeScanning.java @@ -0,0 +1,16 @@ +package com.axonivy.market.github.model; + +import com.axonivy.market.enums.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +public class CodeScanning { + private Map alerts; + private AccessLevel status; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/Dependabot.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/Dependabot.java new file mode 100644 index 000000000..d73fc8925 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/Dependabot.java @@ -0,0 +1,16 @@ +package com.axonivy.market.github.model; + +import com.axonivy.market.enums.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +public class Dependabot { + private Map alerts; + private AccessLevel status; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java new file mode 100644 index 000000000..efca212f3 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/ProductSecurityInfo.java @@ -0,0 +1,24 @@ +package com.axonivy.market.github.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductSecurityInfo { + private String repoName; + private boolean isArchived; + private String visibility; + private boolean branchProtectionEnabled; + private Date lastCommitDate; + private String latestCommitSHA; + private Dependabot dependabot; + private SecretScanning secretScanning; + private CodeScanning codeScanning; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/model/SecretScanning.java b/marketplace-service/src/main/java/com/axonivy/market/github/model/SecretScanning.java new file mode 100644 index 000000000..fbf834a98 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/github/model/SecretScanning.java @@ -0,0 +1,14 @@ +package com.axonivy.market.github.model; + +import com.axonivy.market.enums.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class SecretScanning { + private Integer numberOfAlerts; + private AccessLevel status; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java index dca4173d9..b7357fcf7 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/GitHubService.java @@ -6,6 +6,7 @@ import com.axonivy.market.exceptions.model.UnauthorizedException; import com.axonivy.market.github.model.GitHubAccessTokenResponse; import com.axonivy.market.github.model.GitHubProperty; +import com.axonivy.market.github.model.ProductSecurityInfo; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; @@ -37,4 +38,6 @@ GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitHubPrope User getAndUpdateUser(String accessToken); void validateUserInOrganizationAndTeam(String accessToken, String team, String org) throws UnauthorizedException; + + List getSecurityDetailsForAllProducts(String accessToken, String orgName); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java index ae3285ca8..1d369842f 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java @@ -4,7 +4,6 @@ import com.axonivy.market.constants.ReadmeConstants; import com.axonivy.market.entity.Product; import com.axonivy.market.entity.ProductModuleContent; -import com.axonivy.market.enums.Language; import com.axonivy.market.github.service.GHAxonIvyProductRepoService; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.github.util.GitHubUtils; @@ -12,7 +11,6 @@ import com.axonivy.market.service.ImageService; import com.axonivy.market.util.ProductContentUtils; import lombok.extern.log4j.Log4j2; -import org.apache.commons.lang3.StringUtils; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.springframework.stereotype.Service; @@ -26,7 +24,6 @@ import java.util.Optional; import static com.axonivy.market.constants.CommonConstants.IMAGE_ID_PREFIX; -import static com.axonivy.market.util.ProductContentUtils.*; @Log4j2 @Service diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java index 293850c62..acf3dfac3 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java @@ -11,22 +11,24 @@ import com.axonivy.market.github.model.GitHubAccessTokenResponse; import com.axonivy.market.github.model.GitHubProperty; import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.github.model.ProductSecurityInfo; +import com.axonivy.market.github.util.GitHubUtils; import com.axonivy.market.repository.UserRepository; import lombok.extern.log4j.Log4j2; +import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; +import org.kohsuke.github.GHMyself; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; import org.kohsuke.github.GHTag; import org.kohsuke.github.GHTeam; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; @@ -36,9 +38,12 @@ import java.io.IOException; import java.util.Collections; +import java.util.Comparator; import java.util.List; -import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.EMPTY; @@ -46,15 +51,16 @@ @Service public class GitHubServiceImpl implements GitHubService { - private final RestTemplate restTemplate; + private final RestTemplate restTemplate = new RestTemplate(); private final UserRepository userRepository; private final GitHubProperty gitHubProperty; + private final ThreadPoolTaskScheduler taskScheduler; - public GitHubServiceImpl(RestTemplateBuilder restTemplateBuilder, UserRepository userRepository, - GitHubProperty gitHubProperty) { - this.restTemplate = restTemplateBuilder.build(); + public GitHubServiceImpl(UserRepository userRepository, + GitHubProperty gitHubProperty, ThreadPoolTaskScheduler taskScheduler) { this.userRepository = userRepository; this.gitHubProperty = gitHubProperty; + this.taskScheduler = taskScheduler; } @Override @@ -96,8 +102,8 @@ public GHContent getGHContent(GHRepository ghRepository, String path, String ref } @Override - public GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitHubProperty) - throws Oauth2ExchangeCodeException, MissingHeaderException { + public GitHubAccessTokenResponse getAccessToken(String code, + GitHubProperty gitHubProperty) throws Oauth2ExchangeCodeException, MissingHeaderException { if (gitHubProperty == null) { throw new MissingHeaderException(); } @@ -125,38 +131,26 @@ public GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitH @Override public User getAndUpdateUser(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity> response = restTemplate.exchange(GitHubConstants.Url.USER, HttpMethod.GET, - entity, new ParameterizedTypeReference<>() { - }); - - Map userDetails = response.getBody(); - - if (userDetails == null) { + try { + var gitHub = getGitHub(accessToken); + GHMyself myself = gitHub.getMyself(); + String gitHubId = String.valueOf(myself.getId()); + User user = userRepository.searchByGitHubId(gitHubId); + if (user == null) { + user = new User(); + } + user.setGitHubId(gitHubId); + user.setName(myself.getName()); + user.setUsername(myself.getLogin()); + user.setAvatarUrl(myself.getAvatarUrl()); + user.setProvider(GitHubConstants.GITHUB_PROVIDER_NAME); + + userRepository.save(user); + return user; + } catch(IOException e) { + log.error(e); throw new NotFoundException(ErrorCode.GITHUB_USER_NOT_FOUND, "Failed to fetch user details from GitHub"); } - - String gitHubId = userDetails.get(GitHubConstants.Json.USER_ID).toString(); - String name = (String) userDetails.get(GitHubConstants.Json.USER_NAME); - String avatarUrl = (String) userDetails.get(GitHubConstants.Json.USER_AVATAR_URL); - String username = (String) userDetails.get(GitHubConstants.Json.USER_LOGIN_NAME); - - User user = userRepository.searchByGitHubId(gitHubId); - if (user == null) { - user = new User(); - } - user.setGitHubId(gitHubId); - user.setName(name); - user.setUsername(username); - user.setAvatarUrl(avatarUrl); - user.setProvider(GitHubConstants.GITHUB_PROVIDER_NAME); - - userRepository.save(user); - - return user; } @Override @@ -172,12 +166,35 @@ public void validateUserInOrganizationAndTeam(String accessToken, String organiz } throw new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), - String.format(ErrorMessageConstants.INVALID_USER_ERROR, ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), - team, organization)); + String.format(ErrorMessageConstants.INVALID_USER_ERROR, ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), team, + organization)); } - private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, - String teamName) throws IOException { + @Override + public List getSecurityDetailsForAllProducts(String accessToken, String orgName) { + List securityInfoList; + ExecutorService executor = taskScheduler.getScheduledExecutor(); + try { + GitHub gitHub = getGitHub(accessToken); + GHOrganization organization = gitHub.getOrganization(orgName); + + List> futures = organization.listRepositories().toList().stream() + .map(repo -> CompletableFuture.supplyAsync(() -> fetchSecurityInfoSafe(repo, organization, accessToken), executor)) + .toList(); + + securityInfoList = futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList()); + + securityInfoList.sort(Comparator.comparing(ProductSecurityInfo::getRepoName)); + } catch (IOException e) { + throw new RuntimeException("Error fetching repository data", e); + } + + return securityInfoList; + } + + private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, String teamName) throws IOException { if (gitHub == null) { return false; } @@ -188,7 +205,7 @@ private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, return false; } - for (GHTeam team: hashSetTeam) { + for (GHTeam team : hashSetTeam) { if (teamName.equals(team.getName())) { return true; } @@ -196,4 +213,31 @@ private boolean isUserInOrganizationAndTeam(GitHub gitHub, String organization, return false; } + + private ProductSecurityInfo fetchSecurityInfoSafe(GHRepository repo, GHOrganization organization, String accessToken) { + try { + return fetchSecurityInfo(repo, organization, accessToken); + } catch (IOException e) { + log.error("Error fetching security info for repo: " + repo.getName(), e); + return new ProductSecurityInfo(); + } + } + + private ProductSecurityInfo fetchSecurityInfo(GHRepository repo, GHOrganization organization, + String accessToken) throws IOException { + ProductSecurityInfo productSecurityInfo = new ProductSecurityInfo(); + productSecurityInfo.setRepoName(repo.getName()); + productSecurityInfo.setVisibility(repo.getVisibility().toString()); + productSecurityInfo.setArchived(repo.isArchived()); + String defaultBranch = repo.getDefaultBranch(); + productSecurityInfo.setBranchProtectionEnabled(repo.getBranch(defaultBranch).isProtected()); + String latestCommitSHA = repo.getBranches().get(defaultBranch).getSHA1(); + GHCommit latestCommit = repo.getCommit(latestCommitSHA); + productSecurityInfo.setLatestCommitSHA(latestCommitSHA); + productSecurityInfo.setLastCommitDate(latestCommit.getCommitDate()); + productSecurityInfo.setDependabot(GitHubUtils.getDependabotAlerts(repo, organization, accessToken)); + productSecurityInfo.setSecretScanning(GitHubUtils.getNumberOfSecretScanningAlerts(repo, organization, accessToken)); + productSecurityInfo.setCodeScanning(GitHubUtils.getCodeScanningAlerts(repo, organization, accessToken)); + return productSecurityInfo; + } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java index 0f0de6232..12e27a640 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java @@ -2,6 +2,10 @@ import com.axonivy.market.bo.Artifact; import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.github.model.CodeScanning; +import com.axonivy.market.github.model.Dependabot; +import com.axonivy.market.github.model.SecretScanning; import com.axonivy.market.util.MavenUtils; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -9,14 +13,28 @@ import org.apache.commons.lang3.StringUtils; import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; +import org.kohsuke.github.GHOrganization; +import org.kohsuke.github.GHRepository; import org.kohsuke.github.PagedIterable; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collectors; import static com.axonivy.market.constants.MetaConstants.META_FILE; @@ -66,29 +84,6 @@ public static String convertArtifactIdToName(String artifactId) { .collect(Collectors.joining(CommonConstants.SPACE_SEPARATOR)); } - public static String extractMessageFromExceptionMessage(String exceptionMessage) { - String json = extractJson(exceptionMessage); - String key = "\"message\":\""; - int startIndex = json.indexOf(key); - if (startIndex != -1) { - startIndex += key.length(); - int endIndex = json.indexOf("\"", startIndex); - if (endIndex != -1) { - return json.substring(startIndex, endIndex); - } - } - return StringUtils.EMPTY; - } - - public static String extractJson(String text) { - int start = text.indexOf("{"); - int end = text.lastIndexOf("}") + 1; - if (start != -1 && end != -1) { - return text.substring(start, end); - } - return StringUtils.EMPTY; - } - public static int sortMetaJsonFirst(String fileName1, String fileName2) { if (fileName1.endsWith(META_FILE)) return -1; @@ -132,4 +127,102 @@ public static InputStream extractedContentStream(GHContent content) { return null; } } + + public static Dependabot getDependabotAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + return fetchAlerts( + accessToken, + String.format(GitHubConstants.Url.REPO_DEPENDABOT_ALERTS_OPEN, organization.getLogin(), repo.getName()), + alerts -> { + Dependabot dependabot = new Dependabot(); + Map severityMap = new HashMap<>(); + for (Map alert : alerts) { + Object advisoryObj = alert.get(GitHubConstants.Json.SEVERITY_ADVISORY); + if (advisoryObj instanceof Map securityAdvisory) { + String severity = (String) securityAdvisory.get(GitHubConstants.Json.SEVERITY); + if (severity != null) { + severityMap.put(severity, severityMap.getOrDefault(severity, 0) + 1); + } + } + } + dependabot.setAlerts(severityMap); + return dependabot; + }, + Dependabot::new + ); + } + + public static SecretScanning getNumberOfSecretScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + return fetchAlerts( + accessToken, + String.format(GitHubConstants.Url.REPO_SECRET_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName()), + alerts -> { + SecretScanning secretScanning = new SecretScanning(); + secretScanning.setNumberOfAlerts(alerts.size()); + return secretScanning; + }, + SecretScanning::new + ); + } + + public static CodeScanning getCodeScanningAlerts(GHRepository repo, GHOrganization organization, String accessToken) { + return fetchAlerts( + accessToken, + String.format(GitHubConstants.Url.REPO_CODE_SCANNING_ALERTS_OPEN, organization.getLogin(), repo.getName()), + alerts -> { + CodeScanning codeScanning = new CodeScanning(); + Map codeScanningMap = new HashMap<>(); + for (Map alert : alerts) { + Object ruleObj = alert.get(GitHubConstants.Json.RULE); + if (ruleObj instanceof Map rule) { + String severity = (String) rule.get(GitHubConstants.Json.SECURITY_SEVERITY_LEVEL); + if (severity != null) { + codeScanningMap.put(severity, codeScanningMap.getOrDefault(severity, 0) + 1); + } + } + } + codeScanning.setAlerts(codeScanningMap); + return codeScanning; + }, + CodeScanning::new + ); + } + + private static T fetchAlerts( + String accessToken, + String url, + Function>, T> mapAlerts, + Supplier defaultInstanceSupplier + ) { + T instance = defaultInstanceSupplier.get(); + try { + ResponseEntity>> response = fetchApiResponseAsList(accessToken, url); + instance = mapAlerts.apply(response.getBody() != null ? response.getBody() : List.of()); + setStatus(instance, com.axonivy.market.enums.AccessLevel.ENABLED); + } catch (HttpClientErrorException.Forbidden e) { + setStatus(instance, com.axonivy.market.enums.AccessLevel.DISABLED); + } catch (HttpClientErrorException.NotFound e) { + setStatus(instance, com.axonivy.market.enums.AccessLevel.NO_PERMISSION); + } + return instance; + } + + private static void setStatus(Object instance, com.axonivy.market.enums.AccessLevel status) { + if (instance instanceof Dependabot dependabot) { + dependabot.setStatus(status); + } else if (instance instanceof SecretScanning secretScanning) { + secretScanning.setStatus(status); + } else if (instance instanceof CodeScanning codeScanning) { + codeScanning.setStatus(status); + } + } + + public static ResponseEntity>> fetchApiResponseAsList(String accessToken, + String url) throws RestClientException { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + return new RestTemplate().exchange(url, HttpMethod.GET, entity, new ParameterizedTypeReference<>() { + }); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java new file mode 100644 index 000000000..c86878cfc --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/SecurityMonitorControllerTest.java @@ -0,0 +1,69 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.enums.ErrorCode; +import com.axonivy.market.exceptions.model.UnauthorizedException; +import com.axonivy.market.github.model.ProductSecurityInfo; +import com.axonivy.market.github.service.GitHubService; +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.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityMonitorControllerTest { + + @Mock + private GitHubService gitHubService; + + @InjectMocks + private SecurityMonitorController securityMonitorController; + + @Test + void test_getGitHubMarketplaceSecurity() { + String mockToken = "Bearer sample-token"; + ProductSecurityInfo product1 = new ProductSecurityInfo("product1", false, "public", true, new Date(), "abc123", + null, null, null); + + ProductSecurityInfo product2 = new ProductSecurityInfo("product2", false, "private", false, new Date(), "def456", + null, null, null); + List mockSecurityInfoList = Arrays.asList(product1, product2); + + when(gitHubService.getSecurityDetailsForAllProducts(anyString(), anyString())).thenReturn(mockSecurityInfoList); + + ResponseEntity> expectedResponse = new ResponseEntity<>(mockSecurityInfoList, + HttpStatus.OK); + + ResponseEntity actualResponse = securityMonitorController.getGitHubMarketplaceSecurity(mockToken); + + assertEquals(expectedResponse.getStatusCode(), actualResponse.getStatusCode()); + assertEquals(expectedResponse.getBody(), actualResponse.getBody()); + } + + @Test + void test_getGitHubMarketplaceSecurity_shouldReturnUnauthorized_whenInvalidToken() { + String invalidToken = "Bearer invalid-token"; + + doThrow(new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), + ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText())).when(gitHubService) + .validateUserInOrganizationAndTeam(any(String.class), any(String.class), any(String.class)); + + UnauthorizedException exception = assertThrows(UnauthorizedException.class, + () -> securityMonitorController.getGitHubMarketplaceSecurity(invalidToken)); + + assertEquals(ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText(), exception.getMessage()); + } +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java index d6276d841..522dc85e6 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java @@ -40,40 +40,4 @@ void testSortMetaJsonFirst() { result = GitHubUtils.sortMetaJsonFirst(LOGO_FILE, LOGO_FILE); Assertions.assertEquals(0, result); } - - @Test - void testExtractJson() { - // Test case: valid JSON inside a string - String exceptionMessage = "Error occurred: {\"message\":\"An error occurred\"}"; - String json = GitHubUtils.extractJson(exceptionMessage); - Assertions.assertEquals("{\"message\":\"An error occurred\"}", json); - - // Test case: no JSON in string - exceptionMessage = "Error occurred: no json here"; - json = GitHubUtils.extractJson(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, json); - - // Test case: empty string - exceptionMessage = StringUtils.EMPTY; - json = GitHubUtils.extractJson(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, json); - } - - @Test - void testExtractMessageFromExceptionMessage() { - // Test case: valid message extraction - String exceptionMessage = "Some error occurred: {\"message\":\"Invalid input data\"}"; - String extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); - Assertions.assertEquals("Invalid input data", extractedMessage); - - // Test case: no message key - exceptionMessage = "Some error occurred: {\"error\":\"Something went wrong\"}"; - extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, extractedMessage); - - // Test case: empty exception message - exceptionMessage = ""; - extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); - Assertions.assertEquals(StringUtils.EMPTY, extractedMessage); - } } diff --git a/marketplace-ui/src/app/app.routes.ts b/marketplace-ui/src/app/app.routes.ts index 93f3b9b41..1106a8f60 100644 --- a/marketplace-ui/src/app/app.routes.ts +++ b/marketplace-ui/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { GithubCallbackComponent } from './auth/github-callback/github-callback. import { ErrorPageComponent } from './shared/components/error-page/error-page.component'; import { RedirectPageComponent } from './shared/components/redirect-page/redirect-page.component'; import { ERROR_PAGE } from './shared/constants/common.constant'; +import { SecurityMonitorComponent } from './modules/security-monitor/security-monitor.component'; export const routes: Routes = [ { @@ -15,6 +16,10 @@ export const routes: Routes = [ component: ErrorPageComponent, title: ERROR_PAGE }, + { + path: 'security-monitor', + component: SecurityMonitorComponent + }, { path: '', loadChildren: () => import('./modules/home/home.routes').then(m => m.routes) diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts index aae29c669..b7f944104 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.service.spec.ts @@ -1,47 +1,80 @@ import { TestBed } from '@angular/core/testing'; -import { ProductDetailService } from './product-detail.service'; -import { DisplayValue } from '../../../shared/models/display-value.model'; -import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { environment } from '../../../../environments/environment'; +import { ProductSecurityInfo } from '../../../shared/models/product-security-info-model'; +import { SecurityMonitorService } from '../../security-monitor/security-monitor.service'; +import { SecurityMonitorComponent } from '../../security-monitor/security-monitor.component'; -describe('ProductDetailService', () => { - let service: ProductDetailService; +describe('SecurityMonitorService', () => { + let service: SecurityMonitorService; let httpMock: HttpTestingController; - let httpClient: jasmine.SpyObj; + + const mockApiUrl = environment.apiUrl + '/api/security-monitor'; beforeEach(() => { TestBed.configureTestingModule({ - providers: [ProductDetailService, + imports: [SecurityMonitorComponent], + providers: [ + SecurityMonitorService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - { provide: HttpClient, useValue: httpClient } - ] + ], }); - service = TestBed.inject(ProductDetailService); + + service = TestBed.inject(SecurityMonitorService); httpMock = TestBed.inject(HttpTestingController); }); + afterEach(() => { + httpMock.verify(); + }); + it('should be created', () => { expect(service).toBeTruthy(); }); - it('should have a default productId signal', () => { - expect(service.productId()).toBe(''); - }); + it('should call API with token and return security details', () => { + const mockToken = 'valid-token'; + const mockResponse: ProductSecurityInfo[] = [ + { + repoName: 'repo1', + visibility: 'public', + archived: false, + dependabot: { status: 'ENABLED', alerts: {} }, + codeScanning: { status: 'ENABLED', alerts: {} }, + secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, + branchProtectionEnabled: true, + lastCommitSHA: '12345', + lastCommitDate: new Date(), + }, + ]; - it('should update productId signal', () => { - const newProductId = '12345'; - service.productId.set(newProductId); - expect(service.productId()).toBe(newProductId); - }); + service.getSecurityDetails(mockToken).subscribe((data) => { + expect(data).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); - it('should have a default productNames signal', () => { - expect(service.productNames()).toEqual({} as DisplayValue); + req.flush(mockResponse); }); - it('should update productNames signal', () => { - const newProductNames: DisplayValue = { en: 'en', de: 'de' }; - service.productNames.set(newProductNames); - expect(service.productNames()).toEqual(newProductNames); + it('should handle error response gracefully', () => { + const mockToken = 'invalid-token'; + + service.getSecurityDetails(mockToken).subscribe({ + next: () => fail('Expected an error, but received data.'), + error: (error) => { + expect(error.status).toBe(401); + }, + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); + + req.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); }); -}); \ No newline at end of file +}); diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html new file mode 100644 index 000000000..7d55ad8a5 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.html @@ -0,0 +1,101 @@ +
+
+

GitHub Repository Security Monitor

+

Keep track of your repositories' security status at a glance.

+
+ @if (isAuthenticated) { + +
+ @for (repo of repos; track $index) { +
+
+

{{ repo.repoName }}

+ {{ repo.visibility }} + @if (repo.archived) { + Archived + } +
+
+

🤖Dependabot: + @if (repo.dependabot.status == 'DISABLED') { + Disabled + } + @else if (repo.dependabot.status == 'NO_PERMISSION') { + No permission + } + @else if (hasAlerts(repo.dependabot.alerts)) { + @for (alert of alertKeys(repo.dependabot.alerts); track $index) { + + {{ repo.dependabot.alerts[alert] }} {{ alert }} + + } + } + @else { + No vulnerabilities + } +

+

🖥️Code Scanning: + @if (repo.codeScanning.status == 'DISABLED') { + Disabled + } + @else if (repo.codeScanning.status == 'NO_PERMISSION') { + No permission + } + @else if (hasAlerts(repo.codeScanning.alerts)) { + @for (alert of alertKeys(repo.codeScanning.alerts); track $index) { + + {{ repo.codeScanning.alerts[alert] }} {{ alert }} + + } + } + @else { + No vulnerabilities + } +

+

🔑Secret Scanning: + @if (repo.secretScanning.status == 'DISABLED') { + Disabled + } + @else if (repo.secretScanning.status == 'NO_PERMISSION') { + No permission + } + @else if (repo.secretScanning.numberOfAlerts) { + + {{ repo.secretScanning.numberOfAlerts }} alerts + + } + @else { + No vulnerabilities + } +

+

🚧Branch Protection: + @if (repo.branchProtectionEnabled) { + Enabled + } + @else { + Disabled + } +

+

⏱️Last Commit: {{ repo.lastCommitDate | timeAgo | async }}

+
+
+ } +
+ } + @else { +
+

Please enter your token to access the security page.

+
+ + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
+
+ } +
\ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss new file mode 100644 index 000000000..6515e7b5a --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.scss @@ -0,0 +1,227 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: #f9f9f9; + cursor: default; +} + +.container { + max-width: 1200px; + margin: 20px auto; + padding: 0 15px; +} + +.header { + text-align: center; + padding: 20px 0; + color: #333; + + h2 { + font-size: 4rem; + } +} + +.repo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 15px; +} + +.repo-card { + background-color: #fff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 15px; +} + +.repo-card:hover { + background-color: #f5f5f5; + border-color: #ccc; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + cursor: default; +} + +.repo-card a:hover { + color: #0056b3; + text-decoration: underline; + cursor: pointer; +} + +.repo-header { + display: flex; + align-items: center; + margin-bottom: 10px; + gap: 5px; + + h3 { + margin: 0; + font-size: 1.6rem; + color: #007acc; + } + + h3:hover { + cursor: pointer; + text-decoration: underline; + } + + .visibility { + font-size: 1.1rem; + padding: 3px 8px; + border-radius: 20px; + border: 1px solid gray; + line-height: 12px; + } + + .archived { + font-size: 1.1rem; + padding: 3px 8px; + border-radius: 20px; + border: 1px solid orange; + line-height: 12px; + color: orange; + } +} + +.repo-info { + margin-top: 15px; + + p { + margin: 8px 0; + font-size: 1.5rem; + color: #555; + min-height: 28px; + } + + span { + font-weight: bold; + } +} + +.badge { + display: inline-block; + padding: 7px 10px; + font-size: 1.3rem; + border-radius: 5px; + color: #fff; + margin-left: 5px; + + &.critical { + background-color: #e63946; + } + + &.high { + background-color: #f4a261; + } + + &.medium, &.warning { + background-color: #f4d35e; + color: #333; + } + + &.low, &.note { + background-color: #90be6d; + } + + &.error { + background-color: #d00000; + } + + &.none, &.no-permission { + background-color: #ccc; + color: #666; + } + + &.active { + background-color: #2a9d8f; + } +} + +/* Styling for inactive or zero values */ +.inactive { + color: #bbb; + font-style: italic; +} + +.token-input-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: #f9f9f9; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.token-input-container h3 { + font-size: 1.5rem; + margin-bottom: 20px; + color: #333; +} + +.token-input-container span { + margin-bottom: 20px; + font-size: 1rem; + color: #777; +} + +.token-input-container input { + padding: 10px; + font-size: 1.3rem; + margin-bottom: 10px; + width: 350px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.token-input-container button { + padding: 10px 20px; + font-size: 1.3rem; + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s ease; + margin-left: 10px; + width: 100px; +} + +.token-input-container button:hover { + background-color: #45a049; +} + +.error-message { + color: #f44336; + font-size: 1.2rem; + width: 100%; +} + +.reload-link { + text-decoration: none; + color: #007bff; + font-weight: 600; + cursor: pointer; + margin-left: 10px; + transition: color 0.3s ease, transform 0.2s ease; +} + +.reload-link:hover { + text-decoration: underline; + color: #0056b3; + transform: scale(1.05); +} + +.reload-link:focus { + outline: 2px solid #0056b3; +} + +.repo-info .icon { + display: inline-block; + width: 2rem; + text-align: center; + margin-right: 8px; + font-size: 1.5rem; +} \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts new file mode 100644 index 000000000..e9b2b9de4 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.spec.ts @@ -0,0 +1,128 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SecurityMonitorComponent } from './security-monitor.component'; +import { SecurityMonitorService } from './security-monitor.service'; +import { of, throwError } from 'rxjs'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; +import { HttpErrorResponse } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; + +describe('SecurityMonitorComponent', () => { + let component: SecurityMonitorComponent; + let fixture: ComponentFixture; + let securityMonitorService: jasmine.SpyObj; + + beforeEach(async () => { + const spy = jasmine.createSpyObj('SecurityMonitorService', ['getSecurityDetails']); + + await TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule], + providers: [ + { provide: SecurityMonitorService, useValue: spy }, + { provide: TranslateService, useValue: spy } + ], + }).compileComponents(); + + securityMonitorService = TestBed.inject(SecurityMonitorService) as jasmine.SpyObj; + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SecurityMonitorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should show an error message when token is empty and onSubmit is called', () => { + component.token = ''; + component.onSubmit(); + expect(component.errorMessage).toBe('Token is required'); + }); + + it('should call SecurityMonitorService and display repos when token is valid and response is successful', () => { + const mockRepos: ProductSecurityInfo[] = [ + { + repoName: 'repo1', + visibility: 'public', + archived: false, + dependabot: { status: 'ENABLED', alerts: {} }, + codeScanning: { status: 'ENABLED', alerts: {} }, + secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, + branchProtectionEnabled: true, + lastCommitSHA: '12345', + lastCommitDate: new Date(), + }, + ]; + + securityMonitorService.getSecurityDetails.and.returnValue(of(mockRepos)); + + component.token = 'valid-token'; + component.onSubmit(); + + fixture.detectChanges(); + + expect(securityMonitorService.getSecurityDetails).toHaveBeenCalledWith('valid-token'); + expect(component.repos).toEqual(mockRepos); + expect(component.isAuthenticated).toBeTrue(); + + const repoCards = fixture.debugElement.queryAll(By.css('.repo-card')); + expect(repoCards.length).toBe(mockRepos.length); + expect(repoCards[0].nativeElement.querySelector('h3').textContent).toBe('repo1'); + }); + + it('should handle 401 Unauthorized error correctly', () => { + const mockError = new HttpErrorResponse({ status: 401 }); + + securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); + + component.token = 'invalid-token'; + component.onSubmit(); + + fixture.detectChanges(); + + expect(component.errorMessage).toBe('Unauthorized access.'); + }); + + it('should handle generic error correctly', () => { + const mockError = new HttpErrorResponse({ status: 500 }); + + securityMonitorService.getSecurityDetails.and.returnValue(throwError(() => mockError)); + + component.token = 'invalid-token'; + component.onSubmit(); + + fixture.detectChanges(); + + expect(component.errorMessage).toBe('Failed to fetch security data. Check logs for details.'); + }); + + it('should navigate to the correct URL for a repo page', () => { + spyOn(window, 'open'); + component.navigateToRepoPage('example-repo', 'secretScanning'); + expect(window.open).toHaveBeenCalledWith( + 'https://github.com/axonivy-market/example-repo/security/secret-scanning', + '_blank' + ); + + component.navigateToRepoPage('example-repo', 'lastCommit', 'abc123'); + expect(window.open).toHaveBeenCalledWith( + 'https://github.com/axonivy-market/example-repo/commit/abc123', + '_blank' + ); + }); + + it('should handle empty alerts correctly in hasAlerts', () => { + expect(component.hasAlerts({})).toBeFalse(); + expect(component.hasAlerts({ alert1: 1 })).toBeTrue(); + }); + + it('should return correct alert keys from alertKeys', () => { + const alerts = { alert1: 1, alert2: 2 }; + expect(component.alertKeys(alerts)).toEqual(['alert1', 'alert2']); + }); +}); \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts new file mode 100644 index 000000000..310bda25a --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.component.ts @@ -0,0 +1,130 @@ +import { Component, inject, ViewEncapsulation } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; + +import { SecurityMonitorService } from './security-monitor.service'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; +import { TimeAgoPipe } from '../../shared/pipes/time-ago.pipe'; +import { GITHUB_MARKET_ORG_URL, UNAUTHORIZED } from '../../shared/constants/common.constant'; + +@Component({ + selector: 'app-security-monitor', + standalone: true, + imports: [CommonModule, FormsModule, TimeAgoPipe], + templateUrl: './security-monitor.component.html', + styleUrls: ['./security-monitor.component.scss'], + encapsulation: ViewEncapsulation.Emulated, +}) +export class SecurityMonitorComponent { + isAuthenticated = false; + token = ''; + errorMessage = ''; + repos: ProductSecurityInfo[] = []; + + private readonly securityMonitorService = inject(SecurityMonitorService); + private readonly sessionKeys = { + data: 'security-monitor-data', + token: 'security-monitor-token', + }; + + ngOnInit(): void { + this.loadSessionData(); + } + + onSubmit(): void { + this.token = this.token ?? sessionStorage.getItem(this.sessionKeys.token) ?? ''; + if (!this.token) { + this.handleMissingToken(); + return; + } + + this.errorMessage = ''; + this.fetchSecurityDetails(); + } + + private loadSessionData(): void { + const sessionData = sessionStorage.getItem(this.sessionKeys.data); + if (sessionData) { + try { + this.repos = JSON.parse(sessionData) as ProductSecurityInfo[]; + this.isAuthenticated = true; + } catch (error) { + this.clearSessionData(); + } + } + } + + private handleMissingToken(): void { + this.errorMessage = 'Token is required'; + this.isAuthenticated = false; + this.clearSessionData(); + } + + private fetchSecurityDetails(): void { + this.securityMonitorService.getSecurityDetails(this.token).subscribe({ + next: data => this.handleSuccess(data), + error: (err: HttpErrorResponse) => this.handleError(err), + }); + } + + private handleSuccess(data: ProductSecurityInfo[]): void { + this.repos = data; + this.isAuthenticated = true; + sessionStorage.setItem(this.sessionKeys.token, this.token); + sessionStorage.setItem(this.sessionKeys.data, JSON.stringify(data)); + } + + private handleError(err: HttpErrorResponse): void { + if (err.status === UNAUTHORIZED ) { + this.errorMessage = 'Unauthorized access.'; + } + else { + this.errorMessage = 'Failed to fetch security data. Check logs for details.'; + } + this.isAuthenticated = false; + this.clearSessionData(); + } + + private clearSessionData(): void { + sessionStorage.removeItem(this.sessionKeys.token); + sessionStorage.removeItem(this.sessionKeys.data); + } + + hasAlerts(alerts: Record): boolean { + return Object.keys(alerts).length > 0; + } + + alertKeys(alerts: Record): string[] { + return Object.keys(alerts); + } + + navigateToPage(repoName: string, path: string, additionalPath = ''): void { + const url = `${GITHUB_MARKET_ORG_URL}/${repoName}${path}${additionalPath}`; + window.open(url, '_blank'); + } + + navigateToRepoPage(repoName: string, page: RepoPage, lastCommitSHA?: string): void { + const paths: Record = { + security: '/security', + dependabot: '/security/dependabot', + codeScanning: '/security/code-scanning', + secretScanning: '/security/secret-scanning', + branches: '/settings/branches', + lastCommit: `/commit/${lastCommitSHA ?? ''}`, + }; + + const path = paths[page]; + if (path) { + this.navigateToPage(repoName, path); + } + } +} + +type RepoPage = + | 'security' + | 'dependabot' + | 'codeScanning' + | 'secretScanning' + | 'branches' + | 'lastCommit'; diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts new file mode 100644 index 000000000..8301aed8d --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { SecurityMonitorService } from './security-monitor.service'; +import { environment } from '../../../environments/environment'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; + +describe('SecurityMonitorService', () => { + let service: SecurityMonitorService; + let httpMock: HttpTestingController; + + const mockApiUrl = environment.apiUrl + '/api/security-monitor'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SecurityMonitorService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting() + ] + }); + service = TestBed.inject(SecurityMonitorService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call API with token and return security details', () => { + const mockToken = 'valid-token'; + const mockResponse: ProductSecurityInfo[] = [ + { + repoName: 'repo1', + visibility: 'public', + archived: false, + dependabot: { status: 'ENABLED', alerts: {} }, + codeScanning: { status: 'ENABLED', alerts: {} }, + secretScanning: { status: 'ENABLED', numberOfAlerts: 0 }, + branchProtectionEnabled: true, + lastCommitSHA: '12345', + lastCommitDate: new Date(), + }, + ]; + + service.getSecurityDetails(mockToken).subscribe((data) => { + expect(data).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); + + req.flush(mockResponse); + }); + + it('should handle error response gracefully', () => { + const mockToken = 'invalid-token'; + + service.getSecurityDetails(mockToken).subscribe({ + next: () => fail('Expected an error, but received data.'), + error: (error) => { + expect(error.status).toBe(401); + }, + }); + + const req = httpMock.expectOne(mockApiUrl); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Authorization')).toBe(`Bearer ${mockToken}`); + + req.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' }); + }); +}); \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts new file mode 100644 index 000000000..b2b15ffb5 --- /dev/null +++ b/marketplace-ui/src/app/modules/security-monitor/security-monitor.service.ts @@ -0,0 +1,19 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { ProductSecurityInfo } from '../../shared/models/product-security-info-model'; + +@Injectable({ + providedIn: 'root' +}) +export class SecurityMonitorService { + + private readonly apiUrl = environment.apiUrl + '/api/security-monitor'; + private readonly http = inject(HttpClient); + + getSecurityDetails(token: string): Observable { + const headers = new HttpHeaders().set('Authorization', `Bearer ${token}`); + return this.http.get(this.apiUrl, { headers }); + } +} diff --git a/marketplace-ui/src/app/shared/constants/common.constant.ts b/marketplace-ui/src/app/shared/constants/common.constant.ts index 7631e713b..f2bd52812 100644 --- a/marketplace-ui/src/app/shared/constants/common.constant.ts +++ b/marketplace-ui/src/app/shared/constants/common.constant.ts @@ -236,6 +236,7 @@ export const TOKEN_KEY = 'token'; export const DEFAULT_IMAGE_URL = '/assets/images/misc/axonivy-logo-round.png'; export const DOWNLOAD_URL = 'https://developer.axonivy.com/download'; export const SEARCH_URL = 'https://developer.axonivy.com/search'; +export const GITHUB_MARKET_ORG_URL = 'https://github.com/axonivy-market'; export const SHOW_DEV_VERSION = "showDevVersions"; export const DEFAULT_VENDOR_IMAGE = '/assets/images/misc/axonivy-logo.svg'; export const DEFAULT_VENDOR_IMAGE_BLACK = '/assets/images/misc/axonivy-logo-black.svg'; diff --git a/marketplace-ui/src/app/shared/models/product-security-info-model.ts b/marketplace-ui/src/app/shared/models/product-security-info-model.ts new file mode 100644 index 000000000..a7b15eabe --- /dev/null +++ b/marketplace-ui/src/app/shared/models/product-security-info-model.ts @@ -0,0 +1,20 @@ +export interface ProductSecurityInfo { + repoName: string; + visibility: string; + archived: boolean; + dependabot: { + status: string; + alerts: Record; + }; + codeScanning: { + status: string; + alerts: Record; + }; + secretScanning: { + status: string; + numberOfAlerts: number; + }; + branchProtectionEnabled: boolean; + lastCommitSHA: string; + lastCommitDate: Date; +} \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts b/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts index d3aa7c9dc..0f38206a5 100644 --- a/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts +++ b/marketplace-ui/src/app/shared/pipes/time-ago.pipe.ts @@ -18,7 +18,7 @@ import { TimeAgo } from '../enums/time-ago.enum'; }) export class TimeAgoPipe implements PipeTransform { translateService = inject(TranslateService); - async transform(value?: Date, language?: Language, _args?: []): Promise { + async transform(value?: Date, language: Language = Language.EN, _args?: []): Promise { if (value === undefined || language === undefined) { return ''; } diff --git a/marketplace-ui/src/main.ts b/marketplace-ui/src/main.ts index 3997d76eb..5086f383d 100644 --- a/marketplace-ui/src/main.ts +++ b/marketplace-ui/src/main.ts @@ -6,4 +6,4 @@ import { AppComponent } from './app/app.component'; bootstrapApplication(AppComponent, appConfig).catch(err => { throw err; -}); +}); \ No newline at end of file