Skip to content

Commit 9ba4dce

Browse files
isaac-fletchermeta-codesync[bot]
authored andcommitted
Add ttpforge validate command (#574)
Summary: Pull Request resolved: #574 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
1 parent 031108c commit 9ba4dce

31 files changed

+1922
-21
lines changed

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ TTPForge is a Purple Team engagement tool to execute Tactics, Techniques, and Pr
8181
rootCmd.AddCommand(buildEnumCommand(cfg))
8282
rootCmd.AddCommand(buildShowCommand(cfg))
8383
rootCmd.AddCommand(buildRunCommand(cfg))
84+
rootCmd.AddCommand(buildValidateCommand(cfg))
8485
rootCmd.AddCommand(buildTestCommand(cfg))
8586
rootCmd.AddCommand(buildInstallCommand(cfg))
8687
rootCmd.AddCommand(buildRemoveCommand(cfg))

cmd/validate.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
Copyright © 2023-present, Meta Platforms, Inc. and affiliates
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is
8+
furnished to do so, subject to the following conditions:
9+
The above copyright notice and this permission notice shall be included in
10+
all copies or substantial portions of the Software.
11+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
17+
THE SOFTWARE.
18+
*/
19+
20+
package cmd
21+
22+
import (
23+
"fmt"
24+
25+
"github.com/facebookincubator/ttpforge/pkg/validation"
26+
"github.com/spf13/cobra"
27+
)
28+
29+
func buildValidateCommand(cfg *Config) *cobra.Command {
30+
validateCmd := &cobra.Command{
31+
Use: "validate [repo_name//path/to/ttp]",
32+
Short: "Validate the structure and syntax of a TTP YAML file",
33+
Long: `Validate performs comprehensive validation on a TTP YAML file
34+
Unlike --dry-run, this command:
35+
- Does not require values to be provided for all arguments
36+
- Does not require OS/platform compatibility
37+
- Performs extensive structural and best practice checks
38+
- Reports errors, warnings, and informational messages
39+
40+
This is useful for CI/CD validation, linting, and checking TTP syntax
41+
without needing to provide all runtime arguments or match platform requirements.`,
42+
Args: cobra.ExactArgs(1),
43+
RunE: func(cmd *cobra.Command, args []string) error {
44+
cmd.SilenceUsage = true
45+
46+
ttpRef := args[0]
47+
foundRepo, ttpAbsPath, err := cfg.repoCollection.ResolveTTPRef(ttpRef)
48+
if err != nil {
49+
return fmt.Errorf("failed to resolve TTP reference %v: %w", ttpRef, err)
50+
}
51+
52+
fmt.Printf("Validating TTP: %s\n", ttpAbsPath)
53+
54+
result := validation.ValidateTTP(ttpAbsPath, foundRepo.GetFs(), foundRepo)
55+
result.Print()
56+
57+
if result.HasErrors() {
58+
return fmt.Errorf("validation failed with %d error(s)", len(result.Errors))
59+
}
60+
61+
return nil
62+
},
63+
}
64+
65+
return validateCmd
66+
}

example-ttps/tests/invalid.yaml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
uuid: not-a-valid-uuid
3+
api_version: 99.0
4+
description: bad
5+
mitre:
6+
invalid_key: value
7+
requirements:
8+
platforms:
9+
- os: invalidOS
10+
arch: fake_arch
11+
superuser: "not_a_boolean"
12+
args:
13+
- name: InvalidArgName
14+
type: invalid_type
15+
- name: duplicate_arg
16+
- name: duplicate_arg
17+
description: This is a duplicate
18+
- name: unused_arg
19+
default: value
20+
- name: undefined_ref
21+
- name: another_unused_arg
22+
description: This argument is defined but never referenced anywhere
23+
steps:
24+
- name:
25+
inline: ""
26+
- name: duplicate_step
27+
inline: echo "first"
28+
- name: duplicate_step
29+
inline: echo "duplicate"
30+
executor: invalid_executor
31+
outputvar: InvalidOutputVar
32+
- name: ambiguous_action
33+
inline: echo "has multiple actions"
34+
file: /some/file.sh
35+
create_file: /another/file
36+
- name: no_action_step
37+
description: This step has no action
38+
- name: empty_inline
39+
inline: ""
40+
- name: invalid_http_method
41+
http_request: https://192.168.1.1/hardcoded-ip
42+
type: INVALID_METHOD
43+
- name: missing_create_file_contents
44+
create_file: /tmp/file.txt
45+
- name: missing_copy_to
46+
copy_path: /source
47+
- name: missing_fetch_location
48+
fetch_uri: http://example.com/file
49+
- name: missing_kill_process_param
50+
kill_process: true
51+
- name: missing_edit_file_edits
52+
edit_file: /some/file
53+
- name: invalid_expect
54+
expect:
55+
missing_inline: true
56+
- name: bad_cleanup
57+
create_file: /tmp/test.txt
58+
contents: "test"
59+
cleanup: not_default_or_dict
60+
- name: undefined_template_var
61+
inline: "{{.Args.nonexistent_arg}}"
62+
- name: undefined_stepvar
63+
inline: echo "trying to use {[{.StepVars.nonexistent_outputvar}]}"
64+
- name: cd_empty
65+
cd: ""
66+
- name: ttp_empty
67+
ttp: ""

pkg/args/spec.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ func ParseAndValidate(specs []Spec, argsKvStrs []string, cliBaseDir string, defa
163163
return processedArgs, nil
164164
}
165165

166+
// GetValidArgTypes returns all valid argument types supported by TTPForge
167+
func GetValidArgTypes() []string {
168+
return []string{
169+
"string",
170+
"int",
171+
"bool",
172+
"path",
173+
}
174+
}
175+
166176
func (spec Spec) convertArgToType(val string) (any, error) {
167177
switch spec.Type {
168178
case "", "string":

pkg/blocks/httprequest.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,10 +362,13 @@ func (r *HTTPRequestStep) validateProxy() error {
362362
return nil
363363
}
364364

365+
// ValidHTTPMethods contains all supported HTTP methods
366+
var ValidHTTPMethods = []string{"GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"}
367+
365368
// validateType validates that the request type is a valid HTTP request type. Returns an error if validation fails, otherwise returns nil
366369
func (r *HTTPRequestStep) validateType() error {
367370
isHTTPMethod := false
368-
for _, method := range []string{"GET", "POST", "PUT", "DELETE", "HEAD", "PATCH"} {
371+
for _, method := range ValidHTTPMethods {
369372
if strings.EqualFold(r.Type, method) {
370373
isHTTPMethod = true
371374
break

pkg/platforms/spec.go

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ type Spec struct {
3333
Arch string
3434
}
3535

36+
// GetValidOS returns all valid operating system values supported by TTPForge
37+
func GetValidOS() []string {
38+
return []string{
39+
"android",
40+
"darwin",
41+
"dragonfly",
42+
"freebsd",
43+
"linux",
44+
"netbsd",
45+
"openbsd",
46+
"plan9",
47+
"solaris",
48+
"windows",
49+
}
50+
}
51+
3652
// IsCompatibleWith returns true if the current spec is compatible with the
3753
// spec specified as its argument.
3854
// TTPs will often not care about the architecture and will
@@ -84,29 +100,18 @@ func (s *Spec) Validate() error {
84100
return fmt.Errorf("os and arch cannot both be empty")
85101
}
86102

87-
// this really ought to to be a list I can
88-
// import from some package, but doesn't look
89-
// like that is possible right now
90-
// https://stackoverflow.com/a/20728862
91-
validOS := map[string]bool{
92-
"android": true,
93-
"darwin": true,
94-
"dragonfly": true,
95-
"freebsd": true,
96-
"linux": true,
97-
"netbsd": true,
98-
"openbsd": true,
99-
// if you run this on plan9 I will buy you a beer
100-
"plan9": true,
101-
"solaris": true,
102-
"windows": true,
103+
validOSList := GetValidOS()
104+
validOSMap := make(map[string]bool)
105+
for _, os := range validOSList {
106+
validOSMap[os] = true
103107
}
104-
if s.OS != "" && !validOS[s.OS] {
108+
109+
if s.OS != "" && !validOSMap[s.OS] {
105110
errorMsg := fmt.Sprintf("invalid `os` value %q specified", s.OS)
106111
logging.L().Errorf(errorMsg)
107112
logging.L().Errorf("valid values are:")
108-
for k := range validOS {
109-
logging.L().Errorf("\t%s", k)
113+
for _, os := range validOSList {
114+
logging.L().Errorf("\t%s", os)
110115
}
111116
return errors.New(errorMsg)
112117
}

pkg/validation/args.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright © 2023-present, Meta Platforms, Inc. and affiliates
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is
8+
furnished to do so, subject to the following conditions:
9+
The above copyright notice and this permission notice shall be included in
10+
all copies or substantial portions of the Software.
11+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
17+
THE SOFTWARE.
18+
*/
19+
20+
package validation
21+
22+
import (
23+
"fmt"
24+
"regexp"
25+
"strings"
26+
27+
"github.com/facebookincubator/ttpforge/pkg/args"
28+
)
29+
30+
var (
31+
namingPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
32+
)
33+
34+
// ValidateArgs validates argument definitions
35+
func ValidateArgs(ttpMap map[string]any, result *Result) {
36+
argsVal, ok := ttpMap["args"]
37+
if !ok {
38+
return
39+
}
40+
41+
argsList, isList := argsVal.([]any)
42+
if !isList {
43+
result.AddError("'args' must be a list")
44+
return
45+
}
46+
47+
seenArgs := make(map[string]bool)
48+
for i, arg := range argsList {
49+
argMap, isMap := arg.(map[string]any)
50+
if !isMap {
51+
result.AddError(fmt.Sprintf("Argument %d must be a dictionary", i+1))
52+
continue
53+
}
54+
55+
nameVal, hasName := argMap["name"]
56+
if !hasName {
57+
result.AddError(fmt.Sprintf("Argument %d missing 'name' field", i+1))
58+
continue
59+
}
60+
61+
argName := fmt.Sprintf("%v", nameVal)
62+
63+
if seenArgs[argName] {
64+
result.AddError(fmt.Sprintf("Duplicate argument name: %s", argName))
65+
}
66+
seenArgs[argName] = true
67+
68+
if !namingPattern.MatchString(argName) {
69+
result.AddWarning(fmt.Sprintf("Argument name should be lowercase with underscores: %s", argName))
70+
}
71+
72+
if argType, ok := argMap["type"]; ok {
73+
typeStr := fmt.Sprintf("%v", argType)
74+
validTypes := args.GetValidArgTypes()
75+
validTypesMap := make(map[string]bool)
76+
for _, t := range validTypes {
77+
validTypesMap[t] = true
78+
}
79+
if !validTypesMap[typeStr] {
80+
result.AddError(fmt.Sprintf("Invalid argument type: %s (valid: %s)", typeStr, strings.Join(validTypes, ", ")))
81+
}
82+
} else {
83+
result.AddInfo(fmt.Sprintf("Argument '%s' has no type specified (defaults to string)", argName))
84+
}
85+
86+
if _, ok := argMap["description"]; !ok {
87+
result.AddWarning(fmt.Sprintf("Argument '%s' should have a description", argName))
88+
}
89+
90+
if _, ok := argMap["default"]; !ok {
91+
result.AddInfo(fmt.Sprintf("Argument '%s' has no default value", argName))
92+
}
93+
}
94+
}

0 commit comments

Comments
 (0)