Skip to content

Instantly share code, notes, and snippets.

@ClaraLeigh
Created June 10, 2024 11:32
Show Gist options
  • Save ClaraLeigh/1be29ec270f9371510ee3f81e2401464 to your computer and use it in GitHub Desktop.
Save ClaraLeigh/1be29ec270f9371510ee3f81e2401464 to your computer and use it in GitHub Desktop.
Laravel Security Pest Test - WIP
<?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