197 lines
6.1 KiB
Go
197 lines
6.1 KiB
Go
package uiaselector
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
StrictExitOK = 0
|
|
StrictExitReportGenerationFailure = 1
|
|
StrictExitUnmatched = 2
|
|
StrictExitAmbiguous = 3
|
|
StrictExitInvalidCatalog = 4
|
|
)
|
|
|
|
type Report struct {
|
|
OK bool `json:"ok"`
|
|
SourceDump string `json:"source_dump"`
|
|
CatalogSize int `json:"catalog_size"`
|
|
MatchedCount int `json:"matched_count"`
|
|
UnmatchedCount int `json:"unmatched_count"`
|
|
AmbiguousCount int `json:"ambiguous_count"`
|
|
GeneratedAtUTC string `json:"generated_at_utc"`
|
|
Selectors []SelectorReport `json:"selectors"`
|
|
Warnings []string `json:"warnings"`
|
|
Error *ReportError `json:"error,omitempty"`
|
|
}
|
|
|
|
type ReportError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type SelectorReport struct {
|
|
SelectorID string `json:"selector_id"`
|
|
Description string `json:"description"`
|
|
AllowedUse string `json:"allowed_use"`
|
|
StabilityScore int `json:"stability_score"`
|
|
Evidence Evidence `json:"evidence"`
|
|
Matched bool `json:"matched"`
|
|
MatchCount int `json:"match_count"`
|
|
Matches []NodeMatch `json:"matches"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
func RunCatalogReport(sourceDump string, root *Node, catalog []Selector, generatedAt time.Time) Report {
|
|
report := Report{
|
|
OK: true,
|
|
SourceDump: sourceDump,
|
|
CatalogSize: len(catalog),
|
|
GeneratedAtUTC: generatedAt.UTC().Format(time.RFC3339),
|
|
Warnings: []string{},
|
|
}
|
|
if root == nil {
|
|
report.OK = false
|
|
report.Error = &ReportError{Code: "NO_ROOT", Message: "UIA dump root is nil"}
|
|
return report
|
|
}
|
|
|
|
for _, selector := range catalog {
|
|
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
|
report.OK = false
|
|
report.Error = &ReportError{Code: "INVALID_CATALOG", Message: "selector is not read-only: " + selector.ID}
|
|
return report
|
|
}
|
|
match := MatchSelector(root, catalog, selector.ID)
|
|
selectorReport := SelectorReport{
|
|
SelectorID: selector.ID,
|
|
Description: selector.Description,
|
|
AllowedUse: selector.AllowedUse,
|
|
StabilityScore: selector.StabilityScore,
|
|
Evidence: selector.Evidence,
|
|
Matched: match.Matched,
|
|
MatchCount: match.MatchCount,
|
|
Matches: match.Matches,
|
|
Warnings: append([]string{}, match.Warnings...),
|
|
}
|
|
report.Selectors = append(report.Selectors, selectorReport)
|
|
if match.Matched {
|
|
report.MatchedCount++
|
|
} else {
|
|
report.UnmatchedCount++
|
|
}
|
|
if match.MatchCount > 1 {
|
|
report.AmbiguousCount++
|
|
}
|
|
}
|
|
return report
|
|
}
|
|
|
|
func StrictExitCode(report Report) int {
|
|
if !report.OK {
|
|
if report.Error != nil && report.Error.Code == "INVALID_CATALOG" {
|
|
return StrictExitInvalidCatalog
|
|
}
|
|
return StrictExitReportGenerationFailure
|
|
}
|
|
if report.UnmatchedCount > 0 {
|
|
return StrictExitUnmatched
|
|
}
|
|
if report.AmbiguousCount > 0 {
|
|
return StrictExitAmbiguous
|
|
}
|
|
return StrictExitOK
|
|
}
|
|
|
|
func RenderReportJSON(report Report) ([]byte, error) {
|
|
return json.MarshalIndent(report, "", " ")
|
|
}
|
|
|
|
func WriteReportJSON(report Report, path string) error {
|
|
body, err := RenderReportJSON(report)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileWithParents(path, body)
|
|
}
|
|
|
|
func RenderReportMarkdown(report Report) string {
|
|
var b strings.Builder
|
|
b.WriteString("# N14 UIA Selector Report\n\n")
|
|
b.WriteString("- Source dump: `" + escapeMarkdownInline(report.SourceDump) + "`\n")
|
|
b.WriteString(fmt.Sprintf("- Catalog size: %d\n", report.CatalogSize))
|
|
b.WriteString(fmt.Sprintf("- Matched: %d\n", report.MatchedCount))
|
|
b.WriteString(fmt.Sprintf("- Unmatched: %d\n", report.UnmatchedCount))
|
|
b.WriteString(fmt.Sprintf("- Ambiguous: %d\n", report.AmbiguousCount))
|
|
b.WriteString("\n## Summary\n\n")
|
|
b.WriteString("| selector_id | status | matches | stability | evidence_path |\n")
|
|
b.WriteString("| --- | --- | ---: | ---: | --- |\n")
|
|
for _, selector := range report.Selectors {
|
|
status := "unmatched"
|
|
if selector.MatchCount > 1 {
|
|
status = "ambiguous"
|
|
} else if selector.Matched {
|
|
status = "matched"
|
|
}
|
|
b.WriteString(fmt.Sprintf(
|
|
"| %s | %s | %d | %d | %s |\n",
|
|
escapeMarkdownCell(selector.SelectorID),
|
|
status,
|
|
selector.MatchCount,
|
|
selector.StabilityScore,
|
|
escapeMarkdownCell(selector.Evidence.UIAPath),
|
|
))
|
|
}
|
|
b.WriteString("\n## Selector Details\n")
|
|
for _, selector := range report.Selectors {
|
|
b.WriteString("\n### " + escapeMarkdownInline(selector.SelectorID) + "\n\n")
|
|
b.WriteString("- Description: " + escapeMarkdownInline(selector.Description) + "\n")
|
|
b.WriteString("- Allowed use: " + escapeMarkdownInline(selector.AllowedUse) + "\n")
|
|
b.WriteString(fmt.Sprintf("- Stability score: %d\n", selector.StabilityScore))
|
|
b.WriteString(fmt.Sprintf("- Match count: %d\n", selector.MatchCount))
|
|
b.WriteString("\nMatches:\n\n")
|
|
b.WriteString("| path | control_type | automation_id | class_name | framework_id | has_name | name_length |\n")
|
|
b.WriteString("| --- | --- | --- | --- | --- | --- | ---: |\n")
|
|
for _, match := range selector.Matches {
|
|
b.WriteString(fmt.Sprintf(
|
|
"| %s | %s | %s | %s | %s | %t | %d |\n",
|
|
escapeMarkdownCell(match.Path),
|
|
escapeMarkdownCell(match.ControlType),
|
|
escapeMarkdownCell(match.AutomationID),
|
|
escapeMarkdownCell(match.ClassName),
|
|
escapeMarkdownCell(match.FrameworkID),
|
|
match.HasName,
|
|
match.NameLength,
|
|
))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func WriteReportMarkdown(report Report, path string) error {
|
|
return writeFileWithParents(path, []byte(RenderReportMarkdown(report)))
|
|
}
|
|
|
|
func writeFileWithParents(path string, body []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if dir != "." && dir != "" {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return os.WriteFile(path, body, 0o644)
|
|
}
|
|
|
|
func escapeMarkdownInline(value string) string {
|
|
return strings.NewReplacer("`", "'", "\r", " ", "\n", " ").Replace(value)
|
|
}
|
|
|
|
func escapeMarkdownCell(value string) string {
|
|
return strings.NewReplacer("|", "\\|", "\r", " ", "\n", " ").Replace(value)
|
|
}
|