Initial commit: PHP/Laravel Security Linter v1.0.0

A static security analysis tool for PHP and Laravel applications
with recursive taint analysis capabilities.

Features:
- Comprehensive vulnerability detection (XSS, SQL Injection,
  Command Injection, Path Traversal, CSRF, Authentication issues)
- Recursive taint analysis across function calls
- Blade template analysis with context-aware XSS detection
- Smart escape detection and escape bypass detection
- Syntax highlighting in terminal output
- Multi-language support (Japanese/English)
- Docker support for easy deployment
- Multiple output formats (text, JSON, HTML, SARIF, Markdown)
- CI/CD integration ready (GitHub Actions, GitLab CI)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 15:18:53 +09:00
commit 6280290898
30 changed files with 13160 additions and 0 deletions

View File

@@ -0,0 +1,337 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Analyzer;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
/**
* Builds a call graph from PHP AST for recursive analysis
*/
class CallGraphBuilder
{
/** @var array Function/method definitions: [name => [file, line, params, body]] */
private array $definitions = [];
/** @var array Call relationships: [caller => [callees]] */
private array $calls = [];
/** @var array Class method definitions: [className::methodName => definition] */
private array $classMethods = [];
/** @var array Current file being analyzed */
private string $currentFile = '';
/** @var string|null Current class context */
private ?string $currentClass = null;
/** @var string|null Current function/method context */
private ?string $currentFunction = null;
/**
* Build call graph from AST
*/
public function buildFromAst(array $ast, string $filePath): void
{
$this->currentFile = $filePath;
$traverser = new NodeTraverser();
$traverser->addVisitor(new class($this) extends NodeVisitorAbstract {
private CallGraphBuilder $builder;
public function __construct(CallGraphBuilder $builder)
{
$this->builder = $builder;
}
public function enterNode(Node $node): ?int
{
$this->builder->processNode($node);
return null;
}
public function leaveNode(Node $node): ?int
{
$this->builder->leaveNodeContext($node);
return null;
}
});
$traverser->traverse($ast);
}
/**
* Process a node to build call graph
*/
public function processNode(Node $node): void
{
// Track class context
if ($node instanceof Node\Stmt\Class_) {
$this->currentClass = $node->namespacedName?->toString() ?? $node->name?->toString();
}
// Track function definitions
if ($node instanceof Node\Stmt\Function_) {
$name = $node->namespacedName?->toString() ?? $node->name->toString();
$this->currentFunction = $name;
$this->definitions[$name] = [
'file' => $this->currentFile,
'line' => $node->getStartLine(),
'params' => $this->extractParams($node->params),
'node' => $node,
];
}
// Track method definitions
if ($node instanceof Node\Stmt\ClassMethod && $this->currentClass) {
$name = $this->currentClass . '::' . $node->name->toString();
$this->currentFunction = $name;
$this->classMethods[$name] = [
'file' => $this->currentFile,
'line' => $node->getStartLine(),
'params' => $this->extractParams($node->params),
'visibility' => $this->getVisibility($node),
'static' => $node->isStatic(),
'node' => $node,
];
}
// Track function/method calls
if ($node instanceof Node\Expr\FuncCall) {
$callee = $this->getFunctionName($node);
if ($callee && $this->currentFunction) {
$this->addCall($this->currentFunction, $callee, $node->getStartLine());
}
}
// Track method calls
if ($node instanceof Node\Expr\MethodCall) {
$callee = $this->getMethodCallName($node);
if ($callee && $this->currentFunction) {
$this->addCall($this->currentFunction, $callee, $node->getStartLine());
}
}
// Track static method calls
if ($node instanceof Node\Expr\StaticCall) {
$callee = $this->getStaticCallName($node);
if ($callee && $this->currentFunction) {
$this->addCall($this->currentFunction, $callee, $node->getStartLine());
}
}
}
/**
* Leave node context
*/
public function leaveNodeContext(Node $node): void
{
if ($node instanceof Node\Stmt\Class_) {
$this->currentClass = null;
}
if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) {
$this->currentFunction = null;
}
}
/**
* Extract parameter information
*/
private function extractParams(array $params): array
{
$result = [];
foreach ($params as $param) {
$result[] = [
'name' => '$' . $param->var->name,
'type' => $param->type ? $this->getTypeName($param->type) : null,
'default' => $param->default !== null,
];
}
return $result;
}
/**
* Get type name from node
*/
private function getTypeName(?Node $type): ?string
{
if ($type === null) {
return null;
}
if ($type instanceof Node\Identifier) {
return $type->toString();
}
if ($type instanceof Node\Name) {
return $type->toString();
}
if ($type instanceof Node\NullableType) {
return '?' . $this->getTypeName($type->type);
}
if ($type instanceof Node\UnionType) {
return implode('|', array_map(fn($t) => $this->getTypeName($t), $type->types));
}
return null;
}
/**
* Get visibility modifier
*/
private function getVisibility(Node\Stmt\ClassMethod $node): string
{
if ($node->isPublic()) {
return 'public';
}
if ($node->isProtected()) {
return 'protected';
}
if ($node->isPrivate()) {
return 'private';
}
return 'public';
}
/**
* Get function name from FuncCall
*/
private function getFunctionName(Node\Expr\FuncCall $node): ?string
{
if ($node->name instanceof Node\Name) {
return $node->name->toString();
}
return null;
}
/**
* Get method call name
*/
private function getMethodCallName(Node\Expr\MethodCall $node): ?string
{
if ($node->name instanceof Node\Identifier) {
// Try to determine class from variable
$varName = $this->getVariableName($node->var);
return $varName ? "{$varName}->{$node->name->toString()}" : "->{$node->name->toString()}";
}
return null;
}
/**
* Get static call name
*/
private function getStaticCallName(Node\Expr\StaticCall $node): ?string
{
$class = null;
if ($node->class instanceof Node\Name) {
$class = $node->class->toString();
}
$method = null;
if ($node->name instanceof Node\Identifier) {
$method = $node->name->toString();
}
if ($class && $method) {
return "{$class}::{$method}";
}
return null;
}
/**
* Get variable name
*/
private function getVariableName(Node $node): ?string
{
if ($node instanceof Node\Expr\Variable) {
return is_string($node->name) ? '$' . $node->name : null;
}
if ($node instanceof Node\Expr\PropertyFetch) {
$var = $this->getVariableName($node->var);
$prop = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
return $var && $prop ? "{$var}->{$prop}" : null;
}
return null;
}
/**
* Add a call relationship
*/
private function addCall(string $caller, string $callee, int $line): void
{
if (!isset($this->calls[$caller])) {
$this->calls[$caller] = [];
}
$this->calls[$caller][] = [
'callee' => $callee,
'line' => $line,
'file' => $this->currentFile,
];
}
/**
* Get the complete call graph
*/
public function getCallGraph(): array
{
return [
'definitions' => $this->definitions,
'classMethods' => $this->classMethods,
'calls' => $this->calls,
];
}
/**
* Find all callers of a function/method
*/
public function findCallers(string $functionName): array
{
$callers = [];
foreach ($this->calls as $caller => $callees) {
foreach ($callees as $call) {
if ($call['callee'] === $functionName || str_ends_with($call['callee'], "::{$functionName}")) {
$callers[] = [
'caller' => $caller,
'line' => $call['line'],
'file' => $call['file'],
];
}
}
}
return $callers;
}
/**
* Find all callees of a function/method
*/
public function findCallees(string $functionName): array
{
return $this->calls[$functionName] ?? [];
}
/**
* Get function/method definition
*/
public function getDefinition(string $name): ?array
{
return $this->definitions[$name] ?? $this->classMethods[$name] ?? null;
}
/**
* Check if function exists in call graph
*/
public function hasFunction(string $name): bool
{
return isset($this->definitions[$name]) || isset($this->classMethods[$name]);
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Analyzer;
use PhpParser\ParserFactory;
use PhpParser\Parser;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Error;
/**
* Parses PHP files into AST
*/
class FileAnalyzer
{
private Parser $parser;
private array $errors = [];
public function __construct()
{
$this->parser = (new ParserFactory())->createForNewestSupportedVersion();
}
/**
* Parse PHP code into AST
*/
public function parse(string $code, string $filePath = ''): ?array
{
try {
$ast = $this->parser->parse($code);
if ($ast === null) {
return null;
}
// Resolve names to fully qualified
$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver());
$ast = $traverser->traverse($ast);
return $ast;
} catch (Error $e) {
$this->errors[] = [
'file' => $filePath,
'message' => $e->getMessage(),
'line' => $e->getStartLine(),
];
return null;
}
}
/**
* Parse a Blade template
*/
public function parseBlade(string $code, string $filePath): array
{
$result = [
'raw_outputs' => [], // {!! $var !!}
'escaped_outputs' => [], // {{ $var }}
'php_blocks' => [], // @php ... @endphp
'includes' => [], // @include, @extends, @component
'forms' => [], // <form> tags
'csrf_tokens' => [], // @csrf directives
];
// Find raw (unescaped) outputs - security risk
preg_match_all('/\{!!\s*(.+?)\s*!!\}/s', $code, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as $match) {
$result['raw_outputs'][] = [
'expression' => $match[0],
'line' => $this->getLineNumber($code, $match[1]),
];
}
// Find escaped outputs
preg_match_all('/\{\{\s*(.+?)\s*\}\}/s', $code, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as $match) {
$result['escaped_outputs'][] = [
'expression' => $match[0],
'line' => $this->getLineNumber($code, $match[1]),
];
}
// Find @php blocks
preg_match_all('/@php\s*(.*?)\s*@endphp/s', $code, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[1] as $match) {
$result['php_blocks'][] = [
'code' => $match[0],
'line' => $this->getLineNumber($code, $match[1]),
];
}
// Find includes
preg_match_all('/@(include|extends|component)\s*\(\s*[\'"](.+?)[\'"]/s', $code, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[2] as $i => $match) {
$result['includes'][] = [
'type' => $matches[1][$i][0],
'path' => $match[0],
'line' => $this->getLineNumber($code, $match[1]),
];
}
// Find forms
preg_match_all('/<form[^>]*>/i', $code, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $match) {
$line = $this->getLineNumber($code, $match[1]);
$result['forms'][] = [
'tag' => $match[0],
'line' => $line,
'has_method' => stripos($match[0], 'method=') !== false,
];
}
// Find CSRF tokens
preg_match_all('/@csrf/i', $code, $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $match) {
$result['csrf_tokens'][] = [
'line' => $this->getLineNumber($code, $match[1]),
];
}
return $result;
}
/**
* Get line number from offset
*/
private function getLineNumber(string $code, int $offset): int
{
return substr_count(substr($code, 0, $offset), "\n") + 1;
}
/**
* Get parse errors
*/
public function getErrors(): array
{
return $this->errors;
}
}

View File

@@ -0,0 +1,520 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Analyzer;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
/**
* Preprocessor that analyzes AST to mark tainted variables
*
* This runs before the main analysis to propagate taint information
* from user input sources through assignments.
*/
class TaintPreprocessor
{
private TaintTracker $taintTracker;
private string $currentFile = '';
private ?string $currentClass = null;
private ?string $currentMethod = null;
/** @var array Tracks which function parameters receive tainted data */
private array $taintedParams = [];
public function __construct(TaintTracker $taintTracker)
{
$this->taintTracker = $taintTracker;
}
/**
* Safely get variable name from a Variable node
*/
private function safeGetVarName(Node\Expr\Variable $node): string
{
return is_string($node->name) ? $node->name : '';
}
/**
* Preprocess AST to mark tainted variables
*/
public function process(array $ast, string $filePath): void
{
$this->currentFile = $filePath;
// First pass: collect function/method definitions and their parameters
$this->collectDefinitions($ast);
// Second pass: track taint propagation through assignments
$this->trackTaintPropagation($ast);
// Third pass: propagate taint through function calls
$this->propagateThroughCalls($ast);
}
/**
* Collect function and method definitions
*/
private function collectDefinitions(array $ast): void
{
$traverser = new NodeTraverser();
$self = $this;
$traverser->addVisitor(new class($self) extends NodeVisitorAbstract {
private TaintPreprocessor $processor;
public function __construct(TaintPreprocessor $processor)
{
$this->processor = $processor;
}
public function enterNode(Node $node): ?int
{
if ($node instanceof Node\Stmt\Class_) {
$this->processor->setCurrentClass($node->name?->toString());
}
if ($node instanceof Node\Stmt\ClassMethod) {
$this->processor->setCurrentMethod($node->name->toString());
}
if ($node instanceof Node\Stmt\Function_) {
$this->processor->setCurrentMethod($node->name->toString());
}
return null;
}
public function leaveNode(Node $node): ?int
{
if ($node instanceof Node\Stmt\Class_) {
$this->processor->setCurrentClass(null);
}
if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) {
$this->processor->setCurrentMethod(null);
}
return null;
}
});
$traverser->traverse($ast);
}
/**
* Track taint propagation through assignments
*/
private function trackTaintPropagation(array $ast): void
{
$traverser = new NodeTraverser();
$self = $this;
$traverser->addVisitor(new class($self) extends NodeVisitorAbstract {
private TaintPreprocessor $processor;
private ?string $currentClass = null;
private ?string $currentMethod = null;
public function __construct(TaintPreprocessor $processor)
{
$this->processor = $processor;
}
public function enterNode(Node $node): ?int
{
if ($node instanceof Node\Stmt\Class_) {
$this->currentClass = $node->name?->toString();
}
if ($node instanceof Node\Stmt\ClassMethod) {
$this->currentMethod = $node->name->toString();
// Check if method parameters come from tainted sources (controller methods)
$this->processor->checkMethodParams($node, $this->currentClass);
}
if ($node instanceof Node\Stmt\Function_) {
$this->currentMethod = $node->name->toString();
}
// Check assignments
if ($node instanceof Node\Expr\Assign) {
$this->processor->processAssignment($node);
}
return null;
}
public function leaveNode(Node $node): ?int
{
if ($node instanceof Node\Stmt\Class_) {
$this->currentClass = null;
}
if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) {
$this->currentMethod = null;
}
return null;
}
});
$traverser->traverse($ast);
}
/**
* Propagate taint through function calls
*/
private function propagateThroughCalls(array $ast): void
{
// Multiple iterations to handle nested calls
for ($i = 0; $i < 3; $i++) {
$traverser = new NodeTraverser();
$self = $this;
$traverser->addVisitor(new class($self) extends NodeVisitorAbstract {
private TaintPreprocessor $processor;
public function __construct(TaintPreprocessor $processor)
{
$this->processor = $processor;
}
public function enterNode(Node $node): ?int
{
// Check method calls where we pass tainted data
if ($node instanceof Node\Expr\MethodCall) {
$this->processor->processMethodCall($node);
}
// Check function calls
if ($node instanceof Node\Expr\FuncCall) {
$this->processor->processFunctionCall($node);
}
// Check assignments from function return values
if ($node instanceof Node\Expr\Assign) {
$this->processor->processAssignmentFromCall($node);
}
return null;
}
});
$traverser->traverse($ast);
}
}
/**
* Check controller method parameters (Request $request)
*/
public function checkMethodParams(Node\Stmt\ClassMethod $node, ?string $className): void
{
foreach ($node->params as $param) {
$typeName = $this->getTypeName($param->type);
// Request parameter in controller is always tainted
if ($typeName && (
$typeName === 'Request' ||
str_contains($typeName, 'Request') ||
str_contains($typeName, 'Illuminate\\Http\\Request')
)) {
$varName = '$' . $param->var->name;
$this->taintTracker->markTainted($varName, $this->currentFile, $param);
}
}
}
/**
* Process assignment to detect taint propagation
*/
public function processAssignment(Node\Expr\Assign $node): void
{
// Get variable name being assigned
$varName = $this->getAssignedVarName($node->var);
if ($varName === null) {
return;
}
// Check if the right side is tainted
if ($this->isExpressionTainted($node->expr)) {
$this->taintTracker->markTainted($varName, $this->currentFile, $node->expr);
}
}
/**
* Process assignment from function/method call return
*/
public function processAssignmentFromCall(Node\Expr\Assign $node): void
{
$varName = $this->getAssignedVarName($node->var);
if ($varName === null) {
return;
}
// If the expression is a method/function call that might return tainted data
if ($node->expr instanceof Node\Expr\MethodCall ||
$node->expr instanceof Node\Expr\FuncCall ||
$node->expr instanceof Node\Expr\StaticCall) {
// Check if any argument to the call is tainted
$args = $node->expr->args ?? [];
foreach ($args as $arg) {
// Skip VariadicPlaceholder (spread operator ...)
if (!$arg instanceof Node\Arg) {
continue;
}
if ($this->isExpressionTainted($arg->value)) {
// The function receives tainted data, so its return might be tainted
// (unless it's a known sanitizer)
$funcName = $this->getCallName($node->expr);
if ($funcName && !$this->taintTracker->isSanitizer($funcName)) {
$this->taintTracker->markTainted($varName, $this->currentFile, $node->expr);
break;
}
}
}
}
}
/**
* Process method call to track taint flow
*/
public function processMethodCall(Node\Expr\MethodCall $node): void
{
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
if ($methodName === null) {
return;
}
// Track tainted arguments to method calls
foreach ($node->args as $index => $arg) {
// Skip VariadicPlaceholder (spread operator ...)
if (!$arg instanceof Node\Arg) {
continue;
}
if ($this->isExpressionTainted($arg->value)) {
$key = ($this->currentClass ? $this->currentClass . '::' : '') . $methodName . ':' . $index;
$this->taintedParams[$key] = true;
}
}
}
/**
* Process function call to track taint flow
*/
public function processFunctionCall(Node\Expr\FuncCall $node): void
{
$funcName = $this->getCallName($node);
if ($funcName === null) {
return;
}
// Track tainted arguments
foreach ($node->args as $index => $arg) {
// Skip VariadicPlaceholder (spread operator ...)
if (!$arg instanceof Node\Arg) {
continue;
}
if ($this->isExpressionTainted($arg->value)) {
$key = $funcName . ':' . $index;
$this->taintedParams[$key] = true;
}
}
}
/**
* Check if an expression is tainted
*/
private function isExpressionTainted(Node $expr): bool
{
// Check direct taint via TaintTracker
if ($this->taintTracker->isTainted($expr, $this->currentFile)) {
return true;
}
// Check for direct user input patterns
if ($this->isDirectUserInput($expr)) {
return true;
}
// Check variable against marked tainted variables
if ($expr instanceof Node\Expr\Variable) {
$varName = '$' . ($this->safeGetVarName($expr));
return $this->taintTracker->isTainted($expr, $this->currentFile);
}
// Check concatenation
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
return $this->isExpressionTainted($expr->left) || $this->isExpressionTainted($expr->right);
}
// Check string interpolation
if ($expr instanceof Node\Scalar\Encapsed) {
foreach ($expr->parts as $part) {
if ($this->isExpressionTainted($part)) {
return true;
}
}
}
return false;
}
/**
* Check for direct user input patterns
*/
private function isDirectUserInput(Node $expr): bool
{
// $request->input(), $request->get(), etc.
if ($expr instanceof Node\Expr\MethodCall) {
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : '';
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except', 'file', 'cookie'];
if (in_array($methodName, $taintedMethods)) {
// Check if called on $request
if ($expr->var instanceof Node\Expr\Variable) {
$varName = $this->safeGetVarName($expr->var);
if ($varName === 'request') {
return true;
}
}
// Check for request() helper chain
if ($expr->var instanceof Node\Expr\FuncCall) {
$funcName = $this->getCallName($expr->var);
if ($funcName === 'request') {
return true;
}
}
}
}
// $_GET, $_POST, etc.
if ($expr instanceof Node\Expr\ArrayDimFetch) {
if ($expr->var instanceof Node\Expr\Variable) {
$varName = $this->safeGetVarName($expr->var);
if (in_array($varName, ['_GET', '_POST', '_REQUEST', '_COOKIE', '_FILES', '_SERVER'])) {
return true;
}
}
// Recursive check for nested array access
if ($expr->var instanceof Node\Expr\ArrayDimFetch) {
return $this->isDirectUserInput($expr->var);
}
}
// Request::input(), etc.
if ($expr instanceof Node\Expr\StaticCall) {
$className = $expr->class instanceof Node\Name ? $expr->class->toString() : '';
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : '';
if (str_contains($className, 'Request')) {
$taintedMethods = ['input', 'get', 'post', 'query', 'all'];
if (in_array($methodName, $taintedMethods)) {
return true;
}
}
}
return false;
}
/**
* Get the variable name from an assignment target
*/
private function getAssignedVarName(Node $node): ?string
{
if ($node instanceof Node\Expr\Variable) {
if (!is_string($node->name)) {
return null; // Variable variables not supported
}
return '$' . $node->name;
}
if ($node instanceof Node\Expr\PropertyFetch) {
// Handle $this->property
$var = $this->getAssignedVarName($node->var);
$prop = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
if ($var && $prop) {
return "{$var}->{$prop}";
}
}
if ($node instanceof Node\Expr\ArrayDimFetch) {
return $this->getAssignedVarName($node->var);
}
return null;
}
/**
* Get type name from type node
*/
private function getTypeName(?Node $type): ?string
{
if ($type === null) {
return null;
}
if ($type instanceof Node\Identifier) {
return $type->toString();
}
if ($type instanceof Node\Name) {
return $type->toString();
}
if ($type instanceof Node\NullableType) {
return $this->getTypeName($type->type);
}
return null;
}
/**
* Get function/method name from call
*/
private function getCallName(Node $node): ?string
{
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) {
return $node->name->toString();
}
if ($node instanceof Node\Expr\MethodCall && $node->name instanceof Node\Identifier) {
return $node->name->toString();
}
if ($node instanceof Node\Expr\StaticCall && $node->name instanceof Node\Identifier) {
$class = $node->class instanceof Node\Name ? $node->class->toString() : '';
return $class . '::' . $node->name->toString();
}
return null;
}
/**
* Set current class context
*/
public function setCurrentClass(?string $class): void
{
$this->currentClass = $class;
}
/**
* Set current method context
*/
public function setCurrentMethod(?string $method): void
{
$this->currentMethod = $method;
}
/**
* Get tainted parameters
*/
public function getTaintedParams(): array
{
return $this->taintedParams;
}
}

View File

@@ -0,0 +1,594 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Analyzer;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
/**
* Tracks tainted data flow from sources to sinks
*
* Sources: User input ($_GET, $_POST, $_REQUEST, $request->input(), etc.)
* Sinks: Dangerous functions (echo, eval, DB::raw, exec, etc.)
* Sanitizers: Functions that clean data (htmlspecialchars, escape, etc.)
*/
class TaintTracker
{
/** @var array User input sources */
private const SOURCES = [
// PHP Superglobals
'$_GET', '$_POST', '$_REQUEST', '$_COOKIE', '$_FILES', '$_SERVER',
// Laravel Request methods
'$request->input', '$request->get', '$request->post', '$request->query',
'$request->all', '$request->only', '$request->except',
'$request->file', '$request->cookie',
'request()->input', 'request()->get', 'request()->all',
'Request::input', 'Request::get', 'Request::all',
// Raw input
'file_get_contents(\'php://input\')',
'php://input',
];
/** @var array Functions that sanitize data */
private const SANITIZERS = [
// XSS sanitizers
'htmlspecialchars', 'htmlentities', 'strip_tags',
'e', // Laravel helper
// SQL sanitizers
'addslashes', 'mysqli_real_escape_string', 'pg_escape_string',
// Path sanitizers
'basename', 'realpath',
// Validation (Laravel)
'validate', 'validated',
// Type casting
'(int)', '(float)', '(bool)', 'intval', 'floatval', 'boolval',
];
/** @var array Variables currently tainted */
private array $taintedVariables = [];
/** @var array Taint propagation history */
private array $taintHistory = [];
/** @var array Call graph for recursive analysis */
private array $callGraph = [];
/** @var int Maximum recursion depth */
private int $maxDepth;
/** @var array Cache for analyzed functions */
private array $analysisCache = [];
public function __construct(int $maxDepth = 10)
{
$this->maxDepth = $maxDepth;
}
/**
* Set call graph for cross-function analysis
*/
public function setCallGraph(array $callGraph): void
{
$this->callGraph = $callGraph;
}
/**
* Check if an expression is tainted (contains user input)
*/
public function isTainted(Node $node, string $filePath, int $depth = 0): bool
{
if ($depth > $this->maxDepth) {
return false;
}
// Check if it's a direct source
if ($this->isSource($node)) {
return true;
}
// Check variable
if ($node instanceof Node\Expr\Variable) {
if (!is_string($node->name)) {
// Variable variables ($$foo) - assume tainted for safety
return true;
}
$varName = '$' . $node->name;
return $this->isVariableTainted($varName, $filePath);
}
// Check array access (e.g., $_GET['foo'])
if ($node instanceof Node\Expr\ArrayDimFetch) {
return $this->isTainted($node->var, $filePath, $depth);
}
// Check property fetch (e.g., $request->input)
if ($node instanceof Node\Expr\PropertyFetch) {
return $this->isPropertyFetchTainted($node, $filePath, $depth);
}
// Check method call (e.g., $request->input('foo'))
if ($node instanceof Node\Expr\MethodCall) {
return $this->isMethodCallTainted($node, $filePath, $depth);
}
// Check static call (e.g., Request::input('foo'))
if ($node instanceof Node\Expr\StaticCall) {
return $this->isStaticCallTainted($node, $filePath, $depth);
}
// Check function call (e.g., request()->input('foo'))
if ($node instanceof Node\Expr\FuncCall) {
return $this->isFunctionCallTainted($node, $filePath, $depth);
}
// Check binary operations (concatenation propagates taint)
if ($node instanceof Node\Expr\BinaryOp\Concat) {
return $this->isTainted($node->left, $filePath, $depth)
|| $this->isTainted($node->right, $filePath, $depth);
}
// Check encapsed string (string interpolation)
if ($node instanceof Node\Scalar\Encapsed) {
foreach ($node->parts as $part) {
if ($this->isTainted($part, $filePath, $depth)) {
return true;
}
}
}
// Check ternary
if ($node instanceof Node\Expr\Ternary) {
return $this->isTainted($node->if ?? $node->cond, $filePath, $depth)
|| $this->isTainted($node->else, $filePath, $depth);
}
return false;
}
/**
* Check if node is a direct source of user input
*/
public function isSource(Node $node): bool
{
// Check superglobals
if ($node instanceof Node\Expr\Variable) {
// Variable name can be a string or an expression (variable variables)
if (!is_string($node->name)) {
return false;
}
$name = '$' . $node->name;
return in_array($name, ['$_GET', '$_POST', '$_REQUEST', '$_COOKIE', '$_FILES', '$_SERVER']);
}
// Check array access to superglobals
if ($node instanceof Node\Expr\ArrayDimFetch) {
return $this->isSource($node->var);
}
return false;
}
/**
* Check if variable is tainted
*/
private function isVariableTainted(string $varName, string $filePath): bool
{
$key = "{$filePath}:{$varName}";
return isset($this->taintedVariables[$key]);
}
/**
* Mark a variable as tainted
*/
public function markTainted(string $varName, string $filePath, ?Node $source = null): void
{
$key = "{$filePath}:{$varName}";
$this->taintedVariables[$key] = [
'source' => $source,
'file' => $filePath,
];
}
/**
* Mark a variable as clean (sanitized)
*/
public function markClean(string $varName, string $filePath): void
{
$key = "{$filePath}:{$varName}";
unset($this->taintedVariables[$key]);
}
/**
* Safely get variable name from a Variable node
*/
private function safeGetVarName(Node\Expr\Variable $node): string
{
return is_string($node->name) ? $node->name : '';
}
/**
* Check if property fetch is tainted
*/
private function isPropertyFetchTainted(Node\Expr\PropertyFetch $node, string $filePath, int $depth): bool
{
// Check for $request->property patterns
if ($node->var instanceof Node\Expr\Variable) {
$varName = $this->safeGetVarName($node->var);
$propName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
if ($varName === 'request') {
// Common tainted request properties
$taintedProps = ['input', 'query', 'post', 'all', 'cookie', 'file'];
return in_array($propName, $taintedProps);
}
}
return $this->isTainted($node->var, $filePath, $depth);
}
/**
* Check if method call is tainted
*/
private function isMethodCallTainted(Node\Expr\MethodCall $node, string $filePath, int $depth): bool
{
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
// Check for $request->input() style calls
if ($node->var instanceof Node\Expr\Variable) {
$varName = $this->safeGetVarName($node->var);
if ($varName === 'request') {
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except', 'file', 'cookie'];
if (in_array($methodName, $taintedMethods)) {
return true;
}
}
}
// Check for chained request() helper
if ($node->var instanceof Node\Expr\FuncCall) {
if ($this->getFunctionName($node->var) === 'request') {
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except'];
if (in_array($methodName, $taintedMethods)) {
return true;
}
}
}
// Check if it's a sanitizer method
if ($this->isSanitizerMethod($methodName)) {
return false;
}
// Recursively check if the object is tainted
return $this->isTainted($node->var, $filePath, $depth);
}
/**
* Check if static call is tainted
*/
private function isStaticCallTainted(Node\Expr\StaticCall $node, string $filePath, int $depth): bool
{
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
// Laravel Request facade
if (in_array($className, ['Request', 'Illuminate\\Support\\Facades\\Request'])) {
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except'];
if (in_array($methodName, $taintedMethods)) {
return true;
}
}
return false;
}
/**
* Check if function call is tainted
*/
private function isFunctionCallTainted(Node\Expr\FuncCall $node, string $filePath, int $depth): bool
{
$funcName = $this->getFunctionName($node);
if ($funcName === null) {
return false;
}
// Check if it's the request() helper
if ($funcName === 'request') {
// request() without parameters returns the Request object
// which will be tainted when accessing input methods
return true;
}
// Check if it's a sanitizer
if ($this->isSanitizer($funcName)) {
return false;
}
// Trace through user-defined functions (recursive analysis)
return $this->traceFunction($funcName, $node->args, $filePath, $depth + 1);
}
/**
* Trace taint through a user-defined function
*/
private function traceFunction(string $funcName, array $args, string $filePath, int $depth): bool
{
if ($depth > $this->maxDepth) {
return false;
}
// Check cache
try {
// Filter out VariadicPlaceholder nodes
$filteredArgs = array_filter($args, fn($a) => $a instanceof Node\Arg);
$cacheKey = "{$funcName}:" . serialize(array_map(fn($a) => $this->nodeToString($a->value), $filteredArgs));
if (isset($this->analysisCache[$cacheKey])) {
return $this->analysisCache[$cacheKey];
}
} catch (\Throwable $e) {
// If cache key generation fails, proceed without caching
$cacheKey = null;
}
// Check if any arguments are tainted
$taintedArgs = [];
foreach ($args as $i => $arg) {
// Skip VariadicPlaceholder (spread operator ...)
if (!$arg instanceof Node\Arg) {
continue;
}
if ($this->isTainted($arg->value, $filePath, $depth)) {
$taintedArgs[] = $i;
}
}
if (empty($taintedArgs)) {
if ($cacheKey !== null) {
$this->analysisCache[$cacheKey] = false;
}
return false;
}
// Look up function definition in call graph
$definition = $this->callGraph['definitions'][$funcName] ?? null;
if ($definition === null) {
// Try class methods
foreach ($this->callGraph['classMethods'] ?? [] as $name => $def) {
if (str_ends_with($name, "::{$funcName}")) {
$definition = $def;
break;
}
}
}
if ($definition === null) {
// Unknown function - assume tainted if any arg is tainted
if ($cacheKey !== null) {
$this->analysisCache[$cacheKey] = !empty($taintedArgs);
}
return !empty($taintedArgs);
}
// Check if tainted args flow to return value
// This is a simplified analysis - could be more precise
$result = $this->analyzeDataFlow($definition, $taintedArgs, $depth);
if ($cacheKey !== null) {
$this->analysisCache[$cacheKey] = $result;
}
return $result;
}
/**
* Analyze data flow within a function
*/
private function analyzeDataFlow(array $definition, array $taintedArgIndices, int $depth): bool
{
$node = $definition['node'] ?? null;
if ($node === null) {
return !empty($taintedArgIndices);
}
// Get parameter names for tainted args
$params = $definition['params'] ?? [];
$taintedParams = [];
foreach ($taintedArgIndices as $index) {
if (isset($params[$index])) {
$taintedParams[] = $params[$index]['name'];
}
}
if (empty($taintedParams)) {
return false;
}
// Check if any tainted parameter reaches a return statement
// or is used in a way that could propagate taint
$stmts = $node->stmts ?? [];
return $this->checkTaintPropagation($stmts, $taintedParams, $depth);
}
/**
* Check if tainted variables propagate to return
*/
private function checkTaintPropagation(array $stmts, array $taintedVars, int $depth): bool
{
foreach ($stmts as $stmt) {
// Check return statements
if ($stmt instanceof Node\Stmt\Return_ && $stmt->expr !== null) {
if ($this->containsTaintedVar($stmt->expr, $taintedVars)) {
return true;
}
}
// Check assignments that might propagate taint
if ($stmt instanceof Node\Stmt\Expression) {
$expr = $stmt->expr;
if ($expr instanceof Node\Expr\Assign) {
if ($this->containsTaintedVar($expr->expr, $taintedVars)) {
// Add assigned variable to tainted list
if ($expr->var instanceof Node\Expr\Variable) {
$taintedVars[] = '$' . $expr->var->name;
}
}
}
}
// Recursively check nested statements
if (isset($stmt->stmts)) {
if ($this->checkTaintPropagation($stmt->stmts, $taintedVars, $depth)) {
return true;
}
}
}
return false;
}
/**
* Check if expression contains a tainted variable
*/
private function containsTaintedVar(Node $expr, array $taintedVars): bool
{
if ($expr instanceof Node\Expr\Variable) {
$name = '$' . $this->safeGetVarName($expr);
return in_array($name, $taintedVars);
}
// Check sub-expressions
foreach ($expr->getSubNodeNames() as $name) {
$subNode = $expr->{$name};
if ($subNode instanceof Node) {
if ($this->containsTaintedVar($subNode, $taintedVars)) {
return true;
}
} elseif (is_array($subNode)) {
foreach ($subNode as $item) {
if ($item instanceof Node && $this->containsTaintedVar($item, $taintedVars)) {
return true;
}
}
}
}
return false;
}
/**
* Check if function is a sanitizer
*/
public function isSanitizer(string $funcName): bool
{
$sanitizers = [
'htmlspecialchars', 'htmlentities', 'strip_tags',
'e', 'escape',
'addslashes', 'mysqli_real_escape_string', 'pg_escape_string',
'basename', 'realpath',
'intval', 'floatval', 'boolval',
'filter_var', 'filter_input',
];
return in_array($funcName, $sanitizers);
}
/**
* Check if method is a sanitizer
*/
public function isSanitizerMethod(string $methodName): bool
{
$sanitizers = ['validate', 'validated', 'escape', 'clean', 'sanitize'];
return in_array($methodName, $sanitizers);
}
/**
* Get function name from FuncCall
*/
private function getFunctionName(Node\Expr\FuncCall $node): ?string
{
if ($node->name instanceof Node\Name) {
return $node->name->toString();
}
return null;
}
/**
* Convert node to string representation for caching
*/
private function nodeToString(Node $node): string
{
if ($node instanceof Node\Expr\Variable) {
// Variable name can be a string or another expression (variable variables like $$foo)
if (is_string($node->name)) {
return '$' . $node->name;
}
// For variable variables, return a placeholder
return '$<expr>';
}
if ($node instanceof Node\Scalar\String_) {
return "'{$node->value}'";
}
if ($node instanceof Node\Scalar\LNumber) {
return (string) $node->value;
}
if ($node instanceof Node\Scalar\DNumber) {
return (string) $node->value;
}
if ($node instanceof Node\Expr\ConstFetch) {
return $node->name->toString();
}
if ($node instanceof Node\Expr\ClassConstFetch) {
$class = $node->class instanceof Node\Name ? $node->class->toString() : '<expr>';
$name = $node->name instanceof Node\Identifier ? $node->name->toString() : '<expr>';
return "{$class}::{$name}";
}
if ($node instanceof Node\Expr\Array_) {
return '[array]';
}
if ($node instanceof Node\Expr\FuncCall) {
$name = $node->name instanceof Node\Name ? $node->name->toString() : '<expr>';
return "{$name}(...)";
}
if ($node instanceof Node\Expr\MethodCall) {
$method = $node->name instanceof Node\Identifier ? $node->name->toString() : '<expr>';
return "->$method(...)";
}
if ($node instanceof Node\Expr\StaticCall) {
$class = $node->class instanceof Node\Name ? $node->class->toString() : '<expr>';
$method = $node->name instanceof Node\Identifier ? $node->name->toString() : '<expr>';
return "{$class}::{$method}(...)";
}
return get_class($node);
}
/**
* Get taint trace for debugging
*/
public function getTaintTrace(string $varName, string $filePath): array
{
$key = "{$filePath}:{$varName}";
return $this->taintHistory[$key] ?? [];
}
/**
* Reset taint state for new file
*/
public function resetForFile(string $filePath): void
{
// Keep cross-file taint but reset file-specific tracking
$this->taintHistory = [];
}
/**
* Get all tainted variables
*/
public function getTaintedVariables(): array
{
return $this->taintedVariables;
}
}

547
src/I18n/Messages.php Normal file
View File

@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\I18n;
/**
* Internationalization support for security linter messages
*/
class Messages
{
private static string $locale = 'ja';
private static ?Messages $instance = null;
private array $messages = [];
private function __construct()
{
$this->loadMessages();
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public static function setLocale(string $locale): void
{
self::$locale = in_array($locale, ['ja', 'en']) ? $locale : 'ja';
}
public static function getLocale(): string
{
return self::$locale;
}
public static function get(string $key, array $params = []): string
{
$instance = self::getInstance();
$message = $instance->messages[self::$locale][$key]
?? $instance->messages['en'][$key]
?? $key;
// Replace placeholders
foreach ($params as $name => $value) {
$message = str_replace(":{$name}", (string)$value, $message);
}
return $message;
}
private function loadMessages(): void
{
$this->messages = [
'ja' => [
// CLI messages
'cli.banner' => 'PHP/Laravel セキュリティリンター',
'cli.analyzing' => '解析中: :path',
'cli.completed' => ':time 秒で完了',
'cli.no_vulnerabilities' => 'セキュリティ脆弱性は見つかりませんでした。',
'cli.report_written' => 'レポートを出力しました: :path',
'cli.summary' => 'サマリー',
'cli.total' => '合計',
'cli.critical' => 'クリティカル',
'cli.high' => '高',
'cli.medium' => '中',
'cli.low' => '低',
'cli.file' => 'ファイル',
'cli.line' => '行',
'cli.recommendation' => '推奨対策',
'cli.call_trace' => 'コールトレース',
// Severity labels
'severity.critical' => 'クリティカル',
'severity.high' => '高',
'severity.medium' => '中',
'severity.low' => '低',
// XSS messages
'xss.name' => 'XSS (クロスサイトスクリプティング)',
'xss.unescaped_echo' => 'エスケープされていない出力によりXSSの脆弱性があります。ユーザー入力が適切にエスケープされずに出力されています。',
'xss.unescaped_echo.rec' => 'htmlspecialchars($var, ENT_QUOTES, \'UTF-8\') または Laravel の e() ヘルパーを使用してください。',
'xss.unescaped_print' => 'エスケープされていない print によりXSSの脆弱性があります。',
'xss.unescaped_print.rec' => 'htmlspecialchars() で出力をエスケープしてください。',
'xss.blade_raw' => 'Blade の {!! !!} による生出力はXSSの脆弱性を引き起こす可能性があります。式: :expr',
'xss.blade_raw.rec' => '自動エスケープされる {{ }} を使用するか、生出力の前にコンテンツがサニタイズされていることを確認してください。',
'xss.blade_php_echo' => '@php ブロック内のエスケープされていない echo はXSSの脆弱性を引き起こす可能性があります。',
'xss.blade_php_echo.rec' => 'Blade の {{ }} 構文を使用するか、htmlspecialchars() でエスケープしてください。',
'xss.blade_js_context' => 'JavaScriptコンテキスト内のBlade出力は、エスケープされていてもXSSの脆弱性を引き起こす可能性があります。',
'xss.blade_js_context.rec' => '@json() ディレクティブまたは JSON.parse() を適切なエンコーディングで使用してください。',
'xss.blade_js_raw' => 'JavaScriptコンテキスト内の生のBlade出力 {!! !!} は深刻なXSS脆弱性です。式: :expr',
'xss.blade_js_raw.rec' => 'JavaScriptで {!! !!} を絶対に使用しないでください。@json() または Js::from() を使用してください。',
'xss.url_context' => 'URLコンテキスト (:attr 属性) 内のBlade出力は javascript: URL によるXSS脆弱性を引き起こす可能性があります。式: :expr',
'xss.url_context.rec' => 'URL を検証して javascript:, data:, vbscript: プロトコルを除外するか、route() や url() ヘルパーを使用してください。',
'xss.event_handler' => 'イベントハンドラ属性内のBlade出力はXSSの脆弱性を引き起こす可能性があります。式: :expr',
'xss.event_handler.rec' => 'イベントハンドラに動的な値を渡さないでください。データ属性と JavaScript イベントリスナーを使用してください。',
'xss.event_handler_raw' => 'イベントハンドラ属性内の生のBlade出力 {!! !!} は深刻なXSS脆弱性です。式: :expr',
'xss.event_handler_raw.rec' => 'イベントハンドラに {!! !!} を絶対に使用しないでください。',
'xss.style_injection' => 'style 属性内のBlade出力はCSSインジェクション脆弱性を引き起こす可能性があります。式: :expr',
'xss.style_injection.rec' => 'CSS値を検証し、許可された値のホワイトリストを使用してください。',
'xss.unquoted_attr' => '引用符なしの属性値 (:attr) 内のBlade出力は属性エスケープを壊す可能性があります。式: :expr',
'xss.unquoted_attr.rec' => '属性値を常に引用符で囲んでください: :attr="{{ $value }}"',
'xss.json_in_script' => 'scriptタグ内の @json() ディレクティブはXSSリスクがある可能性があります。式: :expr',
'xss.json_in_script.rec' => 'JavaScript コンテキストでは Js::from() の使用を検討してください。JSONデータが適切にエスケープされていることを確認してください。',
'xss.template_injection' => '@:directive ディレクティブに変数 $:var が使用されています。テンプレートインジェクションの脆弱性があります。',
'xss.template_injection.rec' => '@include, @extends, @component にユーザー入力を絶対に使用しないでください。許可されたテンプレートのホワイトリストを使用してください。',
'xss.svg_context' => 'SVGコンテキスト内のBlade出力はXSSの脆弱性を引き起こす可能性があります。式: :expr',
'xss.svg_context.rec' => 'SVGは script タグやイベントハンドラを含む可能性があります。SVG内のユーザーデータは慎重にサニタイズしてください。',
'xss.printf_tainted' => '汚染された引数を持つ printf はXSSの脆弱性を引き起こす可能性があります。',
'xss.printf_tainted.rec' => 'printf で使用する前にユーザー入力をエスケープしてください: htmlspecialchars($input)',
'xss.response_tainted' => '汚染されたデータを含むレスポンスはXSSの脆弱性を引き起こす可能性があります。',
'xss.response_tainted.rec' => 'ビューに渡すデータが適切にエスケープされていることを確認してください。',
'xss.header_tainted' => 'HTTPヘッダー内の汚染されたデータはヘッダーインジェクションを引き起こす可能性があります。',
'xss.header_tainted.rec' => 'ヘッダー値を検証しサニタイズしてください。',
'xss.content_tainted' => ':method() 内の汚染されたデータはXSSの脆弱性を引き起こす可能性があります。',
'xss.content_tainted.rec' => 'レスポンスボディを設定する前にコンテンツをエスケープしてください。',
// SQL Injection messages
'sqli.name' => 'SQLインジェクション',
'sqli.db_raw' => 'ユーザー入力を含む DB::raw() はSQLインジェクションの脆弱性を引き起こす可能性があります。',
'sqli.db_raw.rec' => 'パラメータ化クエリを使用してください: DB::raw("column = ?", [$value]) または、ユーザー入力での DB::raw() 使用を避けてください。',
'sqli.db_query' => 'ユーザー入力を含む DB:::method() はSQLインジェクションの脆弱性を引き起こす可能性があります。',
'sqli.db_query.rec' => 'パラメータバインディングを使用してください: DB::select("SELECT * FROM users WHERE id = ?", [$id])',
'sqli.raw_method' => 'ユーザー入力を含む :method() はSQLインジェクションの脆弱性を引き起こす可能性があります。',
'sqli.raw_method.rec' => 'バインディングを使用してください: ->:method(\'column = ?\', [$value])',
'sqli.order_by_raw' => '動的なカラム名を含む :method() はSQLインジェクションの脆弱性があります。',
'sqli.order_by_raw.rec' => 'カラム名にはホワイトリスト検証を使用してください: in_array($column, $allowedColumns)',
'sqli.pdo_query' => 'ユーザー入力を含む PDO の query()/exec() はSQLインジェクションの脆弱性があります。',
'sqli.pdo_query.rec' => 'プリペアドステートメントを使用してください: $stmt = $pdo->prepare($sql); $stmt->execute([$params]);',
'sqli.pdo_prepare' => 'prepare() の前にユーザー入力を連結すると、プリペアドステートメントの意味がなくなります。',
'sqli.pdo_prepare.rec' => 'プレースホルダーを使用してください: $pdo->prepare("SELECT * FROM users WHERE id = ?")',
'sqli.mysqli' => 'ユーザー入力を含む mysqli->:method() はSQLインジェクションの脆弱性があります。',
'sqli.mysqli.rec' => 'プリペアドステートメントを使用してください: $stmt = $mysqli->prepare($sql); $stmt->bind_param(...);',
'sqli.func' => 'ユーザー入力を含む :func() はSQLインジェクションの脆弱性があります。',
'sqli.func.rec' => '直接クエリ実行の代わりにプリペアドステートメントを使用してください。',
'sqli.string_concat' => '文字列連結でSQLクエリを構築するとインジェクションの脆弱性を引き起こす可能性があります。',
'sqli.string_concat.rec' => 'パラメータバインディングを持つプリペアドステートメントを使用してください。',
'sqli.dynamic_column' => 'クエリ内の動的なカラム名はSQLインジェクションの脆弱性を引き起こす可能性があります。',
'sqli.dynamic_column.rec' => 'クエリで使用する前に許可されたカラム名をホワイトリストで検証してください。',
'sqli.sanitizer_broken' => 'SQLサニタイズが :func() によって無効化されています。',
'sqli.sanitizer_broken.rec' => 'サニタイズ後に urldecode()、stripslashes() などの関数を使用しないでください。',
// Command Injection messages
'cmdi.name' => 'コマンドインジェクション',
'cmdi.shell_func' => 'ユーザー入力を含む :func() はコマンドインジェクションの脆弱性があります。',
'cmdi.shell_func.rec' => 'ユーザー入力でのシェル関数の使用を避けてください。どうしても必要な場合は escapeshellarg() と escapeshellcmd() を使用するか、配列引数を持つ Process コンポーネントを使用してください。',
'cmdi.shell_func_concat' => '連結されたユーザー入力を含む :func() はコマンドインジェクションの脆弱性がある可能性があります。',
'cmdi.shell_func_concat.rec' => '引数には escapeshellarg()、コマンド全体には escapeshellcmd() を使用してください。',
'cmdi.shell_func_review' => '動的なコマンドを持つ :func() はコマンドインジェクションの確認が必要です。',
'cmdi.shell_func_review.rec' => 'コマンドと引数が適切にエスケープされていることを確認するか、配列引数を持つ Process コンポーネントを使用してください。',
'cmdi.eval' => '動的なコードを含む eval() は非常に危険であり、コードインジェクションの脆弱性があります。',
'cmdi.eval.rec' => 'eval() の使用を完全に避けてください。適切なデザインパターン、コールバック、またはテンプレートエンジンを使用してください。',
'cmdi.create_function' => 'create_function() は非推奨であり、コードインジェクションの脆弱性があります。',
'cmdi.create_function.rec' => '代わりに無名関数(クロージャ)を使用してください: function($args) { ... }',
'cmdi.assert' => '文字列引数を持つ assert() はコードを評価し、インジェクションの脆弱性があります。',
'cmdi.assert.rec' => 'ブール式のみで assert() を使用してください: assert($condition === true)',
'cmdi.call_user_func' => 'ユーザー制御のコールバックを持つ :func() はコードインジェクションの脆弱性があります。',
'cmdi.call_user_func.rec' => '許可されたコールバックをホワイトリストで管理するか、別のデザインパターンを使用してください。',
'cmdi.preg_replace_e' => '/e 修飾子を持つ preg_replace() はコードを実行し、非推奨かつ危険です。',
'cmdi.preg_replace_e.rec' => '/e 修飾子の代わりに preg_replace_callback() を使用してください。',
'cmdi.file_inclusion' => 'ユーザー入力を含む :func() はローカル/リモートファイルインクルージョン (LFI/RFI) の脆弱性があります。',
'cmdi.file_inclusion.rec' => 'インクルードパスにユーザー入力を使用しないでください。許可されたファイルのホワイトリストを使用してください。',
'cmdi.file_inclusion_dynamic' => '動的なパスを持つ :func() はファイルインクルージョンの脆弱性がある可能性があります。',
'cmdi.file_inclusion_dynamic.rec' => 'ファイルパスを検証しサニタイズしてください。ディレクトリトラバーサルを防ぐために basename() を使用してください。',
'cmdi.backtick' => 'ユーザー入力を含むバッククォートでのシェル実行はコマンドインジェクションの脆弱性があります。',
'cmdi.backtick.rec' => 'バッククォート実行を避けてください。適切な引数エスケープを持つ Process コンポーネントを使用してください。',
'cmdi.backtick_review' => 'バッククォートでのシェル実行はコマンドインジェクションのリスクを確認する必要があります。',
'cmdi.backtick_review.rec' => 'バッククォートの代わりに Process コンポーネントの使用を検討してください。',
'cmdi.process_shell' => 'ユーザー入力を含む Process::fromShellCommandline() はコマンドインジェクションの脆弱性があります。',
'cmdi.process_shell.rec' => '代わりに new Process([\'command\', \'arg1\', \'arg2\']) を配列引数で使用してください。',
'cmdi.artisan_call' => 'ユーザー制御のコマンド名を持つ Artisan::call() は危険です。',
'cmdi.artisan_call.rec' => '許可された Artisan コマンドをホワイトリストで管理してください。',
'cmdi.process_tainted' => 'ユーザー入力を含む Process のインスタンス化はコマンドインジェクションの脆弱性がある可能性があります。',
'cmdi.process_tainted.rec' => '文字列コマンドの代わりに配列引数を使用してください: new Process([\'command\', $arg])',
'cmdi.process_args' => 'ユーザー制御の引数を持つ Process は慎重に検証する必要があります。',
'cmdi.process_args.rec' => 'Process 引数として使用する前にユーザー入力を検証してください。',
'cmdi.sanitizer_broken' => 'コマンドサニタイズが :func() によって無効化されています。',
'cmdi.sanitizer_broken.rec' => 'escapeshellarg()/escapeshellcmd() の後に urldecode()、str_replace() などを使用しないでください。',
// Path Traversal messages
'path.name' => 'パストラバーサル',
'path.traversal' => 'ユーザー入力を含む :func() はパストラバーサル攻撃の脆弱性があります。',
'path.traversal_potential' => '汚染された可能性のあるパスを含む :func() はパストラバーサルの脆弱性がある可能性があります。',
'path.traversal.rec' => 'ファイル名には basename()、パスの解決には realpath() を使用し、許可されたディレクトリに対して検証してください。',
'path.upload' => 'ユーザー制御の宛先パスを持つ move_uploaded_file() は任意のファイルアップロードを許可します。',
'path.upload.rec' => '許可されたディレクトリのホワイトリストを使用してください。ファイル名はサーバー側で生成し、ユーザー入力から取得しないでください。',
'path.storage' => 'ユーザー制御のパスを持つ :method() は許可されていないファイルへのアクセスを許可する可能性があります。',
'path.storage.rec' => '許可されたパターンに対してパスを検証してください。ファイル名には basename() を使用してください。',
'path.download' => 'ユーザー入力を含む response()->:method() は任意のファイルダウンロードを許可します。',
'path.download.rec' => 'basename() を使用し、許可されたディレクトリのホワイトリストに対して検証してください。',
'path.store' => 'ユーザー制御のパスを持つ :method() は意図しない場所へのファイル保存を許可する可能性があります。',
'path.store.rec' => '固定の宛先パスを使用してください。ユーザー入力が必要な場合は、basename() を使用し、ディレクトリをホワイトリストで管理してください。',
'path.store_filename' => 'ユーザー制御のファイル名を持つ :method() はパストラバーサルを許可する可能性があります。',
'path.store_filename.rec' => 'ユーザー提供のファイル名には basename() を使用するか、サーバー側でファイル名を生成してください。',
'path.sanitizer_broken' => 'パスサニタイズが :func() によって無効化されています。',
'path.sanitizer_broken.rec' => 'basename()/realpath() の後に urldecode()、base64_decode() などを使用しないでください。',
'path.dangerous_pattern' => 'パスに危険なトラバーサルパターン (:pattern) が含まれています。',
'path.dangerous_pattern.rec' => 'パスから ../ などのトラバーサルシーケンスを削除するか、basename() を使用してください。',
// Authentication messages
'auth.name' => '認証セキュリティ',
'auth.weak_hash' => 'パスワードのハッシュ化に :func() を使用すべきではありません。レインボーテーブルやブルートフォース攻撃に対して脆弱です。',
'auth.weak_hash.rec' => 'PASSWORD_ARGON2ID または PASSWORD_BCRYPT を指定した password_hash()、または Laravel の Hash::make() を使用してください。',
'auth.weak_hash_review' => ':func() が使用されています。これがパスワードやセキュリティ上重要なデータに使用されていないことを確認してください。',
'auth.weak_hash_review.rec' => 'パスワードには password_hash() を使用してください。セキュリティトークンには random_bytes() を使用してください。',
'auth.weak_algo' => ':algo を指定した password_hash() は安全ではありません。',
'auth.weak_algo.rec' => 'PASSWORD_ARGON2ID または PASSWORD_BCRYPT を使用してください。',
'auth.low_cost' => 'パスワードハッシュのコストファクター :cost は低すぎます。推奨最小値は12です。',
'auth.low_cost.rec' => 'bcrypt には少なくとも12のコストファクターを使用してください: [\'cost\' => 12]',
'auth.low_rounds' => 'ハッシュラウンド数 :rounds は低すぎます。',
'auth.low_rounds.rec' => 'bcrypt には少なくとも12ラウンドを使用してください。',
'auth.encrypt_password' => 'パスワードにハッシュではなく暗号化を使用しています。暗号化されたパスワードは復号できます。',
'auth.encrypt_password.rec' => 'パスワードには Crypt::encrypt() ではなく Hash::make() を使用してください。',
'auth.hardcoded' => '変数 \':var\' にハードコードされた認証情報が見つかりました。',
'auth.hardcoded.rec' => '認証情報は環境変数または安全なシークレットマネージャーに保存してください。',
'auth.hardcoded_array' => '配列キー \':key\' にハードコードされた認証情報が見つかりました。',
'auth.hardcoded_array.rec' => '環境変数を使用してください: env(\'KEY_NAME\') または config 値。',
'auth.timing' => '認証情報/トークンの文字列比較はタイミング攻撃に対して脆弱な可能性があります。',
'auth.timing.rec' => '定時間文字列比較には hash_equals() を、パスワードには password_verify() を使用してください。',
'auth.base64' => ':func() は暗号化ではありません。パスワードはエンコードではなくハッシュ化する必要があります。',
'auth.base64.rec' => 'パスワードには base64_encode() ではなく password_hash() を使用してください。',
'auth.strcmp' => '認証情報の比較に :func() を使用するとタイミング攻撃に対して脆弱な可能性があります。',
'auth.strcmp.rec' => '定時間比較には hash_equals() を使用してください。',
// CSRF/Session messages
'csrf.name' => 'CSRF/セッションセキュリティ',
'csrf.missing_token' => '状態変更メソッドを持つフォームにCSRF保護がありません。',
'csrf.missing_token.rec' => 'フォーム内に @csrf ディレクティブを追加するか、csrf_field() を使用してください。',
'csrf.missing_method' => 'PUT/PATCH/DELETE に @method ディレクティブが必要なようです。',
'csrf.missing_method.rec' => 'メソッドスプーフィングには @method(\'PUT\') ディレクティブを使用してください。',
'csrf.ajax_no_token' => 'CSRFトークン設定なしのAJAXリクエストが検出されました。',
'csrf.ajax_no_token.rec' => 'AJAXリクエストにはメタタグまたはCookieから X-CSRF-TOKEN ヘッダーを設定してください。',
'csrf.disabled' => 'このルートでCSRFミドルウェアが無効化されています。',
'csrf.disabled.rec' => '代替認証を持つWebhook/APIでのみCSRFを無効にしてください。',
'session.no_options' => 'session_start() が明示的なセキュリティ設定なしで呼び出されています。',
'session.no_options.rec' => 'セキュアなオプションでセッションを設定してください: cookie_httponly, cookie_secure, cookie_samesite。',
'session.no_httponly' => 'セッションCookieに HttpOnly フラグがないため、XSSに対して脆弱です。',
'session.no_httponly.rec' => 'session_start() オプションで \'cookie_httponly\' => true を設定してください。',
'session.no_secure' => 'セッションCookieがHTTP経由で送信される可能性があり、傍受に対して脆弱です。',
'session.no_secure.rec' => 'HTTPS環境では \'cookie_secure\' => true を設定してください。',
'session.no_samesite' => 'セッションCookieに SameSite 属性がないため、CSRFに対して脆弱です。',
'session.no_samesite.rec' => '\'cookie_samesite\' => \'Strict\' または \'Lax\' を設定してください。',
'session.fixation' => '古いセッションを削除せずに session_regenerate_id() が呼び出されています。',
'session.fixation.rec' => '古いセッションを削除するために session_regenerate_id(true) を使用してください。',
'session.fixation_false' => 'session_regenerate_id(false) は古いセッションデータを保持します。',
'session.fixation_false.rec' => 'セキュリティのために session_regenerate_id(true) を使用してください。',
'session.insecure_ini' => 'ini_set(\':setting\', \':value\') はセッションセキュリティを弱めます。',
'session.insecure_ini.rec' => ':setting を安全な値1 または trueに設定してください。',
'cookie.no_httponly' => 'Cookie に HttpOnly フラグがないため、JavaScript からアクセス可能です。',
'cookie.no_httponly.rec' => 'Cookie オプションに \'httponly\' => true を追加してください。',
'cookie.no_secure' => 'Cookie が安全でないHTTP接続経由で送信される可能性があります。',
'cookie.no_secure.rec' => 'HTTPS環境では \'secure\' => true を追加してください。',
'cookie.no_samesite' => 'Cookie に SameSite 属性がないため、CSRFに対して脆弱です。',
'cookie.no_samesite.rec' => '\'samesite\' => \'Strict\' または \'Lax\' を追加してください。',
'session.sensitive_data' => '潜在的に機密性の高いデータ \':key\' をセッションに保存しています。',
'session.sensitive_data.rec' => 'セッションに機密データを保存しないでください。暗号化されたストレージまたは参照IDを使用してください。',
// Insecure Config messages
'config.name' => '設定セキュリティ',
'config.phpinfo' => 'phpinfo() は機密性の高いサーバー設定を公開します。',
'config.phpinfo.rec' => '本番コードから phpinfo() を削除してください。',
'config.debug_output' => ':func() は本番環境で機密情報を公開する可能性があります。',
'config.debug_output.rec' => 'デバッグ出力を削除するか、適切なログ記録を使用してください。',
'config.error_reporting' => 'error_reporting(-1) はすべてのエラーを表示し、本番環境ではリスクがあります。',
'config.error_reporting.rec' => '本番環境では error_reporting(0) を使用し、エラーはファイルに記録してください。',
'config.insecure_ini' => 'ini_set(\':setting\', \':value\') は本番環境では安全ではありません。',
'config.insecure_ini.rec' => '本番環境では :setting を無効にしてください。',
'config.header_powered_by' => 'X-Powered-By ヘッダーはサーバー技術を公開します。',
'config.header_powered_by.rec' => 'X-Powered-By ヘッダーを削除するか、サーバー設定で非表示にしてください。',
'config.header_server' => 'Server ヘッダーはサーバーソフトウェアを公開します。',
'config.header_server.rec' => 'バージョン情報を隠すようにサーバーを設定してください。',
'config.unserialize' => 'allowed_classes オプションのない unserialize() はオブジェクトインジェクションにつながる可能性があります。',
'config.unserialize.rec' => 'unserialize($data, [\'allowed_classes\' => false]) を使用するか、許可されるクラスを指定してください。',
'config.unserialize_true' => '\'allowed_classes\' => true を指定した unserialize() はすべてのクラスを許可します。',
'config.unserialize_true.rec' => '\'allowed_classes\' を false または特定のクラスの配列に設定してください。',
'config.unserialize_no_key' => 'unserialize() オプションに \'allowed_classes\' キーがありません。',
'config.unserialize_no_key.rec' => 'オプション配列に \'allowed_classes\' => false を追加してください。',
'config.debug_mode' => 'Config::set() でデバッグモードが有効化されています。',
'config.debug_mode.rec' => '本番環境ではデバッグモードを無効にしてください。',
'config.sensitive_log' => '潜在的に機密性の高いデータ \':var\' がログに記録される可能性があります。',
'config.sensitive_log.rec' => 'パスワード、トークン、その他の機密データをログに記録しないでください。',
'config.sensitive_log_key' => '機密キー \':key\' がログに記録される可能性があります。',
'config.sensitive_log_key.rec' => 'ログから機密データをマスクするか除外してください。',
'config.dd_dump' => ':func() が見つかりました - デバッグヘルパーは本番環境にあるべきではありません。',
'config.dd_dump.rec' => 'デプロイ前にデバッグヘルパーを削除してください。',
'config.debug_hardcoded' => '設定でデバッグモードが true にハードコードされています。',
'config.debug_hardcoded.rec' => 'デバッグ設定には env(\'APP_DEBUG\', false) を使用してください。',
'config.hardcoded_secret' => 'シークレット \':key\' が設定ファイルにハードコードされています。',
'config.hardcoded_secret.rec' => '環境から読み込むために env(\':key\') を使用してください。',
'config.env_no_default' => '変数が存在しない場合に問題を引き起こす可能性があるデフォルト値なしの env() が呼び出されています。',
'config.env_no_default.rec' => 'デフォルト値を指定してください: env(\'KEY\', \'default\')',
// Report messages
'report.title' => 'セキュリティスキャン結果',
'report.generated' => '生成日時',
'report.no_issues' => '脆弱性は見つかりませんでした。',
],
'en' => [
// CLI messages
'cli.banner' => 'PHP/Laravel Security Linter',
'cli.analyzing' => 'Analyzing: :path',
'cli.completed' => 'Completed in :time s',
'cli.no_vulnerabilities' => 'No security vulnerabilities found!',
'cli.report_written' => 'Report written to: :path',
'cli.summary' => 'Summary',
'cli.total' => 'Total',
'cli.critical' => 'Critical',
'cli.high' => 'High',
'cli.medium' => 'Medium',
'cli.low' => 'Low',
'cli.file' => 'File',
'cli.line' => 'Line',
'cli.recommendation' => 'Recommendation',
'cli.call_trace' => 'Call Trace',
// Severity labels
'severity.critical' => 'Critical',
'severity.high' => 'High',
'severity.medium' => 'Medium',
'severity.low' => 'Low',
// XSS messages
'xss.name' => 'XSS (Cross-Site Scripting)',
'xss.unescaped_echo' => 'Unescaped output may lead to XSS. User input is echoed without proper escaping.',
'xss.unescaped_echo.rec' => 'Use htmlspecialchars($var, ENT_QUOTES, \'UTF-8\') or Laravel\'s e() helper.',
'xss.unescaped_print' => 'Unescaped print may lead to XSS.',
'xss.unescaped_print.rec' => 'Use htmlspecialchars() to escape output.',
'xss.blade_raw' => 'Raw Blade output {!! !!} may lead to XSS. Expression: :expr',
'xss.blade_raw.rec' => 'Use {{ }} for automatic escaping, or ensure content is sanitized before raw output.',
'xss.blade_php_echo' => 'Echo in @php block without escaping may lead to XSS.',
'xss.blade_php_echo.rec' => 'Use Blade {{ }} syntax or escape with htmlspecialchars().',
'xss.blade_js_context' => 'Blade output in JavaScript context may lead to XSS even with escaping.',
'xss.blade_js_context.rec' => 'Use @json() directive or JSON.parse() with proper encoding for JS context.',
'xss.blade_js_raw' => 'Raw Blade output {!! !!} in JavaScript context is a critical XSS vulnerability. Expression: :expr',
'xss.blade_js_raw.rec' => 'Never use {!! !!} in JavaScript. Use @json() or Js::from() instead.',
'xss.url_context' => 'Blade output in URL context (:attr attribute) may lead to XSS via javascript: URLs. Expression: :expr',
'xss.url_context.rec' => 'Validate URLs to exclude javascript:, data:, vbscript: protocols, or use route() and url() helpers.',
'xss.event_handler' => 'Blade output in event handler attribute may lead to XSS. Expression: :expr',
'xss.event_handler.rec' => 'Avoid passing dynamic values to event handlers. Use data attributes and JavaScript event listeners instead.',
'xss.event_handler_raw' => 'Raw Blade output {!! !!} in event handler attribute is a critical XSS vulnerability. Expression: :expr',
'xss.event_handler_raw.rec' => 'Never use {!! !!} in event handler attributes.',
'xss.style_injection' => 'Blade output in style attribute may lead to CSS injection. Expression: :expr',
'xss.style_injection.rec' => 'Validate CSS values and use a whitelist of allowed values.',
'xss.unquoted_attr' => 'Blade output in unquoted attribute value (:attr) may break attribute escaping. Expression: :expr',
'xss.unquoted_attr.rec' => 'Always quote attribute values: :attr="{{ $value }}"',
'xss.json_in_script' => '@json() directive in script tag may have XSS risk. Expression: :expr',
'xss.json_in_script.rec' => 'Consider using Js::from() for JavaScript context. Ensure JSON data is properly escaped.',
'xss.template_injection' => '@:directive directive with variable $:var may lead to template injection.',
'xss.template_injection.rec' => 'Never use user input in @include, @extends, @component. Use a whitelist of allowed templates.',
'xss.svg_context' => 'Blade output in SVG context may lead to XSS. Expression: :expr',
'xss.svg_context.rec' => 'SVG can contain script tags and event handlers. Sanitize user data in SVG carefully.',
'xss.printf_tainted' => 'Printf with tainted argument may lead to XSS.',
'xss.printf_tainted.rec' => 'Escape user input before using in printf: htmlspecialchars($input)',
'xss.response_tainted' => 'Response with tainted data may lead to XSS.',
'xss.response_tainted.rec' => 'Ensure data passed to views is properly escaped.',
'xss.header_tainted' => 'Tainted data in HTTP header may lead to header injection.',
'xss.header_tainted.rec' => 'Validate and sanitize header values.',
'xss.content_tainted' => 'Tainted data in :method() may lead to XSS.',
'xss.content_tainted.rec' => 'Escape content before setting response body.',
// SQL Injection messages
'sqli.name' => 'SQL Injection',
'sqli.db_raw' => 'DB::raw() with user input may lead to SQL injection.',
'sqli.db_raw.rec' => 'Use parameterized queries: DB::raw("column = ?", [$value]) or avoid DB::raw() with user input.',
'sqli.db_query' => 'DB:::method() with user input may lead to SQL injection.',
'sqli.db_query.rec' => 'Use parameter bindings: DB::select("SELECT * FROM users WHERE id = ?", [$id])',
'sqli.raw_method' => ':method() with user input may lead to SQL injection.',
'sqli.raw_method.rec' => 'Use bindings: ->:method(\'column = ?\', [$value])',
'sqli.order_by_raw' => ':method() with dynamic column names is vulnerable to SQL injection.',
'sqli.order_by_raw.rec' => 'Use whitelist validation for column names: in_array($column, $allowedColumns)',
'sqli.pdo_query' => 'PDO query()/exec() with user input is vulnerable to SQL injection.',
'sqli.pdo_query.rec' => 'Use prepared statements: $stmt = $pdo->prepare($sql); $stmt->execute([$params]);',
'sqli.pdo_prepare' => 'Concatenating user input before prepare() defeats prepared statements.',
'sqli.pdo_prepare.rec' => 'Use placeholders: $pdo->prepare("SELECT * FROM users WHERE id = ?")',
'sqli.mysqli' => 'mysqli->:method() with user input is vulnerable to SQL injection.',
'sqli.mysqli.rec' => 'Use prepared statements: $stmt = $mysqli->prepare($sql); $stmt->bind_param(...);',
'sqli.func' => ':func() with user input is vulnerable to SQL injection.',
'sqli.func.rec' => 'Use prepared statements instead of direct query execution.',
'sqli.string_concat' => 'Building SQL query with string concatenation may lead to injection.',
'sqli.string_concat.rec' => 'Use prepared statements with parameter binding.',
'sqli.dynamic_column' => 'Dynamic column names in query may lead to SQL injection.',
'sqli.dynamic_column.rec' => 'Whitelist allowed column names before using in queries.',
'sqli.sanitizer_broken' => 'SQL sanitization is broken by :func().',
'sqli.sanitizer_broken.rec' => 'Do not use urldecode(), stripslashes(), etc. after sanitization.',
// Command Injection messages
'cmdi.name' => 'Command Injection',
'cmdi.shell_func' => ':func() with user input is vulnerable to command injection.',
'cmdi.shell_func.rec' => 'Avoid shell functions with user input. Use escapeshellarg() and escapeshellcmd() if unavoidable, or use Process component with array arguments.',
'cmdi.shell_func_concat' => ':func() with concatenated user input may be vulnerable to command injection.',
'cmdi.shell_func_concat.rec' => 'Use escapeshellarg() for arguments and escapeshellcmd() for the entire command.',
'cmdi.shell_func_review' => ':func() with dynamic command should be reviewed for command injection.',
'cmdi.shell_func_review.rec' => 'Ensure command and arguments are properly escaped or use Process component with array arguments.',
'cmdi.eval' => 'eval() with dynamic code is extremely dangerous and vulnerable to code injection.',
'cmdi.eval.rec' => 'Avoid eval() entirely. Use proper design patterns, callbacks, or template engines instead.',
'cmdi.create_function' => 'create_function() is deprecated and vulnerable to code injection.',
'cmdi.create_function.rec' => 'Use anonymous functions (closures) instead: function($args) { ... }',
'cmdi.assert' => 'assert() with string argument evaluates code and is vulnerable to injection.',
'cmdi.assert.rec' => 'Use assert() with boolean expressions only: assert($condition === true)',
'cmdi.call_user_func' => ':func() with user-controlled callback is vulnerable to code injection.',
'cmdi.call_user_func.rec' => 'Whitelist allowed callbacks or use a different design pattern.',
'cmdi.preg_replace_e' => 'preg_replace() with /e modifier executes code and is deprecated/dangerous.',
'cmdi.preg_replace_e.rec' => 'Use preg_replace_callback() instead of the /e modifier.',
'cmdi.file_inclusion' => ':func() with user input is vulnerable to Local/Remote File Inclusion (LFI/RFI).',
'cmdi.file_inclusion.rec' => 'Never use user input in include paths. Use a whitelist of allowed files.',
'cmdi.file_inclusion_dynamic' => ':func() with dynamic path may be vulnerable to file inclusion.',
'cmdi.file_inclusion_dynamic.rec' => 'Validate and sanitize file paths. Use basename() to prevent directory traversal.',
'cmdi.backtick' => 'Backtick shell execution with user input is vulnerable to command injection.',
'cmdi.backtick.rec' => 'Avoid backtick execution. Use Process component with proper argument escaping.',
'cmdi.backtick_review' => 'Backtick shell execution should be reviewed for command injection risks.',
'cmdi.backtick_review.rec' => 'Consider using Process component instead of backticks.',
'cmdi.process_shell' => 'Process::fromShellCommandline() with user input is vulnerable to command injection.',
'cmdi.process_shell.rec' => 'Use new Process([\'command\', \'arg1\', \'arg2\']) with array arguments instead.',
'cmdi.artisan_call' => 'Artisan::call() with user-controlled command name is dangerous.',
'cmdi.artisan_call.rec' => 'Whitelist allowed Artisan commands.',
'cmdi.process_tainted' => 'Process instantiation with user input may be vulnerable to command injection.',
'cmdi.process_tainted.rec' => 'Use array arguments: new Process([\'command\', $arg]) instead of string commands.',
'cmdi.process_args' => 'Process with user-controlled arguments should be carefully validated.',
'cmdi.process_args.rec' => 'Validate user input before using as Process arguments.',
'cmdi.sanitizer_broken' => 'Command sanitization is broken by :func().',
'cmdi.sanitizer_broken.rec' => 'Do not use urldecode(), str_replace(), etc. after escapeshellarg()/escapeshellcmd().',
// Path Traversal messages
'path.name' => 'Path Traversal',
'path.traversal' => ':func() with user input is vulnerable to path traversal attacks.',
'path.traversal_potential' => ':func() with potentially tainted path may be vulnerable to path traversal.',
'path.traversal.rec' => 'Use basename() for filenames, realpath() to resolve paths, and validate against allowed directories.',
'path.upload' => 'move_uploaded_file() with user-controlled destination path allows arbitrary file upload.',
'path.upload.rec' => 'Use a whitelist for allowed directories. Generate filenames server-side, never from user input.',
'path.storage' => ':method() with user-controlled path may allow access to unauthorized files.',
'path.storage.rec' => 'Validate path against allowed patterns. Use basename() for filenames.',
'path.download' => 'response()->:method() with user input allows arbitrary file download.',
'path.download.rec' => 'Use basename() and validate against a whitelist of allowed directories.',
'path.store' => ':method() with user-controlled path may allow storing files in unintended locations.',
'path.store.rec' => 'Use a fixed destination path. If user input is needed, use basename() and whitelist directories.',
'path.store_filename' => ':method() with user-controlled filename may allow path traversal.',
'path.store_filename.rec' => 'Use basename() on user-provided filenames or generate filenames server-side.',
'path.sanitizer_broken' => 'Path sanitization is broken by :func().',
'path.sanitizer_broken.rec' => 'Do not use urldecode(), base64_decode(), etc. after basename()/realpath().',
'path.dangerous_pattern' => 'Path contains dangerous traversal pattern (:pattern).',
'path.dangerous_pattern.rec' => 'Remove traversal sequences like ../ from paths or use basename().',
// Authentication messages
'auth.name' => 'Authentication Security',
'auth.weak_hash' => ':func() should not be used for password hashing. It is vulnerable to rainbow table and brute force attacks.',
'auth.weak_hash.rec' => 'Use password_hash() with PASSWORD_ARGON2ID or PASSWORD_BCRYPT, or Laravel Hash::make().',
'auth.weak_hash_review' => ':func() is used. Ensure this is not for password or security-sensitive data.',
'auth.weak_hash_review.rec' => 'For passwords, use password_hash(). For security tokens, use random_bytes().',
'auth.weak_algo' => 'password_hash() with :algo is insecure.',
'auth.weak_algo.rec' => 'Use PASSWORD_ARGON2ID or PASSWORD_BCRYPT.',
'auth.low_cost' => 'Password hash cost factor :cost is too low. Minimum recommended is 12.',
'auth.low_cost.rec' => 'Use cost factor of at least 12 for bcrypt: [\'cost\' => 12]',
'auth.low_rounds' => 'Hash rounds :rounds is too low.',
'auth.low_rounds.rec' => 'Use at least 12 rounds for bcrypt.',
'auth.encrypt_password' => 'Using encryption for passwords instead of hashing. Encrypted passwords can be decrypted.',
'auth.encrypt_password.rec' => 'Use Hash::make() for passwords, not Crypt::encrypt().',
'auth.hardcoded' => 'Hardcoded credential found in variable \':var\'.',
'auth.hardcoded.rec' => 'Store credentials in environment variables or a secure secrets manager.',
'auth.hardcoded_array' => 'Hardcoded credential found in array key \':key\'.',
'auth.hardcoded_array.rec' => 'Use environment variables: env(\'KEY_NAME\') or config values.',
'auth.timing' => 'String comparison of credentials/tokens may be vulnerable to timing attacks.',
'auth.timing.rec' => 'Use hash_equals() for constant-time string comparison, or password_verify() for passwords.',
'auth.base64' => ':func() is not encryption. Passwords should be hashed, not encoded.',
'auth.base64.rec' => 'Use password_hash() for passwords, not base64_encode().',
'auth.strcmp' => ':func() for credential comparison may be vulnerable to timing attacks.',
'auth.strcmp.rec' => 'Use hash_equals() for constant-time comparison.',
// CSRF/Session messages
'csrf.name' => 'CSRF/Session Security',
'csrf.missing_token' => 'Form with state-changing method is missing CSRF protection.',
'csrf.missing_token.rec' => 'Add @csrf directive inside the form or use csrf_field().',
'csrf.missing_method' => 'Form appears to need @method directive for PUT/PATCH/DELETE.',
'csrf.missing_method.rec' => 'Use @method(\'PUT\') directive for method spoofing.',
'csrf.ajax_no_token' => 'AJAX request detected without CSRF token setup.',
'csrf.ajax_no_token.rec' => 'Set X-CSRF-TOKEN header from meta tag or cookie for AJAX requests.',
'csrf.disabled' => 'CSRF middleware disabled for this route.',
'csrf.disabled.rec' => 'Only disable CSRF for webhooks/APIs with alternative authentication.',
'session.no_options' => 'session_start() called without explicit security settings.',
'session.no_options.rec' => 'Configure session with secure options: cookie_httponly, cookie_secure, cookie_samesite.',
'session.no_httponly' => 'Session cookie missing HttpOnly flag, vulnerable to XSS.',
'session.no_httponly.rec' => 'Set \'cookie_httponly\' => true in session_start() options.',
'session.no_secure' => 'Session cookie may be sent over HTTP, vulnerable to interception.',
'session.no_secure.rec' => 'Set \'cookie_secure\' => true for HTTPS environments.',
'session.no_samesite' => 'Session cookie missing SameSite attribute, vulnerable to CSRF.',
'session.no_samesite.rec' => 'Set \'cookie_samesite\' => \'Strict\' or \'Lax\'.',
'session.fixation' => 'session_regenerate_id() called without deleting old session.',
'session.fixation.rec' => 'Use session_regenerate_id(true) to delete the old session.',
'session.fixation_false' => 'session_regenerate_id(false) keeps old session data.',
'session.fixation_false.rec' => 'Use session_regenerate_id(true) for security.',
'session.insecure_ini' => 'ini_set(\':setting\', \':value\') weakens session security.',
'session.insecure_ini.rec' => 'Set :setting to a secure value (1 or true).',
'cookie.no_httponly' => 'Cookie missing HttpOnly flag, accessible via JavaScript.',
'cookie.no_httponly.rec' => 'Add \'httponly\' => true to cookie options.',
'cookie.no_secure' => 'Cookie may be sent over insecure HTTP connection.',
'cookie.no_secure.rec' => 'Add \'secure\' => true for HTTPS environments.',
'cookie.no_samesite' => 'Cookie missing SameSite attribute, vulnerable to CSRF.',
'cookie.no_samesite.rec' => 'Add \'samesite\' => \'Strict\' or \'Lax\'.',
'session.sensitive_data' => 'Storing potentially sensitive data \':key\' in session.',
'session.sensitive_data.rec' => 'Avoid storing sensitive data in sessions. Use encrypted storage or reference IDs.',
// Insecure Config messages
'config.name' => 'Insecure Configuration',
'config.phpinfo' => 'phpinfo() exposes sensitive server configuration.',
'config.phpinfo.rec' => 'Remove phpinfo() from production code.',
'config.debug_output' => ':func() may expose sensitive information in production.',
'config.debug_output.rec' => 'Remove debug output or use proper logging.',
'config.error_reporting' => 'error_reporting(-1) shows all errors, risky in production.',
'config.error_reporting.rec' => 'Use error_reporting(0) and log errors to file in production.',
'config.insecure_ini' => 'ini_set(\':setting\', \':value\') is insecure for production.',
'config.insecure_ini.rec' => 'Disable :setting in production.',
'config.header_powered_by' => 'X-Powered-By header exposes server technology.',
'config.header_powered_by.rec' => 'Remove X-Powered-By header or hide it in server config.',
'config.header_server' => 'Server header exposes server software.',
'config.header_server.rec' => 'Configure server to hide version information.',
'config.unserialize' => 'unserialize() without allowed_classes option may lead to object injection.',
'config.unserialize.rec' => 'Use unserialize($data, [\'allowed_classes\' => false]) or specify allowed classes.',
'config.unserialize_true' => 'unserialize() with \'allowed_classes\' => true allows all classes.',
'config.unserialize_true.rec' => 'Set \'allowed_classes\' to false or an array of specific classes.',
'config.unserialize_no_key' => 'unserialize() options missing \'allowed_classes\' key.',
'config.unserialize_no_key.rec' => 'Add \'allowed_classes\' => false to options array.',
'config.debug_mode' => 'Debug mode enabled via Config::set().',
'config.debug_mode.rec' => 'Disable debug mode in production.',
'config.sensitive_log' => 'Potentially sensitive data \':var\' may be logged.',
'config.sensitive_log.rec' => 'Never log passwords, tokens, or other sensitive data.',
'config.sensitive_log_key' => 'Sensitive key \':key\' may be logged.',
'config.sensitive_log_key.rec' => 'Mask or exclude sensitive data from logs.',
'config.dd_dump' => ':func() found - debug helper should not be in production.',
'config.dd_dump.rec' => 'Remove debug helpers before deployment.',
'config.debug_hardcoded' => 'Debug mode is hardcoded to true in config.',
'config.debug_hardcoded.rec' => 'Use env(\'APP_DEBUG\', false) for debug settings.',
'config.hardcoded_secret' => 'Secret \':key\' is hardcoded in config file.',
'config.hardcoded_secret.rec' => 'Use env(\':key\') to load from environment.',
'config.env_no_default' => 'env() called without default value may cause issues if variable is missing.',
'config.env_no_default.rec' => 'Provide a default value: env(\'KEY\', \'default\')',
// Report messages
'report.title' => 'Security Scan Results',
'report.generated' => 'Generated',
'report.no_issues' => 'No vulnerabilities found.',
],
];
}
}

View File

@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Report;
/**
* Generates security reports in various formats
*/
class ReportGenerator
{
/**
* Generate report in specified format
*/
public function generate(array $vulnerabilities, string $format = 'text'): string
{
return match ($format) {
'json' => $this->generateJson($vulnerabilities),
'html' => $this->generateHtml($vulnerabilities),
'sarif' => $this->generateSarif($vulnerabilities),
'markdown' => $this->generateMarkdown($vulnerabilities),
default => $this->generateText($vulnerabilities),
};
}
/**
* Generate plain text report
*/
private function generateText(array $vulnerabilities): string
{
if (empty($vulnerabilities)) {
return "No security vulnerabilities found.\n";
}
$output = "Security Scan Results\n";
$output .= str_repeat("=", 60) . "\n\n";
// Group by severity
$grouped = $this->groupBySeverity($vulnerabilities);
foreach (['critical', 'high', 'medium', 'low'] as $severity) {
if (empty($grouped[$severity])) {
continue;
}
$count = count($grouped[$severity]);
$output .= strtoupper($severity) . " ({$count})\n";
$output .= str_repeat("-", 40) . "\n";
foreach ($grouped[$severity] as $vuln) {
$output .= $this->formatVulnerabilityText($vuln);
$output .= "\n";
}
}
// Summary
$output .= str_repeat("=", 60) . "\n";
$output .= "Summary:\n";
$output .= " Critical: " . count($grouped['critical'] ?? []) . "\n";
$output .= " High: " . count($grouped['high'] ?? []) . "\n";
$output .= " Medium: " . count($grouped['medium'] ?? []) . "\n";
$output .= " Low: " . count($grouped['low'] ?? []) . "\n";
$output .= " Total: " . count($vulnerabilities) . "\n";
return $output;
}
/**
* Format a single vulnerability for text output
*/
private function formatVulnerabilityText(Vulnerability $vuln): string
{
$output = "\n[{$vuln->getType()}] {$vuln->getMessage()}\n";
$output .= " File: {$vuln->getFile()}:{$vuln->getLine()}\n";
if ($vuln->getCode()) {
$output .= " Code: {$vuln->getCode()}\n";
}
if ($vuln->getCweId()) {
$output .= " CWE: {$vuln->getCweId()}\n";
}
if ($vuln->getOwaspCategory()) {
$output .= " OWASP: {$vuln->getOwaspCategory()}\n";
}
if (!empty($vuln->getCallTrace())) {
$output .= " Call Trace:\n";
foreach ($vuln->getCallTrace() as $trace) {
$output .= " -> {$trace['function']} at {$trace['file']}:{$trace['line']}\n";
}
}
if ($vuln->getRecommendation()) {
$output .= " Recommendation: {$vuln->getRecommendation()}\n";
}
return $output;
}
/**
* Generate JSON report
*/
private function generateJson(array $vulnerabilities): string
{
$data = [
'timestamp' => date('c'),
'total' => count($vulnerabilities),
'summary' => $this->getSummary($vulnerabilities),
'vulnerabilities' => array_map(fn($v) => $v->toArray(), $vulnerabilities),
];
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Generate HTML report
*/
private function generateHtml(array $vulnerabilities): string
{
$grouped = $this->groupBySeverity($vulnerabilities);
$summary = $this->getSummary($vulnerabilities);
$html = <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Scan Report</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 20px; }
header h1 { font-size: 24px; margin-bottom: 10px; }
.summary { display: flex; gap: 15px; flex-wrap: wrap; }
.summary-card { background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 8px; text-align: center; }
.summary-card .count { font-size: 32px; font-weight: bold; }
.summary-card .label { font-size: 12px; text-transform: uppercase; opacity: 0.8; }
.critical .count { color: #ff4757; }
.high .count { color: #ff6b6b; }
.medium .count { color: #ffa502; }
.low .count { color: #7bed9f; }
.section { background: white; border-radius: 10px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.section-title { font-size: 18px; font-weight: 600; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #eee; }
.vuln { background: #f8f9fa; border-left: 4px solid #ccc; padding: 15px; margin-bottom: 15px; border-radius: 0 8px 8px 0; }
.vuln.critical { border-left-color: #ff4757; }
.vuln.high { border-left-color: #ff6b6b; }
.vuln.medium { border-left-color: #ffa502; }
.vuln.low { border-left-color: #7bed9f; }
.vuln-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.vuln-type { font-weight: 600; color: #2c3e50; }
.vuln-severity { padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
.vuln-severity.critical { background: #ff4757; color: white; }
.vuln-severity.high { background: #ff6b6b; color: white; }
.vuln-severity.medium { background: #ffa502; color: white; }
.vuln-severity.low { background: #7bed9f; color: #155724; }
.vuln-location { font-family: monospace; font-size: 13px; color: #6c757d; margin-bottom: 8px; }
.vuln-message { margin-bottom: 10px; }
.vuln-code { background: #2d3436; color: #dfe6e9; padding: 10px; border-radius: 5px; font-family: monospace; font-size: 13px; overflow-x: auto; margin-bottom: 10px; }
.vuln-recommendation { background: #e8f5e9; padding: 10px; border-radius: 5px; font-size: 14px; }
.vuln-recommendation::before { content: "Recommendation: "; font-weight: 600; }
.call-trace { background: #fff3cd; padding: 10px; border-radius: 5px; margin-top: 10px; font-size: 13px; }
.call-trace-title { font-weight: 600; margin-bottom: 5px; }
.call-trace-item { font-family: monospace; padding-left: 20px; }
.meta { display: flex; gap: 15px; font-size: 12px; color: #6c757d; margin-top: 10px; }
.meta span { background: #eee; padding: 2px 8px; border-radius: 3px; }
footer { text-align: center; padding: 20px; color: #6c757d; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>Security Scan Report</h1>
<p>Generated: {$this->escapeHtml(date('Y-m-d H:i:s'))}</p>
<div class="summary">
<div class="summary-card critical"><div class="count">{$summary['critical']}</div><div class="label">Critical</div></div>
<div class="summary-card high"><div class="count">{$summary['high']}</div><div class="label">High</div></div>
<div class="summary-card medium"><div class="count">{$summary['medium']}</div><div class="label">Medium</div></div>
<div class="summary-card low"><div class="count">{$summary['low']}</div><div class="label">Low</div></div>
<div class="summary-card"><div class="count">{$summary['total']}</div><div class="label">Total</div></div>
</div>
</header>
HTML;
foreach (['critical', 'high', 'medium', 'low'] as $severity) {
if (empty($grouped[$severity])) {
continue;
}
$count = count($grouped[$severity]);
$html .= "<div class=\"section\">\n";
$html .= "<h2 class=\"section-title\">" . ucfirst($severity) . " Severity ({$count})</h2>\n";
foreach ($grouped[$severity] as $vuln) {
$html .= $this->formatVulnerabilityHtml($vuln);
}
$html .= "</div>\n";
}
$html .= <<<HTML
<footer>
<p>Generated by PHP/Laravel Security Linter</p>
</footer>
</div>
</body>
</html>
HTML;
return $html;
}
/**
* Format vulnerability for HTML output
*/
private function formatVulnerabilityHtml(Vulnerability $vuln): string
{
$severity = $vuln->getSeverity();
$type = $this->escapeHtml($vuln->getType());
$message = $this->escapeHtml($vuln->getMessage());
$file = $this->escapeHtml($vuln->getFile());
$line = $vuln->getLine();
$html = "<div class=\"vuln {$severity}\">\n";
$html .= "<div class=\"vuln-header\">\n";
$html .= "<span class=\"vuln-type\">{$type}</span>\n";
$html .= "<span class=\"vuln-severity {$severity}\">{$severity}</span>\n";
$html .= "</div>\n";
$html .= "<div class=\"vuln-location\">{$file}:{$line}</div>\n";
$html .= "<div class=\"vuln-message\">{$message}</div>\n";
if ($vuln->getCode()) {
$code = $this->escapeHtml($vuln->getCode());
$html .= "<div class=\"vuln-code\">{$code}</div>\n";
}
if (!empty($vuln->getCallTrace())) {
$html .= "<div class=\"call-trace\">\n";
$html .= "<div class=\"call-trace-title\">Call Trace:</div>\n";
foreach ($vuln->getCallTrace() as $trace) {
$func = $this->escapeHtml($trace['function']);
$tFile = $this->escapeHtml($trace['file']);
$tLine = $trace['line'];
$html .= "<div class=\"call-trace-item\">→ {$func} at {$tFile}:{$tLine}</div>\n";
}
$html .= "</div>\n";
}
if ($vuln->getRecommendation()) {
$rec = $this->escapeHtml($vuln->getRecommendation());
$html .= "<div class=\"vuln-recommendation\">{$rec}</div>\n";
}
$html .= "<div class=\"meta\">\n";
if ($vuln->getCweId()) {
$html .= "<span>CWE: {$this->escapeHtml($vuln->getCweId())}</span>\n";
}
if ($vuln->getOwaspCategory()) {
$html .= "<span>OWASP: {$this->escapeHtml($vuln->getOwaspCategory())}</span>\n";
}
$html .= "</div>\n";
$html .= "</div>\n";
return $html;
}
/**
* Generate SARIF format (for IDE integration)
*/
private function generateSarif(array $vulnerabilities): string
{
$results = [];
foreach ($vulnerabilities as $vuln) {
$results[] = [
'ruleId' => $vuln->getType(),
'level' => $this->sarifLevel($vuln->getSeverity()),
'message' => ['text' => $vuln->getMessage()],
'locations' => [
[
'physicalLocation' => [
'artifactLocation' => ['uri' => $vuln->getFile()],
'region' => ['startLine' => $vuln->getLine()],
],
],
],
];
}
$sarif = [
'version' => '2.1.0',
'$schema' => 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
'runs' => [
[
'tool' => [
'driver' => [
'name' => 'PHP/Laravel Security Linter',
'version' => '1.0.0',
],
],
'results' => $results,
],
],
];
return json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Generate Markdown report
*/
private function generateMarkdown(array $vulnerabilities): string
{
if (empty($vulnerabilities)) {
return "# Security Scan Results\n\nNo vulnerabilities found.\n";
}
$grouped = $this->groupBySeverity($vulnerabilities);
$summary = $this->getSummary($vulnerabilities);
$md = "# Security Scan Results\n\n";
$md .= "Generated: " . date('Y-m-d H:i:s') . "\n\n";
$md .= "## Summary\n\n";
$md .= "| Severity | Count |\n";
$md .= "|----------|-------|\n";
$md .= "| Critical | {$summary['critical']} |\n";
$md .= "| High | {$summary['high']} |\n";
$md .= "| Medium | {$summary['medium']} |\n";
$md .= "| Low | {$summary['low']} |\n";
$md .= "| **Total** | **{$summary['total']}** |\n\n";
foreach (['critical', 'high', 'medium', 'low'] as $severity) {
if (empty($grouped[$severity])) {
continue;
}
$md .= "## " . ucfirst($severity) . " Severity\n\n";
foreach ($grouped[$severity] as $vuln) {
$md .= "### {$vuln->getType()}\n\n";
$md .= "**Location:** `{$vuln->getFile()}:{$vuln->getLine()}`\n\n";
$md .= "{$vuln->getMessage()}\n\n";
if ($vuln->getCode()) {
$md .= "```php\n{$vuln->getCode()}\n```\n\n";
}
if ($vuln->getRecommendation()) {
$md .= "> **Recommendation:** {$vuln->getRecommendation()}\n\n";
}
$md .= "---\n\n";
}
}
return $md;
}
/**
* Group vulnerabilities by severity
*/
private function groupBySeverity(array $vulnerabilities): array
{
$grouped = ['critical' => [], 'high' => [], 'medium' => [], 'low' => []];
foreach ($vulnerabilities as $vuln) {
$grouped[$vuln->getSeverity()][] = $vuln;
}
return $grouped;
}
/**
* Get summary statistics
*/
private function getSummary(array $vulnerabilities): array
{
$summary = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'total' => 0];
foreach ($vulnerabilities as $vuln) {
$summary[$vuln->getSeverity()]++;
$summary['total']++;
}
return $summary;
}
/**
* Convert severity to SARIF level
*/
private function sarifLevel(string $severity): string
{
return match ($severity) {
'critical', 'high' => 'error',
'medium' => 'warning',
default => 'note',
};
}
/**
* Escape HTML entities
*/
private function escapeHtml(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Report;
/**
* Represents a detected security vulnerability
*/
class Vulnerability
{
public const SEVERITY_CRITICAL = 'critical';
public const SEVERITY_HIGH = 'high';
public const SEVERITY_MEDIUM = 'medium';
public const SEVERITY_LOW = 'low';
private string $type;
private string $severity;
private string $message;
private string $file;
private int $line;
private ?string $code;
private ?string $recommendation;
private array $callTrace;
private ?string $cweId;
private ?string $owaspCategory;
public function __construct(
string $type,
string $severity,
string $message,
string $file,
int $line,
?string $code = null,
?string $recommendation = null,
array $callTrace = [],
?string $cweId = null,
?string $owaspCategory = null
) {
$this->type = $type;
$this->severity = $severity;
$this->message = $message;
$this->file = $file;
$this->line = $line;
$this->code = $code;
$this->recommendation = $recommendation;
$this->callTrace = $callTrace;
$this->cweId = $cweId;
$this->owaspCategory = $owaspCategory;
}
public function getType(): string
{
return $this->type;
}
public function getSeverity(): string
{
return $this->severity;
}
public function getMessage(): string
{
return $this->message;
}
public function getFile(): string
{
return $this->file;
}
public function getLine(): int
{
return $this->line;
}
public function getCode(): ?string
{
return $this->code;
}
public function getRecommendation(): ?string
{
return $this->recommendation;
}
public function getCallTrace(): array
{
return $this->callTrace;
}
public function getCweId(): ?string
{
return $this->cweId;
}
public function getOwaspCategory(): ?string
{
return $this->owaspCategory;
}
/**
* Get unique key for deduplication
*/
public function getUniqueKey(): string
{
return md5("{$this->type}:{$this->file}:{$this->line}:{$this->message}");
}
/**
* Convert to array for JSON serialization
*/
public function toArray(): array
{
return [
'type' => $this->type,
'severity' => $this->severity,
'message' => $this->message,
'file' => $this->file,
'line' => $this->line,
'code' => $this->code,
'recommendation' => $this->recommendation,
'callTrace' => $this->callTrace,
'cweId' => $this->cweId,
'owaspCategory' => $this->owaspCategory,
];
}
/**
* Create from array
*/
public static function fromArray(array $data): self
{
return new self(
$data['type'] ?? 'unknown',
$data['severity'] ?? self::SEVERITY_MEDIUM,
$data['message'] ?? '',
$data['file'] ?? '',
$data['line'] ?? 0,
$data['code'] ?? null,
$data['recommendation'] ?? null,
$data['callTrace'] ?? [],
$data['cweId'] ?? null,
$data['owaspCategory'] ?? null
);
}
}

View File

@@ -0,0 +1,883 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Rules;
use PhpParser\Node;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\Report\Vulnerability;
/**
* Detects Authentication and Password Security vulnerabilities
*
* Checks for:
* - Weak password hashing (md5, sha1, sha256 for passwords)
* - Missing bcrypt/argon2id
* - Hardcoded credentials
* - Insecure password comparison
* - Missing password validation
*/
class AuthenticationRule extends BaseRule
{
/** @var array Weak hash functions for passwords */
private const WEAK_HASH_FUNCTIONS = [
'md5', 'sha1', 'sha256', 'sha512',
'hash', 'crypt',
];
/** @var array Secure password functions */
private const SECURE_PASSWORD_FUNCTIONS = [
'password_hash', 'password_verify',
'Hash::make', 'Hash::check',
'bcrypt', 'argon2id',
];
/** @var array Common credential variable names */
private const CREDENTIAL_VAR_NAMES = [
'password', 'passwd', 'pwd', 'secret',
'api_key', 'apikey', 'api_secret',
'token', 'auth_token', 'access_token',
'private_key', 'secret_key',
];
/** @var array Patterns that indicate i18n/message keys (not credentials) */
private const I18N_KEY_PATTERNS = [
'/^[a-z_]+\.[a-z_]+/', // dot notation like "auth.password"
'/^[a-z_]+:[a-z_]+/', // colon notation like "auth:password"
'/\.rec$/', // ends with .rec (recommendation)
'/\.msg$/', // ends with .msg
'/\.message$/', // ends with .message
'/\.error$/', // ends with .error
'/\.description$/', // ends with .description
'/\.title$/', // ends with .title
'/\.label$/', // ends with .label
];
/** @var array Laravel validation rule patterns */
private const VALIDATION_RULE_PATTERNS = [
'required', 'nullable', 'sometimes', 'present', 'filled',
'string', 'integer', 'numeric', 'boolean', 'array', 'json',
'email', 'url', 'uuid', 'ulid', 'ip', 'ipv4', 'ipv6',
'date', 'date_format', 'before', 'after', 'timezone',
'file', 'image', 'mimes', 'mimetypes',
'min:', 'max:', 'size:', 'between:', 'digits:', 'digits_between:',
'in:', 'not_in:', 'exists:', 'unique:', 'regex:', 'confirmed',
'alpha', 'alpha_num', 'alpha_dash', 'active_url',
'accepted', 'declined', 'prohibited', 'exclude',
];
/** @var array Laravel cast type patterns */
private const LARAVEL_CASTS = [
'hashed', 'encrypted', 'datetime', 'date', 'timestamp',
'boolean', 'bool', 'integer', 'int', 'real', 'float', 'double',
'string', 'array', 'json', 'object', 'collection', 'immutable_date',
'immutable_datetime', 'decimal:', 'AsStringable',
'AsArrayObject', 'AsCollection', 'AsEncryptedCollection',
'AsEncryptedArrayObject', 'Attribute',
];
/** @var array File paths that are typically i18n/translation files */
private const I18N_FILE_PATTERNS = [
'/I18n/',
'/lang/',
'/locales/',
'/translations/',
'/messages/',
'/resources\/lang/',
];
/** @var array Non-security uses of md5 (unique ID generation, checksums) */
private const NON_SECURITY_MD5_CONTEXTS = [
'key', 'id', 'unique', 'identifier', 'cache', 'hash_key',
'etag', 'checksum', 'fingerprint', 'signature',
];
public function getName(): string
{
return $this->msg('auth.name');
}
public function getDescription(): string
{
return 'Detects Authentication and Password Security issues';
}
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
{
$vulnerabilities = [];
$this->traverse($ast, function (Node $node) use ($filePath, $taintTracker, &$vulnerabilities) {
// Check function calls
if ($node instanceof Node\Expr\FuncCall) {
$this->checkFunctionCall($node, $filePath, $vulnerabilities);
}
// Check static method calls
if ($node instanceof Node\Expr\StaticCall) {
$this->checkStaticCall($node, $filePath, $vulnerabilities);
}
// Check assignments for hardcoded credentials
if ($node instanceof Node\Expr\Assign) {
$this->checkAssignment($node, $filePath, $vulnerabilities);
}
// Check comparisons for timing attacks
if ($node instanceof Node\Expr\BinaryOp\Equal
|| $node instanceof Node\Expr\BinaryOp\Identical
|| $node instanceof Node\Expr\BinaryOp\NotEqual
|| $node instanceof Node\Expr\BinaryOp\NotIdentical) {
$this->checkComparison($node, $filePath, $vulnerabilities);
}
// Check array definitions for hardcoded credentials
if ($node instanceof Node\Expr\Array_) {
$this->checkArrayCredentials($node, $filePath, $vulnerabilities);
}
return null;
});
return $vulnerabilities;
}
/**
* Check function calls for weak password handling
*/
private function checkFunctionCall(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$funcName = $this->getCallName($node);
if ($funcName === null) {
return;
}
// Check for weak hash functions used for passwords
if (in_array($funcName, self::WEAK_HASH_FUNCTIONS)) {
$this->checkWeakHash($node, $funcName, $filePath, $vulnerabilities);
}
// Check password_hash for weak algorithm
if ($funcName === 'password_hash') {
$this->checkPasswordHash($node, $filePath, $vulnerabilities);
}
// Check for base64 encoding of passwords (not encryption)
if (in_array($funcName, ['base64_encode', 'base64_decode'])) {
$this->checkBase64Password($node, $funcName, $filePath, $vulnerabilities);
}
// Check for strcmp/strcasecmp timing attacks
if (in_array($funcName, ['strcmp', 'strcasecmp'])) {
$this->checkStringCompare($node, $funcName, $filePath, $vulnerabilities);
}
}
/**
* Check weak hash function usage
*/
private function checkWeakHash(
Node\Expr\FuncCall $node,
string $funcName,
string $filePath,
array &$vulnerabilities
): void {
// Check context - is this used for password?
$args = $this->getArguments($node);
$isPasswordContext = false;
if (!empty($args)) {
$arg = $args[0]->value;
// Check if argument variable name suggests password
if ($arg instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($arg));
$isPasswordContext = $this->isCredentialName($varName);
}
// Check for hash() function with password-related input
if ($funcName === 'hash' && count($args) >= 2) {
$arg = $args[1]->value;
if ($arg instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($arg));
$isPasswordContext = $this->isCredentialName($varName);
}
}
}
if ($isPasswordContext) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('auth.weak_hash', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.weak_hash.rec'),
[],
'CWE-328',
'A2:2017-Broken Authentication'
);
} else {
// Skip non-security uses (unique key generation, cache keys, etc.)
if ($this->isNonSecurityHashContext($node)) {
return; // Don't flag - it's used for ID/key generation
}
// Still flag as potential issue (but low severity)
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('auth.weak_hash_review', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.weak_hash_review.rec'),
[],
'CWE-328',
'A2:2017-Broken Authentication'
);
}
}
/**
* Check password_hash algorithm
*/
private function checkPasswordHash(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (count($args) < 2) {
return;
}
$algoArg = $args[1]->value;
// Check for PASSWORD_DEFAULT (OK but could specify stronger)
if ($algoArg instanceof Node\Expr\ConstFetch) {
$constName = $algoArg->name->toString();
if ($constName === 'PASSWORD_MD5' || $constName === 'PASSWORD_SHA1') {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('auth.weak_algo', ['algo' => $constName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.weak_algo.rec'),
[],
'CWE-328',
'A2:2017-Broken Authentication'
);
}
}
// Check cost parameter if provided
if (count($args) >= 3) {
$optionsArg = $args[2]->value;
if ($optionsArg instanceof Node\Expr\Array_) {
foreach ($optionsArg->items as $item) {
if ($item && $item->key instanceof Node\Scalar\String_) {
if ($item->key->value === 'cost' && $item->value instanceof Node\Scalar\LNumber) {
$cost = $item->value->value;
if ($cost < 10) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('auth.low_cost', ['cost' => $cost]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.low_cost.rec'),
[],
'CWE-916',
'A2:2017-Broken Authentication'
);
}
}
}
}
}
}
}
/**
* Check static method calls
*/
private function checkStaticCall(
Node\Expr\StaticCall $node,
string $filePath,
array &$vulnerabilities
): void {
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
// Check Laravel Hash facade
if (str_contains($className, 'Hash')) {
if ($methodName === 'make') {
// Check for weak driver options
$args = $this->getArguments($node);
if (count($args) >= 2) {
$optionsArg = $args[1]->value;
if ($optionsArg instanceof Node\Expr\Array_) {
foreach ($optionsArg->items as $item) {
if ($item && $item->key instanceof Node\Scalar\String_) {
if ($item->key->value === 'rounds' && $item->value instanceof Node\Scalar\LNumber) {
$rounds = $item->value->value;
if ($rounds < 10) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('auth.low_rounds', ['rounds' => $rounds]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.low_rounds.rec'),
[],
'CWE-916',
'A2:2017-Broken Authentication'
);
}
}
}
}
}
}
}
}
// Check for Crypt facade usage
if (str_contains($className, 'Crypt') && $methodName === 'encrypt') {
// Crypt is for encryption, not password hashing
// Check if it's being used for passwords
$args = $this->getArguments($node);
if (!empty($args) && $args[0]->value instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($args[0]->value));
if ($this->isCredentialName($varName)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('auth.encrypt_password'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.encrypt_password.rec'),
[],
'CWE-327',
'A2:2017-Broken Authentication'
);
}
}
}
}
/**
* Check assignments for hardcoded credentials
*/
private function checkAssignment(
Node\Expr\Assign $node,
string $filePath,
array &$vulnerabilities
): void {
// Get variable name
$varName = '';
if ($node->var instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($node->var));
} elseif ($node->var instanceof Node\Expr\PropertyFetch) {
if ($node->var->name instanceof Node\Identifier) {
$varName = strtolower($node->var->name->toString());
}
}
// Check if it's a credential variable
if (!$this->isCredentialName($varName)) {
return;
}
// Check if assigned a hardcoded string
if ($node->expr instanceof Node\Scalar\String_) {
$value = $node->expr->value;
// Ignore empty strings and placeholder values
if (empty($value) || $this->isPlaceholder($value)) {
return;
}
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('auth.hardcoded', ['var' => $varName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.hardcoded.rec'),
[],
'CWE-798',
'A2:2017-Broken Authentication'
);
}
}
/**
* Check comparisons for timing attacks
*/
private function checkComparison(
Node $node,
string $filePath,
array &$vulnerabilities
): void {
$left = $node->left ?? null;
$right = $node->right ?? null;
if ($left === null || $right === null) {
return;
}
// Check if comparing credential-like variables
$leftIsCredential = $this->isCredentialVariable($left);
$rightIsCredential = $this->isCredentialVariable($right);
if ($leftIsCredential || $rightIsCredential) {
// Check if it's a hash comparison (vulnerable to timing)
$isHashComparison = $this->looksLikeHashComparison($left, $right);
if ($isHashComparison) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('auth.timing'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.timing.rec'),
[],
'CWE-208',
'A2:2017-Broken Authentication'
);
}
}
}
/**
* Check array for hardcoded credentials
*/
private function checkArrayCredentials(
Node\Expr\Array_ $node,
string $filePath,
array &$vulnerabilities
): void {
// Skip i18n/translation files entirely
if ($this->isI18nFile($filePath)) {
return;
}
foreach ($node->items as $item) {
if ($item === null) {
continue;
}
// Check key name
$keyName = '';
$originalKeyName = '';
if ($item->key instanceof Node\Scalar\String_) {
$originalKeyName = $item->key->value;
$keyName = strtolower($originalKeyName);
}
// Skip i18n message keys (dot notation, colon notation, etc.)
if ($this->isI18nKey($originalKeyName)) {
continue;
}
if (!$this->isCredentialName($keyName)) {
continue;
}
// Check if value is hardcoded
if ($item->value instanceof Node\Scalar\String_) {
$value = $item->value->value;
// Skip Laravel validation rules
if ($this->isLaravelValidationRule($value)) {
continue;
}
// Skip Laravel casts
if ($this->isLaravelCast($value)) {
continue;
}
if (!empty($value) && !$this->isPlaceholder($value) && !$this->isDescriptiveMessage($value)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('auth.hardcoded_array', ['key' => $keyName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.hardcoded_array.rec'),
[],
'CWE-798',
'A2:2017-Broken Authentication'
);
}
}
}
}
/**
* Check base64 encoding for password context
*/
private function checkBase64Password(
Node\Expr\FuncCall $node,
string $funcName,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (empty($args)) {
return;
}
$arg = $args[0]->value;
if ($arg instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($arg));
if ($this->isCredentialName($varName)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('auth.base64', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.base64.rec'),
[],
'CWE-327',
'A2:2017-Broken Authentication'
);
}
}
}
/**
* Check strcmp/strcasecmp for credential comparison
*/
private function checkStringCompare(
Node\Expr\FuncCall $node,
string $funcName,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (count($args) < 2) {
return;
}
$hasCredential = false;
foreach ($args as $arg) {
if ($arg->value instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($arg->value));
if ($this->isCredentialName($varName)) {
$hasCredential = true;
break;
}
}
}
if ($hasCredential) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('auth.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('auth.strcmp', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('auth.strcmp.rec'),
[],
'CWE-208',
'A2:2017-Broken Authentication'
);
}
}
/**
* Check if variable name suggests credentials
*/
private function isCredentialName(string $name): bool
{
foreach (self::CREDENTIAL_VAR_NAMES as $credName) {
if (str_contains($name, $credName)) {
return true;
}
}
return false;
}
/**
* Check if variable is a credential variable
*/
private function isCredentialVariable(Node $node): bool
{
if ($node instanceof Node\Expr\Variable) {
if (!is_string($node->name)) {
return false;
}
$name = strtolower($node->name);
return $this->isCredentialName($name);
}
return false;
}
/**
* Check if value looks like a placeholder
*/
private function isPlaceholder(string $value): bool
{
$placeholders = [
'xxx', 'password', 'secret', 'changeme', 'your_',
'your-', '<', '>', '{', '}', 'TODO', 'FIXME',
'env(', 'config(', 'getenv',
];
$lower = strtolower($value);
foreach ($placeholders as $placeholder) {
if (str_contains($lower, strtolower($placeholder))) {
return true;
}
}
return false;
}
/**
* Check if comparison looks like hash comparison
*/
private function looksLikeHashComparison(Node $left, Node $right): bool
{
// Check variable names that suggest hash/token comparison
$names = [];
if ($left instanceof Node\Expr\Variable) {
$names[] = strtolower($this->getVariableName($left));
}
if ($right instanceof Node\Expr\Variable) {
$names[] = strtolower($this->getVariableName($right));
}
$hashIndicators = ['hash', 'token', 'signature', 'hmac', 'digest', 'checksum'];
foreach ($names as $name) {
foreach ($hashIndicators as $indicator) {
if (str_contains($name, $indicator)) {
return true;
}
}
}
return false;
}
/**
* Check if file path is an i18n/translation file
*/
private function isI18nFile(string $filePath): bool
{
foreach (self::I18N_FILE_PATTERNS as $pattern) {
if (preg_match($pattern, $filePath)) {
return true;
}
}
return false;
}
/**
* Check if array key is an i18n message key (not a credential key)
*/
private function isI18nKey(string $key): bool
{
// Empty keys are not i18n
if (empty($key)) {
return false;
}
// Check against i18n patterns
foreach (self::I18N_KEY_PATTERNS as $pattern) {
if (preg_match($pattern, $key)) {
return true;
}
}
return false;
}
/**
* Check if value looks like a descriptive message rather than a credential
*/
private function isDescriptiveMessage(string $value): bool
{
// Strings with multiple spaces are likely descriptions/messages
if (substr_count($value, ' ') >= 2) {
return true;
}
// Strings ending with punctuation are likely messages
if (preg_match('/[.!?。!?]$/', $value)) {
return true;
}
// Contains typical message indicators
$messageIndicators = [
'してください', // Japanese polite request
'ください', // Japanese please
'です。', // Japanese sentence ending
'ます。', // Japanese sentence ending
'ません', // Japanese negative
'please',
'should',
'must',
'warning',
'error',
'invalid',
'expired',
'required',
'missing',
'detected',
'found',
'failed',
'success',
'unable',
'cannot',
'not found',
'not allowed',
'use ',
'Use ',
];
$lower = strtolower($value);
foreach ($messageIndicators as $indicator) {
if (str_contains($value, $indicator) || str_contains($lower, strtolower($indicator))) {
return true;
}
}
return false;
}
/**
* Check if value looks like a Laravel validation rule
*/
private function isLaravelValidationRule(string $value): bool
{
// Empty value is not a validation rule
if (empty($value)) {
return false;
}
// Check for pipe-separated rules (e.g., 'required|string|max:255')
if (str_contains($value, '|')) {
$parts = explode('|', $value);
foreach ($parts as $part) {
$ruleName = explode(':', $part)[0];
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
$patternName = rtrim($pattern, ':');
if ($ruleName === $patternName || str_starts_with($part, $pattern)) {
return true;
}
}
}
}
// Check single rule
$ruleName = explode(':', $value)[0];
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
$patternName = rtrim($pattern, ':');
if ($ruleName === $patternName || str_starts_with($value, $pattern)) {
return true;
}
}
return false;
}
/**
* Check if value looks like a Laravel cast definition
*/
private function isLaravelCast(string $value): bool
{
if (empty($value)) {
return false;
}
foreach (self::LARAVEL_CASTS as $cast) {
if ($value === rtrim($cast, ':') || str_starts_with($value, $cast)) {
return true;
}
}
return false;
}
/**
* Check if md5/hash usage is for non-security purposes (ID generation, etc.)
*/
private function isNonSecurityHashContext(Node $node): bool
{
// Check if argument is a concatenated/interpolated string (typical for unique key generation)
if ($node instanceof Node\Expr\FuncCall) {
$args = $this->getArguments($node);
if (!empty($args)) {
$arg = $args[0]->value;
// Interpolated strings or concatenations suggest key generation
if ($arg instanceof Node\Scalar\InterpolatedString) {
return true;
}
if ($arg instanceof Node\Scalar\Encapsed) {
return true;
}
if ($arg instanceof Node\Expr\BinaryOp\Concat) {
return true;
}
}
}
// Check parent context for assignment to key/id variables
$parent = $node->getAttribute('parent');
if ($parent instanceof Node\Expr\Assign) {
$varName = '';
if ($parent->var instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($parent->var));
}
foreach (self::NON_SECURITY_MD5_CONTEXTS as $context) {
if (str_contains($varName, $context)) {
return true;
}
}
}
// Check if result is concatenated with other strings (unique key generation)
if ($parent instanceof Node\Expr\BinaryOp\Concat) {
return true;
}
// Check if this is a return statement (common in getUniqueKey, getId methods)
if ($parent instanceof Node\Stmt\Return_) {
return true;
}
return false;
}
}

271
src/Rules/BaseRule.php Normal file
View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Rules;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\I18n\Messages;
use SecurityLinter\Report\Vulnerability;
/**
* Base class for security rules with common functionality
*/
abstract class BaseRule implements RuleInterface
{
protected PrettyPrinter $printer;
public function __construct()
{
$this->printer = new PrettyPrinter();
}
/**
* Get localized message
*/
protected function msg(string $key, array $params = []): string
{
return Messages::get($key, $params);
}
/**
* Create a vulnerability instance
*/
protected function createVulnerability(
string $type,
string $severity,
string $message,
string $file,
int $line,
?Node $node = null,
?string $recommendation = null,
array $callTrace = [],
?string $cweId = null,
?string $owaspCategory = null
): Vulnerability {
$code = $node ? $this->getCodeSnippet($node) : null;
return new Vulnerability(
$type,
$severity,
$message,
$file,
$line,
$code,
$recommendation,
$callTrace,
$cweId,
$owaspCategory
);
}
/**
* Get code snippet from node
*/
protected function getCodeSnippet(Node $node): string
{
try {
$code = $this->printer->prettyPrint([$node]);
// Limit length
if (strlen($code) > 200) {
$code = substr($code, 0, 197) . '...';
}
return trim($code);
} catch (\Throwable $e) {
return '';
}
}
/**
* Traverse AST with a visitor
*/
protected function traverse(array $ast, callable $visitor): void
{
$traverser = new NodeTraverser();
$traverser->addVisitor(new class($visitor) extends NodeVisitorAbstract {
private $callback;
public function __construct(callable $callback)
{
$this->callback = $callback;
}
public function enterNode(Node $node)
{
return ($this->callback)($node);
}
});
$traverser->traverse($ast);
}
/**
* Get function/method name from a call node
*/
protected function getCallName(Node $node): ?string
{
if ($node instanceof Node\Expr\FuncCall) {
if ($node->name instanceof Node\Name) {
return $node->name->toString();
}
}
if ($node instanceof Node\Expr\MethodCall) {
if ($node->name instanceof Node\Identifier) {
return $node->name->toString();
}
}
if ($node instanceof Node\Expr\StaticCall) {
$class = $node->class instanceof Node\Name ? $node->class->toString() : null;
$method = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
if ($class && $method) {
return "{$class}::{$method}";
}
}
return null;
}
/**
* Check if node is a string literal
*/
protected function isStringLiteral(Node $node): bool
{
return $node instanceof Node\Scalar\String_;
}
/**
* Check if node is a numeric literal
*/
protected function isNumericLiteral(Node $node): bool
{
return $node instanceof Node\Scalar\LNumber
|| $node instanceof Node\Scalar\DNumber;
}
/**
* Check if node is a safe constant
*/
protected function isSafeConstant(Node $node): bool
{
if ($node instanceof Node\Expr\ConstFetch) {
$name = strtolower($node->name->toString());
return in_array($name, ['true', 'false', 'null']);
}
return false;
}
/**
* Check if node contains user input (quick check)
*/
protected function containsUserInput(Node $node): bool
{
// Check superglobals
if ($node instanceof Node\Expr\Variable) {
if (!is_string($node->name)) {
return true; // Variable variables - assume user input for safety
}
$name = $node->name;
if (in_array($name, ['_GET', '_POST', '_REQUEST', '_COOKIE', '_FILES', '_SERVER'])) {
return true;
}
// Check for request-related variable names
if (in_array($name, ['request', 'input', 'data', 'params'])) {
return true; // Might be user input
}
return false;
}
// Check array access to superglobals
if ($node instanceof Node\Expr\ArrayDimFetch) {
return $this->containsUserInput($node->var);
}
// Recursively check child nodes
foreach ($node->getSubNodeNames() as $name) {
$subNode = $node->{$name};
if ($subNode instanceof Node && $this->containsUserInput($subNode)) {
return true;
}
if (is_array($subNode)) {
foreach ($subNode as $item) {
if ($item instanceof Node && $this->containsUserInput($item)) {
return true;
}
}
}
}
return false;
}
/**
* Get all arguments from a function call
* Filters out VariadicPlaceholder nodes (spread operator ...)
*/
protected function getArguments(Node $node): array
{
if ($node instanceof Node\Expr\FuncCall
|| $node instanceof Node\Expr\MethodCall
|| $node instanceof Node\Expr\StaticCall) {
// Filter out VariadicPlaceholder (spread operator ...)
return array_values(array_filter(
$node->args,
fn($arg) => $arg instanceof Node\Arg
));
}
return [];
}
/**
* Get argument at specific position
*/
protected function getArgument(Node $node, int $position): ?Node\Arg
{
$args = $this->getArguments($node);
return $args[$position] ?? null;
}
/**
* Safely get a variable name from a Variable node
* Returns empty string if the name is not a string (e.g., variable variables like $$foo)
*/
protected function getVariableName(Node $node): string
{
if ($node instanceof Node\Expr\Variable) {
return is_string($node->name) ? $node->name : '';
}
return '';
}
/**
* Check if a method call is on a specific variable
*/
protected function isMethodCallOn(Node\Expr\MethodCall $node, string $varName): bool
{
if ($node->var instanceof Node\Expr\Variable) {
return $this->getVariableName($node->var) === $varName;
}
return false;
}
/**
* Check if a static call is on a specific class
*/
protected function isStaticCallOn(Node\Expr\StaticCall $node, array $classNames): bool
{
if ($node->class instanceof Node\Name) {
$className = $node->class->toString();
foreach ($classNames as $name) {
if ($className === $name || str_ends_with($className, "\\{$name}")) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,928 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Rules;
use PhpParser\Node;
use PhpParser\ParserFactory;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\Report\Vulnerability;
/**
* Detects OS Command Injection vulnerabilities
*
* This rule performs sophisticated analysis including:
* - Detection of tainted input in command execution
* - Recognition of command sanitizer functions (escapeshellarg, escapeshellcmd)
* - Detection of sanitizer-breaking patterns
* - Recursive analysis of user-defined functions
* - Safe Process component usage detection
*/
class CommandInjectionRule extends BaseRule
{
private const SHELL_FUNCTIONS = [
'exec', 'shell_exec', 'system', 'passthru',
'popen', 'proc_open', 'pcntl_exec',
];
private const CODE_EXECUTION_FUNCTIONS = [
'eval', 'assert', 'create_function',
'call_user_func', 'call_user_func_array',
'preg_replace',
];
/** @var array Command sanitizer functions */
private const COMMAND_SANITIZERS = [
'escapeshellarg', // Escapes a single argument
'escapeshellcmd', // Escapes entire command
'basename', // Safe for filenames in commands
'intval', 'floatval', // Type casting
];
/** @var array Functions that may break command escaping */
private const COMMAND_SANITIZER_BREAKERS = [
'stripslashes', 'stripcslashes',
'urldecode', 'rawurldecode',
'html_entity_decode', 'htmlspecialchars_decode',
'base64_decode',
'sprintf', // Can bypass sanitization in format strings
'str_replace', // Can be used to remove escape characters
'preg_replace', // Can remove escape characters
];
/** @var array Dangerous shell metacharacters */
private const SHELL_METACHARACTERS = [
';', '|', '&', '`', '$', '(', ')', '{', '}',
'<', '>', '\n', '\r', '\\',
];
/** @var array Cache for function analysis */
private array $functionSanitizeCache = [];
/** @var TaintTracker|null */
private ?TaintTracker $currentTaintTracker = null;
public function getName(): string
{
return $this->msg('cmdi.name');
}
public function getDescription(): string
{
return 'Detects OS Command and Code Injection vulnerabilities';
}
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
{
$vulnerabilities = [];
$this->currentTaintTracker = $taintTracker;
$this->traverse($ast, function (Node $node) use ($filePath, $taintTracker, &$vulnerabilities) {
if ($node instanceof Node\Expr\FuncCall) {
$this->checkFunctionCall($node, $filePath, $taintTracker, $vulnerabilities);
}
if ($node instanceof Node\Expr\ShellExec) {
$this->checkShellExec($node, $filePath, $taintTracker, $vulnerabilities);
}
if ($node instanceof Node\Expr\StaticCall) {
$this->checkStaticCall($node, $filePath, $taintTracker, $vulnerabilities);
}
if ($node instanceof Node\Expr\New_) {
$this->checkNewProcess($node, $filePath, $taintTracker, $vulnerabilities);
}
return null;
});
return $vulnerabilities;
}
private function checkFunctionCall(
Node\Expr\FuncCall $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$funcName = $this->getCallName($node);
if ($funcName === null) return;
if (in_array($funcName, self::SHELL_FUNCTIONS)) {
$this->checkShellFunction($node, $funcName, $filePath, $taintTracker, $vulnerabilities);
}
if (in_array($funcName, self::CODE_EXECUTION_FUNCTIONS)) {
$this->checkCodeExecution($node, $funcName, $filePath, $taintTracker, $vulnerabilities);
}
if ($funcName === 'preg_replace') {
$this->checkPregReplaceE($node, $filePath, $vulnerabilities);
}
if (in_array($funcName, ['include', 'include_once', 'require', 'require_once'])) {
$this->checkFileInclusion($node, $funcName, $filePath, $taintTracker, $vulnerabilities);
}
}
private function checkShellFunction(
Node\Expr\FuncCall $node,
string $funcName,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (empty($args)) return;
$commandArg = $args[0]->value;
// Check if the command is properly sanitized
if ($this->isCommandExpressionSanitized($commandArg, $taintTracker, $filePath, 0)) {
// Properly sanitized - no vulnerability
return;
}
// Direct taint check
if ($taintTracker->isTainted($commandArg, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('cmdi.shell_func', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.shell_func.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
return;
}
// Check concatenation for tainted unsanitized parts
if ($this->containsConcatenation($commandArg) && $this->hasTaintedUnsanitizedPart($commandArg, $taintTracker, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('cmdi.shell_func_concat', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.shell_func_concat.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
return;
}
// Non-literal command that isn't verified as safe
if (!$this->isStringLiteral($commandArg)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('cmdi.shell_func_review', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.shell_func_review.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
}
}
private function checkCodeExecution(
Node\Expr\FuncCall $node,
string $funcName,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if ($funcName === 'eval') {
if (empty($args)) return;
$codeArg = $args[0]->value;
if (!$this->isStringLiteral($codeArg)) {
$severity = $taintTracker->isTainted($codeArg, $filePath)
? Vulnerability::SEVERITY_CRITICAL
: Vulnerability::SEVERITY_HIGH;
$vulnerabilities[] = $this->createVulnerability(
'Code Injection',
$severity,
$this->msg('cmdi.eval'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.eval.rec'),
[],
'CWE-94',
'A1:2017-Injection'
);
}
}
if ($funcName === 'create_function') {
$vulnerabilities[] = $this->createVulnerability(
'Code Injection',
Vulnerability::SEVERITY_HIGH,
$this->msg('cmdi.create_function'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.create_function.rec'),
[],
'CWE-94',
'A1:2017-Injection'
);
}
if ($funcName === 'assert' && !empty($args) && $args[0]->value instanceof Node\Scalar\String_) {
$vulnerabilities[] = $this->createVulnerability(
'Code Injection',
Vulnerability::SEVERITY_HIGH,
$this->msg('cmdi.assert'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.assert.rec'),
[],
'CWE-94',
'A1:2017-Injection'
);
}
if (in_array($funcName, ['call_user_func', 'call_user_func_array']) && !empty($args)) {
$callbackArg = $args[0]->value;
if ($taintTracker->isTainted($callbackArg, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
'Code Injection',
Vulnerability::SEVERITY_CRITICAL,
$this->msg('cmdi.call_user_func', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.call_user_func.rec'),
[],
'CWE-94',
'A1:2017-Injection'
);
}
}
}
private function checkPregReplaceE(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (empty($args)) return;
$patternArg = $args[0]->value;
if ($patternArg instanceof Node\Scalar\String_) {
if (preg_match('/\/[a-z]*e[a-z]*$/i', $patternArg->value)) {
$vulnerabilities[] = $this->createVulnerability(
'Code Injection',
Vulnerability::SEVERITY_CRITICAL,
$this->msg('cmdi.preg_replace_e'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.preg_replace_e.rec'),
[],
'CWE-94',
'A1:2017-Injection'
);
}
}
}
private function checkFileInclusion(
Node\Expr\FuncCall $node,
string $funcName,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (empty($args)) return;
$pathArg = $args[0]->value;
if ($taintTracker->isTainted($pathArg, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
'File Inclusion',
Vulnerability::SEVERITY_CRITICAL,
$this->msg('cmdi.file_inclusion', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.file_inclusion.rec'),
[],
'CWE-98',
'A1:2017-Injection'
);
} elseif (!$this->isStringLiteral($pathArg) && $this->containsUserInput($pathArg)) {
$vulnerabilities[] = $this->createVulnerability(
'File Inclusion',
Vulnerability::SEVERITY_HIGH,
$this->msg('cmdi.file_inclusion_dynamic', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.file_inclusion_dynamic.rec'),
[],
'CWE-98',
'A1:2017-Injection'
);
}
}
private function checkShellExec(
Node\Expr\ShellExec $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$hasTainted = false;
foreach ($node->parts as $part) {
if ($taintTracker->isTainted($part, $filePath)) {
$hasTainted = true;
break;
}
}
if ($hasTainted) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('cmdi.backtick'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.backtick.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
} else {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('cmdi.backtick_review'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.backtick_review.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
}
}
private function checkStaticCall(
Node\Expr\StaticCall $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
if (str_contains($className, 'Process') && $methodName === 'fromShellCommandline') {
$args = $this->getArguments($node);
if (!empty($args) && $taintTracker->isTainted($args[0]->value, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('cmdi.process_shell'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.process_shell.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
}
}
if (str_contains($className, 'Artisan') && $methodName === 'call') {
$args = $this->getArguments($node);
if (!empty($args) && $taintTracker->isTainted($args[0]->value, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('cmdi.artisan_call'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.artisan_call.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
}
}
}
private function checkNewProcess(
Node\Expr\New_ $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
if (!str_contains($className, 'Process')) return;
$args = $this->getArguments($node);
if (empty($args)) return;
$commandArg = $args[0]->value;
if (!($commandArg instanceof Node\Expr\Array_)) {
if ($taintTracker->isTainted($commandArg, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('cmdi.process_tainted'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.process_tainted.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
}
} elseif ($commandArg instanceof Node\Expr\Array_) {
foreach ($commandArg->items as $item) {
if ($item && $taintTracker->isTainted($item->value, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('cmdi.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('cmdi.process_args'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('cmdi.process_args.rec'),
[],
'CWE-78',
'A1:2017-Injection'
);
break;
}
}
}
}
private function containsConcatenation(Node $node): bool
{
return $node instanceof Node\Expr\BinaryOp\Concat || $node instanceof Node\Scalar\Encapsed;
}
private function hasTaintedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
{
if ($node instanceof Node\Expr\BinaryOp\Concat) {
return $taintTracker->isTainted($node->left, $filePath)
|| $taintTracker->isTainted($node->right, $filePath)
|| $this->hasTaintedPart($node->left, $taintTracker, $filePath)
|| $this->hasTaintedPart($node->right, $taintTracker, $filePath);
}
if ($node instanceof Node\Scalar\Encapsed) {
foreach ($node->parts as $part) {
if ($taintTracker->isTainted($part, $filePath)) {
return true;
}
}
}
return $taintTracker->isTainted($node, $filePath);
}
/**
* Check if a command expression is properly sanitized
*
* @param Node $expr The expression to analyze
* @param TaintTracker $taintTracker The taint tracker
* @param string $filePath Current file path
* @param int $depth Recursion depth to prevent infinite loops
* @return bool True if the expression is safely sanitized
*/
private function isCommandExpressionSanitized(Node $expr, TaintTracker $taintTracker, string $filePath, int $depth = 0): bool
{
// Prevent infinite recursion
if ($depth > 10) {
return false;
}
// String literals are safe
if ($expr instanceof Node\Scalar\String_) {
return true;
}
// Numeric literals are safe
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
return true;
}
// Boolean/null constants are safe
if ($expr instanceof Node\Expr\ConstFetch) {
$name = strtolower($expr->name->toString());
return in_array($name, ['true', 'false', 'null']);
}
// Type casts to int/float are safe
if ($expr instanceof Node\Expr\Cast\Int_
|| $expr instanceof Node\Expr\Cast\Double
|| $expr instanceof Node\Expr\Cast\Bool_) {
return true;
}
// Function calls - check if they sanitize commands
if ($expr instanceof Node\Expr\FuncCall) {
return $this->isFunctionCallCommandSafe($expr, $taintTracker, $filePath, $depth);
}
// Method calls
if ($expr instanceof Node\Expr\MethodCall) {
return $this->isMethodCallCommandSafe($expr, $taintTracker, $filePath, $depth);
}
// Concatenation - needs careful analysis
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
// For command concatenation, we need both parts to be safe
// escapeshellcmd() on left + escapeshellarg() on right is a valid pattern
return $this->isCommandConcatenationSafe($expr, $taintTracker, $filePath, $depth);
}
// Encapsed string - all variable parts must be sanitized
if ($expr instanceof Node\Scalar\Encapsed) {
foreach ($expr->parts as $part) {
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
if (!$this->isCommandExpressionSanitized($part, $taintTracker, $filePath, $depth + 1)) {
return false;
}
}
}
return true;
}
// Ternary - both branches must be safe
if ($expr instanceof Node\Expr\Ternary) {
$ifSafe = $expr->if
? $this->isCommandExpressionSanitized($expr->if, $taintTracker, $filePath, $depth + 1)
: $this->isCommandExpressionSanitized($expr->cond, $taintTracker, $filePath, $depth + 1);
$elseSafe = $this->isCommandExpressionSanitized($expr->else, $taintTracker, $filePath, $depth + 1);
return $ifSafe && $elseSafe;
}
// Null coalesce
if ($expr instanceof Node\Expr\BinaryOp\Coalesce) {
return $this->isCommandExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
&& $this->isCommandExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
}
// Parentheses
if ($expr instanceof Node\Expr\Parenthesized) {
return $this->isCommandExpressionSanitized($expr->expr, $taintTracker, $filePath, $depth + 1);
}
return false;
}
/**
* Check if a function call produces command-safe output
*/
private function isFunctionCallCommandSafe(Node\Expr\FuncCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
{
$funcName = $this->getCallName($expr);
if ($funcName === null) {
return false;
}
// Known command sanitizer functions
if (in_array($funcName, self::COMMAND_SANITIZERS)) {
// Check if arguments don't contain sanitizer-breaking patterns
$args = $this->getArguments($expr);
if (!empty($args) && !$this->containsSanitizerBreaker($args[0]->value)) {
return true;
}
}
// Sanitizer-breaking functions are NEVER safe
if (in_array($funcName, self::COMMAND_SANITIZER_BREAKERS)) {
return false;
}
// Analyze user-defined functions
return $this->analyzeUserFunctionForCommandSafety($funcName, $expr->args, $taintTracker, $filePath, $depth);
}
/**
* Check if a method call produces command-safe output
*/
private function isMethodCallCommandSafe(Node\Expr\MethodCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
{
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
if ($methodName === null) {
return false;
}
// Sanitizer-breaking methods
if (in_array($methodName, self::COMMAND_SANITIZER_BREAKERS)) {
return false;
}
// Chained calls where inner is escapeshellarg/escapeshellcmd
if ($expr->var instanceof Node\Expr\FuncCall) {
$innerFunc = $this->getCallName($expr->var);
if ($innerFunc && in_array($innerFunc, self::COMMAND_SANITIZERS)) {
// Check if this method doesn't break sanitization
if (!in_array($methodName, self::COMMAND_SANITIZER_BREAKERS)) {
return true;
}
}
}
return false;
}
/**
* Check if a command concatenation is safe
* Valid patterns:
* - escapeshellcmd($cmd) . ' ' . escapeshellarg($arg)
* - 'literal' . escapeshellarg($arg)
*/
private function isCommandConcatenationSafe(Node\Expr\BinaryOp\Concat $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
{
// Both parts must be individually safe
$leftSafe = $this->isCommandExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1);
$rightSafe = $this->isCommandExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
return $leftSafe && $rightSafe;
}
/**
* Analyze a user-defined function for command safety
*/
private function analyzeUserFunctionForCommandSafety(
string $funcName,
array $args,
TaintTracker $taintTracker,
string $filePath,
int $depth
): bool {
// Check cache
$cacheKey = $funcName . ':cmd_safe';
if (isset($this->functionSanitizeCache[$cacheKey])) {
return $this->functionSanitizeCache[$cacheKey];
}
$callGraph = $this->getCallGraphFromTracker($taintTracker);
if ($callGraph === null) {
$this->functionSanitizeCache[$cacheKey] = false;
return false;
}
// Find function definition
$definition = $callGraph['definitions'][$funcName] ?? null;
if ($definition === null) {
foreach ($callGraph['classMethods'] ?? [] as $name => $def) {
if (str_ends_with($name, "::{$funcName}")) {
$definition = $def;
break;
}
}
}
if ($definition === null || !isset($definition['node'])) {
$this->functionSanitizeCache[$cacheKey] = false;
return false;
}
// Analyze return statements
$result = $this->analyzeReturnStatementsForCommandSafety($definition['node'], $depth + 1);
$this->functionSanitizeCache[$cacheKey] = $result;
return $result;
}
/**
* Analyze return statements in a function for command safety
*/
private function analyzeReturnStatementsForCommandSafety(Node $functionNode, int $depth): bool
{
if ($depth > 10) {
return false;
}
$stmts = $functionNode->stmts ?? [];
$returnResults = [];
$hasSanitizerBreaker = false;
foreach ($stmts as $stmt) {
$result = $this->analyzeStatementForCommandSafety($stmt, $depth, $hasSanitizerBreaker);
if ($result !== null) {
$returnResults[] = $result;
}
}
if ($hasSanitizerBreaker) {
return false;
}
if (empty($returnResults)) {
return false;
}
return !in_array(false, $returnResults, true);
}
/**
* Analyze a statement for command safety
*/
private function analyzeStatementForCommandSafety(Node $stmt, int $depth, bool &$hasSanitizerBreaker): ?bool
{
if ($stmt instanceof Node\Stmt\Return_ && $stmt->expr !== null) {
return $this->isReturnExpressionCommandSafe($stmt->expr, $depth);
}
if ($stmt instanceof Node\Stmt\Expression) {
if ($this->containsSanitizerBreaker($stmt->expr)) {
$hasSanitizerBreaker = true;
}
}
// Recursively check nested statements
$childStmts = [];
if (isset($stmt->stmts) && is_array($stmt->stmts)) {
$childStmts = array_merge($childStmts, $stmt->stmts);
}
if (isset($stmt->else) && isset($stmt->else->stmts)) {
$childStmts = array_merge($childStmts, $stmt->else->stmts);
}
if (isset($stmt->elseifs)) {
foreach ($stmt->elseifs as $elseif) {
if (isset($elseif->stmts)) {
$childStmts = array_merge($childStmts, $elseif->stmts);
}
}
}
$results = [];
foreach ($childStmts as $childStmt) {
$result = $this->analyzeStatementForCommandSafety($childStmt, $depth, $hasSanitizerBreaker);
if ($result !== null) {
$results[] = $result;
}
}
if (!empty($results)) {
return !in_array(false, $results, true);
}
return null;
}
/**
* Check if a return expression is command-safe
*/
private function isReturnExpressionCommandSafe(Node $expr, int $depth): bool
{
// Direct command sanitizer function call
if ($expr instanceof Node\Expr\FuncCall) {
$funcName = $this->getCallName($expr);
if ($funcName && in_array($funcName, self::COMMAND_SANITIZERS)) {
return true;
}
}
// Numeric literal
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
return true;
}
// Type cast to int/float
if ($expr instanceof Node\Expr\Cast\Int_ || $expr instanceof Node\Expr\Cast\Double) {
return true;
}
// String literal
if ($expr instanceof Node\Scalar\String_) {
return true;
}
// Concatenation where all parts are safe
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
return $this->isReturnExpressionCommandSafe($expr->left, $depth)
&& $this->isReturnExpressionCommandSafe($expr->right, $depth);
}
// Ternary where both branches are safe
if ($expr instanceof Node\Expr\Ternary) {
$ifExpr = $expr->if ?? $expr->cond;
return $this->isReturnExpressionCommandSafe($ifExpr, $depth)
&& $this->isReturnExpressionCommandSafe($expr->else, $depth);
}
return false;
}
/**
* Check if an expression contains sanitizer-breaking function calls
*/
private function containsSanitizerBreaker(Node $expr): bool
{
if ($expr instanceof Node\Expr\FuncCall) {
$funcName = $this->getCallName($expr);
if ($funcName && in_array($funcName, self::COMMAND_SANITIZER_BREAKERS)) {
return true;
}
}
if ($expr instanceof Node\Expr\MethodCall) {
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
if ($methodName && in_array($methodName, self::COMMAND_SANITIZER_BREAKERS)) {
return true;
}
}
// Recursively check child nodes
foreach ($expr->getSubNodeNames() as $name) {
$subNode = $expr->{$name};
if ($subNode instanceof Node) {
if ($this->containsSanitizerBreaker($subNode)) {
return true;
}
} elseif (is_array($subNode)) {
foreach ($subNode as $item) {
if ($item instanceof Node && $this->containsSanitizerBreaker($item)) {
return true;
}
}
}
}
return false;
}
/**
* Check if concatenation has tainted unsanitized parts
*/
private function hasTaintedUnsanitizedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
{
if ($node instanceof Node\Expr\BinaryOp\Concat) {
return $this->hasTaintedUnsanitizedPart($node->left, $taintTracker, $filePath)
|| $this->hasTaintedUnsanitizedPart($node->right, $taintTracker, $filePath);
}
// Check if this part is sanitized
if ($this->isCommandExpressionSanitized($node, $taintTracker, $filePath, 0)) {
return false;
}
if ($node instanceof Node\Scalar\Encapsed) {
foreach ($node->parts as $part) {
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
if ($taintTracker->isTainted($part, $filePath) &&
!$this->isCommandExpressionSanitized($part, $taintTracker, $filePath, 0)) {
return true;
}
}
}
return false;
}
return $taintTracker->isTainted($node, $filePath);
}
/**
* Get the call graph from the taint tracker using reflection
*/
private function getCallGraphFromTracker(TaintTracker $taintTracker): ?array
{
try {
$reflection = new \ReflectionClass($taintTracker);
$property = $reflection->getProperty('callGraph');
$property->setAccessible(true);
return $property->getValue($taintTracker);
} catch (\Throwable $e) {
return null;
}
}
}

View File

@@ -0,0 +1,697 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Rules;
use PhpParser\Node;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\Report\Vulnerability;
/**
* Detects CSRF and Session Security vulnerabilities
*
* Checks for:
* - Missing CSRF protection in forms
* - Insecure session configuration
* - Missing session regeneration
* - Cookie security flags
*/
class CsrfSessionRule extends BaseRule
{
public function getName(): string
{
return $this->msg('csrf.name');
}
public function getDescription(): string
{
return 'Detects CSRF and Session Security issues';
}
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
{
$vulnerabilities = [];
// Check if it's a Blade template
if (str_ends_with($filePath, '.blade.php')) {
$vulnerabilities = array_merge(
$vulnerabilities,
$this->analyzeBladeTemplate($filePath)
);
}
// Analyze PHP code
$this->traverse($ast, function (Node $node) use ($filePath, &$vulnerabilities) {
// Check function calls
if ($node instanceof Node\Expr\FuncCall) {
$this->checkFunctionCall($node, $filePath, $vulnerabilities);
}
// Check method calls
if ($node instanceof Node\Expr\MethodCall) {
$this->checkMethodCall($node, $filePath, $vulnerabilities);
}
// Check static calls
if ($node instanceof Node\Expr\StaticCall) {
$this->checkStaticCall($node, $filePath, $vulnerabilities);
}
// Check array access (session config)
if ($node instanceof Node\Expr\ArrayDimFetch) {
$this->checkArrayAccess($node, $filePath, $vulnerabilities);
}
return null;
});
return $vulnerabilities;
}
/**
* Analyze Blade template for CSRF
*/
private function analyzeBladeTemplate(string $filePath): array
{
$vulnerabilities = [];
$content = file_get_contents($filePath);
// Find all forms
preg_match_all('/<form[^>]*>(.*?)<\/form>/si', $content, $formMatches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
foreach ($formMatches as $formMatch) {
$formTag = $formMatch[0][0];
$formContent = $formMatch[1][0] ?? '';
$formOffset = $formMatch[0][1];
$line = $this->getLineFromOffset($content, $formOffset);
// Check if form has method POST/PUT/DELETE/PATCH
$needsCsrf = $this->formNeedsCsrf($formTag);
if ($needsCsrf) {
// Check for CSRF token
$hasCsrf = $this->hasCsrfToken($formContent);
if (!$hasCsrf) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('csrf.missing_token'),
$filePath,
$line,
null,
$this->msg('csrf.missing_token.rec'),
[],
'CWE-352',
'A8:2017-CSRF'
);
}
}
// Check for @method directive when using PUT/PATCH/DELETE
if ($this->formNeedsMethodSpoofing($formTag)) {
if (!$this->hasMethodField($formContent)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('csrf.missing_method'),
$filePath,
$line,
null,
$this->msg('csrf.missing_method.rec'),
[],
'CWE-352',
'A8:2017-CSRF'
);
}
}
}
// Check for AJAX without CSRF header setup
if (preg_match('/\$\.ajax|fetch\s*\(|axios/', $content)) {
if (!preg_match('/X-CSRF-TOKEN|csrf[_-]?token/i', $content)) {
// Find the line
preg_match('/\$\.ajax|fetch\s*\(|axios/', $content, $match, PREG_OFFSET_CAPTURE);
$line = isset($match[0][1]) ? $this->getLineFromOffset($content, $match[0][1]) : 1;
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('csrf.ajax_no_token'),
$filePath,
$line,
null,
$this->msg('csrf.ajax_no_token.rec'),
[],
'CWE-352',
'A8:2017-CSRF'
);
}
}
return $vulnerabilities;
}
/**
* Check function calls for session issues
*/
private function checkFunctionCall(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$funcName = $this->getCallName($node);
if ($funcName === null) {
return;
}
// Check session_start() without secure settings
if ($funcName === 'session_start') {
$this->checkSessionStart($node, $filePath, $vulnerabilities);
}
// Check setcookie() for secure flags
if ($funcName === 'setcookie') {
$this->checkSetCookie($node, $filePath, $vulnerabilities);
}
// Check for session_regenerate_id
if ($funcName === 'session_regenerate_id') {
// Check if delete_old_session is true
$args = $this->getArguments($node);
if (empty($args)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('session.fixation'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.fixation.rec'),
[],
'CWE-384',
'A2:2017-Broken Authentication'
);
} else {
$arg = $args[0]->value;
if ($arg instanceof Node\Expr\ConstFetch) {
$val = strtolower($arg->name->toString());
if ($val === 'false') {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('session.fixation_false'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.fixation_false.rec'),
[],
'CWE-384',
'A2:2017-Broken Authentication'
);
}
}
}
}
// Check ini_set for session settings
if ($funcName === 'ini_set') {
$this->checkIniSet($node, $filePath, $vulnerabilities);
}
}
/**
* Check session_start settings
*/
private function checkSessionStart(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
// If no options provided, flag for review
if (empty($args)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('session.no_options'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.no_options.rec'),
[],
'CWE-614',
'A6:2017-Security Misconfiguration'
);
return;
}
// Check options array
$optionsArg = $args[0]->value;
if ($optionsArg instanceof Node\Expr\Array_) {
$options = $this->extractArrayOptions($optionsArg);
// Check for missing HttpOnly
if (!isset($options['cookie_httponly']) || !$options['cookie_httponly']) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('session.no_httponly'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.no_httponly.rec'),
[],
'CWE-1004',
'A6:2017-Security Misconfiguration'
);
}
// Check for missing Secure flag
if (!isset($options['cookie_secure'])) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('session.no_secure'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.no_secure.rec'),
[],
'CWE-614',
'A6:2017-Security Misconfiguration'
);
}
// Check for missing SameSite
if (!isset($options['cookie_samesite'])) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('session.no_samesite'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.no_samesite.rec'),
[],
'CWE-352',
'A8:2017-CSRF'
);
}
}
}
/**
* Check setcookie for security flags
*/
private function checkSetCookie(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (count($args) < 3) {
return; // Not enough arguments to analyze
}
// PHP 7.3+ uses options array as last argument
// Older style: setcookie(name, value, expire, path, domain, secure, httponly)
$lastArg = end($args);
if ($lastArg && $lastArg->value instanceof Node\Expr\Array_) {
// Modern options array style
$options = $this->extractArrayOptions($lastArg->value);
if (!isset($options['httponly']) || !$options['httponly']) {
$this->addCookieVulnerability($vulnerabilities, 'HttpOnly', $node, $filePath);
}
if (!isset($options['secure'])) {
$this->addCookieVulnerability($vulnerabilities, 'Secure', $node, $filePath);
}
if (!isset($options['samesite'])) {
$this->addCookieVulnerability($vulnerabilities, 'SameSite', $node, $filePath);
}
} else {
// Legacy style - check 7th argument (httponly)
if (count($args) < 7) {
$this->addCookieVulnerability($vulnerabilities, 'HttpOnly', $node, $filePath);
} else {
$httponlyArg = $args[6]->value ?? null;
if ($httponlyArg instanceof Node\Expr\ConstFetch) {
if (strtolower($httponlyArg->name->toString()) === 'false') {
$this->addCookieVulnerability($vulnerabilities, 'HttpOnly', $node, $filePath);
}
}
}
// Check 6th argument (secure)
if (count($args) < 6) {
$this->addCookieVulnerability($vulnerabilities, 'Secure', $node, $filePath);
}
}
}
/**
* Add cookie vulnerability
*/
private function addCookieVulnerability(
array &$vulnerabilities,
string $flag,
Node $node,
string $filePath
): void {
$messages = [
'HttpOnly' => [
'msg' => 'cookie.no_httponly',
'rec' => 'cookie.no_httponly.rec',
'cwe' => 'CWE-1004',
],
'Secure' => [
'msg' => 'cookie.no_secure',
'rec' => 'cookie.no_secure.rec',
'cwe' => 'CWE-614',
],
'SameSite' => [
'msg' => 'cookie.no_samesite',
'rec' => 'cookie.no_samesite.rec',
'cwe' => 'CWE-352',
],
];
$info = $messages[$flag] ?? $messages['HttpOnly'];
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg($info['msg']),
$filePath,
$node->getStartLine(),
$node,
$this->msg($info['rec']),
[],
$info['cwe'],
'A6:2017-Security Misconfiguration'
);
}
/**
* Check ini_set for session settings
*/
private function checkIniSet(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (count($args) < 2) {
return;
}
$settingArg = $args[0]->value;
$valueArg = $args[1]->value;
if (!($settingArg instanceof Node\Scalar\String_)) {
return;
}
$setting = $settingArg->value;
// Check for insecure session settings
$insecureSettings = [
'session.cookie_httponly' => ['0', 'false', ''],
'session.cookie_secure' => ['0', 'false'],
'session.use_only_cookies' => ['0', 'false'],
'session.use_strict_mode' => ['0', 'false'],
];
if (isset($insecureSettings[$setting])) {
$insecureValues = $insecureSettings[$setting];
$value = '';
if ($valueArg instanceof Node\Scalar\String_) {
$value = strtolower($valueArg->value);
} elseif ($valueArg instanceof Node\Scalar\LNumber) {
$value = (string) $valueArg->value;
} elseif ($valueArg instanceof Node\Expr\ConstFetch) {
$value = strtolower($valueArg->name->toString());
}
if (in_array($value, $insecureValues)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('session.insecure_ini', ['setting' => $setting, 'value' => $value]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.insecure_ini.rec', ['setting' => $setting]),
[],
'CWE-614',
'A6:2017-Security Misconfiguration'
);
}
}
}
/**
* Check method calls
*/
private function checkMethodCall(
Node\Expr\MethodCall $node,
string $filePath,
array &$vulnerabilities
): void {
$methodName = $this->getCallName($node);
// Check for session()->regenerate()
if ($methodName === 'regenerate') {
// This is good practice, no vulnerability
return;
}
// Check for withoutMiddleware('csrf')
if ($methodName === 'withoutMiddleware') {
$args = $this->getArguments($node);
foreach ($args as $arg) {
if ($arg->value instanceof Node\Scalar\String_) {
$value = strtolower($arg->value->value);
if (str_contains($value, 'csrf') || str_contains($value, 'verifycsrftoken')) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('csrf.disabled'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('csrf.disabled.rec'),
[],
'CWE-352',
'A8:2017-CSRF'
);
}
}
}
}
}
/**
* Check static calls
*/
private function checkStaticCall(
Node\Expr\StaticCall $node,
string $filePath,
array &$vulnerabilities
): void {
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
// Check Session facade usage
if (str_contains($className, 'Session')) {
if ($methodName === 'put' || $methodName === 'set') {
// Check for sensitive data in session
$args = $this->getArguments($node);
if (!empty($args) && $args[0]->value instanceof Node\Scalar\String_) {
$key = strtolower($args[0]->value->value);
$sensitiveKeys = ['password', 'credit_card', 'ssn', 'secret'];
foreach ($sensitiveKeys as $sensitive) {
if (str_contains($key, $sensitive)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('session.sensitive_data', ['key' => $key]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.sensitive_data.rec'),
[],
'CWE-922',
'A3:2017-Sensitive Data Exposure'
);
break;
}
}
}
}
}
}
/**
* Check array access
*/
private function checkArrayAccess(
Node\Expr\ArrayDimFetch $node,
string $filePath,
array &$vulnerabilities
): void {
// Check $_SESSION direct access
if ($node->var instanceof Node\Expr\Variable) {
$varName = $this->getVariableName($node->var);
if ($varName === '_SESSION') {
// Check the key
if ($node->dim instanceof Node\Scalar\String_) {
$key = strtolower($node->dim->value);
$sensitiveKeys = ['password', 'credit_card', 'ssn'];
foreach ($sensitiveKeys as $sensitive) {
if (str_contains($key, $sensitive)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('csrf.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('session.sensitive_data', ['key' => $key]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('session.sensitive_data.rec'),
[],
'CWE-922',
'A3:2017-Sensitive Data Exposure'
);
}
}
}
}
}
}
/**
* Check if form needs CSRF protection
*/
private function formNeedsCsrf(string $formTag): bool
{
// Check for method attribute
if (preg_match('/method\s*=\s*["\']?(post|put|patch|delete)/i', $formTag)) {
return true;
}
// No method or GET - doesn't need CSRF
return false;
}
/**
* Check if form content has CSRF token
*/
private function hasCsrfToken(string $content): bool
{
// Check for @csrf directive
if (preg_match('/@csrf\b/', $content)) {
return true;
}
// Check for csrf_field()
if (preg_match('/csrf_field\s*\(\s*\)/', $content)) {
return true;
}
// Check for hidden input with _token
if (preg_match('/<input[^>]*name\s*=\s*["\']_token["\'][^>]*>/i', $content)) {
return true;
}
// Check for {{ csrf_token() }}
if (preg_match('/\{\{\s*csrf_token\s*\(\s*\)\s*\}\}/', $content)) {
return true;
}
return false;
}
/**
* Check if form needs method spoofing
*/
private function formNeedsMethodSpoofing(string $formTag): bool
{
// HTML forms only support GET and POST
// PUT/PATCH/DELETE need spoofing
return preg_match('/method\s*=\s*["\']?(put|patch|delete)/i', $formTag) === 1;
}
/**
* Check if form has method field
*/
private function hasMethodField(string $content): bool
{
if (preg_match('/@method\s*\(/', $content)) {
return true;
}
if (preg_match('/method_field\s*\(/', $content)) {
return true;
}
if (preg_match('/<input[^>]*name\s*=\s*["\']_method["\'][^>]*>/i', $content)) {
return true;
}
return false;
}
/**
* Extract options from array node
*/
private function extractArrayOptions(Node\Expr\Array_ $node): array
{
$options = [];
foreach ($node->items as $item) {
if ($item === null) {
continue;
}
$key = null;
if ($item->key instanceof Node\Scalar\String_) {
$key = $item->key->value;
}
$value = null;
if ($item->value instanceof Node\Scalar\String_) {
$value = $item->value->value;
} elseif ($item->value instanceof Node\Expr\ConstFetch) {
$value = strtolower($item->value->name->toString()) === 'true';
} elseif ($item->value instanceof Node\Scalar\LNumber) {
$value = $item->value->value;
}
if ($key !== null) {
$options[$key] = $value;
}
}
return $options;
}
/**
* Get line number from offset
*/
private function getLineFromOffset(string $content, int $offset): int
{
return substr_count(substr($content, 0, $offset), "\n") + 1;
}
}

View File

@@ -0,0 +1,687 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Rules;
use PhpParser\Node;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\Report\Vulnerability;
/**
* Detects Insecure Configuration vulnerabilities
*
* Checks for:
* - Debug mode enabled in production
* - Exposed .env files
* - Insecure headers
* - Missing security configurations
* - Information disclosure
*/
class InsecureConfigRule extends BaseRule
{
/** @var array Laravel validation rule patterns */
private const VALIDATION_RULE_PATTERNS = [
'required', 'nullable', 'sometimes', 'present', 'filled',
'string', 'integer', 'numeric', 'boolean', 'array', 'json',
'email', 'url', 'uuid', 'ulid', 'ip', 'ipv4', 'ipv6',
'date', 'date_format', 'before', 'after', 'timezone',
'file', 'image', 'mimes', 'mimetypes',
'min:', 'max:', 'size:', 'between:', 'digits:', 'digits_between:',
'in:', 'not_in:', 'exists:', 'unique:', 'regex:', 'confirmed',
'alpha', 'alpha_num', 'alpha_dash', 'active_url',
'accepted', 'declined', 'prohibited', 'exclude',
];
/** @var array Laravel cast type patterns */
private const LARAVEL_CASTS = [
'hashed', 'encrypted', 'datetime', 'date', 'timestamp',
'boolean', 'bool', 'integer', 'int', 'real', 'float', 'double',
'string', 'array', 'json', 'object', 'collection', 'immutable_date',
'immutable_datetime', 'decimal:', 'AsStringable',
];
/** @var array Patterns that indicate i18n/message keys */
private const I18N_KEY_PATTERNS = [
'/^[a-z_]+\.[a-z_]+/', // dot notation like "auth.password"
'/^[a-z_]+:[a-z_]+/', // colon notation
];
public function getName(): string
{
return $this->msg('config.name');
}
public function getDescription(): string
{
return 'Detects Insecure Configuration and Information Disclosure';
}
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
{
$vulnerabilities = [];
$this->traverse($ast, function (Node $node) use ($filePath, &$vulnerabilities) {
// Check function calls
if ($node instanceof Node\Expr\FuncCall) {
$this->checkFunctionCall($node, $filePath, $vulnerabilities);
}
// Check static calls
if ($node instanceof Node\Expr\StaticCall) {
$this->checkStaticCall($node, $filePath, $vulnerabilities);
}
// Check method calls
if ($node instanceof Node\Expr\MethodCall) {
$this->checkMethodCall($node, $filePath, $vulnerabilities);
}
// Check array definitions (config arrays)
if ($node instanceof Node\Expr\Array_) {
$this->checkConfigArray($node, $filePath, $vulnerabilities);
}
// Check return statements in config files
if ($node instanceof Node\Stmt\Return_) {
$this->checkConfigReturn($node, $filePath, $vulnerabilities);
}
return null;
});
return $vulnerabilities;
}
/**
* Check function calls for security issues
*/
private function checkFunctionCall(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$funcName = $this->getCallName($node);
if ($funcName === null) {
return;
}
// Check phpinfo() - information disclosure
if ($funcName === 'phpinfo') {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('config.phpinfo'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.phpinfo.rec'),
[],
'CWE-200',
'A6:2017-Security Misconfiguration'
);
}
// Check var_dump/print_r in production code
if (in_array($funcName, ['var_dump', 'print_r', 'var_export', 'debug_print_backtrace'])) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('config.debug_output', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.debug_output.rec'),
[],
'CWE-200',
'A6:2017-Security Misconfiguration'
);
}
// Check error_reporting settings
if ($funcName === 'error_reporting') {
$args = $this->getArguments($node);
if (!empty($args)) {
$arg = $args[0]->value;
if ($arg instanceof Node\Scalar\LNumber && $arg->value === -1) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('config.error_reporting'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.error_reporting.rec'),
[],
'CWE-209',
'A6:2017-Security Misconfiguration'
);
}
}
}
// Check ini_set for display_errors
if ($funcName === 'ini_set') {
$this->checkIniSet($node, $filePath, $vulnerabilities);
}
// Check header() for security headers
if ($funcName === 'header') {
$this->checkHeader($node, $filePath, $vulnerabilities);
}
// Check env() without default value
if ($funcName === 'env') {
$args = $this->getArguments($node);
if (count($args) === 1) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('config.env_no_default'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.env_no_default.rec'),
[],
'CWE-1188',
'A6:2017-Security Misconfiguration'
);
}
}
// Check for dangerous deserialization
if ($funcName === 'unserialize') {
$this->checkUnserialize($node, $filePath, $vulnerabilities);
}
}
/**
* Check ini_set calls
*/
private function checkIniSet(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (count($args) < 2) {
return;
}
$settingArg = $args[0]->value;
$valueArg = $args[1]->value;
if (!($settingArg instanceof Node\Scalar\String_)) {
return;
}
$setting = $settingArg->value;
$value = $this->getScalarValue($valueArg);
// Check for dangerous settings
$dangerousSettings = [
'display_errors' => ['1', 'true', 'on'],
'display_startup_errors' => ['1', 'true', 'on'],
'expose_php' => ['1', 'true', 'on'],
'allow_url_include' => ['1', 'true', 'on'],
'allow_url_fopen' => ['1', 'true', 'on'],
];
if (isset($dangerousSettings[$setting])) {
if (in_array(strtolower((string)$value), $dangerousSettings[$setting])) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('config.insecure_ini', ['setting' => $setting, 'value' => $value]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.insecure_ini.rec', ['setting' => $setting]),
[],
'CWE-209',
'A6:2017-Security Misconfiguration'
);
}
}
}
/**
* Check header() for security headers
*/
private function checkHeader(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (empty($args)) {
return;
}
$headerArg = $args[0]->value;
if (!($headerArg instanceof Node\Scalar\String_)) {
return;
}
$header = $headerArg->value;
// Check for X-Powered-By exposure
if (stripos($header, 'X-Powered-By') !== false) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('config.header_powered_by'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.header_powered_by.rec'),
[],
'CWE-200',
'A6:2017-Security Misconfiguration'
);
}
// Check for Server header exposure
if (stripos($header, 'Server:') !== false && !stripos($header, 'Server:$')) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_LOW,
$this->msg('config.header_server'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.header_server.rec'),
[],
'CWE-200',
'A6:2017-Security Misconfiguration'
);
}
}
/**
* Check unserialize for security
*/
private function checkUnserialize(
Node\Expr\FuncCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (empty($args)) {
return;
}
// Check for allowed_classes option (PHP 7.0+)
if (count($args) < 2) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('config.unserialize'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.unserialize.rec'),
[],
'CWE-502',
'A8:2017-Insecure Deserialization'
);
} else {
// Check if allowed_classes is properly set
$optionsArg = $args[1]->value;
if ($optionsArg instanceof Node\Expr\Array_) {
$hasAllowedClasses = false;
foreach ($optionsArg->items as $item) {
if ($item && $item->key instanceof Node\Scalar\String_) {
if ($item->key->value === 'allowed_classes') {
$hasAllowedClasses = true;
// Check if it's true (allows all classes)
if ($item->value instanceof Node\Expr\ConstFetch) {
if (strtolower($item->value->name->toString()) === 'true') {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('config.unserialize_true'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.unserialize_true.rec'),
[],
'CWE-502',
'A8:2017-Insecure Deserialization'
);
}
}
break;
}
}
}
if (!$hasAllowedClasses) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('config.unserialize_no_key'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.unserialize_no_key.rec'),
[],
'CWE-502',
'A8:2017-Insecure Deserialization'
);
}
}
}
}
/**
* Check static calls
*/
private function checkStaticCall(
Node\Expr\StaticCall $node,
string $filePath,
array &$vulnerabilities
): void {
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
// Check Log facade for sensitive data
if (str_contains($className, 'Log') && in_array($methodName, ['info', 'debug', 'warning', 'error'])) {
$this->checkLogCall($node, $filePath, $vulnerabilities);
}
// Check Config facade
if (str_contains($className, 'Config') && $methodName === 'set') {
$args = $this->getArguments($node);
if (!empty($args) && $args[0]->value instanceof Node\Scalar\String_) {
$key = strtolower($args[0]->value->value);
if (str_contains($key, 'debug') && count($args) >= 2) {
$value = $this->getScalarValue($args[1]->value);
if (in_array(strtolower((string)$value), ['true', '1'])) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('config.debug_mode'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.debug_mode.rec'),
[],
'CWE-489',
'A6:2017-Security Misconfiguration'
);
}
}
}
}
}
/**
* Check log calls for sensitive data
*/
private function checkLogCall(
Node\Expr\StaticCall $node,
string $filePath,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
foreach ($args as $arg) {
// Check for variables with sensitive names
if ($arg->value instanceof Node\Expr\Variable) {
$varName = strtolower($this->getVariableName($arg->value));
$sensitiveNames = ['password', 'secret', 'token', 'key', 'credit', 'ssn'];
foreach ($sensitiveNames as $sensitive) {
if (str_contains($varName, $sensitive)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('config.sensitive_log', ['var' => $varName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.sensitive_log.rec'),
[],
'CWE-532',
'A3:2017-Sensitive Data Exposure'
);
break 2;
}
}
}
// Check for array with sensitive keys
if ($arg->value instanceof Node\Expr\Array_) {
foreach ($arg->value->items as $item) {
if ($item && $item->key instanceof Node\Scalar\String_) {
$key = strtolower($item->key->value);
$sensitiveKeys = ['password', 'secret', 'token', 'api_key', 'credit_card'];
foreach ($sensitiveKeys as $sensitive) {
if (str_contains($key, $sensitive)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('config.sensitive_log_key', ['key' => $key]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.sensitive_log_key.rec'),
[],
'CWE-532',
'A3:2017-Sensitive Data Exposure'
);
break 2;
}
}
}
}
}
}
}
/**
* Check method calls
*/
private function checkMethodCall(
Node\Expr\MethodCall $node,
string $filePath,
array &$vulnerabilities
): void {
$methodName = $this->getCallName($node);
// Check for dd() and dump()
if (in_array($methodName, ['dd', 'dump'])) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_MEDIUM,
$this->msg('config.dd_dump', ['func' => $methodName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.dd_dump.rec'),
[],
'CWE-200',
'A6:2017-Security Misconfiguration'
);
}
}
/**
* Check config array for insecure settings
*/
private function checkConfigArray(
Node\Expr\Array_ $node,
string $filePath,
array &$vulnerabilities
): void {
foreach ($node->items as $item) {
if ($item === null || !($item->key instanceof Node\Scalar\String_)) {
continue;
}
$originalKey = $item->key->value;
$key = strtolower($originalKey);
$value = $this->getScalarValue($item->value);
// Check for debug settings
if ($key === 'debug' && in_array(strtolower((string)$value), ['true', '1'])) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('config.debug_hardcoded'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.debug_hardcoded.rec'),
[],
'CWE-489',
'A6:2017-Security Misconfiguration'
);
}
// Check for hardcoded secrets
if (in_array($key, ['key', 'secret', 'password', 'api_key'])) {
if ($item->value instanceof Node\Scalar\String_) {
$val = $item->value->value;
// Skip i18n message keys
if ($this->isI18nKey($originalKey)) {
continue;
}
// Skip Laravel validation rules
if ($this->isLaravelValidationRule($val)) {
continue;
}
// Skip Laravel casts
if ($this->isLaravelCast($val)) {
continue;
}
// Check if it's not using env()
if (!empty($val) && !str_starts_with($val, 'env(')) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('config.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('config.hardcoded_secret', ['key' => $key]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('config.hardcoded_secret.rec', ['key' => $key]),
[],
'CWE-798',
'A6:2017-Security Misconfiguration'
);
}
}
}
}
}
/**
* Check if value looks like a Laravel validation rule
*/
private function isLaravelValidationRule(string $value): bool
{
if (empty($value)) {
return false;
}
// Check for pipe-separated rules
if (str_contains($value, '|')) {
$parts = explode('|', $value);
foreach ($parts as $part) {
$ruleName = explode(':', $part)[0];
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
$patternName = rtrim($pattern, ':');
if ($ruleName === $patternName || str_starts_with($part, $pattern)) {
return true;
}
}
}
}
// Check single rule
$ruleName = explode(':', $value)[0];
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
$patternName = rtrim($pattern, ':');
if ($ruleName === $patternName || str_starts_with($value, $pattern)) {
return true;
}
}
return false;
}
/**
* Check if value looks like a Laravel cast definition
*/
private function isLaravelCast(string $value): bool
{
if (empty($value)) {
return false;
}
foreach (self::LARAVEL_CASTS as $cast) {
if ($value === rtrim($cast, ':') || str_starts_with($value, $cast)) {
return true;
}
}
return false;
}
/**
* Check if key is an i18n message key
*/
private function isI18nKey(string $key): bool
{
foreach (self::I18N_KEY_PATTERNS as $pattern) {
if (preg_match($pattern, $key)) {
return true;
}
}
return false;
}
/**
* Check config file return statements
*/
private function checkConfigReturn(
Node\Stmt\Return_ $node,
string $filePath,
array &$vulnerabilities
): void {
// Only check files in config directory
if (!str_contains($filePath, '/config/')) {
return;
}
// If returning an array, it's checked by checkConfigArray
}
/**
* Get scalar value from node
*/
private function getScalarValue(Node $node): mixed
{
if ($node instanceof Node\Scalar\String_) {
return $node->value;
}
if ($node instanceof Node\Scalar\LNumber) {
return $node->value;
}
if ($node instanceof Node\Expr\ConstFetch) {
return $node->name->toString();
}
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Rules;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\Report\Vulnerability;
/**
* Interface for security rules
*/
interface RuleInterface
{
/**
* Get the rule name
*/
public function getName(): string;
/**
* Get the rule description
*/
public function getDescription(): string;
/**
* Analyze AST and return vulnerabilities
*
* @param array $ast The PHP AST
* @param string $filePath Path to the file being analyzed
* @param TaintTracker $taintTracker Taint tracking for data flow analysis
* @return Vulnerability[]
*/
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array;
}

View File

@@ -0,0 +1,885 @@
<?php
declare(strict_types=1);
namespace SecurityLinter\Rules;
use PhpParser\Node;
use PhpParser\ParserFactory;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\Report\Vulnerability;
/**
* Detects SQL Injection vulnerabilities
*
* This rule performs sophisticated analysis including:
* - Detection of tainted input in SQL queries
* - Recognition of SQL sanitizer functions (intval, PDO::quote, etc.)
* - Detection of sanitizer-breaking patterns
* - Recursive analysis of user-defined functions
*/
class SqlInjectionRule extends BaseRule
{
private const RAW_METHODS = [
'whereRaw', 'orWhereRaw', 'havingRaw', 'orHavingRaw',
'selectRaw', 'orderByRaw', 'groupByRaw',
'raw', 'statement', 'unprepared',
];
/** @var array SQL sanitizer functions */
private const SQL_SANITIZERS = [
// Type casting (safest)
'intval', 'floatval', 'boolval',
'abs', 'ceil', 'floor', 'round',
// String escaping
'mysqli_real_escape_string', 'mysql_real_escape_string',
'pg_escape_string', 'pg_escape_literal', 'pg_escape_identifier',
'sqlite_escape_string', 'sqlite3_escape_string',
'addslashes', // Not recommended but does provide some protection
// Validation
'ctype_digit', 'ctype_alnum', 'ctype_alpha',
'is_numeric', 'is_int', 'is_integer', 'is_float',
// Filter
'filter_var',
];
/** @var array Methods that sanitize SQL */
private const SQL_SANITIZER_METHODS = [
'quote', 'escape', 'escapeString', 'quoteIdentifier',
'real_escape_string', 'escape_string',
];
/** @var array Functions/patterns that break SQL sanitization */
private const SQL_SANITIZER_BREAKERS = [
// These can undo escaping or reintroduce dangerous characters
'stripslashes', 'stripcslashes',
'html_entity_decode', 'htmlspecialchars_decode',
'urldecode', 'rawurldecode',
'base64_decode',
'sprintf', // Can bypass sanitization in format strings
];
/** @var array Cache for function analysis */
private array $functionSanitizeCache = [];
/** @var TaintTracker|null */
private ?TaintTracker $currentTaintTracker = null;
public function getName(): string
{
return $this->msg('sqli.name');
}
public function getDescription(): string
{
return 'Detects SQL Injection vulnerabilities';
}
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
{
$vulnerabilities = [];
$this->currentTaintTracker = $taintTracker;
$this->traverse($ast, function (Node $node) use ($filePath, $taintTracker, &$vulnerabilities) {
if ($node instanceof Node\Expr\StaticCall) {
$this->checkStaticCall($node, $filePath, $taintTracker, $vulnerabilities);
}
if ($node instanceof Node\Expr\MethodCall) {
$this->checkMethodCall($node, $filePath, $taintTracker, $vulnerabilities);
}
if ($node instanceof Node\Expr\FuncCall) {
$this->checkFunctionCall($node, $filePath, $taintTracker, $vulnerabilities);
}
if ($node instanceof Node\Expr\Assign) {
$this->checkAssignment($node, $filePath, $taintTracker, $vulnerabilities);
}
return null;
});
return $vulnerabilities;
}
private function checkStaticCall(
Node\Expr\StaticCall $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
if (in_array($className, ['DB', 'Illuminate\\Support\\Facades\\DB'])) {
if ($methodName === 'raw') {
$args = $this->getArguments($node);
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('sqli.db_raw'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.db_raw.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
if (in_array($methodName, ['select', 'insert', 'update', 'delete', 'statement', 'unprepared'])) {
$args = $this->getArguments($node);
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
$severity = $methodName === 'unprepared'
? Vulnerability::SEVERITY_CRITICAL
: Vulnerability::SEVERITY_HIGH;
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
$severity,
$this->msg('sqli.db_query', ['method' => $methodName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.db_query.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
}
if (in_array($methodName, self::RAW_METHODS)) {
$this->checkRawMethod($node, $methodName, $filePath, $taintTracker, $vulnerabilities);
}
}
private function checkRawMethod(
Node $node,
string $methodName,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$args = $this->getArguments($node);
if (empty($args)) {
return;
}
$queryArg = $args[0]->value;
$hasBindings = count($args) > 1;
if ($this->isTaintedSqlArg($queryArg, $taintTracker, $filePath)) {
if (!$hasBindings || $this->containsConcatenation($queryArg)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('sqli.raw_method', ['method' => $methodName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.raw_method.rec', ['method' => $methodName]),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
if (in_array($methodName, ['orderByRaw', 'groupByRaw'])) {
if ($this->containsUserInput($queryArg)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('sqli.order_by_raw', ['method' => $methodName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.order_by_raw.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
}
private function checkMethodCall(
Node\Expr\MethodCall $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$methodName = $this->getCallName($node);
if (in_array($methodName, self::RAW_METHODS)) {
$this->checkRawMethod($node, $methodName, $filePath, $taintTracker, $vulnerabilities);
}
if (in_array($methodName, ['query', 'exec'])) {
$args = $this->getArguments($node);
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('sqli.pdo_query'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.pdo_query.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
if ($methodName === 'prepare') {
$args = $this->getArguments($node);
if (!empty($args)) {
$queryArg = $args[0]->value;
if ($this->containsConcatenation($queryArg) && $this->isTaintedSqlArg($queryArg, $taintTracker, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('sqli.pdo_prepare'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.pdo_prepare.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
}
if (in_array($methodName, ['query', 'multi_query', 'real_query'])) {
$args = $this->getArguments($node);
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('sqli.mysqli', ['method' => $methodName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.mysqli.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
}
private function checkFunctionCall(
Node\Expr\FuncCall $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$funcName = $this->getCallName($node);
$dangerousFunctions = [
'mysqli_query', 'mysqli_multi_query', 'mysqli_real_query',
'pg_query', 'pg_query_params', 'pg_send_query',
'sqlite_query', 'sqlite_exec',
];
if (in_array($funcName, $dangerousFunctions)) {
$args = $this->getArguments($node);
$queryArgIndex = 1;
if (isset($args[$queryArgIndex])) {
$queryArg = $args[$queryArgIndex]->value;
if ($this->isTaintedSqlArg($queryArg, $taintTracker, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_CRITICAL,
$this->msg('sqli.func', ['func' => $funcName]),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.func.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
}
}
private function checkAssignment(
Node\Expr\Assign $node,
string $filePath,
TaintTracker $taintTracker,
array &$vulnerabilities
): void {
$varName = '';
if ($node->var instanceof Node\Expr\Variable && is_string($node->var->name)) {
$varName = strtolower($node->var->name);
}
$sqlVarNames = ['sql', 'query', 'stmt', 'statement'];
if (!in_array($varName, $sqlVarNames)) {
return;
}
if ($this->containsConcatenation($node->expr) && $taintTracker->isTainted($node->expr, $filePath)) {
$vulnerabilities[] = $this->createVulnerability(
$this->msg('sqli.name'),
Vulnerability::SEVERITY_HIGH,
$this->msg('sqli.string_concat'),
$filePath,
$node->getStartLine(),
$node,
$this->msg('sqli.string_concat.rec'),
[],
'CWE-89',
'A1:2017-Injection'
);
}
}
/**
* Check if a SQL argument is tainted (contains unsanitized user input)
*
* This method performs recursive analysis to detect:
* 1. Direct tainted variables
* 2. Properly sanitized input (intval, mysqli_real_escape_string, etc.)
* 3. Sanitizer-breaking patterns
* 4. User-defined functions that sanitize or don't sanitize
*/
private function isTaintedSqlArg(Node $arg, TaintTracker $taintTracker, string $filePath): bool
{
// String literals are safe
if ($arg instanceof Node\Scalar\String_) {
return false;
}
// Numeric literals are safe
if ($arg instanceof Node\Scalar\LNumber || $arg instanceof Node\Scalar\DNumber) {
return false;
}
// Check if the expression is properly sanitized
if ($this->isSqlExpressionSanitized($arg, $taintTracker, $filePath, 0)) {
return false;
}
// Direct taint check
if ($taintTracker->isTainted($arg, $filePath)) {
return true;
}
// Check concatenation for tainted parts
if ($this->containsConcatenation($arg)) {
return $this->hasTaintedUnsanitizedPart($arg, $taintTracker, $filePath);
}
return false;
}
/**
* Check if a SQL expression is properly sanitized
*
* @param Node $expr The expression to analyze
* @param TaintTracker $taintTracker The taint tracker
* @param string $filePath Current file path
* @param int $depth Recursion depth to prevent infinite loops
* @return bool True if the expression is safely sanitized
*/
private function isSqlExpressionSanitized(Node $expr, TaintTracker $taintTracker, string $filePath, int $depth = 0): bool
{
// Prevent infinite recursion
if ($depth > 10) {
return false;
}
// Numeric literals are always safe
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
return true;
}
// String literals are safe
if ($expr instanceof Node\Scalar\String_) {
return true;
}
// Boolean/null constants are safe
if ($expr instanceof Node\Expr\ConstFetch) {
$name = strtolower($expr->name->toString());
return in_array($name, ['true', 'false', 'null']);
}
// Type casts to int/float are safe
if ($expr instanceof Node\Expr\Cast\Int_
|| $expr instanceof Node\Expr\Cast\Double
|| $expr instanceof Node\Expr\Cast\Bool_) {
return true;
}
// Function calls - check if they sanitize SQL
if ($expr instanceof Node\Expr\FuncCall) {
return $this->isFunctionCallSqlSafe($expr, $taintTracker, $filePath, $depth);
}
// Method calls - check for sanitizer methods
if ($expr instanceof Node\Expr\MethodCall) {
return $this->isMethodCallSqlSafe($expr, $taintTracker, $filePath, $depth);
}
// Static method calls
if ($expr instanceof Node\Expr\StaticCall) {
return $this->isStaticCallSqlSafe($expr, $taintTracker, $filePath, $depth);
}
// Concatenation - all parts must be safe
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
return $this->isSqlExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
&& $this->isSqlExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
}
// Encapsed string - all parts must be safe
if ($expr instanceof Node\Scalar\Encapsed) {
foreach ($expr->parts as $part) {
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
if (!$this->isSqlExpressionSanitized($part, $taintTracker, $filePath, $depth + 1)) {
return false;
}
}
}
return true;
}
// Ternary - both branches must be safe
if ($expr instanceof Node\Expr\Ternary) {
$ifSafe = $expr->if
? $this->isSqlExpressionSanitized($expr->if, $taintTracker, $filePath, $depth + 1)
: $this->isSqlExpressionSanitized($expr->cond, $taintTracker, $filePath, $depth + 1);
$elseSafe = $this->isSqlExpressionSanitized($expr->else, $taintTracker, $filePath, $depth + 1);
return $ifSafe && $elseSafe;
}
// Null coalesce - both sides must be safe
if ($expr instanceof Node\Expr\BinaryOp\Coalesce) {
return $this->isSqlExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
&& $this->isSqlExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
}
// Parentheses
if ($expr instanceof Node\Expr\Parenthesized) {
return $this->isSqlExpressionSanitized($expr->expr, $taintTracker, $filePath, $depth + 1);
}
// Arithmetic operations with safe operands
if ($expr instanceof Node\Expr\BinaryOp\Plus
|| $expr instanceof Node\Expr\BinaryOp\Minus
|| $expr instanceof Node\Expr\BinaryOp\Mul
|| $expr instanceof Node\Expr\BinaryOp\Div
|| $expr instanceof Node\Expr\BinaryOp\Mod) {
return $this->isSqlExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
&& $this->isSqlExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
}
return false;
}
/**
* Check if a function call produces SQL-safe output
*/
private function isFunctionCallSqlSafe(Node\Expr\FuncCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
{
$funcName = $this->getCallName($expr);
if ($funcName === null) {
return false;
}
// Known SQL sanitizer functions are safe
if (in_array($funcName, self::SQL_SANITIZERS)) {
return true;
}
// Sanitizer-breaking functions are NEVER safe
if (in_array($funcName, self::SQL_SANITIZER_BREAKERS)) {
return false;
}
// filter_var with specific filters
if ($funcName === 'filter_var') {
$args = $this->getArguments($expr);
if (count($args) >= 2 && $args[1]->value instanceof Node\Expr\ConstFetch) {
$filter = $args[1]->value->name->toString();
$safeFilters = ['FILTER_VALIDATE_INT', 'FILTER_VALIDATE_FLOAT', 'FILTER_SANITIZE_NUMBER_INT', 'FILTER_SANITIZE_NUMBER_FLOAT'];
if (in_array($filter, $safeFilters)) {
return true;
}
}
}
// Analyze user-defined functions
return $this->analyzeUserFunctionForSqlSafety($funcName, $expr->args, $taintTracker, $filePath, $depth);
}
/**
* Check if a method call produces SQL-safe output
*/
private function isMethodCallSqlSafe(Node\Expr\MethodCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
{
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
if ($methodName === null) {
return false;
}
// Known sanitizer methods (PDO::quote, mysqli::real_escape_string, etc.)
if (in_array($methodName, self::SQL_SANITIZER_METHODS)) {
return true;
}
// Sanitizer-breaking methods
if (in_array($methodName, self::SQL_SANITIZER_BREAKERS)) {
return false;
}
// Chained calls - check if inner call is a sanitizer
if ($expr->var instanceof Node\Expr\MethodCall) {
$innerMethod = $expr->var->name instanceof Node\Identifier ? $expr->var->name->toString() : null;
if ($innerMethod && in_array($innerMethod, self::SQL_SANITIZER_METHODS)) {
// Check if this method doesn't break sanitization
if (!in_array($methodName, self::SQL_SANITIZER_BREAKERS)) {
return true;
}
}
}
return false;
}
/**
* Check if a static method call produces SQL-safe output
*/
private function isStaticCallSqlSafe(Node\Expr\StaticCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
{
$className = $expr->class instanceof Node\Name ? $expr->class->toString() : null;
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
if ($className === null || $methodName === null) {
return false;
}
// Known sanitizer static methods
if (in_array($methodName, self::SQL_SANITIZER_METHODS)) {
return true;
}
// Type casting methods
$safeClasses = ['Str', 'Illuminate\\Support\\Str'];
if (in_array($className, $safeClasses)) {
if (in_array($methodName, ['toInteger', 'toFloat'])) {
return true;
}
}
return false;
}
/**
* Analyze a user-defined function to determine if it returns SQL-safe output
*/
private function analyzeUserFunctionForSqlSafety(
string $funcName,
array $args,
TaintTracker $taintTracker,
string $filePath,
int $depth
): bool {
// Check cache
$cacheKey = $funcName . ':sql_safe';
if (isset($this->functionSanitizeCache[$cacheKey])) {
return $this->functionSanitizeCache[$cacheKey];
}
$callGraph = $this->getCallGraphFromTracker($taintTracker);
if ($callGraph === null) {
$this->functionSanitizeCache[$cacheKey] = false;
return false;
}
// Find function definition
$definition = $callGraph['definitions'][$funcName] ?? null;
if ($definition === null) {
foreach ($callGraph['classMethods'] ?? [] as $name => $def) {
if (str_ends_with($name, "::{$funcName}")) {
$definition = $def;
break;
}
}
}
if ($definition === null || !isset($definition['node'])) {
$this->functionSanitizeCache[$cacheKey] = false;
return false;
}
// Analyze return statements
$result = $this->analyzeReturnStatementsForSqlSafety($definition['node'], $depth + 1);
$this->functionSanitizeCache[$cacheKey] = $result;
return $result;
}
/**
* Analyze return statements in a function for SQL safety
*/
private function analyzeReturnStatementsForSqlSafety(Node $functionNode, int $depth): bool
{
if ($depth > 10) {
return false;
}
$stmts = $functionNode->stmts ?? [];
$returnResults = [];
$hasSanitizerBreaker = false;
foreach ($stmts as $stmt) {
$result = $this->analyzeStatementForSqlSafety($stmt, $depth, $hasSanitizerBreaker);
if ($result !== null) {
$returnResults[] = $result;
}
}
// If any sanitizer breaker is found, the function is unsafe
if ($hasSanitizerBreaker) {
return false;
}
// If no returns found, assume unsafe
if (empty($returnResults)) {
return false;
}
// All returns must be safe
return !in_array(false, $returnResults, true);
}
/**
* Analyze a statement for SQL safety
*/
private function analyzeStatementForSqlSafety(Node $stmt, int $depth, bool &$hasSanitizerBreaker): ?bool
{
// Check for return statements
if ($stmt instanceof Node\Stmt\Return_ && $stmt->expr !== null) {
return $this->isReturnExpressionSqlSafe($stmt->expr, $depth);
}
// Check for expression statements that might break sanitization
if ($stmt instanceof Node\Stmt\Expression) {
if ($this->containsSanitizerBreaker($stmt->expr)) {
$hasSanitizerBreaker = true;
}
}
// Recursively check nested statements
$childStmts = [];
if (isset($stmt->stmts) && is_array($stmt->stmts)) {
$childStmts = array_merge($childStmts, $stmt->stmts);
}
if (isset($stmt->else) && isset($stmt->else->stmts)) {
$childStmts = array_merge($childStmts, $stmt->else->stmts);
}
if (isset($stmt->elseifs)) {
foreach ($stmt->elseifs as $elseif) {
if (isset($elseif->stmts)) {
$childStmts = array_merge($childStmts, $elseif->stmts);
}
}
}
$results = [];
foreach ($childStmts as $childStmt) {
$result = $this->analyzeStatementForSqlSafety($childStmt, $depth, $hasSanitizerBreaker);
if ($result !== null) {
$results[] = $result;
}
}
if (!empty($results)) {
return !in_array(false, $results, true);
}
return null;
}
/**
* Check if a return expression is SQL-safe
*/
private function isReturnExpressionSqlSafe(Node $expr, int $depth): bool
{
// Direct SQL sanitizer function call
if ($expr instanceof Node\Expr\FuncCall) {
$funcName = $this->getCallName($expr);
if ($funcName && in_array($funcName, self::SQL_SANITIZERS)) {
return true;
}
}
// Method call to sanitizer method
if ($expr instanceof Node\Expr\MethodCall) {
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
if ($methodName && in_array($methodName, self::SQL_SANITIZER_METHODS)) {
return true;
}
}
// Numeric literal
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
return true;
}
// Type cast to int/float
if ($expr instanceof Node\Expr\Cast\Int_ || $expr instanceof Node\Expr\Cast\Double) {
return true;
}
// String literal (safe if no user input)
if ($expr instanceof Node\Scalar\String_) {
return true;
}
// Concatenation where all parts are safe
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
return $this->isReturnExpressionSqlSafe($expr->left, $depth)
&& $this->isReturnExpressionSqlSafe($expr->right, $depth);
}
// Ternary where both branches are safe
if ($expr instanceof Node\Expr\Ternary) {
$ifExpr = $expr->if ?? $expr->cond;
return $this->isReturnExpressionSqlSafe($ifExpr, $depth)
&& $this->isReturnExpressionSqlSafe($expr->else, $depth);
}
return false;
}
/**
* Check if an expression contains sanitizer-breaking function calls
*/
private function containsSanitizerBreaker(Node $expr): bool
{
if ($expr instanceof Node\Expr\FuncCall) {
$funcName = $this->getCallName($expr);
if ($funcName && in_array($funcName, self::SQL_SANITIZER_BREAKERS)) {
return true;
}
}
if ($expr instanceof Node\Expr\MethodCall) {
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
if ($methodName && in_array($methodName, self::SQL_SANITIZER_BREAKERS)) {
return true;
}
}
// Recursively check child nodes
foreach ($expr->getSubNodeNames() as $name) {
$subNode = $expr->{$name};
if ($subNode instanceof Node) {
if ($this->containsSanitizerBreaker($subNode)) {
return true;
}
} elseif (is_array($subNode)) {
foreach ($subNode as $item) {
if ($item instanceof Node && $this->containsSanitizerBreaker($item)) {
return true;
}
}
}
}
return false;
}
/**
* Check if concatenation has tainted unsanitized parts
*/
private function hasTaintedUnsanitizedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
{
if ($node instanceof Node\Expr\BinaryOp\Concat) {
return $this->hasTaintedUnsanitizedPart($node->left, $taintTracker, $filePath)
|| $this->hasTaintedUnsanitizedPart($node->right, $taintTracker, $filePath);
}
// Check if this part is sanitized
if ($this->isSqlExpressionSanitized($node, $taintTracker, $filePath, 0)) {
return false;
}
if ($node instanceof Node\Scalar\Encapsed) {
foreach ($node->parts as $part) {
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
if ($taintTracker->isTainted($part, $filePath) &&
!$this->isSqlExpressionSanitized($part, $taintTracker, $filePath, 0)) {
return true;
}
}
}
return false;
}
return $taintTracker->isTainted($node, $filePath);
}
/**
* Get the call graph from the taint tracker using reflection
*/
private function getCallGraphFromTracker(TaintTracker $taintTracker): ?array
{
try {
$reflection = new \ReflectionClass($taintTracker);
$property = $reflection->getProperty('callGraph');
$property->setAccessible(true);
return $property->getValue($taintTracker);
} catch (\Throwable $e) {
return null;
}
}
private function containsConcatenation(Node $node): bool
{
return $node instanceof Node\Expr\BinaryOp\Concat
|| $node instanceof Node\Scalar\Encapsed
|| $node instanceof Node\Expr\AssignOp\Concat;
}
private function hasTaintedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
{
if ($node instanceof Node\Expr\BinaryOp\Concat) {
return $taintTracker->isTainted($node->left, $filePath)
|| $taintTracker->isTainted($node->right, $filePath)
|| $this->hasTaintedPart($node->left, $taintTracker, $filePath)
|| $this->hasTaintedPart($node->right, $taintTracker, $filePath);
}
if ($node instanceof Node\Scalar\Encapsed) {
foreach ($node->parts as $part) {
if ($taintTracker->isTainted($part, $filePath)) {
return true;
}
}
}
return $taintTracker->isTainted($node, $filePath);
}
}

1251
src/Rules/XssRule.php Normal file

File diff suppressed because it is too large Load Diff

308
src/SecurityLinter.php Normal file
View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace SecurityLinter;
use SecurityLinter\Analyzer\FileAnalyzer;
use SecurityLinter\Analyzer\CallGraphBuilder;
use SecurityLinter\Analyzer\TaintTracker;
use SecurityLinter\Analyzer\TaintPreprocessor;
use SecurityLinter\Report\Vulnerability;
use SecurityLinter\Report\ReportGenerator;
use SecurityLinter\Rules\RuleInterface;
use SecurityLinter\Rules\XssRule;
use SecurityLinter\Rules\SqlInjectionRule;
use SecurityLinter\Rules\CommandInjectionRule;
use SecurityLinter\Rules\PathTraversalRule;
use SecurityLinter\Rules\AuthenticationRule;
use SecurityLinter\Rules\CsrfSessionRule;
use SecurityLinter\Rules\InsecureConfigRule;
/**
* PHP/Laravel Security Linter
*
* Analyzes PHP and Laravel code for security vulnerabilities
* with recursive call tracing capabilities.
*/
class SecurityLinter
{
/** @var RuleInterface[] */
private array $rules = [];
/** @var Vulnerability[] */
private array $vulnerabilities = [];
private FileAnalyzer $fileAnalyzer;
private CallGraphBuilder $callGraphBuilder;
private TaintTracker $taintTracker;
private TaintPreprocessor $taintPreprocessor;
private ReportGenerator $reportGenerator;
private array $config = [
'recursive_depth' => 10,
'follow_includes' => true,
'laravel_mode' => true,
'severity_threshold' => 'low',
'exclude_patterns' => ['vendor/*', 'node_modules/*', 'storage/*'],
'include_patterns' => [], // Patterns that override exclude
];
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
$this->fileAnalyzer = new FileAnalyzer();
$this->callGraphBuilder = new CallGraphBuilder();
$this->taintTracker = new TaintTracker($this->config['recursive_depth']);
$this->taintPreprocessor = new TaintPreprocessor($this->taintTracker);
$this->reportGenerator = new ReportGenerator();
$this->registerDefaultRules();
}
/**
* Register default security rules based on guidelines
*/
private function registerDefaultRules(): void
{
$this->rules = [
new XssRule(),
new SqlInjectionRule(),
new CommandInjectionRule(),
new PathTraversalRule(),
new AuthenticationRule(),
new CsrfSessionRule(),
new InsecureConfigRule(),
];
}
/**
* Add a custom rule
*/
public function addRule(RuleInterface $rule): self
{
$this->rules[] = $rule;
return $this;
}
/**
* Analyze a single file
*/
public function analyzeFile(string $filePath): array
{
if (!file_exists($filePath)) {
throw new \InvalidArgumentException("File not found: {$filePath}");
}
$content = file_get_contents($filePath);
$ast = $this->fileAnalyzer->parse($content, $filePath);
if ($ast === null) {
return [];
}
// Build call graph for this file
$this->callGraphBuilder->buildFromAst($ast, $filePath);
// Set call graph in taint tracker
$this->taintTracker->setCallGraph($this->callGraphBuilder->getCallGraph());
// Preprocess to track taint propagation through assignments
$this->taintPreprocessor->process($ast, $filePath);
// Analyze with each rule
foreach ($this->rules as $rule) {
$ruleVulns = $rule->analyze($ast, $filePath, $this->taintTracker);
$this->vulnerabilities = array_merge($this->vulnerabilities, $ruleVulns);
}
return $this->vulnerabilities;
}
/**
* Analyze a directory recursively
*/
public function analyzeDirectory(string $directory): array
{
$this->vulnerabilities = [];
$files = $this->findPhpFiles($directory);
// First pass: build complete call graph
foreach ($files as $file) {
$content = file_get_contents($file);
$ast = $this->fileAnalyzer->parse($content, $file);
if ($ast !== null) {
$this->callGraphBuilder->buildFromAst($ast, $file);
}
}
// Set call graph in taint tracker for recursive analysis
$this->taintTracker->setCallGraph($this->callGraphBuilder->getCallGraph());
// Second pass: preprocess all files for taint tracking
foreach ($files as $file) {
$content = file_get_contents($file);
$ast = $this->fileAnalyzer->parse($content, $file);
if ($ast !== null) {
$this->taintPreprocessor->process($ast, $file);
}
}
// Third pass: analyze each file with full call graph and taint context
foreach ($files as $file) {
$content = file_get_contents($file);
$ast = $this->fileAnalyzer->parse($content, $file);
if ($ast === null) {
continue;
}
foreach ($this->rules as $rule) {
$ruleVulns = $rule->analyze($ast, $file, $this->taintTracker);
$this->vulnerabilities = array_merge($this->vulnerabilities, $ruleVulns);
}
}
// Deduplicate vulnerabilities
$this->vulnerabilities = $this->deduplicateVulnerabilities($this->vulnerabilities);
return $this->vulnerabilities;
}
/**
* Find all PHP files in directory
*/
private function findPhpFiles(string $directory): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && $this->isPhpFile($file->getPathname())) {
if (!$this->isExcluded($file->getPathname(), $directory)) {
$files[] = $file->getPathname();
}
}
}
return $files;
}
/**
* Check if file is a PHP file
*/
private function isPhpFile(string $path): bool
{
$ext = pathinfo($path, PATHINFO_EXTENSION);
return in_array($ext, ['php', 'phtml', 'blade.php'], true)
|| str_ends_with($path, '.blade.php');
}
/**
* Check if path is excluded
*
* Include patterns take precedence over exclude patterns.
* If a path matches an include pattern, it will not be excluded.
*/
private function isExcluded(string $path, string $basePath): bool
{
$relativePath = str_replace($basePath . '/', '', $path);
// Check if explicitly included (include overrides exclude)
foreach ($this->config['include_patterns'] as $pattern) {
if (fnmatch($pattern, $relativePath)) {
return false; // Explicitly included, don't exclude
}
}
// Check if excluded
foreach ($this->config['exclude_patterns'] as $pattern) {
if (fnmatch($pattern, $relativePath)) {
return true;
}
// Also check parent directories
$dir = dirname($relativePath);
while ($dir !== '.' && $dir !== '') {
if (fnmatch($pattern, $dir) || fnmatch($pattern, $dir . '/')) {
return true;
}
$dir = dirname($dir);
}
}
return false;
}
/**
* Deduplicate vulnerabilities
*/
private function deduplicateVulnerabilities(array $vulnerabilities): array
{
$seen = [];
$unique = [];
foreach ($vulnerabilities as $vuln) {
$key = $vuln->getUniqueKey();
if (!isset($seen[$key])) {
$seen[$key] = true;
$unique[] = $vuln;
}
}
return $unique;
}
/**
* Get vulnerabilities filtered by severity
*/
public function getVulnerabilities(string $minSeverity = 'low'): array
{
$severityOrder = ['low' => 0, 'medium' => 1, 'high' => 2, 'critical' => 3];
$minLevel = $severityOrder[$minSeverity] ?? 0;
return array_filter($this->vulnerabilities, function (Vulnerability $v) use ($severityOrder, $minLevel) {
$level = $severityOrder[$v->getSeverity()] ?? 0;
return $level >= $minLevel;
});
}
/**
* Generate report in specified format
*/
public function generateReport(string $format = 'text', ?array $vulnerabilities = null): string
{
$vulns = $vulnerabilities ?? $this->vulnerabilities;
return $this->reportGenerator->generate($vulns, $format);
}
/**
* Get call graph for debugging
*/
public function getCallGraph(): array
{
return $this->callGraphBuilder->getCallGraph();
}
/**
* Get statistics
*/
public function getStatistics(): array
{
$stats = [
'total' => count($this->vulnerabilities),
'by_severity' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0],
'by_type' => [],
];
foreach ($this->vulnerabilities as $vuln) {
$stats['by_severity'][$vuln->getSeverity()]++;
$type = $vuln->getType();
$stats['by_type'][$type] = ($stats['by_type'][$type] ?? 0) + 1;
}
return $stats;
}
}