diff --git a/.github/workflows/php-lint.yml b/.github/workflows/php-lint.yml
index 0e83136..804d020 100644
--- a/.github/workflows/php-lint.yml
+++ b/.github/workflows/php-lint.yml
@@ -10,6 +10,7 @@ on:
- 'composer.json'
- 'composer.lock'
- 'phpstan.neon'
+ - 'phpunit.xml'
- '.php-cs-fixer.dist.php'
- '.github/workflows/php-lint.yml'
pull_request:
@@ -19,6 +20,7 @@ on:
- 'composer.json'
- 'composer.lock'
- 'phpstan.neon'
+ - 'phpunit.xml'
- '.php-cs-fixer.dist.php'
- '.github/workflows/php-lint.yml'
@@ -285,6 +287,58 @@ jobs:
run: |
composer audit --format=plain || echo "::warning::Vulnerabilities found in dependencies"
+ # ============================================================
+ # Unit Tests - PHPUnit with coverage
+ # ============================================================
+ tests:
+ name: Unit Tests (PHPUnit)
+ runs-on: ubuntu-latest
+ needs: syntax
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0
+ with:
+ php-version: ${{ env.PHP_VERSION }}
+ extensions: xdebug
+ coverage: xdebug
+ tools: phpunit:10
+
+ - name: Install Composer dependencies
+ run: composer install --no-progress --prefer-dist --no-interaction
+
+ - name: Run PHPUnit tests
+ run: |
+ vendor/bin/phpunit --coverage-text --coverage-clover=coverage/clover.xml
+
+ - name: Check coverage threshold
+ run: |
+ # Extract coverage percentage from clover.xml
+ if [ -f coverage/clover.xml ]; then
+ COVERAGE=$(php -r "
+ \$xml = simplexml_load_file('coverage/clover.xml');
+ \$metrics = \$xml->project->metrics;
+ \$elements = (int)\$metrics['elements'];
+ \$covered = (int)\$metrics['coveredelements'];
+ if (\$elements > 0) {
+ echo round((\$covered / \$elements) * 100, 2);
+ } else {
+ echo '0';
+ }
+ ")
+ echo "Code coverage: ${COVERAGE}%"
+ echo "## Test Coverage: ${COVERAGE}%" >> $GITHUB_STEP_SUMMARY
+
+ # Fail if coverage is below 70%
+ if (( $(echo "$COVERAGE < 70" | bc -l) )); then
+ echo "::warning::Coverage is below 70% threshold"
+ fi
+ fi
+
# ============================================================
# Multi-version PHP Test - Ensure compatibility
# ============================================================
@@ -324,7 +378,7 @@ jobs:
lint-summary:
name: Lint Summary
runs-on: ubuntu-latest
- needs: [syntax, code-style, phpstan, license-headers, strict-types, security-patterns, composer-audit, php-compat]
+ needs: [syntax, code-style, phpstan, tests, license-headers, strict-types, security-patterns, composer-audit, php-compat]
if: always()
permissions:
contents: read
@@ -338,6 +392,7 @@ jobs:
echo "| Syntax | ${{ needs.syntax.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Code Style | ${{ needs.code-style.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
echo "| PHPStan | ${{ needs.phpstan.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Unit Tests | ${{ needs.tests.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
echo "| License Headers | ${{ needs.license-headers.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Strict Types | ${{ needs.strict-types.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Security Patterns | ${{ needs.security-patterns.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY
@@ -348,6 +403,7 @@ jobs:
if [ "${{ needs.syntax.result }}" != "success" ] || \
[ "${{ needs.code-style.result }}" != "success" ] || \
[ "${{ needs.phpstan.result }}" != "success" ] || \
+ [ "${{ needs.tests.result }}" != "success" ] || \
[ "${{ needs.strict-types.result }}" != "success" ] || \
[ "${{ needs.security-patterns.result }}" != "success" ]; then
echo ""
diff --git a/SECURE_DEFAULTS.md b/SECURE_DEFAULTS.md
index ce85e4d..ac9d560 100644
--- a/SECURE_DEFAULTS.md
+++ b/SECURE_DEFAULTS.md
@@ -4,6 +4,7 @@ This document provides a comprehensive checklist for secure PHP development usin
## Table of Contents
+- [OWASP Top 10 Mapping](#owasp-top-10-mapping)
- [PHP Configuration](#php-configuration)
- [Input Validation](#input-validation)
- [Output Sanitization](#output-sanitization)
@@ -18,6 +19,329 @@ This document provides a comprehensive checklist for secure PHP development usin
---
+## OWASP Top 10 Mapping
+
+This section maps php-aegis features and checklist items to the [OWASP Top 10 2021](https://owasp.org/Top10/) vulnerabilities.
+
+### Summary Matrix
+
+| OWASP ID | Vulnerability | php-aegis Coverage | Section |
+|----------|--------------|-------------------|---------|
+| A01:2021 | Broken Access Control | Partial (Headers) | [HTTP Headers](#http-security-headers) |
+| A02:2021 | Cryptographic Failures | Guidelines | [Cryptography](#cryptography) |
+| A03:2021 | Injection | **Full** (Validator, Sanitizer, TurtleEscaper) | [Input](#input-validation), [Output](#output-sanitization) |
+| A04:2021 | Insecure Design | Guidelines | [All Sections](#secure-defaults-checklist) |
+| A05:2021 | Security Misconfiguration | **Full** (Headers) | [HTTP Headers](#http-security-headers), [PHP Config](#php-configuration) |
+| A06:2021 | Vulnerable Components | Guidelines | [Dependencies](#dependency-management) |
+| A07:2021 | Auth Failures | Guidelines | [Authentication](#authentication--sessions) |
+| A08:2021 | Data Integrity Failures | Partial (CSP) | [HTTP Headers](#http-security-headers) |
+| A09:2021 | Logging Failures | Guidelines | [Error Handling](#error-handling) |
+| A10:2021 | SSRF | Partial (Validator) | [Input Validation](#input-validation) |
+
+---
+
+### A01:2021 - Broken Access Control
+
+**Risk:** Attackers access unauthorized resources or perform actions outside their permissions.
+
+**php-aegis Mitigations:**
+
+| Control | php-aegis Feature | Code Example |
+|---------|------------------|--------------|
+| CSRF Prevention | `Headers::secure()` sets SameSite cookies | `Headers::secure()` |
+| Clickjacking | `Headers::frameOptions('DENY')` | `Headers::frameOptions()` |
+| CORS Policies | `Headers::crossOrigin*Policy()` | `Headers::crossOriginResourcePolicy()` |
+
+**Checklist:**
+- [ ] Use `Headers::frameOptions('DENY')` to prevent clickjacking
+- [ ] Implement proper session management (see [Authentication](#authentication--sessions))
+- [ ] Validate user permissions on every request
+- [ ] Use CSRF tokens for state-changing operations
+- [ ] Apply principle of least privilege
+
+---
+
+### A02:2021 - Cryptographic Failures
+
+**Risk:** Sensitive data exposed due to weak/missing encryption.
+
+**php-aegis Mitigations:**
+
+| Control | php-aegis Feature | Code Example |
+|---------|------------------|--------------|
+| HTTPS Enforcement | `Validator::httpsUrl()` | `Validator::httpsUrl($url)` |
+| HSTS | `Headers::strictTransportSecurity()` | `Headers::strictTransportSecurity(31536000, true, true)` |
+
+**Checklist:**
+- [ ] Use `Validator::httpsUrl()` to reject non-HTTPS URLs
+- [ ] Enable HSTS with `Headers::strictTransportSecurity()`
+- [ ] Never use MD5/SHA1 for security (see [Cryptography](#cryptography))
+- [ ] Use `random_bytes()` for secure random data
+- [ ] Use Argon2id for password hashing
+
+**CI Enforcement:**
+```yaml
+# In php-lint.yml - checks for weak cryptography
+- name: Check weak cryptography
+ run: grep -rEn 'md5\s*\(|sha1\s*\(' --include="*.php" src/
+```
+
+---
+
+### A03:2021 - Injection
+
+**Risk:** Untrusted data interpreted as commands (SQL, XSS, OS, LDAP, Turtle).
+
+**php-aegis Mitigations:**
+
+| Attack Type | php-aegis Feature | Code Example |
+|-------------|------------------|--------------|
+| XSS (HTML) | `Sanitizer::html()` | `echo Sanitizer::html($input)` |
+| XSS (Attr) | `Sanitizer::attr()` | `value="= Sanitizer::attr($v) ?>"` |
+| XSS (JS) | `Sanitizer::js()` | `var x = = Sanitizer::js($v) ?>` |
+| Path Traversal | `Validator::safeFilename()` | `Validator::safeFilename($name)` |
+| Null Byte | `Validator::noNullBytes()` | `Validator::noNullBytes($path)` |
+| RDF/SPARQL | `TurtleEscaper::string()` | `TurtleEscaper::literal($v)` |
+| URL Injection | `Sanitizer::url()` | `href="= Sanitizer::url($u) ?>"` |
+| JSON Injection | `Sanitizer::json()` | `Sanitizer::json($data)` |
+
+**Checklist:**
+- [ ] Use `Sanitizer::html()` for all HTML output
+- [ ] Use `Sanitizer::attr()` for HTML attributes
+- [ ] Use `Sanitizer::js()` for inline JavaScript
+- [ ] Use `Sanitizer::json()` for JSON responses
+- [ ] Use `TurtleEscaper::literal()` for RDF/Turtle data
+- [ ] Use `Validator::safeFilename()` for file operations
+- [ ] Use prepared statements for ALL database queries
+
+**CI Enforcement:**
+```yaml
+# In php-lint.yml - checks for injection patterns
+- name: Check dangerous functions
+ run: |
+ grep -rEn 'eval\s*\(|exec\s*\(' --include="*.php" src/
+ grep -rEn 'echo\s+\$_(GET|POST)' --include="*.php" src/
+```
+
+---
+
+### A04:2021 - Insecure Design
+
+**Risk:** Missing or ineffective security controls in application design.
+
+**php-aegis Mitigations:**
+
+| Control | php-aegis Feature | Purpose |
+|---------|------------------|---------|
+| Secure Defaults | `Headers::secure()` | One-call security setup |
+| Type Safety | All methods require `string` types | Prevents type confusion |
+| Fail Secure | Validators return `false` on invalid input | Reject by default |
+
+**Checklist:**
+- [ ] Call `Headers::secure()` early in every request
+- [ ] Use `declare(strict_types=1)` in all PHP files
+- [ ] Validate before processing, sanitize before output
+- [ ] Reject invalid input (don't try to "fix" it)
+- [ ] Design with defense in depth
+
+---
+
+### A05:2021 - Security Misconfiguration
+
+**Risk:** Missing security hardening, default credentials, verbose errors.
+
+**php-aegis Mitigations:**
+
+| Misconfiguration | php-aegis Feature | Code Example |
+|-----------------|------------------|--------------|
+| Missing CSP | `Headers::contentSecurityPolicy()` | `Headers::secure()` |
+| Missing HSTS | `Headers::strictTransportSecurity()` | `Headers::secure()` |
+| Server Leakage | `Headers::removeInsecureHeaders()` | `Headers::secure()` |
+| MIME Sniffing | `Headers::contentTypeOptions()` | `Headers::secure()` |
+| Missing Permissions-Policy | `Headers::permissionsPolicy()` | `Headers::secure()` |
+
+**Headers set by `Headers::secure()`:**
+```
+Content-Security-Policy: default-src 'self'
+Strict-Transport-Security: max-age=31536000; includeSubDomains
+X-Frame-Options: DENY
+X-Content-Type-Options: nosniff
+X-XSS-Protection: 1; mode=block
+Referrer-Policy: strict-origin-when-cross-origin
+Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()
+```
+
+**Checklist:**
+- [ ] Call `Headers::secure()` on every response
+- [ ] Configure PHP securely (see [PHP Configuration](#php-configuration))
+- [ ] Disable `display_errors` in production
+- [ ] Remove default credentials and accounts
+- [ ] Review all security headers with [securityheaders.com](https://securityheaders.com)
+
+---
+
+### A06:2021 - Vulnerable and Outdated Components
+
+**Risk:** Using libraries with known vulnerabilities.
+
+**php-aegis Design:**
+- **Zero runtime dependencies** - Only PHP 8.1+ built-ins
+- No vulnerable dependencies to track in production
+
+**Checklist:**
+- [ ] Run `composer audit` on every CI build
+- [ ] Keep PHP version updated (8.1+ required)
+- [ ] Review dev dependencies before adding
+- [ ] Enable Dependabot/Renovate for automatic updates
+
+**CI Enforcement:**
+```yaml
+# In php-lint.yml
+- name: Run Composer audit
+ run: composer audit --format=plain
+```
+
+---
+
+### A07:2021 - Identification and Authentication Failures
+
+**Risk:** Weak passwords, session hijacking, credential stuffing.
+
+**php-aegis Mitigations:**
+
+| Control | php-aegis Feature | Purpose |
+|---------|------------------|---------|
+| Session Security | `Headers::secure()` sets cookie flags | SameSite, Secure |
+
+**Checklist:**
+- [ ] Use `password_hash()` with `PASSWORD_ARGON2ID`
+- [ ] Use `password_verify()` for constant-time comparison
+- [ ] Regenerate session ID on login (`session_regenerate_id(true)`)
+- [ ] Set session cookie flags: HttpOnly, Secure, SameSite=Strict
+- [ ] Implement rate limiting for authentication
+- [ ] Use MFA for sensitive operations
+
+---
+
+### A08:2021 - Software and Data Integrity Failures
+
+**Risk:** Untrusted code execution, insecure CI/CD, missing integrity checks.
+
+**php-aegis Mitigations:**
+
+| Control | php-aegis Feature | Purpose |
+|---------|------------------|---------|
+| CSP | `Headers::contentSecurityPolicy()` | Prevents inline script injection |
+| SRI Support | Design for external script verification | Subresource Integrity |
+
+**Checklist:**
+- [ ] Use Content-Security-Policy to block inline scripts
+- [ ] Pin GitHub Actions to commit SHAs (not tags)
+- [ ] Verify `composer.lock` in CI builds
+- [ ] Sign commits with GPG
+- [ ] Use Subresource Integrity for CDN resources
+
+**CI Enforcement:**
+```yaml
+# Pin actions to SHA for integrity
+- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+```
+
+---
+
+### A09:2021 - Security Logging and Monitoring Failures
+
+**Risk:** Insufficient logging, missing alerting, undetected breaches.
+
+**Checklist:**
+- [ ] Log authentication attempts (success and failure)
+- [ ] Log access control failures
+- [ ] Log input validation failures (potential attacks)
+- [ ] Don't log sensitive data (passwords, tokens, PII)
+- [ ] Set up alerting for anomalous patterns
+- [ ] Monitor error logs for security issues
+
+**Error Handler Pattern:**
+```php
+set_exception_handler(function (Throwable $e): void {
+ // Log for operators
+ error_log(sprintf('[%s] %s', get_class($e), $e->getMessage()));
+
+ // Generic response to users
+ http_response_code(500);
+ echo json_encode(['error' => 'An unexpected error occurred']);
+ exit(1);
+});
+```
+
+---
+
+### A10:2021 - Server-Side Request Forgery (SSRF)
+
+**Risk:** Attacker forces server to make requests to unintended destinations.
+
+**php-aegis Mitigations:**
+
+| Control | php-aegis Feature | Code Example |
+|---------|------------------|--------------|
+| URL Validation | `Validator::url()` | `Validator::url($url)` |
+| HTTPS Enforcement | `Validator::httpsUrl()` | `Validator::httpsUrl($url)` |
+| Hostname Validation | `Validator::hostname()` | `Validator::hostname($host)` |
+| IP Validation | `Validator::ip()`, `ipv4()`, `ipv6()` | `Validator::ip($ip)` |
+
+**Checklist:**
+- [ ] Validate all user-supplied URLs with `Validator::url()`
+- [ ] Prefer `Validator::httpsUrl()` to enforce HTTPS
+- [ ] Maintain allowlist of permitted domains/IPs
+- [ ] Block requests to internal/private IP ranges
+- [ ] Don't follow redirects blindly
+
+**Safe URL Fetching:**
+```php
+use PhpAegis\Validator;
+
+function safeFetch(string $url): string {
+ // Validate URL format
+ if (!Validator::httpsUrl($url)) {
+ throw new InvalidArgumentException('Invalid or non-HTTPS URL');
+ }
+
+ // Parse and validate hostname
+ $host = parse_url($url, PHP_URL_HOST);
+ if (!$host || !Validator::domain($host)) {
+ throw new InvalidArgumentException('Invalid hostname');
+ }
+
+ // Block internal/private IPs (allowlist approach is better)
+ $ip = gethostbyname($host);
+ if (filter_var($ip, FILTER_VALIDATE_IP,
+ FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
+ throw new InvalidArgumentException('Private/reserved IP not allowed');
+ }
+
+ // Now safe to fetch
+ return file_get_contents($url);
+}
+```
+
+---
+
+### OWASP Coverage Summary
+
+| php-aegis Class | OWASP Categories Addressed |
+|----------------|---------------------------|
+| `Validator` | A03, A10 |
+| `Sanitizer` | A03 |
+| `Headers` | A01, A02, A04, A05, A08 |
+| `TurtleEscaper` | A03 |
+
+**Legend:**
+- **Full Coverage**: php-aegis provides direct protection
+- **Partial Coverage**: php-aegis helps but additional measures needed
+- **Guidelines**: Documentation and checklists provided
+
+---
+
## PHP Configuration
### Required Settings
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..43cbab4
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/HeadersTest.php b/tests/HeadersTest.php
new file mode 100644
index 0000000..350ea49
--- /dev/null
+++ b/tests/HeadersTest.php
@@ -0,0 +1,537 @@
+expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('X-Frame-Options must be DENY, SAMEORIGIN, or ALLOW-FROM uri');
+
+ Headers::frameOptions('INVALID');
+ }
+
+ // =========================================================================
+ // Content-Type Options (OWASP A05 - Security Misconfiguration)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testContentTypeOptions(): void
+ {
+ Headers::contentTypeOptions();
+
+ $headers = xdebug_get_headers();
+ self::assertContains('X-Content-Type-Options: nosniff', $headers);
+ }
+
+ // =========================================================================
+ // XSS Protection (OWASP A03 - Legacy Browser Protection)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testXssProtectionDefault(): void
+ {
+ Headers::xssProtection();
+
+ $headers = xdebug_get_headers();
+ self::assertContains('X-XSS-Protection: 1; mode=block', $headers);
+ }
+
+ #[RunInSeparateProcess]
+ public function testXssProtectionEnabled(): void
+ {
+ Headers::xssProtection(true, true);
+
+ $headers = xdebug_get_headers();
+ self::assertContains('X-XSS-Protection: 1; mode=block', $headers);
+ }
+
+ #[RunInSeparateProcess]
+ public function testXssProtectionEnabledNoBlock(): void
+ {
+ Headers::xssProtection(true, false);
+
+ $headers = xdebug_get_headers();
+ self::assertContains('X-XSS-Protection: 1', $headers);
+ }
+
+ #[RunInSeparateProcess]
+ public function testXssProtectionDisabled(): void
+ {
+ Headers::xssProtection(false);
+
+ $headers = xdebug_get_headers();
+ self::assertContains('X-XSS-Protection: 0', $headers);
+ }
+
+ // =========================================================================
+ // Referrer Policy (OWASP A05 - Security Misconfiguration)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testReferrerPolicyDefault(): void
+ {
+ Headers::referrerPolicy();
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Referrer-Policy: strict-origin-when-cross-origin', $headers);
+ }
+
+ #[DataProvider('validReferrerPoliciesProvider')]
+ #[RunInSeparateProcess]
+ public function testReferrerPolicyValid(string $policy): void
+ {
+ Headers::referrerPolicy($policy);
+
+ $headers = xdebug_get_headers();
+ self::assertContains("Referrer-Policy: {$policy}", $headers);
+ }
+
+ /**
+ * @return array
+ */
+ public static function validReferrerPoliciesProvider(): array
+ {
+ return [
+ 'no-referrer' => ['no-referrer'],
+ 'no-referrer-when-downgrade' => ['no-referrer-when-downgrade'],
+ 'origin' => ['origin'],
+ 'origin-when-cross-origin' => ['origin-when-cross-origin'],
+ 'same-origin' => ['same-origin'],
+ 'strict-origin' => ['strict-origin'],
+ 'strict-origin-when-cross-origin' => ['strict-origin-when-cross-origin'],
+ 'unsafe-url' => ['unsafe-url'],
+ ];
+ }
+
+ public function testReferrerPolicyRejectsInvalid(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid Referrer-Policy');
+
+ Headers::referrerPolicy('invalid-policy');
+ }
+
+ // =========================================================================
+ // Strict Transport Security (OWASP A02 - Cryptographic Failures)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testStrictTransportSecurityDefault(): void
+ {
+ Headers::strictTransportSecurity();
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Strict-Transport-Security: max-age=31536000; includeSubDomains', $headers);
+ }
+
+ #[RunInSeparateProcess]
+ public function testStrictTransportSecurityWithPreload(): void
+ {
+ Headers::strictTransportSecurity(31536000, true, true);
+
+ $headers = xdebug_get_headers();
+ self::assertContains(
+ 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload',
+ $headers
+ );
+ }
+
+ #[RunInSeparateProcess]
+ public function testStrictTransportSecurityNoSubdomains(): void
+ {
+ Headers::strictTransportSecurity(86400, false, false);
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Strict-Transport-Security: max-age=86400', $headers);
+ }
+
+ #[RunInSeparateProcess]
+ public function testStrictTransportSecurityCustomMaxAge(): void
+ {
+ Headers::strictTransportSecurity(3600);
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Strict-Transport-Security: max-age=3600; includeSubDomains', $headers);
+ }
+
+ // =========================================================================
+ // Content Security Policy (OWASP A03, A05, A08)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testContentSecurityPolicySingleDirective(): void
+ {
+ Headers::contentSecurityPolicy([
+ 'default-src' => ["'self'"],
+ ]);
+
+ $headers = xdebug_get_headers();
+ self::assertContains("Content-Security-Policy: default-src 'self'", $headers);
+ }
+
+ #[RunInSeparateProcess]
+ public function testContentSecurityPolicyMultipleDirectives(): void
+ {
+ Headers::contentSecurityPolicy([
+ 'default-src' => ["'self'"],
+ 'script-src' => ["'self'", 'https://cdn.example.com'],
+ 'style-src' => ["'self'", "'unsafe-inline'"],
+ ]);
+
+ $headers = xdebug_get_headers();
+
+ // Find the CSP header
+ $cspHeader = null;
+ foreach ($headers as $header) {
+ if (str_starts_with($header, 'Content-Security-Policy:')) {
+ $cspHeader = $header;
+ break;
+ }
+ }
+
+ self::assertNotNull($cspHeader);
+ self::assertStringContainsString("default-src 'self'", $cspHeader);
+ self::assertStringContainsString('script-src', $cspHeader);
+ self::assertStringContainsString('style-src', $cspHeader);
+ }
+
+ #[RunInSeparateProcess]
+ public function testContentSecurityPolicyReportOnly(): void
+ {
+ Headers::contentSecurityPolicy([
+ 'default-src' => ["'self'"],
+ ], true);
+
+ $headers = xdebug_get_headers();
+
+ $found = false;
+ foreach ($headers as $header) {
+ if (str_starts_with($header, 'Content-Security-Policy-Report-Only:')) {
+ $found = true;
+ break;
+ }
+ }
+
+ self::assertTrue($found, 'Expected Content-Security-Policy-Report-Only header');
+ }
+
+ #[RunInSeparateProcess]
+ public function testContentSecurityPolicyEmptyDirective(): void
+ {
+ Headers::contentSecurityPolicy([
+ 'upgrade-insecure-requests' => [],
+ ]);
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Content-Security-Policy: upgrade-insecure-requests', $headers);
+ }
+
+ // =========================================================================
+ // Permissions Policy (OWASP A05 - Security Misconfiguration)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testPermissionsPolicyEmpty(): void
+ {
+ Headers::permissionsPolicy([
+ 'camera' => [],
+ 'microphone' => [],
+ ]);
+
+ $headers = xdebug_get_headers();
+
+ $found = null;
+ foreach ($headers as $header) {
+ if (str_starts_with($header, 'Permissions-Policy:')) {
+ $found = $header;
+ break;
+ }
+ }
+
+ self::assertNotNull($found);
+ self::assertStringContainsString('camera=()', $found);
+ self::assertStringContainsString('microphone=()', $found);
+ }
+
+ #[RunInSeparateProcess]
+ public function testPermissionsPolicySelf(): void
+ {
+ Headers::permissionsPolicy([
+ 'geolocation' => ['self'],
+ ]);
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Permissions-Policy: geolocation=(self)', $headers);
+ }
+
+ #[RunInSeparateProcess]
+ public function testPermissionsPolicyWithOrigins(): void
+ {
+ Headers::permissionsPolicy([
+ 'camera' => ['self', 'https://example.com'],
+ ]);
+
+ $headers = xdebug_get_headers();
+
+ $found = null;
+ foreach ($headers as $header) {
+ if (str_starts_with($header, 'Permissions-Policy:')) {
+ $found = $header;
+ break;
+ }
+ }
+
+ self::assertNotNull($found);
+ self::assertStringContainsString('camera=(self "https://example.com")', $found);
+ }
+
+ // =========================================================================
+ // Cross-Origin Policies (OWASP A01 - Broken Access Control)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testCrossOriginEmbedderPolicyDefault(): void
+ {
+ Headers::crossOriginEmbedderPolicy();
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Cross-Origin-Embedder-Policy: require-corp', $headers);
+ }
+
+ #[DataProvider('validCoepPoliciesProvider')]
+ #[RunInSeparateProcess]
+ public function testCrossOriginEmbedderPolicyValid(string $policy): void
+ {
+ Headers::crossOriginEmbedderPolicy($policy);
+
+ $headers = xdebug_get_headers();
+ self::assertContains("Cross-Origin-Embedder-Policy: {$policy}", $headers);
+ }
+
+ /**
+ * @return array
+ */
+ public static function validCoepPoliciesProvider(): array
+ {
+ return [
+ 'require-corp' => ['require-corp'],
+ 'credentialless' => ['credentialless'],
+ 'unsafe-none' => ['unsafe-none'],
+ ];
+ }
+
+ public function testCrossOriginEmbedderPolicyRejectsInvalid(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ Headers::crossOriginEmbedderPolicy('invalid');
+ }
+
+ #[RunInSeparateProcess]
+ public function testCrossOriginOpenerPolicyDefault(): void
+ {
+ Headers::crossOriginOpenerPolicy();
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Cross-Origin-Opener-Policy: same-origin', $headers);
+ }
+
+ #[DataProvider('validCoopPoliciesProvider')]
+ #[RunInSeparateProcess]
+ public function testCrossOriginOpenerPolicyValid(string $policy): void
+ {
+ Headers::crossOriginOpenerPolicy($policy);
+
+ $headers = xdebug_get_headers();
+ self::assertContains("Cross-Origin-Opener-Policy: {$policy}", $headers);
+ }
+
+ /**
+ * @return array
+ */
+ public static function validCoopPoliciesProvider(): array
+ {
+ return [
+ 'same-origin' => ['same-origin'],
+ 'same-origin-allow-popups' => ['same-origin-allow-popups'],
+ 'unsafe-none' => ['unsafe-none'],
+ ];
+ }
+
+ public function testCrossOriginOpenerPolicyRejectsInvalid(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ Headers::crossOriginOpenerPolicy('invalid');
+ }
+
+ #[RunInSeparateProcess]
+ public function testCrossOriginResourcePolicyDefault(): void
+ {
+ Headers::crossOriginResourcePolicy();
+
+ $headers = xdebug_get_headers();
+ self::assertContains('Cross-Origin-Resource-Policy: same-origin', $headers);
+ }
+
+ #[DataProvider('validCorpPoliciesProvider')]
+ #[RunInSeparateProcess]
+ public function testCrossOriginResourcePolicyValid(string $policy): void
+ {
+ Headers::crossOriginResourcePolicy($policy);
+
+ $headers = xdebug_get_headers();
+ self::assertContains("Cross-Origin-Resource-Policy: {$policy}", $headers);
+ }
+
+ /**
+ * @return array
+ */
+ public static function validCorpPoliciesProvider(): array
+ {
+ return [
+ 'same-origin' => ['same-origin'],
+ 'same-site' => ['same-site'],
+ 'cross-origin' => ['cross-origin'],
+ ];
+ }
+
+ public function testCrossOriginResourcePolicyRejectsInvalid(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ Headers::crossOriginResourcePolicy('invalid');
+ }
+
+ // =========================================================================
+ // Remove Insecure Headers (OWASP A05 - Security Misconfiguration)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testRemoveInsecureHeaders(): void
+ {
+ // Set headers that should be removed
+ header('X-Powered-By: PHP/8.3.0');
+ header('Server: Apache/2.4.41');
+
+ Headers::removeInsecureHeaders();
+
+ $headers = xdebug_get_headers();
+
+ // These headers should be removed
+ foreach ($headers as $header) {
+ self::assertStringNotContainsString('X-Powered-By', $header);
+ self::assertStringNotContainsString('Server:', $header);
+ }
+ }
+
+ // =========================================================================
+ // Secure() - All-in-one (OWASP A05 - Secure Defaults)
+ // =========================================================================
+
+ #[RunInSeparateProcess]
+ public function testSecureSetsAllHeaders(): void
+ {
+ Headers::secure();
+
+ $headers = xdebug_get_headers();
+
+ // Check for essential security headers
+ $hasContentType = false;
+ $hasFrameOptions = false;
+ $hasXssProtection = false;
+ $hasReferrerPolicy = false;
+ $hasHsts = false;
+ $hasCsp = false;
+ $hasPermissions = false;
+
+ foreach ($headers as $header) {
+ if (str_starts_with($header, 'X-Content-Type-Options:')) {
+ $hasContentType = true;
+ }
+ if (str_starts_with($header, 'X-Frame-Options:')) {
+ $hasFrameOptions = true;
+ }
+ if (str_starts_with($header, 'X-XSS-Protection:')) {
+ $hasXssProtection = true;
+ }
+ if (str_starts_with($header, 'Referrer-Policy:')) {
+ $hasReferrerPolicy = true;
+ }
+ if (str_starts_with($header, 'Strict-Transport-Security:')) {
+ $hasHsts = true;
+ }
+ if (str_starts_with($header, 'Content-Security-Policy:')) {
+ $hasCsp = true;
+ }
+ if (str_starts_with($header, 'Permissions-Policy:')) {
+ $hasPermissions = true;
+ }
+ }
+
+ self::assertTrue($hasContentType, 'Missing X-Content-Type-Options');
+ self::assertTrue($hasFrameOptions, 'Missing X-Frame-Options');
+ self::assertTrue($hasXssProtection, 'Missing X-XSS-Protection');
+ self::assertTrue($hasReferrerPolicy, 'Missing Referrer-Policy');
+ self::assertTrue($hasHsts, 'Missing Strict-Transport-Security');
+ self::assertTrue($hasCsp, 'Missing Content-Security-Policy');
+ self::assertTrue($hasPermissions, 'Missing Permissions-Policy');
+ }
+}
diff --git a/tests/SanitizerTest.php b/tests/SanitizerTest.php
new file mode 100644
index 0000000..6d5c707
--- /dev/null
+++ b/tests/SanitizerTest.php
@@ -0,0 +1,407 @@
+'));
+ self::assertSame('&', Sanitizer::html('&'));
+ self::assertSame('"', Sanitizer::html('"'));
+ self::assertSame(''', Sanitizer::html("'"));
+ self::assertSame('<', Sanitizer::html('<'));
+ self::assertSame('>', Sanitizer::html('>'));
+ }
+
+ #[DataProvider('xssVectorsProvider')]
+ public function testHtmlPreventsXss(string $attack, string $context): void
+ {
+ $sanitized = Sanitizer::html($attack);
+
+ // The sanitized output should not contain unescaped dangerous characters
+ self::assertStringNotContainsString('', 'script tag'],
+ 'img onerror' => ['
', 'event handler'],
+ 'svg onload' => ['