diff --git a/analyzer/analyzer.go b/analyzer/analyzer.go index a0f5a58..a339330 100644 --- a/analyzer/analyzer.go +++ b/analyzer/analyzer.go @@ -1,6 +1,14 @@ // Package analyzer provides an interface for analyzing source code for compatibility issues. package analyzer +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" +) + // Analyzer represents the interface for the analyzer. type Analyzer interface { // Analyze analyzes the provided source code and returns any issues found. @@ -26,6 +34,62 @@ type Issue struct { Severity IssueSeverity `json:"severity"` Impact string `json:"impact,omitempty"` Reference string `json:"reference,omitempty"` + Hash string `json:"hash"` +} + +// Opt is a functional option for configuring an Issue. +type Opt func(*Issue) + +// WithImpact sets the impact of the issue. +func WithImpact(impact string) Opt { + return func(i *Issue) { + i.Impact = impact + } +} + +// WithReference sets the reference for the issue. +func WithReference(reference string) Opt { + return func(i *Issue) { + i.Reference = reference + } +} + +// WithCallStack sets the call stack for the issue. +func WithCallStack(callStack *CallStack) Opt { + return func(i *Issue) { + i.CallStack = callStack + } +} + +// WithSeverity sets the severity for the issue. +func WithSeverity(severity IssueSeverity) Opt { + return func(i *Issue) { + i.Severity = severity + } +} + +// WithMessage sets the message for the issue. +func WithMessage(message string) Opt { + return func(i *Issue) { + i.Message = message + } +} + +// NewIssue creates a new issue with the provided severity, message, and source. +func NewIssue(opts ...Opt) *Issue { + issue := new(Issue) + for _, opt := range opts { + opt(issue) + } + issue.PopulateHash() + return issue +} + +// PopulateHash generates a hash for the issue and populates it into the issue +func (i *Issue) PopulateHash() { + h := sha256.New() + _, _ = fmt.Fprintf(h, "%s:%s", i.Message, i.CallStack.Trace()) + i.Hash = hex.EncodeToString(h.Sum(nil)) } // CallStack represents a location in the code where the issue originates. @@ -37,6 +101,14 @@ type CallStack struct { CallStack *CallStack `json:"callStack,omitempty"` // The trace of calls leading to this source. } +func (src *CallStack) Trace() string { + sub := src.Function + if src.CallStack != nil { + sub = fmt.Sprintf("%s:%s", sub, src.CallStack.Trace()) + } + return sub +} + // Copy creates a deep copy of the CallStack. func (src *CallStack) Copy() *CallStack { if src == nil { @@ -66,3 +138,14 @@ func (src *CallStack) AddCallStack(stack *CallStack) { } src.CallStack.AddCallStack(stack) } + +// SortIssues sorts the issues by severity and hash. +func SortIssues(issues []*Issue) []*Issue { + sort.Slice(issues, func(i, j int) bool { + if issues[i].Severity != issues[j].Severity { + return issues[i].Severity < issues[j].Severity + } + return strings.Compare(issues[i].Hash, issues[j].Hash) < 0 + }) + return issues +} diff --git a/analyzer/opcode/opcode.go b/analyzer/opcode/opcode.go index 8e23166..f3d7afa 100644 --- a/analyzer/opcode/opcode.go +++ b/analyzer/opcode/opcode.go @@ -45,19 +45,23 @@ func (op *opcode) Analyze(path string, withTrace bool) ([]*analyzer.Issue, error if err != nil { // non-reachable portion ignored continue } - issue := &analyzer.Issue{ - Severity: analyzer.IssueSeverityCritical, - CallStack: source, - Message: fmt.Sprintf("Potential Incompatible Opcode Detected: Opcode: %s, Funct: %s", - instruction.OpcodeHex(), instruction.Funct()), + + opts := []analyzer.Opt{ + analyzer.WithSeverity(analyzer.IssueSeverityCritical), + analyzer.WithCallStack(source), + analyzer.WithMessage( + fmt.Sprintf("Potential Incompatible Opcode Detected: Opcode: %s, Funct: %s", + instruction.OpcodeHex(), instruction.Funct()), + ), } if common.ShouldIgnoreSource(source, op.profile.IgnoredFunctions) { - issue.Severity = analyzer.IssueSeverityWarning + opts = append(opts, analyzer.WithSeverity(analyzer.IssueSeverityWarning)) } if !withTrace { source.CallStack = nil } - issues = append(issues, issue) + + issues = append(issues, analyzer.NewIssue(opts...)) } } } diff --git a/analyzer/syscall/asm_syscall.go b/analyzer/syscall/asm_syscall.go index e6d61dc..e34b11d 100644 --- a/analyzer/syscall/asm_syscall.go +++ b/analyzer/syscall/asm_syscall.go @@ -80,13 +80,14 @@ func (a *asmSyscallAnalyser) Analyze(path string, withTrace bool) ([]*analyzer.I if !withTrace { source.CallStack = nil } - issues = append(issues, &analyzer.Issue{ - Severity: severity, - Message: message, - CallStack: source, - Impact: potentialImpactMsg, - Reference: analyzerWorkingPrincipalURL, - }) + + issues = append(issues, analyzer.NewIssue( + analyzer.WithImpact(potentialImpactMsg), + analyzer.WithReference(analyzerWorkingPrincipalURL), + analyzer.WithCallStack(source), + analyzer.WithSeverity(severity), + analyzer.WithMessage(message), + )) } } } diff --git a/analyzer/syscall/go_syscall.go b/analyzer/syscall/go_syscall.go index db44e0b..61a607a 100644 --- a/analyzer/syscall/go_syscall.go +++ b/analyzer/syscall/go_syscall.go @@ -63,11 +63,14 @@ func (a *goSyscallAnalyser) Analyze(path string, withTrace bool) ([]*analyzer.Is message = fmt.Sprintf("Potential NOOP Syscall Detected: %d", syscll.num) } - issues = append(issues, &analyzer.Issue{ - Severity: severity, - CallStack: stackTrace, - Message: message, - }) + issues = append( + issues, + analyzer.NewIssue( + analyzer.WithSeverity(severity), + analyzer.WithCallStack(stackTrace), + analyzer.WithMessage(message), + ), + ) } return issues, nil diff --git a/cmd/analyze.go b/cmd/analyze.go index fedf4db..7a938db 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -8,6 +8,7 @@ import ( "github.com/ChainSafe/vm-compat/analyzer" "github.com/ChainSafe/vm-compat/analyzer/opcode" "github.com/ChainSafe/vm-compat/analyzer/syscall" + "github.com/ChainSafe/vm-compat/comparer" "github.com/ChainSafe/vm-compat/disassembler" "github.com/ChainSafe/vm-compat/disassembler/manager" "github.com/ChainSafe/vm-compat/profile" @@ -50,6 +51,12 @@ var ( Required: false, Value: false, } + + BaselineReport = &cli.StringFlag{ + Name: "baseline-report", + Usage: "Path to the baseline report", + Required: false, + } ) func CreateAnalyzeCommand(action cli.ActionFunc) *cli.Command { @@ -65,6 +72,7 @@ func CreateAnalyzeCommand(action cli.ActionFunc) *cli.Command { FormatFlag, ReportOutputPathFlag, TraceFlag, + BaselineReport, }, } } @@ -84,6 +92,7 @@ func AnalyzeCompatibility(ctx *cli.Context) error { reportOutputPath := ctx.Path(ReportOutputPathFlag.Name) analysisType := ctx.String(AnalysisTypeFlag.Name) withTrace := ctx.Bool(TraceFlag.Name) + baselineReport := ctx.Path(BaselineReport.Name) disassemblyPath, err = disassemble(prof, source, disassemblyPath) if err != nil { @@ -95,6 +104,14 @@ func AnalyzeCompatibility(ctx *cli.Context) error { return fmt.Errorf("analysis failed: %w", err) } + if baselineReport != "" { + err = compareReport(issues, format, reportOutputPath, prof, baselineReport) + if err != nil { + return fmt.Errorf("error comparing reports: %w", err) + } + return nil + } + if err := writeReport(issues, format, reportOutputPath, prof); err != nil { return fmt.Errorf("unable to write report: %w", err) } @@ -168,3 +185,25 @@ func writeReport(issues []*analyzer.Issue, format, outputPath string, prof *prof return rendererInstance.Render(issues, output) } + +func compareReport(issues []*analyzer.Issue, format, outputPath string, prof *profile.VMProfile, baselineReport string) error { + absPath, err := filepath.Abs(baselineReport) + if err != nil { + return fmt.Errorf("error determining absolute path of baseline report: %w", err) + } + + baselineIssuesFile, err := os.OpenFile(absPath, os.O_RDONLY, 0600) + if err != nil { + return fmt.Errorf("error loading baseline report: %w", err) + } + defer func() { + _ = baselineIssuesFile.Close() + }() + + issues, err = comparer.NewJSONComparer().CompareReport(issues, baselineIssuesFile) + if err != nil { + return fmt.Errorf("error comparing reports: %w", err) + } + + return writeReport(issues, format, outputPath, prof) +} diff --git a/comparer/json.go b/comparer/json.go new file mode 100644 index 0000000..20dfaaa --- /dev/null +++ b/comparer/json.go @@ -0,0 +1,52 @@ +// Package comparer provides a way to compare current issues with the baseline report. +package comparer + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/ChainSafe/vm-compat/analyzer" +) + +// Comparer defines the interface for comparing the issues +type Comparer interface { + CompareReport(issues []*analyzer.Issue, reader io.Reader) ([]*analyzer.Issue, error) + + Format() string +} + +type jsonComparer struct{} + +func NewJSONComparer() Comparer { + return &jsonComparer{} +} + +func (r *jsonComparer) CompareReport(issues []*analyzer.Issue, reader io.Reader) ([]*analyzer.Issue, error) { + baseLineIssues := make([]*analyzer.Issue, 0) + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("error reading data while decoding issues: %w", err) + } + err = json.Unmarshal(data, &baseLineIssues) + if err != nil { + return nil, fmt.Errorf("error decoding issues: %w", err) + } + + baselineIssuesMap := make(map[string]*analyzer.Issue) + for _, issue := range baseLineIssues { + baselineIssuesMap[issue.Hash] = issue + } + + newIssues := make([]*analyzer.Issue, 0) + for _, newIssue := range issues { + if _, ok := baselineIssuesMap[newIssue.Hash]; !ok { + newIssues = append(newIssues, newIssue) + } + } + return newIssues, nil +} + +func (r *jsonComparer) Format() string { + return "json" +} diff --git a/renderer/json.go b/renderer/json.go index 66e17e3..7641355 100644 --- a/renderer/json.go +++ b/renderer/json.go @@ -16,7 +16,7 @@ func NewJSONRenderer() Renderer { } func (r *JSONRenderer) Render(issues []*analyzer.Issue, output io.Writer) error { - return json.NewEncoder(output).Encode(issues) + return json.NewEncoder(output).Encode(analyzer.SortIssues(issues)) } func (r *JSONRenderer) Format() string {