Skip to content

Security Rules

Security rules detect patterns that may introduce security vulnerabilities into your codebase. These rules focus on finding credentials, secrets, and other sensitive data that should never be hardcoded.

Scope limitation

These security rules detect only direct superglobal usage patterns ($_GET, $_POST, etc.). They do NOT perform taint analysis or track data flow through variables. For deeper security analysis with taint tracking, consider dedicated tools like PHPStan Security, Psalm Taint Analysis, or SonarQube.


Hardcoded Credentials

Rule ID: security.hardcoded-credentials Severity: Error

What it measures

Detects hardcoded credentials in PHP code -- string literal values assigned to variables, properties, constants, array keys, and parameters with credential-related names.

Detection patterns:

  • Variable assignment: $password = 'secret';
  • Property assignment: $this->apiKey = '...';, self::$secret = '...'; (also with ??=)
  • Array element assignment: $config['password'] = '...';
  • Array item: ['api_key' => 'abc123']
  • Class constant: const DB_PASSWORD = 'root';
  • Namespace or global constant: const API_PASSWORD = 'root'; (including several constants in one declaration)
  • define() call: define('API_KEY', '...');
  • Property default: private string $token = 'x';
  • Parameter default: function f($pwd = 'root')

Sensitive name matching:

  • Suffix words (match anywhere): password, passwd, pwd, secret, credential(s)
  • Compound "key" (only with qualifier): apiKey, secretKey, privateKey, encryptionKey, signingKey, authKey, accessKey
  • Compound "token" (only with qualifier): authToken, accessToken, bearerToken, apiToken, refreshToken

Names are split into words at case changes, underscores and letter/digit boundaries (apiKey1 is api, key, 1). A word written without any boundary is split when it ends with a sensitive word: $apikey, $dbpassword, APISECRET match, while passwordless, secretary, keyword and monkey do not.

Names like $passwordHash, $tokenStorage, $cacheKey, OPTION_PASSWORD are excluded (non-credential context).

Value filtering: these values are skipped:

  • empty strings, strings shorter than 4 characters, and strings of identical characters (***, xxx);
  • whole angle-bracket placeholders such as <password> and native built-in PHP type syntax such as ?string or int|string. The type filter parses syntax against a built-in whitelist; it does not validate PHP type semantics;
  • lowercase dotted identifiers such as auth.password.reset or auth.password-reset (translation, configuration or channel keys). Uppercase dotted values such as App.Models.User, capitalized or digit-bearing hyphen segments such as Summer-2024.Pass and sk-live.abc123-def456, and JWT-shaped values are judged as possible secrets. A lowercase dotted secret phrase such as correct-horse.battery-staple can still be skipped;
  • messages: longer than 20 characters and at least three whitespace-separated words. Hyphens, dots, slashes and plus signs are not word breaks, so UUID-, AWS- and base64-shaped keys are still reported.

Bare token8 is outside the default sensitive-name policy; configure token as an extra sensitive name if that name should be judged. A credential-looking value in an alias map may still be reported; suppress an intentional case with a reasoned @qmx-ignore.

Findings inside named classes, methods, and functions carry their declaration symbol. File-scope and anonymous evidence retain a file symbol and null namespace. Direct superglobal checks recognize literal variable-variable names and literal $GLOBALS keys; an unknown dynamic name such as $$name is outside their finite pattern set.

Example

class DatabaseConfig
{
    // Bad: credentials hardcoded directly
    private const DB_PASSWORD = 'super_secret_123';
    private string $apiKey = 'sk-live-abc123def456';

    public function connect(string $password = 'root'): void
    {
        $apiToken = 'ghp_xxxxxxxxxxxxxxxxxxxx';
        // ...
    }
}

How to fix

  1. Use environment variables:

    $password = $_ENV['DB_PASSWORD'];
    // or
    $password = getenv('DB_PASSWORD');
    
  2. Use a secrets manager (Vault, AWS Secrets Manager, etc.)

  3. Use framework configuration:

    // Symfony
    $password = $this->getParameter('database_password');
    
    // Laravel
    $password = config('database.password');
    

Warning

Hardcoded credentials in source code are a serious security risk. They can be leaked through version control, logs, error messages, or compiled artifacts.


SQL Injection

Rule ID: security.sql-injection Severity: Error

What it measures

Detects use of superglobals ($_GET, $_POST, $_REQUEST, $_COOKIE) in SQL contexts, where unsanitized user input can lead to SQL injection attacks.

Detection patterns:

  • String concatenation with SQL keywords: "SELECT * FROM users WHERE id = " . $_GET['id']
  • Arguments to unsafe query functions: mysql_query($_GET['q']), mysqli_query($conn, $_POST['sql']), pg_query($_REQUEST['q'])
  • sprintf() with SQL template: sprintf("SELECT * FROM users WHERE id = %s", $_GET['id'])

The superglobal is found through the same value-passing wrappers as for XSS ("... WHERE id = " . ($_GET['id'] ?? 0) is reported); a function call or an (int)/(float) cast ends the search.

One query is one finding, however many superglobals it reads. When a query function, a sprintf() call or a concatenation reports a superglobal, the queries built inside it are reported again only for a read it did not reach: mysqli_query($conn, "SELECT * FROM users WHERE name = '{$_POST['name']}'") is one violation, not one for the call and one for the interpolated string. A query behind any other call is still reported on its own: mysqli_query($conn, trim("SELECT * FROM users WHERE id = " . $_GET['id'])) is reported for the concatenation, and in "SELECT * FROM t WHERE a = " . $_GET['a'] . " AND b IN (" . implode(',', ["SELECT id FROM u WHERE n = '{$_POST['n']}'"]) . ")" the subquery is reported for $_POST next to the outer query's $_GET.

Example

// Bad: superglobal directly in SQL query
$result = mysqli_query($conn, "SELECT * FROM users WHERE id = " . $_GET['id']);

// Bad: superglobal as argument to query function
$result = pg_query("SELECT * FROM orders WHERE status = '" . $_POST['status'] . "'");

// Bad: sprintf with unsanitized input
$sql = sprintf("DELETE FROM sessions WHERE token = '%s'", $_COOKIE['session']);

How to fix

Use parameterized queries (prepared statements) instead of string concatenation:

// Good: PDO prepared statement
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_GET['id']]);

// Good: mysqli prepared statement
$stmt = $conn->prepare("SELECT * FROM users WHERE id = ?");
$stmt->bind_param("i", $_GET['id']);
$stmt->execute();

Warning

SQL injection is one of the most dangerous and common web vulnerabilities. Never concatenate user input into SQL strings, even if you think the input is "safe."


XSS

Rule ID: security.xss Severity: Error

What it measures

Detects echo or print statements that output superglobals ($_GET, $_POST, $_REQUEST, $_COOKIE) without proper sanitization, which can lead to Cross-Site Scripting (XSS) attacks.

The superglobal is found through the wrappers that pass its value on: concatenation, interpolation, ??, the branches of ?:, match arm results, (string), @ and assignment — echo $_GET['name'] ?? 'guest'; is reported.

A violation is not reported when the value passes through a function or method call or an (int)/(float) cast. That covers the sanitizers — htmlspecialchars(), htmlentities(), strip_tags(), intval() — but equally every other call: function results are not tracked (see What is NOT detected under Detection scope below).

Example

// Bad: unsanitized superglobal echoed directly
echo $_GET['name'];
print("Welcome, " . $_POST['username']);

// Good: sanitized output
echo htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8');
echo (int) $_GET['page'];

How to fix

Always sanitize user input before outputting it in HTML:

// Use htmlspecialchars with ENT_QUOTES and explicit encoding
echo htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8');

// For integer values, cast to int
echo (int) $_GET['id'];

// In templates, use your framework's auto-escaping (Twig, Blade, etc.)

Warning

XSS allows attackers to inject malicious scripts into pages viewed by other users. Always escape output, even in "internal" admin panels.


Command Injection

Rule ID: security.command-injection Severity: Error

What it measures

Detects superglobals ($_GET, $_POST, $_REQUEST, $_COOKIE) passed as arguments to shell execution functions, which can lead to command injection attacks.

Detected sinks: exec(), system(), passthru(), shell_exec(), proc_open(), popen(), and the backtick operator (`ls {$_GET['dir']}`), which runs a command exactly like shell_exec().

The superglobal is found through the same value-passing wrappers as for XSS (??, ?:, (string), concatenation, interpolation, ...). A violation is not reported when the value passes through a function call — escapeshellarg(), escapeshellcmd() and any other — or an (int)/(float) cast.

Example

// Bad: superglobal passed directly to shell function
exec("convert " . $_GET['filename'] . " output.png");
system("ping " . $_POST['host']);
$output = shell_exec("grep " . $_REQUEST['pattern'] . " /var/log/app.log");

// Good: properly escaped
exec("convert " . escapeshellarg($_GET['filename']) . " output.png");

How to fix

  1. Use escapeshellarg() to escape individual arguments:

    exec("convert " . escapeshellarg($_GET['filename']) . " output.png");
    
  2. Avoid shell commands entirely when possible. Use PHP built-in functions instead:

    // Instead of: exec("ls " . escapeshellarg($dir))
    $files = scandir($dir);
    
    // Instead of: exec("ping " . escapeshellarg($host))
    // Use sockets or a library
    

Warning

Command injection allows attackers to execute arbitrary commands on your server. Even with escaping, prefer PHP-native alternatives to shell commands when they exist.


Sensitive Parameter

Rule ID: security.sensitive-parameter Severity: Warning

What it measures

Detects function and method parameters with sensitive names that are missing the #[\SensitiveParameter] attribute (available since PHP 8.2). Without this attribute, sensitive values like passwords and tokens will appear in plain text in stack traces, error logs, and exception reports.

Sensitive parameter matching uses the same credential-name policy as hardcoded credential detection: standalone password, passwd, pwd, secret, credential, and credentials words are matched, while key and token require a qualifying prefix such as api, auth, access, private, or refresh. Contexts such as passwordHash, tokenStorage, cacheKey, and OPTION_PASSWORD are excluded.

Example

// Bad: sensitive parameter without the attribute
class AuthService
{
    public function login(string $username, string $password): bool
    {
        // If an exception is thrown here, $password appears in the stack trace
        return $this->verify($username, $password);
    }
}

// Good: attribute prevents value from appearing in stack traces
class AuthService
{
    public function login(
        string $username,
        #[\SensitiveParameter] string $password,
    ): bool {
        return $this->verify($username, $password);
    }
}

How to fix

Add the #[\SensitiveParameter] attribute to parameters that handle sensitive data:

function authenticate(
    string $username,
    #[\SensitiveParameter] string $password,
): bool {
    // ...
}

function callApi(
    string $url,
    #[\SensitiveParameter] string $apiToken,
): Response {
    // ...
}

Tip

The #[\SensitiveParameter] attribute was introduced in PHP 8.2. It replaces the value with SensitiveParameterValue in stack traces, preventing accidental exposure in logs and error reports.


Detection scope

The security rules (sql-injection, xss, command-injection) use pattern-based detection -- they look for superglobal variables ($_GET, $_POST, $_REQUEST, $_COOKIE) used directly in dangerous contexts. This approach is fast and produces zero false positives for direct patterns, but has inherent limitations.

What IS detected

Direct superglobal-to-sink patterns within one expression, including through the wrappers that pass the value on:

// All detected:
echo $_GET['name'];                                          // direct
echo "Hello " . $_POST['user'];                              // concatenation
echo "Welcome {$_GET['name']}";                              // interpolation
echo $_GET['name'] ?? 'guest';                               // null coalescing
echo $ok ? $_GET['name'] : '';                               // ternary branch
echo (string) $_GET['name'];                                 // string cast
mysqli_query($conn, "SELECT * FROM t WHERE id=" . $_GET['id']); // SQL function arg
exec("ping " . $_GET['host']);                                // command function arg
$out = `ls {$_GET['dir']}`;                                  // backtick command

What is NOT detected

These rules do not perform taint analysis -- they cannot track data flow through variables, function returns, or object properties:

// NOT detected -- value assigned to intermediate variable:
$name = $_GET['name'];
echo $name;

// NOT detected -- value passed through a function:
function getName() { return $_GET['name']; }
echo getName();

// NOT detected -- any function call ends the search, not only a sanitizer:
echo trim($_GET['name']);

// NOT detected -- value stored in an object:
$request->name = $_POST['name'];
echo $request->name;

// NOT detected -- indirect SQL injection:
$id = $_GET['id'];
$query = "SELECT * FROM users WHERE id = " . $id;

Recommendations

For comprehensive security analysis with full taint tracking, use dedicated tools alongside Qualimetrix:

Qualimetrix security rules are best used as a first line of defense to catch the most obvious patterns. They complement but do not replace dedicated security analysis tools.


Configuration

# qmx.yaml
rules:
  security.hardcoded-credentials:
    enabled: true  # or false to disable
  security.sql-injection:
    enabled: true
  security.xss:
    enabled: true
  security.command-injection:
    enabled: true
  security.sensitive-parameter:
    enabled: true

You can also disable via the CLI:

# Disable a specific rule
bin/qmx check src/ --disable-rule=security.hardcoded-credentials

# Disable all security rules (wildcard match; matches descendants only, not "security" itself)
bin/qmx check src/ --disable-rule=security.*