Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f648a94a25 | ||
|
|
12c29fd0be |
@@ -0,0 +1,97 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"coopcloud.tech/abra/cli/internal"
|
||||
"coopcloud.tech/abra/pkg/client"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/i18n"
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
"coopcloud.tech/abra/pkg/upstream/stack"
|
||||
"coopcloud.tech/abra/pkg/vulncheck"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"context"
|
||||
)
|
||||
|
||||
var appVulncheckAliases = i18n.G("vc")
|
||||
|
||||
var AppVulncheckCommand = &cobra.Command{
|
||||
Use: i18n.G("vulncheck [flags] APP"),
|
||||
Aliases: []string{appVulncheckAliases},
|
||||
Short: i18n.G("Check for vulnerabilities in images for this app"),
|
||||
Long: i18n.G("Check for vulnerabilities in images for this app"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !deployMeta.IsDeployed {
|
||||
log.Fatal(i18n.G("%s is not deployed?", app.Name))
|
||||
}
|
||||
|
||||
// log.Debugf("App: %+v", app)
|
||||
// log.Debugf("Deployed version: %s", deployMeta.Version)
|
||||
status := stack.GetAllDeployedServices(cl, app.StackName())
|
||||
|
||||
ac := &vulncheck.AdvisoryClient{}
|
||||
ac.Init()
|
||||
|
||||
rows := [][]string{}
|
||||
for _, svc := range status.Services {
|
||||
imgname := formatter.TrimNs(svc.Spec.Labels["com.docker.stack.image"])
|
||||
advisories := ac.GetAdvisoriesForImage(imgname)
|
||||
for _, advisory := range advisories {
|
||||
rows = append(rows, []string{
|
||||
advisory.Recipe,
|
||||
svc.Spec.Name,
|
||||
imgname,
|
||||
advisory.ShortDesc,
|
||||
advisory.Details,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
log.Info(i18n.G("No advisories found for %s", app.Name))
|
||||
return
|
||||
}
|
||||
|
||||
table, err := formatter.CreateTable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
headers := []string{
|
||||
i18n.G("RECIPE"),
|
||||
i18n.G("SERVICE"),
|
||||
i18n.G("IMAGE"),
|
||||
i18n.G("VERSION"),
|
||||
i18n.G("DETAILS"),
|
||||
}
|
||||
|
||||
table.
|
||||
Headers(headers...).
|
||||
Rows(rows...)
|
||||
|
||||
if err := formatter.PrintTable(table); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
upgradeTo := ac.GetRecipeUpgradeTo(deployMeta.Version)
|
||||
|
||||
log.Infof(i18n.G("Security advisories found for %s, upgrade recipe from %s to %s"), app.Recipe.Name, deployMeta.Version, upgradeTo)
|
||||
},
|
||||
}
|
||||
@@ -311,6 +311,7 @@ Config:
|
||||
app.AppVolumeCommand,
|
||||
app.AppLabelsCommand,
|
||||
app.AppEnvCommand,
|
||||
app.AppVulncheckCommand,
|
||||
)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
||||
+9
-51
@@ -10,58 +10,16 @@ import (
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
)
|
||||
|
||||
// setLabel writes a label to both the service object and the task template of
|
||||
// every service of the stack.
|
||||
//
|
||||
// The task template labels end up on the containers themselves and are therefore
|
||||
// visible to container-level tooling (cAdvisor, log shippers, docker events),
|
||||
// which cannot read service labels at all.
|
||||
//
|
||||
// The service object is written as well because it is free: changing
|
||||
// Spec.Labels updates the service without recreating any task and because
|
||||
// `docker service ls --filter label=...` matches service labels only.
|
||||
func setLabel(compose *composetypes.Config, key string, value string) {
|
||||
for i := range compose.Services {
|
||||
if compose.Services[i].Deploy.Labels == nil {
|
||||
compose.Services[i].Deploy.Labels = composetypes.Labels{}
|
||||
}
|
||||
|
||||
if compose.Services[i].Labels == nil {
|
||||
compose.Services[i].Labels = composetypes.Labels{}
|
||||
}
|
||||
|
||||
compose.Services[i].Deploy.Labels[key] = value
|
||||
compose.Services[i].Labels[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// setAppLabel writes a label to the service object of the app service only.
|
||||
func setAppLabel(compose *composetypes.Config, key string, value string) {
|
||||
for i := range compose.Services {
|
||||
if compose.Services[i].Name != "app" {
|
||||
continue
|
||||
}
|
||||
|
||||
if compose.Services[i].Deploy.Labels == nil {
|
||||
compose.Services[i].Deploy.Labels = composetypes.Labels{}
|
||||
}
|
||||
|
||||
compose.Services[i].Deploy.Labels[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// SetRecipeLabel adds two distinct labels to signal which recipe is connected
|
||||
// to the deployed app:
|
||||
// - 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' on the app service object,
|
||||
// unchanged, as read back by GetLabel
|
||||
// - 'coop-cloud.recipe=${RECIPE}' on every service of the stack, so that
|
||||
// tooling can attribute any container to a recipe without knowing the
|
||||
// stack name up front
|
||||
// SetRecipeLabel adds the label 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' to the app container
|
||||
// to signal which recipe is connected to the deployed app
|
||||
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
|
||||
log.Debug(i18n.G("set recipe labels 'coop-cloud.%s.recipe' and 'coop-cloud.recipe' to %s for %s", stackName, recipe, stackName))
|
||||
|
||||
setAppLabel(compose, fmt.Sprintf("coop-cloud.%s.recipe", stackName), recipe)
|
||||
setLabel(compose, "coop-cloud.recipe", recipe)
|
||||
for _, service := range compose.Services {
|
||||
if service.Name == "app" {
|
||||
log.Debug(i18n.G("set recipe label 'coop-cloud.%s.recipe' to %s for %s", stackName, recipe, stackName))
|
||||
labelKey := fmt.Sprintf("coop-cloud.%s.recipe", stackName)
|
||||
service.Deploy.Labels[labelKey] = recipe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetChaosLabel adds the label 'coop-cloud.${STACK_NAME}.chaos=true/false' to the app container
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
testPkg "coopcloud.tech/abra/pkg/test"
|
||||
stack "coopcloud.tech/abra/pkg/upstream/stack"
|
||||
|
||||
composetypes "github.com/docker/cli/cli/compose/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -62,43 +61,3 @@ func TestGetTimeoutFromLabel(t *testing.T) {
|
||||
assert.Equal(t, timeout, test.expectedTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRecipeLabel(t *testing.T) {
|
||||
// the app service brings labels along on both levels, the db service
|
||||
// has none at all, so the pre-existing and the nil map case are covered
|
||||
compose := &composetypes.Config{
|
||||
Services: []composetypes.ServiceConfig{
|
||||
{
|
||||
Name: "app",
|
||||
Labels: composetypes.Labels{"example.container.label": "keep me"},
|
||||
Deploy: composetypes.DeployConfig{
|
||||
Labels: composetypes.Labels{"coop-cloud.backupbot.enabled": "true"},
|
||||
},
|
||||
},
|
||||
{Name: "db"},
|
||||
},
|
||||
}
|
||||
|
||||
appPkg.SetRecipeLabel(compose, "test_example_com", "test-recipe")
|
||||
|
||||
services := make(map[string]composetypes.ServiceConfig)
|
||||
for _, service := range compose.Services {
|
||||
services[service.Name] = service
|
||||
}
|
||||
|
||||
// the stack scoped label stays on the app service object only
|
||||
assert.Equal(t, "test-recipe",
|
||||
services["app"].Deploy.Labels["coop-cloud.test_example_com.recipe"])
|
||||
assert.NotContains(t, services["db"].Deploy.Labels,
|
||||
"coop-cloud.test_example_com.recipe")
|
||||
|
||||
// the static label reaches every container of the stack
|
||||
for name, service := range services {
|
||||
assert.Equal(t, "test-recipe", service.Labels["coop-cloud.recipe"], name)
|
||||
assert.Equal(t, "test-recipe", service.Deploy.Labels["coop-cloud.recipe"], name)
|
||||
}
|
||||
|
||||
// labels the recipe brought along are left alone
|
||||
assert.Equal(t, "keep me", services["app"].Labels["example.container.label"])
|
||||
assert.Equal(t, "true", services["app"].Deploy.Labels["coop-cloud.backupbot.enabled"])
|
||||
}
|
||||
|
||||
@@ -34,6 +34,24 @@ func SmallSHA(hash string) string {
|
||||
return hash[:8]
|
||||
}
|
||||
|
||||
// TrimNs strips any repository namespace/org and digest from an image string.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "n8nio/n8n:1.81.2" -> "n8n:1.81.2"
|
||||
// "nginx:1.29.0@sha256:3ab4ed..." -> "nginx:1.29.0"
|
||||
// "docker.io/library/nginx:latest" -> "nginx:latest"
|
||||
func TrimNs(image string) string {
|
||||
// 1. Remove digest suffix if present (@sha256:...)
|
||||
if idx := strings.Index(image, "@"); idx != -1 {
|
||||
image = image[:idx]
|
||||
}
|
||||
|
||||
// 2. Remove registry domain and org/namespace path (keep only the last element)
|
||||
parts := strings.Split(image, "/")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// RemoveSha remove image sha from a string that are added in some docker outputs
|
||||
func RemoveSha(str string) string {
|
||||
return strings.Split(str, "@")[0]
|
||||
|
||||
@@ -3047,7 +3047,7 @@ msgstr ""
|
||||
msgid "generated secrets %s shown again, please take note of them %s"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:105
|
||||
#: ./pkg/app/compose.go:63
|
||||
#, c-format
|
||||
msgid "get label '%s'"
|
||||
msgstr ""
|
||||
@@ -3695,7 +3695,7 @@ msgstr ""
|
||||
msgid "no %s exists, skipping reading gitignore paths"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:111
|
||||
#: ./pkg/app/compose.go:69
|
||||
#, c-format
|
||||
msgid "no %s label found for %s"
|
||||
msgstr ""
|
||||
@@ -4746,24 +4746,24 @@ msgstr ""
|
||||
msgid "set 'main' as the default branch"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:72
|
||||
#: ./pkg/app/compose.go:30
|
||||
#, c-format
|
||||
msgid "set label 'coop-cloud.%s.chaos' to %v for %s"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:83
|
||||
#: ./pkg/app/compose.go:41
|
||||
#, c-format
|
||||
msgid "set label 'coop-cloud.%s.chaos-version' to %v for %s"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:93
|
||||
#: ./pkg/app/compose.go:51
|
||||
#, c-format
|
||||
msgid "set label 'coop-cloud.%s.version' to %v for %s"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:61
|
||||
#: ./pkg/app/compose.go:18
|
||||
#, c-format
|
||||
msgid "set recipe labels 'coop-cloud.%s.recipe' and 'coop-cloud.recipe' to %s for %s"
|
||||
msgid "set recipe label 'coop-cloud.%s.recipe' to %s for %s"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/git/init.go:60
|
||||
@@ -5064,7 +5064,7 @@ msgstr ""
|
||||
msgid "timed out on undeploy (timeout=%v sec)"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:122
|
||||
#: ./pkg/app/compose.go:80
|
||||
#, c-format
|
||||
msgid "timeout label: %s"
|
||||
msgstr ""
|
||||
@@ -5172,7 +5172,7 @@ msgstr ""
|
||||
msgid "unable to continue, input required for initial version"
|
||||
msgstr ""
|
||||
|
||||
#: ./pkg/app/compose.go:127
|
||||
#: ./pkg/app/compose.go:85
|
||||
#, c-format
|
||||
msgid "unable to convert timeout label %s to int: %s"
|
||||
msgstr ""
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package vulncheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"coopcloud.tech/abra/pkg/log"
|
||||
|
||||
"coopcloud.tech/abra/pkg/config"
|
||||
"coopcloud.tech/abra/pkg/formatter"
|
||||
"coopcloud.tech/abra/pkg/git"
|
||||
)
|
||||
|
||||
type Advisory struct {
|
||||
Id string `json:"ccsa_id"`
|
||||
Recipe string `json:"recipe"`
|
||||
AdvisoryDate string `json:"date"`
|
||||
RecipeVersions []string `json:"versions"`
|
||||
RecipesUpgradeTo []string `json:"upgrade_to"`
|
||||
Images []string `json:"images"`
|
||||
ShortDesc string `json:"short"`
|
||||
Details string `json:"description"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type AdvisoryClient struct {
|
||||
Advisories []Advisory
|
||||
}
|
||||
|
||||
func (a *AdvisoryClient) Init() {
|
||||
dir := path.Join(config.ABRA_DIR, "security-advisories")
|
||||
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
log.Infof("Syncing abra security advisories...")
|
||||
err := git.Clone(dir, "https://git.coopcloud.tech/sixsmith/security-advisories.git")
|
||||
if err != nil {
|
||||
log.Debugf("Error cloning security advisories repo: ", err)
|
||||
log.Fatalf("Error cloning security advisories repo: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
afp := path.Join(dir, "CCSA.json")
|
||||
err := a.loadAdvisoriesFromFile(afp)
|
||||
if err != nil {
|
||||
log.Fatalf("Error loading advisories: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AdvisoryClient) loadAdvisoriesFromFile(filePath string) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var advisories []Advisory
|
||||
decoder := json.NewDecoder(file)
|
||||
err = decoder.Decode(&advisories)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.Advisories = advisories
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AdvisoryClient) GetAdvisoriesForImage(image string) []Advisory {
|
||||
var relevantAdvisories []Advisory
|
||||
for _, advisory := range a.Advisories {
|
||||
for _, img := range advisory.Images {
|
||||
if formatter.TrimNs(image) == formatter.TrimNs(img) {
|
||||
relevantAdvisories = append(relevantAdvisories, advisory)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return relevantAdvisories
|
||||
}
|
||||
|
||||
func (a *AdvisoryClient) GetRecipeUpgradeTo(recipeVer string) string {
|
||||
for _, advisory := range a.Advisories {
|
||||
for _, ver := range advisory.RecipeVersions {
|
||||
if recipeVer == ver {
|
||||
if len(advisory.RecipesUpgradeTo) > 0 {
|
||||
return advisory.RecipesUpgradeTo[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package vulncheck
|
||||
Reference in New Issue
Block a user