Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Eljakani/ward/llms.txt

Use this file to discover all available pages before exploring further.

The rules scanner executes YAML-defined security rules against your Laravel project. Ward includes 40 built-in rules covering secrets, injection attacks, XSS, debug code, weak cryptography, and authentication issues.

How it works

The rules scanner:
  1. Loads rules from ~/.ward/rules/*.yaml and any custom directories
  2. Filters disabled rules and applies overrides from config.yaml
  3. Matches patterns against target files using regex, contains, or file-exists checks
  4. Emits findings for each match with severity, category, and remediation

Built-in rules

Ward ships with 40 default rules organized into 7 categories:

Secrets

7 rules detecting hardcoded passwords, API keys, AWS credentials, JWT tokens, and generic secrets

Injection

6 rules for SQL injection, command injection, eval(), unserialize(), and code execution vulnerabilities

XSS

4 rules detecting unescaped Blade output ({!! !!}), JavaScript injection, and unsafe HTML rendering

Debug

6 rules flagging dd(), dump(), var_dump(), phpinfo(), debug bars, and other debug artifacts

Cryptography

5 rules identifying weak hashing (md5, sha1), rand() usage, mcrypt, and base64 misused as encryption

Security Config

7 rules for CORS, SSL verification, CSRF, mass assignment, and unsafe file uploads

Authentication

5 rules detecting missing middleware, rate limiting issues, and loginUsingId() abuse

Implementation details

Extracted from internal/scanner/rules/scanner.go:

Rule evaluation

// scanner.go:29-45
func (s *Scanner) Scan(_ context.Context, project models.ProjectContext, emit func(models.Finding)) ([]models.Finding, error) {
    var findings []models.Finding

    for _, rule := range s.rules {
        if !rule.Enabled {
            continue
        }

        rf := s.evaluateRule(rule, project.RootPath)
        for _, f := range rf {
            findings = append(findings, f)
            emit(f)
        }
    }

    return findings, nil
}

Pattern types

Three pattern matching modes are supported:
Regular expression matching against each line of the file:
// scanner.go:102-108
var re *regexp.Regexp
if pat.Type == "regex" {
    re, err = regexp.Compile(pat.Pattern)
    if err != nil {
        return nil // skip invalid regex
    }
}
Example rule:
patterns:
  - type: regex
    target: php-files
    pattern: 'DB::raw\(.*\$'

Target resolution

The scanner resolves predefined targets to file globs:
// scanner.go:227-250
func targetGlobs(target, root string) []string {
    switch target {
    case "php-files":
        return []string{filepath.Join(root, "*.php"), filepath.Join(root, "app", "*.php")}
    case "blade-files":
        return []string{filepath.Join(root, "resources", "views", "*.blade.php")}
    case "config-files":
        return []string{filepath.Join(root, "config", "*.php")}
    case "env-files":
        return []string{filepath.Join(root, ".env"), filepath.Join(root, ".env.*")}
    case "routes-files":
        return []string{filepath.Join(root, "routes", "*.php")}
    case "migration-files":
        return []string{filepath.Join(root, "database", "migrations", "*.php")}
    case "js-files":
        return []string{filepath.Join(root, "resources", "js", "*.js"), filepath.Join(root, "resources", "js", "*.ts")}
    default:
        // Custom glob pattern
        if strings.ContainsAny(target, "*?[") {
            return []string{filepath.Join(root, target)}
        }
        return nil
    }
}
For php-files, blade-files, and js-files, the scanner recursively walks subdirectories while skipping vendor, node_modules, .git, and other common exclusions.

Negative patterns

Negative patterns trigger when a pattern is absent:
// scanner.go:118-127
if pat.Negative {
    // Finding if pattern was NOT found in this file
    if len(matches) == 0 {
        findings = append(findings, s.buildFinding(rule, rel, 0, ""))
    }
} else {
    for _, m := range matches {
        findings = append(findings, s.buildFinding(rule, rel, m.line, m.text))
    }
}
Example use case:
- id: AUTH-005
  title: "Missing @csrf directive in form"
  patterns:
    - type: contains
      target: blade-files
      pattern: '<form'
      negative: true  # Trigger if <form> exists but @csrf does NOT
    - type: contains
      target: blade-files
      pattern: '@csrf'
      negative: true

Exclude patterns

Exclude patterns filter out false positives:
// scanner.go:146-170
var excludeRe *regexp.Regexp
if pat.ExcludePattern != "" {
    excludeRe, _ = regexp.Compile(pat.ExcludePattern)
}

for scanner.Scan() {
    line := scanner.Text()
    
    var matched bool
    // ... pattern matching logic ...
    
    // Skip if the line also matches the exclude pattern
    if matched && excludeRe != nil && excludeRe.MatchString(line) {
        matched = false
    }
}
Example:
patterns:
  - type: regex
    target: php-files
    pattern: 'password.*=.*"[^"]+"'
    exclude_pattern: 'env\('  # Ignore lines with env() calls

Example built-in rules

SQL injection detection

From rules/injection.yaml:
- id: INJECT-001
  title: "Potential SQL injection via DB::raw with variable interpolation"
  description: "DB::raw() is being called with what looks like variable interpolation. This can lead to SQL injection if user input reaches the query."
  severity: high
  category: Injection
  enabled: true
  patterns:
    - type: regex
      target: php-files
      pattern: 'DB::raw\(.*\$'
  remediation: |
    Use parameter binding instead:
      DB::raw('SELECT * FROM users WHERE id = ?', [$userId])
    Or use query builder methods:
      DB::table('users')->where('id', $userId)->get()
  references:
    - https://owasp.org/www-community/attacks/SQL_Injection

Hardcoded AWS credentials

From rules/secrets.yaml:
- id: SECRET-003
  title: "Hardcoded AWS credentials"
  description: "AWS access key or secret key appears to be hardcoded in source code."
  severity: critical
  category: Secrets
  enabled: true
  patterns:
    - type: regex
      target: php-files
      pattern: 'AKIA[0-9A-Z]{16}'
    - type: regex
      target: php-files
      pattern: 'aws_secret.*=.*["''][^"'']{20,}'
  remediation: |
    Move AWS credentials to .env:
      AWS_ACCESS_KEY_ID=your_key_here
      AWS_SECRET_ACCESS_KEY=your_secret_here
    
    Then use in code:
      'key' => env('AWS_ACCESS_KEY_ID')

Unescaped Blade output (XSS)

From rules/xss.yaml:
- id: XSS-001
  title: "Unescaped Blade output"
  description: "The {!! !!} syntax outputs raw HTML without escaping. If the variable contains user input, this creates an XSS vulnerability."
  severity: high
  category: XSS
  enabled: true
  patterns:
    - type: regex
      target: blade-files
      pattern: '\{!!.*\$.*!!\}'
  remediation: |
    Use {{ }} for automatic HTML escaping:
      {{ $variable }}
    
    Only use {!! !!} for trusted HTML content, never user input.

Writing custom rules

See the Writing custom rules guide for detailed instructions on creating your own rules.

Performance considerations

  • Regex compilation: Rules with invalid regex are skipped (logged in verbose mode)
  • File walking: Vendor and node_modules are automatically excluded
  • Lazy evaluation: Rules are only evaluated against matching target files
  • Typical scan time: 1-3 seconds for 40 rules on a medium Laravel project

Disabling rules

Disable individual rules:
~/.ward/config.yaml
rules:
  disable:
    - DEBUG-001  # Allow dd() in codebase
    - XSS-001    # Allow {!! !!} (not recommended)
Or disable the entire rules scanner:
scanners:
  disable:
    - rules-scanner

Build docs developers (and LLMs) love