1
0
mirror of https://github.com/goharbor/harbor.git synced 2026-10-23 10:08:57 +03:00

fix: implement signature inheritance for OCI index children (#23540)

* fix: implement cosign signature inheritance for OCI index children

When a multi-arch OCI index is signed with cosign, its per-arch child
artifacts have no accessory of their own, so the UI shows them as
unsigned. Populate InheritedAccessories from the parent index's cosign
signature (display only, never used in copy/delete/walk) and surface it
in ListAccessories when the query filters for signature.cosign.

Ported from container-registry/harbor-next#27.

Signed-off-by: aloui ikram <ikram@container-registry.com>

* refactor: expose inherited cosign signatures as a separate API field

Rework the OCI index signature inheritance so that the accessory list
stays faithful to registry content. The parent's signature is no longer
merged into ListAccessories for a child digest; instead the already
computed InheritedAccessories is exposed as a new, explicit
inherited_accessories field on the artifact get and list endpoints.

The inherited entries keep the parent index as their subject digest, so
they remain distinguishable from a direct signature: cosign verify
against the child digest still fails, and the referrers API still does
not list them for that digest. Responses that do not ask for the field
are unchanged.

Resolving the field costs a reference lookup per artifact, so it is
opt-in behind a new with_inherited_accessory query parameter, and an
artifact that no index references short-circuits after a single indexed
lookup. with_accessory could not serve as the gate because it defaults
to true and is also set on the copy path.

The Portal reads the new field and renders a distinct icon with a
tooltip, and now fetches the child accessories inline with the artifact
instead of issuing one extra request per child.

Signed-off-by: aloui ikram <ikram@container-registry.com>

* feat: extend signature inheritance to notation

The inheritance introduced for cosign applies equally to notation. Like
cosign, notation signs a single digest and has no flag to descend into
the children of an index, so a notation-signed index leaves its children
carrying no signature of their own. The Portal already treats both kinds
as signed, so handling only cosign meant a notation-signed index still
showed every one of its children as unsigned.

Inherit both signature types, both when collecting the parent's
accessories and in the check that skips artifacts already signed in
their own right. That second place matters on its own: a child with its
own notation signature under a cosign-signed index was not recognised as
directly signed, and so was given an inherited entry, contradicting the
documented meaning of the field. Accessory kinds that are not signatures
stay excluded, since an SBOM or an accelerator on an index says nothing
about the children of that index.

Take the inherited icon colour from the Clarity neutral status token
rather than a literal, so that it follows the light and dark themes
instead of remaining darker than the text beside it in dark mode.

Signed-off-by: aloui ikram <ikram@container-registry.com>

---------

Signed-off-by: aloui ikram <ikram@container-registry.com>
Co-authored-by: aloui ikram <ikram@container-registry.com>
Co-authored-by: Wang Yan <wangyan_0219@hotmail.com>
This commit is contained in:
Ikram ALOUI
2026-08-10 09:15:45 +02:00
committed by GitHub
parent 2f7addaf2a
commit c3ec2b133e
23 changed files with 600 additions and 15 deletions

View File

@@ -1072,6 +1072,12 @@ paths:
type: boolean
required: false
default: false
- name: with_inherited_accessory
in: query
description: Specify whether the accessories of the parent OCI index(es) referencing the artifact are included, in the separate inherited_accessories field. Off by default because it costs an extra reference lookup per artifact.
type: boolean
required: false
default: false
responses:
'200':
description: Success
@@ -1185,6 +1191,12 @@ paths:
type: boolean
required: false
default: false
- name: with_inherited_accessory
in: query
description: Specify whether the accessories of the parent OCI index(es) referencing the artifact are included, in the separate inherited_accessories field. Off by default because it costs an extra reference lookup per artifact.
type: boolean
required: false
default: false
# should be in tag level
- name: with_signature
in: query
@@ -1716,6 +1728,12 @@ paths:
type: boolean
required: false
default: false
- name: with_inherited_accessory
in: query
description: Specify whether the accessories of the parent OCI index(es) referencing the artifact are included, in the separate inherited_accessories field. Off by default because it costs an extra reference lookup per artifact.
type: boolean
required: false
default: false
- name: latest_in_repository
in: query
description: Specify whether only the latest pushed artifact of each repository is included inside the returning artifacts. Only works when either artifact_type or media_type is included in the query.
@@ -6852,6 +6870,23 @@ definitions:
items:
$ref: '#/definitions/Accessory'
description: The accessory of the artifact.
inherited_accessories:
type: array
# omitted rather than sent as null, so that responses which did not ask for the
# inherited accessories stay exactly as they are today
x-omitempty: true
description: |
The signatures of the parent OCI index(es) that reference this artifact, both
cosign and notation. A signature on an index covers the whole index, so a child
manifest of a signed index is covered by that signature even though it carries
none of its own. These entries describe the parent, not this artifact: their
subject_artifact_digest is the index digest, so verifying them against this
artifact's digest fails and they are not listed by the referrers API for this
digest. Only returned when the with_inherited_accessory query parameter is set
to true.
items:
$ref: '#/definitions/Accessory'
description: The accessory inherited from the parent OCI index.
Tag:
type: object
properties:

View File

@@ -753,6 +753,9 @@ func (c *controller) assembleArtifact(ctx context.Context, art *artifact.Artifac
if option.WithAccessory {
c.populateAccessories(ctx, artifact)
}
if option.WithInheritedAccessory {
c.populateInheritedAccessories(ctx, artifact)
}
return artifact
}
@@ -807,6 +810,75 @@ func (c *controller) populateAccessories(ctx context.Context, art *Artifact) {
art.Accessories = accs
}
// populateInheritedAccessories populates the signatures of the parent OCI indexes that
// reference the artifact. A signature on an index covers the whole index, so a child
// manifest of a signed index is covered by that signature even though it carries none of
// its own. Both signing tools Harbor supports behave this way: neither "cosign sign" nor
// "notation sign" descends into the children of an index by default. These accessories
// describe the parent, not the artifact: their subject digest is the index digest, so
// verification against the child digest still fails and Harbor's referrers API still does
// not list them. They are exposed separately from Accessories for exactly that reason and
// must never be used for copy/delete/walk.
//
// Artifacts that already carry a signature of their own are skipped, as are artifacts that
// no index references.
func (c *controller) populateInheritedAccessories(ctx context.Context, art *Artifact) {
// resolve the parents first: most artifacts in a registry are referenced by no index at
// all, so this single lookup short-circuits the common case before any further query.
parents, err := c.artMgr.ListReferences(ctx, &q.Query{
Keywords: map[string]any{"ChildID": art.ID},
})
if err != nil {
log.Errorf("failed to list parent references of artifact %d: %v", art.ID, err)
return
}
if len(parents) == 0 {
return
}
// nothing to inherit if the artifact is signed in its own right. Accessories is only
// filled in when the caller asked for it, so fall back to a direct lookup when it is not.
ownAccs := art.Accessories
if ownAccs == nil {
ownAccs, err = c.accessoryMgr.List(ctx, q.New(q.KeyWords{"SubjectArtifactID": art.ID}))
if err != nil {
log.Errorf("failed to list accessories of artifact %d: %v", art.ID, err)
return
}
}
for _, acc := range ownAccs {
if isSignature(acc) {
return
}
}
for _, p := range parents {
parentAccs, err := c.accessoryMgr.List(ctx, q.New(q.KeyWords{"SubjectArtifactID": p.ParentID}))
if err != nil {
log.Errorf("failed to list accessories of parent artifact %d: %v", p.ParentID, err)
continue
}
for _, pAcc := range parentAccs {
if isSignature(pAcc) {
art.InheritedAccessories = append(art.InheritedAccessories, pAcc)
}
}
}
}
// isSignature reports whether the accessory is a signature over its subject, regardless of
// the tool that produced it. Only signatures are inherited from a parent index: the other
// accessory kinds either describe a single manifest (an SBOM) or are not a claim about it
// at all (a nydus accelerator), so they say nothing about the children of an index.
func isSignature(acc accessorymodel.Accessory) bool {
switch acc.GetData().Type {
case accessorymodel.TypeCosignSignature, accessorymodel.TypeNotationSignature:
return true
default:
return false
}
}
// HasUnscannableLayer check if it is a in-toto sbom, if it contains any blob with a content_type is application/vnd.in-toto+json, then consider as in-toto sbom
func (c *controller) HasUnscannableLayer(ctx context.Context, dgst string) (bool, error) {
if len(dgst) == 0 {

View File

@@ -158,9 +158,199 @@ func (c *controllerTestSuite) TestAssembleArtifact() {
c.Contains(artifact.Tags, tg)
c.Contains(artifact.Labels, lb)
c.Contains(artifact.Accessories, acc)
c.Empty(artifact.InheritedAccessories)
// the inherited accessories are opt-in, so no parent lookup may happen for this option
c.artMgr.AssertNotCalled(c.T(), "ListReferences", mock.Anything, mock.Anything)
// TODO check other fields of option
}
// cosignAcc builds a cosign signature accessory whose subject is subDigest
func accOfType(id, artID int64, subDigest, accType string) accessorymodel.Accessory {
return &basemodel.Default{
Data: accessorymodel.AccessoryData{
ID: id,
ArtifactID: artID,
SubArtifactDigest: subDigest,
Type: accType,
},
}
}
func cosignAcc(id, artID int64, subDigest string) accessorymodel.Accessory {
return accOfType(id, artID, subDigest, accessorymodel.TypeCosignSignature)
}
func notationAcc(id, artID int64, subDigest string) accessorymodel.Accessory {
return accOfType(id, artID, subDigest, accessorymodel.TypeNotationSignature)
}
// subjectArtifactID matches an accessory query listing the accessories of the given artifact
func subjectArtifactID(id int64) any {
return mock.MatchedBy(func(query *q.Query) bool {
return query != nil && query.Keywords["SubjectArtifactID"] == id
})
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesNoParent() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{}, nil)
art := &Artifact{Artifact: artifact.Artifact{ID: 1}}
c.ctl.populateInheritedAccessories(nil, art)
c.Empty(art.InheritedAccessories)
// an artifact that no index references must cost nothing beyond the reference lookup
c.accMgr.AssertNotCalled(c.T(), "List", mock.Anything, mock.Anything)
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesFromParent() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
}, nil)
parentSig := cosignAcc(1, 100, "sha256:parent")
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
Return([]accessorymodel.Accessory{parentSig}, nil)
// Accessories is set but empty: the artifact carries no signature of its own
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
c.ctl.populateInheritedAccessories(nil, art)
c.Require().Len(art.InheritedAccessories, 1)
c.Equal(parentSig, art.InheritedAccessories[0])
// the inherited entry keeps the parent as its subject, so it stays distinguishable
c.Equal("sha256:parent", art.InheritedAccessories[0].GetData().SubArtifactDigest)
// and it is never merged into the artifact's own accessories
c.Empty(art.Accessories)
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesOwnSignature() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
}, nil)
// the artifact is signed in its own right, so there is nothing to inherit
art := &Artifact{
Artifact: artifact.Artifact{ID: 1},
Accessories: []accessorymodel.Accessory{cosignAcc(1, 100, "sha256:child")},
}
c.ctl.populateInheritedAccessories(nil, art)
c.Empty(art.InheritedAccessories)
c.accMgr.AssertNotCalled(c.T(), "List", mock.Anything, subjectArtifactID(int64(10)))
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesNotationFromParent() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
}, nil)
parentSig := notationAcc(1, 100, "sha256:parent")
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
Return([]accessorymodel.Accessory{parentSig}, nil)
// notation signs a single digest just as cosign does, so an index signed with it leaves
// its children unsigned in exactly the same way
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
c.ctl.populateInheritedAccessories(nil, art)
c.Require().Len(art.InheritedAccessories, 1)
c.Equal(parentSig, art.InheritedAccessories[0])
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesOwnNotationSignature() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
}, nil)
// a signature of either kind makes the artifact signed in its own right: inheriting the
// parent's cosign signature on top of it would attach an inherited entry to an artifact
// that is directly signed
art := &Artifact{
Artifact: artifact.Artifact{ID: 1},
Accessories: []accessorymodel.Accessory{notationAcc(1, 100, "sha256:child")},
}
c.ctl.populateInheritedAccessories(nil, art)
c.Empty(art.InheritedAccessories)
c.accMgr.AssertNotCalled(c.T(), "List", mock.Anything, subjectArtifactID(int64(10)))
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesNonSignatureParent() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
}, nil)
// an SBOM on the index describes the index alone and says nothing about its children,
// so only signatures are inherited
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
Return([]accessorymodel.Accessory{
accOfType(1, 100, "sha256:parent", accessorymodel.TypeHarborSBOM),
}, nil)
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
c.ctl.populateInheritedAccessories(nil, art)
c.Empty(art.InheritedAccessories)
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesMultipleParents() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
{ParentID: 20, ChildID: 1},
}, nil)
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
Return([]accessorymodel.Accessory{cosignAcc(1, 100, "sha256:parent1")}, nil)
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(20))).
Return([]accessorymodel.Accessory{cosignAcc(2, 200, "sha256:parent2")}, nil)
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
c.ctl.populateInheritedAccessories(nil, art)
// a child can sit in more than one index, and every signing index counts
c.Require().Len(art.InheritedAccessories, 2)
c.Equal("sha256:parent1", art.InheritedAccessories[0].GetData().SubArtifactDigest)
c.Equal("sha256:parent2", art.InheritedAccessories[1].GetData().SubArtifactDigest)
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesUnsignedParent() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
}, nil)
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
Return([]accessorymodel.Accessory{}, nil)
art := &Artifact{Artifact: artifact.Artifact{ID: 1}, Accessories: []accessorymodel.Accessory{}}
c.ctl.populateInheritedAccessories(nil, art)
c.Empty(art.InheritedAccessories)
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesWithoutOwnAccessories() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).Return([]*artifact.Reference{
{ParentID: 10, ChildID: 1},
}, nil)
// Accessories is nil because the caller did not ask for them, so the artifact's own
// accessories have to be resolved before deciding whether anything is inherited
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(1))).
Return([]accessorymodel.Accessory{}, nil)
c.accMgr.On("List", mock.Anything, subjectArtifactID(int64(10))).
Return([]accessorymodel.Accessory{cosignAcc(1, 100, "sha256:parent")}, nil)
art := &Artifact{Artifact: artifact.Artifact{ID: 1}}
c.ctl.populateInheritedAccessories(nil, art)
c.Require().Len(art.InheritedAccessories, 1)
c.accMgr.AssertCalled(c.T(), "List", mock.Anything, subjectArtifactID(int64(1)))
}
func (c *controllerTestSuite) TestPopulateInheritedAccessoriesListReferencesError() {
c.artMgr.On("ListReferences", mock.Anything, mock.Anything).
Return(nil, errors.New("failed to list references"))
art := &Artifact{Artifact: artifact.Artifact{ID: 1}}
c.ctl.populateInheritedAccessories(nil, art)
// a failed lookup degrades to "nothing inherited" rather than failing the request
c.Empty(art.InheritedAccessories)
}
func (c *controllerTestSuite) TestPopulateIcon() {
cases := []struct {
art *artifact.Artifact

View File

@@ -33,6 +33,10 @@ type Artifact struct {
AdditionLinks map[string]*AdditionLink `json:"addition_links"` // the resource link for build history(image), values.yaml(chart), dependency(chart), etc
Labels []*model.Label `json:"labels"`
Accessories []accessoryModel.Accessory `json:"-"`
// signatures of the parent OCI index that also cover this artifact, informational only,
// never use in copy/delete/walk. Untagged like Accessories: the accessory interface
// cannot be unmarshalled directly, the API view is built by the handler instead.
InheritedAccessories []accessoryModel.Accessory `json:"-"`
}
// UnmarshalJSON to customize the accessories unmarshal
@@ -112,9 +116,14 @@ type AdditionLink struct {
// Option is used to specify the properties returned when listing/getting artifacts
type Option struct {
WithTag bool
TagOption *tag.Option // only works when WithTag is set to true
WithLabel bool
WithAccessory bool
LatestInRepository bool
WithTag bool
TagOption *tag.Option // only works when WithTag is set to true
WithLabel bool
// WithAccessory returns the accessories whose subject is the artifact itself
WithAccessory bool
// WithInheritedAccessory returns the accessories of the parent OCI index that also
// cover the artifact. Opt-in only: it costs an extra reference lookup per artifact,
// so it must never be enabled by default or on internal paths such as copy.
WithInheritedAccessory bool
LatestInRepository bool
}

View File

@@ -31,6 +31,22 @@ func TestUnmarshalJSONWithACC(t *testing.T) {
assert.True(t, ok)
}
// InheritedAccessories must stay out of the JSON view of the artifact: the accessory type
// is an interface, so a payload carrying that field would fail to unmarshal here. The API
// exposes the inherited accessories through the handler's swagger model instead.
func TestUnmarshalJSONWithInheritedACC(t *testing.T) {
data := []byte(`[{"accessories":[{"artifact_id":9,"type":"signature.cosign"}],
"inherited_accessories":[{"artifact_id":10,"subject_artifact_digest":"sha256:parent","type":"signature.cosign"}],
"digest":"sha256:e4b315ad03a1d1d9ff0c111e648a1a91066c09ead8352d3d6a48fa971a82922c","type":"IMAGE"}]`)
var artifact []Artifact
err := json.Unmarshal(data, &artifact)
assert.Nil(t, err)
assert.Equal(t, int64(9), artifact[0].Accessories[0].GetData().ArtifactID)
assert.Empty(t, artifact[0].InheritedAccessories)
}
func TestUnmarshalJSONWithACCPartial(t *testing.T) {
data := []byte(`[{"accessories":[{"artifact_id":9,"creation_time":"2022-01-20T09:18:50.993Z","digest":"sha256:a7caa2636af890178a0b8c4cdbc47ced4dbdf29a1680e9e50823e85ce35b28d3","icon":"","id":4,"size":501,"subject_artifact_digest":"sha256:a7caa2636af890178a0b8c4cdbc47ced4dbdf29a1680e9e50823e85ce35b28d3","type":"signature.cosign"}, {"artifact_id":2, "type":"signature.cosign"}],
"digest":"sha256:e4b315ad03a1d1d9ff0c111e648a1a91066c09ead8352d3d6a48fa971a82922c","tags":[{"artifact_id":8,"id":6,"immutable":false,"name":"latest","pull_time":"2022-01-20T09:18:50.783Z","push_time":"2022-01-20T09:18:50.303Z","repository_id":5,"signed":false}],"type":"IMAGE"}]`)

View File

@@ -415,6 +415,22 @@
shape="check-circle"
size="20"
class="signed"></clr-icon>
} @if (artifact.signed === 'inherited') {
<clr-tooltip>
<clr-icon
clrTooltipTrigger
shape="check-circle"
size="20"
class="signed-inherited"></clr-icon>
<clr-tooltip-content
clrPosition="top-right"
clrSize="lg"
*clrIfOpen>
<span>{{
'ARTIFACT.INHERITED_SIGNATURE' | translate
}}</span>
</clr-tooltip-content>
</clr-tooltip>
} @if (artifact.signed === 'false') {
<clr-icon
shape="times-circle"

View File

@@ -174,6 +174,14 @@
color: #00d40f;
}
// deliberately not the green of a direct signature: the artifact carries no signature of
// its own and is only covered by the one on the index that references it. The Clarity
// neutral-status token rather than a literal, so the icon tracks the light/dark theme
// instead of staying darker than the text beside it in dark mode.
.signed-inherited {
color: var(--cds-alias-status-neutral);
}
.mb-05 {
margin-bottom: 0.5rem;
}

View File

@@ -92,6 +92,8 @@ export const AVAILABLE_TIME = '0001-01-01T00:00:00.000Z';
const CHECKING: string = 'checking';
const TRUE: string = 'true';
const FALSE: string = 'false';
// the artifact carries no signature of its own but the index referencing it is signed
const INHERITED: string = 'inherited';
@Component({
selector: 'artifact-list-tab',
@@ -413,7 +415,12 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
withTag: false,
XAcceptVulnerabilities:
DEFAULT_SUPPORTED_MIME_TYPES,
withAccessory: false,
// ask for both kinds of accessory here: a child of this
// index may be covered by a signature on the index
// itself, and having them inline saves a request per
// child compared with looking the direct ones up after
withAccessory: true,
withInheritedAccessory: true,
};
platFormAttr.push({ platform: child.platform });
observableLists.push(
@@ -441,7 +448,7 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
});
this.getArtifactTagsAsync(this.artifactList);
this.getAccessoriesAsync(this.artifactList);
this.checkCosignAndSbomAsync(this.artifactList);
this.checkCosignAndSbom(this.artifactList);
this.getIconsFromBackEnd();
},
error => {
@@ -1166,6 +1173,42 @@ export class ArtifactListTabComponent implements OnInit, OnDestroy {
}
}
}
// Children of an index are fetched with their accessories already included, so their
// signed state is decided from what is in hand instead of a request per child.
checkCosignAndSbom(artifacts: ArtifactFront[]) {
artifacts?.forEach(item => {
const accessories = item?.accessories ?? [];
item.signed = accessories.some(
acc =>
acc.type === AccessoryType.COSIGN ||
acc.type === AccessoryType.NOTATION
)
? TRUE
: this.inheritedSignedState(item);
item.sbomDigest = item?.sbom_overview?.sbom_digest;
if (!item.sbomDigest) {
item.sbomDigest =
accessories.filter(
acc => acc.type === AccessoryType.SBOM
)?.[0]?.digest ?? undefined;
}
});
}
// An artifact with no signature of its own is still covered by a signature on the index
// that references it. That is a weaker claim than being signed directly, so it gets its
// own state rather than being folded into either "signed" or "not signed".
inheritedSignedState(item: ArtifactFront): string {
return item?.inherited_accessories?.some(
acc =>
acc.type === AccessoryType.COSIGN ||
acc.type === AccessoryType.NOTATION
)
? INHERITED
: FALSE;
}
// return true if all selected rows are in "running" state
canStopScan(): boolean {
if (this.onSendingStopScanCommand) {

View File

@@ -1053,6 +1053,7 @@
"COPY_ERROR": "Kopieren fehlgeschlagen, bitte die Befehlsreferenz manuell kopieren."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "Abgeleitet von der Signatur des übergeordneten Index. Dieses Artefakt ist nicht direkt signiert.",
"FILTER_FOR_ARTIFACTS": "Filter Artefakte",
"ADDITIONS": "Hinzufügungen",
"ANNOTATION": "Annotationen",

View File

@@ -1053,6 +1053,7 @@
"COPY_ERROR": "Copy failed, please try to manually copy the command references."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "Derived from the signature on the parent index. This artifact is not signed directly.",
"FILTER_FOR_ARTIFACTS": "Filter Artifacts",
"ADDITIONS": "Additions",
"ANNOTATION": "Annotations",

View File

@@ -1056,6 +1056,7 @@
"COPY_ERROR": "Copiar fallido, por favor intenta copiar manualmente las referencias del comando."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "Derivada de la firma del índice padre. Este artefacto no está firmado directamente.",
"FILTER_FOR_ARTIFACTS": "Filtrar Artifacts",
"ADDITIONS": "Adiciones",
"ANNOTATION": "Anotaciones",

View File

@@ -1055,6 +1055,7 @@
"COPY_ERROR": "Copie échouée, veuillez essayer de copier manuellement les commandes de référence."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "Dérivée de la signature de l'index parent. Cet artefact n'est pas signé directement.",
"FILTER_FOR_ARTIFACTS": "Filtrer les artefacts",
"ADDITIONS": "Ajouts",
"ANNOTATION": "Annotations",

View File

@@ -1053,6 +1053,7 @@
"COPY_ERROR": "복사에 실패했습니다. 명령 참조를 수동으로 복사해 보세요."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "상위 인덱스의 서명에서 파생되었습니다. 이 아티팩트는 직접 서명되지 않았습니다.",
"FILTER_FOR_ARTIFACTS": "아티팩트 필터",
"ADDITIONS": "추가사항",
"ANNOTATION": "주석",

View File

@@ -1056,6 +1056,7 @@
"COPY_ERROR": "Cópia falhou, por favor tente copiar o comando de referência manualmente."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "Derivada da assinatura do índice pai. Este artefato não está assinado diretamente.",
"FILTER_FOR_ARTIFACTS": "Filtrar",
"ADDITIONS": "Informações Complementares",
"ANNOTATION": "Anotações",

View File

@@ -2291,6 +2291,7 @@
"COPY_ERROR": "Копирование не удалось, попробуйте скопировать пример команды вручную."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "Унаследована от подписи родительского индекса. Этот артефакт не подписан напрямую.",
"FILTER_FOR_ARTIFACTS": "Фильтр Артефактов",
"ADDITIONS": "Дополнения",
"ANNOTATION": "Аннотации",

View File

@@ -1054,6 +1054,7 @@
"COPY_ERROR": "Kopyalama başarısız oldu, lütfen komut referanslarını el ile kopyalamayı deneyin."
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "Üst dizinin imzasından türetilmiştir. Bu yapıt doğrudan imzalanmamış.",
"FILTER_FOR_ARTIFACTS": "Filter Artifacts",
"ADDITIONS": "Additions",
"ANNOTATION": "Annotations",

View File

@@ -1055,6 +1055,7 @@
"COPY_ERROR": "拷贝失败,请尝试手动拷贝参考命令。"
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "派生自父索引的签名。此制品未直接签名。",
"FILTER_FOR_ARTIFACTS": "Filter Artifacts",
"ADDITIONS": "其他",
"ANNOTATION": "注解",

View File

@@ -1054,6 +1054,7 @@
"COPY_ERROR": "複製失敗,請嘗試手動複製指令。"
},
"ARTIFACT": {
"INHERITED_SIGNATURE": "衍生自父索引的簽章。此成品未直接簽署。",
"FILTER_FOR_ARTIFACTS": "篩選 Artifact",
"ADDITIONS": "附加物",
"ANNOTATION": "註解",

View File

@@ -95,7 +95,7 @@ func (a *artifactAPI) ListArtifacts(ctx context.Context, params operation.ListAr
// set option
option := option(params.WithTag, params.WithImmutableStatus,
params.WithLabel, params.WithAccessory, nil)
params.WithLabel, params.WithAccessory, nil, params.WithInheritedAccessory)
// get the total count of artifacts
total, err := a.artCtl.Count(ctx, query)
@@ -129,7 +129,7 @@ func (a *artifactAPI) GetArtifact(ctx context.Context, params operation.GetArtif
}
// set option
option := option(params.WithTag, params.WithImmutableStatus,
params.WithLabel, params.WithAccessory, nil)
params.WithLabel, params.WithAccessory, nil, params.WithInheritedAccessory)
// get the artifact
artifact, err := a.artCtl.GetByReference(ctx, fmt.Sprintf("%s/%s", params.ProjectName, params.RepositoryName), params.Reference, option)
@@ -505,12 +505,15 @@ func (a *artifactAPI) RequireLabelInProject(ctx context.Context, projectID, labe
return nil
}
func option(withTag, withImmutableStatus, withLabel, withAccessory *bool, latestInRepository *bool) *artifact.Option {
func option(withTag, withImmutableStatus, withLabel, withAccessory *bool, latestInRepository *bool, withInheritedAccessory *bool) *artifact.Option {
option := &artifact.Option{
WithTag: true, // return the tag by default
WithLabel: lib.BoolValue(withLabel),
WithAccessory: true, // return the accessory by default
LatestInRepository: lib.BoolValue(latestInRepository),
WithTag: true, // return the tag by default
WithLabel: lib.BoolValue(withLabel),
WithAccessory: true, // return the accessory by default
// the inherited accessories cost an extra reference lookup per artifact, so unlike
// the accessories they are opt-in rather than returned by default
WithInheritedAccessory: lib.BoolValue(withInheritedAccessory),
LatestInRepository: lib.BoolValue(latestInRepository),
}
if withTag != nil {

View File

@@ -18,11 +18,15 @@ import (
"testing"
"github.com/stretchr/testify/assert"
testifymock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/goharbor/harbor/src/controller/artifact"
"github.com/goharbor/harbor/src/controller/project"
accessorymodel "github.com/goharbor/harbor/src/pkg/accessory/model"
basemodel "github.com/goharbor/harbor/src/pkg/accessory/model/base"
pkg_art "github.com/goharbor/harbor/src/pkg/artifact"
"github.com/goharbor/harbor/src/pkg/scan/dao/scan"
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
"github.com/goharbor/harbor/src/server/v2.0/restapi"
@@ -32,6 +36,27 @@ import (
htesting "github.com/goharbor/harbor/src/testing/server/v2.0/handler"
)
func TestOption(t *testing.T) {
boolPtr := func(b bool) *bool { return &b }
// the accessories are returned by default, the inherited ones are not: resolving them
// costs a reference lookup per artifact, so a caller has to ask for them explicitly
opt := option(nil, nil, nil, nil, nil, nil)
assert.True(t, opt.WithAccessory)
assert.False(t, opt.WithInheritedAccessory)
opt = option(nil, nil, nil, nil, nil, boolPtr(false))
assert.False(t, opt.WithInheritedAccessory)
opt = option(nil, nil, nil, nil, nil, boolPtr(true))
assert.True(t, opt.WithInheritedAccessory)
// the two flags are independent of each other
opt = option(nil, nil, nil, boolPtr(false), nil, boolPtr(true))
assert.False(t, opt.WithAccessory)
assert.True(t, opt.WithInheritedAccessory)
}
func TestParse(t *testing.T) {
// with tag
input := "library/hello-world:latest"
@@ -190,6 +215,80 @@ func (suite *ArtifactTestSuite) TestGetVulnerabilitiesAddition() {
}
}
// A child manifest of a signed index is covered by the signature on that index, but carries
// no accessory of its own. The inherited accessories are reported in their own field so that
// the accessories field keeps describing only what is really attached to this digest.
func (suite *ArtifactTestSuite) TestGetArtifactInheritedAccessories() {
times := 2
suite.Security.On("IsAuthenticated").Return(true).Times(times)
suite.Security.On("IsSysAdmin").Return(true).Times(times)
mock.OnAnything(suite.Security, "Can").Return(true).Times(times)
child := "sha256:9572f7cdcee8591948c2963463447a53466950b3fc15a247fcad1917ca215a2f"
url := "/projects/library/repositories/photon/artifacts/" + child
// the artifact as the controller returns it when the inherited accessories were asked for
inherited := &artifact.Artifact{
Artifact: pkg_art.Artifact{ID: 1, Digest: child},
InheritedAccessories: []accessorymodel.Accessory{
&basemodel.Default{
Data: accessorymodel.AccessoryData{
ID: 1,
ArtifactID: 2,
SubArtifactDigest: "sha256:parent",
Type: accessorymodel.TypeCosignSignature,
},
},
},
}
{
// default request: the option is off and the response is shaped exactly as before
var opt *artifact.Option
suite.artCtl.On("GetByReference", mock.Anything, mock.Anything, mock.Anything,
testifymock.MatchedBy(func(o *artifact.Option) bool { opt = o; return true })).
Return(&artifact.Artifact{Artifact: pkg_art.Artifact{ID: 1, Digest: child}}, nil).Once()
var body map[string]any
res, err := suite.GetJSON(url, &body)
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.Require().NotNil(opt)
suite.False(opt.WithInheritedAccessory, "the parent lookup must not run unless asked for")
suite.NotContains(body, "inherited_accessories", "the default response must not grow a new field")
}
{
// opted in: the inherited signature is reported, and only in its own field
var opt *artifact.Option
suite.artCtl.On("GetByReference", mock.Anything, mock.Anything, mock.Anything,
testifymock.MatchedBy(func(o *artifact.Option) bool { opt = o; return true })).
Return(inherited, nil).Once()
var body map[string]any
res, err := suite.GetJSON(url+"?with_inherited_accessory=true", &body)
suite.NoError(err)
suite.Equal(200, res.StatusCode)
suite.Require().NotNil(opt)
suite.True(opt.WithInheritedAccessory)
// the artifact's own accessory list stays empty: it really is unsigned
suite.Empty(body["accessories"])
accs, ok := body["inherited_accessories"].([]any)
suite.Require().True(ok, "inherited_accessories must be present when opted in")
suite.Require().Len(accs, 1)
acc := accs[0].(map[string]any)
suite.Equal("signature.cosign", acc["type"])
// the subject is the parent index, which is what makes the entry self-describing:
// a consumer can see this signature does not verify against the requested digest
suite.Equal("sha256:parent", acc["subject_artifact_digest"])
suite.NotEqual(child, acc["subject_artifact_digest"])
}
}
func TestArtifactTestSuite(t *testing.T) {
suite.Run(t, &ArtifactTestSuite{})
}

View File

@@ -59,6 +59,9 @@ func (a *Artifact) ToSwagger() *models.Artifact {
for _, acc := range a.Accessories {
art.Accessories = append(art.Accessories, NewAccessory(acc.GetData()).ToSwagger())
}
for _, acc := range a.InheritedAccessories {
art.InheritedAccessories = append(art.InheritedAccessories, NewAccessory(acc.GetData()).ToSwagger())
}
for _, tag := range a.Tags {
art.Tags = append(art.Tags, NewTag(tag).ToSwagger())
}

View File

@@ -0,0 +1,81 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/goharbor/harbor/src/controller/artifact"
accessorymodel "github.com/goharbor/harbor/src/pkg/accessory/model"
basemodel "github.com/goharbor/harbor/src/pkg/accessory/model/base"
pkg_art "github.com/goharbor/harbor/src/pkg/artifact"
)
func cosignAccessory(id int64, subDigest string) accessorymodel.Accessory {
return &basemodel.Default{
Data: accessorymodel.AccessoryData{
ID: id,
ArtifactID: id + 100,
SubArtifactDigest: subDigest,
Type: accessorymodel.TypeCosignSignature,
},
}
}
func TestArtifactToSwaggerAccessories(t *testing.T) {
// an artifact with no accessory at all: both lists stay absent from the response
art := &Artifact{Artifact: artifact.Artifact{Artifact: pkg_art.Artifact{ID: 1, Digest: "sha256:child"}}}
swag := art.ToSwagger()
assert.Empty(t, swag.Accessories)
assert.Empty(t, swag.InheritedAccessories)
// the two kinds of accessory are reported side by side and never merged, so a consumer
// reading "accessories" keeps getting only what is really attached to this digest
art = &Artifact{
Artifact: artifact.Artifact{
Artifact: pkg_art.Artifact{ID: 1, Digest: "sha256:child"},
Accessories: []accessorymodel.Accessory{cosignAccessory(1, "sha256:child")},
InheritedAccessories: []accessorymodel.Accessory{cosignAccessory(2, "sha256:parent")},
},
}
swag = art.ToSwagger()
require.Len(t, swag.Accessories, 1)
assert.Equal(t, "sha256:child", swag.Accessories[0].SubjectArtifactDigest)
require.Len(t, swag.InheritedAccessories, 1)
// the inherited entry carries the parent index as its subject, which is what makes it
// self-describing: it is not verifiable against this artifact's digest
assert.Equal(t, "sha256:parent", swag.InheritedAccessories[0].SubjectArtifactDigest)
assert.Equal(t, accessorymodel.TypeCosignSignature, swag.InheritedAccessories[0].Type)
}
func TestArtifactToSwaggerInheritedOnly(t *testing.T) {
// a child of a signed index: nothing of its own, one inherited signature
art := &Artifact{
Artifact: artifact.Artifact{
Artifact: pkg_art.Artifact{ID: 1, Digest: "sha256:child"},
Accessories: []accessorymodel.Accessory{},
InheritedAccessories: []accessorymodel.Accessory{cosignAccessory(2, "sha256:parent")},
},
}
swag := art.ToSwagger()
assert.Empty(t, swag.Accessories)
require.Len(t, swag.InheritedAccessories, 1)
}

View File

@@ -715,7 +715,7 @@ func (a *projectAPI) ListArtifactsOfProject(ctx context.Context, params operatio
// set option
option := option(params.WithTag, params.WithImmutableStatus,
params.WithLabel, params.WithAccessory, params.LatestInRepository)
params.WithLabel, params.WithAccessory, params.LatestInRepository, params.WithInheritedAccessory)
var total int64
// list artifacts according to the query and option