diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java index 3666c46c..9ea74d6d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java @@ -19,9 +19,14 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SecurityAuditResponse; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SecurityScanRetryAppService; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -41,19 +46,40 @@ public class SecurityAuditController extends BaseApiController { private final SkillVersionRepository skillVersionRepository; private final VisibilityChecker visibilityChecker; private final ObjectMapper objectMapper; + private final SecurityScanRetryAppService securityScanRetryAppService; public SecurityAuditController(SecurityAuditRepository securityAuditRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, VisibilityChecker visibilityChecker, ApiResponseFactory responseFactory, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + SecurityScanRetryAppService securityScanRetryAppService) { super(responseFactory); this.securityAuditRepository = securityAuditRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.visibilityChecker = visibilityChecker; this.objectMapper = objectMapper; + this.securityScanRetryAppService = securityScanRetryAppService; + } + + @PostMapping("/retry") + public ApiResponse retrySecurityScan( + @PathVariable Long skillId, + @PathVariable Long versionId, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest request) { + SkillLifecycleMutationResponse result = securityScanRetryAppService.retry( + skillId, + versionId, + principal.userId(), + principal.platformRoles(), + userNsRoles, + AuditRequestContext.from(request) + ); + return ok("security_audit.retry.started", result); } @GetMapping diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SecurityScanRetryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SecurityScanRetryAppService.java new file mode 100644 index 00000000..7c766bdb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SecurityScanRetryAppService.java @@ -0,0 +1,123 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.security.ScanTask; +import com.iflytek.skillhub.domain.security.ScannerType; +import com.iflytek.skillhub.domain.security.SecurityAuditRepository; +import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SecurityScanRetryAppService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SecurityAuditRepository securityAuditRepository; + private final SecurityScanService securityScanService; + private final ObjectStorageService objectStorageService; + private final AuditLogService auditLogService; + + public SecurityScanRetryAppService(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SecurityAuditRepository securityAuditRepository, + SecurityScanService securityScanService, + ObjectStorageService objectStorageService, + AuditLogService auditLogService) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.securityAuditRepository = securityAuditRepository; + this.securityScanService = securityScanService; + this.objectStorageService = objectStorageService; + this.auditLogService = auditLogService; + } + + @Transactional + public SkillLifecycleMutationResponse retry(Long skillId, + Long versionId, + String userId, + Set platformRoles, + Map namespaceRoles, + AuditRequestContext auditContext) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + authorize(skill, userId, platformRoles, namespaceRoles); + SkillVersion version = skillVersionRepository.findBySkillIdForUpdate(skillId).stream() + .filter(candidate -> candidate.getId().equals(versionId)) + .findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + if (version.getStatus() == SkillVersionStatus.SCANNING && hasActiveAttempt(versionId)) { + return response(skillId, versionId); + } + if (version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + throw new DomainBadRequestException("error.security.scan.retry.status", version.getStatus()); + } + if (!securityScanService.isEnabled()) { + throw new DomainBadRequestException("error.security.scan.retry.disabled"); + } + + String bundleKey = bundleKey(skillId, versionId); + if (!objectStorageService.exists(bundleKey)) { + throw new DomainBadRequestException("error.security.scan.retry.bundleMissing"); + } + + ScanTask task = securityScanService.retryStoredBundleScan(version, bundleKey, userId); + auditLogService.record( + userId, + "RETRY_SECURITY_SCAN", + "SKILL_VERSION", + versionId, + null, + auditContext.clientIp(), + auditContext.userAgent(), + AuditDetail.of("taskId", task.taskId(), "version", version.getVersion()) + ); + return response(skillId, versionId); + } + + private void authorize(Skill skill, + String userId, + Set platformRoles, + Map namespaceRoles) { + Set roles = platformRoles != null ? platformRoles : Set.of(); + Map memberships = namespaceRoles != null ? namespaceRoles : Map.of(); + NamespaceRole namespaceRole = memberships.get(skill.getNamespaceId()); + boolean allowed = skill.getOwnerId().equals(userId) + || namespaceRole == NamespaceRole.OWNER + || namespaceRole == NamespaceRole.ADMIN + || roles.contains("SUPER_ADMIN") + || roles.contains("SKILL_ADMIN"); + if (!allowed) { + throw new DomainForbiddenException("error.forbidden"); + } + } + + private boolean hasActiveAttempt(Long versionId) { + return securityAuditRepository + .findLatestActiveByVersionIdAndScannerType(versionId, ScannerType.SKILL_SCANNER) + .filter(audit -> audit.getScannedAt() == null) + .isPresent(); + } + + private String bundleKey(Long skillId, Long versionId) { + return String.format("packages/%d/%d/bundle.zip", skillId, versionId); + } + + private SkillLifecycleMutationResponse response(Long skillId, Long versionId) { + return new SkillLifecycleMutationResponse(skillId, versionId, "RETRY_SECURITY_SCAN", "SCANNING"); + } +} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index c6d80a2a..0f807c91 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -102,6 +102,10 @@ error.skill.publish.skillMd.notFound=SKILL.md not found error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0} error.skill.publish.precheck.failed=Pre-publish validation failed: {0} error.security.scanner.required=Security scanner must be enabled before publishing public or namespace-visible skills +error.security.scan.retry.status=Only a failed security scan can be retried (current status: {0}) +error.security.scan.retry.disabled=Security scanning is disabled; enable it before retrying +error.security.scan.retry.bundleMissing=The stored package is unavailable; upload the skill again to retry scanning +security_audit.retry.started=Security scan retry started error.skill.publish.archived=Archived skill must be restored before publishing: {0} review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0} review.withdraw.not_submitter=Only the submitter can withdraw this review diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 0302c2f5..f2416492 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -99,6 +99,10 @@ error.skill.publish.skillMd.notFound=SKILL.md не найден error.skill.publish.precheck.confirmRequired=Предупреждения перед публикацией требуют подтверждения:\n{0} error.skill.publish.precheck.failed=Проверка перед публикацией не пройдена: {0} error.security.scanner.required=Перед публикацией публичных или видимых в пространстве имён скиллов необходимо включить сканер безопасности +error.security.scan.retry.status=Повторить можно только неудачное сканирование безопасности (текущий статус: {0}) +error.security.scan.retry.disabled=Сканер безопасности отключён; включите его перед повторной попыткой +error.security.scan.retry.bundleMissing=Сохранённый пакет недоступен; загрузите скилл заново для повторного сканирования +security_audit.retry.started=Повторное сканирование безопасности запущено error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0} review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0} review.withdraw.not_submitter=Отозвать это ревью может только отправитель diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 8b3e7a66..5fb0f83f 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -102,6 +102,10 @@ error.skill.publish.skillMd.notFound=未找到 SKILL.md error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0} error.skill.publish.precheck.failed=预发布校验失败:{0} error.security.scanner.required=发布公开或命名空间可见技能前必须启用安全扫描器 +error.security.scan.retry.status=只有安全扫描失败的版本才能重试(当前状态:{0}) +error.security.scan.retry.disabled=安全扫描器未启用,请启用后再重试 +error.security.scan.retry.bundleMissing=原技能包已不存在,请重新上传技能后再扫描 +security_audit.retry.started=已重新发起安全扫描 error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} review.withdraw.not_submitter=只有提交人本人可以撤销此次审核 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java index eb5f87d6..e19be679 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java @@ -15,6 +15,8 @@ import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.service.SecurityScanRetryAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -34,7 +36,9 @@ import java.util.Set; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -61,6 +65,37 @@ class SecurityAuditControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private SecurityScanRetryAppService securityScanRetryAppService; + + @Test + void retrySecurityScan_returnsScanningState() throws Exception { + given(securityScanRetryAppService.retry( + org.mockito.ArgumentMatchers.eq(8L), + org.mockito.ArgumentMatchers.eq(42L), + org.mockito.ArgumentMatchers.eq("owner-1"), + org.mockito.ArgumentMatchers.eq(Set.of()), + org.mockito.ArgumentMatchers.anyMap(), + org.mockito.ArgumentMatchers.any())) + .willReturn(new SkillLifecycleMutationResponse(8L, 42L, "RETRY_SECURITY_SCAN", "SCANNING")); + + mockMvc.perform(post("/api/v1/skills/8/versions/42/security-audit/retry") + .with(auth("owner-1")) + .with(csrf()) + .requestAttr("userNsRoles", Map.of())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.action").value("RETRY_SECURITY_SCAN")) + .andExpect(jsonPath("$.data.status").value("SCANNING")); + } + + @Test + void retrySecurityScan_requiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/skills/8/versions/42/security-audit/retry").with(csrf())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + @Test void getSecurityAudit_returnsAuditPayload() throws Exception { SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryAppServiceTest.java new file mode 100644 index 00000000..87693db6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryAppServiceTest.java @@ -0,0 +1,172 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.security.ScanTask; +import com.iflytek.skillhub.domain.security.ScannerType; +import com.iflytek.skillhub.domain.security.SecurityAudit; +import com.iflytek.skillhub.domain.security.SecurityAuditRepository; +import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SecurityScanRetryAppServiceTest { + + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; + @Mock private SecurityAuditRepository securityAuditRepository; + @Mock private SecurityScanService securityScanService; + @Mock private ObjectStorageService objectStorageService; + @Mock private AuditLogService auditLogService; + + private SecurityScanRetryAppService service; + private Skill skill; + private SkillVersion version; + + @BeforeEach + void setUp() { + service = new SecurityScanRetryAppService( + skillRepository, + skillVersionRepository, + securityAuditRepository, + securityScanService, + objectStorageService, + auditLogService + ); + skill = skill(8L, "owner-1"); + version = version(42L, SkillVersionStatus.SCAN_FAILED); + given(skillRepository.findById(8L)).willReturn(Optional.of(skill)); + } + + @Test + void retry_asOwnerCreatesNewAttemptAndAuditLog() { + given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version)); + given(securityScanService.isEnabled()).willReturn(true); + given(objectStorageService.exists("packages/8/42/bundle.zip")).willReturn(true); + given(securityScanService.retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1")) + .willReturn(new ScanTask("task-new", 42L, null, "packages/8/42/bundle.zip", + "owner-1", 1L, Map.of())); + + var result = service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext("127.0.0.1", "test")); + + assertThat(result.status()).isEqualTo("SCANNING"); + verify(securityScanService).retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1"); + verify(auditLogService).record( + "owner-1", "RETRY_SECURITY_SCAN", "SKILL_VERSION", 42L, + null, "127.0.0.1", "test", "{\"taskId\":\"task-new\",\"version\":\"1.0.0\"}"); + } + + @Test + void retry_allowsNamespaceAdminAndPlatformSecurityAdmin() { + given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version)); + given(securityScanService.isEnabled()).willReturn(true); + given(objectStorageService.exists("packages/8/42/bundle.zip")).willReturn(true); + given(securityScanService.retryStoredBundleScan(any(), any(), any())) + .willReturn(new ScanTask("task-new", 42L, null, "bundle", "admin", 1L, Map.of())); + + service.retry(8L, 42L, "namespace-admin", Set.of(), Map.of(5L, NamespaceRole.ADMIN), + new AuditRequestContext(null, null)); + version.setStatus(SkillVersionStatus.SCAN_FAILED); + service.retry(8L, 42L, "security-admin", Set.of("SKILL_ADMIN"), Map.of(), + new AuditRequestContext(null, null)); + + verify(securityScanService, org.mockito.Mockito.times(2)).retryStoredBundleScan(any(), any(), any()); + } + + @Test + void retry_rejectsUnauthorizedUserBeforeReadingVersionState() { + assertThatThrownBy(() -> service.retry( + 8L, 42L, "viewer", Set.of(), Map.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainForbiddenException.class); + + verify(skillVersionRepository, never()).findBySkillIdForUpdate(any()); + } + + @Test + void retry_rejectsNonFailedVersion() { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version)); + + assertThatThrownBy(() -> service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainBadRequestException.class); + + verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + } + + @Test + void retry_rejectsMissingStoredBundle() { + given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version)); + given(securityScanService.isEnabled()).willReturn(true); + + assertThatThrownBy(() -> service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainBadRequestException.class); + + verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + } + + @Test + void retry_whenAttemptAlreadyStartedReturnsCurrentStateWithoutDuplicateTask() { + version.setStatus(SkillVersionStatus.SCANNING); + given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version)); + given(securityAuditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) + .willReturn(Optional.of(new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-existing"))); + + var result = service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null)); + + assertThat(result.status()).isEqualTo("SCANNING"); + verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + private Skill skill(Long id, String ownerId) { + Skill value = new Skill(5L, "demo", ownerId, SkillVisibility.PRIVATE); + setField(value, "id", id); + return value; + } + + private SkillVersion version(Long id, SkillVersionStatus status) { + SkillVersion value = new SkillVersion(8L, "1.0.0", "owner-1"); + setField(value, "id", id); + value.setStatus(status); + return value; + } + + private void setField(Object target, String name, Object value) { + try { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 61a8578a..59e9c274 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -69,6 +69,27 @@ public class SecurityScanService { return enabled; } + @Transactional + public ScanTask retryStoredBundleScan(SkillVersion version, String bundleKey, String publisherId) { + if (!enabled) { + throw new IllegalStateException("Security scanner is disabled"); + } + if (version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + throw new IllegalStateException("Only SCAN_FAILED versions can be retried"); + } + ScanTask scanTask = new ScanTask( + UUID.randomUUID().toString(), + version.getId(), + null, + bundleKey, + publisherId, + System.currentTimeMillis(), + Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue()) + ); + persistScanAttempt(version, scanTask); + return scanTask; + } + @Transactional public void triggerScan(Long versionId, List entries, String publisherId) { if (!enabled) { @@ -87,7 +108,6 @@ public class SecurityScanService { } else { packagePath = saveTempDirectory(versionId, entries).toString(); } - // Always create a new audit record — supports multiple rounds per version final ScanTask scanTask = new ScanTask( UUID.randomUUID().toString(), versionId, @@ -97,7 +117,12 @@ public class SecurityScanService { System.currentTimeMillis(), Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue()) ); - auditRepository.save(new SecurityAudit(versionId, ScannerType.SKILL_SCANNER, scanTask.taskId())); + persistScanAttempt(version, scanTask); + } + + private void persistScanAttempt(SkillVersion version, ScanTask scanTask) { + // A new record preserves prior scan history while identifying this attempt independently. + auditRepository.save(new SecurityAudit(version.getId(), ScannerType.SKILL_SCANNER, scanTask.taskId())); if (scanTaskOutboxRepository != null) { scanTaskOutboxRepository.save(new ScanTaskOutbox(scanTask)); } else { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java index b5e90381..cd98b43f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java @@ -136,6 +136,50 @@ class SecurityScanServiceTest { assertThat(task.bundleKey()).isEqualTo("packages/8/42/bundle.zip"); } + @Test + void retryStoredBundleScan_createsFreshAuditAndDurableOutbox() throws Exception { + ScanTaskOutboxRepository outboxRepository = org.mockito.Mockito.mock(ScanTaskOutboxRepository.class); + service = new SecurityScanService( + auditRepository, + skillVersionRepository, + scanTaskProducer, + new ObjectMapper(), + "local", + true, + outboxRepository + ); + SkillVersion version = new SkillVersion(8L, "1.0.0", "owner-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCAN_FAILED); + + ScanTask task = service.retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1"); + + ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(SecurityAudit.class); + ArgumentCaptor outboxCaptor = ArgumentCaptor.forClass(ScanTaskOutbox.class); + verify(auditRepository).save(auditCaptor.capture()); + verify(outboxRepository).save(outboxCaptor.capture()); + verify(scanTaskProducer, never()).publishScanTask(any()); + verify(skillVersionRepository).save(version); + assertThat(auditCaptor.getValue().getTaskId()).isEqualTo(task.taskId()); + assertThat(outboxCaptor.getValue().toScanTask()).isEqualTo(task); + assertThat(task.bundleKey()).isEqualTo("packages/8/42/bundle.zip"); + assertThat(task.metadata()).containsEntry("scannerType", "skill-scanner"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + } + + @Test + void retryStoredBundleScan_rejectsNonFailedVersion() throws Exception { + SkillVersion version = new SkillVersion(8L, "1.0.0", "owner-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCANNING); + + assertThatThrownBy(() -> service.retryStoredBundleScan(version, "bundle.zip", "owner-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SCAN_FAILED"); + + verify(auditRepository, never()).save(any()); + } + @Test void triggerScan_defersTaskPublishingUntilTransactionCommit() throws Exception { SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); diff --git a/web/src/features/security-audit/security-audit-summary.test.tsx b/web/src/features/security-audit/security-audit-summary.test.tsx index b75f7ab3..a20a51fe 100644 --- a/web/src/features/security-audit/security-audit-summary.test.tsx +++ b/web/src/features/security-audit/security-audit-summary.test.tsx @@ -34,9 +34,11 @@ function createAudit(overrides: Partial = {}): SecurityAudi } let mockAudits: SecurityAuditRecord[] | undefined = undefined +const retryMutation = { mutate: vi.fn(), isPending: false } vi.mock('./use-security-audit', () => ({ useSecurityAudits: () => ({ data: mockAudits }), + useRetrySecurityScan: () => retryMutation, })) // Mock the Dialog components to avoid Radix UI portal / context issues in static render @@ -116,6 +118,20 @@ describe('SecurityAuditSummary', () => { expect(html).not.toContain('securityAudit.statusScanning') }) + it('renders retry only for an authorized failed version', () => { + mockAudits = [createAudit({ scannedAt: null })] + + const failedHtml = renderToStaticMarkup( + + ) + const unauthorizedHtml = renderToStaticMarkup( + + ) + + expect(failedHtml).toContain('securityAudit.retry') + expect(unauthorizedHtml).not.toContain('securityAudit.retry') + }) + it('renders the total findings count across all audits', () => { mockAudits = [ createAudit({ id: 1, findingsCount: 3 }), diff --git a/web/src/features/security-audit/security-audit-summary.tsx b/web/src/features/security-audit/security-audit-summary.tsx index c9f306d4..9830751a 100644 --- a/web/src/features/security-audit/security-audit-summary.tsx +++ b/web/src/features/security-audit/security-audit-summary.tsx @@ -4,7 +4,8 @@ import { Shield } from 'lucide-react' import { Card } from '@/shared/ui/card' import { Button } from '@/shared/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog' -import { useSecurityAudits } from './use-security-audit' +import { toast } from '@/shared/lib/toast' +import { useRetrySecurityScan, useSecurityAudits } from './use-security-audit' import { getSecurityAuditDisplayState } from './display-state' import { VerdictBadge } from './verdict-badge' import { SecurityAuditSection } from './security-audit-section' @@ -13,12 +14,14 @@ interface SecurityAuditSummaryProps { skillId: number versionId: number versionStatus?: string + canRetry?: boolean } -export function SecurityAuditSummary({ skillId, versionId, versionStatus }: SecurityAuditSummaryProps) { +export function SecurityAuditSummary({ skillId, versionId, versionStatus, canRetry = false }: SecurityAuditSummaryProps) { const { t } = useTranslation() const { data: audits } = useSecurityAudits(skillId, versionId) const [dialogOpen, setDialogOpen] = useState(false) + const retryMutation = useRetrySecurityScan(skillId, versionId) if (!audits || audits.length === 0) { return null @@ -49,6 +52,22 @@ export function SecurityAuditSummary({ skillId, versionId, versionStatus }: Secu

{t('securityAudit.totalFindings', { count: totalFindings })}

+ {canRetry && versionStatus === 'SCAN_FAILED' && ( + + )} diff --git a/web/src/features/security-audit/use-security-audit.ts b/web/src/features/security-audit/use-security-audit.ts index 4098f3e6..88bc3278 100644 --- a/web/src/features/security-audit/use-security-audit.ts +++ b/web/src/features/security-audit/use-security-audit.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, fetchJson } from '@/api/client' import type { SecurityAuditRecord } from './types' @@ -31,3 +31,16 @@ export function useSecurityAudits( retry: false, }) } + +export function useRetrySecurityScan(skillId: number, versionId: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => fetchJson(`/api/v1/skills/${skillId}/versions/${versionId}/security-audit/retry`, { + method: 'POST', + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['security-audits', skillId, versionId] }) + void queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e8b12806..3f0006ad 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1589,6 +1589,10 @@ "statusScanning": "Scanning", "statusScanFailed": "Scan Failed", "failureReason": "Reason: {{reason}}", + "retry": "Retry scan", + "retrying": "Retrying...", + "retrySuccess": "Security scan restarted", + "retryError": "Could not retry security scan", "remediation": "Remediation", "viewDetails": "View Details", "verdict": { diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index c40a39c6..899e94b4 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1620,6 +1620,10 @@ "statusScanning": "Сканирование", "statusScanFailed": "Сканирование не удалось", "failureReason": "Причина: {{reason}}", + "retry": "Повторить сканирование", + "retrying": "Повторное сканирование...", + "retrySuccess": "Сканирование запущено повторно", + "retryError": "Не удалось повторить сканирование", "remediation": "Рекомендации", "viewDetails": "Подробности", "verdict": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b34b0c07..24dd2c9e 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1588,6 +1588,10 @@ "statusScanning": "扫描中", "statusScanFailed": "扫描失败", "failureReason": "失败原因:{{reason}}", + "retry": "重新扫描", + "retrying": "正在重新扫描...", + "retrySuccess": "已重新发起安全扫描", + "retryError": "重新扫描失败", "remediation": "修复建议", "viewDetails": "查看详情", "verdict": { diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index db43c094..47842d82 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -199,6 +199,9 @@ export function SkillDetailPage() { const canReport = skill?.canReport ?? true const canHardDeleteSkill = Boolean(skill && user && (skill.ownerId === user.userId || hasRole('SUPER_ADMIN'))) const canManageLabels = Boolean(skill && user && (skill.canManageLifecycle || hasRole('SUPER_ADMIN'))) + const canManageSecurityScan = Boolean(skill && user && ( + skill.canManageLifecycle || hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') + )) const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false) useEffect(() => { @@ -1256,8 +1259,13 @@ export function SkillDetailPage() { description={skill.summary} /> - {skill.canManageLifecycle && selectedVersionEntry && ( - + {canManageSecurityScan && selectedVersionEntry && ( + )}