A security scanner built specifically for Laravel.
Ward understands your Laravel application — its routes, models, controllers, middleware, Blade templates, config files, .env secrets, Composer dependencies, and more. It doesn't just grep for patterns. It resolves your project's structure first, then runs targeted security checks against it.
Laravel gives you a lot out of the box — CSRF protection, Eloquent's mass assignment guards, Bcrypt hashing, encrypted cookies. But it's easy to misconfigure things or leave gaps that standard linters won't catch:
APP_DEBUG=true shipping to production$guarded = [] on a model that handles paymentsDB::raw() with interpolated user inputSecure flagauth:sanctum{!! !!} on user dataWard checks for all of these and more. It's designed to fit into the workflow you already have — run it locally during development, or wire it into CI to gate deployments.
Ward scans your project in a pipeline of five stages:
Provider --> Resolvers --> Scanners --> Post-Process --> Report
1. Provider — Locates and prepares your project source. Supports local paths and git URLs (shallow clone).
2. Resolvers — Parses composer.json, composer.lock, .env, and config/*.php to build a structured project context: framework version, PHP version, installed packages, environment variables, config files.
3. Scanners — Independent security checks run against the resolved context:
| Scanner | What it checks |
|---|---|
env-scanner |
.env misconfigurations — debug mode, empty APP_KEY, non-production env, weak credentials, leaked secrets in .env.example |
config-scanner |
config/*.php — hardcoded debug mode, session cookie flags, CORS wildcards, hardcoded credentials in config files |
dependency-scanner |
composer.lock — live CVE lookup via OSV.dev against the entire Packagist advisory database (no hardcoded list, always up-to-date) |
rules-scanner |
40 built-in YAML rules covering secrets, SQL/command/code injection, XSS, debug artifacts, weak crypto, auth issues, mass assignment, unsafe file uploads |
4. Post-Process — Deduplicates findings, filters by minimum severity (from config), and diffs against your last scan to show what's new vs resolved.
5. Report — Generates output in multiple formats and saves scan history for trending.
go install github.com/eljakani/ward@latest
Note:
@latestresolves to the latest Git tag (e.g.,v0.3.0). To install a specific version:go install github.com/eljakani/ward@v0.3.0
Make sure $GOPATH/bin is in your PATH (Go installs binaries there):
export PATH="$PATH:$(go env GOPATH)/bin"
Add this line to your
~/.bashrcor~/.zshrcto make it permanent.
Or build from source:
git clone https://github.com/Eljakani/ward.git
cd ward
make build # builds ./ward with embedded version, commit, and date
make install # installs to $GOPATH/bin
ward init
This creates ~/.ward/ with your configuration and 40 default security rules:
~/.ward/
├── config.yaml # Main configuration
├── rules/ # Security rules (YAML)
│ ├── secrets.yaml # 7 rules: hardcoded passwords, API keys, AWS creds, JWT, tokens
│ ├── injection.yaml # 6 rules: SQL injection, command injection, eval, unserialize
│ ├── xss.yaml # 4 rules: unescaped Blade output, JS injection
│ ├── debug.yaml # 6 rules: dd(), dump(), phpinfo(), debug bars
│ ├── crypto.yaml # 5 rules: md5, sha1, rand(), mcrypt, base64-as-encryption
│ ├── security-config.yaml # 7 rules: CORS, SSL verify, CSRF, mass assignment, uploads
│ ├── auth.yaml # 5 rules: missing middleware, rate limiting, loginUsingId
│ └── custom-example.yaml # Disabled template showing how to write your own rules
├── reports/ # Scan report output
└── store/ # Scan history for diffing between runs
ward scan /path/to/your/laravel-project
ward scan https://github.com/user/laravel-project.git
```bash
ward scan ./my-app --output json
When no TTY is available or --output is specified, Ward runs in headless mode with styled text output — no interactive TUI.
--fail-on)# Exit code 1 if any High or Critical findings exist
ward scan . --output json --fail-on high
# Fail on any finding (including Info)
ward scan . --output json --fail-on info
Severity threshold is inclusive: --fail-on medium fails on Medium, High, and Critical.
On first run, generate a baseline of current findings:
ward scan . --output json --update-baseline .ward-baseline.json
On subsequent runs, suppress those known findings:
ward scan . --output json --baseline .ward-baseline.json --fail-on high
Only new findings (not in the baseline) will be reported. Commit .ward-baseline.json to your repo to track acknowledged findings.
- name: Run Ward
run: |
ward scan . --output json,sarif \
--baseline .ward-baseline.json \
--fail-on high
📖 Full CI/CD guide — GitHub Actions, GitLab CI, Bitbucket, Azure DevOps, Docker, caching, and troubleshooting: docs/ci-integration.md
Configure output formats in ~/.ward/config.yaml:
output:
formats:
- json # ward-report.json — machine-readable
- sarif # ward-report.sarif — GitHub Code Scanning / IDE integration
- html # ward-report.html — standalone visual report (dark theme)
- markdown # ward-report.md — text-based, great for PRs
dir: ./reports
JSON is always generated as a baseline. All report files are written to the configured output directory (defaults to .).
Add the SARIF format and upload it in your CI workflow:
- name: Run Ward
run: ward scan . --output json
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ward-report.sarif
See the GitHub Actions example below for a complete workflow.
Ward loads its config from ~/.ward/config.yaml:
# Minimum severity to report: info, low, medium, high, critical
severity: info
output:
formats: [json, sarif, html, markdown]
dir: ./reports
scanners:
disable: [] # scanner names to skip, e.g. ["dependency-scanner"]
rules:
disable: [] # rule IDs to silence, e.g. ["DEBUG-001", "AUTH-001"]
override: # change severity for specific rules
DEBUG-002:
severity: low
# custom_dirs: # load rules from additional directories
# - /path/to/team-rules
providers:
git_depth: 1 # shallow clone depth (0 = full history)
Drop .yaml files into ~/.ward/rules/ and Ward picks them up automatically. See custom-example.yaml for a documented template.
rules:
- id: TEAM-001
title: "Hardcoded internal service URL"
description: "Detects hardcoded URLs to internal services."
severity: medium
category: Configuration
enabled: true
patterns:
- type: regex
target: php-files
pattern: 'https?://internal-service\.\w+'
remediation: |
Use environment variables:
$url = env('INTERNAL_SERVICE_URL');
| Type | Description |
|---|---|
regex |
Regular expression match (line-by-line) |
contains |
Exact substring match |
file-exists |
Check if a file matching the glob exists |
regex-scoped |
Regex match that suppresses findings inside a detected scope block |
| Target | Files matched |
|---|---|
php-files |
All .php files (recursive, skips vendor/) |
blade-files |
resources/views/**/*.blade.php |
config-files |
config/*.php |
env-files |
.env, .env.* |
routes-files |
routes/*.php |
migration-files |
database/migrations/*.php |
js-files |
resources/js/**/*.{js,ts,jsx,tsx} |
path/to/*.ext |
Any custom glob pattern |
Set negative: true to trigger when a pattern is absent — useful for "must have X" checks:
patterns:
- type: contains
target: blade-files
pattern: "@csrf"
negative: true # fire if @csrf is NOT found
Use type: regex-scoped when a match should only be flagged if it is not inside a surrounding block (e.g. a middleware group). The scanner reads the whole file, tracks brace depth to find blocks that start with scope_exclude, and suppresses any matches that fall within those blocks.
patterns:
- type: regex-scoped
target: routes-files
pattern: 'Route::(get|post|put|patch|delete)\s*\([^;]*\)\s*;'
scope_exclude: 'Route::middleware|->middleware\('
exclude_pattern: '(login|register|password|reset|webhook|health)'
| Field | Required | Description |
|---|---|---|
pattern |
yes | Regex to match on each line |
scope_exclude |
yes | Regex identifying lines that open a protected scope (brace-depth tracked) |
exclude_pattern |
no | Additional per-line exclusion applied after scope filtering (same as regex) |
negative |
no | Invert: fire when pattern is absent (same semantics as regex) |
How it works:
scope_exclude that open a {...} block. The closing } is found by counting brace depth.pattern regex is then applied line-by-line, skipping protected lines.Known limitations:
require'd inside a group are not linked across files; those will still be scanned in isolation.Disable or change severity of any rule in config.yaml without editing rule files:
rules:
disable: [DEBUG-001, DEBUG-002]
override:
CRYPTO-003:
severity: low
AUTH-001:
enabled: false
Ward automatically saves each scan to ~/.ward/store/. On subsequent scans of the same project, it shows what changed:
[info] vs last scan: 2 new, 3 resolved (12->11)
This lets you track security posture over time and catch regressions.
Ward's TUI is built on Bubble Tea and adapts to both light and dark terminals automatically.
Displayed while scanning is in progress — shows pipeline stage progress, scanner status with spinners, live severity counts, and a scrollable event log.
Displayed after scan completion — sortable findings table with severity badges, category grouping, and a detail panel showing description, code snippet, remediation, and references.
| Key | Action |
|---|---|
q / Ctrl+C |
Quit |
? |
Toggle help |
Tab |
Switch view or panel |
j / k / arrows |
Navigate findings |
s |
Cycle sort column (severity, category, file) |
Esc |
Back to scan view |
Add this workflow to your Laravel project as .github/workflows/ward.yml:
```yaml name: Ward Security Scan on: [push, pull_request]
jobs: security-scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Install Ward
run: go install github.com/eljakani/ward@latest
- name: Run Ward
run: ward init && ward scan . --output json
- name: Upload SARIF
if: always()
use