claw-code/.guardrails/mcp-server/internal/ingest/rule_parser.go

531 lines
15 KiB
Go

package ingest
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/thearchitectit/guardrail-mcp/internal/database"
"github.com/thearchitectit/guardrail-mcp/internal/models"
"github.com/thearchitectit/guardrail-mcp/internal/validation"
)
// RuleParser parses markdown files containing prevention rules
type RuleParser struct {
// ruleHeaderRegex matches rule headers like "## PREVENT-001: Rule Name"
ruleHeaderRegex *regexp.Regexp
// metadataRegex matches metadata fields like "**Pattern:** `regex`"
metadataRegex *regexp.Regexp
// backtickRegex extracts content from backticks
backtickRegex *regexp.Regexp
}
// NewRuleParser creates a new rule parser
func NewRuleParser() *RuleParser {
return &RuleParser{
ruleHeaderRegex: regexp.MustCompile(`(?m)^##\s+(PREVENT-\d+)\s*:\s*(.+)$`),
metadataRegex: regexp.MustCompile(`(?m)^\*\*(\w+):\*\*\s*(.+?)$`),
backtickRegex: regexp.MustCompile("`([^`]+)`"),
}
}
// ParsedRule represents a rule extracted from markdown
type ParsedRule struct {
RuleID string
Name string
Pattern string
Message string
Severity string
Category string
PatternHash string
}
// ParseRuleFile parses a single markdown file and extracts rules
func (p *RuleParser) ParseRuleFile(path string) ([]ParsedRule, error) {
slog.Info("Parsing rule file", "file", path)
content, err := os.ReadFile(path)
if err != nil {
slog.Error("Failed to read rule file", "file", path, "error", err)
slog.Error("Failed to read rule file", "file", path, "error", err)
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
rules, err := p.ParseRuleContent(string(content), path)
if err != nil {
slog.Error("Failed to parse rule content", "file", path, "error", err)
return nil, err
}
slog.Info("Successfully parsed rule file", "file", path, "rules_found", len(rules))
return rules, nil
}
// ParseRuleContent parses markdown content and extracts rules
func (p *RuleParser) ParseRuleContent(content, source string) ([]ParsedRule, error) {
slog.Debug("Parsing rule content", "source", source, "content_length", len(content))
var rules []ParsedRule
// Find all rule sections
matches := p.ruleHeaderRegex.FindAllStringIndex(content, -1)
if matches == nil {
slog.Debug("No rule sections found in content", "source", source)
return rules, nil
}
slog.Debug("Found rule sections in content", "source", source, "section_count", len(matches))
for i, match := range matches {
start := match[0]
end := len(content)
if i < len(matches)-1 {
end = matches[i+1][0]
}
section := content[start:end]
rule, err := p.parseRuleSection(section)
if err != nil {
return nil, fmt.Errorf("failed to parse rule in %s: %w", source, err)
}
if rule != nil {
// Compute pattern hash for change detection
hash := sha256.Sum256([]byte(section))
rule.PatternHash = fmt.Sprintf("%x", hash[:8])
rules = append(rules, *rule)
}
}
slog.Debug("Completed parsing rule content", "source", source, "rules_extracted", len(rules))
return rules, nil
}
// parseRuleSection parses a single rule section
func (p *RuleParser) parseRuleSection(section string) (*ParsedRule, error) {
// Extract rule ID and name from header
headerMatch := p.ruleHeaderRegex.FindStringSubmatch(section)
if headerMatch == nil {
return nil, nil
}
rule := &ParsedRule{
RuleID: headerMatch[1],
Name: strings.TrimSpace(headerMatch[2]),
}
// Extract metadata fields
metadata := p.extractMetadata(section)
// Map metadata to rule fields
if pattern, ok := metadata["Pattern"]; ok {
rule.Pattern = p.extractBacktickContent(pattern)
}
if message, ok := metadata["Message"]; ok {
rule.Message = strings.TrimSpace(message)
}
if severity, ok := metadata["Severity"]; ok {
rule.Severity = strings.ToLower(strings.TrimSpace(severity))
}
if category, ok := metadata["Category"]; ok {
rule.Category = strings.ToLower(strings.TrimSpace(category))
}
// Extract description (content after metadata, before next section or end)
// Note: Description is not stored in the database model but can be used for documentation
_ = p.extractDescription(section)
// Set default message if not provided
if rule.Message == "" {
rule.Message = fmt.Sprintf("Rule violation: %s", rule.Name)
}
// Validate the parsed rule
if err := p.validateRule(rule); err != nil {
return nil, err
}
return rule, nil
}
// extractMetadata extracts all **Key:** Value pairs from content
func (p *RuleParser) extractMetadata(content string) map[string]string {
metadata := make(map[string]string)
matches := p.metadataRegex.FindAllStringSubmatch(content, -1)
for _, match := range matches {
if len(match) >= 3 {
key := strings.TrimSpace(match[1])
value := strings.TrimSpace(match[2])
metadata[key] = value
}
}
return metadata
}
// extractBacktickContent extracts content from backticks
func (p *RuleParser) extractBacktickContent(content string) string {
match := p.backtickRegex.FindStringSubmatch(content)
if len(match) >= 2 {
return match[1]
}
return strings.TrimSpace(content)
}
// extractDescription extracts the description text from a rule section
func (p *RuleParser) extractDescription(section string) string {
// Split by lines and find description after metadata
lines := strings.Split(section, "\n")
var descLines []string
inDescription := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip header line
if strings.HasPrefix(trimmed, "## ") {
continue
}
// Skip metadata lines
if strings.HasPrefix(trimmed, "**") && strings.Contains(trimmed, "**:") {
continue
}
// Skip empty lines at start
if !inDescription && trimmed == "" {
continue
}
inDescription = true
// Stop at horizontal rules
if strings.HasPrefix(trimmed, "---") {
break
}
descLines = append(descLines, line)
}
// Clean up the description
description := strings.Join(descLines, "\n")
description = strings.TrimSpace(description)
// Remove markdown formatting for plain text description
description = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(description, "$1")
description = regexp.MustCompile("`([^`]+)`").ReplaceAllString(description, "$1")
return description
}
// validateRule validates a parsed rule
func (p *RuleParser) validateRule(rule *ParsedRule) error {
if rule.RuleID == "" {
return fmt.Errorf("rule ID is required")
}
if rule.Name == "" {
return fmt.Errorf("rule name is required")
}
if rule.Pattern == "" {
return fmt.Errorf("pattern is required for rule %s", rule.RuleID)
}
// Validate regex pattern
if err := validation.ValidatePattern(rule.Pattern); err != nil {
return fmt.Errorf("invalid pattern for rule %s: %w", rule.RuleID, err)
}
// Validate severity
validSeverities := map[string]bool{"error": true, "warning": true, "info": true}
if !validSeverities[rule.Severity] {
rule.Severity = "warning" // Default to warning
}
// Validate category
validCategories := map[string]bool{"git": true, "bash": true, "docker": true, "security": true, "general": true}
if !validCategories[rule.Category] {
rule.Category = "general" // Default to general
}
return nil
}
// RuleSyncResult tracks the results of a rule sync operation
type RuleSyncResult struct {
Added int
Updated int
Disabled int
Errors []string
}
// JSONRuleFile represents the structure of JSON rule files
type JSONRuleFile struct {
Schema string `json:"$schema"`
Description string `json:"description"`
Version string `json:"version"`
Rules []JSONRule `json:"rules"`
}
// JSONRule represents a single rule in JSON format
type JSONRule struct {
RuleID string `json:"rule_id"`
FailureID *string `json:"failure_id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Pattern string `json:"pattern"`
ForbiddenContext *string `json:"forbidden_context"`
Message string `json:"message"`
Severity string `json:"severity"`
FileGlob []string `json:"file_glob"`
Suggestion string `json:"suggestion"`
Category string `json:"category"`
}
// ParseJSONRuleFile parses a JSON rule file and extracts rules
func (p *RuleParser) ParseJSONRuleFile(path string) ([]ParsedRule, error) {
slog.Info("Parsing JSON rule file", "file", path)
content, err := os.ReadFile(path)
if err != nil {
slog.Error("Failed to read JSON rule file", "file", path, "error", err)
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
var jsonFile JSONRuleFile
if err := json.Unmarshal(content, &jsonFile); err != nil {
slog.Error("Failed to unmarshal JSON rule file", "file", path, "error", err)
return nil, fmt.Errorf("failed to parse JSON file %s: %w", path, err)
}
var rules []ParsedRule
for _, jsonRule := range jsonFile.Rules {
// Skip disabled rules
if !jsonRule.Enabled {
slog.Debug("Skipping disabled rule", "rule_id", jsonRule.RuleID)
continue
}
rule := ParsedRule{
RuleID: jsonRule.RuleID,
Name: jsonRule.Name,
Pattern: jsonRule.Pattern,
Message: jsonRule.Message,
Severity: jsonRule.Severity,
Category: jsonRule.Category,
}
// Default category if not set
if rule.Category == "" {
rule.Category = "general"
}
// Compute hash
hash := sha256.Sum256([]byte(jsonRule.Pattern + jsonRule.Message))
rule.PatternHash = fmt.Sprintf("%x", hash[:8])
// Validate
if err := p.validateRule(&rule); err != nil {
slog.Error("Invalid JSON rule", "rule_id", jsonRule.RuleID, "error", err)
continue
}
rules = append(rules, rule)
}
slog.Info("Successfully parsed JSON rule file", "file", path, "rules_found", len(rules))
return rules, nil
}
// RuleSyncService handles syncing parsed rules to the database
type RuleSyncService struct {
ruleStore *database.RuleStore
parser *RuleParser
}
// NewRuleSyncService creates a new rule sync service
func NewRuleSyncService(ruleStore *database.RuleStore) *RuleSyncService {
return &RuleSyncService{
ruleStore: ruleStore,
parser: NewRuleParser(),
}
}
// SyncRulesFromDirectory syncs all rules from markdown and JSON files in a directory
func (s *RuleSyncService) SyncRulesFromDirectory(ctx context.Context, dir string) (*RuleSyncResult, error) {
slog.Info("Syncing rules from directory", "dir", dir)
result := &RuleSyncResult{}
fileCount := 0
processedRuleIDs := make(map[string]bool)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
var rules []ParsedRule
var parseErr error
// Handle markdown files
if IsMarkdownFile(path) {
fileCount++
slog.Debug("Processing markdown file", "file", path)
rules, parseErr = s.parser.ParseRuleFile(path)
} else if strings.HasSuffix(strings.ToLower(path), ".json") {
// Handle JSON rule files
fileCount++
slog.Debug("Processing JSON rule file", "file", path)
rules, parseErr = s.parser.ParseJSONRuleFile(path)
} else {
return nil // Skip other files
}
if parseErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("failed to parse %s: %v", path, parseErr))
return nil
}
for _, parsedRule := range rules {
processedRuleIDs[parsedRule.RuleID] = true
if err := s.syncRule(ctx, parsedRule, result); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("failed to sync %s: %v", parsedRule.RuleID, err))
}
}
return nil
})
if err != nil {
return result, fmt.Errorf("failed to walk directory: %w", err)
}
// Disable rules that no longer exist in markdown files
if err := s.disableOrphanedRules(ctx, processedRuleIDs, result); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("failed to disable orphaned rules: %v", err))
}
return result, nil
}
// SyncRulesFromContent syncs rules from markdown content (for uploaded files)
func (s *RuleSyncService) SyncRulesFromContent(ctx context.Context, content, filename string) (*RuleSyncResult, error) {
result := &RuleSyncResult{}
rules, err := s.parser.ParseRuleContent(content, filename)
if err != nil {
return result, fmt.Errorf("failed to parse content: %w", err)
}
for _, parsedRule := range rules {
if err := s.syncRule(ctx, parsedRule, result); err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("failed to sync %s: %v", parsedRule.RuleID, err))
}
}
return result, nil
}
// syncRule syncs a single rule to the database
func (s *RuleSyncService) syncRule(ctx context.Context, parsed ParsedRule, result *RuleSyncResult) error {
// Check if rule already exists
existing, err := s.ruleStore.GetByRuleID(ctx, parsed.RuleID)
if err != nil {
// Check if it's a "not found" error
if !strings.Contains(err.Error(), "not found") {
return fmt.Errorf("failed to check existing rule: %w", err)
}
existing = nil
}
if existing != nil {
// Check if content changed
if existing.PatternHash != nil && *existing.PatternHash == parsed.PatternHash {
// Rule unchanged, just ensure it's enabled
if !existing.Enabled {
existing.Enabled = true
if err := s.ruleStore.Update(ctx, existing); err != nil {
return fmt.Errorf("failed to re-enable rule: %w", err)
}
result.Updated++
}
return nil
}
// Update existing rule
existing.Name = parsed.Name
existing.Pattern = parsed.Pattern
existing.PatternHash = &parsed.PatternHash
existing.Message = parsed.Message
existing.Severity = models.Severity(parsed.Severity)
existing.Category = parsed.Category
existing.Enabled = true
if err := s.ruleStore.Update(ctx, existing); err != nil {
return fmt.Errorf("failed to update rule: %w", err)
}
result.Updated++
} else {
// Create new rule
newRule := &models.PreventionRule{
ID: uuid.New(),
RuleID: parsed.RuleID,
Name: parsed.Name,
Pattern: parsed.Pattern,
PatternHash: &parsed.PatternHash,
Message: parsed.Message,
Severity: models.Severity(parsed.Severity),
Category: parsed.Category,
Enabled: true,
}
if err := s.ruleStore.Create(ctx, newRule); err != nil {
return fmt.Errorf("failed to create rule: %w", err)
}
result.Added++
}
return nil
}
// disableOrphanedRules disables rules that no longer exist in markdown files
func (s *RuleSyncService) disableOrphanedRules(ctx context.Context, processedIDs map[string]bool, result *RuleSyncResult) error {
// Get all enabled rules (using large limit to get all)
rules, err := s.ruleStore.List(ctx, boolPtr(true), "", 10000, 0)
if err != nil {
return fmt.Errorf("failed to list rules: %w", err)
}
for _, rule := range rules {
if !processedIDs[rule.RuleID] {
// Rule no longer exists in markdown files
rule.Enabled = false
if err := s.ruleStore.Update(ctx, &rule); err != nil {
return fmt.Errorf("failed to disable rule %s: %w", rule.RuleID, err)
}
result.Disabled++
}
}
return nil
}
// boolPtr returns a pointer to a bool value
func boolPtr(b bool) *bool {
return &b
}