-
-
Save ClaraLeigh/1be29ec270f9371510ee3f81e2401464 to your computer and use it in GitHub Desktop.
Laravel Security Pest Test - WIP
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
use Illuminate\Database\Eloquent\Model; | |
use Illuminate\Foundation\Testing\RefreshDatabase; | |
use Illuminate\Support\Facades\File; | |
use Symfony\Component\Finder\SplFileInfo; | |
uses(RefreshDatabase::class); | |
expect()->extend('toHavePolicies', function (?array $exclude = null) { | |
// Get Policies | |
$policies = Gate::policies(); | |
$namespace = $this->value ?? 'App\\Models'; | |
$exclude = $exclude ?? []; | |
// get the folder from the namespace | |
$folder = str_replace('App\\', '', $namespace); | |
// Get Models | |
$models = collect(File::allFiles(app_path($folder))) | |
->map( | |
fn (SplFileInfo $i) => | |
$namespace . '\\' . ltrim(str_replace('/', '\\', $i->getRelativePath()) . '\\' . $i->getFilenameWithoutExtension(), '\\') | |
) | |
->filter( | |
fn($name) => !Str::contains($name, $exclude) | |
)->toArray(); | |
// Check if all models have a policy | |
$missing = collect(); | |
foreach ($models as $model) { | |
if (!array_key_exists($model, $policies)) { | |
$missing->push($model); | |
} | |
} | |
if ($missing->isNotEmpty()) { | |
test()->fail("The following models do not have a policy: \n" . $missing->implode("\n")); | |
} | |
return expect($missing)->toBeEmpty(); | |
}); | |
expect()->extend('DoesntHaveInsecureFunctions', function () { | |
$functions = ['md5', 'sha1', 'uniqid', 'rand', 'mt_rand', 'tempnam', 'str_shuffle', 'shuffle', 'array_rand']; | |
$files = File::allFiles(app_path()); | |
$insecure = collect(); | |
foreach ($files as $file) { | |
// Check each file for the insecure functions, if found, add the file, line and function to the $insecureFunctions array | |
$content = $file->getContents(); | |
foreach ($functions as $function) { | |
// Check if the function is used in the file, however we need to make sure it's not a false positive | |
// For example, if we're looking for 'rand', we need to make sure it's not a variable name or part of another function | |
// Before the string, it should be a space, semicolon, open brace, comma, new line, tab, close brace. | |
// After the string, it should be a bracket or a space then a bracket | |
$line = 0; | |
$regex = "/(?<=\s|;|\(|,|\n|\t|\{)($function)(?=\(|\s\()/"; | |
while (preg_match($regex, $content, $matches, PREG_OFFSET_CAPTURE, $line)) { | |
$line = $matches[0][1]; | |
$line_number = substr_count(substr($content, 0, $line), "\n") + 1; | |
$line_content = explode("\n", $content)[$line_number - 1]; | |
// check the line before, for a comment like @allow-{function} | |
if (preg_match('/@allow-' . $function . '/', explode("\n", $content)[$line_number - 2])) { | |
$line++; | |
continue; | |
} | |
$insecure->push([ | |
'file' => $file->getRelativePathname(), | |
'line' => $line_number, | |
'function' => $function, | |
'content' => $line_content | |
]); | |
$line++; | |
} | |
} | |
} | |
if ($insecure->isNotEmpty()) { | |
$error = ''; | |
$insecure->each(function ($item) use (&$error) { | |
$error .= "Insecure function {$item['function']} found in {$item['file']} on line {$item['line']}\n {$item['content']}\n"; | |
}); | |
test()->fail($error); | |
} | |
return $insecure->isEmpty(); | |
}); | |
expect()->extend('DoesntHaveRequestAll', function () { | |
$files = File::allFiles(app_path()); | |
$insecure = collect(); | |
foreach ($files as $file) { | |
$content = $file->getContents(); | |
$line = 0; | |
// Things to look for: $request()->all(), request()->all(), $request?->all(), request()?->all() | |
$regex = "/(\$request\(\)|request\(\))\s*\??->\s*all\(\)/"; | |
while (preg_match($regex, $content, $matches, PREG_OFFSET_CAPTURE, $line)) { | |
$line = $matches[0][1]; | |
$line_number = substr_count(substr($content, 0, $line), "\n") + 1; | |
$line_content = explode("\n", $content)[$line_number - 1]; | |
$insecure->push([ | |
'file' => $file->getRelativePathname(), | |
'line' => $line_number, | |
'content' => $line_content, | |
]); | |
$line++; | |
} | |
} | |
if ($insecure->isNotEmpty()) { | |
$error = ''; | |
$insecure->each(function ($item) use (&$error) { | |
$error .= "request()->all() found in {$item['file']} on line {$item['line']}\n {$item['content']}\n"; | |
}); | |
test()->fail($error); | |
} | |
return $insecure->isEmpty(); | |
}); | |
expect()->extend('EnvNotUsedOutsideConfig', function () { | |
$files = File::allFiles(app_path()); | |
$insecure = collect(); | |
foreach ($files as $file) { | |
$content = $file->getContents(); | |
$line = 0; | |
// Things to look for: env( | |
$regex = "/env\(/"; | |
while (preg_match($regex, $content, $matches, PREG_OFFSET_CAPTURE, $line)) { | |
$line = $matches[0][1]; | |
$line_number = substr_count(substr($content, 0, $line), "\n") + 1; | |
$line_content = explode("\n", $content)[$line_number - 1]; | |
$insecure->push([ | |
'file' => $file->getRelativePathname(), | |
'line' => $line_number, | |
'content' => $line_content, | |
]); | |
$line++; | |
} | |
} | |
if ($insecure->isNotEmpty()) { | |
$error = ''; | |
$insecure->each(function ($item) use (&$error) { | |
$error .= "env() found in {$item['file']} on line {$item['line']}\n {$item['content']}\n"; | |
}); | |
test()->fail($error); | |
} | |
return expect($insecure)->toBeEmpty(); | |
}); | |
describe('App is secure', function () { | |
arch('Models have Policies') | |
->expect('App\\Models') | |
->toHavePolicies( | |
exclude: ['Trait', 'Scope'] | |
); | |
arch('Doesn\'t have insecure functions') | |
->expect('app') | |
->DoesntHaveInsecureFunctions(); | |
arch('Doesn\'t have request()->all()') | |
->expect('app') | |
->DoesntHaveRequestAll(); | |
arch('Env is not used outside config folder') | |
->expect('app') | |
->EnvNotUsedOutsideConfig(); | |
})->skip(); | |
// Lets search through all models, get all of their column names, then check if they are in a list of keys likely to contain sensitive information | |
// Sensitve keys (match any part of the string): password, token, secret, key, pin, cvv, ssn, hash, salt, signature | |
// Then lets make sure the related models either include them in the $hidden array or alternatively has a $visible array that doesn't include them | |
test('Common sensitive db fields are hidden', function () { | |
$models = collect(File::allFiles(app_path('Models'))) | |
->map( | |
fn (SplFileInfo $i) => | |
'App\\Models\\' . ltrim(str_replace('/', '\\', $i->getRelativePath()) . '\\' . $i->getFilenameWithoutExtension(), '\\') | |
) | |
// make sure that the model is actually a class and extends Illuminate\Database\Eloquent\Model | |
->filter( | |
fn($name) => class_exists($name) && is_subclass_of($name, Model::class) | |
) | |
->toArray(); | |
$sensitiveKeys = ['password', 'token', 'secret', 'key', 'pin', 'cvv', 'ssn', 'hash', 'salt', 'signature']; | |
$insecure = collect(); | |
foreach ($models as $model) { | |
$instance = new $model(); | |
$hidden = $instance->getHidden(); | |
$visible = $instance->getVisible(); | |
$columns = $instance->getConnection()->getSchemaBuilder()->getColumnListing($instance->getTable()); | |
$sensitiveColumns = collect($columns)->filter(function ($column) use ($sensitiveKeys) { | |
return collect($sensitiveKeys)->contains(fn($key) => Str::contains($column, $key)); | |
}); | |
$sensitiveColumns->each(function ($column) use ($hidden, $visible, $model, &$insecure) { | |
if (in_array($column, $hidden) || (!empty($visible) && is_array($visible) && !in_array($column, $visible))) { | |
return; | |
} | |
$insecure->push([ | |
'model' => $model, | |
'column' => $column, | |
'hidden' => $hidden, | |
'visible' => $visible, | |
]); | |
}); | |
} | |
if ($insecure->isNotEmpty()) { | |
$error = ''; | |
$insecure->each(function ($item) use (&$error) { | |
$error .= "Sensitive column {$item['column']} in model {$item['model']} is not hidden or visible\n"; | |
}); | |
test()->fail($error); | |
} | |
return expect($insecure)->toBeEmpty(); | |
}); | |
// TODO: Composer audit | |
// - `composer audit` | |
// TODO: package.json audit | |
// TODO: Route Audit | |
// TODO: Tool audit check versions: php, composer, npm, node, laravel? | |
// TODO: Check for dd, dump, die, exit, var_dump, print_r, console.log, alert | |
// TODO: Route Audit | |
// - Look for raw API responses that include things like "created_at" and "updated_at" | |
// - a route:list --json > routes.json | |
// TODO: Header Audit | |
// TODO: Middleware audit | |
// TODO: CSRF Token Audit | |
// TODO: Backup / Recovery Audit | |
// TODO: Check debug mode is off | |
// TODO: Check rate limiting is in place? | |
// TODO: SQL Injection Audit | |
// TODO: Input validation audit | |
// TODO: File upload audit | |
// TODO: Indepth Policy Audit | |
// TODO: Ensure Dependabot is enabled: possibly add github actions | |
// TODO: .gitignore audit | |
// TODO: MFA Authentication | |
// TODO: Validate password steps | |
// TODO: Email and Mobile verification | |
// TODO: Code coverage: Type Coverage | |
// - ./vendor/bin/pest --type-coverage --min=100 --type-coverage-json=my-report.json | |
// TODO: Implement Snapshot testing |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment