diff --git a/example/main.tf b/example/main.tf index a161d5c..95c8dc1 100644 --- a/example/main.tf +++ b/example/main.tf @@ -14,6 +14,8 @@ resource "starchitect_iac_pac" "demo_example" { iac_path = var.iac_path # pac_path = var.pac_path # pac_version = var.pac_version + threshold = var.threshold + log_path = var.log_path } variable "iac_path" { @@ -29,6 +31,18 @@ variable "pac_version" { default = "main" } +variable "threshold" { + description = "Minimum required security score (0-100)" + type = string + default = "50" +} + +variable "log_path" { + description = "Path to store log files" + type = string + default = "../logs" # Logs will be stored in ./logs directory +} + output "scan_result" { value = starchitect_iac_pac.demo_example.scan_result } diff --git a/resources/iac_pac.go b/resources/iac_pac.go index 5b7228d..d8ecca5 100644 --- a/resources/iac_pac.go +++ b/resources/iac_pac.go @@ -9,6 +9,9 @@ import ( "os" "os/exec" "path/filepath" + "strconv" + "strings" + "time" "github.com/hashicorp/terraform-plugin-framework/resource" resschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -25,8 +28,33 @@ type IACPACResourceModel struct { IACPath types.String `tfsdk:"iac_path"` PACPath types.String `tfsdk:"pac_path"` PACVersion types.String `tfsdk:"pac_version"` + LogPath types.String `tfsdk:"log_path"` ScanResult types.String `tfsdk:"scan_result"` Score types.String `tfsdk:"score"` + Threshold types.String `tfsdk:"threshold"` +} + +type RegulaRuleResult struct { + Controls []string `json:"controls"` + Families []string `json:"families"` + Filepath string `json:"filepath"` + InputType string `json:"input_type"` + Provider string `json:"provider"` + ResourceID string `json:"resource_id"` + ResourceType string `json:"resource_type"` + ResourceTags map[string]string `json:"resource_tags"` + RuleDescription string `json:"rule_description"` + RuleID string `json:"rule_id"` + RuleMessage string `json:"rule_message"` + RuleName string `json:"rule_name"` + RuleRawResult bool `json:"rule_raw_result"` + RuleResult string `json:"rule_result"` + RuleSeverity string `json:"rule_severity"` + RuleSummary string `json:"rule_summary"` +} + +type RegulaOutput struct { + RuleResults []RegulaRuleResult `json:"rule_results"` } func NewIACPACResource() resource.Resource { @@ -48,11 +76,54 @@ func (r *IACPACResource) ModifyPlan(ctx context.Context, req resource.ModifyPlan iacPath := plan.IACPath.ValueString() pacPath := plan.PACPath.ValueString() pacVersion := plan.PACVersion.ValueString() + threshold := plan.Threshold.ValueString() + logPath := plan.LogPath.ValueString() - scanResult, score := GetScanResult(iacPath, pacPath, pacVersion) + scanResult, score := GetScanResult(iacPath, pacPath, pacVersion, logPath) plan.ScanResult = types.StringValue(scanResult) plan.Score = types.StringValue(score) + // Check threshold if specified + if threshold != "" { + thresholdValue, err := strconv.ParseFloat(threshold, 64) + if err != nil { + resp.Diagnostics.AddError( + "Invalid threshold value", + fmt.Sprintf("Could not parse threshold value: %v", err), + ) + return + } + + // Extract score value + scoreStr := strings.TrimSpace(score) + parts := strings.Split(scoreStr, "Score: ") + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid score format", + fmt.Sprintf("Could not parse score from: %s", score), + ) + return + } + + scoreStr = strings.TrimSuffix(parts[1], " percent") + scoreValue, err := strconv.ParseFloat(scoreStr, 64) + if err != nil { + resp.Diagnostics.AddError( + "Invalid score value", + fmt.Sprintf("Could not parse score value: %v", err), + ) + return + } + + if scoreValue < thresholdValue { + resp.Diagnostics.AddError( + "Security Score Below Threshold", + fmt.Sprintf("Security score (%.2f%%) is below the required threshold (%.2f%%)", scoreValue, thresholdValue), + ) + return + } + } + diags = resp.Plan.Set(ctx, plan) resp.Diagnostics.Append(diags...) } @@ -77,6 +148,14 @@ func (r *IACPACResource) Schema(_ context.Context, _ resource.SchemaRequest, res Description: "default PAC version", Optional: true, }, + "log_path": resschema.StringAttribute{ + Description: "Path to store log files", + Optional: true, + }, + "threshold": resschema.StringAttribute{ + Description: "Minimum required security score (0-100)", + Optional: true, + }, "scan_result": resschema.StringAttribute{ Description: "Generated scan result", Computed: true, @@ -106,8 +185,9 @@ func (r *IACPACResource) Create(ctx context.Context, req resource.CreateRequest, iacPath := plan.IACPath.ValueString() pacPath := plan.PACPath.ValueString() pacVersion := plan.PACVersion.ValueString() + logPath := plan.LogPath.ValueString() - scanResult, score := GetScanResult(iacPath, pacPath, pacVersion) + scanResult, score := GetScanResult(iacPath, pacPath, pacVersion, logPath) plan.ScanResult = types.StringValue(scanResult) plan.Score = types.StringValue(score) @@ -126,8 +206,9 @@ func (r *IACPACResource) Read(ctx context.Context, req resource.ReadRequest, res iacPath := state.IACPath.ValueString() pacPath := state.PACPath.ValueString() pacVersion := state.PACVersion.ValueString() + logPath := state.LogPath.ValueString() - scanResult, score := GetScanResult(iacPath, pacPath, pacVersion) + scanResult, score := GetScanResult(iacPath, pacPath, pacVersion, logPath) state.ScanResult = types.StringValue(scanResult) state.Score = types.StringValue(score) @@ -146,8 +227,9 @@ func (r *IACPACResource) Update(ctx context.Context, req resource.UpdateRequest, iacPath := plan.IACPath.ValueString() pacPath := plan.PACPath.ValueString() pacVersion := plan.PACVersion.ValueString() + logPath := plan.LogPath.ValueString() - scanResult, score := GetScanResult(iacPath, pacPath, pacVersion) + scanResult, score := GetScanResult(iacPath, pacPath, pacVersion, logPath) plan.ScanResult = types.StringValue(scanResult) plan.Score = types.StringValue(score) @@ -164,47 +246,119 @@ func (r *IACPACResource) Delete(ctx context.Context, req resource.DeleteRequest, } } -type RegulaOutput struct { - Summary struct { - RuleResults struct { - Fail int `json:"FAIL"` - Pass int `json:"PASS"` - Waived int `json:"WAIVED"` - } `json:"rule_results"` - } `json:"summary"` +func formatRegulaOutput(regulaOutput RegulaOutput) string { + var formatted strings.Builder + + // Add timestamp + formatted.WriteString(fmt.Sprintf("Scan Time: %s\n", time.Now().Format(time.RFC3339))) + formatted.WriteString("====================\n\n") + + // Calculate summary + var passCount, failCount int + for _, rule := range regulaOutput.RuleResults { + if rule.RuleResult == "PASS" { + passCount++ + } else if rule.RuleResult == "FAIL" { + failCount++ + } + } + + // Add summary + formatted.WriteString("Summary:\n") + formatted.WriteString(fmt.Sprintf("PASSED: %d\n", passCount)) + formatted.WriteString(fmt.Sprintf("FAILED: %d\n", failCount)) + formatted.WriteString("\nDetailed Results:\n") + formatted.WriteString("----------------\n") + + for _, rule := range regulaOutput.RuleResults { + formatted.WriteString(fmt.Sprintf("\nRule ID: %s\n", rule.RuleID)) + formatted.WriteString(fmt.Sprintf("Name: %s\n", rule.RuleName)) + formatted.WriteString(fmt.Sprintf("Result: %s\n", rule.RuleResult)) + formatted.WriteString(fmt.Sprintf("Severity: %s\n", rule.RuleSeverity)) + formatted.WriteString(fmt.Sprintf("Summary: %s\n", rule.RuleSummary)) + formatted.WriteString(fmt.Sprintf("Description: %s\n", rule.RuleDescription)) + + if rule.ResourceType != "" { + formatted.WriteString(fmt.Sprintf("Resource Type: %s\n", rule.ResourceType)) + } + if rule.ResourceID != "" { + formatted.WriteString(fmt.Sprintf("Resource ID: %s\n", rule.ResourceID)) + } + if rule.RuleMessage != "" { + formatted.WriteString(fmt.Sprintf("Message: %s\n", rule.RuleMessage)) + } + + if len(rule.Controls) > 0 { + formatted.WriteString("Controls:\n") + for _, control := range rule.Controls { + formatted.WriteString(fmt.Sprintf(" - %s\n", control)) + } + } + + if len(rule.Families) > 0 { + formatted.WriteString("Families:\n") + for _, family := range rule.Families { + formatted.WriteString(fmt.Sprintf(" - %s\n", family)) + } + } + + formatted.WriteString("---\n") + } + + return formatted.String() } -func calculateScore(filePath string) string { - // Open the file - file, err := os.Open(filePath) - if err != nil { - return fmt.Sprintf("failed to open file: %v", err) +func writeToLogFiles(rawOutput string, formattedOutput string, logPath string) error { + timestamp := time.Now().Format("20060102_150405") + + // Create log directory if it doesn't exist + if logPath != "" { + if err := os.MkdirAll(logPath, 0755); err != nil { + return fmt.Errorf("failed to create log directory: %v", err) + } } - defer file.Close() - // Decode the JSON - var regulaOutput RegulaOutput - decoder := json.NewDecoder(file) - if err := decoder.Decode(®ulaOutput); err != nil { - return fmt.Sprintf("failed to decode JSON: %v", err) + // Write raw output to JSON file + rawFileName := fmt.Sprintf("%s_starchitect_raw.json", timestamp) + if logPath != "" { + rawFileName = filepath.Join(logPath, rawFileName) + } + if err := os.WriteFile(rawFileName, []byte(rawOutput), 0644); err != nil { + return fmt.Errorf("failed to write raw output: %v", err) + } + + // Write formatted summary to log file + summaryFileName := fmt.Sprintf("%s_starchitect_summary.log", timestamp) + if logPath != "" { + summaryFileName = filepath.Join(logPath, summaryFileName) + } + if err := os.WriteFile(summaryFileName, []byte(formattedOutput), 0644); err != nil { + return fmt.Errorf("failed to write summary: %v", err) } - // Get counts - passCount := regulaOutput.Summary.RuleResults.Pass - failCount := regulaOutput.Summary.RuleResults.Fail + return nil +} + +func calculateScore(regulaOutput RegulaOutput) string { + var passCount, failCount int + for _, rule := range regulaOutput.RuleResults { + if rule.RuleResult == "PASS" { + passCount++ + } else if rule.RuleResult == "FAIL" { + failCount++ + } + } - // Calculate the score total := passCount + failCount if total == 0 { return "no PASS or FAIL results found" } score := (float64(passCount) / float64(total)) * 100 - return fmt.Sprintf("PASSED: %v FAILED: %v Score: %v percent", passCount, failCount, score) + return fmt.Sprintf("PASSED: %d FAILED: %d Score: %.2f percent", passCount, failCount, score) } -func GetScanResult(iacPath, pacPath, pacVersion string) (string, string) { - +func GetScanResult(iacPath, pacPath, pacVersion, logPath string) (string, string) { if pacPath == "" { // Step 1: Create a temporary directory tempCloneDir, err := os.MkdirTemp("", "pac-clone-*") @@ -256,10 +410,30 @@ func GetScanResult(iacPath, pacPath, pacVersion string) (string, string) { err = nil } + // Read the raw output content, err := os.ReadFile(outputFile) if err != nil { return fmt.Sprintf("Error reading output file: %s %v\n", outputFile, err), "" } - return string(content), calculateScore(outputFile) + rawOutput := string(content) + + // Parse the JSON content + var regulaOutput RegulaOutput + if err := json.Unmarshal(content, ®ulaOutput); err != nil { + return fmt.Sprintf("Error parsing JSON output: %v\n", err), "" + } + + // Calculate score + score := calculateScore(regulaOutput) + + // Format the summary output + formattedOutput := formatRegulaOutput(regulaOutput) + + // Write both outputs to separate files + if err := writeToLogFiles(rawOutput, formattedOutput, logPath); err != nil { + log.Printf("Warning: Failed to write to log files: %v", err) + } + + return formattedOutput, score } diff --git a/resources/iac_pac_test.go b/resources/iac_pac_test.go index 3aa9113..f3c7126 100644 --- a/resources/iac_pac_test.go +++ b/resources/iac_pac_test.go @@ -1,34 +1,43 @@ package resources import ( - "fmt" "testing" ) func TestGetScanResult(t *testing.T) { - type args struct { + tests := []struct { + name string iacPath string pacPath string pacVersion string - } - tests := []struct { - name string - args args - want string + logPath string + wantErr bool }{ { - name: "success", - args: args{ - iacPath: "../testdata/valid_iac", - pacPath: "../testdata/valid_pac", - }, + name: "Valid IAC and PAC paths", + iacPath: "../testdata/valid_iac", + pacPath: "../testdata/valid_pac", + pacVersion: "", + logPath: "test_logs", + wantErr: false, + }, + { + name: "Invalid IAC path", + iacPath: "../testdata/invalid_path", + pacPath: "../testdata/valid_pac", + pacVersion: "", + logPath: "test_logs", + wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, score := GetScanResult(tt.args.iacPath, tt.args.pacPath, tt.args.pacVersion) - fmt.Println(result) - fmt.Println(score) + result, score := GetScanResult(tt.iacPath, tt.pacPath, tt.pacVersion, tt.logPath) + if (result == "" || score == "") != tt.wantErr { + t.Errorf("GetScanResult() error = %v, wantErr %v", result, tt.wantErr) + return + } }) } }