Add unit tests for PhpFrameworkSurface and PhpPharScanner
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled

- Implement comprehensive tests for PhpFrameworkSurface, covering scenarios such as empty surfaces, presence of routes, controllers, middlewares, CLI commands, cron jobs, and event listeners.
- Validate metadata creation for route counts, HTTP methods, protected and public routes, and route patterns.
- Introduce tests for PhpPharScanner, including handling of non-existent files, null or empty paths, invalid PHAR files, and minimal PHAR structures.
- Ensure correct computation of SHA256 for valid PHAR files and validate the properties of PhpPharArchive, PhpPharEntry, and PhpPharScanResult.
This commit is contained in:
StellaOps Bot
2025-12-07 13:44:13 +02:00
parent af30fc322f
commit 965cbf9574
49 changed files with 11935 additions and 152 deletions

View File

@@ -0,0 +1,376 @@
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
public sealed class ComposerLockReaderTests : IDisposable
{
private readonly string _testDir;
public ComposerLockReaderTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"php-lock-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
}
[Fact]
public async Task LoadAsync_NoLockFile_ReturnsEmpty()
{
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.True(result.IsEmpty);
Assert.Empty(result.Packages);
Assert.Empty(result.DevPackages);
}
[Fact]
public async Task LoadAsync_ValidLockFile_ParsesPackages()
{
var lockContent = @"{
""content-hash"": ""abc123def456"",
""plugin-api-version"": ""2.6.0"",
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.2.3"",
""type"": ""library""
}
],
""packages-dev"": []
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.False(result.IsEmpty);
Assert.Single(result.Packages);
Assert.Equal("vendor/package", result.Packages[0].Name);
Assert.Equal("1.2.3", result.Packages[0].Version);
Assert.Equal("library", result.Packages[0].Type);
Assert.False(result.Packages[0].IsDev);
}
[Fact]
public async Task LoadAsync_ParsesDevPackages()
{
var lockContent = @"{
""packages"": [],
""packages-dev"": [
{
""name"": ""phpunit/phpunit"",
""version"": ""10.0.0"",
""type"": ""library""
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.DevPackages);
Assert.Equal("phpunit/phpunit", result.DevPackages[0].Name);
Assert.True(result.DevPackages[0].IsDev);
}
[Fact]
public async Task LoadAsync_ParsesContentHashAndPluginApi()
{
var lockContent = @"{
""content-hash"": ""a1b2c3d4e5f6"",
""plugin-api-version"": ""2.3.0"",
""packages"": []
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Equal("a1b2c3d4e5f6", result.ContentHash);
Assert.Equal("2.3.0", result.PluginApiVersion);
}
[Fact]
public async Task LoadAsync_ParsesSourceInfo()
{
var lockContent = @"{
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.0.0"",
""source"": {
""type"": ""git"",
""url"": ""https://github.com/vendor/package.git"",
""reference"": ""abc123def456789""
}
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.Equal("git", result.Packages[0].SourceType);
Assert.Equal("abc123def456789", result.Packages[0].SourceReference);
}
[Fact]
public async Task LoadAsync_ParsesDistInfo()
{
var lockContent = @"{
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.0.0"",
""dist"": {
""type"": ""zip"",
""url"": ""https://packagist.org/vendor/package/1.0.0"",
""shasum"": ""sha256hashhere""
}
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.Equal("sha256hashhere", result.Packages[0].DistSha);
Assert.Equal("https://packagist.org/vendor/package/1.0.0", result.Packages[0].DistUrl);
}
[Fact]
public async Task LoadAsync_ParsesAutoloadPsr4()
{
var lockContent = @"{
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.0.0"",
""autoload"": {
""psr-4"": {
""Vendor\\Package\\"": ""src/""
}
}
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.NotEmpty(result.Packages[0].Autoload.Psr4);
Assert.Contains("Vendor\\Package\\->src/", result.Packages[0].Autoload.Psr4);
}
[Fact]
public async Task LoadAsync_ParsesAutoloadClassmap()
{
var lockContent = @"{
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.0.0"",
""autoload"": {
""classmap"": [
""src/"",
""lib/""
]
}
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.Equal(2, result.Packages[0].Autoload.Classmap.Count);
Assert.Contains("src/", result.Packages[0].Autoload.Classmap);
Assert.Contains("lib/", result.Packages[0].Autoload.Classmap);
}
[Fact]
public async Task LoadAsync_ParsesAutoloadFiles()
{
var lockContent = @"{
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.0.0"",
""autoload"": {
""files"": [
""src/helpers.php"",
""src/functions.php""
]
}
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.Equal(2, result.Packages[0].Autoload.Files.Count);
Assert.Contains("src/helpers.php", result.Packages[0].Autoload.Files);
}
[Fact]
public async Task LoadAsync_MultiplePackages_ParsesAll()
{
var lockContent = @"{
""packages"": [
{ ""name"": ""vendor/first"", ""version"": ""1.0.0"" },
{ ""name"": ""vendor/second"", ""version"": ""2.0.0"" },
{ ""name"": ""vendor/third"", ""version"": ""3.0.0"" }
],
""packages-dev"": [
{ ""name"": ""dev/tool"", ""version"": ""0.1.0"" }
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Equal(3, result.Packages.Count);
Assert.Single(result.DevPackages);
}
[Fact]
public async Task LoadAsync_ComputesSha256()
{
var lockContent = @"{ ""packages"": [] }";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.NotNull(result.LockSha256);
Assert.Equal(64, result.LockSha256.Length); // SHA256 hex string length
Assert.True(result.LockSha256.All(c => char.IsAsciiHexDigitLower(c)));
}
[Fact]
public async Task LoadAsync_SetsLockPath()
{
var lockContent = @"{ ""packages"": [] }";
var lockPath = Path.Combine(_testDir, "composer.lock");
await File.WriteAllTextAsync(lockPath, lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Equal(lockPath, result.LockPath);
}
[Fact]
public async Task LoadAsync_MissingRequiredFields_SkipsPackage()
{
var lockContent = @"{
""packages"": [
{ ""name"": ""valid/package"", ""version"": ""1.0.0"" },
{ ""name"": ""missing-version"" },
{ ""version"": ""1.0.0"" }
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.Equal("valid/package", result.Packages[0].Name);
}
[Fact]
public void Empty_ReturnsEmptyInstance()
{
var empty = ComposerLockData.Empty;
Assert.True(empty.IsEmpty);
Assert.Empty(empty.Packages);
Assert.Empty(empty.DevPackages);
Assert.Equal(string.Empty, empty.LockPath);
Assert.Null(empty.ContentHash);
Assert.Null(empty.PluginApiVersion);
Assert.Null(empty.LockSha256);
}
[Fact]
public async Task LoadAsync_Psr4ArrayPaths_ParsesMultiplePaths()
{
var lockContent = @"{
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.0.0"",
""autoload"": {
""psr-4"": {
""Vendor\\Package\\"": [""src/"", ""lib/""]
}
}
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.Equal(2, result.Packages[0].Autoload.Psr4.Count);
}
[Fact]
public async Task LoadAsync_NormalizesBackslashesInPaths()
{
var lockContent = @"{
""packages"": [
{
""name"": ""vendor/package"",
""version"": ""1.0.0"",
""autoload"": {
""files"": [""src\\helpers.php""]
}
}
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.lock"), lockContent);
var context = CreateContext(_testDir);
var result = await ComposerLockData.LoadAsync(context, CancellationToken.None);
Assert.Single(result.Packages);
Assert.Contains("src/helpers.php", result.Packages[0].Autoload.Files);
}
private static LanguageAnalyzerContext CreateContext(string rootPath)
{
return new LanguageAnalyzerContext(rootPath);
}
}

View File

@@ -0,0 +1,672 @@
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
public sealed class PhpCapabilityScannerTests
{
#region Exec Capabilities
[Theory]
[InlineData("exec('ls -la');", "exec")]
[InlineData("shell_exec('whoami');", "shell_exec")]
[InlineData("system('cat /etc/passwd');", "system")]
[InlineData("passthru('top');", "passthru")]
[InlineData("popen('/bin/sh', 'r');", "popen")]
[InlineData("proc_open('ls', $descriptors, $pipes);", "proc_open")]
[InlineData("pcntl_exec('/bin/bash');", "pcntl_exec")]
public void ScanContent_ExecFunction_DetectsCriticalRisk(string line, string expectedFunction)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Exec && e.FunctionOrPattern == expectedFunction);
Assert.All(result.Where(e => e.Kind == PhpCapabilityKind.Exec), e => Assert.Equal(PhpCapabilityRisk.Critical, e.Risk));
}
[Fact]
public void ScanContent_BacktickOperator_DetectsCriticalRisk()
{
var content = "<?php\n$output = `ls -la`;";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Exec && e.FunctionOrPattern == "backtick_operator");
Assert.Contains(result, e => e.Risk == PhpCapabilityRisk.Critical);
}
[Fact]
public void ScanContent_ExecInComment_DoesNotDetect()
{
var content = @"<?php
// exec('ls -la');
/* shell_exec('whoami'); */
# system('cat');
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.Empty(result);
}
#endregion
#region Filesystem Capabilities
[Theory]
[InlineData("fopen('file.txt', 'r');", "fopen", PhpCapabilityRisk.Medium)]
[InlineData("fwrite($fp, $data);", "fwrite", PhpCapabilityRisk.Medium)]
[InlineData("fread($fp, 1024);", "fread", PhpCapabilityRisk.Low)]
[InlineData("file_get_contents('data.txt');", "file_get_contents", PhpCapabilityRisk.Medium)]
[InlineData("file_put_contents('out.txt', $data);", "file_put_contents", PhpCapabilityRisk.Medium)]
public void ScanContent_FileReadWrite_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Filesystem && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
[Theory]
[InlineData("unlink('file.txt');", "unlink", PhpCapabilityRisk.High)]
[InlineData("rmdir('/tmp/dir');", "rmdir", PhpCapabilityRisk.High)]
[InlineData("chmod('script.sh', 0755);", "chmod", PhpCapabilityRisk.High)]
[InlineData("chown('file.txt', 'root');", "chown", PhpCapabilityRisk.High)]
[InlineData("symlink('/etc/passwd', 'link');", "symlink", PhpCapabilityRisk.High)]
public void ScanContent_DangerousFileOps_DetectsHighRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Filesystem && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
[Fact]
public void ScanContent_DirectoryFunctions_DetectsLowRisk()
{
var content = @"<?php
$files = scandir('/var/www');
$matches = glob('*.php');
$dir = opendir('/home');
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.All(result.Where(e => e.Kind == PhpCapabilityKind.Filesystem), e => Assert.Equal(PhpCapabilityRisk.Low, e.Risk));
}
#endregion
#region Network Capabilities
[Theory]
[InlineData("curl_init('http://example.com');", "curl_init")]
[InlineData("curl_exec($ch);", "curl_exec")]
[InlineData("curl_multi_exec($mh, $active);", "curl_multi_exec")]
public void ScanContent_CurlFunctions_DetectsMediumRisk(string line, string expectedFunction)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Network && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.Medium, evidence.Risk);
}
[Theory]
[InlineData("fsockopen('localhost', 80);", "fsockopen")]
[InlineData("socket_create(AF_INET, SOCK_STREAM, SOL_TCP);", "socket_create")]
[InlineData("socket_connect($socket, '127.0.0.1', 8080);", "socket_connect")]
[InlineData("stream_socket_client('tcp://localhost:80');", "stream_socket_client")]
[InlineData("stream_socket_server('tcp://0.0.0.0:8000');", "stream_socket_server")]
public void ScanContent_SocketFunctions_DetectsHighRisk(string line, string expectedFunction)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Network && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
}
[Fact]
public void ScanContent_FileGetContentsWithUrl_DetectsNetworkCapability()
{
var content = "<?php\n$data = file_get_contents('http://example.com/api');";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Network && e.FunctionOrPattern == "file_get_contents_url");
}
#endregion
#region Environment Capabilities
[Theory]
[InlineData("getenv('HOME');", "getenv", PhpCapabilityRisk.Medium)]
[InlineData("putenv('PATH=/usr/bin');", "putenv", PhpCapabilityRisk.High)]
[InlineData("apache_getenv('DOCUMENT_ROOT');", "apache_getenv", PhpCapabilityRisk.Medium)]
[InlineData("apache_setenv('MY_VAR', 'value');", "apache_setenv", PhpCapabilityRisk.High)]
public void ScanContent_EnvFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Environment && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
[Fact]
public void ScanContent_EnvSuperglobal_DetectsMediumRisk()
{
var content = "<?php\n$path = $_ENV['PATH'];";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Environment && e.FunctionOrPattern == "$_ENV");
}
[Fact]
public void ScanContent_ServerSuperglobal_DetectsLowRisk()
{
var content = "<?php\n$host = $_SERVER['HTTP_HOST'];";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Environment && e.FunctionOrPattern == "$_SERVER");
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.Low, evidence.Risk);
}
#endregion
#region Serialization Capabilities
[Fact]
public void ScanContent_Unserialize_DetectsCriticalRisk()
{
var content = "<?php\n$obj = unserialize($data);";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Serialization && e.FunctionOrPattern == "unserialize");
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.Critical, evidence.Risk);
}
[Theory]
[InlineData("serialize($object);", "serialize", PhpCapabilityRisk.Low)]
[InlineData("json_encode($data);", "json_encode", PhpCapabilityRisk.Low)]
[InlineData("json_decode($json);", "json_decode", PhpCapabilityRisk.Low)]
[InlineData("igbinary_unserialize($data);", "igbinary_unserialize", PhpCapabilityRisk.High)]
public void ScanContent_SerializationFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Serialization && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
[Theory]
[InlineData("public function __wakeup()")]
[InlineData("private function __sleep()")]
[InlineData("public function __serialize()")]
[InlineData("public function __unserialize($data)")]
public void ScanContent_SerializationMagicMethods_Detects(string line)
{
var content = $"<?php\nclass Test {{\n {line} {{ }}\n}}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Serialization);
}
#endregion
#region Crypto Capabilities
[Fact]
public void ScanContent_OpenSslFunctions_DetectsMediumRisk()
{
var content = @"<?php
openssl_encrypt($data, 'AES-256-CBC', $key);
openssl_decrypt($encrypted, 'AES-256-CBC', $key);
openssl_sign($data, $signature, $privateKey);
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.True(result.Count(e => e.Kind == PhpCapabilityKind.Crypto) >= 3);
}
[Fact]
public void ScanContent_SodiumFunctions_DetectsLowRisk()
{
var content = @"<?php
sodium_crypto_secretbox($message, $nonce, $key);
sodium_crypto_box($message, $nonce, $keyPair);
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.All(result.Where(e => e.Kind == PhpCapabilityKind.Crypto && e.Pattern.StartsWith("sodium")),
e => Assert.Equal(PhpCapabilityRisk.Low, e.Risk));
}
[Theory]
[InlineData("md5($password);", "md5", PhpCapabilityRisk.Medium)]
[InlineData("sha1($data);", "sha1", PhpCapabilityRisk.Low)]
[InlineData("hash('sha256', $data);", "hash", PhpCapabilityRisk.Low)]
[InlineData("password_hash($password, PASSWORD_DEFAULT);", "password_hash", PhpCapabilityRisk.Low)]
[InlineData("mcrypt_encrypt(MCRYPT_RIJNDAEL_256, $key, $data, MCRYPT_MODE_CBC);", "mcrypt_encrypt", PhpCapabilityRisk.High)]
public void ScanContent_HashFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Crypto && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
#endregion
#region Database Capabilities
[Fact]
public void ScanContent_MysqliFunctions_DetectsDatabase()
{
var content = @"<?php
$conn = mysqli_connect('localhost', 'user', 'pass', 'db');
mysqli_query($conn, 'SELECT * FROM users');
mysqli_close($conn);
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.True(result.Count(e => e.Kind == PhpCapabilityKind.Database) >= 2);
}
[Fact]
public void ScanContent_PdoUsage_DetectsDatabase()
{
var content = @"<?php
$pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Database && e.FunctionOrPattern == "PDO");
}
[Fact]
public void ScanContent_PostgresFunctions_DetectsDatabase()
{
var content = @"<?php
$conn = pg_connect('host=localhost dbname=test');
pg_query($conn, 'SELECT * FROM users');
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.True(result.Count(e => e.Kind == PhpCapabilityKind.Database) >= 2);
}
[Fact]
public void ScanContent_RawSqlQuery_DetectsHighRisk()
{
var content = "<?php\n$query = \"SELECT * FROM users WHERE id = $id\";";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Database && e.FunctionOrPattern == "raw_sql_query");
}
#endregion
#region Upload Capabilities
[Fact]
public void ScanContent_FilesSuperglobal_DetectsHighRisk()
{
var content = "<?php\n$file = $_FILES['upload'];";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Upload && e.FunctionOrPattern == "$_FILES");
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
}
[Fact]
public void ScanContent_MoveUploadedFile_DetectsHighRisk()
{
var content = "<?php\nmove_uploaded_file($_FILES['file']['tmp_name'], '/uploads/file.txt');";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Upload && e.FunctionOrPattern == "move_uploaded_file");
}
#endregion
#region Stream Wrapper Capabilities
[Theory]
[InlineData("php://input", PhpCapabilityRisk.High)]
[InlineData("php://filter", PhpCapabilityRisk.Critical)]
[InlineData("php://memory", PhpCapabilityRisk.Low)]
[InlineData("data://", PhpCapabilityRisk.High)]
[InlineData("phar://", PhpCapabilityRisk.Critical)]
[InlineData("zip://", PhpCapabilityRisk.High)]
[InlineData("expect://", PhpCapabilityRisk.Critical)]
public void ScanContent_StreamWrappers_DetectsAppropriateRisk(string wrapper, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n$data = file_get_contents('{wrapper}data');";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.StreamWrapper && e.FunctionOrPattern == wrapper);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
[Fact]
public void ScanContent_StreamWrapperRegister_DetectsHighRisk()
{
var content = "<?php\nstream_wrapper_register('myproto', 'MyProtocolHandler');";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.StreamWrapper && e.FunctionOrPattern == "stream_wrapper_register");
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
}
#endregion
#region Dynamic Code Capabilities
[Theory]
[InlineData("eval($code);", "eval")]
[InlineData("create_function('$a', 'return $a * 2;');", "create_function")]
[InlineData("assert($condition);", "assert")]
public void ScanContent_DynamicCodeExecution_DetectsCriticalRisk(string line, string expectedFunction)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.DynamicCode && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.Critical, evidence.Risk);
}
[Theory]
[InlineData("call_user_func($callback, $arg);", "call_user_func")]
[InlineData("call_user_func_array($callback, $args);", "call_user_func_array")]
[InlineData("preg_replace('/pattern/e', 'code', $subject);", "preg_replace")]
public void ScanContent_DynamicCodeHigh_DetectsHighRisk(string line, string expectedFunction)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.DynamicCode && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.High, evidence.Risk);
}
[Fact]
public void ScanContent_VariableFunction_DetectsHighRisk()
{
var content = "<?php\n$func = 'system';\n$func('ls');";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.DynamicCode && e.FunctionOrPattern == "variable_function");
}
#endregion
#region Reflection Capabilities
[Theory]
[InlineData("new ReflectionClass('MyClass');", "ReflectionClass")]
[InlineData("new ReflectionMethod($obj, 'method');", "ReflectionMethod")]
[InlineData("new ReflectionFunction('func');", "ReflectionFunction")]
[InlineData("new ReflectionProperty($obj, 'prop');", "ReflectionProperty")]
public void ScanContent_ReflectionClasses_DetectsMediumRisk(string line, string expectedClass)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Reflection && e.FunctionOrPattern == expectedClass);
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.Medium, evidence.Risk);
}
[Theory]
[InlineData("get_defined_functions();", "get_defined_functions")]
[InlineData("get_defined_vars();", "get_defined_vars")]
[InlineData("get_loaded_extensions();", "get_loaded_extensions")]
public void ScanContent_IntrospectionFunctions_Detects(string line, string expectedFunction)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Reflection && e.FunctionOrPattern == expectedFunction);
}
#endregion
#region Output Control Capabilities
[Theory]
[InlineData("header('Location: /redirect');", "header", PhpCapabilityRisk.Medium)]
[InlineData("setcookie('session', $value);", "setcookie", PhpCapabilityRisk.Medium)]
[InlineData("ob_start();", "ob_start", PhpCapabilityRisk.Low)]
public void ScanContent_OutputFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.OutputControl && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
#endregion
#region Session Capabilities
[Fact]
public void ScanContent_SessionSuperglobal_DetectsMediumRisk()
{
var content = "<?php\n$_SESSION['user'] = $userId;";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Session && e.FunctionOrPattern == "$_SESSION");
Assert.NotNull(evidence);
Assert.Equal(PhpCapabilityRisk.Medium, evidence.Risk);
}
[Theory]
[InlineData("session_start();", "session_start", PhpCapabilityRisk.Medium)]
[InlineData("session_destroy();", "session_destroy", PhpCapabilityRisk.Low)]
[InlineData("session_set_save_handler($handler);", "session_set_save_handler", PhpCapabilityRisk.High)]
public void ScanContent_SessionFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.Session && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
#endregion
#region Error Handling Capabilities
[Theory]
[InlineData("ini_set('display_errors', 1);", "ini_set", PhpCapabilityRisk.High)]
[InlineData("ini_get('memory_limit');", "ini_get", PhpCapabilityRisk.Low)]
[InlineData("phpinfo();", "phpinfo", PhpCapabilityRisk.High)]
[InlineData("error_reporting(E_ALL);", "error_reporting", PhpCapabilityRisk.Medium)]
[InlineData("set_error_handler($handler);", "set_error_handler", PhpCapabilityRisk.Medium)]
public void ScanContent_ErrorFunctions_DetectsAppropriateRisk(string line, string expectedFunction, PhpCapabilityRisk expectedRisk)
{
var content = $"<?php\n{line}";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.FirstOrDefault(e => e.Kind == PhpCapabilityKind.ErrorHandling && e.FunctionOrPattern == expectedFunction);
Assert.NotNull(evidence);
Assert.Equal(expectedRisk, evidence.Risk);
}
#endregion
#region Edge Cases and Integration
[Fact]
public void ScanContent_EmptyContent_ReturnsEmpty()
{
var result = PhpCapabilityScanner.ScanContent("", "test.php");
Assert.Empty(result);
}
[Fact]
public void ScanContent_NullContent_ReturnsEmpty()
{
var result = PhpCapabilityScanner.ScanContent(null!, "test.php");
Assert.Empty(result);
}
[Fact]
public void ScanContent_WhitespaceContent_ReturnsEmpty()
{
var result = PhpCapabilityScanner.ScanContent(" \n\t ", "test.php");
Assert.Empty(result);
}
[Fact]
public void ScanContent_MultipleCapabilities_DetectsAll()
{
var content = @"<?php
exec('ls');
$data = file_get_contents('data.txt');
$conn = mysqli_connect('localhost', 'user', 'pass');
$_SESSION['user'] = $user;
unserialize($input);
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.True(result.Count >= 5);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Exec);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Filesystem);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Database);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Session);
Assert.Contains(result, e => e.Kind == PhpCapabilityKind.Serialization);
}
[Fact]
public void ScanContent_MultiLineComment_SkipsCommentedCode()
{
var content = @"<?php
/*
exec('ls');
unserialize($data);
*/
echo 'Hello';
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.Empty(result);
}
[Fact]
public void ScanContent_CaseInsensitive_DetectsFunctions()
{
var content = @"<?php
EXEC('ls');
Shell_Exec('whoami');
UNSERIALIZE($data);
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
Assert.Contains(result, e => e.FunctionOrPattern == "exec");
Assert.Contains(result, e => e.FunctionOrPattern == "shell_exec");
Assert.Contains(result, e => e.FunctionOrPattern == "unserialize");
}
[Fact]
public void ScanContent_CorrectLineNumbers_ReportsAccurately()
{
var content = @"<?php
// Line 2
// Line 3
exec('ls'); // Line 4
// Line 5
shell_exec('pwd'); // Line 6
";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var execEvidence = result.FirstOrDefault(e => e.FunctionOrPattern == "exec");
var shellExecEvidence = result.FirstOrDefault(e => e.FunctionOrPattern == "shell_exec");
Assert.NotNull(execEvidence);
Assert.NotNull(shellExecEvidence);
Assert.Equal(4, execEvidence.SourceLine);
Assert.Equal(6, shellExecEvidence.SourceLine);
}
[Fact]
public void ScanContent_SnippetTruncation_TruncatesLongLines()
{
var longLine = new string('x', 200);
var content = $"<?php\nexec('{longLine}');";
var result = PhpCapabilityScanner.ScanContent(content, "test.php");
Assert.NotEmpty(result);
var evidence = result.First();
Assert.NotNull(evidence.Snippet);
Assert.True(evidence.Snippet.Length <= 153); // 150 + "..."
}
[Fact]
public void ScanContent_SourceFilePreserved_InEvidence()
{
var content = "<?php\nexec('ls');";
var result = PhpCapabilityScanner.ScanContent(content, "src/controllers/AdminController.php");
Assert.NotEmpty(result);
Assert.All(result, e => Assert.Equal("src/controllers/AdminController.php", e.SourceFile));
}
#endregion
}

View File

@@ -0,0 +1,471 @@
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
public sealed class PhpComposerManifestReaderTests : IDisposable
{
private readonly string _testDir;
public PhpComposerManifestReaderTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"manifest-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
}
#region PhpComposerManifestReader Tests
[Fact]
public async Task LoadAsync_NullPath_ReturnsNull()
{
var result = await PhpComposerManifestReader.LoadAsync(null!, CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task LoadAsync_EmptyPath_ReturnsNull()
{
var result = await PhpComposerManifestReader.LoadAsync("", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task LoadAsync_NonExistentDirectory_ReturnsNull()
{
var result = await PhpComposerManifestReader.LoadAsync("/nonexistent/path", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task LoadAsync_NoComposerJson_ReturnsNull()
{
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task LoadAsync_InvalidJson_ReturnsNull()
{
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), "{ invalid json }");
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task LoadAsync_ValidManifest_ParsesBasicFields()
{
var manifest = @"{
""name"": ""vendor/package"",
""description"": ""A test package"",
""type"": ""library"",
""version"": ""1.2.3"",
""license"": ""MIT""
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("vendor/package", result.Name);
Assert.Equal("A test package", result.Description);
Assert.Equal("library", result.Type);
Assert.Equal("1.2.3", result.Version);
Assert.Equal("MIT", result.License);
}
[Fact]
public async Task LoadAsync_ParsesLicenseArray()
{
var manifest = @"{
""name"": ""vendor/package"",
""license"": [""MIT"", ""Apache-2.0""]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("MIT OR Apache-2.0", result.License);
}
[Fact]
public async Task LoadAsync_ParsesAuthors()
{
var manifest = @"{
""name"": ""vendor/package"",
""authors"": [
{ ""name"": ""John Doe"", ""email"": ""john@example.com"" },
{ ""name"": ""Jane Smith"" }
]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(2, result.Authors.Count);
Assert.Contains("John Doe <john@example.com>", result.Authors);
Assert.Contains("Jane Smith", result.Authors);
}
[Fact]
public async Task LoadAsync_ParsesRequireDependencies()
{
var manifest = @"{
""name"": ""vendor/package"",
""require"": {
""php"": "">=8.1"",
""ext-json"": ""*"",
""monolog/monolog"": ""^3.0""
}
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(3, result.Require.Count);
Assert.Equal(">=8.1", result.Require["php"]);
Assert.Equal("*", result.Require["ext-json"]);
Assert.Equal("^3.0", result.Require["monolog/monolog"]);
}
[Fact]
public async Task LoadAsync_ParsesRequireDevDependencies()
{
var manifest = @"{
""name"": ""vendor/package"",
""require-dev"": {
""phpunit/phpunit"": ""^10.0"",
""phpstan/phpstan"": ""^1.0""
}
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(2, result.RequireDev.Count);
Assert.Equal("^10.0", result.RequireDev["phpunit/phpunit"]);
Assert.Equal("^1.0", result.RequireDev["phpstan/phpstan"]);
}
[Fact]
public async Task LoadAsync_ParsesAutoloadPsr4()
{
var manifest = @"{
""name"": ""vendor/package"",
""autoload"": {
""psr-4"": {
""Vendor\\Package\\"": ""src/""
}
}
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.NotEmpty(result.Autoload.Psr4);
Assert.Contains("Vendor\\Package\\->src/", result.Autoload.Psr4);
}
[Fact]
public async Task LoadAsync_ParsesAutoloadClassmap()
{
var manifest = @"{
""name"": ""vendor/package"",
""autoload"": {
""classmap"": [""lib/"", ""src/Legacy/""]
}
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(2, result.Autoload.Classmap.Count);
Assert.Contains("lib/", result.Autoload.Classmap);
Assert.Contains("src/Legacy/", result.Autoload.Classmap);
}
[Fact]
public async Task LoadAsync_ParsesAutoloadFiles()
{
var manifest = @"{
""name"": ""vendor/package"",
""autoload"": {
""files"": [""src/helpers.php"", ""src/functions.php""]
}
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(2, result.Autoload.Files.Count);
Assert.Contains("src/helpers.php", result.Autoload.Files);
Assert.Contains("src/functions.php", result.Autoload.Files);
}
[Fact]
public async Task LoadAsync_ParsesScripts()
{
var manifest = @"{
""name"": ""vendor/package"",
""scripts"": {
""test"": ""phpunit"",
""lint"": ""phpstan analyse""
}
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(2, result.Scripts.Count);
Assert.Equal("phpunit", result.Scripts["test"]);
Assert.Equal("phpstan analyse", result.Scripts["lint"]);
}
[Fact]
public async Task LoadAsync_ParsesScriptsArray()
{
var manifest = @"{
""name"": ""vendor/package"",
""scripts"": {
""check"": [""phpstan analyse"", ""phpunit""]
}
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Single(result.Scripts);
Assert.Contains("phpstan analyse", result.Scripts["check"]);
Assert.Contains("phpunit", result.Scripts["check"]);
}
[Fact]
public async Task LoadAsync_ParsesBin()
{
var manifest = @"{
""name"": ""vendor/package"",
""bin"": [""bin/console"", ""bin/migrate""]
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(2, result.Bin.Count);
Assert.Equal("bin/console", result.Bin["console"]);
Assert.Equal("bin/migrate", result.Bin["migrate"]);
}
[Fact]
public async Task LoadAsync_ParsesMinimumStability()
{
var manifest = @"{
""name"": ""vendor/package"",
""minimum-stability"": ""dev""
}";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("dev", result.MinimumStability);
}
[Fact]
public async Task LoadAsync_ComputesSha256()
{
var manifest = @"{ ""name"": ""vendor/package"" }";
await File.WriteAllTextAsync(Path.Combine(_testDir, "composer.json"), manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.NotNull(result.Sha256);
Assert.Equal(64, result.Sha256.Length);
Assert.True(result.Sha256.All(c => char.IsAsciiHexDigitLower(c)));
}
[Fact]
public async Task LoadAsync_SetsManifestPath()
{
var manifest = @"{ ""name"": ""vendor/package"" }";
var manifestPath = Path.Combine(_testDir, "composer.json");
await File.WriteAllTextAsync(manifestPath, manifest);
var result = await PhpComposerManifestReader.LoadAsync(_testDir, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(manifestPath, result.ManifestPath);
}
#endregion
#region PhpComposerManifest Tests
[Fact]
public void RequiredPhpVersion_ReturnsPhpConstraint()
{
var manifest = new PhpComposerManifest(
"/test/composer.json",
"vendor/package",
null, null, null, null,
Array.Empty<string>(),
new Dictionary<string, string> { { "php", ">=8.1" } },
new Dictionary<string, string>(),
ComposerAutoloadData.Empty,
ComposerAutoloadData.Empty,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
null, null);
Assert.Equal(">=8.1", manifest.RequiredPhpVersion);
}
[Fact]
public void RequiredPhpVersion_ReturnsNullWhenNotSpecified()
{
var manifest = new PhpComposerManifest(
"/test/composer.json",
"vendor/package",
null, null, null, null,
Array.Empty<string>(),
new Dictionary<string, string>(),
new Dictionary<string, string>(),
ComposerAutoloadData.Empty,
ComposerAutoloadData.Empty,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
null, null);
Assert.Null(manifest.RequiredPhpVersion);
}
[Fact]
public void RequiredExtensions_ReturnsExtensionsList()
{
var manifest = new PhpComposerManifest(
"/test/composer.json",
"vendor/package",
null, null, null, null,
Array.Empty<string>(),
new Dictionary<string, string>
{
{ "ext-json", "*" },
{ "ext-mbstring", "*" },
{ "ext-curl", "*" },
{ "monolog/monolog", "^3.0" }
},
new Dictionary<string, string>(),
ComposerAutoloadData.Empty,
ComposerAutoloadData.Empty,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
null, null);
var extensions = manifest.RequiredExtensions.ToList();
Assert.Equal(3, extensions.Count);
Assert.Contains("json", extensions);
Assert.Contains("mbstring", extensions);
Assert.Contains("curl", extensions);
}
[Fact]
public void CreateMetadata_IncludesAllFields()
{
var manifest = new PhpComposerManifest(
"/test/composer.json",
"vendor/package",
"Test package",
"library",
"1.0.0",
"MIT",
new[] { "Author" },
new Dictionary<string, string>
{
{ "php", ">=8.1" },
{ "ext-json", "*" },
{ "monolog/monolog", "^3.0" }
},
new Dictionary<string, string> { { "phpunit/phpunit", "^10.0" } },
ComposerAutoloadData.Empty,
ComposerAutoloadData.Empty,
new Dictionary<string, string>(),
new Dictionary<string, string>(),
null,
"abc123def456");
var metadata = manifest.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("vendor/package", metadata["composer.manifest.name"]);
Assert.Equal("library", metadata["composer.manifest.type"]);
Assert.Equal("MIT", metadata["composer.manifest.license"]);
Assert.Equal(">=8.1", metadata["composer.manifest.php_version"]);
Assert.Equal("json", metadata["composer.manifest.extensions"]);
Assert.Equal("3", metadata["composer.manifest.require_count"]);
Assert.Equal("1", metadata["composer.manifest.require_dev_count"]);
Assert.Equal("abc123def456", metadata["composer.manifest.sha256"]);
}
[Fact]
public void Empty_HasNullValues()
{
var empty = PhpComposerManifest.Empty;
Assert.Equal(string.Empty, empty.ManifestPath);
Assert.Null(empty.Name);
Assert.Null(empty.Description);
Assert.Null(empty.Type);
Assert.Null(empty.Version);
Assert.Null(empty.License);
Assert.Empty(empty.Authors);
Assert.Empty(empty.Require);
Assert.Empty(empty.RequireDev);
Assert.Null(empty.MinimumStability);
Assert.Null(empty.Sha256);
}
#endregion
#region ComposerAutoloadData Tests
[Fact]
public void ComposerAutoloadData_Empty_HasEmptyCollections()
{
var empty = ComposerAutoloadData.Empty;
Assert.Empty(empty.Psr4);
Assert.Empty(empty.Classmap);
Assert.Empty(empty.Files);
}
#endregion
}

View File

@@ -0,0 +1,417 @@
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
public sealed class PhpExtensionScannerTests
{
#region PhpExtension Tests
[Fact]
public void PhpExtension_RecordProperties_SetCorrectly()
{
var extension = new PhpExtension(
"pdo_mysql",
"8.2.0",
"/usr/lib/php/extensions/pdo_mysql.so",
PhpExtensionSource.PhpIni,
false,
PhpExtensionCategory.Database);
Assert.Equal("pdo_mysql", extension.Name);
Assert.Equal("8.2.0", extension.Version);
Assert.Equal("/usr/lib/php/extensions/pdo_mysql.so", extension.LibraryPath);
Assert.Equal(PhpExtensionSource.PhpIni, extension.Source);
Assert.False(extension.IsBundled);
Assert.Equal(PhpExtensionCategory.Database, extension.Category);
}
[Fact]
public void PhpExtension_CreateMetadata_IncludesAllFields()
{
var extension = new PhpExtension(
"openssl",
"3.0.0",
"/usr/lib/php/openssl.so",
PhpExtensionSource.ConfD,
false,
PhpExtensionCategory.Crypto);
var metadata = extension.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("openssl", metadata["extension.name"]);
Assert.Equal("3.0.0", metadata["extension.version"]);
Assert.Equal("/usr/lib/php/openssl.so", metadata["extension.library"]);
Assert.Equal("confd", metadata["extension.source"]);
Assert.Equal("false", metadata["extension.bundled"]);
Assert.Equal("crypto", metadata["extension.category"]);
}
[Fact]
public void PhpExtension_BundledExtension_MarkedCorrectly()
{
var extension = new PhpExtension(
"json",
null,
null,
PhpExtensionSource.Bundled,
true,
PhpExtensionCategory.Core);
Assert.True(extension.IsBundled);
Assert.Equal(PhpExtensionSource.Bundled, extension.Source);
}
#endregion
#region PhpExtensionSource Tests
[Fact]
public void PhpExtensionSource_HasExpectedValues()
{
Assert.Equal(0, (int)PhpExtensionSource.PhpIni);
Assert.Equal(1, (int)PhpExtensionSource.ConfD);
Assert.Equal(2, (int)PhpExtensionSource.Bundled);
Assert.Equal(3, (int)PhpExtensionSource.Container);
Assert.Equal(4, (int)PhpExtensionSource.UsageDetected);
}
#endregion
#region PhpExtensionCategory Tests
[Fact]
public void PhpExtensionCategory_HasExpectedValues()
{
Assert.Equal(0, (int)PhpExtensionCategory.Core);
Assert.Equal(1, (int)PhpExtensionCategory.Database);
Assert.Equal(2, (int)PhpExtensionCategory.Crypto);
Assert.Equal(3, (int)PhpExtensionCategory.Image);
Assert.Equal(4, (int)PhpExtensionCategory.Compression);
Assert.Equal(5, (int)PhpExtensionCategory.Xml);
Assert.Equal(6, (int)PhpExtensionCategory.Cache);
Assert.Equal(7, (int)PhpExtensionCategory.Debug);
Assert.Equal(8, (int)PhpExtensionCategory.Network);
Assert.Equal(9, (int)PhpExtensionCategory.Text);
Assert.Equal(10, (int)PhpExtensionCategory.Other);
}
#endregion
#region PhpEnvironmentSettings Tests
[Fact]
public void PhpEnvironmentSettings_Empty_HasDefaults()
{
var settings = PhpEnvironmentSettings.Empty;
Assert.Empty(settings.Extensions);
Assert.NotNull(settings.Security);
Assert.NotNull(settings.Upload);
Assert.NotNull(settings.Session);
Assert.NotNull(settings.Error);
Assert.NotNull(settings.Limits);
Assert.Empty(settings.WebServerSettings);
}
[Fact]
public void PhpEnvironmentSettings_HasSettings_TrueWithExtensions()
{
var extensions = new[] { new PhpExtension("pdo", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Database) };
var settings = new PhpEnvironmentSettings(
extensions,
PhpSecuritySettings.Default,
PhpUploadSettings.Default,
PhpSessionSettings.Default,
PhpErrorSettings.Default,
PhpResourceLimits.Default,
new Dictionary<string, string>());
Assert.True(settings.HasSettings);
}
[Fact]
public void PhpEnvironmentSettings_CreateMetadata_IncludesExtensionCount()
{
var extensions = new[]
{
new PhpExtension("pdo", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Database),
new PhpExtension("openssl", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Crypto),
new PhpExtension("gd", null, null, PhpExtensionSource.PhpIni, false, PhpExtensionCategory.Image)
};
var settings = new PhpEnvironmentSettings(
extensions,
PhpSecuritySettings.Default,
PhpUploadSettings.Default,
PhpSessionSettings.Default,
PhpErrorSettings.Default,
PhpResourceLimits.Default,
new Dictionary<string, string>());
var metadata = settings.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("3", metadata["env.extension_count"]);
Assert.Equal("1", metadata["env.extensions_database"]);
Assert.Equal("1", metadata["env.extensions_crypto"]);
Assert.Equal("1", metadata["env.extensions_image"]);
}
#endregion
#region PhpSecuritySettings Tests
[Fact]
public void PhpSecuritySettings_Default_HasExpectedValues()
{
var security = PhpSecuritySettings.Default;
Assert.Empty(security.DisabledFunctions);
Assert.Empty(security.DisabledClasses);
Assert.False(security.OpenBasedir);
Assert.Null(security.OpenBasedirValue);
Assert.True(security.AllowUrlFopen);
Assert.False(security.AllowUrlInclude);
Assert.True(security.ExposePhp);
Assert.False(security.RegisterGlobals);
}
[Fact]
public void PhpSecuritySettings_CreateMetadata_IncludesDisabledFunctions()
{
var security = new PhpSecuritySettings(
new[] { "exec", "shell_exec", "system", "passthru" },
new[] { "Directory" },
true,
"/var/www",
false,
false,
false,
false);
var metadata = security.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("4", metadata["security.disabled_functions_count"]);
Assert.Contains("exec", metadata["security.disabled_functions"]);
Assert.Contains("shell_exec", metadata["security.disabled_functions"]);
Assert.Equal("1", metadata["security.disabled_classes_count"]);
Assert.Equal("true", metadata["security.open_basedir"]);
Assert.Equal("false", metadata["security.allow_url_fopen"]);
Assert.Equal("false", metadata["security.allow_url_include"]);
Assert.Equal("false", metadata["security.expose_php"]);
}
[Fact]
public void PhpSecuritySettings_DangerousConfiguration_Detectable()
{
var security = new PhpSecuritySettings(
Array.Empty<string>(),
Array.Empty<string>(),
false,
null,
true,
true, // allow_url_include is dangerous!
true,
false);
Assert.True(security.AllowUrlInclude);
Assert.True(security.AllowUrlFopen);
Assert.False(security.OpenBasedir);
}
#endregion
#region PhpUploadSettings Tests
[Fact]
public void PhpUploadSettings_Default_HasExpectedValues()
{
var upload = PhpUploadSettings.Default;
Assert.True(upload.FileUploads);
Assert.Equal("2M", upload.MaxFileSize);
Assert.Equal("8M", upload.MaxPostSize);
Assert.Equal(20, upload.MaxFileUploads);
Assert.Null(upload.UploadTmpDir);
}
[Fact]
public void PhpUploadSettings_CreateMetadata_IncludesAllFields()
{
var upload = new PhpUploadSettings(
true,
"64M",
"128M",
50,
"/tmp/uploads");
var metadata = upload.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("true", metadata["upload.enabled"]);
Assert.Equal("64M", metadata["upload.max_file_size"]);
Assert.Equal("128M", metadata["upload.max_post_size"]);
Assert.Equal("50", metadata["upload.max_files"]);
}
[Fact]
public void PhpUploadSettings_DisabledUploads()
{
var upload = new PhpUploadSettings(false, null, null, 0, null);
Assert.False(upload.FileUploads);
Assert.Equal(0, upload.MaxFileUploads);
}
#endregion
#region PhpSessionSettings Tests
[Fact]
public void PhpSessionSettings_Default_HasExpectedValues()
{
var session = PhpSessionSettings.Default;
Assert.Equal("files", session.SaveHandler);
Assert.Null(session.SavePath);
Assert.False(session.CookieHttponly);
Assert.False(session.CookieSecure);
Assert.Null(session.CookieSamesite);
}
[Fact]
public void PhpSessionSettings_CreateMetadata_IncludesAllFields()
{
var session = new PhpSessionSettings(
"redis",
"tcp://localhost:6379",
true,
true,
"Strict");
var metadata = session.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("redis", metadata["session.save_handler"]);
Assert.Equal("true", metadata["session.cookie_httponly"]);
Assert.Equal("true", metadata["session.cookie_secure"]);
Assert.Equal("Strict", metadata["session.cookie_samesite"]);
}
[Fact]
public void PhpSessionSettings_SecureConfiguration()
{
var session = new PhpSessionSettings(
"files",
"/var/lib/php/sessions",
true,
true,
"Lax");
Assert.True(session.CookieHttponly);
Assert.True(session.CookieSecure);
Assert.Equal("Lax", session.CookieSamesite);
}
#endregion
#region PhpErrorSettings Tests
[Fact]
public void PhpErrorSettings_Default_HasExpectedValues()
{
var error = PhpErrorSettings.Default;
Assert.False(error.DisplayErrors);
Assert.False(error.DisplayStartupErrors);
Assert.True(error.LogErrors);
Assert.Equal("E_ALL", error.ErrorReporting);
}
[Fact]
public void PhpErrorSettings_CreateMetadata_IncludesAllFields()
{
var error = new PhpErrorSettings(
true,
true,
false,
"E_ALL & ~E_NOTICE");
var metadata = error.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("true", metadata["error.display_errors"]);
Assert.Equal("true", metadata["error.display_startup_errors"]);
Assert.Equal("false", metadata["error.log_errors"]);
Assert.Equal("E_ALL & ~E_NOTICE", metadata["error.error_reporting"]);
}
[Fact]
public void PhpErrorSettings_ProductionConfiguration()
{
var error = new PhpErrorSettings(false, false, true, "E_ALL");
Assert.False(error.DisplayErrors);
Assert.False(error.DisplayStartupErrors);
Assert.True(error.LogErrors);
}
[Fact]
public void PhpErrorSettings_DevelopmentConfiguration()
{
var error = new PhpErrorSettings(true, true, true, "E_ALL");
Assert.True(error.DisplayErrors);
Assert.True(error.DisplayStartupErrors);
Assert.True(error.LogErrors);
}
#endregion
#region PhpResourceLimits Tests
[Fact]
public void PhpResourceLimits_Default_HasExpectedValues()
{
var limits = PhpResourceLimits.Default;
Assert.Equal("128M", limits.MemoryLimit);
Assert.Equal(30, limits.MaxExecutionTime);
Assert.Equal(60, limits.MaxInputTime);
Assert.Equal("1000", limits.MaxInputVars);
}
[Fact]
public void PhpResourceLimits_CreateMetadata_IncludesAllFields()
{
var limits = new PhpResourceLimits(
"512M",
120,
180,
"5000");
var metadata = limits.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("512M", metadata["limits.memory_limit"]);
Assert.Equal("120", metadata["limits.max_execution_time"]);
Assert.Equal("180", metadata["limits.max_input_time"]);
Assert.Equal("5000", metadata["limits.max_input_vars"]);
}
[Fact]
public void PhpResourceLimits_HighPerformanceConfiguration()
{
var limits = new PhpResourceLimits("2G", 300, 300, "10000");
Assert.Equal("2G", limits.MemoryLimit);
Assert.Equal(300, limits.MaxExecutionTime);
}
[Fact]
public void PhpResourceLimits_RestrictedConfiguration()
{
var limits = new PhpResourceLimits("64M", 10, 10, "500");
Assert.Equal("64M", limits.MemoryLimit);
Assert.Equal(10, limits.MaxExecutionTime);
Assert.Equal(10, limits.MaxInputTime);
}
#endregion
}

View File

@@ -0,0 +1,421 @@
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
public sealed class PhpFrameworkSurfaceScannerTests
{
#region PhpFrameworkSurface Tests
[Fact]
public void Empty_ReturnsEmptySurface()
{
var surface = PhpFrameworkSurface.Empty;
Assert.Empty(surface.Routes);
Assert.Empty(surface.Controllers);
Assert.Empty(surface.Middlewares);
Assert.Empty(surface.CliCommands);
Assert.Empty(surface.CronJobs);
Assert.Empty(surface.EventListeners);
Assert.False(surface.HasSurface);
}
[Fact]
public void HasSurface_TrueWhenRoutesPresent()
{
var routes = new[] { CreateRoute("/api/users", "GET") };
var surface = new PhpFrameworkSurface(
routes,
Array.Empty<PhpController>(),
Array.Empty<PhpMiddleware>(),
Array.Empty<PhpCliCommand>(),
Array.Empty<PhpCronJob>(),
Array.Empty<PhpEventListener>());
Assert.True(surface.HasSurface);
}
[Fact]
public void HasSurface_TrueWhenControllersPresent()
{
var controllers = new[] { new PhpController("UserController", "App\\Http\\Controllers", "app/Http/Controllers/UserController.php", new[] { "index", "show" }, true) };
var surface = new PhpFrameworkSurface(
Array.Empty<PhpRoute>(),
controllers,
Array.Empty<PhpMiddleware>(),
Array.Empty<PhpCliCommand>(),
Array.Empty<PhpCronJob>(),
Array.Empty<PhpEventListener>());
Assert.True(surface.HasSurface);
}
[Fact]
public void HasSurface_TrueWhenMiddlewaresPresent()
{
var middlewares = new[] { new PhpMiddleware("AuthMiddleware", "App\\Http\\Middleware", "app/Http/Middleware/AuthMiddleware.php", PhpMiddlewareKind.Auth) };
var surface = new PhpFrameworkSurface(
Array.Empty<PhpRoute>(),
Array.Empty<PhpController>(),
middlewares,
Array.Empty<PhpCliCommand>(),
Array.Empty<PhpCronJob>(),
Array.Empty<PhpEventListener>());
Assert.True(surface.HasSurface);
}
[Fact]
public void HasSurface_TrueWhenCliCommandsPresent()
{
var commands = new[] { new PhpCliCommand("app:sync", "Sync data", "SyncCommand", "app/Console/Commands/SyncCommand.php") };
var surface = new PhpFrameworkSurface(
Array.Empty<PhpRoute>(),
Array.Empty<PhpController>(),
Array.Empty<PhpMiddleware>(),
commands,
Array.Empty<PhpCronJob>(),
Array.Empty<PhpEventListener>());
Assert.True(surface.HasSurface);
}
[Fact]
public void HasSurface_TrueWhenCronJobsPresent()
{
var cronJobs = new[] { new PhpCronJob("hourly", "ReportCommand", "Generate hourly report", "app/Console/Kernel.php") };
var surface = new PhpFrameworkSurface(
Array.Empty<PhpRoute>(),
Array.Empty<PhpController>(),
Array.Empty<PhpMiddleware>(),
Array.Empty<PhpCliCommand>(),
cronJobs,
Array.Empty<PhpEventListener>());
Assert.True(surface.HasSurface);
}
[Fact]
public void HasSurface_TrueWhenEventListenersPresent()
{
var listeners = new[] { new PhpEventListener("UserRegistered", "SendWelcomeEmail", 0, "app/Providers/EventServiceProvider.php") };
var surface = new PhpFrameworkSurface(
Array.Empty<PhpRoute>(),
Array.Empty<PhpController>(),
Array.Empty<PhpMiddleware>(),
Array.Empty<PhpCliCommand>(),
Array.Empty<PhpCronJob>(),
listeners);
Assert.True(surface.HasSurface);
}
[Fact]
public void CreateMetadata_IncludesAllCounts()
{
var routes = new[] { CreateRoute("/api/users", "GET"), CreateRoute("/api/users/{id}", "GET", true) };
var controllers = new[] { new PhpController("UserController", null, "UserController.php", Array.Empty<string>(), false) };
var middlewares = new[] { new PhpMiddleware("AuthMiddleware", null, "AuthMiddleware.php", PhpMiddlewareKind.Auth) };
var commands = new[] { new PhpCliCommand("app:sync", null, "SyncCommand", "SyncCommand.php") };
var cronJobs = new[] { new PhpCronJob("hourly", "Report", null, "Kernel.php") };
var listeners = new[] { new PhpEventListener("Event", "Handler", 0, "Provider.php") };
var surface = new PhpFrameworkSurface(routes, controllers, middlewares, commands, cronJobs, listeners);
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("2", metadata["surface.route_count"]);
Assert.Equal("1", metadata["surface.controller_count"]);
Assert.Equal("1", metadata["surface.middleware_count"]);
Assert.Equal("1", metadata["surface.cli_command_count"]);
Assert.Equal("1", metadata["surface.cron_job_count"]);
Assert.Equal("1", metadata["surface.event_listener_count"]);
}
[Fact]
public void CreateMetadata_IncludesHttpMethods()
{
var routes = new[]
{
CreateRoute("/users", "GET"),
CreateRoute("/users", "POST"),
CreateRoute("/users/{id}", "PUT"),
CreateRoute("/users/{id}", "DELETE")
};
var surface = new PhpFrameworkSurface(
routes,
Array.Empty<PhpController>(),
Array.Empty<PhpMiddleware>(),
Array.Empty<PhpCliCommand>(),
Array.Empty<PhpCronJob>(),
Array.Empty<PhpEventListener>());
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Contains("GET", metadata["surface.http_methods"]);
Assert.Contains("POST", metadata["surface.http_methods"]);
Assert.Contains("PUT", metadata["surface.http_methods"]);
Assert.Contains("DELETE", metadata["surface.http_methods"]);
}
[Fact]
public void CreateMetadata_CountsProtectedAndPublicRoutes()
{
var routes = new[]
{
CreateRoute("/public", "GET", requiresAuth: false),
CreateRoute("/api/users", "GET", requiresAuth: true),
CreateRoute("/api/admin", "GET", requiresAuth: true)
};
var surface = new PhpFrameworkSurface(
routes,
Array.Empty<PhpController>(),
Array.Empty<PhpMiddleware>(),
Array.Empty<PhpCliCommand>(),
Array.Empty<PhpCronJob>(),
Array.Empty<PhpEventListener>());
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("2", metadata["surface.protected_routes"]);
Assert.Equal("1", metadata["surface.public_routes"]);
}
[Fact]
public void CreateMetadata_IncludesRoutePatterns()
{
var routes = new[]
{
CreateRoute("/api/v1/users", "GET"),
CreateRoute("/api/v1/posts", "GET"),
CreateRoute("/api/v1/comments", "GET")
};
var surface = new PhpFrameworkSurface(
routes,
Array.Empty<PhpController>(),
Array.Empty<PhpMiddleware>(),
Array.Empty<PhpCliCommand>(),
Array.Empty<PhpCronJob>(),
Array.Empty<PhpEventListener>());
var metadata = surface.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.True(metadata.ContainsKey("surface.route_patterns"));
Assert.Contains("/api/v1/users", metadata["surface.route_patterns"]);
}
#endregion
#region PhpRoute Tests
[Fact]
public void PhpRoute_RecordProperties_SetCorrectly()
{
var route = new PhpRoute(
"/api/users/{id}",
new[] { "GET", "HEAD" },
"UserController",
"show",
"users.show",
true,
new[] { "auth", "throttle" },
"routes/api.php",
42);
Assert.Equal("/api/users/{id}", route.Pattern);
Assert.Equal(2, route.Methods.Count);
Assert.Contains("GET", route.Methods);
Assert.Contains("HEAD", route.Methods);
Assert.Equal("UserController", route.Controller);
Assert.Equal("show", route.Action);
Assert.Equal("users.show", route.Name);
Assert.True(route.RequiresAuth);
Assert.Equal(2, route.Middlewares.Count);
Assert.Equal("routes/api.php", route.SourceFile);
Assert.Equal(42, route.SourceLine);
}
#endregion
#region PhpController Tests
[Fact]
public void PhpController_RecordProperties_SetCorrectly()
{
var controller = new PhpController(
"UserController",
"App\\Http\\Controllers",
"app/Http/Controllers/UserController.php",
new[] { "index", "show", "store", "update", "destroy" },
true);
Assert.Equal("UserController", controller.ClassName);
Assert.Equal("App\\Http\\Controllers", controller.Namespace);
Assert.Equal("app/Http/Controllers/UserController.php", controller.SourceFile);
Assert.Equal(5, controller.Actions.Count);
Assert.True(controller.IsApiController);
}
[Fact]
public void PhpController_IsApiController_FalseForWebController()
{
var controller = new PhpController(
"HomeController",
"App\\Http\\Controllers",
"app/Http/Controllers/HomeController.php",
new[] { "index", "about" },
false);
Assert.False(controller.IsApiController);
}
#endregion
#region PhpMiddleware Tests
[Fact]
public void PhpMiddleware_RecordProperties_SetCorrectly()
{
var middleware = new PhpMiddleware(
"AuthenticateMiddleware",
"App\\Http\\Middleware",
"app/Http/Middleware/AuthenticateMiddleware.php",
PhpMiddlewareKind.Auth);
Assert.Equal("AuthenticateMiddleware", middleware.ClassName);
Assert.Equal("App\\Http\\Middleware", middleware.Namespace);
Assert.Equal("app/Http/Middleware/AuthenticateMiddleware.php", middleware.SourceFile);
Assert.Equal(PhpMiddlewareKind.Auth, middleware.Kind);
}
[Fact]
public void PhpMiddlewareKind_HasExpectedValues()
{
Assert.Equal(0, (int)PhpMiddlewareKind.General);
Assert.Equal(1, (int)PhpMiddlewareKind.Auth);
Assert.Equal(2, (int)PhpMiddlewareKind.Cors);
Assert.Equal(3, (int)PhpMiddlewareKind.RateLimit);
Assert.Equal(4, (int)PhpMiddlewareKind.Logging);
Assert.Equal(5, (int)PhpMiddlewareKind.Security);
}
#endregion
#region PhpCliCommand Tests
[Fact]
public void PhpCliCommand_RecordProperties_SetCorrectly()
{
var command = new PhpCliCommand(
"app:import-data",
"Import data from external source",
"ImportDataCommand",
"app/Console/Commands/ImportDataCommand.php");
Assert.Equal("app:import-data", command.Name);
Assert.Equal("Import data from external source", command.Description);
Assert.Equal("ImportDataCommand", command.ClassName);
Assert.Equal("app/Console/Commands/ImportDataCommand.php", command.SourceFile);
}
[Fact]
public void PhpCliCommand_NullDescription_Allowed()
{
var command = new PhpCliCommand(
"app:sync",
null,
"SyncCommand",
"SyncCommand.php");
Assert.Null(command.Description);
}
#endregion
#region PhpCronJob Tests
[Fact]
public void PhpCronJob_RecordProperties_SetCorrectly()
{
var cronJob = new PhpCronJob(
"daily",
"CleanupOldData",
"Remove data older than 30 days",
"app/Console/Kernel.php");
Assert.Equal("daily", cronJob.Schedule);
Assert.Equal("CleanupOldData", cronJob.Handler);
Assert.Equal("Remove data older than 30 days", cronJob.Description);
Assert.Equal("app/Console/Kernel.php", cronJob.SourceFile);
}
[Fact]
public void PhpCronJob_VariousSchedules()
{
var jobs = new[]
{
new PhpCronJob("hourly", "HourlyJob", null, "Kernel.php"),
new PhpCronJob("daily", "DailyJob", null, "Kernel.php"),
new PhpCronJob("weekly", "WeeklyJob", null, "Kernel.php"),
new PhpCronJob("monthly", "MonthlyJob", null, "Kernel.php"),
new PhpCronJob("everyMinute", "MinuteJob", null, "Kernel.php"),
new PhpCronJob("everyFiveMinutes", "FiveMinJob", null, "Kernel.php")
};
Assert.Equal(6, jobs.Length);
Assert.All(jobs, j => Assert.NotNull(j.Schedule));
}
#endregion
#region PhpEventListener Tests
[Fact]
public void PhpEventListener_RecordProperties_SetCorrectly()
{
var listener = new PhpEventListener(
"App\\Events\\UserRegistered",
"App\\Listeners\\SendWelcomeEmail",
10,
"app/Providers/EventServiceProvider.php");
Assert.Equal("App\\Events\\UserRegistered", listener.EventName);
Assert.Equal("App\\Listeners\\SendWelcomeEmail", listener.Handler);
Assert.Equal(10, listener.Priority);
Assert.Equal("app/Providers/EventServiceProvider.php", listener.SourceFile);
}
[Fact]
public void PhpEventListener_DefaultPriority()
{
var listener = new PhpEventListener(
"EventName",
"Handler",
0,
"Provider.php");
Assert.Equal(0, listener.Priority);
}
#endregion
#region Helper Methods
private static PhpRoute CreateRoute(string pattern, string method, bool requiresAuth = false)
{
return new PhpRoute(
pattern,
new[] { method },
null,
null,
null,
requiresAuth,
Array.Empty<string>(),
"routes/web.php",
1);
}
#endregion
}

View File

@@ -0,0 +1,485 @@
using System.Text;
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Php.Tests.Internal;
public sealed class PhpPharScannerTests : IDisposable
{
private readonly string _testDir;
public PhpPharScannerTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"phar-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
}
#region PhpPharScanner Tests
[Fact]
public async Task ScanFileAsync_NonExistentFile_ReturnsNull()
{
var result = await PhpPharScanner.ScanFileAsync(
Path.Combine(_testDir, "nonexistent.phar"),
"nonexistent.phar",
CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task ScanFileAsync_NullPath_ReturnsNull()
{
var result = await PhpPharScanner.ScanFileAsync(null!, "test.phar", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task ScanFileAsync_EmptyPath_ReturnsNull()
{
var result = await PhpPharScanner.ScanFileAsync("", "test.phar", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task ScanFileAsync_InvalidPharFile_ReturnsNull()
{
var filePath = Path.Combine(_testDir, "invalid.phar");
await File.WriteAllTextAsync(filePath, "This is not a valid PHAR file");
var result = await PhpPharScanner.ScanFileAsync(filePath, "invalid.phar", CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task ScanFileAsync_MinimalPhar_ParsesStub()
{
// Create a minimal PHAR structure with __HALT_COMPILER();
var stub = "<?php\necho 'Hello';\n__HALT_COMPILER();";
var pharContent = CreateMinimalPharBytes(stub);
var filePath = Path.Combine(_testDir, "minimal.phar");
await File.WriteAllBytesAsync(filePath, pharContent);
var result = await PhpPharScanner.ScanFileAsync(filePath, "minimal.phar", CancellationToken.None);
// May return null if manifest parsing fails, but should not throw
// The minimal PHAR may not have a valid manifest
if (result is not null)
{
Assert.Contains("__HALT_COMPILER();", result.Stub);
}
}
[Fact]
public async Task ScanFileAsync_ComputesSha256()
{
var stub = "<?php\n__HALT_COMPILER();";
var pharContent = CreateMinimalPharBytes(stub);
var filePath = Path.Combine(_testDir, "hash.phar");
await File.WriteAllBytesAsync(filePath, pharContent);
var result = await PhpPharScanner.ScanFileAsync(filePath, "hash.phar", CancellationToken.None);
if (result is not null)
{
Assert.NotNull(result.Sha256);
Assert.Equal(64, result.Sha256.Length);
Assert.True(result.Sha256.All(c => char.IsAsciiHexDigitLower(c)));
}
}
#endregion
#region PhpPharArchive Tests
[Fact]
public void PhpPharArchive_Constructor_NormalizesBackslashes()
{
var archive = new PhpPharArchive(
@"C:\path\to\file.phar",
@"vendor\file.phar",
null,
null,
Array.Empty<PhpPharEntry>(),
null);
Assert.Equal("C:/path/to/file.phar", archive.FilePath);
Assert.Equal("vendor/file.phar", archive.RelativePath);
}
[Fact]
public void PhpPharArchive_Constructor_RequiresFilePath()
{
Assert.Throws<ArgumentException>(() => new PhpPharArchive(
"",
"test.phar",
null,
null,
Array.Empty<PhpPharEntry>(),
null));
}
[Fact]
public void PhpPharArchive_HasEmbeddedVendor_TrueForVendorPath()
{
var entries = new[]
{
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
Assert.True(archive.HasEmbeddedVendor);
}
[Fact]
public void PhpPharArchive_HasEmbeddedVendor_FalseWithoutVendor()
{
var entries = new[]
{
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("lib/Helper.php", 100, 80, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
Assert.False(archive.HasEmbeddedVendor);
}
[Fact]
public void PhpPharArchive_HasComposerFiles_TrueForComposerJson()
{
var entries = new[]
{
new PhpPharEntry("composer.json", 500, 400, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
Assert.True(archive.HasComposerFiles);
}
[Fact]
public void PhpPharArchive_HasComposerFiles_TrueForComposerLock()
{
var entries = new[]
{
new PhpPharEntry("composer.lock", 5000, 4000, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
Assert.True(archive.HasComposerFiles);
}
[Fact]
public void PhpPharArchive_FileCount_ReturnsCorrectCount()
{
var entries = new[]
{
new PhpPharEntry("file1.php", 100, 80, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("file2.php", 200, 150, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("file3.php", 300, 250, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
Assert.Equal(3, archive.FileCount);
}
[Fact]
public void PhpPharArchive_TotalUncompressedSize_SumsCorrectly()
{
var entries = new[]
{
new PhpPharEntry("file1.php", 100, 80, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("file2.php", 200, 150, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("file3.php", 300, 250, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
Assert.Equal(600, archive.TotalUncompressedSize);
}
[Fact]
public void PhpPharArchive_CreateMetadata_IncludesBasicInfo()
{
var entries = new[]
{
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("composer.json", 200, 150, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, "abc123");
var metadata = archive.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("test.phar", metadata["phar.path"]);
Assert.Equal("2", metadata["phar.file_count"]);
Assert.Equal("300", metadata["phar.total_size"]);
Assert.Equal("true", metadata["phar.has_vendor"]);
Assert.Equal("true", metadata["phar.has_composer"]);
Assert.Equal("abc123", metadata["phar.sha256"]);
}
[Fact]
public void PhpPharArchive_CreateMetadata_IncludesManifestInfo()
{
var manifest = new PhpPharManifest(
"myapp",
"1.2.3",
0x1100,
PhpPharCompression.GZip,
PhpPharSignatureType.Sha256,
new Dictionary<string, string>());
var archive = new PhpPharArchive("/test.phar", "test.phar", manifest, null, Array.Empty<PhpPharEntry>(), null);
var metadata = archive.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("myapp", metadata["phar.alias"]);
Assert.Equal("1.2.3", metadata["phar.version"]);
Assert.Equal("gzip", metadata["phar.compression"]);
Assert.Equal("sha256", metadata["phar.signature_type"]);
}
[Fact]
public void PhpPharArchive_CreateMetadata_DetectsAutoloadInStub()
{
var stubWithAutoload = "<?php\nspl_autoload_register(function($class) {});\n__HALT_COMPILER();";
var archive = new PhpPharArchive("/test.phar", "test.phar", null, stubWithAutoload, Array.Empty<PhpPharEntry>(), null);
var metadata = archive.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("true", metadata["phar.stub_has_autoload"]);
}
#endregion
#region PhpPharEntry Tests
[Fact]
public void PhpPharEntry_Extension_ReturnsCorrectExtension()
{
var entry = new PhpPharEntry("src/Main.php", 100, 80, 0, 0, PhpPharCompression.None, null);
Assert.Equal("php", entry.Extension);
}
[Fact]
public void PhpPharEntry_IsPhpFile_TrueForPhpExtension()
{
var entry = new PhpPharEntry("src/Main.php", 100, 80, 0, 0, PhpPharCompression.None, null);
Assert.True(entry.IsPhpFile);
}
[Fact]
public void PhpPharEntry_IsPhpFile_FalseForOtherExtensions()
{
var entry = new PhpPharEntry("config/app.json", 100, 80, 0, 0, PhpPharCompression.None, null);
Assert.False(entry.IsPhpFile);
}
[Fact]
public void PhpPharEntry_IsVendorFile_TrueForVendorPath()
{
var entry = new PhpPharEntry("vendor/monolog/monolog/src/Logger.php", 100, 80, 0, 0, PhpPharCompression.None, null);
Assert.True(entry.IsVendorFile);
}
[Fact]
public void PhpPharEntry_IsVendorFile_FalseForSrcPath()
{
var entry = new PhpPharEntry("src/Main.php", 100, 80, 0, 0, PhpPharCompression.None, null);
Assert.False(entry.IsVendorFile);
}
#endregion
#region PhpPharScanResult Tests
[Fact]
public void PhpPharScanResult_Empty_HasNoContent()
{
var result = PhpPharScanResult.Empty;
Assert.Empty(result.Archives);
Assert.Empty(result.Usages);
Assert.False(result.HasPharContent);
Assert.Equal(0, result.TotalArchivedFiles);
}
[Fact]
public void PhpPharScanResult_HasPharContent_TrueWithArchives()
{
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, Array.Empty<PhpPharEntry>(), null);
var result = new PhpPharScanResult(new[] { archive }, Array.Empty<PhpPharUsage>());
Assert.True(result.HasPharContent);
}
[Fact]
public void PhpPharScanResult_HasPharContent_TrueWithUsages()
{
var usage = new PhpPharUsage("src/Main.php", 10, "phar://myapp.phar/src/Helper.php", "myapp.phar/src/Helper.php");
var result = new PhpPharScanResult(Array.Empty<PhpPharArchive>(), new[] { usage });
Assert.True(result.HasPharContent);
}
[Fact]
public void PhpPharScanResult_TotalArchivedFiles_SumsAcrossArchives()
{
var entries1 = new[]
{
new PhpPharEntry("file1.php", 100, 80, 0, 0, PhpPharCompression.None, null),
new PhpPharEntry("file2.php", 200, 150, 0, 0, PhpPharCompression.None, null)
};
var entries2 = new[]
{
new PhpPharEntry("file3.php", 300, 250, 0, 0, PhpPharCompression.None, null)
};
var archive1 = new PhpPharArchive("/test1.phar", "test1.phar", null, null, entries1, null);
var archive2 = new PhpPharArchive("/test2.phar", "test2.phar", null, null, entries2, null);
var result = new PhpPharScanResult(new[] { archive1, archive2 }, Array.Empty<PhpPharUsage>());
Assert.Equal(3, result.TotalArchivedFiles);
}
[Fact]
public void PhpPharScanResult_ArchivesWithVendor_FiltersCorrectly()
{
var entriesWithVendor = new[]
{
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null)
};
var entriesWithoutVendor = new[]
{
new PhpPharEntry("src/Main.php", 200, 150, 0, 0, PhpPharCompression.None, null)
};
var archive1 = new PhpPharArchive("/with-vendor.phar", "with-vendor.phar", null, null, entriesWithVendor, null);
var archive2 = new PhpPharArchive("/without-vendor.phar", "without-vendor.phar", null, null, entriesWithoutVendor, null);
var result = new PhpPharScanResult(new[] { archive1, archive2 }, Array.Empty<PhpPharUsage>());
var archivesWithVendor = result.ArchivesWithVendor.ToList();
Assert.Single(archivesWithVendor);
Assert.Equal("with-vendor.phar", archivesWithVendor[0].RelativePath);
}
[Fact]
public void PhpPharScanResult_CreateMetadata_IncludesAllCounts()
{
var entries = new[]
{
new PhpPharEntry("vendor/autoload.php", 100, 80, 0, 0, PhpPharCompression.None, null)
};
var archive = new PhpPharArchive("/test.phar", "test.phar", null, null, entries, null);
var usage = new PhpPharUsage("src/Main.php", 10, "phar://test.phar/file.php", "test.phar/file.php");
var result = new PhpPharScanResult(new[] { archive }, new[] { usage });
var metadata = result.CreateMetadata().ToDictionary(kv => kv.Key, kv => kv.Value);
Assert.Equal("1", metadata["phar.archive_count"]);
Assert.Equal("1", metadata["phar.usage_count"]);
Assert.Equal("1", metadata["phar.total_archived_files"]);
Assert.Equal("1", metadata["phar.archives_with_vendor"]);
}
#endregion
#region PhpPharUsage Tests
[Fact]
public void PhpPharUsage_RecordProperties_SetCorrectly()
{
var usage = new PhpPharUsage("src/Main.php", 42, "include 'phar://app.phar/Helper.php';", "app.phar/Helper.php");
Assert.Equal("src/Main.php", usage.SourceFile);
Assert.Equal(42, usage.SourceLine);
Assert.Equal("include 'phar://app.phar/Helper.php';", usage.Snippet);
Assert.Equal("app.phar/Helper.php", usage.PharPath);
}
#endregion
#region Compression and Signature Enums Tests
[Fact]
public void PhpPharCompression_HasExpectedValues()
{
Assert.Equal(0, (int)PhpPharCompression.None);
Assert.Equal(1, (int)PhpPharCompression.GZip);
Assert.Equal(2, (int)PhpPharCompression.BZip2);
}
[Fact]
public void PhpPharSignatureType_HasExpectedValues()
{
Assert.Equal(0, (int)PhpPharSignatureType.None);
Assert.Equal(1, (int)PhpPharSignatureType.Md5);
Assert.Equal(2, (int)PhpPharSignatureType.Sha1);
Assert.Equal(3, (int)PhpPharSignatureType.Sha256);
Assert.Equal(4, (int)PhpPharSignatureType.Sha512);
Assert.Equal(5, (int)PhpPharSignatureType.OpenSsl);
}
#endregion
#region Helper Methods
private static byte[] CreateMinimalPharBytes(string stub)
{
// Create a minimal PHAR file structure
// This is a simplified version - real PHARs have more complex structure
var stubBytes = Encoding.UTF8.GetBytes(stub);
// Add some padding and a minimal manifest structure after __HALT_COMPILER();
var padding = new byte[] { 0x0D, 0x0A }; // CRLF
// Minimal manifest: 4 bytes length + 4 bytes file count + 2 bytes API + 4 bytes flags + 4 bytes alias len + 4 bytes metadata len
var manifestLength = 18u;
var fileCount = 0u;
var apiVersion = (ushort)0x1100;
var flags = 0u;
var aliasLength = 0u;
var metadataLength = 0u;
using var ms = new MemoryStream();
ms.Write(stubBytes, 0, stubBytes.Length);
ms.Write(padding, 0, padding.Length);
ms.Write(BitConverter.GetBytes(manifestLength), 0, 4);
ms.Write(BitConverter.GetBytes(fileCount), 0, 4);
ms.Write(BitConverter.GetBytes(apiVersion), 0, 2);
ms.Write(BitConverter.GetBytes(flags), 0, 4);
ms.Write(BitConverter.GetBytes(aliasLength), 0, 4);
ms.Write(BitConverter.GetBytes(metadataLength), 0, 4);
return ms.ToArray();
}
#endregion
}

View File

@@ -30,7 +30,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Analyzers.Lang.Tests/StellaOps.Scanner.Analyzers.Lang.Tests.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/StellaOps.Scanner.Analyzers.Lang.Php.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />