commands/credential_scan.go (417 lines of code) (raw):

package commands import ( "encoding/json" "flag" "fmt" "log" "os" "path" "path/filepath" "strings" "time" "github.com/azure/armstrong/coverage" "github.com/azure/armstrong/hcl" "github.com/sirupsen/logrus" ) type CredentialScanCommand struct { workingDir string outputDir string swaggerRepoPath string swaggerIndexFile string verbose bool } func (c *CredentialScanCommand) flags() *flag.FlagSet { fs := defaultFlagSet("test") fs.BoolVar(&c.verbose, "v", false, "whether show terraform logs") fs.StringVar(&c.workingDir, "working-dir", "", "path to directory containing Terraform configuration files") fs.StringVar(&c.outputDir, "output-dir", "", "path to directory to save output files, default to working-dir") fs.StringVar(&c.swaggerRepoPath, "swagger-repo", "", "path to the swagger repo specification directory") fs.StringVar(&c.swaggerIndexFile, "swagger-index-file", "", "path to the swagger index file, omit this will use the online swagger index file or locally build index") fs.Usage = func() { logrus.Error(c.Help()) } return fs } func (c CredentialScanCommand) Help() string { helpText := ` Usage: armstrong credscan [-v] [-working-dir <path to directory containing Terraform configuration files>] [-swagger-repo <path to the swagger repo specification directory>] [-swagger-index-file <path to the swagger index file>] [-output-dir <path to directory to save output files>] ` + c.Synopsis() + "\n\n" + helpForFlags(c.flags()) return strings.TrimSpace(helpText) } func (c CredentialScanCommand) Synopsis() string { return "Scan the credential in given Terraform configuration" } func (c CredentialScanCommand) Run(args []string) int { f := c.flags() if err := f.Parse(args); err != nil { logrus.Errorf("Error parsing command-line flags: %s", err) return 1 } if c.verbose { log.SetOutput(os.Stdout) logrus.SetLevel(logrus.DebugLevel) logrus.Infof("verbose mode enabled") } return c.Execute() } func (c CredentialScanCommand) Execute() int { wd, err := os.Getwd() if err != nil { logrus.Errorf("failed to get working directory: %+v", err) return 1 } if c.workingDir != "" { wd, err = filepath.Abs(c.workingDir) if err != nil { logrus.Errorf("working directory is invalid: %+v", err) return 1 } } if c.swaggerRepoPath != "" { c.swaggerRepoPath, err = filepath.Abs(c.swaggerRepoPath) if err != nil { logrus.Errorf("swagger repo path %q is invalid: %+v", c.swaggerRepoPath, err) return 1 } if _, err := os.Stat(c.swaggerRepoPath); os.IsNotExist(err) { logrus.Errorf("swagger repo path %q is invalid: path does not exist", c.swaggerRepoPath) return 1 } c.swaggerRepoPath = strings.TrimSuffix(c.swaggerRepoPath, "/") if !strings.HasSuffix(c.swaggerRepoPath, "specification") { logrus.Errorf("swagger repo path %q is invalid: must point to \"specification\", e.g., /home/projects/azure-rest-api-specs/specification", c.swaggerRepoPath) return 1 } c.swaggerRepoPath += "/" } if c.swaggerIndexFile != "" { c.swaggerIndexFile, err = filepath.Abs(c.swaggerIndexFile) if err != nil { logrus.Errorf("swagger index file path %q is invalid: %+v", c.swaggerIndexFile, err) return 1 } if _, err := os.Stat(c.swaggerIndexFile); os.IsNotExist(err) { logrus.Infof("swagger index file %q does not exist, will try to build or download index", c.swaggerIndexFile) } } outputDir := wd if c.outputDir != "" { outputDir, err = filepath.Abs(c.outputDir) if err != nil { logrus.Errorf("output directory is invalid: %+v", err) return 1 } } tfFiles, err := hcl.FindTfFiles(wd) if err != nil { logrus.Errorf("failed to find tf files for %q: %+v", wd, err) return 1 } if len(*tfFiles) == 0 { logrus.Warnf("no tf file found in %q", wd) } logrus.Infof("find %v tf file(s) under %s", len(*tfFiles), wd) azapiResources := make([]hcl.AzapiResource, 0) vars := make(map[string]hcl.Variable, 0) azureProviders := make([]hcl.AzureProvider, 0) for _, tfFile := range *tfFiles { f, err := hcl.ParseHclFile(tfFile) if err != nil { logrus.Errorf("failed to parse hcl file %q: %+v", tfFile, err) return 1 } azapiResourceInFile, err := hcl.ParseAzapiResource(*f) if err != nil { logrus.Errorf("failed to parse azapi resource for %q: %+v", tfFile, err) return 1 } azapiResources = append(azapiResources, *azapiResourceInFile...) varsInFile, err := hcl.ParseVariables(*f) if err != nil { logrus.Errorf("failed to parse variables for %q: %+v", tfFile, err) return 1 } for k, v := range *varsInFile { vars[k] = v } azureProvidersInFile, err := hcl.ParseAzureProvider(*f) if err != nil { logrus.Errorf("failed to parse azure provider for %q: %+v", tfFile, err) return 1 } azureProviders = append(azureProviders, *azureProvidersInFile...) } credScanErrors := make([]CredScanError, 0) for _, azureProvider := range azureProviders { if v := azureProvider.SubscriptionId; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "subscription_id", v, vars)...) } if v := azureProvider.TenantId; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "tenant_id", v, vars)...) } if v := azureProvider.AuxiliaryTenantIds; len(v) > 0 { for i, tenant_id := range v { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, fmt.Sprintf("auxiliary_tenant_ids[%v]", i), tenant_id, vars)...) } } if v := azureProvider.AuxiliaryTenantIdsString; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "auxiliary_tenant_ids", v, vars)...) } if v := azureProvider.ClientId; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "client_id", v, vars)...) } if v := azureProvider.ClientCertificate; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "client_certificate", v, vars)...) } if v := azureProvider.ClientCertificatePassword; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "client_certificate_password", v, vars)...) } if v := azureProvider.ClientSecret; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "client_secret", v, vars)...) } if v := azureProvider.OidcRequestToken; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "oidc_request_token", v, vars)...) } if v := azureProvider.OidcToken; v != "" { credScanErrors = append(credScanErrors, checkAzureProviderSecret(azureProvider, "oidc_token", v, vars)...) } } for _, azapiResource := range azapiResources { logrus.Infof("scaning azapi_resource.%s(%s)", azapiResource.Name, azapiResource.Type) if azapiResource.Body == "" { continue } var body interface{} err = json.Unmarshal([]byte(azapiResource.Body), &body) if err != nil { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("failed to unmarshal body: %+v", err), "", ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) continue } mockedResourceId, apiVersion := coverage.MockResourceIDFromType(azapiResource.Type) logrus.Infof("azapi_resource.%s(%s): mocked possible resource ID: %s, API version: %s", azapiResource.Name, azapiResource.Type, mockedResourceId, apiVersion) var swaggerModel *coverage.SwaggerModel if c.swaggerRepoPath != "" { logrus.Infof("scan based on local swagger repo: %s", c.swaggerRepoPath) swaggerModel, err = coverage.GetModelInfoFromLocalIndex(mockedResourceId, apiVersion, "PUT", c.swaggerRepoPath, c.swaggerIndexFile) if err != nil { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("fail to find swagger model from local swagger with possible resource ID(%s) API version(%s): %+v", mockedResourceId, apiVersion, err), "", ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) continue } } else { swaggerModel, err = coverage.GetModelInfoFromIndex(mockedResourceId, apiVersion, "PUT", c.swaggerIndexFile) if err != nil { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("fail to find swagger model with possible resource ID(%s) API version(%s): %+v", mockedResourceId, apiVersion, err), "", ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) continue } } if swaggerModel == nil { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("unable to find swagger model with possible resource ID(%s) API version(%s)", mockedResourceId, apiVersion), "", ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) continue } logrus.Infof("find swagger model for azapi_resource.%s(%s): %+v", azapiResource.Name, azapiResource.Type, *swaggerModel) model, err := coverage.Expand(swaggerModel.ModelName, swaggerModel.SwaggerPath) if err != nil { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("failed to expand model: %+v", err), "", ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) continue } secrets := make(map[string]string) model.CredScan(body, secrets) logrus.Infof("find secrets for azapi_resource.%s(%s): %+v", azapiResource.Name, azapiResource.Type, secrets) for k, v := range secrets { if !strings.HasPrefix(v, "$") || strings.HasPrefix(v, "$local.") { credScanErr := makeCredScanError( azapiResource, "cannot use plain text or 'local' for secret, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to hide the secret values", k, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) continue } if strings.HasPrefix(v, "$var.") { varName := strings.TrimPrefix(v, "$var.") varName = strings.Split(varName, ".")[0] theVar, ok := vars[varName] if !ok { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("variable %q was not found, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to set the variable for secret values", varName), k, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) continue } if theVar.HasDefault { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("variable %q (%v:%v) used in secret field but has a default value, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to set the variable for secret values", varName, theVar.FileName, theVar.LineNumber), k, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) } if !theVar.IsSensitive { credScanErr := makeCredScanError( azapiResource, fmt.Sprintf("variable %q (%v:%v) used in secret field but is not marked as sensitive, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to set the variable for secret values", varName, theVar.FileName, theVar.LineNumber), k, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) } } } } storeCredScanErrors(outputDir, credScanErrors) return 0 } type CredScanError struct { FileName string `json:"file_name"` Name string `json:"name"` Type string `json:"type"` PropertyName string `json:"property_name"` ErrorMessage string `json:"error_message"` LineNumber int `json:"line_number"` } func makeCredScanError(azapiResource hcl.AzapiResource, errMessage, propertyName string) CredScanError { result := CredScanError{ FileName: azapiResource.FileName, LineNumber: azapiResource.LineNumber, Name: fmt.Sprintf("azapi_resource.%s", azapiResource.Name), Type: azapiResource.Type, ErrorMessage: errMessage, } if propertyName != "" { result.PropertyName = propertyName } return result } func makeCredScanErrorForProvider(azureProvider hcl.AzureProvider, errMessage, propertyName string) CredScanError { result := CredScanError{ FileName: azureProvider.FileName, LineNumber: azureProvider.LineNumber, Name: azureProvider.Name(), Type: "provider", ErrorMessage: errMessage, } if propertyName != "" { result.PropertyName = propertyName } return result } func (e CredScanError) Error() string { return fmt.Sprintf("%s:%d %s(%s) --%s: %s", e.FileName, e.LineNumber, e.Name, e.Type, e.PropertyName, e.ErrorMessage) } func storeCredScanErrors(wd string, credScanErrors []CredScanError) { reportDir := fmt.Sprintf("armstrong_credscan_%s", time.Now().Format(time.Stamp)) reportDir = strings.ReplaceAll(reportDir, ":", "") reportDir = strings.ReplaceAll(reportDir, " ", "_") reportDir = path.Join(wd, reportDir) err := os.Mkdir(reportDir, 0755) if err != nil { logrus.Fatalf("error creating report dir %s: %+v", reportDir, err) } markdownFileName := "errors.md" credScanErrorsMarkdown := ` | File Name | Line Number | Name | Type | Property Name | Error Message | | --- | --- | --- | --- | --- | --- | ` for _, r := range credScanErrors { credScanErrorsMarkdown += fmt.Sprintf("| %s | %d | %s | %s | %s | %s |\n", r.FileName, r.LineNumber, r.Name, r.Type, r.PropertyName, r.ErrorMessage) } markdownFileName = path.Join(reportDir, markdownFileName) err = os.WriteFile(markdownFileName, []byte(credScanErrorsMarkdown), 0644) if err != nil { logrus.Errorf("failed to save markdown report to %s: %+v", markdownFileName, err) } else { logrus.Infof("markdown report saved to %s", markdownFileName) } jsonFileName := "errors.json" jsonContent, err := json.MarshalIndent(credScanErrors, "", " ") if err != nil { logrus.Errorf("failed to marshal json content %+v: %+v", credScanErrors, err) } jsonFileName = path.Join(reportDir, jsonFileName) err = os.WriteFile(jsonFileName, jsonContent, 0644) if err != nil { logrus.Errorf("failed to save json report to %s: %+v", jsonFileName, err) } else { logrus.Infof("json report saved to %s", jsonFileName) } } func checkAzureProviderSecret(azureProvider hcl.AzureProvider, propertyName, propertyValue string, vars map[string]hcl.Variable) []CredScanError { credScanErrors := make([]CredScanError, 0) if !strings.HasPrefix(propertyValue, "$") || strings.HasPrefix(propertyValue, "$local.") { credScanErr := makeCredScanErrorForProvider( azureProvider, "cannot use plain text or 'local' for secret, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to hide the secret values", propertyName, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) return credScanErrors } if strings.HasPrefix(propertyValue, "$var.") { varName := strings.TrimPrefix(propertyValue, "$var.") varName = strings.Split(varName, ".")[0] theVar, ok := vars[varName] if !ok { credScanErr := makeCredScanErrorForProvider( azureProvider, fmt.Sprintf("variable %q was not found, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to set the variable for secret values", varName), propertyName, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) return credScanErrors } if theVar.HasDefault { credScanErr := makeCredScanErrorForProvider( azureProvider, fmt.Sprintf("variable %q (%v:%v) used in secret field but has a default value, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to set the variable for secret values", varName, theVar.FileName, theVar.LineNumber), propertyName, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) } if !theVar.IsSensitive { credScanErr := makeCredScanErrorForProvider( azureProvider, fmt.Sprintf("variable %q (%v:%v) used in secret field but is not marked as sensitive, please follow https://github.com/Azure/armstrong/blob/main/docs/guidance-for-api-test.md#4-q-i-have-some-sensitive-information-in-the-test-case-how-to-hide-it to set the variable for secret values", varName, theVar.FileName, theVar.LineNumber), propertyName, ) credScanErrors = append(credScanErrors, credScanErr) logrus.Error(credScanErr) } } return credScanErrors }