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);
}
/**