9 Commits
v0.1.0 ... main

Author SHA1 Message Date
Christian Dupuis
45dec2439e Fix reporting for images without CVEs
Some checks failed
Go / build (push) Has been cancelled
Signed-off-by: Christian Dupuis <cd@atomist.com>
2022-10-25 15:06:58 +02:00
Christian Dupuis
81ff63779b Add goreleaser GitHub action
Some checks failed
goreleaser / goreleaser (push) Has been cancelled
Signed-off-by: Christian Dupuis <cd@atomist.com>
2022-10-17 13:33:15 +02:00
Christian Dupuis
5073234a81 Add query support for index
Signed-off-by: Christian Dupuis <cd@atomist.com>
2022-10-17 13:32:19 +02:00
Christian Dupuis
28e2578d9e Fix typo
Signed-off-by: Christian Dupuis <cd@atomist.com>
2022-09-19 17:01:04 +02:00
Christian Dupuis
c4fb3dbee0 Merge pull request #3 from docker/atomist/gofmt-main
Go format fixes
2022-09-19 16:57:40 +02:00
Atomist Bot
424469a2b1 Run go mod tidy
[atomist:generated]
 [atomist-skill:atomist/go-format-skill]

Signed-off-by: Christian Dupuis <christian@atomist.com>
2022-09-19 14:42:00 +00:00
Christian Dupuis
f0e36ec311 Merge pull request #2 from docker/issue-1
Remove `--api-key` parameter
2022-09-19 16:41:47 +02:00
Christian Dupuis
12098e0f6e Remove --api-key parameter
fixes #1

Signed-off-by: Christian Dupuis <cd@atomist.com>
2022-09-19 16:39:53 +02:00
Christian Dupuis
1b78e08fca Add another line break
Signed-off-by: Christian Dupuis <cd@atomist.com>
2022-09-16 17:58:54 +02:00
12 changed files with 348 additions and 50 deletions

28
.github/workflows/goreleaser.yaml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: goreleaser
on:
push:
tags:
- 'v*.*.*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v3
with:
distribution: goreleaser
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
dist/
/base-cli-plugin
/go-skill.iml
/.idea

View File

@@ -38,23 +38,29 @@ Alternatively, you can install manually by following these steps:
To detect base images for local or remote images, use the following command:
```shell
$ docker base detect <image>
$ docker base detect <IMAGE>
```
`<image>` can either be a local image id or fully qualified image name from a remote registry.
`<IMAGE>` can either be a local image id or fully qualified image name from a remote registry.
### `docker base login`
To authenticate with the Atomist data plane, use the following command:
```shell
$ docker base login --workspace <workspace id> --api-key <api key>
$ docker base login <WORKSPACE ID>
```
Authentication is not required. If not authenticated, the plugin will only use public data from Docker Official Images,
Docker Verified Publishers or Docker-sponsored Open Source.
For the security reasons the command does not accept an API key as command parameter. Instead, an API key can be passed
in via stdin with the parameter `--api-key-stdin`.
Visit [dso.docker.com](https://dso.docker.com/r/auth/integrations) to obtain a `workspace id` and `api key`.
The `login` command will also check the legacy `ATOMIST_API_KEY` environment variable.
Authentication is not required. If not authenticated, the plugin will only use public data from Docker Official Images,
Docker Verified Publishers or Docker-sponsored Open Source. Without authentication the `detect` command will not take
into account your own data on Docker Hub when searching for matching base images.
Visit [dso.docker.com](https://dso.docker.com/r/auth/integrations) to obtain a `WORKSPACE ID` and `API KEY`.
### `docker base logout`

27
Taskfile.yaml Normal file
View File

@@ -0,0 +1,27 @@
version: '3'
tasks:
go:build:
cmds:
- go build -ldflags="-w -s -X 'github.com/docker/base-cli-plugin/internal.version={{.GIT_COMMIT}}'"
env:
CGO_ENABLED: 0
vars:
GIT_COMMIT:
sh: git describe --tags | cut -c 2-
go:install:
deps: [go:build]
cmds:
- mkdir -p ~/.docker/cli-plugins
- install base-cli-plugin ~/.docker/cli-plugins/docker-base
go:fmt:
cmds:
- goimports -w .
- gofmt -w .
- go mod tidy
go:release:
cmds:
- goreleaser release --rm-dist

View File

@@ -60,10 +60,12 @@ func Detect(dockerCli command.Cli, image string, workspace string, apiKey string
chainId := identity.ChainID(chainIds)
s.Suffix = fmt.Sprintf(" Finding matching base images for %s", label)
s.Restart()
images, err := query.ForBaseImage(chainId, workspace, apiKey)
if err != nil {
return err
images, err := query.ForBaseImageInDb(chainId, workspace, apiKey)
if err != nil || images == nil {
images, err = query.ForBaseImageInIndex(chainId, workspace, apiKey)
if err != nil {
return err
}
}
if images != nil {
bi := make([]string, len(*images))
@@ -103,7 +105,7 @@ func Detect(dockerCli command.Cli, image string, workspace string, apiKey string
bi[ix] = e
}
s.Stop()
fmt.Printf("Base image for %s\n%s\n\n", label, strings.Join(bi, "\n"))
fmt.Printf("Base image for %s\n%s\n\n", label, strings.Join(bi, "\n\n"))
}
}
return nil
@@ -182,6 +184,9 @@ func renderCommit(image query.Image) string {
func renderVulnerabilities(image query.Image) string {
if len(image.Report) > 0 {
report := image.Report[0]
if report.Total == -1 {
return " no CVE data available "
}
parts := make([]string, 0)
if report.Critical > 0 {
parts = append(parts, " C"+strconv.FormatInt(report.Critical, 10))

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/docker/cli v20.10.17+incompatible
github.com/fatih/color v1.13.0
github.com/google/go-containerregistry v0.5.1
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799
github.com/pkg/errors v0.9.1
@@ -40,7 +41,6 @@ require (
github.com/miekg/pkcs11 v1.0.3 // indirect
github.com/moby/sys/mount v0.3.3 // indirect
github.com/moby/sys/mountinfo v0.6.2 // indirect
github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/prometheus/client_golang v1.7.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect

102
main.go
View File

@@ -17,7 +17,11 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"github.com/docker/base-cli-plugin/commands"
"github.com/docker/base-cli-plugin/internal"
@@ -25,6 +29,7 @@ import (
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli-plugins/plugin"
"github.com/docker/cli/cli/command"
"github.com/moby/term"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
@@ -32,7 +37,7 @@ import (
func main() {
plugin.Run(func(dockerCli command.Cli) *cobra.Command {
var (
workspace, apiKey string
apiKeyStdin bool
)
logout := &cobra.Command{
@@ -46,23 +51,29 @@ func main() {
}
login := &cobra.Command{
Use: "login",
Short: "Authenticate with an Atomist workspace",
RunE: func(cmd *cobra.Command, _ []string) error {
if !query.CheckAuth(workspace, apiKey) {
return errors.New("login failed")
} else {
Use: "login WORKSPACE",
Short: "Authenticate with Atomist workspace",
RunE: func(cmd *cobra.Command, args []string) error {
workspace, err := readWorkspace(args, dockerCli)
if err != nil {
return err
}
apiKey, err := readApiKey(apiKeyStdin, dockerCli)
if err != nil {
return err
}
if query.CheckAuth(workspace, apiKey) {
fmt.Println("Login successful")
dockerCli.ConfigFile().SetPluginConfig("base", "workspace", workspace)
dockerCli.ConfigFile().SetPluginConfig("base", "api-key", apiKey)
return dockerCli.ConfigFile().Save()
} else {
return errors.New("Login failed")
}
},
}
loginFlags := login.Flags()
loginFlags.StringVar(&workspace, "workspace", "", "Atomist workspace")
loginFlags.StringVar(&apiKey, "api-key", "", "Atomist API key")
login.MarkFlagRequired("workspace")
login.MarkFlagRequired("api-key")
loginFlags.BoolVar(&apiKeyStdin, "api-key-stdin", false, "Atomist API key")
base := &cobra.Command{
Use: "detect [OPTIONS] IMAGE",
@@ -74,24 +85,17 @@ func main() {
}
return fmt.Errorf(`"docker base detect" requires exactly 1 argument`)
}
if workspace == "" {
workspace, _ = dockerCli.ConfigFile().PluginConfig("base", "workspace")
}
if apiKey == "" {
apiKey, _ = dockerCli.ConfigFile().PluginConfig("base", "api-key")
}
workspace, _ := dockerCli.ConfigFile().PluginConfig("base", "workspace")
apiKey, _ := dockerCli.ConfigFile().PluginConfig("base", "api-key")
return commands.Detect(dockerCli, args[0], workspace, apiKey)
},
}
baseFlags := base.Flags()
baseFlags.StringVar(&workspace, "workspace", "", "Atomist workspace")
baseFlags.StringVar(&apiKey, "api-key", "", "Atomist API key")
base.MarkFlagRequired("image")
cmd := &cobra.Command{
Use: "base",
Short: "Identify base image",
Short: "Identify base images",
}
cmd.AddCommand(login, logout, base)
@@ -103,3 +107,59 @@ func main() {
Version: internal.FromBuild().Version,
})
}
func readWorkspace(args []string, cli command.Cli) (string, error) {
var workspace string
if len(args) == 1 {
workspace = args[0]
} else {
fmt.Fprintf(cli.Out(), "Workspace: ")
workspace = readInput(cli.In(), cli.Out())
if workspace == "" {
return "", errors.Errorf("Error: Workspace required")
}
}
return workspace, nil
}
func readApiKey(apiKeyStdin bool, cli command.Cli) (string, error) {
var apiKey string
if apiKeyStdin {
contents, err := io.ReadAll(cli.In())
if err != nil {
return "", err
}
apiKey = strings.TrimSuffix(string(contents), "\n")
apiKey = strings.TrimSuffix(apiKey, "\r")
} else if v, ok := os.LookupEnv("ATOMIST_API_KEY"); v != "" && ok {
apiKey = v
} else {
oldState, err := term.SaveState(cli.In().FD())
if err != nil {
return "", err
}
fmt.Fprintf(cli.Out(), "API key: ")
term.DisableEcho(cli.In().FD(), oldState)
apiKey = readInput(cli.In(), cli.Out())
fmt.Fprint(cli.Out(), "\n")
term.RestoreTerminal(cli.In().FD(), oldState)
if apiKey == "" {
return "", errors.Errorf("Error: API key required")
}
}
return apiKey, nil
}
func readInput(in io.Reader, out io.Writer) string {
reader := bufio.NewReader(in)
line, _, err := reader.ReadLine()
if err != nil {
fmt.Fprintln(out, err.Error())
os.Exit(1)
}
return string(line)
}

96
query/index.go Normal file
View File

@@ -0,0 +1,96 @@
/*
* Copyright © 2022 Docker, Inc.
*
* 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 query
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
)
type IndexImage struct {
Digest string `json:"digest"`
CreatedAt time.Time `json:"createdAt"`
Platform struct {
Os string `json:"os"`
Arch string `json:"arch"`
Variant string `json:"variant"`
} `json:"platform"`
Layers []struct {
Digest string `json:"digest"`
Size int `json:"size"`
LastModified time.Time `json:"lastModified"`
} `json:"layers"`
DigestChainId string `json:"digestChainId"`
DiffIdChainId string `json:"diffIdChainId"`
}
type IndexManifestList struct {
Name string `json:"name"`
Tags []string `json:"tags"`
Digest string `json:"digest"`
Images []IndexImage `json:"images"`
}
func ForBaseImageInIndex(digest digest.Digest, workspace string, apiKey string) (*[]Image, error) {
url := fmt.Sprintf("https://api.dso.docker.com/docker-images/chain-ids/%s.json", digest.String())
resp, err := http.Get(url)
if err != nil {
return nil, errors.Wrapf(err, "failed to query index")
}
if resp.StatusCode == 200 {
var manifestList []IndexManifestList
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrapf(err, "failed to read response body")
}
err = json.Unmarshal(body, &manifestList)
if err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal response body")
}
var ii IndexImage
for _, i := range manifestList[0].Images {
if i.DigestChainId == digest.String() || i.DiffIdChainId == digest.String() {
ii = i
break
}
}
repository, err := ForRepositoryInDb(manifestList[0].Name, workspace, apiKey)
if err != nil {
return nil, errors.Wrapf(err, "failed to query for respository")
}
image := Image{
Digest: ii.Digest,
CreatedAt: ii.CreatedAt,
Tags: manifestList[0].Tags,
Repository: *repository,
Report: []Report{{
Total: -1,
}},
}
return &[]Image{image}, nil
}
return nil, nil
}

View File

@@ -36,6 +36,7 @@ type ManifestList struct {
}
type Report struct {
Total int64 `edn:"vulnerability.report/total"`
Critical int64 `edn:"vulnerability.report/critical"`
High int64 `edn:"vulnerability.report/high"`
Medium int64 `edn:"vulnerability.report/medium"`
@@ -43,6 +44,13 @@ type Report struct {
Unspecified int64 `edn:"vulnerability.report/unspecified"`
}
type Repository struct {
Badge string `edn:"docker.repository/badge"`
Host string `edn:"docker.repository/host"`
Name string `edn:"docker.repository/name"`
SupportedTags []string `edn:"docker.repository/supported-tags"`
}
type Image struct {
TeamId string `edn:"atomist/team-id"`
Digest string `edn:"docker.image/digest"`
@@ -52,13 +60,8 @@ type Image struct {
Name string `edn:"docker.tag/name"`
} `edn:"docker.image/tag"`
ManifestList []ManifestList `edn:"docker.image/manifest-list"`
Repository struct {
Badge string `edn:"docker.repository/badge"`
Host string `edn:"docker.repository/host"`
Name string `edn:"docker.repository/name"`
SupportedTags []string `edn:"docker.repository/supported-tags"`
} `edn:"docker.image/repository"`
File struct {
Repository Repository `edn:"docker.image/repository"`
File struct {
Path string `edn:"git.file/path"`
} `edn:"docker.image/file"`
Commit struct {
@@ -73,16 +76,25 @@ type Image struct {
Report []Report `edn:"vulnerability.report/report"`
}
type QueryResult struct {
type ImageQueryResult struct {
Query struct {
Data [][]Image `edn:"data"`
} `edn:"query"`
}
type RepositoryQueryResult struct {
Query struct {
Data [][]Repository `edn:"data"`
} `edn:"query"`
}
//go:embed base_image_query.edn
var baseImageQuery string
//go:embed enabled_skills.edn
//go:embed repository_query.edn
var repositoryQuery string
//go:embed enabled_skills_query.edn
var enabledSkillsQuery string
func CheckAuth(workspace string, apiKey string) bool {
@@ -93,12 +105,12 @@ func CheckAuth(workspace string, apiKey string) bool {
return true
}
// ForBaseImage returns images with matching digest in :docker.image/blob-digest or :docker.image/diff-chain-id
func ForBaseImage(digest digest.Digest, workspace string, apiKey string) (*[]Image, error) {
// ForBaseImageInDb returns images with matching digest in :docker.image/blob-digest or :docker.image/diff-chain-id
func ForBaseImageInDb(digest digest.Digest, workspace string, apiKey string) (*[]Image, error) {
resp, err := query(fmt.Sprintf(baseImageQuery, digest), workspace, apiKey)
if workspace == "" || apiKey == "" {
var result QueryResult
var result ImageQueryResult
err = edn.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal response")
@@ -120,7 +132,7 @@ func ForBaseImage(digest digest.Digest, workspace string, apiKey string) (*[]Ima
for _, img := range images {
tba := true
for j := range image {
if image[j].Digest == img[0].Digest && img[0].TeamId == "A0GLG1QQA" {
if image[j].Digest == img[0].Digest && img[0].TeamId == "A11PU8L1C" {
image[j] = img[0]
tba = false
break
@@ -138,10 +150,38 @@ func ForBaseImage(digest digest.Digest, workspace string, apiKey string) (*[]Ima
}
}
func query(query string, workspace string, apiKey string) (*http.Response, error) {
url := "https://api.atomist.com/datalog/team/" + workspace
func ForRepositoryInDb(repo string, workspace string, apiKey string) (*Repository, error) {
resp, err := query(fmt.Sprintf(repositoryQuery, repo), workspace, apiKey)
if workspace == "" || apiKey == "" {
url = "https://api.atomist.com/datalog/shared-vulnerability/queries"
var result RepositoryQueryResult
err = edn.NewDecoder(resp.Body).Decode(&result)
if err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal response")
}
if len(result.Query.Data) > 0 {
return &result.Query.Data[0][0], nil
} else {
return nil, nil
}
} else {
var repositories [][]Repository
err = edn.NewDecoder(resp.Body).Decode(&repositories)
if err != nil {
return nil, errors.Wrapf(err, "failed to unmarshal response")
}
if len(repositories) > 0 {
return &repositories[0][0], nil
} else {
return nil, nil
}
}
}
func query(query string, workspace string, apiKey string) (*http.Response, error) {
url := "https://api.dso.docker.com/datalog/team/" + workspace
if workspace == "" || apiKey == "" {
url = "https://api.dso.docker.com/datalog/shared-vulnerability/queries"
query = fmt.Sprintf(`{:queries [{:name "query" :query %s}]}`, query)
} else {
query = fmt.Sprintf(`{:query %s}`, query)

View File

@@ -0,0 +1,35 @@
;; Copyright © 2022 Docker, Inc.
;;
;; 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.
[:find
?repo
:in $ $before-db %% ?ctx
:where
[(ground "%s") ?name]
[(adb/query (quote [:find
(pull ?repo [:atomist/team-id
:docker.repository/host
:docker.repository/badge
:docker.repository/supported-tags
(:docker.repository/repository :as :docker.repository/name)])
:in $ $b %% ?ctx [?name]
:where
[?repo :docker.repository/repository ?name]
[?repo :docker.repository/host "hub.docker.com"]
])
?name)
?results]
[(untuple ?results) [?result ...]]
[(untuple ?result) [?repo]]
]

View File

@@ -48,7 +48,7 @@ func DigestForImage(dockerCli command.Cli, image string) ([]digest.Digest, error
}
// check local daemon first
img, err := daemon.Image(ref)
img, err := daemon.Image(ref, daemon.WithClient(dockerCli.Client()))
if err != nil {
// image doesn't exist in daemon; try remote
index, _ := remote.Index(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))