Laravel Supply-Chain Defense
A Practical Defense Model for Composer, Packagist, and PHP Ecosystems
STATUS: ACTIVE 🟢
PHP / Laravel
Composer
Supply Chain Security
⚡ TL;DR
The PHP ecosystem—and Laravel in particular—lives a paradox: It is extremely productive but deeply dependent on third parties. Hundreds of packages, thousands of versions, millions of installs. Each composer update opens the door to thousands of actors you don't control.
This research explores real supply-chain risks, historical incidents, and common vulnerabilities, proposing a practical, reproducible, and developer-oriented framework to defend Laravel projects from threats that don't come through the front door... but through the package manager.
Deliverables:
LiveyScore™ 2.0
Configurable Policies
Livey Dashboard
Automated CI/CD
1. Objective & Context
🎯 Objective
Evaluate real risks within the Composer/Packagist ecosystem.
Model relevant threats for Laravel projects.
Design a viable secure pipeline for small teams.
Create a set of practices to mitigate malicious packages and insecure updates.
⚠️ Why Laravel?
Laravel encourages modularity. Authentication, logs, queues... everything is a package. The problem isn't Laravel; it's the density of dependencies .
A typical project includes 50-100 direct packages, which in turn depend on 200-400 indirect ones. This creates a massive attack surface with less visibility than npm.
3. Threat Model: Laravel/Composer Ecosystem
🚨 Key Threats
1. Typosquatting
Attackers create illuminate/supp0rt hoping for a typo.
2. Compromised Maintainers
Trusted accounts hijacked to push malicious updates.
3. "Miraculous" Updates
Abandoned packages suddenly updating after years.
4. Transitive Threats
Deep dependencies compromised without your knowledge.
5. Malicious Scripts
post-install-cmd executing malware on composer require.
4. Architecture of Risk
```mermaid
flowchart TD
A[Developer] --> B[composer require]
B --> C[Packagist]
C --> D[Mirrors / Cache]
D --> E[Package Source GitHub/GitLab]
E --> F[Dependency Tree Primary + Transitives]
F --> G[Local Project]
G --> H[SAST / SCA Tools]
H --> I[Build / CI]
I --> J[Production]
subgraph Threats
X1[Typosquatting]
X2[Malicious Script Hook]
X3[Compromised Maintainer]
X4[Abandoned Package Update]
X5[Transitive Malicious Dep]
end
B -.-> X1
E -.-> X3
E -.-> X4
F -.-> X5
C -.-> X2
```
5. Supply Chain Defense Framework
🔐 5.1 LiveyScore™ 2.0 Scoring Model
We introduce a weighted scoring system (0-100) to objectively evaluate package trust.
A. Vendor Origin (Max 25)
Allowlisted Vendor: +25
Reputable/Known: +15
Unknown: +5
Denylisted: 0 (Fail)
B. Activity (Max 20)
Last release < 1 yr: +20
Last release < 3 yrs: +15
Last release > 5 yrs: +5
No release info: 0
C. Popularity (Max 15)
>1M downloads: +15
>100k downloads: +10
<10k downloads: +0
<1k downloads: -10 (Risk)
D. Stability (Max 15)
Stable: +15
RC/Beta: +10
Dev/Master: +0
E. Scripts (Max 15)
No scripts: +15
Benign scripts: +10
Risky scripts: -15
F. Signals (Max 10)
Small package: +5
Large w/o docs: -5
Binaries (.phar, .so): -10
Obfuscated: -10
🏁 Thresholds
Score < 60: WARN (Manual Review)
Score < 40: FAIL (Block)
This research provides concrete tools to implement this defense model.
🛠️ 6.1 Policy Configuration
A centralized configuration file to manage your supply chain rules.
// tools/supply_chain_policy.yaml
```yaml
policy_version: "1.0"
allowlist_vendors:
- laravel
- illuminate
- symfony
- guzzlehttp
- nesbot
- doctrine
- league
- ramsey
denylist_vendors:
- malware-corp
- suspicious-vendor
thresholds:
warn_score: 60
fail_score: 40
scoring:
vendor:
allowlist: 25
reputable: 15
unknown: 5
activity:
active: 20 # < 1 year
semi_active: 15 # < 3 years
old: 10 # 3-5 years
very_old: 5 # > 5 years
unknown: 0
popularity:
million: 15
hundred_k: 10
ten_k: 5
low: 0
tiny: -10
version:
stable: 15
rc: 10
dev: 0
scripts:
none: 15
benign: 10
risky: -15
additional:
has_binaries: -10
```
🛠️ 6.2 LiveyScore™ Scanner 2.0
This Python script reads your policy and composer.lock to enforce security. Includes JSON reporting for the dashboard.
// tools/livey_supply_chain_scan.py
```python
#!/usr/bin/env python3
import json
import argparse
import datetime
import sys
import yaml
from pathlib import Path
try:
import requests
except ImportError:
requests = None
def load_json(path: Path):
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def load_yaml(path: Path):
with path.open("r", encoding="utf-8") as f:
return yaml.safe_load(f)
def parse_time(ts: str):
try:
return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
except Exception:
return None
def classify_vendor(name, policy):
vendor = name.split("/")[0]
if vendor in policy["denylist_vendors"]:
return "deny"
if vendor in policy["allowlist_vendors"]:
return "allow"
return "unknown"
def classify_popularity(downloads, scoring):
if downloads is None: return "unknown"
if downloads > 1_000_000: return "million"
if downloads > 100_000: return "hundred_k"
if downloads > 10_000: return "ten_k"
if downloads > 1_000: return "low"
return "tiny"
def get_packagist_meta(pkg):
if not requests: return None
url = f"https://repo.packagist.org/p2/{pkg}.json"
try:
r = requests.get(url, timeout=5)
if r.status_code != 200: return None
data = r.json().get("packages", {}).get(pkg, [])
if not data: return None
latest = data[-1]
return {
"time": latest.get("time"),
"downloads": latest.get("downloads", {}).get("total", None),
}
except Exception:
return None
def analyze_scripts(scripts):
if not scripts: return "none"
risky_keywords = ["exec", "system", "shell", "wget", "curl", "php ", "artisan "]
benign = ["post-autoload-dump", "package:discover"]
script_text = str(scripts)
if any(kw in script_text for kw in risky_keywords): return "risky"
if any(b in script_text.lower() for b in benign): return "benign"
return "unclear"
def detect_binaries(pkg_dir):
suspicious_exts = (".phar", ".so", ".dll")
if not pkg_dir.exists(): return False
for file in pkg_dir.rglob("*"):
if file.suffix.lower() in suspicious_exts: return True
return False
def compute_score(pkg, meta, scripts, policy, project_root):
scoring = policy["scoring"]
total = 0
reasons = []
name = pkg.get("name")
version = pkg.get("version")
vendor_class = classify_vendor(name, policy)
# Vendor
if vendor_class == "deny": return 0, ["Vendor in denylist"]
if vendor_class == "allow": total += scoring["vendor"]["allowlist"]
elif vendor_class == "unknown":
total += scoring["vendor"]["unknown"]
reasons.append("Unknown Vendor")
else: total += scoring["vendor"]["reputable"]
# Activity
last = parse_time(pkg.get("time", ""))
if not last and meta and meta.get("time"): last = parse_time(meta["time"])
if last:
years = (datetime.datetime.now(datetime.timezone.utc) - last).days / 365
if years < 1: total += scoring["activity"]["active"]
elif years < 3: total += scoring["activity"]["semi_active"]
elif years < 5: total += scoring["activity"]["old"]
else:
total += scoring["activity"]["very_old"]
reasons.append(f"Inactive for {years:.1f} years")
else:
total += scoring["activity"]["unknown"]
reasons.append("No activity metadata")
# Popularity
pop = classify_popularity(meta.get("downloads") if meta else None, scoring)
total += scoring["popularity"].get(pop, 0)
if pop == "tiny": reasons.append("Very low popularity")
# Version
if version.startswith("dev-"):
total += scoring["version"]["dev"]
reasons.append("Dev version")
elif "RC" in version or "beta" in version.lower():
total += scoring["version"]["rc"]
reasons.append("Unstable version")
else: total += scoring["version"]["stable"]
# Scripts
script_class = analyze_scripts(scripts)
total += scoring["scripts"].get(script_class, 0)
if script_class == "risky": reasons.append("Risky Composer scripts")
# Binaries
vendor, pkg_name = name.split("/")
pkg_path = project_root / "vendor" / vendor / pkg_name
if detect_binaries(pkg_path):
total += scoring["additional"]["has_binaries"]
reasons.append("Contains binaries (.so/.dll/.phar)")
return max(0, min(100, total)), reasons
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--composer-lock", default="composer.lock")
parser.add_argument("--composer-json", default="composer.json")
parser.add_argument("--policy", default="tools/supply_chain_policy.yaml")
parser.add_argument("--json-output", default=None, help="Generate JSON report")
args = parser.parse_args()
lock = load_json(Path(args.composer_lock))
composer = load_json(Path(args.composer_json))
policy = load_yaml(Path(args.policy))
packages = lock.get("packages", []) + lock.get("packages-dev", [])
project_root = Path(".").resolve()
warn_score = policy["thresholds"]["warn_score"]
fail_score = policy["thresholds"]["fail_score"]
print("🔒 LiveySupplyChain Defense Scanner 2.0\n")
bad = []
report_data = {
"generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"warn_threshold": warn_score,
"fail_threshold": fail_score,
"packages": []
}
for pkg in packages:
name = pkg["name"]
scripts = composer.get("scripts", )
meta = get_packagist_meta(name)
score, reasons = compute_score(pkg, meta, scripts, policy, project_root)
status = "OK"
if score < fail_score: status = "FAIL"
elif score < warn_score: status = "WARN"
print(f"📦 {name} | v{pkg.get('version')} | Score: {score}/100 [{status}]")
if reasons:
for r in reasons: print(f" - {r}")
print("")
if status != "OK":
bad.append((name, score, status))
report_data["packages"].append({
"name": name,
"version": pkg.get("version"),
"score": score,
"status": status,
"reasons": reasons
})
if args.json_output:
with open(args.json_output, "w", encoding="utf-8") as f:
json.dump(report_data, f, indent=2)
print(f"📄 JSON report generated: {args.json_output}")
if any(x[2] == "FAIL" for x in bad):
print("❌ BLOCKED: packages below fail threshold.")
sys.exit(1)
if any(x[2] == "WARN" for x in bad):
print("⚠️ WARNINGS present. Review required.")
print("✅ Supply Chain Scan completed.")
sys.exit(0)
if __name__ == "__main__":
main()
```
🛠️ 6.3 GitHub Actions Workflow
// .github/workflows/supply-chain.yml
```yaml
name: Laravel Supply-Chain Defense
on: [pull_request, push]
jobs:
supply-chain-defense:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.3' }
- name: Validate Structure
run: composer validate --strict
- name: Install Dependencies
run: composer install --no-interaction --no-scripts --no-progress
- name: Native Audit
run: composer audit || echo "Composer audit found issues"
- name: Setup Scanner
uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install requests pyyaml
- name: Run LiveyScore™ Scanner
run: python tools/livey_supply_chain_scan.py --policy tools/supply_chain_policy.yaml --json-output reports/livey_supply_report.json
- name: Upload Report
if: always()
uses: actions/upload-artifact@v4
with:
name: supply-chain-report
path: reports/livey_supply_report.json
```
7. Visualization & Dashboard
To make supply chain data consumable for humans, we provide a standalone HTML dashboard.
🎨 7.1 LiveySupplyChain™ Logo (SVG)
// assets/livey_supplychain_logo.svg
```xml
LiveySupplyChain
L A R A V E L · D E F E N S E
™
```
🖥️ 7.2 Livey Dashboard
(Full HTML/JS dashboard code available in the implementation repository)
8. Conclusion
Defending Laravel from supply-chain attacks isn't paranoia; it's acknowledging that our chain depends on hundreds of strangers.
Composer is powerful but dangerous without discipline.
The biggest risk is often in indirect dependencies.
Effective defense doesn't need a massive team, just a reproducible, automated pipeline.
Hardening Composer is hardening your application.