diff --git a/central/graphql/resolvers/gen/main.go b/central/graphql/resolvers/gen/main.go index 29ac8258bac0b..3ca94ad62b263 100644 --- a/central/graphql/resolvers/gen/main.go +++ b/central/graphql/resolvers/gen/main.go @@ -69,6 +69,7 @@ var ( reflect.TypeOf((*v1.SearchResult)(nil)), }, SkipResolvers: []reflect.Type{ + reflect.TypeOf(storage.BaseImageInfo{}), reflect.TypeOf(storage.EmbeddedVulnerability{}), reflect.TypeOf(storage.EmbeddedImageScanComponent{}), reflect.TypeOf(storage.EmbeddedNodeScanComponent{}), @@ -82,6 +83,10 @@ var ( ParentType: reflect.TypeOf(storage.Image{}), FieldName: "Scan", }, + { + ParentType: reflect.TypeOf(storage.Image{}), + FieldName: "BaseImageInfo", + }, { ParentType: reflect.TypeOf(storage.ImageV2{}), FieldName: "Scan", @@ -90,6 +95,10 @@ var ( ParentType: reflect.TypeOf(storage.ImageV2{}), FieldName: "ScanStats", }, + { + ParentType: reflect.TypeOf(storage.ImageV2{}), + FieldName: "BaseImageInfo", + }, { ParentType: reflect.TypeOf(storage.ImageScan{}), FieldName: "Components", diff --git a/central/graphql/resolvers/generated.go b/central/graphql/resolvers/generated.go index b5e51cf34a994..253ca2ce23841 100644 --- a/central/graphql/resolvers/generated.go +++ b/central/graphql/resolvers/generated.go @@ -158,12 +158,6 @@ func registerGeneratedTypes(builder generator.SchemaBuilder) { utils.Must(builder.AddType("AzureProviderMetadata", []string{ "subscriptionId: String!", })) - utils.Must(builder.AddType("BaseImageInfo", []string{ - "baseImageDigest: String!", - "baseImageFullName: String!", - "baseImageId: String!", - "created: Time", - })) generator.RegisterProtoEnum(builder, reflect.TypeOf(storage.BooleanOperator(0))) utils.Must(builder.AddType("CSCC", []string{ "serviceAccount: String!", @@ -721,7 +715,6 @@ func registerGeneratedTypes(builder generator.SchemaBuilder) { "value: String!", })) utils.Must(builder.AddType("Image", []string{ - "baseImageInfo: [BaseImageInfo]!", "id: ID!", "isClusterLocal: Boolean!", "lastUpdated: Time", @@ -785,7 +778,6 @@ func registerGeneratedTypes(builder generator.SchemaBuilder) { })) generator.RegisterProtoEnum(builder, reflect.TypeOf(storage.ImageSignatureVerificationResult_Status(0))) utils.Must(builder.AddType("ImageV2", []string{ - "baseImageInfo: [BaseImageInfo]!", "digest: String!", "id: ID!", "isClusterLocal: Boolean!", @@ -3021,68 +3013,6 @@ func (resolver *azureProviderMetadataResolver) SubscriptionId(ctx context.Contex return value } -type baseImageInfoResolver struct { - ctx context.Context - root *Resolver - data *storage.BaseImageInfo -} - -func (resolver *Resolver) wrapBaseImageInfo(value *storage.BaseImageInfo, ok bool, err error) (*baseImageInfoResolver, error) { - if !ok || err != nil || value == nil { - return nil, err - } - return &baseImageInfoResolver{root: resolver, data: value}, nil -} - -func (resolver *Resolver) wrapBaseImageInfos(values []*storage.BaseImageInfo, err error) ([]*baseImageInfoResolver, error) { - if err != nil || len(values) == 0 { - return nil, err - } - output := make([]*baseImageInfoResolver, len(values)) - for i, v := range values { - output[i] = &baseImageInfoResolver{root: resolver, data: v} - } - return output, nil -} - -func (resolver *Resolver) wrapBaseImageInfoWithContext(ctx context.Context, value *storage.BaseImageInfo, ok bool, err error) (*baseImageInfoResolver, error) { - if !ok || err != nil || value == nil { - return nil, err - } - return &baseImageInfoResolver{ctx: ctx, root: resolver, data: value}, nil -} - -func (resolver *Resolver) wrapBaseImageInfosWithContext(ctx context.Context, values []*storage.BaseImageInfo, err error) ([]*baseImageInfoResolver, error) { - if err != nil || len(values) == 0 { - return nil, err - } - output := make([]*baseImageInfoResolver, len(values)) - for i, v := range values { - output[i] = &baseImageInfoResolver{ctx: ctx, root: resolver, data: v} - } - return output, nil -} - -func (resolver *baseImageInfoResolver) BaseImageDigest(ctx context.Context) string { - value := resolver.data.GetBaseImageDigest() - return value -} - -func (resolver *baseImageInfoResolver) BaseImageFullName(ctx context.Context) string { - value := resolver.data.GetBaseImageFullName() - return value -} - -func (resolver *baseImageInfoResolver) BaseImageId(ctx context.Context) string { - value := resolver.data.GetBaseImageId() - return value -} - -func (resolver *baseImageInfoResolver) Created(ctx context.Context) (*graphql.Time, error) { - value := resolver.data.GetCreated() - return protocompat.ConvertTimestampToGraphqlTimeOrError(value) -} - func toBooleanOperator(value *string) storage.BooleanOperator { if value != nil { return storage.BooleanOperator(storage.BooleanOperator_value[*value]) @@ -8649,12 +8579,6 @@ func (resolver *imageResolver) ensureData(ctx context.Context) { } } -func (resolver *imageResolver) BaseImageInfo(ctx context.Context) ([]*baseImageInfoResolver, error) { - resolver.ensureData(ctx) - value := resolver.data.GetBaseImageInfo() - return resolver.root.wrapBaseImageInfos(value, nil) -} - func (resolver *imageResolver) Id(ctx context.Context) graphql.ID { value := resolver.data.GetId() if resolver.data == nil { @@ -9352,12 +9276,6 @@ func (resolver *imageV2Resolver) ensureData(ctx context.Context) { } } -func (resolver *imageV2Resolver) BaseImageInfo(ctx context.Context) ([]*baseImageInfoResolver, error) { - resolver.ensureData(ctx) - value := resolver.data.GetBaseImageInfo() - return resolver.root.wrapBaseImageInfos(value, nil) -} - func (resolver *imageV2Resolver) Digest(ctx context.Context) string { resolver.ensureData(ctx) value := resolver.data.GetDigest() diff --git a/central/graphql/resolvers/images.go b/central/graphql/resolvers/images.go index afdb3a04b9659..ce9de9967635a 100644 --- a/central/graphql/resolvers/images.go +++ b/central/graphql/resolvers/images.go @@ -2,6 +2,7 @@ package resolvers import ( "context" + "slices" "time" "github.com/graph-gophers/graphql-go" @@ -48,9 +49,9 @@ type ImageResolver interface { Signature(ctx context.Context) (*imageSignatureResolver, error) SignatureVerificationData(ctx context.Context) (*imageSignatureVerificationDataResolver, error) TopCvss(ctx context.Context) float64 - BaseImageInfo(ctx context.Context) ([]*baseImageInfoResolver, error) UnknownCveCount(ctx context.Context) int32 + BaseImage(ctx context.Context) (*baseImageResolver, error) Deployments(ctx context.Context, args PaginatedQuery) ([]*deploymentResolver, error) DeploymentCount(ctx context.Context, args RawQuery) (int32, error) TopImageVulnerability(ctx context.Context, args RawQuery) (ImageVulnerabilityResolver, error) @@ -88,8 +89,14 @@ func registerImageWatchStatus(s string) string { func init() { schema := getBuilder() utils.Must( + schema.AddType("BaseImage", []string{ + "imageSha: String!", + "names: [String!]!", + "created: Time", + }), // NOTE: This list is and should remain alphabetically ordered schema.AddExtraResolvers("Image", []string{ + "baseImage: BaseImage", "deploymentCount(query: String): Int!", "deployments(query: String, pagination: Pagination): [Deployment!]!", "imageComponentCount(query: String): Int!", @@ -515,3 +522,66 @@ func (resolver *imageResolver) TopCvss(_ context.Context) float64 { } return float64(value) } + +func (resolver *imageResolver) BaseImage(ctx context.Context) (*baseImageResolver, error) { + resolver.ensureData(ctx) + baseImageInfos := resolver.data.GetBaseImageInfo() + return resolver.root.wrapBaseImage(baseImageInfos) +} + +// baseImageResolver resolves base image information +type baseImageResolver struct { + root *Resolver + data *baseImageData +} + +type baseImageData struct { + imageSha string + names []string + created *graphql.Time +} + +func (resolver *Resolver) wrapBaseImage(baseImageInfos []*storage.BaseImageInfo) (*baseImageResolver, error) { + if len(baseImageInfos) == 0 { + return nil, nil + } + + // All entries should have the same digest and create time, take the first one + imageSha := baseImageInfos[0].GetBaseImageDigest() + createTimestamp := baseImageInfos[0].GetCreated() + created, err := protocompat.ConvertTimestampToGraphqlTimeOrError(createTimestamp) + if err != nil { + return nil, err + } + + // Collect all full names + names := make([]string, 0, len(baseImageInfos)) + for _, info := range baseImageInfos { + names = append(names, info.GetBaseImageFullName()) + } + + // Stablize the names + slices.Sort(names) + data := &baseImageData{ + imageSha: imageSha, + names: names, + created: created, + } + + return &baseImageResolver{ + root: resolver, + data: data, + }, nil +} + +func (resolver *baseImageResolver) ImageSha(_ context.Context) string { + return resolver.data.imageSha +} + +func (resolver *baseImageResolver) Names(_ context.Context) []string { + return resolver.data.names +} + +func (resolver *baseImageResolver) Created(_ context.Context) (*graphql.Time, error) { + return resolver.data.created, nil +} diff --git a/central/graphql/resolvers/images_test.go b/central/graphql/resolvers/images_test.go index 66d4d68689c9d..661ddb5e34f1b 100644 --- a/central/graphql/resolvers/images_test.go +++ b/central/graphql/resolvers/images_test.go @@ -6,6 +6,7 @@ import ( "context" "strings" "testing" + "time" "github.com/graph-gophers/graphql-go" "github.com/stackrox/rox/central/graphql/resolvers/loaders" @@ -18,6 +19,7 @@ import ( "github.com/stackrox/rox/pkg/grpc/authz/allow" "github.com/stackrox/rox/pkg/pointers" "github.com/stackrox/rox/pkg/postgres/pgtest" + "github.com/stackrox/rox/pkg/protocompat" "github.com/stackrox/rox/pkg/sac" "github.com/stackrox/rox/pkg/sac/resources" "github.com/stretchr/testify/assert" @@ -267,16 +269,38 @@ func (s *ImageResolversTestSuite) TestDeployments() { assert.Equal(t, int32(expectedCVESevCount.moderate), moderate.Total(testCtx)) assert.Equal(t, int32(expectedCVESevCount.low), low.Total(testCtx)) - // Test BaseImageInfo field for each image resolver. - expectedImage := expectedImages[imageID] - actualBaseImageInfo, err := image.BaseImageInfo(testCtx) + // Test BaseImage field + actualBaseImage, err := image.BaseImage(testCtx) assert.NoError(t, err) - assert.Len(t, actualBaseImageInfo, len(expectedImage.GetBaseImageInfo())) - for i, baseInfo := range actualBaseImageInfo { - expectedBaseInfo := expectedImage.GetBaseImageInfo()[i] - assert.Equal(t, expectedBaseInfo.GetBaseImageId(), baseInfo.BaseImageId(testCtx)) - assert.Equal(t, expectedBaseInfo.GetBaseImageFullName(), baseInfo.BaseImageFullName(testCtx)) - assert.Equal(t, expectedBaseInfo.GetBaseImageDigest(), baseInfo.BaseImageDigest(testCtx)) + + expectedImage, exists := expectedImages[imageID] + require.True(t, exists, "Expected image %s not found in expectedImages map", imageID) + baseImageInfos := expectedImage.GetBaseImageInfo() + if len(baseImageInfos) == 0 { + assert.Nil(t, actualBaseImage) + } else { + require.NotNil(t, actualBaseImage) + + // Test imageSha (should be the digest from the first base image info) + expectedSha := baseImageInfos[0].GetBaseImageDigest() + assert.Equal(t, expectedSha, actualBaseImage.ImageSha(testCtx)) + + // Test name array + expectedNames := []string{ + baseImageInfos[1].GetBaseImageFullName(), + baseImageInfos[0].GetBaseImageFullName(), + } + assert.Equal(t, expectedNames, actualBaseImage.Names(testCtx)) + + // Test created timestamp + actualCreated, err := actualBaseImage.Created(testCtx) + assert.NoError(t, err) + assert.NotNil(t, actualCreated) + expectedTimestamp, err := protocompat.ConvertTimeToTimestampOrError(time.Unix(0, 3000)) + assert.NoError(t, err) + expectedCreated, err := protocompat.ConvertTimestampToGraphqlTimeOrError(expectedTimestamp) + assert.NoError(t, err) + assert.Equal(t, expectedCreated, actualCreated) } // Test image -> deployments -> images diff --git a/central/graphql/resolvers/images_v2.go b/central/graphql/resolvers/images_v2.go index 94dbe92ecda64..9ea15e44e716a 100644 --- a/central/graphql/resolvers/images_v2.go +++ b/central/graphql/resolvers/images_v2.go @@ -23,6 +23,7 @@ func init() { schema := getBuilder() utils.Must( schema.AddExtraResolvers("ImageV2", []string{ + "baseImage: BaseImage", "deploymentCount(query: String): Int!", "deployments(query: String, pagination: Pagination): [Deployment!]!", "imageComponentCount(query: String): Int!", @@ -393,3 +394,9 @@ func (resolver *imageV2Resolver) FixableLowCveCount(ctx context.Context) int32 { resolver.ensureData(ctx) return resolver.data.GetScanStats().GetFixableLowCveCount() } + +func (resolver *imageV2Resolver) BaseImage(ctx context.Context) (*baseImageResolver, error) { + resolver.ensureData(ctx) + baseImageInfos := resolver.data.GetBaseImageInfo() + return resolver.root.wrapBaseImage(baseImageInfos) +} diff --git a/central/graphql/resolvers/test_utils.go b/central/graphql/resolvers/test_utils.go index bb113742a1e0a..d8ffb0e60db62 100644 --- a/central/graphql/resolvers/test_utils.go +++ b/central/graphql/resolvers/test_utils.go @@ -96,6 +96,8 @@ func testImages() []*storage.Image { utils.CrashOnError(err) t2, err := protocompat.ConvertTimeToTimestampOrError(time.Unix(0, 2000)) utils.CrashOnError(err) + t3, err := protocompat.ConvertTimeToTimestampOrError(time.Unix(0, 3000)) + utils.CrashOnError(err) return []*storage.Image{ { Id: "sha1", @@ -233,13 +235,15 @@ func testImages() []*storage.Image { BaseImageInfo: []*storage.BaseImageInfo{ { BaseImageId: "base-sha2", - BaseImageFullName: "alpine:3.12", + BaseImageFullName: "busybox:latest", BaseImageDigest: "sha256:alpine312", + Created: t3, }, { BaseImageId: "base-sha3", - BaseImageFullName: "busybox:latest", + BaseImageFullName: "alpine:3.12", BaseImageDigest: "sha256:busybox1", + Created: t3, }, }, }, diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePage.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePage.tsx index eca9c72a9627b..c3f44313816df 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePage.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePage.tsx @@ -316,6 +316,7 @@ function ImagePage({ tag: '', } } + baseImage={imageData?.baseImage ?? null} refetchAll={refetchAll} pagination={pagination} vulnerabilityState={vulnerabilityState} diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePageVulnerabilities.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePageVulnerabilities.tsx index 7527ec1804ee9..a91943188a39d 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePageVulnerabilities.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/Image/ImagePageVulnerabilities.tsx @@ -68,6 +68,8 @@ import { imageCVESearchFilterConfig, imageComponentSearchFilterConfig, } from '../../searchFilterConfig'; +import BaseImageAssessmentCard from '../components/BaseImageAssessmentCard'; +import type { BaseImage } from '../components/ImageDetailBadges'; export const imageVulnerabilitiesQuery = gql` ${imageMetadataContextFragment} @@ -127,6 +129,7 @@ export type ImagePageVulnerabilitiesProps = { remote: string; tag: string; }; + baseImage: BaseImage | null; refetchAll: () => void; pagination: UseURLPaginationResult; vulnerabilityState: VulnerabilityState; @@ -139,6 +142,7 @@ export type ImagePageVulnerabilitiesProps = { function ImagePageVulnerabilities({ imageId, imageName, + baseImage, refetchAll, pagination, vulnerabilityState, @@ -148,6 +152,7 @@ function ImagePageVulnerabilities({ setSearchFilter, }: ImagePageVulnerabilitiesProps) { const { isFeatureFlagEnabled } = useFeatureFlags(); + const isBaseImageDetectionEnabled = isFeatureFlagEnabled('ROX_BASE_IMAGE_DETECTION'); const isNewImageDataModelEnabled = isFeatureFlagEnabled('ROX_FLATTEN_IMAGE_DATA'); const { analyticsTrack } = useAnalytics(); @@ -291,6 +296,11 @@ function ImagePageVulnerabilities({ Review and triage vulnerability data scanned on this image + {isBaseImageDetectionEnabled && baseImage && ( + + + + )} { + setIsExpanded(expanded); + }; + + // Use the digest (imageSha) as the image ID for the detail link + const imageDetailPath = urlBuilder.imageDetails(baseImage.imageSha, 'OBSERVED'); + + return ( + + + + + + + {baseImage.names.length > 1 ? 'Image names' : 'Image name'} + + + + {baseImage.names.map((name) => ( + + ))} + + + + + Image digest + + + {baseImage.imageSha} + + + + {baseImage.created && ( + + Image age + + {getDistanceStrict(baseImage.created, new Date())} + + + )} + + + + + ); +} + +export default BaseImageAssessmentCard; diff --git a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/components/ImageDetailBadges.tsx b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/components/ImageDetailBadges.tsx index be8a92bd3a764..3e9af5f586ddc 100644 --- a/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/components/ImageDetailBadges.tsx +++ b/ui/apps/platform/src/Containers/Vulnerabilities/WorkloadCves/components/ImageDetailBadges.tsx @@ -6,6 +6,12 @@ import type { SignatureVerificationResult } from '../../types'; import SignatureCountLabel from './SignatureCountLabel'; import VerifiedSignatureLabel, { getVerifiedSignatureInResults } from './VerifiedSignatureLabel'; +export type BaseImage = { + imageSha: string; + names: string[]; + created?: string; +}; + export type ImageDetails = { deploymentCount: number; operatingSystem: string; @@ -22,6 +28,7 @@ export type ImageDetails = { signatureVerificationData: { results: SignatureVerificationResult[]; } | null; + baseImage: BaseImage | null; }; export const imageDetailsFragment = gql` @@ -49,6 +56,11 @@ export const imageDetailsFragment = gql` verifierId } } + baseImage { + imageSha + names + created + } } `; @@ -77,6 +89,11 @@ export const imageV2DetailsFragment = gql` verifierId } } + baseImage { + imageSha + names + created + } } `; diff --git a/ui/apps/platform/src/services/BaseImagesService.ts b/ui/apps/platform/src/services/BaseImagesService.ts index 3a87b473d42d0..84d2d58ac38c2 100644 --- a/ui/apps/platform/src/services/BaseImagesService.ts +++ b/ui/apps/platform/src/services/BaseImagesService.ts @@ -15,6 +15,10 @@ export type BaseImagesResponse = { baseImageReferences: BaseImageReference[]; }; +export type CreateBaseImageReferenceResponse = { + baseImageReference: BaseImageReference; +}; + /** * Fetch the list of configured base images. */ @@ -32,8 +36,11 @@ export function addBaseImage( baseImageTagPattern: string ): Promise { return axios - .post(baseImagesUrl, { baseImageRepoPath, baseImageTagPattern }) - .then((response) => response.data); + .post(baseImagesUrl, { + baseImageRepoPath, + baseImageTagPattern, + }) + .then((response) => response.data.baseImageReference); } /**