mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
feat(security): retry failed scans
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
e02c22e678
commit
680a5d1b94
16 changed files with 513 additions and 8 deletions
|
|
@ -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<SkillLifecycleMutationResponse> retrySecurityScan(
|
||||
@PathVariable Long skillId,
|
||||
@PathVariable Long versionId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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
|
||||
|
|
|
|||
|
|
@ -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<String> platformRoles,
|
||||
Map<Long, NamespaceRole> 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<String> platformRoles,
|
||||
Map<Long, NamespaceRole> namespaceRoles) {
|
||||
Set<String> roles = platformRoles != null ? platformRoles : Set.of();
|
||||
Map<Long, NamespaceRole> 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=Отозвать это ревью может только отправитель
|
||||
|
|
|
|||
|
|
@ -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=只有提交人本人可以撤销此次审核
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PackageEntry> 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 {
|
||||
|
|
|
|||
|
|
@ -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<SecurityAudit> auditCaptor = ArgumentCaptor.forClass(SecurityAudit.class);
|
||||
ArgumentCaptor<ScanTaskOutbox> 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");
|
||||
|
|
|
|||
|
|
@ -34,9 +34,11 @@ function createAudit(overrides: Partial<SecurityAuditRecord> = {}): 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(
|
||||
<SecurityAuditSummary skillId={1} versionId={10} versionStatus="SCAN_FAILED" canRetry />
|
||||
)
|
||||
const unauthorizedHtml = renderToStaticMarkup(
|
||||
<SecurityAuditSummary skillId={1} versionId={10} versionStatus="SCAN_FAILED" />
|
||||
)
|
||||
|
||||
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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<p className="text-xs text-muted-foreground">
|
||||
{t('securityAudit.totalFindings', { count: totalFindings })}
|
||||
</p>
|
||||
{canRetry && versionStatus === 'SCAN_FAILED' && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={retryMutation.isPending}
|
||||
onClick={() => retryMutation.mutate(undefined, {
|
||||
onSuccess: () => toast.success(t('securityAudit.retrySuccess')),
|
||||
onError: (error) => toast.error(
|
||||
t('securityAudit.retryError'),
|
||||
error instanceof Error ? error.message : undefined
|
||||
),
|
||||
})}
|
||||
>
|
||||
{retryMutation.isPending ? t('securityAudit.retrying') : t('securityAudit.retry')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={() => setDialogOpen(true)}>
|
||||
{t('securityAudit.viewDetails')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1620,6 +1620,10 @@
|
|||
"statusScanning": "Сканирование",
|
||||
"statusScanFailed": "Сканирование не удалось",
|
||||
"failureReason": "Причина: {{reason}}",
|
||||
"retry": "Повторить сканирование",
|
||||
"retrying": "Повторное сканирование...",
|
||||
"retrySuccess": "Сканирование запущено повторно",
|
||||
"retryError": "Не удалось повторить сканирование",
|
||||
"remediation": "Рекомендации",
|
||||
"viewDetails": "Подробности",
|
||||
"verdict": {
|
||||
|
|
|
|||
|
|
@ -1588,6 +1588,10 @@
|
|||
"statusScanning": "扫描中",
|
||||
"statusScanFailed": "扫描失败",
|
||||
"failureReason": "失败原因:{{reason}}",
|
||||
"retry": "重新扫描",
|
||||
"retrying": "正在重新扫描...",
|
||||
"retrySuccess": "已重新发起安全扫描",
|
||||
"retryError": "重新扫描失败",
|
||||
"remediation": "修复建议",
|
||||
"viewDetails": "查看详情",
|
||||
"verdict": {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<SecurityAuditSummary skillId={skill.id} versionId={selectedVersionEntry.id} versionStatus={selectedVersionEntry.status} />
|
||||
{canManageSecurityScan && selectedVersionEntry && (
|
||||
<SecurityAuditSummary
|
||||
skillId={skill.id}
|
||||
versionId={selectedVersionEntry.id}
|
||||
versionStatus={selectedVersionEntry.status}
|
||||
canRetry
|
||||
/>
|
||||
)}
|
||||
|
||||
<SkillLabelPanel
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue