Laravel quality gate — phpcs, phpstan, phpunit, composer audit, security analyzers.
The package’s custom analyzers are static heuristics, not sound verifiers. This guide helps you tell real findings from noise and handle them properly instead of disabling whole rule groups.
$fillable), fix the code.--baseline-generate/--update
only after reading the full report. A blind baseline also hides real bugs.high confidence rules — if there is noise, raise
--min-confidence or change --tier instead of disabling analyzers.security.*.// quality-checker-ignore RULE comment on the same line (or
// quality-checker-ignore-next-line RULE on the line above) skips exactly
that finding; use all instead of RULE to skip all rules on that line.
Disable entirely with analyzers.inline_suppression => false.analyzers.exclude_paths ships as
['*/fixtures/*']. Code under a fixtures/ directory exists to be
vulnerable, so findings there are noise by construction. Use --exclude-path
(or the config key) to extend the list to your own sample directories; every
excluded file is counted in the custom checker summary.| Confidence | Example | Recommended action |
|---|---|---|
high |
SQL_INJECTION, UNSAFE_EVAL, HARDCODED_SECRET, OWASP_*, TAINT_*, composer_audit |
Treat as a real bug until proven otherwise. Fix the code, do not rush to baseline. |
medium |
INSECURE_HASH, LARAVEL_PITFALL (dd()/env()), MIGRATION_*, ROUTE_MISSING_VALIDATION |
Usually correct. Check the context (test environment? config file?) then fix or baseline. |
low |
DEAD_CODE, NAMING_CONVENTION, TODO_FIXME, MISSING_*_TEST |
Heuristic suggestions. Enable opt-in when cleaning up code, do not gate CI on this group. |
Quick filters for review:
# Only show high-confidence findings (least noise)
php artisan quality:check --min-confidence=high --tier=security
# Standard quality CI gate (package default)
php artisan quality:check --tier=quality
# Show everything including heuristics for local cleanup
php artisan quality:check --tier=all --fail-on=none
SQL_INJECTION / TAINT_SQL_INJECTION / LARAVEL_TAINT$request->input(), $_GET, …) is
concatenated or interpolated into DB::select, whereRaw, selectRaw, …orderBy()/orderByDesc()/groupBy()
with tainted input (e.g. orderBy($request->input('sort'))) — the column position
is not a bound value, so user input there is injectable.where('id', $id)) — the
analyzer skips this case, so if it still reports, check carefully because it may be
real taint through an intermediate variable.DB::select('select * from users where id = ?', [$id]);
For sort columns, map input through an allow-list:
$sort = in_array($request->input('sort'), ['name', 'created_at'], true) ? $request->input('sort') : 'name';
$query->orderBy($sort);
MASS_ASSIGNMENTModel::create($request->all()) while the model has no
$fillable/$guarded. Also covered: updateOrCreate()/firstOrCreate()/
updateOrInsert()/firstOrNew() with tainted data in either argument, models
whose $guarded = [] is explicitly empty (that guards nothing — everything stays
mass-assignable), and Model::unguard() / User::unguard() (global unguard —
every attribute of every model becomes fillable; the called class is resolved
through use imports against the scanned model files).->forceFill($request->all()) — bypasses $fillable/$guarded
by design, so it is flagged even when the model declares $fillable
(seeder-style forceFill([...literals...]) stays silent).Model::unguard(false) (explicit re-guard intent).app/, add the scan path containing the models so this rule takes effect,
and list the directory in analyzers.models_dirs (e.g. app/Domain/Shop/Models).$fillable or use a FormRequest + validated().OWASP_BROKEN_ACCESS_CONTROLstore/update/destroy/…) gets one
verdict per route context — protected / review / exposed / unknown — never a
bare boolean. The finding carries the evidence: controller, action, route
(methods + URI), full inherited middleware stack, authorization_evidence, and
semantic_status.auth/auth:api/token guards (sanctum,
jwt, API-key guards) and verified prove identity only. Auth-only routes yield
review findings (Error severity, medium confidence — visible but non-blocking)
instead of being suppressed. Only ability checks suppress: can:*, permission:*,
role:*, configured analyzers.extra_middleware fragments, and local evidence
($this->authorize(), Gate::authorize(), enforcing denies() branches,
authorizing FormRequests, $request->authenticate()/Auth::attempt() credential
verification, hasValidSignature()/hash_equals() capability proofs).admin, owner, checkLevel,
signed, … sound protective but their implementation is unknown to the engine —
recorded as evidence for human review. (signed/verified/throttle are explicitly
never authorization; throttle/guest/web/api carry no access meaning, so
routes guarded only by them are exposed.)exposed finding citing that route — contexts are never merged into
“has auth”. Actions with no resolvable route stay unknown and keep the fail-safe
finding (Error, high confidence).LaravelSemanticIndex, which reads route files (/routes/,
/Routes/, web.php/api.php, *ServiceProvider loading) and understands:
Route::middleware(...) / ->middleware(...) chains, nested
Route::group(['middleware' => ...]) (middleware inherited through every level),
Route::controller(X::class) with the action as a bare string,
Route::resource()/apiResource() (with only/except), Route::match()/any(),
and require/include of route files inside a group closure (a required file
inherits the middleware stack, it is not parsed standalone).
base_path()/app_path() targets are resolved by walking up to the project root.
Groups nested in top-level guards (installer checks, maintenance mode) are
descended with the ambient stack.
Both the legacy array syntax (['as' => ..., 'uses' => 'FQCN@method']) and
[Controller::class, 'method'] are resolved. Actions are matched by
FQCN (use imports are resolved) so two same-named controllers in different namespaces
(Admin vs Shop API) are not mixed up. Disable with
analyzers.owasp.route_middleware => false (no route contexts → every action is
unknown and flags, the legacy method-only behavior).Docs
of one pilot — example classes named *Controller that never run).review: 24 behind the
project-specific authorize:superuser gate middleware — human-verified as
Gate::allows() authorization, clearable via extra_middleware or a future
middleware-alias resolver — plus 3 pure-auth endpoints; 4 kept exposed/unknown
unchanged). Linkstack 6 → 30 (0 suppressed, 24 new review: 11 behind the
project-specific admin role middleware and 3 behind the ownership-checking
link-id middleware — both human-verified as real authorization — plus 10
auth-only studio/auth endpoints including genuine IDOR surface such as
UserController@deleteLink; 6 kept unchanged).review findings behind custom
middleware are re-examined in two tiers. Tier 1 resolves the alias to a class
(App\Http\Kernel::$routeMiddleware / $middlewareAliases,
bootstrap/app.php $middleware->alias([...])) and locates the class file by
*Middleware/<Short>.php convention — symbol resolution only. Tier 2 inspects
handle() for bounded patterns: Gate::allows()/authorize(), $user->can()
with a deny-shape (abort*(), throw, HTTP 403) on the failing path, and
role-literal guards (->role/is_admin vs literal) passing to $next() with a
deny (high confidence) or redirect (medium) fallback. A trigger only counts when
it structurally gates $next() — nested inside unrelated conditions (e.g. a role
check conjoined with a maintenance flag) it gates a branch, not the request, and
stays review. Ownership checks ($user->id != $link->user_id), redirect-only
fallbacks, truthy flags, and inverted role checks are explicitly out of scope.
Proven middleware suppresses with composable evidence
(type/alias/class/method/mechanism/ability/source/confidence);
resolved-but-unrecognized middleware is attached to review findings as
middleware_resolution so humans can clear it in seconds. extra_middleware
remains the escape hatch for middleware static analysis cannot understand.authorize:superuser proven via
CheckPermissions::handle() → Gate::allows() + 403; 3 genuine review kept).
Linkstack 30 → 19 (11 admin proven via role guard; the 3 link-id ownership
routes — including the UserController@deleteLink IDOR surface — correctly stay
review; 0 new findings on both pilots).$a = 'x', $b = $a, $c = $a . 'y'),
__DIR__/__FILE__, base_path()/app_path()/... helpers and trivial
Foo::class — through ConstantValueResolver, shared by the whole semantic
engine. Conservative rules: one assignment per variable per scope (any
conditional, repeated or later assignment → unknown), assignments must precede
the use in the same function scope (no backward resolution, no cross-function
leakage; enclosing registration-closure bodies contribute visibility), depth cap
16 with cycle protection. config(), env(), request(), sprintf(), method
calls and everything else stay unknown. Every value carries provenance
(literal → variable → concat → use-site). The index reports coverage:
total/resolved/full/partial/unknown plus unknown reasons (dynamic-variable,
function-call, conditional-assignment, unsupported-expression,
unresolved-include). Snipe-it/Linkstack BAC results are byte-identical to
v0.3.3; Voyager stays 0 resolved with every unknown explained (37
config-based, 6 loop-varying BREAD controllers) — config() support is
deferred, and false resolution is worse than unresolved.FormRequestIndex, exposing composable
ValidationEvidence (source/fields/confidence) and AuthorizationEvidence
(mechanism/ability/confidence). rules() presence counts even when dynamic —
but then fields are unknown (medium confidence, never false-safe); a resolved
FormRequest without rules() is no validation evidence. authorize()
returning literal true (or absent) is explicitly not authorization;
can/Gate/deny/403 patterns are strong evidence, other non-trivial bodies
stay protective (medium). Inline $request->validate([...]),
Validator::make(...) and validated()/safe() are recognized separately
(validated-use is the bridge later mass-assignment work needs). Unresolvable
*Request hints keep the legacy name-heuristic suppression (low confidence).
BAC consumes the same evidence with unchanged outcomes; Voyager config()
unknowns are kept as the future config-semantics benchmark.PolicyRegistry resolves
model → policy (AuthServiceProvider::$policies, Gate::policy()) and
ability → Gate::define() (trivial fn () => true defines are not
evidence, mirroring the authorize() => true rule). Convention alone
(Post → PostPolicy) never maps — unregistered chains stay
medium/unresolved, never guesses. ModelTypeResolver recovers subject
types from parameter hints, single straight-line Model::.../new Model
assignments and one variable hop; properties and dynamic expressions stay
unknown. resolveAuthorizeCall(ability, model) returns the full chain
(model → policy → method, high) when proven, ability-only (medium) for
defined abilities without a model, unresolved (medium) otherwise. Policy
methods only need to exist — the framework enforces their boolean, so no
body analysis is required (unlike middleware gates). Self-enforcing calls
($this->authorize(), Gate::authorize()) fail closed at runtime and
therefore never flag, even with unresolved chains; bare Gate::allows()
without an enforcing branch still flags (polarity preserved). BAC outcomes
are byte-identical on both pilots (7/7, 19/19); authorizeResource()
per-action mapping stays a sub-wave.MassAssignmentFlow
classifies each mass-assignment sink argument by provenance — raw request
(all()/input()), validated (validated()/safe()/validate()),
bounded (only([...]), safe()->only([...])), internal (literal arrays),
unknown (service returns, conditional raw/safe mixes, dynamic keys) — with
except() exclusions recorded and forceFill/forceCreate distinguished
as guard-bypassing sinks. Variable propagation reuses the shared engine
(AssignmentMap::visible + same-scope filtering + single-assignment
discipline). validated never implies mass-assignment safe. Findings carry
the classification as flow_provenance metadata (source → propagation →
sink trace) without changing any decision; the legacy analyzer is untouched
otherwise. Snipe-it probe: 194 sinks classified (98 internal, 51 unknown,
38 raw, 4 validated, 2 bounded) against 1 legacy finding (unguard() —
no data flow by design); linkstack 28 sinks, all internal. The 38 raw
instance-fill() flows the legacy analyzer cannot see are the v0.4.2
escalation pool, once $fillable/$guarded field-set reasoning lands.verdict/input_kind/source/sink/
model_protection/bypass/confidence/flow), UNKNOWN and
no-context sinks keep the byte-identical legacy heuristic. Framework
default-guarded models (neither property declared) suppress with
evidence; partially-guarded models with raw input become REVIEW instead
of silent. Findings answer the whole chain
($request->all() → $data → $user->fill($data) → model → guarded
→ decision). Snipe-it 1 → 3 (gone 0; 2 new REVIEW both human-verified:
CustomFieldsetsController@store/update fill raw input into a
guarded = ['id']-only model — authorized but all-but-id assignable);
linkstack 0 → 0. Protocol from here on: every new EXPOSED/REVIEW and
every gone finding gets 100% human review before the semantic engine
earns production-outcome power.--profile (or QUALITY_CHECKER_PROFILE=1) emits timers, parse /
file-visit amplification and AstPool stats as profile.json. Findings
are byte-identical with the profiler on or off (linkstack 108/108 with
matching rule distribution; snipe-it 500/500; corpus 1.000).
MethodSummaryIndex summarizes one call boundary deep — param-derived
returns (PARAM(i) + operation, never context-free RAW), literals as
internal, mixed provenances as unknown — with cache, cycle guard and
full perf counters (requests/hits/misses/methods/AST-parses/cycles).
Receivers resolve only when statically proven (promoted/typed/constructor
DI, new X, app(X::class)); untyped properties, containers, facades,
magic methods and dynamic dispatch stay unknown with first-class reasons
(dynamic-receiver, unresolved-class/method, mixed-return-provenance,
nested-call, cycle, depth-limit). The mass-flow classifier consumes
summaries opt-in; production analyzers pass none, so outcomes are frozen
by construction. Shadow over 534 real unknowns (snipe-it, invoiceninja,
firefly): 0 resolved, 0 incorrect — the dominant pools are parent-class
methods (explicitly out of scope), untyped receivers and non-call shapes;
the pattern set is proven by a 17-case A–T corpus instead. Summary cost
on snipe-it: 19 requests, 11 AST parses, 0.07s.parent::, protected members only through
$this, multi-level chains with cycle/depth guards, declaring class +
visibility in every summary’s provenance. Traits, interfaces, container
dispatch and magic methods stay out. Snipe-it 19 unresolved-method
candidates: Target Resolution 19/19 (all declaring classes found),
Return Resolution 0/19 (7 nested-call, 12 unsupported-expression —
verified by hand: collection pipelines, dynamic only($model
->getFillable()), closure callbacks; correctly unknown). Incorrect: 0.
Per the standing rule (no real resolutions → no production
integration), interprocedural machinery stays dormant; cost 0.14s.ROUTE_MISSING_VALIDATION
finding carries an evidence_trail proving the negative decision —
which of FormRequest-param/rules()/$request->validate()/
Validator::make()/validated() was searched and whether each came
back found, missing, or unresolved (dynamic shapes are never labeled
missing). EvidenceTrail is the shared abstraction so later rules
(BAC, mass assignment, SSRF, …) can prove their negatives the same way.
Finding count, severity and messages are byte-identical on both pilots
(100/100, 3/3) — explainability without outcome drift.ModelMetadata returns state, never bare arrays — fillable non-empty
wins (FILLABLE), $guarded = [] guards nothing (UNGUARDED), ['*'] or
neither property declared (framework default) guards everything
(GUARDED), explicit lists stay lists, dynamic values are UNKNOWN (never
empty). A non-seed Model::unguard() without reguard() marks every
model unguarded; scoped unguarded() does not. MassAssignmentDecision
reasons Source × Model: raw into unguarded → EXPOSED; validated/bounded
into unguarded → REVIEW (validated is never auto-safe); force*
bypasses model protection (raw → EXPOSED, else REVIEW); fillable and
guarded-all models → SAFE; guarded-list → REVIEW unless bounded input is
fully guarded; internal data → SAFE; unknown model/input → UNKNOWN.
Instance targets resolve via params, single straight-line
Model::.../new Model assigns (later reassignments do not poison
earlier uses) and one variable hop. The analyzer is untouched — shadow
only. Snipe-it 38 raw flows: 34 SAFE via $fillable (sampled),
2 REVIEW via $guarded = ['id'] (CustomFieldset), 2 UNKNOWN via
loop-nested assigns; 0 EXPOSED, and no $guarded = [] exists in its
models, so zero is correct rather than a miss. Full-pilot verdicts:
snipe-it 138 safe / 2 review / 53 unknown, linkstack 28 safe.OWASP_SSRF / OWASP_COMMAND_INJECTION / OWASP_SSTI`...`) with any dynamic part is always reported
as command injection — there is no escaping mechanism for backticks, so no exemption applies.tests/, Test.php) — applies to SSRF,
command injection, XXE;fopen() in write/append mode (w, a, x, c, …) — creates a local file,
not a server-side request;$path, $file, $source, $fullPath, …) with no remote hint
($url, $endpoint, …), or built from storage_path()/base_path()/
public_path()/…escapeshellarg()/escapeshellcmd(),
and new Process() with an array command (no shell — even when
the array is in a $command = [...] variable in the same function).pcntl_exec(), backtick execution with any dynamic part,
and Process::fromShellCommandline() with user input are always reported —
backticks have no escaping mechanism and shell-commandline strings always
go through a shell.PHP_BINARY, DIRECTORY_SEPARATOR), Laravel path helpers
(base_path()…), escapeshellarg()-wrapped values, and variables assigned from
those parts in the same function (e.g. passthru(PHP_BINARY." $artisan ...")
with $artisan = base_path('artisan') — variables assigned from method calls
such as $v = $this->getVerbosity() are still reported).new Process() with an array command — including via an
array type-hint, @param array docblock, or a ternary choosing between
arrays.mail() is only reported when the 5th argument
($additional_parameters, passed to sendmail as CLI flags) is present —
recipient/subject/body/headers alone cannot inject shell flags.foreach loop variables over an array literal or class constant
(foreach (self::LIST as $item) — deploy-time values).new GuzzleHttp\Client([...]) is not a sink (the constructor only takes
a config array — the real request at ->get()/->post() is covered
separately); for Guzzle $client->request($method, $url) the URL is the 2nd argument;
->getRealPath()/->getPathname() (UploadedFile/SplFileInfo)
are always local paths; fixed-literal-host URLs
(sprintf('https://example.com/...', $v), including via intermediate variables)
are not SSRF; env()/config() are deploy-time,
even when wrapped in rtrim()/sprintf()/all-deploy-time concatenation.$file, $path, $source, $outputDir, $tmp, $dest…) count as local
paths — unless the root is $request/request(); ?-> nullsafe chains
follow the same rules; $this->prop assigned only deploy-time values
(SSRF: env() in constructor) counts as deploy-time; copy()/readfile()/
file() count as read sinks;
fsockopen()/pfsockopen()/stream_socket_client() read the host from
the first argument (literals like 'localhost' stay silent);
curl_setopt($ch, CURLOPT_URL, $url) reads the URL from the third argument;
dynamic include/require is always reported (LFI to RCE, no exemption).LIBXML_NOENT is not protection — it substitutes entities (enables
XXE). Only LIBXML_NONET / libxml_disable_entity_loader(true) silence
a file.glob()/scandir()/readdir() (including via foreach loop variables and
$list[$i] element reads: foreach (glob(...) as $f),
$uploads['k'] = glob(...)), Storage::files()/allFiles(),
File::files(). Deploy-time path builders count too: dirname()/
basename()/realpath() over deploy-time values, __DIR__/__FILE__.
URLs gated by a *sanitiz*() call in the same function stay silent
(if (!sanitizeRemoteUrl($url)) throw ...;).$viewName = 'front.pos_' . $industry, Blade::render('...' . Color::Gray[400])),
template registry properties ($this->views['show'], $this->template),
non-public helpers where every same-file call site passes a literal for the
template parameter (public helpers are not exempted because they can be called from another file),
and variables gated by an in_array() allow-list of literals in the same
function (if (in_array($type, ['a', 'b'])) { view("x.{$type}"); } — the
list may be inline or a variable assigned only literal arrays; a dynamic
list variable stays flaggable).View::make()/composer()/creator() in addition to
view()/Blade::render(); named url:/uri:/path: arguments are read
as the SSRF target instead of the first positional argument.XMLReader::open($uri) / $reader->open($uri) in
addition to simplexml_load_* / DOMDocument::load* / new SimpleXMLElement.shell_exec('where ' . escapeshellarg($tool)), new Process($command) with
an array from config, and the updater uses escapeshellarg(base_path()) —
all 4 command injection findings resolved automatically; likewise for 9 SSRF findings
(local files + test fixtures).INSECURE_HASHmd5()/sha1() — or hash('md5'|'sha1'|'md4', ...) —
on a password in a credential context.rand()/mt_rand()/uniqid() assigned to a token/secret
variable ($otp, $resetToken, ['verification_code' => ...], returned from
a generate*Token() function) — predictable RNG for security tokens; use
random_int()/random_bytes(). Counters, offsets and filenames stay silent,
and random_int()/random_bytes() never flag.pwnedpasswords — HIBP k-anonymity only sends
the first 5 characters of the SHA-1 hash to the API, it does not store passwords with SHA-1.INSECURE_COOKIECookie::queue()/make()/forever(), the cookie() helper,
or ->cookie() on a response omits the $secure flag (Warning — relies on
session.secure_cookie, verify it) or passes literal false (Error).
Literal true or a config-driven expression stays silent.SESSION_FIXATIONAuth::attempt(), ->login()/
->loginUsingId()) has no session rotation in the same function. Either
session()->regenerate() or regenerateToken() counts.WEAK_PASSWORD_POLICYPassword::min(N) with N < 8, a password validation rule
with min:N below 8, or a password rule with no length floor at all
(including $rules variables returned from rules()).'password.min' => '...'
names a message, not a field), non-password fields, min:8+, and rule
objects such as Password::min(8).ROUTE_MISSING_VALIDATIONstore/update/delete/…)
shows no $request->validate()/validated()/Validator::make() call and no
FormRequest parameter.use
imports (store(StoreRequest $request) with
use App\Http\Requests\StoreRequest; — the short name alone used to miss).MIGRATION_DESTRUCTIVE_UPup() drops a table/column that down() does not restore —
including raw DROP TABLE/DATABASE, TRUNCATE, and ALTER TABLE ... DROP COLUMN
via DB::statement()/DB::unprepared().down() (for example a dropped column guarded by
Schema::hasColumn + down() recreating the column).dropIndex, dropUnique,
dropForeign, dropPrimary, dropTimestamps) — no data rows are lost,
recoverable from the schema.OWASP_OPEN_REDIRECTredirect() / ->away() / ->to() /
->intended() / Redirect::away() / Redirect::intended() /
response(...)->header('Location', ...) is a variable, call, or concatenation
containing a dynamic part.str_starts_with() prefix gates with reassignment
(if (!str_starts_with($v, $safe)) { $v = '/'; }), early enforcement
(throw/abort/return), and guarded ternaries count; suffix/contains
checks do not. Shared primitives (ScopeResolver, AssignmentMap,
GuardMap) back SSRF and traversal too, and findings carry a flow
trace (source → propagation → sink) in metadata.redirect()->route() / Redirect::route(), back(),
string literals, url()->previous(), config()/env() (including concatenation where
every leaf is safe, e.g. redirect(config('app.url') . '/done') — including via
an intermediate variable $url = config(...) . '/login'),
url() with all-literal arguments, concatenations led by a host-pinning call
(redirect(route('index') . $from) — the framework host cannot change, only the
path varies; note url($dynamic) is NOT safe because url() returns
already-valid URLs unchanged), sprintf() with a fixed-host format
(sprintf('https://oauth.host/authorize?%s', $query) — including via a
variable assigned that literal), no-argument $request->url() concatenations
(current-URL getter), SDK-signed storage URLs
(Storage::disk()->temporaryUrl(), ->getPresignedUrl()) and OAuth SDK
authorization URLs (->getAuthorizationUrl() — the host is the configured
provider), and *Safe* methods (getSafeUrl(),
getSafePreviousUrl() — same naming-convention trade-off as *Html).$request->input() / dynamic concatenation = High;
redirect($page->getUrl()) (method/property/static — usually an internal
URL builder) = Medium. Run --min-confidence=high to see only the
most dangerous group.redirect()->route('home') instead of redirect($request->input('next')).OWASP_PATH_TRAVERSALfile_get_contents, Storage::get/putFile,
File::get (facade/Filesystem), unlink/rename, response()->download,
include $var, …) receives a dynamic path.storage_path() with literal arguments),
basename()-wrapped values, env()/config(), local-named variables ($file, $path,
$outputDir…) except when rooted at $request, getRealPath()/getPathname() methods,
File::/Storage::delete() (deleting a file cannot exfiltrate or include its contents),
and paths from tempnam()/tmpfile()/sys_get_temp_dir() (fresh server-side temp files).fopen($x, 'wb') is still reported — writing a file to the
wrong place is a real vulnerability (unlike read-only SSRF). Use an inline-ignore once reviewed.basename() to the input or pin the base directory:
Storage::get('docs/' . basename($name)).OWASP_BLADE_XSS{!! ... !!} contains a $variable or request( in
*.blade.php.request(...), $request,
$_GET/$_POST) is Error/High; other dynamic output is Warning/Medium —
it may be pre-sanitized or intentionally trusted HTML.{!! csrf_field() !!} (no dynamic data),
explicit sanitizers (e(), sanitizeHtml(), strip_tags(),
htmlspecialchars(), purify(), clean(), md_to_html(),
markdownHelp()/markdownNotes() (HTMLPurifier), excerpt() (tag-stripped
summary convention), plus project-specific functions
listed in analyzers.extra_sanitizers), ternaries with all-literal branches
({!! $checked ? 'checked' : '' !!} — only branches render; Elvis $x ?: 'd'
still flags because it renders $x), json_encode() with all 4
JSON_HEX_* flags (missing flags are still reported — </script> breakout is real),
framework event hooks (view_render_event(...) — output from internal
listeners), form builders (Form::/Html:: — values escaped by the
builder), paginator ->links() / ->appends()->render(), ``
(escaped syntax), numeric formatters (Number::currency()/format()/…,
format_amount_by_symbol()/_code/_currency/_account — NumberFormatter
float-cast output cannot carry markup), and sanitized-HTML conventions: *Html/*Rendered/*Sanitized
variables or ->getHtml()/->renderedHTML methods/properties
(markdown rendered and purified at the model layer).{!! ... !!} + an inline
ignore for reviewed HTML.OWASP_BLADE_DYNAMIC_INCLUDE@include/@extends/@includeWhen/
@includeFirst/@each) takes a dynamic view name (@include($view),
@extends('layouts.' . $theme)). A steerable template name loads and
executes unintended PHP (LFI vector, not XSS), so it is reported with
Medium confidence (Blade has no data-flow analysis — string literals and
config()/env() stay silent).HARDCODED_SECRETsk-, AIza, AKIA, private keys, … Obvious fixtures
in test paths are skipped (whsec_test_secret, AKIA...EXAMPLE — matched
values containing test/fake/example/sample/demo/mock/dummy/changeme/
placeholder/xxx). Production values with those markers still flag. For other
test fixtures, either use clearly fake values, or exclude the test directory
from the security scan paths.FEATURE_X = 'x_value' /
PASSWORD_FIELD = 'password' / define('ROUTE', 'route.name') where the
UPPER_SNAKE name and the slug value name each other (either normalized form
contains the other). Real secrets in constants (API_KEY = 'sk-live-...',
DEFAULT_PASSWORD = 'admin123') still flag — as do variable assignments.$credentials['password'] == 'long-literal') are
reported as master-password pattern — including Yoda order. Variable-to-variable
comparisons stay silent.evidence: matched prefix (first 32 chars), length,
Shannon entropy, known token prefix (sk_live_, ghp_, …) or null, and
whether it looks like a test fixture.OPT_DB_PASSWORD = 'db-password') are skipped —
the right side names a CLI option, it is not a credential.xxx, changeme, or empty strings in config files are reported
by OWASP_MISCONFIGURATION (warning level) — replace them with env().OWASP_MISCONFIGURATION'debug' => true, APP_DEBUG=true),
CORS allows wildcard origins, a placeholder/empty secret is present, or session
cookies weaken their flags ('secure' => false, 'http_only' => false,
SESSION_SECURE_COOKIE=false, same_site => 'none')./config/, .env), never tests/seeders.UNSAFE_EVALeval()/assert()/create_function()/call_user_func()/
call_user_func_array() receive a non-literal first argument. Only the callable
position is checked — tainted arguments to a literal callable are the callee’s business.[$this, 'handle'],
$this->callback properties holding internally-assigned handlers,
app('router')/resolve(...) container lookups with all-literal arguments),
assert() with provably-boolean arguments (instanceof, comparisons,
empty()/isset(), is_*() predicates), and expressions
whose every dynamic leaf was validated by preg_match()/preg_match_all() in the
same function (e.g. a math expression allow-listed before eval). Filter strength itself is
not verified — review the regex.UNSAFE_UNSERIALIZEunserialize() / yaml_parse() / yaml_parse_file() /
yaml_parse_url() receive a non-literal argument (YAML tags can instantiate
PHP objects). Literals (including class constants) are skipped.unserialize($data, ['allowed_classes' => false]) —
with object instantiation disabled the object-injection vector is closed.DISABLED_CSRF_EXCEPTION_STAR$except contains a wildcard beyond api/* (e.g. *,
admin/*) — broadly disables CSRF.api/* is excluded — acceptable for a stateless
API, but verify that no session-authenticated route exists under /api/.low heuristic group (DEAD_CODE, NAMING_CONVENTION, TODO_FIXME, MISSING_*_TEST)analyzers.test_coverage.*, analyzers.convention.* =
false). If you enable them and see a flood of warnings, that is expected —
do not baseline them in bulk, turn them back off and only enable them for themed cleanup.MISSING_*_TEST means “no directly associated test detected”, not “untested”:
the class may be covered by feature/API/E2E suites under unrelated names.
They report at Info/Low for exactly this reason — wire real coverage data
instead of gating on filenames.0 = gate passed, 1 = quality gate failed, 2 = checker or
internal error (e.g. context build failure). Safe for GitHub Actions, GitLab
CI, Jenkins, pre-commit and pre-push hooks.quality_gate.ignore (config) and --ignore= (CLI, comma-separated) hide
rules from reports and the gate, e.g. MISSING_MODEL_TEST noise on legacy
projects. --fail-on= sets the severity threshold, --min-confidence=
drops low-confidence findings from the gate.baseline.json present, every run prints Delta vs baseline: X new,
Y fixed, Z existing (console), plus a delta block in JSON and a Delta
card in HTML. Moved lines count as new+fixed (same trade-off as baselining).confidence_score (High 1.0 / Medium 0.5 /
Low 0.25), shown in JSON and next to the confidence badge in HTML. The
Quality Score dashboard deducts severity x confidence per finding, so
low-confidence noise cannot nuke the score — and only high-confidence
severe findings block the release gate.# 1. See the full picture without failing
php artisan quality:check --format=all --fail-on=none
# 2. Focus on high-confidence findings first
php artisan quality:check --min-confidence=high --tier=security --fail-on=none
# 3. Fix what can be fixed in code (binding, fillable, validation, authorize)
# 4. Accept the reviewed remainder as baseline
php artisan quality:check --baseline-generate
# 5. From now on CI only fails on NEW findings
php artisan quality:check --ci --baseline-file=baseline.json
Commit baseline.json so the whole team shares the same threshold. Only run
--baseline-update after re-reviewing the full new report.
Optimization stays P2 — no caching layers before profiling. Budgets (Snipe-it, 713 files, measured):
| Stage | Warm | Notes |
|---|---|---|
| Semantic index build | ~0.2s | 26 route/provider files, AstPool 25 hits / 26 misses |
| BAC analyze (incl. registry + handle inspection) | ~3.0s | per-file parse dominates; middleware inspection parses one file per distinct alias |
| Cold full BAC scan | < 30s | first-touch outlier 28.7s (file cache + autoload); warm ~3–4s |
Rules going forward:
ParserFactory per file).PERF-EVAL-1 (measured, Snipe-it 8629 files / Linkstack 225 files): parsing ~80% of wall time; 151116 parse ops for 7976 parsed files = 18.9x amplification (22 analyzers x N files); IO 10.8x; visits 22x; superlinear scaling at small N. Snipe-it no-cache full run ~449s, Linkstack ~17.2s.
PERF-OPT-1 — shared ScanContext (source + AST + failure cache, one parse
per file for the whole analyzer engine; parent links attached once):
all 22 profiled analyzers read via sharedSource()/sharedAst(), injected
by CustomAnalyzerChecker; private parse()/readFile() deleted;
architecture test ScanContextArchitectureTest forbids new
ParserFactory / ->parse( / file_get_contents( in src/Analyzers/**
except AbstractAnalyzer (unit-test fallback) and TaintEngine
(separate opt-in engine, off by default).
Results (old code vs new, same machine, no cache):
Snipe-it 500 = 500 findings, rule histogram byte-identical,
parse_operations 158615 -> 16289 (9.7x), wall 448.6s -> 272.4s (1.65x);
Linkstack 108 = 108, histogram identical, parse_ops 4315 -> 512 (8.4x),
wall 17.2s -> 13.4s. Analyzer-engine AST amplification is now 1.0x
(8628 misses = 8628 parsed files); remaining ~7661 parses come from the
semantic indexes (second parse engine — PERF-OPT-2 candidate). Gate for
this wave was <150s warm; at 272s no-cache the gate is NOT yet met —
next lever is sharing ASTs with the semantic indexes, not more
analyzer work.
PERF-OPT-2A — semantic consumers join the canonical AST.
Audit of src/Semantic/** + src/Analysis/** found 8 private-parse
sites, classified:
MUST SHARE (parse plain application source already covered by the
run context): LaravelSemanticIndex (via AstPool), MiddlewareRegistry,
MiddlewareInspector, FormRequestIndex, ModelMetadataIndex,
ModelMetadata (static fromFile takes ?ScanContext), PolicyRegistry,
MethodSummaryIndex (keeps its own lookup cache + ast_parses stats,
filled from the shared tree).
NEEDS SPECIAL AST: none — no index uses NameResolver/custom visitors
or reads parent attributes.
INTENTIONALLY INDEPENDENT (named, arch-test allowlisted): ScanContext
(owner), AstPool (parser infrastructure, kept for its tests/direct
consumers), AbstractAnalyzer private fallback (unit tests), TaintEngine
(opt-in engine, off by default).
Analyzers hand their context down (sharedScanContext()); indexes
implement ScanContextAware with per-instance fallback so unit tests
keep calling build() directly.
Canonical AST contract (enforced in ScanContext, guarded by
ScanContextArchitectureTest::testParserOwnershipAcrossSrc over all
of src/**): parsed once, parent links attached, same tree shared,
read-only consumers, failures cached, enrichment centralized — a
consumer needing more must promote it into the contract, never parse
a side tree.
GC parking: canonical trees carry parent links (cycles) and are
retained by design, so the Zend collector rescanned a growing cyclic
graph (~2x wall on Linkstack: 27.5s -> ~15s; gc_runs = 0 during the
run, peak unchanged). CustomAnalyzerChecker::run() wraps the
analyzer loop in gc_disable()/gc_enable() (finally) with the
reason documented inline — refcount still frees temporaries.
Results (OPT-1 vs OPT-2A, no cache): Snipe-it 500 = 500, histogram
identical, parse_operations 16289 -> 8628 = exactly the 8628 parsed
files, global amplification 1.89x -> 1.0x; wall ~272s -> ~194s on a
noisy box (observed 194-436s across runs — wall needs PERF-EVAL-2
methodology with repeats, not single samples). Linkstack 108 = 108,
parse_ops 512 -> 222, ~15s. Semantic duplicate parses ~= 0. The <150s
gate is still open — but parse work is now done; what remains is true
compute cost, to be profiled in PERF-EVAL-2 before any algorithm
changes (SqlInjection untouched until then).
PERF-EVAL-2 — true compute profile (Snipe-it 1 warm-up + 5 measured
runs, median/min/max; Linkstack GC A/B 3+3; snipe-it/app subset A/B).
Instrumentation only, no optimization: CountingNodeFinder (exact
nodes-visited via filter wrapping; instanceof/first variants delegate
internally so all four entry points are covered; profiler-off path
delegates untouched), NodeCountVisitor sharing the single canonical
linker walk for the tree census (zero extra traversals), per-analyzer
attribution via the timeAnalyzer context (inclusive of each
analyzer’s semantic-index work), semantic-index phase timers in the
3 wiring spots, per-analyzer issue counts, and a VV_NO_GC_PARK=1
kill-switch for the GC experiment. Phase note: canonical-parse and
semantic-index times are subsets of analyzer wall (lazy parse on
first touch; index builds at analyzer heads), not disjoint phases.
Totals (runs 163/165/166/167/247s, median 166.1s): 500 findings,
rule histograms byte-identical across all 5 runs, parse amplification
1.0x (8628/8628), traversal amplification 112x, node amplification
136x (250M node visits over 1.8M canonical nodes) — the repeated
traversal hypothesis is confirmed at full scale. Peak 1252MB,
gc_runs=0 (parked). Canonical-parse subset 9.2s; semantic-index
subset 0.93s — neither is the bottleneck; analyzers phase 165.5s of
166.3s total.
Top-5 true compute (median wall | traversals | nodes | ns/node):
OpenRedirect 76.7s | 367K | 126M (half of ALL node visits) | 607,
stable — pure repeated traversal. MassAssignment 18.7s | 135K |
31.6M | 590. Ssrf 17.2s | 99K | 27.6M | 624. SqlInjection 11.5s
[11.4-66.9s!] | 8.6K (1/file) | 1.8M | 6253 — 10x the uniform ~600ns
of every other AST analyzer: per-node logic/dataflow blowup, not
traversal count, plus wild run variance needing its own
investigation. Ssti 9.5s | 103K | 15.5M | 610. ns/node is uniform
~600ns everywhere else, so cost ~= visit count.
Zero-yield/high-cost markers (absence proofs, future prefilter
candidates — not useless rules): SQLi 11.5s/0, CommandInjection
6.5s/0, Xxe 1.8s/0, InsecureHash 1.2s/0, UnsafeDeser 1.1s/0,
LaravelTaint 1.1s/0, DisabledCsrf 1.1s/0, InsecureCookie 0.9s/0.
Regex/source analyzers (BladeXss, Misconfiguration, HardcodedSecret)
show trav=0/N/A by design — ns/node is AST-only.
GC A/B verdict: Linkstack parked 8.9s vs normal 9.0s (in noise);
snipe-it/app subset ranges overlap (parked 117-153s, normal
108-147s) — no measurable effect either way on this box. The early
27s linkstack anomaly never reproduced (environmental load, same
window as the 436s snipe-it outlier). gc_collected=0 in every
normal-arm run proves collections reclaim nothing
(retained-by-design graph), so parking is kept on architectural
grounds — skips useless full-graph scans at zero peak cost —
reversible via VV_NO_GC_PARK, no further GC work.
Decision: node amplification 136x + uniform ns/node → PERF-OPT-3 =
shared fact/node index (one traversal, analyzers query facts) for
the traversal-bound group (Redirect/Mass/Ssrf/Ssti/Command). SQLi
goes its own track (10x ns/node + variance). Median 166s > 120s →
performance work continues.
PERF-OPT-3A — Redirect on the StructuralFactIndex (PASS).
Blowup anatomy (per sink!): ScopeResolver::funcId = functions()
traversal + one containment find PER function; AssignmentMap::visible
= functions() traversal; guard scans = per-scope finds + per-node
funcId. ~42 traversals/file → 367K traversals / 126M nodes for
Redirect alone. New Analysis\StructuralFactIndex (ScanContextAware,
lazy per file): ONE combined find per file buckets canonical refs —
calls (FuncCall|MethodCall|StaticCall), functions
(ClassMethod|Function_), assignsAndIfs (Assign|If_) — in traversal
order, so every consumer sees the identical node set a direct find
would return. No named buckets yet (string compares were never the
cost). No copies/wrappers/interpretation; dataflow primitives
untouched. funcId replaced by a parent-link walk (outermost-wins,
proven equivalent to traversal-order first-containment, incl. the
nested-function edge); per-scope guard scans iterate the facts with
the same subtree+funcId filter. AssignmentMap::visible and traceFor
kept as-is (per-sink/per-finding, now the residual).
Gates (2 warm-ups + 3 measured, Snipe-it): Redirect 76.7s → 1.8s
median [1.6-1.8s], visits 126M → 440K, findings 22 identical,
Linkstack 108 + redirect 7 identical, corpus 1.000, FactIndex build
1.0s (<10s), peak unchanged 1252MB (<1.6GB), global parse 1.0x.
Full median 166s → 105.6s (98.7/105.6/106.0); node amplification
136x → 68x; traversal 112x → 70x. Next bottleneck by median: Mass
22.1s, Ssrf 19.6s, SQLi 12.5s (frozen track), Ssti 11.1s, Command
7.3s. → OPT-3B (Mass + SSRF).
PERF-OPT-3B — Mass + SSRF on the shared index (PASS).
Anatomy first (no blind migration): Mass per sink = funcId storm
(functions() + N containment finds) + useMap/namespaceOf/Use_ finds
per file + MassFlow method-subtree finds per candidate (kept:
shared semantic, method-scoped, bounded). SSRF per file =
origins() (1 traversal, kept) + GuardMap containment storm +
full calls find + funcId per EVERY call + per-sink subtree finds
(kept: cheap). New shared helper Analysis\ScopeIds::outermost
(O(depth) parent walk, proven equivalent to traversal-order
first-containment — OpenRedirect’s private version promoted).
FactIndex extended rule-agnostically (same single pass, refs only):
combined callsAny (+Nullsafe/ New_), foreaches, properties, uses,
namespaces. calls() becomes an order-preserving filter over the
combined list (Redirect behavior unchanged). Named buckets
deliberately DEFERRED: both analyzers do ordered full scans, so
buckets would only pay off with skip-logic (future prefilter work,
not 3B) — no overengineering for a hypothetical.
Mass: discovery + funcs + uses/namespaces + 3 funcId sites + guard
properties via facts; decideCall/decideSink/classify/decision
untouched. SSRF: discovery + sanitiz-gate partition + per-call
funcId via facts + ScopeIds; origins(), subtree finds, and all sink
semantics untouched. GuardMap/ScopeResolver/AssignmentMap unchanged.
Shadow equivalence (profile-only Profiler::countDecision):
linkstack Mass safe 2/unknown 9, SSRF 38 candidates → 9 flagged —
identical old-vs-new; snipe-it/app Mass review 2/safe 41/unknown 54,
SSRF 33 → 3 — identical (old code measured by stashing 3B and
adding counters only). Full-issue rule histograms identical on both
pilots + snipe-it full (5/5 runs).
Gates (2 warm-ups + 5 measured: 58.6/58.7/60.0/75.5/125.9s, median
60.0s; M5 is another loaded-box outlier, all analyzers inflated
proportionally): Mass 22.1s → 2.4s, SSRF 19.6s → 2.0s (both <8s,
both <5s stretch); visits −95% each (31.6M→1.5M, 27.6M→1.4M);
node amplification 68x → 37x (<40x; <30x stretch left for
Ssti/Command/SQLi territory — not pursued for the number);
FactIndex build 3.2s (3 per-analyzer index instances over the same
files — known duplication, ~5% of total; unifying to a run-scoped
instance saves ~2s and is documented, not done); peak 1270MB
(+18MB, refs only); parse 1.0x; 500/108 identical; corpus 1.000.
Journey so far: 543s → 272s → 166s → 105.6s → 60.0s (~9x) with
byte-identical detection. STOP as agreed — re-profile before any
3C decision (Ssti 9.5s + Command 6.6s = 16.1s combined remain).
PERFORMANCE FREEZE BASELINE: fd6fd66 (median ~60s Snipe-it). SSTI + Command (16s) and SQLi (11.8s, own track) are backlog — untouched.
v0.6.1 — Ownership / Object-Level Authorization (IDOR), SHADOW mode.
New OwaspOwnershipAnalyzer (opt-in owasp.ownership_idor, default
false; zero Issue objects) records one OwnershipDecision per
controller action doing update/delete/destroy/restore/forceDelete on
a request-identified resource: PROTECTED | REVIEW | EXPOSED |
UNKNOWN + identifier/lookup/operation/principal + ownership and
authorization evidence + confidence + FlowTrace-style chain, dumped
via the ownership-shadow.php sidecar driver. Reuses canonical AST,
StructuralFactIndex, ScopeIds and the route/middleware indexes — no
new full-tree traversals (per-method subtree scans only).
Evidence (bounded, in-method, must precede the sink): relationship-
scoped receiver ($user->links()->…->delete), where(user_id|
owner_id|created_by, current-user), explicit owner comparison +
deny-shape branch (abort/throw/deny/return), $this->authorize()/
Gate::/can() (policy-gate), can: middleware. Auth alone is never
ownership; custom middleware (even ownership-shaped LinkId) is NOT
proven in v0.6.1 — the body must prove it. Service returns,
tenant/team multi-hop and polymorphic ownership stay UNKNOWN/REVIEW.
Acceptance: Linkstack UserController@deleteLink →
REVIEW ($linkId request input → Link::where() → delete,
auth-middleware principal, no owner predicate, no Policy/Gate).
Pilot shadow: Linkstack 26 decisions (1 protected saveLink via
owner-compare-deny, 23 review incl. 7 AdminController admin-context
actions human-clearable, 1 exposed InstallerController@createAdmin
pre-auth installer, 1 unknown); Snipe-it 84 decisions (72 protected
all via policy-gate — Snipe-it authorizes pervasively, 10 review
mostly SettingsController admin token/OAuth/DB ops +
GroupsController@destroy route-param id, 0 exposed, 2 unknown).
Production findings byte-identical (500/108). Corpus
OwnershipSemanticsTest 11 cases incl. authorize-after-sink and
custom-middleware-stays-review. Perf: ownership wall 0.26s
Linkstack, 5.8s Snipe-it under load (budget <5s — needs quiet-box
confirmation in v0.6.2; natural dedup: BAC builds the same indexes).
Gate for v0.6.2 production: 100% EXPOSED/REVIEW review + per-
mechanism PROTECTED sampling (relationship-scoped and owner-where
have corpus but zero pilot samples so far).
v0.6.1b — Validation / Calibration (shadow stays shadow). No new semantics batch; three closed gaps:
reason on every decision): PROTECTED
carries its proving mechanism; REVIEW/UNKNOWN carry AUTH_ONLY,
ADMIN_CONTEXT, CUSTOM_MIDDLEWARE, UNRESOLVED_RESOURCE,
UNRESOLVED_PRINCIPAL, AUTHORIZATION_AFTER_SINK; EXPOSED carries
NO_AUTH. Priority order documented in reviewReason() — labeling
only, never changes verdicts.$budget->id !==
$budgetLimit->budget_id → throw). relationship-scoped and
owner-where: ZERO in-controller pilot samples — Firefly/Canvas keep
their owner scoping in repositories/query helpers
(visiblePostsQuery, BelongsUser), correctly out of v0.6.1
in-method scope; both mechanisms stay corpus-covered, NOT
pilot-proven. Reason distributions: Linkstack 23 REVIEW =
ADMIN_CONTEXT 7 + CUSTOM_MIDDLEWARE 8 + AUTH_ONLY 5 + others;
Firefly 66 REVIEW = ADMIN_CONTEXT 36 + CUSTOM_MIDDLEWARE 29
(model-binding API controllers) + UNRESOLVED_RESOURCE 4; Snipe-it 10
REVIEW mostly admin token/OAuth/DB ops; Canvas 0 REVIEW.$request->id PropertyFetch was not request
input; (b) authorize()/Gate AFTER the sink counted as evidence —
now requires preceding line (+ AUTHORIZATION_AFTER_SINK reason +
regression test); (c) deny-on-EQUALITY ($admin->id ===
$user->id → return, a self-guard proving nothing about target
ownership) counted as owner-compare-deny — now restricted to
deny-on-inequality (+ regression test; the remaining Firefly
PROTECTED is the genuine parent-child guard); (d) auth()->user()
->id parses as PropertyFetch, not MethodCall — principal-root
receiver check added; (e) owner-where extended to qualified columns
(t.user_id) and where(col, '=', user) (Firefly’s dominant
shape) — same mechanism, syntactic variants, wrong-operator
(<>) regression test included.
Perf (2 warm-ups + 3 measured, Snipe-it): ownership wall median
2.3s (2.24/2.32/2.36s) — gate <5s PASS with margin; full median
68.4s vs 60s freeze baseline (delta in box noise). No BAC index
dedup — profiler never showed duplicated construction as material.
Production findings byte-identical throughout (500/108/483/38).baseline.json to .gitignore while complaining that CI
reports differ per machine — an uncommitted baseline means everyone has a different threshold.--fail-on=none in CI just to keep it green — that flag is for review only.analyzers.security / analyzers.owasp because of one wrong finding —
baseline exactly that finding and keep the rule to catch new bugs.