diff --git a/.goreleaser.yml b/.goreleaser.yml index f45f203e..29fff213 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -17,7 +17,7 @@ builds: goarm: - 6 ldflags: - - -s -w -X main.GitVersion={{ .Version }} -X main.GitCommit={{ .ShortCommit }} -X main.BuildDate={{ .CommitDate }} + - -s -w -X main.GitVersion={{ .Tag }} -X main.GitCommit={{ .ShortCommit }} -X main.BuildDate={{ .CommitDate }} ignore: - goos: darwin goarch: 386 @@ -50,7 +50,7 @@ universal_binaries: checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ incpatch .Version }}-next" + name_template: "{{ incpatch .Tag }}-next" changelog: sort: asc use: github @@ -92,7 +92,7 @@ dockers: - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.name={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.version={{.Tag}}" - "--label=org.opencontainers.image.source={{.GitURL}}" - "--platform=linux/amd64" extra_files: @@ -106,7 +106,7 @@ dockers: - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.name={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" + - "--label=org.opencontainers.image.version={{.Tag}}" - "--label=org.opencontainers.image.source={{.GitURL}}" - "--platform=linux/arm64" extra_files: diff --git a/Jenkinsfile_benchmark b/Jenkinsfile_benchmark index 51b1bddf..d66bb898 100644 --- a/Jenkinsfile_benchmark +++ b/Jenkinsfile_benchmark @@ -24,6 +24,7 @@ pipeline { allOf { expression { env.CHANGE_ID != null } expression { env.CHANGE_TARGET != null } + expression { env.CHANGE_BRANCH != 'develop' } } } steps { diff --git a/README.md b/README.md index 366550a7..853ac7f5 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,11 @@ This section aims to show you how to use Ddosify without deep dive into its deta ddosify -config ddosify_config_correlation.json Ddosify allows you to specify variables at the global level and use them throughout the scenario, as well as extract variables from previous steps and inject them to the next steps in each iteration individually. You can inject those variables in requests *url*, *headers* and *payload(body)*. The example config can be found in [correlation-config-example](#Correlation). + +7. ### Test Data + + ddosify -config ddosify_data_csv.json + Ddosify allows you to load test data from a file, tag specific columns for later use. You can inject those variables in requests *url*, *headers* and *payload(body)*. The example config can be found in [test-data-example](#test-data-set). ## Details You can configure your load test by the CLI options or a config file. Config file supports more features than the CLI. For example, you can't create a scenario-based load test with CLI options. @@ -262,18 +267,43 @@ There is an example config file at [config_examples/config.json](/config_example - `env` *optional* Scenario-scoped global variables. Note that dynamic variables changes every iteration. ```json - "steps": [ - { - "id": 1, - "url": "http://target.com/endpoint1", - "env": { - "COMPANY_NAME" :"Ddosify", - "randomCountry" : "{{_randomCountry}}" - } - }, - ] + "env": { + "COMPANY_NAME" :"Ddosify", + "randomCountry" : "{{_randomCountry}}" + } ``` - +- `data` *optional* + Config for loading test data from a csv file. + [Csv data](https://github.com/ddosify/ddosify/tree/master/config/config_testdata/test.csv) used in below config. + ```json + "data":{ + "info": { + "path" : "config/config_testdata/test.csv", + "delimiter": ";", + "vars": { + "0":{"tag":"name"}, + "1":{"tag":"city"}, + "2":{"tag":"team"}, + "3":{"tag":"payload", "type":"json"}, + "4":{"tag":"age", "type":"int"} + }, + "allowQuota" : true, + "order": "sequential", + "skipFirstLine" : true, + "skipEmptyLine" : true + } + } + ``` + | Field | Description | Type | Default | Required? | + | ------ | -------------------------------------------------------- | ------ | ------- | --------- | + | `path` | Local path or remote url for your csv file | `string` | - | Yes | + | `delimiter` | Delimiter for reading csv | `string` | `,` | No | + | `vars` | Tag columns using column index as key, use `type` field if you want to cast a column to a specific type, default is `string`, can be one of the following: `json`, `int`, `float`,`bool`. | `map` | - | Yes | + | `allowQuota` | If set to true, a quote may appear in an unquoted field and a non-doubled quote may appear in a quoted field | `bool` | `false` | No | + | `order` | Order of reading records from csv. Can be `random` or `sequential` | `string` | `random` | No | + | `skipFirstLine` | Skips first line while reading records from csv. | `bool` | `false` | No | + | `skipEmptyLine` | Skips empty lines while reading records from csv. | `bool` | `true` | No | + - `steps` *mandatory* This parameter lets you create your scenario. Ddosify runs the provided steps, respectively. For the given example file step id: 2 will be executed immediately after the response of step id: 1 is received. The order of the execution is the same as the order of the steps in the config file. @@ -587,7 +617,8 @@ ddosify -config ddosify_config_correlation.json -debug "TARGET_URL" : "http://localhost:8084/hello", "USER_KEY" : "ABC", "COMPANY_NAME" : "Ddosify", - "RANDOM_COUNTRY" : "{{_randomCountry}}" + "RANDOM_COUNTRY" : "{{_randomCountry}}", + "NUMBERS" : [22,33,10,52] }, } ``` @@ -595,6 +626,7 @@ ddosify -config ddosify_config_correlation.json -debug ### :hammer: Overall Config and Injection +On array-like captured variables or environment vars, the **rand( )** function can be utilized. ```json // ddosify_config_correlation.json { @@ -607,7 +639,8 @@ ddosify -config ddosify_config_correlation.json -debug "url": "{{TARGET_URL}}", "method": "POST", "headers": { - "User-Key": "{{USER_KEY}}" + "User-Key": "{{USER_KEY}}", + "Rand-Selected-Num" : "{{rand(NUMBERS)}}" }, "payload" : "{{COMPANY_NAME}}", "captureEnv": { @@ -640,7 +673,8 @@ ddosify -config ddosify_config_correlation.json -debug "TARGET_URL" : "http://localhost:8084/hello", "USER_KEY" : "ABC", "COMPANY_NAME" : "Ddosify", - "RANDOM_COUNTRY" : "{{_randomCountry}}" + "RANDOM_COUNTRY" : "{{_randomCountry}}", + "NUMBERS" : [22,33,10,52] }, } ``` @@ -662,10 +696,50 @@ ddosify -config ddosify_config_correlation.json -debug } ``` +## Test Data Set +Ddosify enables you to load test data from **csv** files. Later, in your scenario, you can inject variables that you tagged. + +We are using this [csv data](https://github.com/ddosify/ddosify/tree/master/config/config_testdata/test.csv) in below config. + + +```json +// config_data_csv.json +"data":{ + "csv_test": { + "path" : "config/config_testdata/test.csv", + "delimiter": ";", + "vars": { + "0":{"tag":"name"}, + "1":{"tag":"city"}, + "2":{"tag":"team"}, + "3":{"tag":"payload", "type":"json"}, + "4":{"tag":"age", "type":"int"} + }, + "allowQuota" : true, + "order": "random", + "skipFirstLine" : true + } + } +``` + +You can refer to tagged variables in your request like below. + +```json +// payload.json +{ + "name" : "{{data.csv_test.name}}", + "team" : "{{data.csv_test.team}}", + "city" : "{{data.csv_test.city}}", + "payload" : "{{data.csv_test.payload}}", + "age" : "{{data.csv_test.age}}" +} +``` + ## Tutorials / Blog Posts * [Testing the Performance of User Authentication Flow](https://ddosify.com/blog/testing-the-performance-of-user-authentication-flow#introduction) + ## Common Issues ### macOS Security Issue diff --git a/config/config_testdata/config_data_csv.json b/config/config_testdata/config_data_csv.json new file mode 100644 index 00000000..c09694b7 --- /dev/null +++ b/config/config_testdata/config_data_csv.json @@ -0,0 +1,47 @@ +{ + "iteration_count": 4, + "load_type": "waved", + "duration": 1, + "steps": [ + { + "id": 2, + "url": "{{LOCAL}}/body", + "name": "JSON", + "method": "GET", + "others": { + "h2": false, + "keep-alive": true, + "disable-redirect": true, + "disable-compression": false + }, + "payload_file": "../config/config_testdata/data_json_payload.json", + "timeout": 10 + } + ], + "output": "stdout", + "env":{ + "HTTPBIN" : "https://httpbin.ddosify.com", + "LOCAL" : "http://localhost:8084", + "RANDOM_NAMES" : ["kenan","fatih","kursat","semih","sertac"] , + "RANDOM_INT" : [52,99,60,33], + "RANDOM_BOOL" : [true,true,true,false] + }, + "data":{ + "info": { + "path" : "../config/config_testdata/test.csv", + "src" : "local", + "delimiter": ";", + "vars": { + "0":{"tag":"name"}, + "1":{"tag":"city"}, + "2":{"tag":"team"}, + "3":{"tag":"payload", "type":"json"}, + "4":{"tag":"age", "type":"int"} + }, + "allowQuota" : true, + "order": "random", + "skipFirstLine" : true + } + }, + "debug" : false +} \ No newline at end of file diff --git a/config/config_testdata/data_json_payload.json b/config/config_testdata/data_json_payload.json new file mode 100644 index 00000000..ad89dd11 --- /dev/null +++ b/config/config_testdata/data_json_payload.json @@ -0,0 +1,7 @@ +{ + "name" : "{{data.info.name}}", + "team" : "{{data.info.team}}", + "city" : "{{data.info.city}}", + "payload" : "{{rand(data.info.payload)}}", + "age" : "{{data.info.age}}" +} \ No newline at end of file diff --git a/config/config_testdata/test.csv b/config/config_testdata/test.csv new file mode 100644 index 00000000..20f97825 --- /dev/null +++ b/config/config_testdata/test.csv @@ -0,0 +1,7 @@ +Username;City;Team;Payload;Age;Percent;BoolField;;; +Kenan;Tokat;Galatasaray;{"data":{"profile":{"name":"Kenan"}}};25;22.3;true;;; +Fatih;Bolu;Galatasaray;[5,6,7];29;44.3;false;;; +Kursat;Samsun;Besiktas;{"a":"b"};28;12.54;True;;; +Semih;Duzce;Besiktas;{"a":"b"};27;663.67;False;;; +;;;;;;;;; +;;;;;;;;; \ No newline at end of file diff --git a/config/csv.go b/config/csv.go new file mode 100644 index 00000000..eddda833 --- /dev/null +++ b/config/csv.go @@ -0,0 +1,131 @@ +package config + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" +) + +func validateConf(conf CsvConf) error { + if !(conf.Order == "random" || conf.Order == "sequential") { + return fmt.Errorf("unsupported order %s, should be random|sequential", conf.Order) + } + return nil +} + +func readCsv(conf CsvConf) ([]map[string]interface{}, error) { + err := validateConf(conf) + if err != nil { + return nil, err + } + + var reader io.Reader + + if _, err = url.ParseRequestURI(conf.Path); err == nil { // url + req, err := http.NewRequest(http.MethodGet, conf.Path, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + if !(resp.StatusCode >= 200 && resp.StatusCode <= 299) { + return nil, fmt.Errorf("request to remote url failed: %d", resp.StatusCode) + } + reader = resp.Body + defer resp.Body.Close() + } else if _, err = os.Stat(conf.Path); err == nil { // local file path + f, err := os.Open(conf.Path) + if err != nil { + return nil, err + } + reader = f + defer f.Close() + } else { + return nil, err + } + + // read csv values using csv.Reader + csvReader := csv.NewReader(reader) + csvReader.Comma = []rune(conf.Delimiter)[0] + csvReader.TrimLeadingSpace = true + csvReader.LazyQuotes = conf.AllowQuota + + data, err := csvReader.ReadAll() + if err != nil { + return nil, err + } + + if conf.SkipFirstLine { + data = data[1:] + } + + rt := make([]map[string]interface{}, 0) // unclear how many empty line exist + + for _, row := range data { + if conf.SkipEmptyLine && emptyLine(row) { + continue + } + x := map[string]interface{}{} + for index, tag := range conf.Vars { + i, err := strconv.Atoi(index) + if err != nil { + return nil, err + } + + if i >= len(row) { + return nil, fmt.Errorf("index number out of range, check your vars or delimiter") + } + + // convert + var val interface{} + switch tag.Type { + case "json": + err := json.Unmarshal([]byte(row[i]), &val) + if err != nil { + return nil, fmt.Errorf("can not convert %s to json,%v", row[i], err) + } + case "int": + var err error + val, err = strconv.Atoi(row[i]) + if err != nil { + return nil, fmt.Errorf("can not convert %s to int,%v", row[i], err) + } + case "float": + var err error + val, err = strconv.ParseFloat(row[i], 64) + if err != nil { + return nil, fmt.Errorf("can not convert %s to float,%v", row[i], err) + } + case "bool": + var err error + val, err = strconv.ParseBool(row[i]) + if err != nil { + return nil, fmt.Errorf("can not convert %s to bool,%v", row[i], err) + } + default: + val = row[i] + } + x[tag.Tag] = val + } + rt = append(rt, x) + } + + return rt, nil +} + +func emptyLine(row []string) bool { + for _, field := range row { + if field != "" { + return false + } + } + return true +} diff --git a/config/csv_test.go b/config/csv_test.go new file mode 100644 index 00000000..84855735 --- /dev/null +++ b/config/csv_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +func TestValidateCsvConf(t *testing.T) { + t.Parallel() + conf := CsvConf{ + Path: "", + Delimiter: "", + SkipFirstLine: false, + Vars: map[string]Tag{}, + SkipEmptyLine: false, + AllowQuota: false, + Order: "", + } + + conf.Order = "invalidOrder" + err := validateConf(conf) + + if err == nil { + t.Errorf("TestValidateCsvConf should be errored") + } +} + +func TestReadCsv(t *testing.T) { + t.Parallel() + conf := CsvConf{ + Path: "config_testdata/test.csv", + Delimiter: ";", + SkipFirstLine: true, + Vars: map[string]Tag{ + "0": {Tag: "name", Type: "string"}, + "3": {Tag: "payload", Type: "json"}, + "4": {Tag: "age", Type: "int"}, + "5": {Tag: "percent", Type: "float"}, + "6": {Tag: "boolField", Type: "bool"}, + }, + SkipEmptyLine: true, + AllowQuota: true, + Order: "sequential", + } + + rows, err := readCsv(conf) + + if err != nil { + t.Errorf("TestReadCsv %v", err) + } + + firstName := rows[0]["name"].(string) + expectedName := "Kenan" + if !strings.EqualFold(firstName, expectedName) { + t.Errorf("TestReadCsv found: %s , expected: %s", firstName, expectedName) + } + + firstAge := rows[0]["age"].(int) + expectedAge := 25 + if firstAge != expectedAge { + t.Errorf("TestReadCsv found: %d , expected: %d", firstAge, expectedAge) + } + + firstPercent := rows[0]["percent"].(float64) + expectedPercent := 22.3 + if firstPercent != expectedPercent { + t.Errorf("TestReadCsv found: %f , expected: %f", firstPercent, expectedPercent) + } + + firstBool := rows[0]["boolField"].(bool) + expectedBool := true + if firstBool != expectedBool { + t.Errorf("TestReadCsv found: %t , expected: %t", firstBool, expectedBool) + } + + firstPayload := rows[0]["payload"].(map[string]interface{}) + expectedPayload := map[string]interface{}{ + "data": map[string]interface{}{ + "profile": map[string]interface{}{ + "name": "Kenan", + }, + }, + } + if !reflect.DeepEqual(firstPayload, expectedPayload) { + t.Errorf("TestReadCsv found: %#v , expected: %#v", firstPayload, expectedPayload) + } + + secondPayload := rows[1]["payload"].([]interface{}) + expectedPayload2 := []interface{}{5.0, 6.0, 7.0} // underlying type float64 + if !reflect.DeepEqual(secondPayload, expectedPayload2) { + t.Errorf("TestReadCsv found: %#v , expected: %#v", secondPayload, expectedPayload2) + } +} + +var table = []struct { + conf CsvConf + latency float64 +}{ + { + conf: CsvConf{ + Path: "config_testdata/test.csv", + Delimiter: ";", + SkipFirstLine: true, + Vars: map[string]Tag{ + "0": {Tag: "name", Type: "string"}, + "3": {Tag: "payload", Type: "json"}, + "4": {Tag: "age", Type: "int"}, + "5": {Tag: "percent", Type: "float"}, + "6": {Tag: "boolField", Type: "bool"}, + }, + SkipEmptyLine: true, + AllowQuota: true, + Order: "sequential", + }, + }, +} + +func TestBenchmarkCsvRead(t *testing.T) { + for _, v := range table { + + res := testing.Benchmark(func(b *testing.B) { + for i := 0; i < b.N; i++ { + readCsv(v.conf) + } + }) + + fmt.Printf("ns:%d", res.T.Nanoseconds()) + fmt.Printf("N:%d", res.N) + } +} diff --git a/config/json.go b/config/json.go index 4b3d514b..612ee237 100644 --- a/config/json.go +++ b/config/json.go @@ -108,6 +108,40 @@ func (s *step) UnmarshalJSON(data []byte) error { return nil } +type Tag struct { + Tag string `json:"tag"` + Type string `json:"type"` +} + +func (t *Tag) UnmarshalJSON(data []byte) error { + // default values + t.Type = "string" + type tempTag Tag + return json.Unmarshal(data, (*tempTag)(t)) +} + +type CsvConf struct { + Path string `json:"path"` + Delimiter string `json:"delimiter"` + SkipFirstLine bool `json:"skipFirstLine"` + Vars map[string]Tag `json:"vars"` // "0":"name", "1":"city","2":"team" + SkipEmptyLine bool `json:"skipEmptyLine"` + AllowQuota bool `json:"allowQuota"` + Order string `json:"order"` +} + +func (c *CsvConf) UnmarshalJSON(data []byte) error { + // default values + c.SkipEmptyLine = true + c.SkipFirstLine = false + c.AllowQuota = false + c.Delimiter = "," + c.Order = "random" + + type tempCsv CsvConf + return json.Unmarshal(data, (*tempCsv)(c)) +} + type JsonReader struct { ReqCount *int `json:"request_count"` IterCount *int `json:"iteration_count"` @@ -118,6 +152,7 @@ type JsonReader struct { Output string `json:"output"` Proxy string `json:"proxy"` Envs map[string]interface{} `json:"env"` + Data map[string]CsvConf `json:"data"` Debug bool `json:"debug"` } @@ -152,9 +187,30 @@ func (j *JsonReader) Init(jsonByte []byte) (err error) { } func (j *JsonReader) CreateHammer() (h types.Hammer, err error) { + // Read Data + var readData map[string]types.CsvData + if len(j.Data) > 0 { + readData = make(map[string]types.CsvData, len(j.Data)) + } + for k, conf := range j.Data { + var rows []map[string]interface{} + rows, err = readCsv(conf) + if err != nil { + return + } + var csvData types.CsvData + csvData.Rows = rows + + if conf.Order == "random" { + csvData.Random = true + } + readData[k] = csvData + } + // Scenario s := types.Scenario{ Envs: j.Envs, + Data: readData, } var si types.ScenarioStep for _, step := range j.Steps { diff --git a/config/json_test.go b/config/json_test.go index 10986afe..2801252b 100644 --- a/config/json_test.go +++ b/config/json_test.go @@ -434,6 +434,43 @@ func TestCreateHammerCaptureEnvs(t *testing.T) { } } +func TestCreateHammerDataCsv(t *testing.T) { + t.Parallel() + jsonReader, _ := NewConfigReader(readConfigFile("config_testdata/config_data_csv.json"), ConfigTypeJson) + + expectedRandom := true + + h, err := jsonReader.CreateHammer() + if err != nil { + t.Errorf("TestCreateHammerDataCsv error occurred: %v", err) + } + + csvData := h.Scenario.Data["info"] + + if !reflect.DeepEqual(csvData.Random, expectedRandom) { + t.Errorf("TestCreateHammerDataCsv got: %t expected: %t", csvData.Random, expectedRandom) + } + + expectedRow := map[string]interface{}{ + "name": "Kenan", + "city": "Tokat", + "team": "Galatasaray", + "payload": map[string]interface{}{ + "data": map[string]interface{}{ + "profile": map[string]interface{}{ + "name": "Kenan", + }, + }, + }, + "age": 25, + } + + if !reflect.DeepEqual(expectedRow, csvData.Rows[0]) { + t.Errorf("TestCreateHammerDataCsv got: %#v expected: %#v", csvData.Rows[0], expectedRow) + } + +} + func TestCreateHammerInvalidTarget(t *testing.T) { t.Parallel() jsonReader, _ := NewConfigReader(readConfigFile("config_testdata/config_invalid_target.json"), ConfigTypeJson) diff --git a/config_examples/config.json b/config_examples/config.json index ccdb3c52..d5e26eb0 100644 --- a/config_examples/config.json +++ b/config_examples/config.json @@ -10,6 +10,31 @@ {"duration": 6, "count": 10}, {"duration": 7, "count": 20} ], + "envs" : { + "HTTPBIN" : "https://httpbin.ddosify.com", + "LOCAL" : "http://localhost:8084", + "NAMES" : ["kenan","fatih","kursat","semih","sertac"] , + "NUMBERS" : [52,99,60,33], + "BOOLS" : [true,true,true,false], + "randomIntPerIteration": "{{_randomInt}}" + }, + "data":{ + "info": { + "path" : "config/config_testdata/test.csv", + "delimiter": ";", + "vars": { + "0":{"tag":"name"}, + "1":{"tag":"city"}, + "2":{"tag":"team"}, + "3":{"tag":"payload", "type":"json"}, + "4":{"tag":"age", "type":"int"} + }, + "allowQuota" : true, + "order": "random", + "skipFirstLine" : true, + "skipEmptyLine" : true + } + }, "proxy": "http://proxy_host.com:proxy_port", "output": "stdout", "steps": [ @@ -33,15 +58,23 @@ "disableCompression": false, "h2": true, "disable-redirect": true + }, + "captureEnv": { + "NUM" :{ "from":"body","jsonPath":"num"} } }, { "id": 2, - "url": "https://test_site1.com/endpoint_2", + "url": "{{LOCAL}}", "method": "GET", "payload_file": "config_examples/payload.txt", "timeout": 2, - "sleep": "1000" + "sleep": "1000", + "headers":{ + "num": "{{NUM}}", + "randNum": "{{rand(NUMBERS)}}", + "randInt" : "{{randomIntPerIteration}}" + } }, { "id": 3, diff --git a/core/engine_test.go b/core/engine_test.go index b8730571..f17033df 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -1421,6 +1421,85 @@ func TestDynamicVarAndEnvVarInSameSection(t *testing.T) { } } +func TestLoadRandomInfoFromData(t *testing.T) { + t.Parallel() + + // Test server + requestCalled := false + kenan := "Kenan" + fatih := "Fatih" + expectedKenanAge := "25" + expectedFatihAge := "29" + + ageMap := map[string]string{kenan: "", fatih: ""} + handler := func(w http.ResponseWriter, r *http.Request) { + requestCalled = true + kenanAge := r.Header.Get(kenan) + fatihAge := r.Header.Get(fatih) + if kenanAge != "" { + ageMap[kenan] = kenanAge + } + + if fatihAge != "" { + ageMap[fatih] = fatihAge + } + } + + path := "/xxx" + mux := http.NewServeMux() + mux.HandleFunc(path, handler) + + server := httptest.NewServer(mux) + defer server.Close() + + // Prepare + h := newDummyHammer() + var csvData types.CsvData + csvData.Random = false + csvData.Rows = []map[string]interface{}{{ + "name": kenan, + "age": expectedKenanAge, + }, { + "name": fatih, + "age": expectedFatihAge, + }} + h.Scenario.Data = map[string]types.CsvData{"info": csvData} + h.Scenario.Envs = map[string]interface{}{ + "A": "B", + "URL_PATH": path, + } + h.IterationCount = 2 + h.Scenario.Steps[0] = types.ScenarioStep{ + ID: 1, + Method: "GET", + URL: server.URL + "{{URL_PATH}}", + Headers: map[string]string{ + "{{data.info.name}}": "{{data.info.age}}", + }, + } + + // Act + e, err := NewEngine(context.TODO(), h) + if err != nil { + t.Errorf("TestLoadRandomInfoFromData error occurred %v", err) + } + + err = e.Init() + if err != nil { + t.Errorf("TestLoadRandomInfoFromData error occurred %v", err) + } + + e.Start() + + if !requestCalled { + t.Errorf("TestLoadRandomInfoFromData test server has not been called, url path injection failed") + } + + if ageMap[kenan] != expectedKenanAge || ageMap[fatih] != expectedFatihAge { + t.Errorf("TestLoadRandomInfoFromData did not match") + } +} + // The test creates a web server with Certificate auth, // then it spawns an Engine and verifies that the auth was successfully passsed. func TestTLSMutualAuth(t *testing.T) { diff --git a/core/report/debug.go b/core/report/debug.go index f1f32ba5..7ed2e972 100644 --- a/core/report/debug.go +++ b/core/report/debug.go @@ -28,6 +28,7 @@ type verboseHttpRequestInfo struct { Request verboseRequest `json:"request"` Response verboseResponse `json:"response"` Envs map[string]interface{} `json:"envs"` + TestData map[string]interface{} `json:"ffff"` FailedCaptures map[string]string `json:"failedCaptures"` Error string `json:"error"` } @@ -74,7 +75,19 @@ func ScenarioStepResultToVerboseHttpRequestInfo(sr *types.ScenarioStepResult) ve Body: responseBody, } } - verboseInfo.Envs = sr.UsableEnvs + + envs := make(map[string]interface{}) + testData := make(map[string]interface{}) + for key, val := range sr.UsableEnvs { + if strings.HasPrefix(key, "data.") { + testData[key] = val + } else { + envs[key] = val + } + } + + verboseInfo.Envs = envs + verboseInfo.TestData = testData verboseInfo.FailedCaptures = sr.FailedCaptures return verboseInfo } diff --git a/core/report/stdout.go b/core/report/stdout.go index dda2bad2..51fc9bb0 100644 --- a/core/report/stdout.go +++ b/core/report/stdout.go @@ -171,6 +171,30 @@ func (s *stdout) printInDebugMode(input chan *types.ScenarioResult) { fmt.Fprintf(w, "\t%s:\t%-5s \n", fmt.Sprint(eKey), fmt.Sprint(eVal)) } } + fmt.Fprintf(w, "\n") + fmt.Fprintf(w, "%s\n", blue(fmt.Sprintf("- Test Data"))) + + for eKey, eVal := range verboseInfo.TestData { + switch eVal.(type) { + case map[string]interface{}: + valPretty, _ := json.Marshal(eVal) + fmt.Fprintf(w, "\t%s:\t%-5s \n", fmt.Sprint(eKey), valPretty) + case []int: + valPretty, _ := json.Marshal(eVal) + fmt.Fprintf(w, "\t%s:\t%-5s \n", fmt.Sprint(eKey), valPretty) + case []string: + valPretty, _ := json.Marshal(eVal) + fmt.Fprintf(w, "\t%s:\t%-5s \n", fmt.Sprint(eKey), valPretty) + case []float64: + valPretty, _ := json.Marshal(eVal) + fmt.Fprintf(w, "\t%s:\t%-5s \n", fmt.Sprint(eKey), valPretty) + case []bool: + valPretty, _ := json.Marshal(eVal) + fmt.Fprintf(w, "\t%s:\t%-5s \n", fmt.Sprint(eKey), valPretty) + default: + fmt.Fprintf(w, "\t%s:\t%-5s \n", fmt.Sprint(eKey), fmt.Sprint(eVal)) + } + } if verboseInfo.Error != "" && isVerboseInfoRequestEmpty(verboseInfo.Request) { fmt.Fprintf(w, "%s Error: \t%-5s \n", emoji.SosButton, verboseInfo.Error) diff --git a/core/report/stdoutJson.go b/core/report/stdoutJson.go index 7bb2cdf1..768c5630 100644 --- a/core/report/stdoutJson.go +++ b/core/report/stdoutJson.go @@ -164,6 +164,7 @@ func (v verboseHttpRequestInfo) MarshalJSON() ([]byte, error) { StepId uint16 `json:"stepId"` StepName string `json:"stepName"` Envs map[string]interface{} `json:"envs"` + TestData map[string]interface{} `json:"testData"` FailedCaptures map[string]string `json:"failedCaptures"` Error string `json:"error"` } @@ -174,6 +175,7 @@ func (v verboseHttpRequestInfo) MarshalJSON() ([]byte, error) { StepName: v.StepName, FailedCaptures: v.FailedCaptures, Envs: v.Envs, + TestData: v.TestData, } return json.Marshal(a) } @@ -183,6 +185,7 @@ func (v verboseHttpRequestInfo) MarshalJSON() ([]byte, error) { StepId uint16 `json:"stepId"` StepName string `json:"stepName"` Envs map[string]interface{} `json:"envs"` + TestData map[string]interface{} `json:"testData"` FailedCaptures map[string]string `json:"failedCaptures"` Request struct { Url string `json:"url"` @@ -200,6 +203,7 @@ func (v verboseHttpRequestInfo) MarshalJSON() ([]byte, error) { StepName: v.StepName, FailedCaptures: v.FailedCaptures, Envs: v.Envs, + TestData: v.TestData, } return json.Marshal(a) } @@ -208,6 +212,7 @@ func (v verboseHttpRequestInfo) MarshalJSON() ([]byte, error) { StepId uint16 `json:"stepId"` StepName string `json:"stepName"` Envs map[string]interface{} `json:"envs"` + TestData map[string]interface{} `json:"testData"` FailedCaptures map[string]string `json:"failedCaptures"` Request struct { Url string `json:"url"` @@ -229,6 +234,7 @@ func (v verboseHttpRequestInfo) MarshalJSON() ([]byte, error) { Response: v.Response, FailedCaptures: v.FailedCaptures, Envs: v.Envs, + TestData: v.TestData, } return json.Marshal(a) diff --git a/core/scenario/requester/base.go b/core/scenario/requester/base.go index 73dcd6a3..d5bbe239 100644 --- a/core/scenario/requester/base.go +++ b/core/scenario/requester/base.go @@ -24,13 +24,14 @@ import ( "context" "net/url" + "go.ddosify.com/ddosify/core/scenario/scripting/injection" "go.ddosify.com/ddosify/core/types" ) // Requester is the interface that abstracts different protocols' request sending implementations. // Protocol field in the types.ScenarioStep determines which requester implementation to use. type Requester interface { - Init(ctx context.Context, ss types.ScenarioStep, url *url.URL, debug bool) error + Init(ctx context.Context, ss types.ScenarioStep, url *url.URL, debug bool, ei *injection.EnvironmentInjector) error Send(envs map[string]interface{}) *types.ScenarioStepResult Done() } diff --git a/core/scenario/requester/http.go b/core/scenario/requester/http.go index 281d884a..8b0d8a81 100644 --- a/core/scenario/requester/http.go +++ b/core/scenario/requester/http.go @@ -59,12 +59,11 @@ type HttpRequester struct { } // Init creates a client with the given scenarioItem. HttpRequester uses the same http.Client for all requests -func (h *HttpRequester) Init(ctx context.Context, s types.ScenarioStep, proxyAddr *url.URL, debug bool) (err error) { +func (h *HttpRequester) Init(ctx context.Context, s types.ScenarioStep, proxyAddr *url.URL, debug bool, ei *injection.EnvironmentInjector) (err error) { h.ctx = ctx h.packet = s h.proxyAddr = proxyAddr - h.ei = &injection.EnvironmentInjector{} - h.ei.Init() + h.ei = ei h.containsDynamicField = make(map[string]bool) h.containsEnvVar = make(map[string]bool) h.debug = debug diff --git a/core/scenario/requester/http_test.go b/core/scenario/requester/http_test.go index 3d3d41dc..60029782 100644 --- a/core/scenario/requester/http_test.go +++ b/core/scenario/requester/http_test.go @@ -45,7 +45,7 @@ func TestInit(t *testing.T) { ctx := context.TODO() h := &HttpRequester{} - h.Init(ctx, s, p, false) + h.Init(ctx, s, p, false, nil) if !reflect.DeepEqual(h.packet, s) { t.Errorf("Expected %v, Found %v", s, h.packet) @@ -155,7 +155,7 @@ func TestInitClient(t *testing.T) { for _, test := range tests { tf := func(t *testing.T) { h := &HttpRequester{} - h.Init(test.ctx, test.scenarioItem, test.proxy, false) + h.Init(test.ctx, test.scenarioItem, test.proxy, false, nil) transport := h.client.Transport.(*http.Transport) tls := transport.TLSClientConfig @@ -309,7 +309,7 @@ func TestInitRequest(t *testing.T) { for _, test := range tests { tf := func(t *testing.T) { h := &HttpRequester{} - err := h.Init(ctx, test.scenarioItem, p, false) + err := h.Init(ctx, test.scenarioItem, p, false, nil) if test.shouldErr { if err == nil { @@ -378,7 +378,7 @@ func TestSendOnDebugModePopulatesDebugInfo(t *testing.T) { h := &HttpRequester{} debug := true var proxy *url.URL - _ = h.Init(ctx, test.scenarioStep, proxy, debug) + _ = h.Init(ctx, test.scenarioStep, proxy, debug, nil) envs := map[string]interface{}{} res := h.Send(envs) @@ -443,7 +443,7 @@ func TestCaptureEnvShouldSetEmptyStringWhenReqFails(t *testing.T) { h := &HttpRequester{} debug := true var proxy *url.URL - _ = h.Init(ctx, test.scenarioStep, proxy, debug) + _ = h.Init(ctx, test.scenarioStep, proxy, debug, nil) envs := map[string]interface{}{} res := h.Send(envs) diff --git a/core/scenario/scripting/extraction/json.go b/core/scenario/scripting/extraction/json.go index 715466a4..58652f71 100644 --- a/core/scenario/scripting/extraction/json.go +++ b/core/scenario/scripting/extraction/json.go @@ -46,6 +46,11 @@ var unmarshalJsonCapture = func(result gjson.Result) (interface{}, error) { return jBoolSlice, err } + jObjectSlice := []map[string]interface{}{} + err = json.Unmarshal(bRaw, &jObjectSlice) + if err == nil { + return jObjectSlice, err + } } if result.IsBool() { diff --git a/core/scenario/scripting/extraction/json_test.go b/core/scenario/scripting/extraction/json_test.go index e0ef7683..38568c4f 100644 --- a/core/scenario/scripting/extraction/json_test.go +++ b/core/scenario/scripting/extraction/json_test.go @@ -238,6 +238,29 @@ func TestJsonExtract_JsonBoolArray(t *testing.T) { } } +func TestJsonExtract_ObjectArray(t *testing.T) { + expected := []map[string]interface{}{ + {"x": "cc"}, + } + payload := map[string]interface{}{ + "age": expected, + } + + byteSlice, _ := json.Marshal(payload) + je := jsonExtractor{} + val, _ := je.extractFromByteSlice(byteSlice, "age") + + if !reflect.DeepEqual(val, expected) { + t.Errorf("TestJsonExtract_JsonBoolArray failed, expected %#v, found %#v", expected, val) + } + + val, _ = je.extractFromString(string(byteSlice), "age") + + if !reflect.DeepEqual(val, expected) { + t.Errorf("TestJsonExtract_JsonBoolArray failed, expected %#v, found %#v", expected, val) + } +} + func TestJsonExtract_JsonPathNotFound(t *testing.T) { payload := map[string]interface{}{ "age": "24", diff --git a/core/scenario/scripting/injection/environment.go b/core/scenario/scripting/injection/environment.go index 280c6f45..89c9136f 100644 --- a/core/scenario/scripting/injection/environment.go +++ b/core/scenario/scripting/injection/environment.go @@ -3,9 +3,11 @@ package injection import ( "encoding/json" "fmt" + "math/rand" "reflect" "regexp" "strings" + "time" "go.ddosify.com/ddosify/core/types/regex" ) @@ -22,6 +24,7 @@ func (ei *EnvironmentInjector) Init() { ei.jr = regexp.MustCompile(regex.JsonEnvironmentVarRegex) ei.dr = regexp.MustCompile(regex.DynamicVariableRegex) ei.jdr = regexp.MustCompile(regex.JsonDynamicVariableRegex) + rand.Seed(time.Now().UnixNano()) } func (ei *EnvironmentInjector) getFakeData(key string) (interface{}, error) { @@ -57,11 +60,7 @@ func (ei *EnvironmentInjector) InjectEnv(text string, envs map[string]interface{ var err error truncated = truncateTag(string(s), regex.EnvironmentVariableRegex) - - env, ok := envs[truncated] - if !ok { - err = fmt.Errorf("env not found") - } + env, err = ei.getEnv(envs, truncated) if err == nil { switch env.(type) { @@ -91,11 +90,7 @@ func (ei *EnvironmentInjector) InjectEnv(text string, envs map[string]interface{ var err error truncated = truncateTag(string(s), regex.JsonEnvironmentVarRegex) - - env, ok := envs[truncated] - if !ok { - err = fmt.Errorf("env not found") - } + env, err = ei.getEnv(envs, truncated) if err == nil { mEnv, err := json.Marshal(env) @@ -105,7 +100,7 @@ func (ei *EnvironmentInjector) InjectEnv(text string, envs map[string]interface{ } errors = append(errors, - fmt.Errorf("%s could not be found in vars global and extracted from previous steps", truncated)) + fmt.Errorf("%s could not be found in vars global and extracted from previous steps: %v", truncated, err)) return s } @@ -114,7 +109,10 @@ func (ei *EnvironmentInjector) InjectEnv(text string, envs map[string]interface{ if json.Valid(bText) { if ei.jr.Match(bText) { replacedBytes := ei.jr.ReplaceAllFunc(bText, injectToJsonByteFunc) - return string(replacedBytes), nil + if len(errors) == 0 { + return string(replacedBytes), nil + } + return "", unifyErrors(errors) } } @@ -199,6 +197,41 @@ func (ei *EnvironmentInjector) InjectDynamic(text string) (string, error) { } +func (ei *EnvironmentInjector) getEnv(envs map[string]interface{}, key string) (interface{}, error) { + var err error + var val interface{} + + pickRand := strings.HasPrefix(key, "rand(") && strings.HasSuffix(key, ")") + if pickRand { + key = key[5 : len(key)-1] + } + + var exists bool + val, exists = envs[key] + if !exists { + err = fmt.Errorf("env not found") + } + + if pickRand { + switch v := val.(type) { + case []interface{}: + val = v[rand.Intn(len(v))] + case []string: + val = v[rand.Intn(len(v))] + case []bool: + val = v[rand.Intn(len(v))] + case []int: + val = v[rand.Intn(len(v))] + case []float64: + val = v[rand.Intn(len(v))] + default: + err = fmt.Errorf("can not perform rand() operation on non-array value") + } + } + + return val, err +} + func unifyErrors(errors []error) error { sb := strings.Builder{} diff --git a/core/scenario/scripting/injection/environment_test.go b/core/scenario/scripting/injection/environment_test.go index d643dd92..010d251d 100644 --- a/core/scenario/scripting/injection/environment_test.go +++ b/core/scenario/scripting/injection/environment_test.go @@ -108,3 +108,163 @@ func ExampleEnvironmentInjector() { fmt.Println(randInt) } } + +func TestRandomInjectionStringSlice(t *testing.T) { + replacer := EnvironmentInjector{} + replacer.Init() + + vals := []string{ + "Kenan", "Kursat", "Fatih", + } + + envs := map[string]interface{}{ + "vals": vals, + } + + val, err := replacer.getEnv(envs, "rand(vals)") + if err != nil { + t.Errorf("%v", err) + } + + found := false + + for _, n := range vals { + if reflect.DeepEqual(val, n) { + found = true + break + } + } + + if !found { + t.Errorf("rand method did not return one of the expecteds") + } +} + +func TestRandomInjectionBoolSlice(t *testing.T) { + replacer := EnvironmentInjector{} + replacer.Init() + + vals := []bool{ + true, false, true, + } + + envs := map[string]interface{}{ + "vals": vals, + } + + val, err := replacer.getEnv(envs, "rand(vals)") + if err != nil { + t.Errorf("%v", err) + } + + found := false + + for _, n := range vals { + if reflect.DeepEqual(val, n) { + found = true + break + } + } + + if !found { + t.Errorf("rand method did not return one of the expecteds") + } + +} + +func TestRandomInjectionIntSlice(t *testing.T) { + replacer := EnvironmentInjector{} + replacer.Init() + + vals := []int{ + 3, 55, 42, + } + + envs := map[string]interface{}{ + "vals": vals, + } + + val, err := replacer.getEnv(envs, "rand(vals)") + if err != nil { + t.Errorf("%v", err) + } + + found := false + + for _, n := range vals { + if reflect.DeepEqual(val, n) { + found = true + break + } + } + + if !found { + t.Errorf("rand method did not return one of the expecteds") + } + +} + +func TestRandomInjectionFloat64Slice(t *testing.T) { + replacer := EnvironmentInjector{} + replacer.Init() + + vals := []float64{ + 3.3, 55.23, 42.1, + } + + envs := map[string]interface{}{ + "vals": vals, + } + + val, err := replacer.getEnv(envs, "rand(vals)") + if err != nil { + t.Errorf("%v", err) + } + + found := false + + for _, n := range vals { + if reflect.DeepEqual(val, n) { + found = true + break + } + } + + if !found { + t.Errorf("rand method did not return one of the expecteds") + } + +} + +func TestRandomInjectionInterfaceSlice(t *testing.T) { + replacer := EnvironmentInjector{} + replacer.Init() + + vals := []interface{}{ + map[string]int{"s": 33}, + []string{"v", "c"}, + } + + envs := map[string]interface{}{ + "vals": vals, + } + + val, err := replacer.getEnv(envs, "rand(vals)") + if err != nil { + t.Errorf("%v", err) + } + + found := false + + for _, n := range vals { + if reflect.DeepEqual(val, n) { + found = true + break + } + } + + if !found { + t.Errorf("rand method did not return one of the expecteds") + } + +} diff --git a/core/scenario/service.go b/core/scenario/service.go index 336d7aa7..fb605592 100644 --- a/core/scenario/service.go +++ b/core/scenario/service.go @@ -48,8 +48,9 @@ type ScenarioService struct { clientMutex sync.Mutex debug bool - - injector *injection.EnvironmentInjector + ei *injection.EnvironmentInjector + indexMu sync.Mutex + iterIndex int } // NewScenarioService is the constructor of the ScenarioService. @@ -57,13 +58,25 @@ func NewScenarioService() *ScenarioService { return &ScenarioService{} } +func (s *ScenarioService) incrementIterIndex() { + s.indexMu.Lock() + defer s.indexMu.Unlock() + s.iterIndex++ +} + // Init initializes the ScenarioService.clients with the given types.Scenario and proxies. // Passes the given ctx to the underlying requestor so we are able to control the life of each request. -func (s *ScenarioService) Init(ctx context.Context, scenario types.Scenario, proxies []*url.URL, debug bool) (err error) { +func (s *ScenarioService) Init(ctx context.Context, scenario types.Scenario, + proxies []*url.URL, debug bool) (err error) { s.scenario = scenario s.ctx = ctx s.debug = debug s.clients = make(map[*url.URL][]scenarioItemRequester, len(proxies)) + + ei := &injection.EnvironmentInjector{} + ei.Init() + s.ei = ei + for _, p := range proxies { err = s.createRequesters(p) if err != nil { @@ -72,7 +85,7 @@ func (s *ScenarioService) Init(ctx context.Context, scenario types.Scenario, pro } vi := &injection.EnvironmentInjector{} vi.Init() - s.injector = vi + s.ei = vi return } @@ -84,6 +97,7 @@ func (s *ScenarioService) Do(proxy *url.URL, startTime time.Time) ( response = &types.ScenarioResult{StepResults: []*types.ScenarioStepResult{}} response.StartTime = startTime response.ProxyAddr = proxy + rand.Seed(time.Now().UnixNano()) requesters, e := s.getOrCreateRequesters(proxy) if e != nil { @@ -96,7 +110,10 @@ func (s *ScenarioService) Do(proxy *url.URL, startTime time.Time) ( envs[k] = v } // inject dynamic variables beforehand for each iteration - injectDynamicVars(s.injector, envs) + injectDynamicVars(s.ei, envs) + // pass a row from data for each iteration + s.enrichEnvFromData(envs) + s.incrementIterIndex() for _, sr := range requesters { res := sr.requester.Send(envs) @@ -126,6 +143,28 @@ func enrichEnvFromPrevStep(m1 map[string]interface{}, m2 map[string]interface{}) } } +func (s *ScenarioService) enrichEnvFromData(envs map[string]interface{}) { + var row map[string]interface{} + sb := strings.Builder{} + for key, csvData := range s.scenario.Data { + if csvData.Random { + row = csvData.Rows[rand.Intn(len(csvData.Rows))] + } else { + row = csvData.Rows[s.iterIndex%len(csvData.Rows)] + } + + for tag, v := range row { + sb.WriteString("data.") + sb.WriteString(key) + sb.WriteString(".") + sb.WriteString(tag) + // data.info.name + envs[sb.String()] = v + sb.Reset() + } + } +} + func (s *ScenarioService) Done() { for _, v := range s.clients { for _, r := range v { @@ -165,7 +204,7 @@ func (s *ScenarioService) createRequesters(proxy *url.URL) (err error) { }, ) - err = r.Init(s.ctx, si, proxy, s.debug) + err = r.Init(s.ctx, si, proxy, s.debug, s.ei) if err != nil { return } @@ -176,7 +215,10 @@ func (s *ScenarioService) createRequesters(proxy *url.URL) (err error) { func injectDynamicVars(vi *injection.EnvironmentInjector, envs map[string]interface{}) { dynamicRgx := regexp.MustCompile(regex.DynamicVariableRegex) for k, v := range envs { - vStr := v.(string) + vStr, isStr := v.(string) + if !isStr { + continue + } if dynamicRgx.MatchString(vStr) { injected, err := vi.InjectDynamic(vStr) if err != nil { diff --git a/core/scenario/service_test.go b/core/scenario/service_test.go index 2e77311a..4bdae6dc 100644 --- a/core/scenario/service_test.go +++ b/core/scenario/service_test.go @@ -46,7 +46,7 @@ type MockRequester struct { ReturnSend *types.ScenarioStepResult } -func (m *MockRequester) Init(ctx context.Context, s types.ScenarioStep, proxyAddr *url.URL, debug bool) (err error) { +func (m *MockRequester) Init(ctx context.Context, s types.ScenarioStep, proxyAddr *url.URL, debug bool, ei *injection.EnvironmentInjector) (err error) { m.InitCalled = true if m.FailInit { return fmt.Errorf(m.FailInitMsg) diff --git a/core/types/regex/regex.go b/core/types/regex/regex.go index 8a20d01d..67eb2d5d 100644 --- a/core/types/regex/regex.go +++ b/core/types/regex/regex.go @@ -3,5 +3,5 @@ package regex const DynamicVariableRegex = `\{{(_)[^}]+\}}` const JsonDynamicVariableRegex = `\"{{(_)[^}]+\}}"` -const EnvironmentVariableRegex = `\{{[^_]\w*\}}` -const JsonEnvironmentVarRegex = `\"{{[^_]\w*\}}"` +const EnvironmentVariableRegex = `\{{[^_][a-zA-Z0-9_().]*\}}` +const JsonEnvironmentVarRegex = `\"{{[^_][a-zA-Z0-9_().]*\}}"` diff --git a/core/types/scenario.go b/core/types/scenario.go index 561b246a..747b7d3e 100644 --- a/core/types/scenario.go +++ b/core/types/scenario.go @@ -70,6 +70,7 @@ func init() { type Scenario struct { Steps []ScenarioStep Envs map[string]interface{} + Data map[string]CsvData } func (s *Scenario) validate() error { @@ -206,6 +207,11 @@ type EnvCaptureConf struct { Key *string `json:"headerKey"` // headerKey } +type CsvData struct { + Rows []map[string]interface{} + Random bool +} + // Auth struct should be able to include all necessary authentication realated data for supportedAuthentications. type Auth struct { Type string