Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
67 changes: 67 additions & 0 deletions cmd/validate.go
Original file line number Diff line number Diff line change
@@ -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
}
67 changes: 67 additions & 0 deletions example-ttps/tests/invalid.yaml
Original file line number Diff line number Diff line change
@@ -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: ""
10 changes: 10 additions & 0 deletions pkg/args/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
5 changes: 4 additions & 1 deletion pkg/blocks/httprequest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 24 additions & 19 deletions pkg/platforms/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
94 changes: 94 additions & 0 deletions pkg/validation/args.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Loading
Loading