Rampart Quality Checker

Laravel quality gate — phpcs, phpstan, phpunit, composer audit, security analyzers.

View the Project on GitHub thiennhant95/Rampart

False Positives

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.

1. Principles

  1. Read the finding first, decide later — each issue has file + line + rule + message. Open that exact line before doing anything else.
  2. Fix code first, baseline later — if the code can be rewritten more safely (parameter binding, validation, $fillable), fix the code.
  3. Baseline only reviewed findings — run --baseline-generate/--update only after reading the full report. A blind baseline also hides real bugs.
  4. Do not broadly disable high confidence rules — if there is noise, raise --min-confidence or change --tier instead of disabling analyzers.security.*.
  5. Inline ignore for reviewed one-off cases — a // 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.
  6. Fixtures are excluded, not reported — 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.

2. Confidence & action

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

3. Common false positives

SQL_INJECTION / TAINT_SQL_INJECTION / LARAVEL_TAINT

MASS_ASSIGNMENT

OWASP_BROKEN_ACCESS_CONTROL

7. Performance profiling (PERF-EVAL-1, no optimization)

--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).

OWASP_SSRF / OWASP_COMMAND_INJECTION / OWASP_SSTI

INSECURE_HASH

SESSION_FIXATION

WEAK_PASSWORD_POLICY

ROUTE_MISSING_VALIDATION

MIGRATION_DESTRUCTIVE_UP

OWASP_OPEN_REDIRECT

OWASP_PATH_TRAVERSAL

OWASP_BLADE_XSS

OWASP_BLADE_DYNAMIC_INCLUDE

HARDCODED_SECRET

OWASP_MISCONFIGURATION

UNSAFE_EVAL

UNSAFE_UNSERIALIZE

DISABLED_CSRF_EXCEPTION_STAR

low heuristic group (DEAD_CODE, NAMING_CONVENTION, TODO_FIXME, MISSING_*_TEST)

CI gate & exit codes

Delta vs baseline & confidence scores

4. Suggested review flow

# 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.

6. Performance budget (semantic engine)

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:

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:

  1. REVIEW reason taxonomy (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.
  2. Real-sample mining (4 pilots shadow-run: linkstack, snipe-it, firefly-iii, canvas): policy-gate 72+ (Snipe-it), can-middleware 5 (Canvas Tag/Topic/User destroy), owner-compare-deny 2 genuine (Linkstack saveLink; Firefly BudgetLimit@update $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.
  3. Calibration bugfixes found by shadow review (the process working as designed): (a) $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).

5. Don’ts