diff --git a/internal/runner/runner.go b/internal/runner/runner.go index dab6751a7f..41661e8db7 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -953,8 +953,10 @@ func (r *Runner) displayExecutionInfo(store *loader.Store) { r.Logger.Warning().Msg("No DAST templates found") } stats.ForceDisplayWarning(templates.SkippedUnverifiedCodeTemplateStats) + stats.ForceDisplayWarning(templates.SkippedUnverifiedJavascriptTemplateStats) } else { stats.DisplayAsWarning(templates.SkippedUnverifiedCodeTemplateStats) + stats.DisplayAsWarning(templates.SkippedUnverifiedJavascriptTemplateStats) } stats.DisplayAsWarning(httpProtocol.SetThreadToCountZero) diff --git a/internal/tests/integration/code_test.go b/internal/tests/integration/code_test.go index 3142415231..18da923837 100644 --- a/internal/tests/integration/code_test.go +++ b/internal/tests/integration/code_test.go @@ -17,8 +17,8 @@ import ( ) var isCodeDisabled = func() bool { return osutils.IsWindows() && os.Getenv("CI") == "true" } -var signedCodeTemplates sync.Once -var signedCodeTemplatesErr error +var signedIntegrationTemplates sync.Once +var signedIntegrationTemplatesErr error func hasAnyExecutable(names ...string) bool { for _, name := range names { @@ -51,23 +51,20 @@ const ( var testcertpath = "" -func ensureSignedCodeTemplates() error { - if isCodeDisabled() { - return nil - } - signedCodeTemplates.Do(func() { +func ensureSignedIntegrationTemplates() error { + signedIntegrationTemplates.Do(func() { previousWD, err := os.Getwd() if err != nil { - signedCodeTemplatesErr = err + signedIntegrationTemplatesErr = err return } if err := os.Chdir(suite.fixturesDir); err != nil { - signedCodeTemplatesErr = err + signedIntegrationTemplatesErr = err return } defer func() { - if chdirErr := os.Chdir(previousWD); chdirErr != nil && signedCodeTemplatesErr == nil { - signedCodeTemplatesErr = chdirErr + if chdirErr := os.Chdir(previousWD); chdirErr != nil && signedIntegrationTemplatesErr == nil { + signedIntegrationTemplatesErr = chdirErr } }() @@ -79,13 +76,16 @@ func ensureSignedCodeTemplates() error { tsigner, err := signer.NewTemplateSignerFromFiles(certPath, keyPath) if err != nil { - signedCodeTemplatesErr = err + signedIntegrationTemplatesErr = err return } - templatesToSign := []string{ - "workflow/code-template-1.yaml", - "workflow/code-template-2.yaml", + var templatesToSign []string + if !isCodeDisabled() { + templatesToSign = append(templatesToSign, + "workflow/code-template-1.yaml", + "workflow/code-template-2.yaml", + ) } for _, v := range codeTestCases { if v.DisableOn != nil && v.DisableOn() { @@ -102,14 +102,20 @@ func ensureSignedCodeTemplates() error { } templatesToSign = append(templatesToSign, v.Path) } + for _, v := range jsTestcases { + if v.DisableOn != nil && v.DisableOn() { + continue + } + templatesToSign = append(templatesToSign, v.Path) + } for _, templatePath := range templatesToSign { if err := templates.SignTemplate(tsigner, fixturePath(templatePath)); err != nil { - signedCodeTemplatesErr = err + signedIntegrationTemplatesErr = err return } } }) - return signedCodeTemplatesErr + return signedIntegrationTemplatesErr } func getEnvValues() []string { @@ -118,6 +124,15 @@ func getEnvValues() []string { } } +func runSignedNucleiTemplateAndGetResults(template, url string, debug bool, extra ...string) ([]string, error) { + if err := ensureSignedIntegrationTemplates(); err != nil { + return nil, err + } + args := []string{"-t", template, "-target", url} + args = append(args, extra...) + return testutils.RunNucleiBareArgsAndGetResults(debug, getEnvValues(), args...) +} + type codeSnippet struct{} // Execute executes a test case and returns an error if occurred diff --git a/internal/tests/integration/javascript_krbroast_test.go b/internal/tests/integration/javascript_krbroast_test.go index 9df58ef3e3..205833c583 100644 --- a/internal/tests/integration/javascript_krbroast_test.go +++ b/internal/tests/integration/javascript_krbroast_test.go @@ -19,8 +19,6 @@ import ( "github.com/jcmturner/gokrb5/v8/iana/nametype" "github.com/jcmturner/gokrb5/v8/messages" "github.com/jcmturner/gokrb5/v8/types" - - "github.com/projectdiscovery/nuclei/v3/internal/tests/testutils" ) // javascriptASRepRoast exercises templates/ad/asrep-roast.yaml end-to-end @@ -46,7 +44,7 @@ func (j *javascriptASRepRoast) Execute(filePath string) error { } defer kdc.Close() - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, kdc.Address(), debug) + results, err := runSignedNucleiTemplateAndGetResults(filePath, kdc.Address(), debug) if err != nil { return err } diff --git a/internal/tests/integration/javascript_test.go b/internal/tests/integration/javascript_test.go index 665beca3ce..4a101dbcba 100644 --- a/internal/tests/integration/javascript_test.go +++ b/internal/tests/integration/javascript_test.go @@ -94,7 +94,7 @@ func newJavascriptDockerSpec(port string, options *dockertest.RunOptions, readyT type javascriptNetHttps struct{} func (j *javascriptNetHttps) Execute(filePath string) error { - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "scanme.sh", debug) + results, err := runSignedNucleiTemplateAndGetResults(filePath, "scanme.sh", debug) if err != nil { return err } @@ -167,7 +167,7 @@ type javascriptMultiPortsSSH struct{} func (j *javascriptMultiPortsSSH) Execute(filePath string) error { // use scanme.sh as target to ensure we match on the 2nd default port 22 - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "scanme.sh", debug) + results, err := runSignedNucleiTemplateAndGetResults(filePath, "scanme.sh", debug) if err != nil { return err } @@ -177,7 +177,7 @@ func (j *javascriptMultiPortsSSH) Execute(filePath string) error { type javascriptNoPortArgs struct{} func (j *javascriptNoPortArgs) Execute(filePath string) error { - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "yo.dawg", debug) + results, err := runSignedNucleiTemplateAndGetResults(filePath, "yo.dawg", debug) if err != nil { return err } @@ -192,7 +192,7 @@ func (j *javascriptWMICommand) Execute(filePath string) error { // the exclude list and short-circuits before any dial, so the JSON result // must contain "ok":false plus "network policy" in the error while not // leaking the password. - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10") + results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10") if err != nil { return err } @@ -204,7 +204,7 @@ type javascriptGoExecRedaction struct{} func (j *javascriptGoExecRedaction) Execute(filePath string) error { listener := newGoExecCloseListener() defer listener.Close() - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding) + results, err := runSignedNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding) if err != nil { return err } @@ -216,7 +216,7 @@ type javascriptGoExecModules struct{} func (j *javascriptGoExecModules) Execute(filePath string) error { listener := newGoExecCloseListener() defer listener.Close() - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding) + results, err := runSignedNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding) if err != nil { return err } @@ -275,7 +275,7 @@ func (j *javascriptGoExecSambaNTLM) Execute(filePath string) error { errS := make([]error, 0, defaultRetry) for attempt := 1; attempt <= defaultRetry; attempt++ { - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug) + results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug) if err == nil { if countErr := expectResultsCount(results, 1); countErr == nil { return nil @@ -383,7 +383,7 @@ func (j *networkMultiStep) Execute(filePath string) error { }) defer server.Close() - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, server.URL, debug) + results, err := runSignedNucleiTemplateAndGetResults(filePath, server.URL, debug) if err != nil { return err } @@ -439,7 +439,7 @@ func runJavascriptDockerCase(filePath string, spec javascriptDockerSpec, expecte errS := make([]error, 0, defaultRetry) for attempt := 1; attempt <= defaultRetry; attempt++ { - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, targetAddress, debug) + results, err := runSignedNucleiTemplateAndGetResults(filePath, targetAddress, debug) if err == nil { if countErr := expectResultsCount(results, expectedNumbers...); countErr == nil { return nil diff --git a/internal/tests/integration/matcher-status_test.go b/internal/tests/integration/matcher-status_test.go index f65c78a944..99e4d36522 100644 --- a/internal/tests/integration/matcher-status_test.go +++ b/internal/tests/integration/matcher-status_test.go @@ -79,7 +79,7 @@ type javascriptNoAccess struct{} // Execute executes a test case and returns an error if occurred func (h *javascriptNoAccess) Execute(filePath string) error { - results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "trust_me_bro.real", debug, "-ms", "-j") + results, err := runSignedNucleiTemplateAndGetResults(filePath, "trust_me_bro.real", debug, "-ms", "-j") if err != nil { return err } diff --git a/internal/tests/integration/runner_test.go b/internal/tests/integration/runner_test.go index 976e15a910..82241dfaf9 100644 --- a/internal/tests/integration/runner_test.go +++ b/internal/tests/integration/runner_test.go @@ -159,9 +159,9 @@ func executeIntegrationCase(testCase integrationCase) error { if testCase.DisableOn != nil && testCase.DisableOn() { return nil } - if needsSignedCodeTemplates(testCase.Path) { - if err := ensureSignedCodeTemplates(); err != nil { - return fmt.Errorf("failed to sign code templates: %w", err) + if needsSignedIntegrationTemplates(testCase.Path) { + if err := ensureSignedIntegrationTemplates(); err != nil { + return fmt.Errorf("failed to sign integration templates: %w", err) } } @@ -191,8 +191,10 @@ func parallelism() int { return parallelism } -func needsSignedCodeTemplates(path string) bool { - return strings.HasPrefix(path, "protocols/code/") || strings.Contains(path, "workflow/code-") +func needsSignedIntegrationTemplates(path string) bool { + return strings.HasPrefix(path, "protocols/code/") || + strings.Contains(path, "workflow/code-") || + strings.HasPrefix(path, "protocols/javascript/") } func testNameForPath(path string) string { diff --git a/pkg/catalog/loader/loader.go b/pkg/catalog/loader/loader.go index 2850bc1a11..5df72a7fe1 100644 --- a/pkg/catalog/loader/loader.go +++ b/pkg/catalog/loader/loader.go @@ -902,6 +902,16 @@ func (store *Store) LoadTemplatesWithTags(templatesList, tags []string) ([]*temp return } + // javascript-protocol templates expose Go-backed modules through + // the JS runtime, so unsigned ones are rejected before execution. + if parsed.IsUnsignedJavascriptTemplate() { + stats.Increment(templates.SkippedUnverifiedJavascriptTemplateStats) + if config.DefaultConfig.LogAllEvents { + store.logger.Warning().Msgf("Unverified javascript template at %q", templatePath) + } + return + } + if missingCaps := parsed.MissingLoadCapabilities(caps); len(missingCaps) > 0 { store.noteMissingCapabilities(templatePath, missingCaps) return diff --git a/pkg/catalog/loader/loader_test.go b/pkg/catalog/loader/loader_test.go index 4850481009..02ff6dd470 100644 --- a/pkg/catalog/loader/loader_test.go +++ b/pkg/catalog/loader/loader_test.go @@ -160,6 +160,122 @@ code: require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats)) } +func loadSingleTemplateForTest(t *testing.T, templatePath, executionID string) []*templates.Template { + t.Helper() + + options := testutils.DefaultOptions.Copy() + options.Logger = &gologger.Logger{} + options.ExecutionId = executionID + options.DisableUnsignedTemplates = false + options.TemplateLoadingConcurrency = 1 + testutils.Init(options) + t.Cleanup(func() { + testutils.Cleanup(options) + }) + + catalog := disk.NewCatalog("") + executerOpts := testutils.NewMockExecuterOptions(options, nil) + executerOpts.Catalog = catalog + executerOpts.Parser = templates.NewParser() + executerOpts.Logger = options.Logger + + workflowLoader, err := workflow.NewLoader(executerOpts) + require.NoError(t, err) + executerOpts.WorkflowLoader = workflowLoader + + store, err := New(NewConfig(options, catalog, executerOpts)) + require.NoError(t, err) + + loaded, err := store.LoadTemplates([]string{templatePath}) + require.NoError(t, err) + return loaded +} + +func TestLoadTemplatesRecordsUnsignedJavascriptTemplateOnlyAsJavascriptSkip(t *testing.T) { + templatePath := filepath.Join(t.TempDir(), "unsigned-javascript.yaml") + err := os.WriteFile(templatePath, []byte(`id: unsigned-javascript-template + +info: + name: Unsigned Javascript Template + author: pdteam + severity: info + +javascript: + - code: | + Export("unsigned-javascript-template") +`), 0o600) + require.NoError(t, err) + + initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats) + initialUnverified := stats.GetValue(templates.SkippedUnverifiedTemplateStats) + + loaded := loadSingleTemplateForTest(t, templatePath, "loader-unsigned-javascript-template") + require.Empty(t, loaded) + require.Equal(t, initialUnverifiedJavascript+1, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)) + require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats)) +} + +func TestLoadTemplatesTreatsMixedTemplateWithJavascriptAsJavascriptSensitive(t *testing.T) { + templatePath := filepath.Join(t.TempDir(), "mixed-javascript.yaml") + err := os.WriteFile(templatePath, []byte(`id: mixed-javascript-template + +info: + name: Mixed Javascript Template + author: pdteam + severity: info + +http: + - method: GET + path: + - "{{BaseURL}}" + matchers: + - type: word + words: + - mixed-javascript-template + +javascript: + - code: | + Export("mixed-javascript-template") +`), 0o600) + require.NoError(t, err) + + initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats) + + loaded := loadSingleTemplateForTest(t, templatePath, "loader-mixed-javascript-template") + require.Empty(t, loaded) + require.Equal(t, initialUnverifiedJavascript+1, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)) +} + +func TestLoadTemplatesAllowsUnsignedFlowTemplateWithoutJavascriptProtocol(t *testing.T) { + templatePath := filepath.Join(t.TempDir(), "flow-only.yaml") + err := os.WriteFile(templatePath, []byte(`id: unsigned-flow-template + +info: + name: Unsigned Flow Template + author: pdteam + severity: info + +flow: http(1) + +http: + - method: GET + path: + - "{{BaseURL}}" + matchers: + - type: word + words: + - unsigned-flow-template +`), 0o600) + require.NoError(t, err) + + initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats) + + loaded := loadSingleTemplateForTest(t, templatePath, "loader-unsigned-flow-template") + require.Len(t, loaded, 1) + require.Equal(t, "unsigned-flow-template", loaded[0].ID) + require.Equal(t, initialUnverifiedJavascript, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)) +} + func TestLoadTemplatesDoesNotRequireGlobalMatchersFlagToLoadTemplate(t *testing.T) { templatePath := filepath.Join(t.TempDir(), "global-matchers.yaml") err := os.WriteFile(templatePath, []byte(`id: global-matchers-template diff --git a/pkg/templates/compile_test.go b/pkg/templates/compile_test.go index 6201ce68e5..68dd65827a 100644 --- a/pkg/templates/compile_test.go +++ b/pkg/templates/compile_test.go @@ -284,6 +284,55 @@ workflows: require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats)) } +func Test_ParseWorkflowRecordsUnsignedJavascriptSubtemplateOnlyAsJavascriptSkip(t *testing.T) { + setup() + previousDisableUnsigned := executerOpts.Options.DisableUnsignedTemplates + defer func() { + executerOpts.Options.DisableUnsignedTemplates = previousDisableUnsigned + }() + + executerOpts.Options.DisableUnsignedTemplates = false + + dir := t.TempDir() + javascriptTemplatePath := filepath.Join(dir, "unsigned-javascript.yaml") + err := os.WriteFile(javascriptTemplatePath, []byte(`id: workflow-unsigned-javascript + +info: + name: Workflow Unsigned Javascript + author: pdteam + severity: info + +javascript: + - code: | + Export("workflow-unsigned-javascript") +`), 0o600) + require.NoError(t, err) + + workflowPath := filepath.Join(dir, "workflow.yaml") + err = os.WriteFile(workflowPath, []byte(fmt.Sprintf(`id: workflow-unsigned-javascript-gate + +info: + name: Workflow Unsigned Javascript Gate + author: pdteam + severity: info + +workflows: + - template: %q +`, javascriptTemplatePath)), 0o600) + require.NoError(t, err) + + initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats) + initialUnverified := stats.GetValue(templates.SkippedUnverifiedTemplateStats) + + got, err := templates.Parse(workflowPath, nil, executerOpts) + require.NoError(t, err) + require.NotNil(t, got.CompiledWorkflow) + require.Len(t, got.CompiledWorkflow.Workflows, 1) + require.Empty(t, got.CompiledWorkflow.Workflows[0].Executers) + require.Equal(t, initialUnverifiedJavascript+1, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)) + require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats)) +} + func Test_WrongTemplate(t *testing.T) { setup() diff --git a/pkg/templates/parser_stats.go b/pkg/templates/parser_stats.go index ab08e2d007..05f5c35f28 100644 --- a/pkg/templates/parser_stats.go +++ b/pkg/templates/parser_stats.go @@ -5,9 +5,10 @@ const ( TemplateSyntaxErrorStats = "template-syntax-errors" TemplateSyntaxWarningStats = "template-syntax-warnings" - SkippedRequestSignatureTemplateStats = "skipped-request-signature-templates" - SkippedUnverifiedCodeTemplateStats = "skipped-unverified-code-templates" - SkippedUnverifiedTemplateStats = "skipped-unverified-templates" + SkippedRequestSignatureTemplateStats = "skipped-request-signature-templates" + SkippedUnverifiedCodeTemplateStats = "skipped-unverified-code-templates" + SkippedUnverifiedJavascriptTemplateStats = "skipped-unverified-javascript-templates" + SkippedUnverifiedTemplateStats = "skipped-unverified-templates" ExcludedCodeTemplateStats = "excluded-code-templates" ExcludedDASTTemplateStats = "excluded-dast-templates" diff --git a/pkg/templates/signer/tmpl_signer.go b/pkg/templates/signer/tmpl_signer.go index 590cf94b21..546581be33 100644 --- a/pkg/templates/signer/tmpl_signer.go +++ b/pkg/templates/signer/tmpl_signer.go @@ -73,6 +73,7 @@ func (t *TemplateSigner) GetUserFragment() string { // Sign signs the given template with the template signer and returns the signature func (t *TemplateSigner) Sign(data []byte, tmpl SignableTemplate) (string, error) { existingSignature, content := ExtractSignatureAndContent(data) + content = normalizeTemplateContentForSignature(content) // while re-signing template check if it has a code protocol // if it does then verify that it is signed by current signer @@ -146,9 +147,7 @@ func (t *TemplateSigner) Verify(data []byte, tmpl SignableTemplate) (bool, error return false, err } - // normalize content by removing \r\n everywhere since this only done for verification - // it does not affect the actual template - content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) + content = normalizeTemplateContentForSignature(content) buff := bytes.NewBuffer(content) // if file has any imports process them @@ -164,6 +163,10 @@ func (t *TemplateSigner) Verify(data []byte, tmpl SignableTemplate) (bool, error return t.verify(buff.Bytes(), digest) } +func normalizeTemplateContentForSignature(content []byte) []byte { + return bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) +} + // Verify verifies the given data with the template signer // Note: this should not be used for verifying templates as file references // in templates are not processed diff --git a/pkg/templates/signer/tmpl_signer_test.go b/pkg/templates/signer/tmpl_signer_test.go index e898490876..e273b94f9d 100644 --- a/pkg/templates/signer/tmpl_signer_test.go +++ b/pkg/templates/signer/tmpl_signer_test.go @@ -48,6 +48,12 @@ func TestTemplateSignerSignAndVerify(t *testing.T) { tmpl: &mockSignableTemplate{}, wantVerified: true, }, + { + name: "Template with CRLF line endings", + data: []byte("id: test-template\r\ninfo:\r\n name: Test Template"), + tmpl: &mockSignableTemplate{}, + wantVerified: true, + }, { name: "Template with imports", data: []byte("id: test-template\ninfo:\n name: Test Template"), diff --git a/pkg/templates/stats.go b/pkg/templates/stats.go index 4f1592569d..79bd7f6d82 100644 --- a/pkg/templates/stats.go +++ b/pkg/templates/stats.go @@ -24,6 +24,10 @@ var templateStatEntries = []templateStatEntry{ name: SkippedUnverifiedCodeTemplateStats, description: "Found %d unsigned or tampered code template (carefully examine before using it & use -sign flag to sign them)", }, + { + name: SkippedUnverifiedJavascriptTemplateStats, + description: "Found %d unsigned or tampered javascript template (carefully examine before using it & use -sign flag to sign them)", + }, { name: ExcludedHeadlessTemplateStats, description: "Excluded %d headless template[s] (disabled as default), use -headless option to run headless templates.", diff --git a/pkg/templates/templates_utils.go b/pkg/templates/templates_utils.go index 9d57ad946e..60dfe1bd4f 100644 --- a/pkg/templates/templates_utils.go +++ b/pkg/templates/templates_utils.go @@ -87,6 +87,12 @@ func (t *Template) HasJavascriptRequest(n ...int) bool { return HasRequest(t.RequestsJavascript, n...) } +// IsUnsignedJavascriptTemplate returns true if the template has a Javascript +// protocol request but no verified template signature. +func (t *Template) IsUnsignedJavascriptTemplate() bool { + return t.HasJavascriptRequest() && !t.Verified +} + // HasQueueRequests returns true if the template has queued requests. // // Queued requests contain all template requests in order (both protocol & diff --git a/pkg/templates/workflows.go b/pkg/templates/workflows.go index 184ce10e37..c52ff17446 100644 --- a/pkg/templates/workflows.go +++ b/pkg/templates/workflows.go @@ -102,6 +102,13 @@ func parseWorkflowTemplate(workflow *workflows.WorkflowTemplate, preprocessor Pr continue } + if template.IsUnsignedJavascriptTemplate() { + // unverified javascript templates are not allowed in workflows + stats.Increment(SkippedUnverifiedJavascriptTemplateStats) + gologger.Warning().Msgf("Skipping unverified javascript template(s) from workflow: %v\n", path) + continue + } + if missingCaps := template.MissingLoadCapabilities(caps); len(missingCaps) > 0 { for _, capability := range missingCaps { stats.Increment(capability.Stat())