Skip to content

Commit

Permalink
feat: add implementation for notation inspect (notaryproject#528)
Browse files Browse the repository at this point in the history
Adds support for `notation inspect` ([spechere](https://github.com/notaryproject/notation/blob/main/specs/commandline/inspect.md))

Example output:
```
chienb@a07817b52895 notation % ./bin/notation inspect $IMAGE
localhost:5000/net-monitor@sha256:5a07385af4e6b6af81b0ebfd435aedccdfa3507f0609c658209e1aba57159b2b
└── application/vnd.cncf.notary.signature
    └── sha256:34e5843a1a8b1607d2ba7da9b61c6a5b3953f5680751c160fb944df87b01b2b2
        ├── signature algorithm : RSASSA-PSS-SHA-256
        ├── signed attributes
        │   ├── expiry : 0001-01-01 00:00:00 +0000 UTC
        │   ├── signingScheme : notary.x509
        │   └── signingTime : 2023-01-27 17:02:22 -0800 PST
        ├── user defined attributes
        │   └── io.wabbit-networks.buildId : 123
        ├── unsigned attributes
        │   └── signingAgent : Notation/1.0.0
        ├── certificates
        │   └── SHA1 fingerprint e1ef7b0f984d1f8222d6bf297e1ad10047997b54
        │       ├── issued to : CN=byron.test,O=Notary,L=Seattle,ST=WA,C=US
        │       ├── issued by : CN=byron.test,O=Notary,L=Seattle,ST=WA,C=US
        │       └── expiry : 2023-01-29 01:02:13 +0000 UTC
        └── signed artifact
            ├── media type : application/vnd.docker.distribution.manifest.v2+json
            ├── digest : sha256:5a07385af4e6b6af81b0ebfd435aedccdfa3507f0609c658209e1aba57159b2b
            └── size : 942
```

Signed-off-by: Byron Chien <[email protected]>
  • Loading branch information
byronchien authored Feb 16, 2023
1 parent 85247a2 commit d62fd58
Show file tree
Hide file tree
Showing 9 changed files with 587 additions and 4 deletions.
302 changes: 302 additions & 0 deletions cmd/notation/inspect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
package main

import (
"crypto/sha1"
b64 "encoding/base64"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"strconv"
"time"

"github.com/notaryproject/notation-core-go/signature"
"github.com/notaryproject/notation-go/plugin/proto"
"github.com/notaryproject/notation-go/registry"
"github.com/notaryproject/notation/internal/cmd"
"github.com/notaryproject/notation/internal/envelope"
"github.com/notaryproject/notation/internal/ioutil"
"github.com/notaryproject/notation/internal/tree"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/cobra"
)

type inspectOpts struct {
cmd.LoggingFlagOpts
SecureFlagOpts
reference string
outputFormat string
}

type inspectOutput struct {
MediaType string `json:"mediaType"`
Signatures []signatureOutput
}

type signatureOutput struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
SignatureAlgorithm string `json:"signatureAlgorithm"`
SignedAttributes map[string]string `json:"signedAttributes"`
UserDefinedAttributes map[string]string `json:"userDefinedAttributes"`
UnsignedAttributes map[string]string `json:"unsignedAttributes"`
Certificates []certificateOutput `json:"certificates"`
SignedArtifact ocispec.Descriptor `json:"signedArtifact"`
}

type certificateOutput struct {
SHA1Fingerprint string `json:"SHA1Fingerprint"`
IssuedTo string `json:"issuedTo"`
IssuedBy string `json:"issuedBy"`
Expiry string `json:"expiry"`
}

func inspectCommand(opts *inspectOpts) *cobra.Command {
if opts == nil {
opts = &inspectOpts{}
}
command := &cobra.Command{
Use: "inspect [reference]",
Short: "Inspect all signatures associated with the signed artifact",
Long: `Inspect all signatures associated with the signed artifact.
Example - Inspect signatures on an OCI artifact identified by a digest:
notation inspect <registry>/<repository>@<digest>
Example - Inspect signatures on an OCI artifact identified by a tag (Notation will resolve tag to digest):
notation inspect <registry>/<repository>:<tag>
Example - Inspect signatures on an OCI artifact identified by a digest and output as json:
notation inspect --output json <registry>/<repository>@<digest>
`,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("missing reference")
}
opts.reference = args[0]
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runInspect(cmd, opts)
},
}

opts.LoggingFlagOpts.ApplyFlags(command.Flags())
opts.SecureFlagOpts.ApplyFlags(command.Flags())
cmd.SetPflagOutput(command.Flags(), &opts.outputFormat, cmd.PflagOutputUsage)
return command
}

func runInspect(command *cobra.Command, opts *inspectOpts) error {
// set log level
ctx := opts.LoggingFlagOpts.SetLoggerLevel(command.Context())

if opts.outputFormat != cmd.OutputJSON && opts.outputFormat != cmd.OutputPlaintext {
return fmt.Errorf("unrecognized output format %s", opts.outputFormat)
}

// initialize
reference := opts.reference
sigRepo, err := getSignatureRepository(ctx, &opts.SecureFlagOpts, reference)
if err != nil {
return err
}

manifestDesc, ref, err := getManifestDescriptor(ctx, &opts.SecureFlagOpts, reference, sigRepo)
if err != nil {
return err
}

// reference is a digest reference
if err := ref.ValidateReferenceAsDigest(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Always inspect the artifact using digest(@sha256:...) rather than a tag(:%s) because resolved digest may not point to the same signed artifact, as tags are mutable.\n", ref.Reference)
ref.Reference = manifestDesc.Digest.String()
}

output := inspectOutput{MediaType: manifestDesc.MediaType, Signatures: []signatureOutput{}}
skippedSignatures := false
err = sigRepo.ListSignatures(ctx, manifestDesc, func(signatureManifests []ocispec.Descriptor) error {
for _, sigManifestDesc := range signatureManifests {
sigBlob, sigDesc, err := sigRepo.FetchSignatureBlob(ctx, sigManifestDesc)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: unable to fetch signature %s due to error: %v\n", sigManifestDesc.Digest.String(), err)
skippedSignatures = true
continue
}

sigEnvelope, err := signature.ParseEnvelope(sigDesc.MediaType, sigBlob)
if err != nil {
logSkippedSignature(sigManifestDesc, err)
skippedSignatures = true
continue
}

envelopeContent, err := sigEnvelope.Content()
if err != nil {
logSkippedSignature(sigManifestDesc, err)
skippedSignatures = true
continue
}

signedArtifactDesc, err := envelope.DescriptorFromSignaturePayload(&envelopeContent.Payload)
if err != nil {
logSkippedSignature(sigManifestDesc, err)
skippedSignatures = true
continue
}

signatureAlgorithm, err := proto.EncodeSigningAlgorithm(envelopeContent.SignerInfo.SignatureAlgorithm)
if err != nil {
logSkippedSignature(sigManifestDesc, err)
skippedSignatures = true
continue
}

sig := signatureOutput{
MediaType: sigDesc.MediaType,
Digest: sigManifestDesc.Digest.String(),
SignatureAlgorithm: string(signatureAlgorithm),
SignedAttributes: getSignedAttributes(opts.outputFormat, envelopeContent),
UserDefinedAttributes: signedArtifactDesc.Annotations,
UnsignedAttributes: getUnsignedAttributes(envelopeContent),
Certificates: getCertificates(opts.outputFormat, envelopeContent),
SignedArtifact: *signedArtifactDesc,
}

// clearing annotations from the SignedArtifact field since they're already
// displayed as UserDefinedAttributes
sig.SignedArtifact.Annotations = nil

output.Signatures = append(output.Signatures, sig)
}
return nil
})

if err != nil {
return err
}

err = printOutput(opts.outputFormat, ref.String(), output)
if err != nil {
return err
}

if skippedSignatures {
return errors.New("at least one signature was skipped and not displayed")
}

return nil
}

func logSkippedSignature(sigDesc ocispec.Descriptor, err error) {
fmt.Fprintf(os.Stderr, "Warning: Skipping signature %s because of error: %v\n", sigDesc.Digest.String(), err)
}

func getSignedAttributes(outputFormat string, envContent *signature.EnvelopeContent) map[string]string {
signedAttributes := map[string]string{
"signingScheme": string(envContent.SignerInfo.SignedAttributes.SigningScheme),
"signingTime": formatTimestamp(outputFormat, envContent.SignerInfo.SignedAttributes.SigningTime),
"expiry": formatTimestamp(outputFormat, envContent.SignerInfo.SignedAttributes.Expiry),
}

for _, attribute := range envContent.SignerInfo.SignedAttributes.ExtendedAttributes {
signedAttributes[fmt.Sprint(attribute.Key)] = fmt.Sprint(attribute.Value)
}

return signedAttributes
}

func getUnsignedAttributes(envContent *signature.EnvelopeContent) map[string]string {
unsignedAttributes := map[string]string{}

if envContent.SignerInfo.UnsignedAttributes.TimestampSignature != nil {
unsignedAttributes["timestampSignature"] = b64.StdEncoding.EncodeToString(envContent.SignerInfo.UnsignedAttributes.TimestampSignature)
}

if envContent.SignerInfo.UnsignedAttributes.SigningAgent != "" {
unsignedAttributes["signingAgent"] = envContent.SignerInfo.UnsignedAttributes.SigningAgent
}

return unsignedAttributes
}

func formatTimestamp(outputFormat string, t time.Time) string {
switch outputFormat {
case cmd.OutputJSON:
return t.Format(time.RFC3339)
default:
return t.Format(time.ANSIC)
}
}

func getCertificates(outputFormat string, envContent *signature.EnvelopeContent) []certificateOutput {
certificates := []certificateOutput{}

for _, cert := range envContent.SignerInfo.CertificateChain {
h := sha1.Sum(cert.Raw)
fingerprint := strings.ToLower(hex.EncodeToString(h[:]))

certificate := certificateOutput{
SHA1Fingerprint: fingerprint,
IssuedTo: cert.Subject.String(),
IssuedBy: cert.Issuer.String(),
Expiry: formatTimestamp(outputFormat, cert.NotAfter),
}

certificates = append(certificates, certificate)
}

return certificates
}

func printOutput(outputFormat string, ref string, output inspectOutput) error {
if outputFormat == cmd.OutputJSON {
return ioutil.PrintObjectAsJSON(output)
}

fmt.Println("Inspecting all signatures for signed artifact")
root := tree.New(ref)
cncfSigNode := root.Add(registry.ArtifactTypeNotation)

for _, signature := range output.Signatures {
sigNode := cncfSigNode.Add(signature.Digest)
sigNode.AddPair("media type", signature.MediaType)
sigNode.AddPair("signature algorithm", signature.SignatureAlgorithm)

signedAttributesNode := sigNode.Add("signed attributes")
addMapToTree(signedAttributesNode, signature.SignedAttributes)

userDefinedAttributesNode := sigNode.Add("user defined attributes")
addMapToTree(userDefinedAttributesNode, signature.UserDefinedAttributes)

unsignedAttributesNode := sigNode.Add("unsigned attributes")
addMapToTree(unsignedAttributesNode, signature.UnsignedAttributes)

certListNode := sigNode.Add("certificates")
for _, cert := range signature.Certificates {
certNode := certListNode.AddPair("SHA1 fingerprint", cert.SHA1Fingerprint)
certNode.AddPair("issued to", cert.IssuedTo)
certNode.AddPair("issued by", cert.IssuedBy)
certNode.AddPair("expiry", cert.Expiry)
}

artifactNode := sigNode.Add("signed artifact")
artifactNode.AddPair("media type", signature.SignedArtifact.MediaType)
artifactNode.AddPair("digest", signature.SignedArtifact.Digest.String())
artifactNode.AddPair("size", strconv.FormatInt(signature.SignedArtifact.Size, 10))
}

root.Print()
return nil
}

func addMapToTree(node *tree.Node, m map[string]string) {
if len(m) > 0 {
for k, v := range m {
node.AddPair(k, v)
}
} else {
node.Add("(empty)")
}
}
71 changes: 71 additions & 0 deletions cmd/notation/inspect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package main

import (
"testing"

"github.com/notaryproject/notation/internal/cmd"
)

func TestInspectCommand_SecretsFromArgs(t *testing.T) {
opts := &inspectOpts{}
command := inspectCommand(opts)
expected := &inspectOpts{
reference: "ref",
SecureFlagOpts: SecureFlagOpts{
Password: "password",
PlainHTTP: true,
Username: "user",
},
outputFormat: cmd.OutputPlaintext,
}
if err := command.ParseFlags([]string{
"--password", expected.Password,
expected.reference,
"-u", expected.Username,
"--plain-http",
"--output", "text"}); err != nil {
t.Fatalf("Parse Flag failed: %v", err)
}
if err := command.Args(command, command.Flags().Args()); err != nil {
t.Fatalf("Parse Args failed: %v", err)
}
if *opts != *expected {
t.Fatalf("Expect inspect opts: %v, got: %v", expected, opts)
}
}

func TestInspectCommand_SecretsFromEnv(t *testing.T) {
t.Setenv(defaultUsernameEnv, "user")
t.Setenv(defaultPasswordEnv, "password")
opts := &inspectOpts{}
expected := &inspectOpts{
reference: "ref",
SecureFlagOpts: SecureFlagOpts{
Password: "password",
Username: "user",
},
outputFormat: cmd.OutputJSON,
}
command := inspectCommand(opts)
if err := command.ParseFlags([]string{
expected.reference,
"--output", "json"}); err != nil {
t.Fatalf("Parse Flag failed: %v", err)
}
if err := command.Args(command, command.Flags().Args()); err != nil {
t.Fatalf("Parse Args failed: %v", err)
}
if *opts != *expected {
t.Fatalf("Expect inspect opts: %v, got: %v", expected, opts)
}
}

func TestInspectCommand_MissingArgs(t *testing.T) {
command := inspectCommand(nil)
if err := command.ParseFlags(nil); err != nil {
t.Fatalf("Parse Flag failed: %v", err)
}
if err := command.Args(command, command.Flags().Args()); err == nil {
t.Fatal("Parse Args expected error, but ok")
}
}
1 change: 1 addition & 0 deletions cmd/notation/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func main() {
loginCommand(nil),
logoutCommand(nil),
versionCommand(),
inspectCommand(nil),
)
if err := cmd.Execute(); err != nil {
os.Exit(1)
Expand Down
Loading

0 comments on commit d62fd58

Please sign in to comment.