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.

Scanner Overview

Ward includes 4 built-in scanner types that run independently during the Scanners stage of the pipeline. Each scanner focuses on a specific security domain:

env-scanner

Checks .env files for misconfigurations and leaked secrets

config-scanner

Analyzes config/*.php for hardcoded credentials and insecure settings

dependency-scanner

Live CVE lookup via OSV.dev for all Composer packages

rules-scanner

Pattern-based checks from YAML rules (40 default rules)

How Scanners Work

All scanners implement the same interface and follow a consistent pattern: Source: internal/models/scanner.go
type Scanner interface {
    Name() string
    Description() string
    Scan(ctx context.Context, project ProjectContext, emit func(Finding)) ([]Finding, error)
}

Scanner Execution

  1. Receive shared context — Scanners get the ProjectContext built by resolvers
  2. Perform checks — Each scanner runs its domain-specific security analysis
  3. Emit findings — Real-time via callback for TUI updates
  4. Return results — All findings as a slice
  5. Independence — Scanners don’t depend on each other; if one fails, others continue
Scanners are stateless and independent. They could run in parallel (though currently sequential) because they only read from the shared immutable ProjectContext.

env-scanner

Source: internal/scanner/env/scanner.go

Description

Checks .env and .env.example files for security misconfigurations, weak credentials, and accidentally committed secrets.

Checks Performed (8 rules)

Severity: InfoDetects when no .env file exists in the project root.Why it matters: While valid in containerized deployments, missing .env may indicate misconfiguration.Remediation:
cp .env.example .env
php artisan key:generate
Severity: HighDetects APP_DEBUG=true in .env.Why it matters: Debug mode exposes stack traces, database queries, and environment variables to end users. Critical information disclosure in production.Code check:
if val, ok := envVars["APP_DEBUG"]; ok && strings.EqualFold(val, "true") {
    // Create finding
}
Remediation:
APP_DEBUG=false
Severity: CriticalDetects when APP_KEY is missing or empty.Why it matters: Laravel uses APP_KEY to encrypt cookies, sessions, and passwords. Without it, all encrypted data is insecure.Remediation:
php artisan key:generate
Severity: CriticalDetects common weak keys like base64:AAAAAAA..., SomeRandomString, or keys shorter than 20 characters.Code check:
func isWeakKey(val string) bool {
    lower := strings.ToLower(val)
    if strings.HasPrefix(lower, "base64:aaaaaaa") {
        return true
    }
    if lower == "somerandomstring" {
        return true
    }
    if strings.HasPrefix(val, "base64:") && len(val) < 20 {
        return true
    }
    return false
}
Severity: MediumDetects APP_ENV set to local, development, or dev.Why it matters: Non-production environments may skip optimizations and enable debugging features.Remediation:
APP_ENV=production
Severity: LowDetects DB_PASSWORD= (empty value).Why it matters: While valid for local development with trust authentication, it’s a risk in production.
Severity: LowDetects SESSION_DRIVER=file when APP_ENV=production.Why it matters: File sessions don’t scale across multiple servers and are slower than Redis/Memcached.Remediation:
SESSION_DRIVER=redis
Severity: MediumDetects non-placeholder values in .env.example for sensitive keys:
  • DB_PASSWORD
  • MAIL_PASSWORD
  • AWS_SECRET_ACCESS_KEY
  • REDIS_PASSWORD
  • PUSHER_APP_SECRET
Why it matters: .env.example is typically committed to version control. Real credentials here are exposed in Git history.Code check:
// Skip obvious placeholders
if lower == "null" || lower == "password" || lower == "changeme" {
    continue
}
// Flag values longer than 6 chars that don't look like placeholders
if len(val) > 6 {
    // Create finding
}

Implementation Details

File parsing:
func readEnvFile(path string) (map[string]string, error) {
    // Read line by line
    // Skip comments and empty lines
    // Parse KEY=VALUE pairs
    // Strip quotes from values
}
Line number tracking:
func findLine(path, key string) int {
    // Re-read file to find exact line number
    // Enables code navigation in IDEs via SARIF
}

config-scanner

Source: internal/scanner/configscan/scanner.go

Description

Analyzes all config/*.php files for hardcoded credentials, insecure defaults, and missing security flags.

Files Analyzed

config/app.php          # Debug mode, encryption cipher
config/auth.php         # Password reset token expiry
config/session.php      # Cookie flags (HttpOnly, Secure, SameSite)
config/mail.php         # Hardcoded mail credentials
config/cors.php         # CORS wildcard origins
config/database.php     # Hardcoded DB credentials
config/broadcasting.php # Hardcoded Pusher keys
config/logging.php      # Hardcoded Slack webhooks

Checks Performed (13 rules)

Severity: HighFile: config/app.phpPattern: 'debug' => trueWhy it matters: Bypasses the .env setting, forcing debug mode even in production.Remediation:
'debug' => env('APP_DEBUG', false),
Severity: MediumFile: config/app.phpPattern: 'cipher' => '(?!AES-256-CBC)...'Remediation:
'cipher' => 'AES-256-CBC',
Severity: LowFile: config/auth.phpPattern: 'expire' => \d{3,} (≥100 minutes)Remediation:
'expire' => 60, // 60 minutes
Severity: LowFile: config/session.phpPattern: 'lifetime' => \d{4,} (≥1000 minutes)
Severity: HighFile: config/mail.phpPattern: 'password' => '[^']{4,}' (not using env())Code check:
if line, n := findPattern(lines, `'password'\s*=>\s*'[^']{4,}'`); n > 0 {
    if !strings.Contains(line, "env(") {
        // Create finding
    }
}
Severity: MediumFile: config/cors.phpPattern: 'allowed_origins' => ['*']Remediation:
'allowed_origins' => [env('FRONTEND_URL')],
Severity: HighFile: config/cors.phpWhy it matters: Combining supports_credentials => true with wildcard origins allows any website to make authenticated requests to your API.Remediation: Never use wildcard origins with credentials enabled.
Severity: HighFile: config/database.phpPattern: 'password' => '[^']{4,}' (not using env())
Severity: MediumFile: config/broadcasting.phpPattern: '(secret|key)' => '[a-zA-Z0-9]{10,}' (not using env())
Severity: MediumFile: config/logging.phpPattern: hooks.slack.com/services (not using env())

Implementation Details

Pattern matching:
func findPattern(lines []string, pattern string) (string, int) {
    re := regexp.Compile(pattern)
    for i, line := range lines {
        if re.MatchString(line) {
            return strings.TrimSpace(line), i + 1
        }
    }
    return "", 0
}
Value masking:
func maskConfigValue(line string) string {
    // Replace 'password' => 'secret123' with 'password' => 'se****23'
    re := regexp.MustCompile(`=> '([^']{4,})'`)
    return re.ReplaceAllStringFunc(line, func(match string) string {
        val := extract(match)
        return "=> '" + val[:2] + "****" + val[len(val)-2:] + "'"
    })
}

dependency-scanner

Source: internal/scanner/dependency/scanner.go

Description

Performs live CVE database lookups via OSV.dev for all packages in composer.lock. This scanner requires network access.

How It Works

1

Read composer.lock

Extract all installed packages with exact versions from the ProjectContext.InstalledPackages map (populated by the Package Resolver).
2

Batch query OSV.dev

Send packages to OSV.dev’s batch endpoint in groups of 100:
const batchSize = 100
const osvBatchURL = "https://api.osv.dev/v1/querybatch"

for i := 0; i < len(allQueries); i += batchSize {
    batch := allQueries[i:i+batchSize]
    // POST to OSV.dev
}
Request format:
{
  "queries": [
    {
      "package": {"name": "symfony/http-kernel", "ecosystem": "Packagist"},
      "version": "6.4.2"
    }
  ]
}
3

Identify vulnerable packages

OSV.dev returns which packages have known vulnerabilities. Ward extracts these for detailed queries.
4

Fetch full vulnerability details

For each affected package, query the full vulnerability data:
vulns, err := s.queryPackage(ctx, packageName, version)
Response includes:
  • CVE ID and aliases
  • Severity (Critical, High, Medium, Low)
  • Affected version ranges
  • Fixed version
  • Advisory URLs
5

Generate findings

Convert each vulnerability to a Ward finding with:
  • CVE ID as the finding ID
  • Severity mapping from OSV data
  • Remediation with upgrade command
  • Reference links to advisories

Finding Format

Example finding:
models.Finding{
    ID:          "CVE-2024-28859",
    Title:       "[CVE-2024-28859] symfony/http-kernel@6.4.2 — RCE via serialized objects",
    Description: "Symfony HttpKernel vulnerable to remote code execution...",
    Severity:    models.SeverityHigh,
    Category:    "Dependencies",
    Scanner:     "dependency-scanner",
    File:        "composer.lock",
    Remediation: "Upgrade symfony/http-kernel to 6.4.4 or later:\n  composer require symfony/http-kernel:^6.4.4",
    References: [
        "https://github.com/advisories/GHSA-...",
        "https://nvd.nist.gov/vuln/detail/CVE-2024-28859",
    ],
}

Severity Mapping

Source: dependency/scanner.go:306-319
func parseSeverity(s string) models.Severity {
    switch strings.ToUpper(s) {
    case "CRITICAL":
        return models.SeverityCritical
    case "HIGH":
        return models.SeverityHigh
    case "MODERATE", "MEDIUM":
        return models.SeverityMedium
    case "LOW":
        return models.SeverityLow
    default:
        return models.SeverityMedium // default to medium for unknown
    }
}

Performance Optimizations

Batch Queries

Sends up to 100 packages per request instead of individual queries, reducing HTTP overhead.

Two-Phase Lookup

First identifies which packages are vulnerable (lightweight), then fetches full details only for affected packages.

Version Normalization

Skips dev branches (dev-main) that OSV.dev can’t match:
if strings.HasPrefix(v, "dev-") {
    return ""
}

Timeout Protection

Uses 30-second HTTP timeout to prevent hanging on slow networks.

Network Requirements

This scanner requires outbound HTTPS access to api.osv.dev. In air-gapped environments, disable it:
scanners:
  disable:
    - dependency-scanner

rules-scanner

Source: internal/scanner/rules/scanner.go

Description

Executes pattern-based security checks defined in YAML rule files. Ward ships with 40 default rules and supports custom rules.

Rule Execution

1

Load rules

Rules are loaded from:
  • ~/.ward/rules/*.yaml (default rules)
  • Custom directories specified in config
Only enabled rules are executed.
2

Resolve targets

For each rule pattern, determine which files to scan based on the target field:Predefined targets:
  • php-files → All .php files (recursive, skips vendor/)
  • blade-filesresources/views/**/*.blade.php
  • config-filesconfig/*.php
  • env-files.env, .env.*
  • routes-filesroutes/*.php
  • migration-filesdatabase/migrations/*.php
  • js-filesresources/js/**/*.{js,ts,jsx,tsx}
Custom glob patterns:
target: "app/Http/Controllers/**/*.php"
3

Scan files

For each file:
  • Read line by line
  • Match against the pattern (regex or substring)
  • Exclude lines matching exclude_pattern if specified
  • Record line number and code snippet
4

Handle negative patterns

If negative: true, the rule triggers when the pattern is NOT found (useful for “must have X” checks).
5

Generate findings

Create a finding for each match with:
  • Rule metadata (ID, title, description, severity, category)
  • File path and line number
  • Code snippet (truncated to 200 chars)
  • Remediation and references from rule definition

Pattern Types

Regular expression matching (line-by-line).Example:
patterns:
  - type: regex
    target: php-files
    pattern: 'DB::raw\s*\(\s*["\'].*\$'
Matches SQL injection via string interpolation in DB::raw().

Negative Patterns

Use case: Enforce that certain code patterns must be present. Example:
patterns:
  - type: contains
    target: blade-files
    pattern: '@csrf'
    negative: true  # Trigger if @csrf is NOT found
This creates a finding for Blade forms missing CSRF protection.

Exclude Patterns

Use case: Reduce false positives by excluding safe contexts. Example:
patterns:
  - type: regex
    target: php-files
    pattern: 'password.*='
    exclude_pattern: 'env\(|config\('  # Ignore env() and config() calls

Implementation Details

File walking with exclusions:
func skipDir(name string) bool {
    switch name {
    case "vendor", "node_modules", ".git", "storage", ".idea", ".vscode":
        return true
    }
    return false
}
Pattern matching:
func scanFile(path string, pat config.PatternDef, re *regexp.Regexp) []match {
    scanner := bufio.NewScanner(file)
    lineNum := 0
    for scanner.Scan() {
        lineNum++
        line := scanner.Text()
        
        var matched bool
        switch pat.Type {
        case "regex":
            matched = re.MatchString(line)
        case "contains":
            matched = strings.Contains(line, pat.Pattern)
        }
        
        // Skip if exclude pattern matches
        if matched && excludeRe != nil && excludeRe.MatchString(line) {
            matched = false
        }
        
        if matched {
            matches = append(matches, match{line: lineNum, text: line})
        }
    }
    return matches
}

Default Rules Summary

Ward ships with 40 rules across 7 categories:
  • Hardcoded passwords in code
  • AWS credentials
  • API keys and tokens
  • JWT secrets
  • Database credentials
  • SQL injection via DB::raw() with interpolation
  • Command injection via exec(), shell_exec()
  • Code injection via eval()
  • Unsafe deserialization with unserialize()
  • Unescaped Blade output {!! $var !!}
  • JavaScript injection in Blade
  • echo with user input
  • dd() and dump() calls
  • var_dump(), print_r()
  • phpinfo()
  • Laravel Debugbar in production
  • MD5/SHA1 for password hashing
  • rand() instead of random_bytes()
  • Deprecated mcrypt functions
  • Base64 mistaken for encryption
  • CORS wildcards
  • SSL verification disabled
  • CSRF middleware missing
  • Mass assignment $guarded = []
  • Unsafe file upload extensions
  • Missing authentication middleware
  • Missing rate limiting
  • loginUsingId() without validation
  • Hardcoded admin checks

Scanner Configuration

Disable Scanners

File: ~/.ward/config.yaml
scanners:
  disable:
    - dependency-scanner  # Skip CVE checks
    - rules-scanner       # Skip YAML pattern rules

Disable Individual Rules

rules:
  disable:
    - DEBUG-001  # Allow dd() calls
    - AUTH-002   # Allow missing rate limiting

Override Rule Severity

rules:
  override:
    DEBUG-002:
      severity: low  # Downgrade dump() from medium to low
    CRYPTO-001:
      severity: critical  # Upgrade MD5 hashing from high to critical

Add Custom Rule Directories

rules:
  custom_dirs:
    - /path/to/team-rules
    - /path/to/project-specific-rules

Scanner Independence

Scanners are fully independent and don’t communicate with each other. This design provides several benefits:
  • Reliability: If one scanner fails, others continue
  • Parallelization: Scanners could run concurrently (future enhancement)
  • Modularity: Easy to add/remove scanners without affecting others
  • Testing: Each scanner can be unit tested in isolation

Shared Dependencies

Scanners only depend on:
  1. The immutable ProjectContext (built by resolvers)
  2. The emit callback (for real-time updates)
  3. The context.Context (for cancellation)
They do not depend on:
  • Other scanners’ results
  • Filesystem state (all paths are in ProjectContext.RootPath)
  • Global state

Adding Custom Scanners

To create a new scanner:
1

Implement the Scanner interface

package myscanner

import (
    "context"
    "github.com/eljakani/ward/internal/models"
)

type MyScanner struct{}

func New() *MyScanner { return &MyScanner{} }

func (s *MyScanner) Name() string {
    return "my-scanner"
}

func (s *MyScanner) Description() string {
    return "My custom security checks"
}

func (s *MyScanner) Scan(ctx context.Context, pc models.ProjectContext, emit func(models.Finding)) ([]models.Finding, error) {
    var findings []models.Finding
    
    // Your scan logic here
    f := models.Finding{
        ID:          "CUSTOM-001",
        Title:       "Custom check failed",
        Description: "Details...",
        Severity:    models.SeverityHigh,
        Category:    "Custom",
        Scanner:     s.Name(),
        File:        "some-file.php",
        Line:        42,
    }
    
    findings = append(findings, f)
    emit(f)  // Emit in real-time
    
    return findings, nil
}
2

Register in the orchestrator

File: internal/orchestrator/orchestrator.go
scanners := []models.Scanner{
    envscanner.New(),
    configscanner.New(),
    depscanner.New(),
    myscanner.New(),  // Add your scanner
}
3

Rebuild and test

make build
ward scan ./test-project

Architecture

System architecture and design principles

Scan Pipeline

How scanners fit into the 5-stage pipeline

Custom Rules

Writing YAML rules for the rules-scanner

Configuration

Scanner configuration options

Build docs developers (and LLMs) love