Skip to content

Commit 0681706

Browse files
isaac-fletchermeta-codesync[bot]
authored andcommitted
Add ttpforge validate command
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
1 parent 031108c commit 0681706

31 files changed

+1924
-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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
- name: ambiguous_action
32+
inline: echo "has multiple actions"
33+
file: /some/file.sh
34+
create_file: /another/file
35+
- name: no_action_step
36+
description: This step has no action
37+
- name: empty_inline
38+
inline: ""
39+
- name: invalid_http_method
40+
http_request: https://192.168.1.1/hardcoded-ip
41+
type: INVALID_METHOD
42+
- name: missing_create_file_contents
43+
create_file: /tmp/file.txt
44+
- name: missing_copy_to
45+
copy_path: /source
46+
- name: missing_fetch_location
47+
fetch_uri: http://example.com/file
48+
- name: missing_kill_process_param
49+
kill_process: true
50+
- name: missing_edit_file_edits
51+
edit_file: /some/file
52+
- name: invalid_expect
53+
expect:
54+
missing_inline: true
55+
- name: bad_cleanup
56+
create_file: /tmp/test.txt
57+
contents: "test"
58+
cleanup: not_default_or_dict
59+
- name: hardcoded_ips_and_urls
60+
inline: curl http://hardcoded.example.com/path && ping 10.0.0.1
61+
outputvar: InvalidOutputVar
62+
- name: undefined_template_var
63+
inline: "{{.Args.nonexistent_arg}}"
64+
- name: undefined_stepvar
65+
inline: echo "trying to use {[{.StepVars.nonexistent_outputvar}]}"
66+
- name: cd_empty
67+
cd: ""
68+
- name: ttp_empty
69+
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]interface{}, result *Result) {
36+
argsVal, ok := ttpMap["args"]
37+
if !ok {
38+
return
39+
}
40+
41+
argsList, isList := argsVal.([]interface{})
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]interface{})
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)