From 697bb952a4ac4d5b598eb3aa6bfcf626acf2e87a Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:25:02 +0800 Subject: [PATCH] fix(security): harden scan retry lifecycle Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../service/SecurityScanRetryAppService.java | 31 +++++++++++------ .../skillhub/stream/ScanTaskConsumer.java | 3 +- .../SecurityScanRetryAppServiceTest.java | 20 +++++++---- .../stream/ScanTaskConsumerLoggingTest.java | 5 ++- .../skillhub/stream/ScanTaskConsumerTest.java | 5 ++- .../policy/RouteSecurityPolicyRegistry.java | 1 + .../RouteSecurityPolicyRegistryTest.java | 12 +++++++ .../domain/security/SecurityScanService.java | 23 +++++++++---- .../domain/skill/SkillVersionRepository.java | 3 ++ .../security/SecurityScanServiceTest.java | 34 ++++++++++++++++--- .../infra/jpa/SkillVersionJpaRepository.java | 4 +++ .../security-audit-summary.test.tsx | 30 ++++++++++++++-- web/src/pages/skill-detail.test.tsx | 32 +++++++++++++++++ web/src/pages/skill-detail.tsx | 9 +++-- 14 files changed, 176 insertions(+), 36 deletions(-) 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 index 7c766bdb..d68cf460 100644 --- 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 @@ -55,9 +55,26 @@ public class SecurityScanRetryAppService { 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() + + SkillVersion observedVersion = skillVersionRepository.findById(versionId) + .filter(candidate -> candidate.getSkillId().equals(skillId)) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + if (observedVersion.getStatus() != SkillVersionStatus.SCAN_FAILED + && observedVersion.getStatus() != SkillVersionStatus.SCANNING) { + throw new DomainBadRequestException("error.security.scan.retry.status", observedVersion.getStatus()); + } + if (!securityScanService.isEnabled()) { + throw new DomainBadRequestException("error.security.scan.retry.disabled"); + } + + String bundleKey = bundleKey(skillId, versionId); + if (observedVersion.getStatus() == SkillVersionStatus.SCAN_FAILED + && !objectStorageService.exists(bundleKey)) { + throw new DomainBadRequestException("error.security.scan.retry.bundleMissing"); + } + + SkillVersion version = skillVersionRepository.findByIdForUpdate(versionId) + .filter(candidate -> candidate.getSkillId().equals(skillId)) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); if (version.getStatus() == SkillVersionStatus.SCANNING && hasActiveAttempt(versionId)) { @@ -66,14 +83,6 @@ public class SecurityScanRetryAppService { 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( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java index 548b28be..490ce90b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java @@ -232,7 +232,8 @@ public class ScanTaskConsumer extends AbstractStreamConsumer service.retry( 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null))) .isInstanceOf(DomainBadRequestException.class); verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + verify(skillVersionRepository, never()).findByIdForUpdate(any()); } @Test void retry_rejectsMissingStoredBundle() { - given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version)); + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); given(securityScanService.isEnabled()).willReturn(true); assertThatThrownBy(() -> service.retry( @@ -130,12 +133,15 @@ class SecurityScanRetryAppServiceTest { .isInstanceOf(DomainBadRequestException.class); verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + verify(skillVersionRepository, never()).findByIdForUpdate(any()); } @Test void retry_whenAttemptAlreadyStartedReturnsCurrentStateWithoutDuplicateTask() { version.setStatus(SkillVersionStatus.SCANNING); - given(skillVersionRepository.findBySkillIdForUpdate(8L)).willReturn(List.of(version)); + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); + given(skillVersionRepository.findByIdForUpdate(42L)).willReturn(Optional.of(version)); + given(securityScanService.isEnabled()).willReturn(true); given(securityAuditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) .willReturn(Optional.of(new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-existing"))); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java index 3f295be1..ccdab04d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java @@ -220,7 +220,10 @@ class ScanTaskConsumerLoggingTest { } @Override - public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { + public void processScanResult(String taskId, + Long versionId, + ScannerType scannerType, + SecurityScanResponse response) { } @Override diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index cbaf65af..eacdef88 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -716,7 +716,10 @@ class ScanTaskConsumerTest { } @Override - public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { + public void processScanResult(String taskId, + Long versionId, + ScannerType scannerType, + SecurityScanResponse response) { this.lastVersionId = versionId; this.lastScannerType = scannerType; this.lastResponse = response; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 58b47ae0..23375ed9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -144,6 +144,7 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/skills/*/*", "skill:delete"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/versions/*/security-audit/retry", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/auth/whoami"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 4cde9090..11697808 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -64,6 +64,18 @@ class RouteSecurityPolicyRegistryTest { assertTrue(allowed.allowed()); } + @Test + void authorizeApiToken_requiresPublishScopeForSecurityScanRetry() { + var denied = registry.authorizeApiToken( + "POST", "/api/v1/skills/8/versions/42/security-audit/retry", Set.of("skill:read")); + var allowed = registry.authorizeApiToken( + "POST", "/api/v1/skills/8/versions/42/security-audit/retry", Set.of("skill:publish")); + + assertFalse(denied.allowed()); + assertEquals("skill:publish", denied.requiredScope()); + assertTrue(allowed.allowed()); + } + @Test void authorizeApiToken_requiresDeleteScopeForHardDeleteEndpoint() { var denied = registry.authorizeApiToken("DELETE", "/api/v1/skills/global/demo-skill", Set.of("skill:publish")); 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 59e9c274..6c96c3a7 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 @@ -168,10 +168,15 @@ public class SecurityScanService { } @Transactional - public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { - SecurityAudit audit = auditRepository.findLatestActiveByVersionIdAndScannerType(versionId, scannerType) + public void processScanResult(String taskId, + Long versionId, + ScannerType scannerType, + SecurityScanResponse response) { + SecurityAudit audit = auditRepository.findByTaskId(taskId) + .filter(candidate -> candidate.getSkillVersionId().equals(versionId)) + .filter(candidate -> candidate.getScannerType() == scannerType) .orElseThrow(() -> new IllegalStateException( - "SecurityAudit not found for versionId=" + versionId + ", scannerType=" + scannerType)); + "SecurityAudit not found for taskId=" + taskId)); SkillVersion version = skillVersionRepository.findById(versionId) .orElseThrow(() -> new IllegalStateException("SkillVersion not found: " + versionId)); @@ -185,15 +190,21 @@ public class SecurityScanService { audit.setScannedAt(Instant.now(Clock.systemUTC())); auditRepository.save(audit); - // Only transition from SCANNING — leave PUBLISHED/REJECTED/YANKED untouched - if (version.getStatus() == SkillVersionStatus.SCANNING) { + boolean currentAttempt = auditRepository + .findLatestActiveByVersionIdAndScannerType(versionId, scannerType) + .map(latest -> taskId.equals(latest.getTaskId())) + .orElse(false); + // A late result is retained on its own audit round but cannot complete a newer attempt. + if (currentAttempt && version.getStatus() == SkillVersionStatus.SCANNING) { if (version.getRequestedVisibility() == SkillVisibility.PRIVATE) { version.setStatus(SkillVersionStatus.UPLOADED); } else { version.setStatus(SkillVersionStatus.PENDING_REVIEW); } } - skillVersionRepository.save(version); + if (currentAttempt) { + skillVersionRepository.save(version); + } } private Path saveTempDirectory(Long versionId, List entries) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index b99cc50f..257ef03c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -8,6 +8,9 @@ import java.util.Optional; */ public interface SkillVersionRepository { Optional findById(Long id); + default Optional findByIdForUpdate(Long id) { + return findById(id); + } List findByIdIn(List ids); List findBySkillIdIn(List skillIds); List findBySkillIdInAndStatus(List skillIds, SkillVersionStatus status); 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 cd98b43f..3292e499 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 @@ -274,10 +274,11 @@ class SecurityScanServiceTest { @Test void processScanResult_updatesAuditAndMovesVersionToPendingReview() { - SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); + SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current"); SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); version.setStatus(SkillVersionStatus.SCANNING); + given(auditRepository.findByTaskId("task-current")).willReturn(Optional.of(audit)); given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) .willReturn(Optional.of(audit)); given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); @@ -300,7 +301,7 @@ class SecurityScanServiceTest { 1.25 ); - service.processScanResult(42L, ScannerType.SKILL_SCANNER, response); + service.processScanResult("task-current", 42L, ScannerType.SKILL_SCANNER, response); assertThat(audit.getScanId()).isEqualTo("scan-123"); assertThat(audit.getVerdict()).isEqualTo(SecurityVerdict.DANGEROUS); @@ -377,10 +378,11 @@ class SecurityScanServiceTest { @Test void processScanResult_shouldNotChangeStatusWhenVersionAlreadyPublished() { - SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); + SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-published"); SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); version.setStatus(SkillVersionStatus.PUBLISHED); + given(auditRepository.findByTaskId("task-published")).willReturn(Optional.of(audit)); given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) .willReturn(Optional.of(audit)); given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); @@ -394,7 +396,7 @@ class SecurityScanServiceTest { 0.5 ); - service.processScanResult(42L, ScannerType.SKILL_SCANNER, response); + service.processScanResult("task-published", 42L, ScannerType.SKILL_SCANNER, response); assertThat(audit.getVerdict()).isEqualTo(SecurityVerdict.SAFE); assertThat(audit.getIsSafe()).isTrue(); @@ -402,6 +404,30 @@ class SecurityScanServiceTest { verify(skillVersionRepository).save(version); } + @Test + void processScanResult_forStaleAttemptDoesNotCompleteCurrentAttempt() throws Exception { + SecurityAudit stale = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-stale"); + SecurityAudit current = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current"); + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCANNING); + given(auditRepository.findByTaskId("task-stale")).willReturn(Optional.of(stale)); + given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) + .willReturn(Optional.of(current)); + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); + + service.processScanResult( + "task-stale", + 42L, + ScannerType.SKILL_SCANNER, + new SecurityScanResponse("scan-stale", SecurityVerdict.SAFE, 0, null, List.of(), 0.1) + ); + + assertThat(stale.getScanId()).isEqualTo("scan-stale"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + verify(skillVersionRepository, never()).save(version); + } + private void setId(Object target, Long id) throws Exception { Field field = target.getClass().getDeclaredField("id"); field.setAccessible(true); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index e7c85e1c..4aba319c 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -22,6 +22,10 @@ import org.springframework.stereotype.Repository; */ @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { + + @Override + @Query(value = "SELECT * FROM skill_version WHERE id = :id FOR UPDATE", nativeQuery = true) + Optional findByIdForUpdate(@Param("id") Long id); List findByIdIn(List ids); List findBySkillId(Long skillId); List findBySkillIdIn(List skillIds); 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 a20a51fe..1ae6c18e 100644 --- a/web/src/features/security-audit/security-audit-summary.test.tsx +++ b/web/src/features/security-audit/security-audit-summary.test.tsx @@ -1,5 +1,8 @@ +/** @vitest-environment jsdom */ + import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { SecurityAuditRecord } from './types' import { SecurityAuditSummary } from './security-audit-summary' @@ -34,13 +37,18 @@ function createAudit(overrides: Partial = {}): SecurityAudi } let mockAudits: SecurityAuditRecord[] | undefined = undefined -const retryMutation = { mutate: vi.fn(), isPending: false } +const { retryMutation, toastMocks } = vi.hoisted(() => ({ + retryMutation: { mutate: vi.fn(), isPending: false }, + toastMocks: { success: vi.fn(), error: vi.fn() }, +})) vi.mock('./use-security-audit', () => ({ useSecurityAudits: () => ({ data: mockAudits }), useRetrySecurityScan: () => retryMutation, })) +vi.mock('@/shared/lib/toast', () => ({ toast: toastMocks })) + // Mock the Dialog components to avoid Radix UI portal / context issues in static render vi.mock('@/shared/ui/dialog', () => ({ Dialog: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -56,6 +64,10 @@ vi.mock('./security-audit-section', () => ({ })) describe('SecurityAuditSummary', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) it('returns null when audits is undefined', () => { mockAudits = undefined @@ -132,6 +144,20 @@ describe('SecurityAuditSummary', () => { expect(unauthorizedHtml).not.toContain('securityAudit.retry') }) + it('starts retry and exposes success and failure feedback callbacks', () => { + mockAudits = [createAudit({ scannedAt: null })] + render() + + fireEvent.click(screen.getByRole('button', { name: 'securityAudit.retry' })) + + expect(retryMutation.mutate).toHaveBeenCalledOnce() + const options = retryMutation.mutate.mock.calls[0]?.[1] + options.onSuccess() + expect(toastMocks.success).toHaveBeenCalledWith('securityAudit.retrySuccess') + options.onError(new Error('scanner unavailable')) + expect(toastMocks.error).toHaveBeenCalledWith('securityAudit.retryError', 'scanner unavailable') + }) + it('renders the total findings count across all audits', () => { mockAudits = [ createAudit({ id: 1, findingsCount: 3 }), diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index b9ba9a71..c65648d9 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -74,6 +74,12 @@ vi.mock('@/features/report/use-skill-reports', () => ({ useSubmitSkillReport: () => ({ mutateAsync: vi.fn(), isPending: false }), })) +vi.mock('@/features/security-audit/security-audit-summary', () => ({ + SecurityAuditSummary: ({ versionId, versionStatus }: { versionId: number; versionStatus?: string }) => ( +
audit:{versionId}:{versionStatus}
+ ), +})) + vi.mock('@/shared/lib/toast', () => ({ toast: { success: toastMocks.success, error: toastMocks.error }, })) @@ -499,6 +505,32 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('skillDetail.versionStatusScanFailed') }) + it('binds scan retry to the failed owner preview when a published version remains visible', () => { + useSkillDetailMock.mockReturnValue({ + data: createSkill({ + canManageLifecycle: true, + headlineVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, + publishedVersion: { id: 10, version: '1.0.0', status: 'PUBLISHED' }, + ownerPreviewVersion: { id: 12, version: '1.2.0', status: 'SCAN_FAILED' }, + resolutionMode: 'PUBLISHED', + }), + isLoading: false, + isFetching: false, + error: null, + }) + useSkillVersionsMock.mockReturnValue({ + data: [ + { id: 10, version: '1.0.0', status: 'PUBLISHED', downloadAvailable: true }, + { id: 12, version: '1.2.0', status: 'SCAN_FAILED', downloadAvailable: false }, + ], + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('audit:12:SCAN_FAILED') + expect(html).not.toContain('audit:10:PUBLISHED') + }) + it('allows long pending review versions to wrap inside the review card', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 47842d82..3122e98a 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -202,6 +202,9 @@ export function SkillDetailPage() { const canManageSecurityScan = Boolean(skill && user && ( skill.canManageLifecycle || hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') )) + const securityAuditVersion = ownerPreviewVersion?.status === 'SCAN_FAILED' + ? ownerPreviewVersion + : selectedVersionEntry const isVersionDownloadable = selectedVersionEntry?.status === 'PUBLISHED' && (selectedVersionEntry?.downloadAvailable ?? false) useEffect(() => { @@ -1259,11 +1262,11 @@ export function SkillDetailPage() { description={skill.summary} /> - {canManageSecurityScan && selectedVersionEntry && ( + {canManageSecurityScan && securityAuditVersion && ( )}