From 93284f83fc6ea362cdea248390e277a13d70a2d8 Mon Sep 17 00:00:00 2001 From: Isaac Fletcher Date: Mon, 1 Dec 2025 07:08:51 -0800 Subject: [PATCH] Add ttpforge validate command (#574) Summary: Implemented a new `validate` command for TTPForge that performs comprehensive static analysis and validation of TTP YAML files. This command helps ensure TTPs are correctly structured before execution. The validator performs multiple types of checks: - Structural validation (required fields, data types, YAML syntax) - Preamble field validation (api_version, uuid, name) - Step action validation (ensures required fields for each action type) - Template variable detection and handling - Integration with blocks package for runtime validation The command supports single-file validation, with colored output for errors (red), warnings (yellow), and informational messages (blue). Template-based TTPs are handled gracefully with appropriate warnings when full validation requires runtime arguments. Reviewed By: d0n601 Differential Revision: D85961391 --- cmd/root.go | 1 + cmd/validate.go | 67 ++++++ example-ttps/tests/invalid.yaml | 67 ++++++ pkg/args/spec.go | 10 + pkg/blocks/httprequest.go | 5 +- pkg/platforms/spec.go | 43 ++-- pkg/validation/args.go | 94 ++++++++ pkg/validation/integration.go | 376 ++++++++++++++++++++++++++++++ pkg/validation/preamble.go | 107 +++++++++ pkg/validation/required_fields.go | 47 ++++ pkg/validation/requirements.go | 72 ++++++ pkg/validation/structure.go | 79 +++++++ pkg/validation/templates.go | 104 +++++++++ pkg/validation/validator.go | 126 ++++++++++ run-all-ttp-tests.sh | 2 +- 15 files changed, 1179 insertions(+), 21 deletions(-) create mode 100644 cmd/validate.go create mode 100644 example-ttps/tests/invalid.yaml create mode 100644 pkg/validation/args.go create mode 100644 pkg/validation/integration.go create mode 100644 pkg/validation/preamble.go create mode 100644 pkg/validation/required_fields.go create mode 100644 pkg/validation/requirements.go create mode 100644 pkg/validation/structure.go create mode 100644 pkg/validation/templates.go create mode 100644 pkg/validation/validator.go diff --git a/cmd/root.go b/cmd/root.go index d19b1b83..bf2da73c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -81,6 +81,7 @@ TTPForge is a Purple Team engagement tool to execute Tactics, Techniques, and Pr rootCmd.AddCommand(buildEnumCommand(cfg)) rootCmd.AddCommand(buildShowCommand(cfg)) rootCmd.AddCommand(buildRunCommand(cfg)) + rootCmd.AddCommand(buildValidateCommand(cfg)) rootCmd.AddCommand(buildTestCommand(cfg)) rootCmd.AddCommand(buildInstallCommand(cfg)) rootCmd.AddCommand(buildRemoveCommand(cfg)) diff --git a/cmd/validate.go b/cmd/validate.go new file mode 100644 index 00000000..76385aef --- /dev/null +++ b/cmd/validate.go @@ -0,0 +1,67 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package cmd + +import ( + "fmt" + + "github.com/facebookincubator/ttpforge/pkg/validation" + "github.com/spf13/cobra" +) + +func buildValidateCommand(cfg *Config) *cobra.Command { + validateCmd := &cobra.Command{ + Use: "validate [repo_name//path/to/ttp]", + Short: "Validate the structure and syntax of a TTP YAML file", + Long: `Validate performs comprehensive validation on a TTP YAML file +Unlike --dry-run, this command: +- Does not require values to be provided for all arguments +- Does not require OS/platform compatibility +- Performs extensive structural and best practice checks +- Reports errors, warnings, and informational messages + +This is useful for CI/CD validation, linting, and checking TTP syntax +without needing to provide all runtime arguments or match platform requirements.`, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeTTPRef(cfg, 1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + ttpRef := args[0] + foundRepo, ttpAbsPath, err := cfg.repoCollection.ResolveTTPRef(ttpRef) + if err != nil { + return fmt.Errorf("failed to resolve TTP reference %v: %w", ttpRef, err) + } + + fmt.Printf("Validating TTP: %s\n", ttpAbsPath) + + result := validation.ValidateTTP(ttpAbsPath, foundRepo.GetFs(), foundRepo) + result.Print() + + if result.HasErrors() { + return fmt.Errorf("validation failed with %d error(s)", len(result.Errors)) + } + + return nil + }, + } + + return validateCmd +} diff --git a/example-ttps/tests/invalid.yaml b/example-ttps/tests/invalid.yaml new file mode 100644 index 00000000..bf1d89a1 --- /dev/null +++ b/example-ttps/tests/invalid.yaml @@ -0,0 +1,67 @@ +--- +uuid: not-a-valid-uuid +api_version: 99.0 +description: bad +mitre: + invalid_key: value +requirements: + platforms: + - os: invalidOS + arch: fake_arch + superuser: "not_a_boolean" +args: + - name: InvalidArgName + type: invalid_type + - name: duplicate_arg + - name: duplicate_arg + description: This is a duplicate + - name: unused_arg + default: value + - name: undefined_ref + - name: another_unused_arg + description: This argument is defined but never referenced anywhere +steps: + - name: + inline: "" + - name: duplicate_step + inline: echo "first" + - name: duplicate_step + inline: echo "duplicate" + executor: invalid_executor + outputvar: InvalidOutputVar + - name: ambiguous_action + inline: echo "has multiple actions" + file: /some/file.sh + create_file: /another/file + - name: no_action_step + description: This step has no action + - name: empty_inline + inline: "" + - name: invalid_http_method + http_request: https://192.168.1.1/hardcoded-ip + type: INVALID_METHOD + - name: missing_create_file_contents + create_file: /tmp/file.txt + - name: missing_copy_to + copy_path: /source + - name: missing_fetch_location + fetch_uri: http://example.com/file + - name: missing_kill_process_param + kill_process: true + - name: missing_edit_file_edits + edit_file: /some/file + - name: invalid_expect + expect: + missing_inline: true + - name: bad_cleanup + create_file: /tmp/test.txt + contents: "test" + cleanup: not_default_or_dict + - name: undefined_template_var + inline: "{{.Args.nonexistent_arg}}" + - name: undefined_stepvar + inline: echo "trying to use {[{.StepVars.nonexistent_outputvar}]}" + - name: cd_empty + cd: "" + - name: ttp_empty + ttp: "" diff --git a/pkg/args/spec.go b/pkg/args/spec.go index 8386b2c7..31d66475 100644 --- a/pkg/args/spec.go +++ b/pkg/args/spec.go @@ -163,6 +163,16 @@ func ParseAndValidate(specs []Spec, argsKvStrs []string, cliBaseDir string, defa return processedArgs, nil } +// GetValidArgTypes returns all valid argument types supported by TTPForge +func GetValidArgTypes() []string { + return []string{ + "string", + "int", + "bool", + "path", + } +} + func (spec Spec) convertArgToType(val string) (any, error) { switch spec.Type { case "", "string": diff --git a/pkg/blocks/httprequest.go b/pkg/blocks/httprequest.go index a4410d25..bb6357e8 100644 --- a/pkg/blocks/httprequest.go +++ b/pkg/blocks/httprequest.go @@ -362,10 +362,13 @@ func (r *HTTPRequestStep) validateProxy() error { return nil } +// ValidHTTPMethods contains all supported HTTP methods +var ValidHTTPMethods = []string{"GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"} + // validateType validates that the request type is a valid HTTP request type. Returns an error if validation fails, otherwise returns nil func (r *HTTPRequestStep) validateType() error { isHTTPMethod := false - for _, method := range []string{"GET", "POST", "PUT", "DELETE", "HEAD", "PATCH"} { + for _, method := range ValidHTTPMethods { if strings.EqualFold(r.Type, method) { isHTTPMethod = true break diff --git a/pkg/platforms/spec.go b/pkg/platforms/spec.go index 6bd47deb..4977c1d6 100644 --- a/pkg/platforms/spec.go +++ b/pkg/platforms/spec.go @@ -33,6 +33,22 @@ type Spec struct { Arch string } +// GetValidOS returns all valid operating system values supported by TTPForge +func GetValidOS() []string { + return []string{ + "android", + "darwin", + "dragonfly", + "freebsd", + "linux", + "netbsd", + "openbsd", + "plan9", + "solaris", + "windows", + } +} + // IsCompatibleWith returns true if the current spec is compatible with the // spec specified as its argument. // TTPs will often not care about the architecture and will @@ -84,29 +100,18 @@ func (s *Spec) Validate() error { return fmt.Errorf("os and arch cannot both be empty") } - // this really ought to to be a list I can - // import from some package, but doesn't look - // like that is possible right now - // https://stackoverflow.com/a/20728862 - validOS := map[string]bool{ - "android": true, - "darwin": true, - "dragonfly": true, - "freebsd": true, - "linux": true, - "netbsd": true, - "openbsd": true, - // if you run this on plan9 I will buy you a beer - "plan9": true, - "solaris": true, - "windows": true, + validOSList := GetValidOS() + validOSMap := make(map[string]bool) + for _, os := range validOSList { + validOSMap[os] = true } - if s.OS != "" && !validOS[s.OS] { + + if s.OS != "" && !validOSMap[s.OS] { errorMsg := fmt.Sprintf("invalid `os` value %q specified", s.OS) logging.L().Errorf(errorMsg) logging.L().Errorf("valid values are:") - for k := range validOS { - logging.L().Errorf("\t%s", k) + for _, os := range validOSList { + logging.L().Errorf("\t%s", os) } return errors.New(errorMsg) } diff --git a/pkg/validation/args.go b/pkg/validation/args.go new file mode 100644 index 00000000..e6c5eccf --- /dev/null +++ b/pkg/validation/args.go @@ -0,0 +1,94 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "fmt" + "regexp" + "strings" + + "github.com/facebookincubator/ttpforge/pkg/args" +) + +var ( + namingPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) +) + +// ValidateArgs validates argument definitions +func ValidateArgs(ttpMap map[string]any, result *Result) { + argsVal, ok := ttpMap["args"] + if !ok { + return + } + + argsList, isList := argsVal.([]any) + if !isList { + result.AddError("'args' must be a list") + return + } + + seenArgs := make(map[string]bool) + for i, arg := range argsList { + argMap, isMap := arg.(map[string]any) + if !isMap { + result.AddError(fmt.Sprintf("Argument %d must be a dictionary", i+1)) + continue + } + + nameVal, hasName := argMap["name"] + if !hasName { + result.AddError(fmt.Sprintf("Argument %d missing 'name' field", i+1)) + continue + } + + argName := fmt.Sprintf("%v", nameVal) + + if seenArgs[argName] { + result.AddError(fmt.Sprintf("Duplicate argument name: %s", argName)) + } + seenArgs[argName] = true + + if !namingPattern.MatchString(argName) { + result.AddWarning(fmt.Sprintf("Argument name should be lowercase with underscores: %s", argName)) + } + + if argType, ok := argMap["type"]; ok { + typeStr := fmt.Sprintf("%v", argType) + validTypes := args.GetValidArgTypes() + validTypesMap := make(map[string]bool) + for _, t := range validTypes { + validTypesMap[t] = true + } + if !validTypesMap[typeStr] { + result.AddError(fmt.Sprintf("Invalid argument type: %s (valid: %s)", typeStr, strings.Join(validTypes, ", "))) + } + } else { + result.AddInfo(fmt.Sprintf("Argument '%s' has no type specified (defaults to string)", argName)) + } + + if _, ok := argMap["description"]; !ok { + result.AddWarning(fmt.Sprintf("Argument '%s' should have a description", argName)) + } + + if _, ok := argMap["default"]; !ok { + result.AddInfo(fmt.Sprintf("Argument '%s' has no default value", argName)) + } + } +} diff --git a/pkg/validation/integration.go b/pkg/validation/integration.go new file mode 100644 index 00000000..884e8daa --- /dev/null +++ b/pkg/validation/integration.go @@ -0,0 +1,376 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "bytes" + "fmt" + "io" + "path/filepath" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/facebookincubator/ttpforge/pkg/blocks" + "github.com/facebookincubator/ttpforge/pkg/logging" + "github.com/facebookincubator/ttpforge/pkg/parseutils" + "github.com/facebookincubator/ttpforge/pkg/platforms" + "github.com/facebookincubator/ttpforge/pkg/repos" + "github.com/spf13/afero" + "gopkg.in/yaml.v3" +) + +// isTemplateRelatedError checks if an error is related to template rendering or variables +func isTemplateRelatedError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "template") || + strings.Contains(errStr, "{{") || + strings.Contains(errStr, "") +} + +// readTTPBytesForValidation reads TTP file contents +func readTTPBytesForValidation(ttpFilePath string, fsys afero.Fs) ([]byte, error) { + var file afero.File + var err error + + if fsys == nil { + fsys = afero.NewOsFs() + } + + file, err = fsys.Open(ttpFilePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + contents, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return contents, nil +} + +// renderTemplatedTTPForValidation renders templates with dummy values for args +// This allows template control structures ({{ if }}, {{ range }}) to be evaluated +// while preventing validation errors from substitutions +func renderTemplatedTTPForValidation(ttpStr string, rp blocks.RenderParameters) (*blocks.TTP, error) { + // First, extract arg definitions from the YAML + dummyArgs := extractDummyArgsFromTTP(ttpStr) + + // Merge dummy args with any provided args + if rp.Args == nil { + rp.Args = make(map[string]any) + } + for k, v := range dummyArgs { + if _, exists := rp.Args[k]; !exists { + rp.Args[k] = v + } + } + + // Now render the template with dummy args + logging.L().Debugf("Rendering template with dummy args: %+v", rp.Args) + tmpl, err := template.New("ttp").Funcs(sprig.TxtFuncMap()).Parse(ttpStr) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + + var result bytes.Buffer + err = tmpl.Execute(&result, rp) + if err != nil { + logging.L().Warnf("Template execution failed (this may be expected if args are not provided): %v", err) + // Fallback to parsing raw YAML + var ttp blocks.TTP + err = yaml.Unmarshal([]byte(ttpStr), &ttp) + if err != nil { + return nil, fmt.Errorf("failed to decode TTP YAML after template error: %w", err) + } + return &ttp, nil + } + + logging.L().Debugf("Rendered YAML:\n%s", result.String()) + + var ttp blocks.TTP + err = yaml.Unmarshal(result.Bytes(), &ttp) + if err != nil { + return nil, fmt.Errorf("YAML unmarshal failed: %w", err) + } + + return &ttp, nil +} + +// extractDummyArgsFromTTP extracts arg definitions and creates dummy values +// This prevents {{.Args.foo}} from becoming during validation +func extractDummyArgsFromTTP(ttpStr string) map[string]any { + dummyArgs := make(map[string]any) + + // Use parseutils to parse the TTP header (which includes args but excludes steps) + // This handles template control structures in steps gracefully + ttp, err := parseutils.ParseTTP([]byte(ttpStr), "validation") + if err != nil { + logging.L().Debugf("Failed to parse TTP for arg extraction: %v", err) + return dummyArgs + } + + // Create dummy values from parsed args + for _, arg := range ttp.Args { + // Check if there's a default value (highest priority) + if arg.Default != nil { + dummyArgs[arg.Name] = arg.Default + continue + } + + // Check if there are choices (use first choice) + if len(arg.Choices) > 0 { + dummyArgs[arg.Name] = arg.Choices[0] + continue + } + + // Generate dummy value based on type (lowest priority) + dummyArgs[arg.Name] = generateDummyValueForType(arg.Type) + } + + return dummyArgs +} + +// generateDummyValueForType creates a dummy value based on arg type +func generateDummyValueForType(argType string) any { + switch argType { + case "int", "integer": + return 1 + case "bool", "boolean": + return true + case "path": + return "/tmp/dummy_path" + case "string", "": + return "dummy_value" + default: + return "dummy_value" + } +} + +// ValidateIntegration attempts validation using the blocks package +// Template errors are converted to warnings +// Note: Preamble validation is now done in ValidatePreamble, so this +// function focuses on step-level validation +// +// Note: The blocks package validation methods log errors to stderr using logging.L().Error(). +// These ERROR logs will appear during validation but the actual validation results are +// captured in the Result object and displayed in the structured output at the end. +func ValidateIntegration(ttpFilePath string, ttpBytes []byte, repo repos.Repo, result *Result) { + rp := blocks.RenderParameters{ + Args: map[string]any{}, + Platform: platforms.GetCurrentPlatformSpec(), + } + + ttp, err := renderTemplatedTTPForValidation(string(ttpBytes), rp) + if err != nil { + if isTemplateRelatedError(err) { + result.AddWarning(fmt.Sprintf("TTP rendering with dummy args (templates may need real values): %v", err)) + } else { + result.AddError(fmt.Sprintf("TTP rendering: %v", err)) + } + // If we can't parse the full TTP, try to validate individual steps + validateIndividualSteps(ttpBytes, repo, result) + return + } + + execCtx := blocks.NewTTPExecutionContext() + execCtx.Cfg.Repo = repo + + absPath, err := filepath.Abs(ttpFilePath) + if err == nil { + ttp.WorkDir = filepath.Dir(absPath) + execCtx.Vars.WorkDir = ttp.WorkDir + } + + for idx, step := range ttp.Steps { + stepCopy := step + + if err := stepCopy.Validate(execCtx); err != nil { + if isTemplateRelatedError(err) { + result.AddWarning(fmt.Sprintf("Step #%d (%s) template validation: %v", idx+1, step.Name, err)) + } else { + result.AddError(fmt.Sprintf("Step #%d (%s) validation: %v", idx+1, step.Name, err)) + } + } + } +} + +// extractStepsFromYAML extracts the steps array from TTP YAML bytes +func extractStepsFromYAML(ttpBytes []byte) ([]any, error) { + var ttpMap map[string]any + if err := yaml.Unmarshal(ttpBytes, &ttpMap); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + stepsVal, ok := ttpMap["steps"] + if !ok { + return nil, fmt.Errorf("no steps found") + } + + stepsList, isList := stepsVal.([]any) + if !isList { + return nil, fmt.Errorf("steps is not a list") + } + + return stepsList, nil +} + +// getStepName returns a human-readable name for a step +func getStepName(stepMap map[string]any, idx int) string { + stepName := fmt.Sprintf("#%d", idx+1) + if nameVal, ok := stepMap["name"]; ok && nameVal != nil { + stepName = fmt.Sprintf("#%d (%v)", idx+1, nameVal) + } + return stepName +} + +// validateSingleStep validates a single step and reports errors/warnings to the result +func validateSingleStep(stepVal any, idx int, execCtx blocks.TTPExecutionContext, result *Result) { + stepMap, isMap := stepVal.(map[string]any) + if !isMap { + return + } + + stepName := getStepName(stepMap, idx) + + // Try to unmarshal this individual step into a block Step + stepYAML, err := yaml.Marshal(stepVal) + if err != nil { + return + } + + var step blocks.Step + if err := yaml.Unmarshal(stepYAML, &step); err != nil { + // Step failed to unmarshal - try to detect which action type was intended + // and provide a more helpful error message + errStr := err.Error() + detailedErr := detectAndValidateActionType(stepMap, stepName, execCtx) + if detailedErr != "" { + result.AddError(fmt.Sprintf("Step %s: %v", stepName, detailedErr)) + } else if !strings.Contains(errStr, "no name specified") { + // Only report the unmarshaling error if it's not about missing name + // (missing name is already caught by ValidateRequiredFields) + result.AddError(fmt.Sprintf("Step %s: %v", stepName, err)) + } + return + } + + // Step unmarshaled successfully, validate it + if err := step.Validate(execCtx); err != nil { + if isTemplateRelatedError(err) { + result.AddWarning(fmt.Sprintf("Step %s template validation: %v", stepName, err)) + } else { + result.AddError(fmt.Sprintf("Step %s validation: %v", stepName, err)) + } + } +} + +// validateIndividualSteps attempts to validate steps one by one +// This is a fallback when the full TTP can't be parsed +func validateIndividualSteps(ttpBytes []byte, repo repos.Repo, result *Result) { + stepsList, err := extractStepsFromYAML(ttpBytes) + if err != nil { + return // Can't extract steps + } + + execCtx := blocks.NewTTPExecutionContext() + execCtx.Cfg.Repo = repo + + // Validate each step individually + for idx, stepVal := range stepsList { + validateSingleStep(stepVal, idx, execCtx, result) + } +} + +// detectAndValidateActionType tries to detect which action type the user intended +// and validates it directly to provide better error messages +func detectAndValidateActionType(stepMap map[string]any, _ string, execCtx blocks.TTPExecutionContext) string { + stepYAML, err := yaml.Marshal(stepMap) + if err != nil { + return "" + } + + // Try each action type and call its Validate() method directly + actionCandidates := []struct { + name string + action blocks.Action + field string + }{ + {"create_file", blocks.NewCreateFileStep(), "create_file"}, + {"copy_path", blocks.NewCopyPathStep(), "copy_path"}, + {"http_request", blocks.NewHTTPRequestStep(), "http_request"}, + {"fetch_uri", blocks.NewFetchURIStep(), "fetch_uri"}, + {"edit_file", blocks.NewEditStep(), "edit_file"}, + {"kill_process", blocks.NewKillProcessStep(), "kill_process"}, + {"remove_path", blocks.NewRemovePathAction(), "remove_path"}, + {"print_str", blocks.NewPrintStrAction(), "print_str"}, + {"cd", blocks.NewChangeDirectoryStep(), "cd"}, + {"file", blocks.NewFileStep(), "file"}, + {"ttp", blocks.NewSubTTPStep(), "ttp"}, + {"inline", blocks.NewBasicStep(), "inline"}, + {"expect", blocks.NewExpectStep(), "responses"}, + } + + // Check which action field is present in the step + for _, candidate := range actionCandidates { + if _, ok := stepMap[candidate.field]; ok { + // Try to unmarshal into this specific action type + err := yaml.Unmarshal(stepYAML, candidate.action) + if err != nil { + continue + } + + // Call Validate() to get the specific error message + if validationErr := candidate.action.Validate(execCtx); validationErr != nil { + return validationErr.Error() + } + + // If validation passed but step still failed to parse, it might be due to IsNil() + if candidate.action.IsNil() { + return fmt.Sprintf("%s action has empty or missing required field", candidate.name) + } + } + } + + // Special case for kill_process which can use kill_process_name or kill_process_id + if _, hasName := stepMap["kill_process_name"]; hasName { + action := blocks.NewKillProcessStep() + if err := yaml.Unmarshal(stepYAML, action); err == nil { + if validationErr := action.Validate(execCtx); validationErr != nil { + return validationErr.Error() + } + } + } + if _, hasID := stepMap["kill_process_id"]; hasID { + action := blocks.NewKillProcessStep() + if err := yaml.Unmarshal(stepYAML, action); err == nil { + if validationErr := action.Validate(execCtx); validationErr != nil { + return validationErr.Error() + } + } + } + + return "" // Couldn't determine the action type +} diff --git a/pkg/validation/preamble.go b/pkg/validation/preamble.go new file mode 100644 index 00000000..87483b0e --- /dev/null +++ b/pkg/validation/preamble.go @@ -0,0 +1,107 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "fmt" + "regexp" + "strings" + + "github.com/facebookincubator/ttpforge/pkg/blocks" + "github.com/facebookincubator/ttpforge/pkg/preprocess" + "gopkg.in/yaml.v3" +) + +// ValidatePreamble validates description, MITRE ATT&CK, and preamble field values +// using both basic structure checks and semantic validation from the blocks package +// Note: Required field presence checks are handled in ValidateRequiredFields +func ValidatePreamble(ttpMap map[string]any, ttpBytes []byte, result *Result) { + // Validate api_version value (if present) + if apiVer, ok := ttpMap["api_version"]; ok { + verStr := fmt.Sprintf("%v", apiVer) + if verStr != "1.0" && verStr != "2.0" && verStr != "1" && verStr != "2" { + result.AddWarning(fmt.Sprintf("Unusual api_version: %v (typically 1.0 or 2.0)", apiVer)) + } + } + + // Validate uuid format (if present) + if uuid, ok := ttpMap["uuid"]; ok { + uuidStr := fmt.Sprintf("%v", uuid) + uuidPattern := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + if !uuidPattern.MatchString(uuidStr) { + result.AddError(fmt.Sprintf("Invalid UUID format: %s (expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)", uuidStr)) + } + } + + // Validate name value (if present) + if name, ok := ttpMap["name"]; ok { + nameStr := fmt.Sprintf("%v", name) + if strings.TrimSpace(nameStr) == "" { + result.AddError("'name' cannot be empty") + } + if regexp.MustCompile(`^\d`).MatchString(nameStr) { + result.AddWarning(fmt.Sprintf("TTP name should not start with a number: %s", nameStr)) + } + } + + // Validate description + if desc, ok := ttpMap["description"]; ok { + descStr := fmt.Sprintf("%v", desc) + if strings.TrimSpace(descStr) == "" || len(strings.TrimSpace(descStr)) < 10 { + result.AddWarning("'description' should be more detailed") + } + } else { + result.AddWarning("Consider adding a 'description' field") + } + + // Validate MITRE ATT&CK + if mitre, ok := ttpMap["mitre"]; ok { + _, isMap := mitre.(map[string]any) + if !isMap { + result.AddError("'mitre' must be a dictionary") + return + } + } else { + result.AddInfo("Consider adding MITRE ATT&CK mappings under 'mitre' field") + } + + // Semantic validation using blocks package + preprocessResult, err := preprocess.Parse(ttpBytes) + if err != nil { + result.AddError(fmt.Sprintf("Preamble preprocessing failed: %v", err)) + return + } + + type PreambleContainer struct { + blocks.PreambleFields `yaml:",inline"` + } + var preamble PreambleContainer + err = yaml.Unmarshal(preprocessResult.PreambleBytes, &preamble) + if err != nil { + result.AddError(fmt.Sprintf("Failed to unmarshal YAML preamble: %v", err)) + return + } + + // Validate using blocks package (strict=false means UUID format check is skipped) + err = preamble.Validate(false) + if err != nil { + result.AddError(err.Error()) + } +} diff --git a/pkg/validation/required_fields.go b/pkg/validation/required_fields.go new file mode 100644 index 00000000..2a64b3e7 --- /dev/null +++ b/pkg/validation/required_fields.go @@ -0,0 +1,47 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "fmt" + "regexp" +) + +// requiredFieldPatterns defines regex patterns for detecting required top-level fields +var requiredFieldPatterns = map[string]*regexp.Regexp{ + "name": regexp.MustCompile(`(?m)^name:`), + "uuid": regexp.MustCompile(`(?m)^uuid:`), + "api_version": regexp.MustCompile(`(?m)^api_version:`), + "steps": regexp.MustCompile(`(?m)^steps:`), +} + +// ValidateRequiredFields checks that all required top-level fields are present +// This uses regex patterns to ensure consistency and runs regardless of YAML parsing success +func ValidateRequiredFields(content string, result *Result) { + // Check each required field + for fieldName, pattern := range requiredFieldPatterns { + matches := pattern.FindAllString(content, -1) + if len(matches) == 0 { + result.AddError(fmt.Sprintf("Missing required field: %s", fieldName)) + } else if len(matches) > 1 { + result.AddError(fmt.Sprintf("The top-level key '%s:' should occur exactly once", fieldName)) + } + } +} diff --git a/pkg/validation/requirements.go b/pkg/validation/requirements.go new file mode 100644 index 00000000..3b13d70d --- /dev/null +++ b/pkg/validation/requirements.go @@ -0,0 +1,72 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "fmt" + + "github.com/facebookincubator/ttpforge/pkg/blocks" + "gopkg.in/yaml.v3" +) + +// ValidateRequirements validates requirements structure and semantics +// using both basic structure checks and validation from the blocks package +func ValidateRequirements(ttpMap map[string]any, result *Result) { + req, ok := ttpMap["requirements"] + if !ok { + result.AddInfo("No requirements specified - TTP will run on all platforms") + return + } + + reqMap, isMap := req.(map[string]any) + if !isMap { + result.AddError("'requirements' must be a dictionary") + return + } + + // Basic structure check + if platformsVal, ok := reqMap["platforms"]; ok { + _, isList := platformsVal.([]any) + if !isList { + result.AddError("'platforms' in requirements must be a list") + return + } + } + + // Semantic validation using blocks package + // Convert the map to a strongly-typed struct for validation + var reqConfig blocks.RequirementsConfig + reqBytes, err := yaml.Marshal(reqMap) + if err != nil { + return + } + + err = yaml.Unmarshal(reqBytes, &reqConfig) + if err != nil { + result.AddError(fmt.Sprintf("Failed to parse requirements: %v", err)) + return + } + + // Validate using blocks package (validates platforms OS/arch values) + err = reqConfig.Validate() + if err != nil { + result.AddError(err.Error()) + } +} diff --git a/pkg/validation/structure.go b/pkg/validation/structure.go new file mode 100644 index 00000000..4a8c8494 --- /dev/null +++ b/pkg/validation/structure.go @@ -0,0 +1,79 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "gopkg.in/yaml.v3" +) + +// ValidateStructure checks top-level structure after YAML parsing +// Note: Required field presence checks are handled in ValidateRequiredFields +func ValidateStructure(content string, result *Result) { + // Parse YAML using Node API to preserve structure + var node yaml.Node + err := yaml.Unmarshal([]byte(content), &node) + if err != nil { + // If YAML is invalid, let the main validation handle it + return + } + + // The root node should be a document node containing a mapping node + if len(node.Content) == 0 || node.Content[0].Kind != yaml.MappingNode { + return + } + + mappingNode := node.Content[0] + + // In a mapping node, keys and values alternate: [key1, value1, key2, value2, ...] + if len(mappingNode.Content) < 2 { + return + } + + // Get the last key (second-to-last element in Content) + lastKeyNode := mappingNode.Content[len(mappingNode.Content)-2] + lastKeyName := lastKeyNode.Value + + // Check if "steps" exists and if it's not the last key + hasSteps := false + for i := 0; i < len(mappingNode.Content); i += 2 { + if mappingNode.Content[i].Value == "steps" { + hasSteps = true + break + } + } + + if hasSteps && lastKeyName != "steps" { + result.AddError("The top-level key 'steps:' should always be the last top-level key in the file") + } + + // Validate steps structure + for i := 0; i < len(mappingNode.Content); i += 2 { + keyNode := mappingNode.Content[i] + if keyNode.Value == "steps" { + valueNode := mappingNode.Content[i+1] + if valueNode.Kind != yaml.SequenceNode { + result.AddError("'steps' must be a list") + } else if len(valueNode.Content) == 0 { + result.AddError("'steps' cannot be empty") + } + break + } + } +} diff --git a/pkg/validation/templates.go b/pkg/validation/templates.go new file mode 100644 index 00000000..750cb703 --- /dev/null +++ b/pkg/validation/templates.go @@ -0,0 +1,104 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "fmt" + "regexp" + + "gopkg.in/yaml.v3" +) + +var ( + templateVarPattern = regexp.MustCompile(`\{\{\.Args\.([a-zA-Z_][a-zA-Z0-9_]*)\}\}`) + stepVarsPattern = regexp.MustCompile(`\{\[\{\.StepVars\.([a-zA-Z_][a-zA-Z0-9_]*)\}\]\}`) +) + +// ValidateTemplateReferences validates that template variables reference defined args/outputvars +// and that all defined args are actually used +func ValidateTemplateReferences(ttpMap map[string]any, result *Result) { + definedArgs := make(map[string]bool) + if argsVal, ok := ttpMap["args"]; ok { + if argsList, isList := argsVal.([]any); isList { + for _, arg := range argsList { + if argMap, isMap := arg.(map[string]any); isMap { + if nameVal, ok := argMap["name"]; ok { + definedArgs[fmt.Sprintf("%v", nameVal)] = true + } + } + } + } + } + + // Collect defined outputvars from steps (in order) + definedOutputVars := make(map[string]bool) + if stepsVal, ok := ttpMap["steps"]; ok { + if stepsList, isList := stepsVal.([]any); isList { + for _, step := range stepsList { + if stepMap, isMap := step.(map[string]any); isMap { + if outputvarVal, ok := stepMap["outputvar"]; ok { + definedOutputVars[fmt.Sprintf("%v", outputvarVal)] = true + } + } + } + } + } + + yamlBytes, err := yaml.Marshal(ttpMap) + if err != nil { + return + } + yamlStr := string(yamlBytes) + + // Validate {{.Args.varname}} references and track which args are used + usedArgs := make(map[string]bool) + argsMatches := templateVarPattern.FindAllStringSubmatch(yamlStr, -1) + seenVars := make(map[string]bool) + for _, match := range argsMatches { + if len(match) > 1 { + varName := match[1] + usedArgs[varName] = true + if !seenVars[varName] && !definedArgs[varName] { + result.AddError(fmt.Sprintf("Template variable '{{.Args.%s}}' references undefined argument '%s'", varName, varName)) + seenVars[varName] = true + } + } + } + + // Validate {[{.StepVars.varname}]} references + stepVarsMatches := stepVarsPattern.FindAllStringSubmatch(yamlStr, -1) + seenStepVars := make(map[string]bool) + for _, match := range stepVarsMatches { + if len(match) > 1 { + varName := match[1] + if !seenStepVars[varName] && !definedOutputVars[varName] { + result.AddError(fmt.Sprintf("Template variable '{[{.StepVars.%s}]}' references undefined outputvar '%s'", varName, varName)) + seenStepVars[varName] = true + } + } + } + + // Check for defined but unused arguments + for argName := range definedArgs { + if !usedArgs[argName] { + result.AddWarning(fmt.Sprintf("Argument '%s' is defined but never used in the TTP", argName)) + } + } +} diff --git a/pkg/validation/validator.go b/pkg/validation/validator.go new file mode 100644 index 00000000..abe671b4 --- /dev/null +++ b/pkg/validation/validator.go @@ -0,0 +1,126 @@ +/* +Copyright © 2023-present, Meta Platforms, Inc. and affiliates +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package validation + +import ( + "fmt" + + "github.com/facebookincubator/ttpforge/pkg/repos" + "github.com/spf13/afero" + "gopkg.in/yaml.v3" +) + +// Result holds the results of validation +type Result struct { + Errors []string + Warnings []string + Infos []string +} + +func (vr *Result) AddError(msg string) { + vr.Errors = append(vr.Errors, msg) +} + +func (vr *Result) AddWarning(msg string) { + vr.Warnings = append(vr.Warnings, msg) +} + +func (vr *Result) AddInfo(msg string) { + vr.Infos = append(vr.Infos, msg) +} + +func (vr *Result) HasErrors() bool { + return len(vr.Errors) > 0 +} + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorRed = "\033[1;31m" + colorYellow = "\033[1;33m" + colorCyan = "\033[1;36m" + colorGreen = "\033[1;32m" +) + +func (vr *Result) Print() { + if len(vr.Errors) > 0 { + fmt.Printf("\n%sERROR (%d):%s\n", colorRed, len(vr.Errors), colorReset) + for _, err := range vr.Errors { + fmt.Printf(" %s✗%s %s\n", colorRed, colorReset, err) + } + } + + if len(vr.Warnings) > 0 { + fmt.Printf("\n%sWARNING (%d):%s\n", colorYellow, len(vr.Warnings), colorReset) + for _, warn := range vr.Warnings { + fmt.Printf(" %s⚠%s %s\n", colorYellow, colorReset, warn) + } + } + + if len(vr.Infos) > 0 { + fmt.Printf("\n%sINFO (%d):%s\n", colorCyan, len(vr.Infos), colorReset) + for _, info := range vr.Infos { + fmt.Printf(" %sℹ%s %s\n", colorCyan, colorReset, info) + } + } + + if !vr.HasErrors() { + fmt.Printf("\n%s✓ TTP structure is valid!%s\n", colorGreen, colorReset) + } +} + +// ValidateTTP performs comprehensive validation using all checks +func ValidateTTP(ttpFilePath string, fsys afero.Fs, repo repos.Repo) *Result { + result := &Result{} + + // Read file once and cache the content + ttpBytes, err := readTTPBytesForValidation(ttpFilePath, fsys) + if err != nil { + result.AddError(fmt.Sprintf("Failed to read TTP file: %v", err)) + return result + } + + ttpContent := string(ttpBytes) + + // Check required fields first - this always runs regardless of YAML parsing success + ValidateRequiredFields(ttpContent, result) + + // Run structural validation checks + ValidateStructure(ttpContent, result) + + // Attempt to parse YAML + var ttpMap map[string]any + err = yaml.Unmarshal(ttpBytes, &ttpMap) + if err != nil { + // If YAML parsing fails, it's likely due to templates + result.AddWarning(fmt.Sprintf("YAML parsing had issues: %v - skipping full validation", err)) + } else { + // YAML parsed successfully - run all structure-based validations + ValidatePreamble(ttpMap, ttpBytes, result) + ValidateRequirements(ttpMap, result) + ValidateArgs(ttpMap, result) + ValidateTemplateReferences(ttpMap, result) + } + + // Run blocks package validation + ValidateIntegration(ttpFilePath, ttpBytes, repo, result) + + return result +} diff --git a/run-all-ttp-tests.sh b/run-all-ttp-tests.sh index 08040c26..890099b9 100755 --- a/run-all-ttp-tests.sh +++ b/run-all-ttp-tests.sh @@ -28,7 +28,7 @@ then fi TTPFORGE_BINARY=$(realpath "${TTPFORGE_BINARY}") -EXCEPTIONS_FILE=("kill-process-windows.yaml" "kill-process-windows-failure.yaml") +EXCEPTIONS_FILE=("kill-process-windows.yaml" "kill-process-windows-failure.yaml" "invalid.yaml") # Loop over all specified directories and validate all ttps within each. shift