diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4ebadce --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @albertollamaso diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..27936f5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + All Submissions: + +* [ ] Have you followed the guidelines in our Contributing document? +* [ ] Have you checked to ensure there aren't other open [Pull Requests](../../../pulls) for the same update/change? + +### New Feature Submissions: + +1. [ ] Does your submission pass tests? +2. [ ] Have you lint your code locally before submission? + +### Changes to Core Features: + +* [ ] Have you added an explanation of what your changes do and why you'd like us to include them? +* [ ] Have you written new tests for your core changes, as applicable? +* [ ] Have you successfully run tests with your changes locally? diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..253bd96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Falco config file +config/ + +# Plugin binary +libnomad.so + +# IDE +.vscode diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..5f703e5 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +SHELL=/bin/bash -o pipefail +GO ?= go + +NAME := nomad +OUTPUT := lib$(NAME).so + +ifeq ($(DEBUG), 1) + GODEBUGFLAGS= GODEBUG=cgocheck=2 +else + GODEBUGFLAGS= GODEBUG=cgocheck=0 +endif + +all: $(OUTPUT) + +clean: + @rm -f $(OUTPUT) + +$(OUTPUT): + @$(GODEBUGFLAGS) $(GO) build -buildmode=c-shared -o $(OUTPUT) ./plugin diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7aa62f --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Falcosecurity Nomad Plugin + +This repositry contains the Nomad plugin, which can fetch event stream containing [nomad](https://www.nomadproject.io/) events, parse the events, and emit sinsp/scap events (e.g. the events used by Falco) for each nomad event. + +## Event Source +The event source for nomad events is the `/event/stream` endpoint used to stream events generated by Nomad. + +## Supported Fields +Here is the current set of supported fields: + + +| NAME | TYPE | ARG | DESCRIPTION | +|-----------------------------------|----------|------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `nomad.index` | `uint64` | None | The index of the nomad event. | +| `nomad.alloc.name` | `string` | None | The name of the nomad allocation. | +| `nomad.alloc.namespace` | `string` | None | The namespace of the allocation. | +| `nomad.alloc.jobID` | `string` | None | The job ID of the allocation. | +| `nomad.alloc.clientStatus` | `string` | None | The client status of the allocation. | +| `nomad.alloc.images` | `string (list)` | None | The list of container images on allocations. | +| `nomad.alloc.images.tags` | `string (list)` | None | The tags of each container image on allocations. | +| `nomad.alloc.images.repositories` | `string (list)` | None | The container repositories used on allocations container images. | +| `nomad.alloc.taskStates.type` | `string (list)` | None | The state of the task on the allocations. | +| `nomad.alloc.res.cpu` | `uint64` | None | The CPU required to run this allocation in MHz. | +| `nomad.alloc.res.cores` | `uint64` | None | The number of CPU cores to reserve for the allocation. | +| `nomad.alloc.res.diskMB` | `uint64` | None | the amount of disk required for the allocation. | +| `nomad.alloc.res.iops` | `uint64` | None | the number of iops required for the allocation. | +| `nomad.alloc.res.memoryMB` | `uint64` | None | The memory required in MB for the allocation. | +| `nomad.alloc.res.memoryMaxMB` | `uint64` | None | The maximum memory the allocation may use. | +| `nomad.event.topic` | `string` | None | The topic of the nomad event. | +| `nomad.event.type` | `string` | None | The type of the nomad event. | +## Configuration + + +### `falco.yaml` Example + +```yaml +plugins: + - name: nomad + library_path: libnomad.so + init_config: + address: http://127.0.0.1:4646 + token: "" + namespace: "*" + +# Optional. If not specified the first entry in plugins is used. +load_plugins: [nomad, json] +``` + diff --git a/pkg/nomad/config.go b/pkg/nomad/config.go new file mode 100644 index 0000000..4610a8a --- /dev/null +++ b/pkg/nomad/config.go @@ -0,0 +1,19 @@ +package nomad + +// Defining a type for the plugin configuration. +// In this simple example, users can define the starting value the event +// counter. the `jsonschema` tags is used to automatically generate a +// JSON Schema definition, so that the framework can perform automatic +// validations. +type PluginConfig struct { + Address string `json:"address" jsonschema:"title=Nomad address,description=The address of the Nomad server.,default=http://localhost:4646"` + Token string `json:"token" jsonschema:"title=Nomad token,description=The token to use to connect to the Nomad server.,default="` + Namespace string `json:"namespace" jsonschema:"title=Nomad namespace,description=The namespace to use to connect to the Nomad server.,default=*"` +} + +// Resets sets the configuration to its default values +func (p *PluginConfig) Reset() { + p.Address = "http://localhost:4646" + p.Token = "" + p.Namespace = "*" +} diff --git a/pkg/nomad/extract.go b/pkg/nomad/extract.go new file mode 100644 index 0000000..e684e41 --- /dev/null +++ b/pkg/nomad/extract.go @@ -0,0 +1,112 @@ +package nomad + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk" + "github.com/hashicorp/nomad/api" +) + +// This method is mandatory the field extraction capability. +// If the Extract method is defined, the framework expects an Fields method +// to be specified too. +func (p *Plugin) Extract(req sdk.ExtractRequest, evt sdk.EventReader) error { + var event api.Event + encoder := json.NewDecoder(evt.Reader()) + if err := encoder.Decode(&event); err != nil { + return err + } + + switch req.Field() { + case "nomad.index": + req.SetValue(event.Index) + case "nomad.alloc.name": + alloc, err := event.Allocation() + if err == nil { + req.SetValue(string(alloc.Name)) + } + case "nomad.alloc.namespace": + alloc, err := event.Allocation() + if err == nil { + req.SetValue(string(alloc.Namespace)) + } + case "nomad.alloc.jobID": + alloc, err := event.Allocation() + if err == nil { + req.SetValue(string(alloc.JobID)) + } + case "nomad.alloc.clientStatus": + alloc, err := event.Allocation() + if err == nil { + req.SetValue(string(alloc.ClientStatus)) + } + case "nomad.alloc.taskStates.type": + var driver []string + alloc, err := event.Allocation() + if err == nil { + for _, task := range alloc.TaskStates { + for _, taskEvent := range task.Events { + if taskEvent.Type == "Driver" { + driver = append(driver, taskEvent.Type) + } + } + } + } + req.SetValue(driver) + case "nomad.alloc.res.cpu": + alloc, err := event.Allocation() + if err == nil { + req.SetValue(uint64(*alloc.Resources.CPU)) + } + case "nomad.alloc.res.cores": + alloc, err := event.Allocation() + if err == nil { + req.SetValue(uint64(*alloc.Resources.Cores)) + } + case "nomad.alloc.res.diskMB": + alloc := event.Payload["Allocation"] + valStr := fmt.Sprintf("%v", alloc.(map[string]interface{})["Resources"].(map[string]interface{})["DiskMB"]) // bug in nomad api. event.Allocation() returns for alloc.Resources.DiskMB + value, _ := strconv.ParseUint(valStr, 10, 64) + req.SetValue(value) + case "nomad.alloc.res.iops": + alloc, err := event.Allocation() + if err == nil { + req.SetValue(uint64(*alloc.Resources.IOPS)) + } + case "nomad.alloc.res.memoryMB": + alloc := event.Payload["Allocation"] + valStr := fmt.Sprintf("%v", alloc.(map[string]interface{})["Resources"].(map[string]interface{})["MemoryMB"]) // bug in nomad api. event.Allocation() returns for alloc.Resources.MemoryMB + value, _ := strconv.ParseUint(valStr, 10, 64) + req.SetValue(value) + case "nomad.alloc.res.memoryMaxMB": + alloc := event.Payload["Allocation"] + valStr := fmt.Sprintf("%v", alloc.(map[string]interface{})["Resources"].(map[string]interface{})["MemoryMaxMB"]) // bug in nomad api. event.Allocation() returns for alloc.Resources.MemoryMaxMB + value, _ := strconv.ParseUint(valStr, 10, 64) + req.SetValue(value) + case "nomad.alloc.images": + images, err := getAllocImages(&event) + if err == nil { + req.SetValue(images) + } + case "nomad.alloc.images.tags": + tags, err := getAllocTags(&event) + if err == nil { + req.SetValue(tags) + } + case "nomad.alloc.images.repositories": + repos, err := getAllocRepos(&event) + if err == nil { + req.SetValue(repos) + } + case "nomad.event.topic": + req.SetValue(string(event.Topic)) + case "nomad.event.type": + req.SetValue(event.Type) + default: + return fmt.Errorf("unsupported field: %s", req.Field()) + } + + return nil +} diff --git a/pkg/nomad/fields.go b/pkg/nomad/fields.go new file mode 100644 index 0000000..635f99b --- /dev/null +++ b/pkg/nomad/fields.go @@ -0,0 +1,31 @@ +package nomad + +import ( + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk" +) + +// Fields return the list of extractor fields exported by this plugin. +// This method is mandatory the field extraction capability. +// If the Fields method is defined, the framework expects an Extract method +// to be specified too. +func (p *Plugin) Fields() []sdk.FieldEntry { + return []sdk.FieldEntry{ + {Type: "uint64", Name: "nomad.index", Display: "Event index", Desc: "the index of the nomad event."}, + {Type: "string", Name: "nomad.alloc.name", Display: "Allocation name", Desc: "the name of the nomad allocation."}, + {Type: "string", Name: "nomad.alloc.namespace", Display: "Allocation namespace", Desc: "the namespace of the allocation."}, + {Type: "string", Name: "nomad.alloc.jobID", Display: "Allocation Job ID", Desc: "the job ID of the allocation."}, + {Type: "string", Name: "nomad.alloc.clientStatus", Display: "Allocation client status", Desc: "the client status of the allocation."}, + {Type: "string", Name: "nomad.alloc.images", Display: "Allocation container images", Desc: "the list of container images on allocations.", IsList: true}, + {Type: "string", Name: "nomad.alloc.images.tags", Display: "Allocation container tags", Desc: "the tags of each container image on allocations.", IsList: true}, + {Type: "string", Name: "nomad.alloc.images.repositories", Display: "Allocation container repositories", Desc: "the container repositories used on allocations container images.", IsList: true}, + {Type: "string", Name: "nomad.alloc.taskStates.type", Display: "Allocation Task State", Desc: "the state of the task on the allocations.", IsList: true}, + {Type: "uint64", Name: "nomad.alloc.res.cpu", Display: "Allocation CPU Resources", Desc: "the CPU required to run this allocation in MHz."}, + {Type: "uint64", Name: "nomad.alloc.res.cores", Display: "Allocation CPU Cores Resources", Desc: "the number of CPU cores to reserve for the allocation."}, + {Type: "uint64", Name: "nomad.alloc.res.diskMB", Display: "Allocation Disk in MB Resources", Desc: "the amount of disk required for the allocation."}, + {Type: "uint64", Name: "nomad.alloc.res.iops", Display: "Allocation IOPS Resources", Desc: "the number of iops required for the allocation."}, + {Type: "uint64", Name: "nomad.alloc.res.memoryMB", Display: "Allocation Memory in MB Resources", Desc: "the memory required in MB for the allocation."}, + {Type: "uint64", Name: "nomad.alloc.res.memoryMaxMB", Display: "Allocation Max Memory in MB Resources", Desc: "the maximum memory the allocation may use."}, + {Type: "string", Name: "nomad.event.topic", Display: "Nomad Event Topic", Desc: "the topic of the nomad event."}, + {Type: "string", Name: "nomad.event.type", Display: "Nomad Event type", Desc: "the type of the nomad event."}, + } +} diff --git a/pkg/nomad/helpers.go b/pkg/nomad/helpers.go new file mode 100644 index 0000000..16fa01e --- /dev/null +++ b/pkg/nomad/helpers.go @@ -0,0 +1,87 @@ +package nomad + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/api" +) + +func getAllocImages(evt *api.Event) ([]string, error) { + alloc, err := evt.Allocation() + if err != nil { + return nil, err + } + + var images []string + for _, task := range alloc.TaskStates { + for _, taskEvent := range task.Events { + image := taskEvent.Details["image"] + fmt.Println("------------------") + fmt.Println(image) + fmt.Println("------------------") + tokens := strings.Split(image, ":") + if strings.Contains(tokens[0], "/") { + // cases + // [registry]/[repository_name]/[repo_path_component]/[image]:[tag] (repository name could have two or more path components, they must be separated by a forward slash (“/”).) + // [registry]/[repository_name]/[image]:[tag] + // [registry]/[image]:[tag] + tokens = strings.Split(tokens[0], "/") + images = append(images, tokens[len(tokens)-1]) + } else { + // case [image]:[tag] + images = append(images, tokens[0]) + } + } + } + + return images, nil +} + +func getAllocTags(evt *api.Event) ([]string, error) { + alloc, err := evt.Allocation() + if err != nil { + return nil, err + } + + var tags []string + for _, task := range alloc.TaskStates { + for _, taskEvent := range task.Events { + tokens := strings.Split(taskEvent.Details["image"], ":") + tags = append(tags, tokens[len(tokens)-1]) + } + } + + return tags, nil +} + +func getAllocRepos(evt *api.Event) ([]string, error) { + alloc, err := evt.Allocation() + if err != nil { + return nil, err + } + + var repos []string + for _, task := range alloc.TaskStates { + for _, taskEvent := range task.Events { + tokens := strings.Split(taskEvent.Details["image"], ":") + if len(tokens) == 2 { + if strings.Contains(tokens[0], "/") { + // cases + // [registry]/[repository_name]/[repo_path_component]/[image]:[tag] (repository name could have two or more path components, they must be separated by a forward slash (“/”).) + // [registry]/[repository_name]/[image]:[tag] + // [registry]/[image]:[tag] + tokens = strings.Split(tokens[0], "/") + tokens = tokens[:len(tokens)-1] + tokenStr := strings.Join(tokens, "/") + repos = append(repos, tokenStr) + } else { + // case [image]:[tag] + repos = append(repos, "docker.io") + } + } + } + } + + return repos, nil +} diff --git a/pkg/nomad/init.go b/pkg/nomad/init.go new file mode 100644 index 0000000..9ab4b3b --- /dev/null +++ b/pkg/nomad/init.go @@ -0,0 +1,51 @@ +package nomad + +import ( + "encoding/json" + + "github.com/alecthomas/jsonschema" + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk" +) + +// InitSchema is gets called by the SDK before initializing the plugin. +// This returns a schema representing the configuration expected by the +// plugin to be passed to the Init() method. Defining InitSchema() allows +// the framework to automatically validate the configuration, so that the +// plugin can assume that it to be always be well-formed when passed to Init(). +// This is ignored if the return value is nil. The returned schema must follow +// the JSON Schema specific. See: https://json-schema.org/ +// This method is optional. +func (p *Plugin) InitSchema() *sdk.SchemaInfo { + // We leverage the jsonschema package to autogenerate the + // JSON Schema definition using reflection from our config struct. + schema, err := jsonschema.Reflect(&PluginConfig{}).MarshalJSON() + if err == nil { + return &sdk.SchemaInfo{ + Schema: string(schema), + } + } + return nil +} + +// Init initializes this plugin with a given config string. +// Since this plugin defines the InitSchema() method, we can assume +// that the configuration is pre-validated by the framework and +// always well-formed according to the provided schema. +// This method is mandatory. +func (p *Plugin) Init(config string) error { + // This is where any state variables can be set and initialize + p.Config.Reset() + + // Deserialize the config json. Ignoring the error + // and not validating the config values is possible + // due to the schema defined through InitSchema(), + // for which the framework performas a pre-validation. + return json.Unmarshal([]byte(config), &p.Config) +} + +// Destroy is gets called by the SDK when the plugin gets deinitialized. +// This is useful to release any open resource used by the plugin. +// This method is optional. +func (p *Plugin) Destroy() { + // here we can cleanup the plugin state when it gets destroyed +} diff --git a/pkg/nomad/plugin.go b/pkg/nomad/plugin.go new file mode 100644 index 0000000..069a8b3 --- /dev/null +++ b/pkg/nomad/plugin.go @@ -0,0 +1,44 @@ +// This plugin is an example of plugin that supports both +// the event sourcing and the field extraction capabilities. +package nomad + +import ( + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk/plugins" +) + +const ( + // note: 999 is for development only. Once released, plugins need to + // get assigned an ID in the public Falcosecurity registry. + // See: https://github.com/falcosecurity/plugins#plugin-registry + PluginID uint32 = 999 + PluginName = "nomad" + PluginDescription = "Falcosecurity Nomad Plugin" + PluginContact = "github.com/albertollamaso/nomad-plugin" + PluginVersion = "0.1.0" + PluginEventSource = "nomad" +) + +// Defining a type for the plugin. +// Composing the struct with plugins.BasePlugin is the recommended practice +// as it provides the boilerplate code that satisfies most of the interface +// requirements of the SDK. +// +// State variables to store in the plugin must be defined here. +type Plugin struct { + plugins.BasePlugin + Config PluginConfig +} + +// Info returns a pointer to a plugin.Info struct, +// containing all the general information about this plugin. +// This method is mandatory. +func (m *Plugin) Info() *plugins.Info { + return &plugins.Info{ + ID: PluginID, + Name: PluginName, + Description: PluginDescription, + Contact: PluginContact, + Version: PluginVersion, + EventSource: PluginEventSource, + } +} diff --git a/pkg/nomad/source.go b/pkg/nomad/source.go new file mode 100644 index 0000000..80f27c1 --- /dev/null +++ b/pkg/nomad/source.go @@ -0,0 +1,72 @@ +package nomad + +import ( + "bytes" + "context" + "encoding/json" + "time" + + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk/plugins/source" + "github.com/hashicorp/nomad/api" +) + +// Open opens the plugin source and starts a new capture session (e.g. stream +// of events), creating a new plugin instance. The state of each instance can +// be initialized here. This method is mandatory for the event sourcing capability. +func (m *Plugin) Open(params string) (source.Instance, error) { + + ctx, cancel := context.WithCancel(context.Background()) + + // Define Nomad client + client, err := api.NewClient(&api.Config{ + Address: m.Config.Address, + SecretID: m.Config.Token, + Namespace: m.Config.Namespace, + }) + if err != nil { + return nil, err + } + + topics := map[api.Topic][]string{ + api.TopicAll: {"*"}, + } + + streamCh, err := client.EventStream().Stream(ctx, topics, 0, &api.QueryOptions{ + WaitIndex: 0, + WaitTime: 30 * time.Second, + }) + if err != nil { + return nil, err + } + + eventCh := make(chan source.PushEvent) + go func() { + defer close(eventCh) + + for event := range streamCh { + m.ParseEventsAndPush(event, eventCh) + } + }() + return source.NewPushInstance(eventCh, source.WithInstanceClose(cancel)) +} + +func (m *Plugin) ParseEventsAndPush(events *api.Events, output chan<- source.PushEvent) { + + if events.Err != nil { + output <- source.PushEvent{ + Err: events.Err, + } + return + } + + var buffer bytes.Buffer + for _, event := range events.Events { + buffer.Reset() + json.NewEncoder(&buffer).Encode(event) + output <- source.PushEvent{ + Err: nil, + Data: buffer.Bytes(), + Timestamp: time.Now(), + } + } +} diff --git a/plugin/nomad.go b/plugin/nomad.go new file mode 100644 index 0000000..6990eb6 --- /dev/null +++ b/plugin/nomad.go @@ -0,0 +1,38 @@ +package main + +import ( + "github.com/albertollamaso/nomad-plugin/pkg/nomad" + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk/plugins" + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk/plugins/extractor" + "github.com/falcosecurity/plugin-sdk-go/pkg/sdk/plugins/source" +) + +// The plugin must be registered to the SDK in the init() function. +// Registering the plugin using both source.Register and extractor.Register +// declares to the SDK a plugin with both sourcing and extraction features +// enabled. The order in which the two Register functions are called is not +// relevant. +// This requires our plugin to implement the source.Plugin interface, so +// compilation will fail if the mandatory methods are not implemented. +func init() { + // plugins are registered in the Plugin SDK by defining a factory function + // to be used by the SDK whenever Falco initializes a new plugin + plugins.SetFactory(func() plugins.Plugin { + // creating an instance of our plugin so that the Plugin SDK + // knows its type definition + p := &nomad.Plugin{} + + // declares that our plugin supports the the event sourcing capability + // (see: https://falco.org/docs/plugins/plugin-api-reference/#event-sourcing-capability-api) + source.Register(p) + + // declares that our plugin supports the the field extraction capability + // (see: https://falco.org/docs/plugins/plugin-api-reference/#field-extraction-capability-api) + extractor.Register(p) + return p + }) +} + +// main is required just because the plugin is compiled in the main package, +// but it's not actually used in by the Plugin SDK +func main() {} diff --git a/rules/nomad_rules.yaml b/rules/nomad_rules.yaml new file mode 100644 index 0000000..ca09dae --- /dev/null +++ b/rules/nomad_rules.yaml @@ -0,0 +1,56 @@ + +- required_engine_version: 15 + +- required_plugin_versions: + - name: nomad + version: 0.1.0 + - name: json + version: 0.2.0 + +- macro: nomad_alloc_updated + condition: nomad.event.topic="Allocation" + and nomad.event.type="AllocationUpdated" + and nomad.alloc.clientStatus="running" + and (nomad.alloc.taskStates.type in ("Driver")) + +- list: nomad_allowed_repositories + items: [ + "123456789123.dkr.ecr.eu-central-1.amazonaws.com/", + "123456789123.dkr.ecr.eu-central-1.amazonaws.com/subpath" + ] + +- rule: Nomad Allocation with Non-Allowed Image + desc: Rule description here. + condition: > + nomad_alloc_updated + and not (nomad.alloc.images.repositories intersects (nomad_allowed_repositories)) + output: 'index=%nomad.index, repo=%nomad.alloc.images.repositories, image=%nomad.alloc.images, tag=%nomad.alloc.images.tags, namespace=%nomad.alloc.namespace, name=%nomad.alloc.name, jobID=%nomad.alloc.jobID' + priority: INFO + source: nomad + tags: [nomad] + + +- rule: Nomad Allocation using more than CPU 100 Mhz + desc: Rule description here. + condition: > + nomad.event.topic="Allocation" + and nomad.event.type="AllocationUpdated" + and nomad.alloc.clientStatus="running" + and nomad.alloc.res.cpu > 100 + output: 'index=%nomad.index, namespace=%nomad.alloc.namespace, name=%nomad.alloc.name, jobID=%nomad.alloc.jobID, cpu=%nomad.alloc.res.cpu' + priority: INFO + source: nomad + tags: [nomad] + + +- rule: Nomad Allocation using more than equal 300 MB memory + desc: Rule description here. + condition: > + nomad.event.topic="Allocation" + and nomad.event.type="AllocationUpdated" + and nomad.alloc.clientStatus="running" + and nomad.alloc.res.memoryMB >= 300 + output: 'index=%nomad.index, namespace=%nomad.alloc.namespace, name=%nomad.alloc.name, jobID=%nomad.alloc.jobID, memoryMB=%nomad.alloc.res.memoryMB' + priority: INFO + source: nomad + tags: [nomad]