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,271 @@
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal.Capabilities;
/// <summary>
/// Orchestrates capability scanning across Node.js/JavaScript source files.
/// </summary>
internal static class NodeCapabilityScanBuilder
{
private static readonly string[] SourceExtensions = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
/// <summary>
/// Scans a Node.js project directory for capabilities.
/// </summary>
public static NodeCapabilityScanResult ScanProject(string projectPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
if (!Directory.Exists(projectPath))
{
return NodeCapabilityScanResult.Empty;
}
var allEvidences = new List<NodeCapabilityEvidence>();
foreach (var sourceFile in EnumerateSourceFiles(projectPath))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var content = File.ReadAllText(sourceFile);
var relativePath = Path.GetRelativePath(projectPath, sourceFile);
var evidences = NodeCapabilityScanner.ScanFile(content, relativePath);
allEvidences.AddRange(evidences);
}
catch (IOException)
{
// Skip inaccessible files
}
catch (UnauthorizedAccessException)
{
// Skip inaccessible files
}
}
// Deduplicate and sort for determinism
var finalEvidences = allEvidences
.DistinctBy(e => e.DeduplicationKey)
.OrderBy(e => e.SourceFile, StringComparer.Ordinal)
.ThenBy(e => e.SourceLine)
.ThenBy(e => e.Kind)
.ToList();
return new NodeCapabilityScanResult(finalEvidences);
}
/// <summary>
/// Scans a Node.js project from package.json location.
/// </summary>
public static NodeCapabilityScanResult ScanPackage(string packageJsonPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageJsonPath);
var projectDir = File.Exists(packageJsonPath)
? Path.GetDirectoryName(packageJsonPath) ?? packageJsonPath
: packageJsonPath;
if (!Directory.Exists(projectDir))
{
return NodeCapabilityScanResult.Empty;
}
var allEvidences = new List<NodeCapabilityEvidence>();
// Scan src directory if it exists
var srcDir = Path.Combine(projectDir, "src");
if (Directory.Exists(srcDir))
{
var result = ScanProject(srcDir, cancellationToken);
allEvidences.AddRange(result.Evidences);
}
// Scan lib directory if it exists
var libDir = Path.Combine(projectDir, "lib");
if (Directory.Exists(libDir))
{
var result = ScanProject(libDir, cancellationToken);
allEvidences.AddRange(result.Evidences);
}
// Scan root level .js files
foreach (var ext in SourceExtensions)
{
foreach (var file in Directory.EnumerateFiles(projectDir, $"*{ext}", SearchOption.TopDirectoryOnly))
{
cancellationToken.ThrowIfCancellationRequested();
// Skip config files
var fileName = Path.GetFileName(file);
if (IsConfigFile(fileName))
{
continue;
}
try
{
var content = File.ReadAllText(file);
var relativePath = Path.GetRelativePath(projectDir, file);
var evidences = NodeCapabilityScanner.ScanFile(content, relativePath);
allEvidences.AddRange(evidences);
}
catch (IOException)
{
// Skip inaccessible files
}
catch (UnauthorizedAccessException)
{
// Skip inaccessible files
}
}
}
// If no structured directories found, scan the whole project
if (allEvidences.Count == 0)
{
return ScanProject(projectDir, cancellationToken);
}
var finalEvidences = allEvidences
.DistinctBy(e => e.DeduplicationKey)
.OrderBy(e => e.SourceFile, StringComparer.Ordinal)
.ThenBy(e => e.SourceLine)
.ThenBy(e => e.Kind)
.ToList();
return new NodeCapabilityScanResult(finalEvidences);
}
/// <summary>
/// Scans specific JavaScript/TypeScript source content.
/// </summary>
public static NodeCapabilityScanResult ScanContent(string content, string filePath)
{
if (string.IsNullOrWhiteSpace(content))
{
return NodeCapabilityScanResult.Empty;
}
var evidences = NodeCapabilityScanner.ScanFile(content, filePath);
return new NodeCapabilityScanResult(evidences.ToList());
}
private static IEnumerable<string> EnumerateSourceFiles(string rootPath)
{
var options = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 30
};
foreach (var ext in SourceExtensions)
{
foreach (var file in Directory.EnumerateFiles(rootPath, $"*{ext}", options))
{
// Skip node_modules
if (file.Contains($"{Path.DirectorySeparatorChar}node_modules{Path.DirectorySeparatorChar}") ||
file.Contains($"{Path.AltDirectorySeparatorChar}node_modules{Path.AltDirectorySeparatorChar}"))
{
continue;
}
// Skip dist/build output directories
if (file.Contains($"{Path.DirectorySeparatorChar}dist{Path.DirectorySeparatorChar}") ||
file.Contains($"{Path.DirectorySeparatorChar}build{Path.DirectorySeparatorChar}") ||
file.Contains($"{Path.DirectorySeparatorChar}out{Path.DirectorySeparatorChar}") ||
file.Contains($"{Path.AltDirectorySeparatorChar}dist{Path.AltDirectorySeparatorChar}") ||
file.Contains($"{Path.AltDirectorySeparatorChar}build{Path.AltDirectorySeparatorChar}") ||
file.Contains($"{Path.AltDirectorySeparatorChar}out{Path.AltDirectorySeparatorChar}"))
{
continue;
}
// Skip coverage directories
if (file.Contains($"{Path.DirectorySeparatorChar}coverage{Path.DirectorySeparatorChar}") ||
file.Contains($"{Path.AltDirectorySeparatorChar}coverage{Path.AltDirectorySeparatorChar}"))
{
continue;
}
// Skip hidden directories
if (file.Contains($"{Path.DirectorySeparatorChar}.") ||
file.Contains($"{Path.AltDirectorySeparatorChar}."))
{
// But allow .github, .vscode source files if they contain JS
if (!file.Contains(".github") && !file.Contains(".vscode"))
{
continue;
}
}
// Skip test files (optional - can be useful for scanning)
// if (IsTestFile(Path.GetFileName(file)))
// {
// continue;
// }
// Skip minified files
var fileName = Path.GetFileName(file);
if (fileName.Contains(".min.") || fileName.EndsWith(".min.js", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Skip config files
if (IsConfigFile(fileName))
{
continue;
}
yield return file;
}
}
}
private static bool IsConfigFile(string fileName)
{
var configPatterns = new[]
{
"webpack.config",
"rollup.config",
"vite.config",
"babel.config",
"jest.config",
"eslint.config",
"prettier.config",
"tsconfig",
"jsconfig",
".eslintrc",
".prettierrc",
".babelrc",
"karma.conf",
"protractor.conf",
"gulpfile",
"gruntfile",
"postcss.config",
"tailwind.config",
"next.config",
"nuxt.config",
"svelte.config",
"astro.config",
"vitest.config"
};
var lowerFileName = fileName.ToLowerInvariant();
return configPatterns.Any(p => lowerFileName.Contains(p));
}
// Uncomment if you want to skip test files
// private static bool IsTestFile(string fileName)
// {
// var lowerFileName = fileName.ToLowerInvariant();
// return lowerFileName.Contains(".test.") ||
// lowerFileName.Contains(".spec.") ||
// lowerFileName.Contains("_test.") ||
// lowerFileName.Contains("_spec.") ||
// lowerFileName.EndsWith(".test.js") ||
// lowerFileName.EndsWith(".spec.js") ||
// lowerFileName.EndsWith(".test.ts") ||
// lowerFileName.EndsWith(".spec.ts");
// }
}

View File

@@ -0,0 +1,538 @@
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal.Capabilities;
/// <summary>
/// Scans Node.js/JavaScript source files for security-relevant capabilities.
/// Detects patterns for command execution, file I/O, network access,
/// serialization, dynamic code evaluation, native addons, and more.
/// </summary>
internal static class NodeCapabilityScanner
{
// ========================================
// EXEC - Command/Process Execution (Critical)
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] ExecPatterns =
[
// child_process module
(new Regex(@"require\s*\(\s*['""]child_process['""]", RegexOptions.Compiled), "require('child_process')", CapabilityRisk.Critical, 1.0f),
(new Regex(@"from\s+['""]child_process['""]", RegexOptions.Compiled), "import child_process", CapabilityRisk.Critical, 1.0f),
(new Regex(@"child_process\s*\.\s*exec\s*\(", RegexOptions.Compiled), "child_process.exec", CapabilityRisk.Critical, 1.0f),
(new Regex(@"child_process\s*\.\s*execSync\s*\(", RegexOptions.Compiled), "child_process.execSync", CapabilityRisk.Critical, 1.0f),
(new Regex(@"child_process\s*\.\s*spawn\s*\(", RegexOptions.Compiled), "child_process.spawn", CapabilityRisk.Critical, 1.0f),
(new Regex(@"child_process\s*\.\s*spawnSync\s*\(", RegexOptions.Compiled), "child_process.spawnSync", CapabilityRisk.Critical, 1.0f),
(new Regex(@"child_process\s*\.\s*fork\s*\(", RegexOptions.Compiled), "child_process.fork", CapabilityRisk.High, 0.95f),
(new Regex(@"child_process\s*\.\s*execFile\s*\(", RegexOptions.Compiled), "child_process.execFile", CapabilityRisk.Critical, 1.0f),
(new Regex(@"child_process\s*\.\s*execFileSync\s*\(", RegexOptions.Compiled), "child_process.execFileSync", CapabilityRisk.Critical, 1.0f),
// Destructured imports
(new Regex(@"\{\s*(?:exec|execSync|spawn|spawnSync|fork|execFile)\s*\}", RegexOptions.Compiled), "destructured child_process", CapabilityRisk.Critical, 0.9f),
// Shell execution via execa, shelljs, etc.
(new Regex(@"require\s*\(\s*['""]execa['""]", RegexOptions.Compiled), "require('execa')", CapabilityRisk.Critical, 0.95f),
(new Regex(@"require\s*\(\s*['""]shelljs['""]", RegexOptions.Compiled), "require('shelljs')", CapabilityRisk.Critical, 0.95f),
(new Regex(@"shell\s*\.\s*exec\s*\(", RegexOptions.Compiled), "shelljs.exec", CapabilityRisk.Critical, 0.9f),
// process.binding for internal access
(new Regex(@"process\s*\.\s*binding\s*\(", RegexOptions.Compiled), "process.binding", CapabilityRisk.Critical, 0.95f),
];
// ========================================
// FILESYSTEM - File/Directory Operations
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] FilesystemPatterns =
[
// fs module
(new Regex(@"require\s*\(\s*['""]fs['""]", RegexOptions.Compiled), "require('fs')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]fs/promises['""]", RegexOptions.Compiled), "require('fs/promises')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"from\s+['""]fs['""]", RegexOptions.Compiled), "import fs", CapabilityRisk.Medium, 0.9f),
(new Regex(@"from\s+['""]fs/promises['""]", RegexOptions.Compiled), "import fs/promises", CapabilityRisk.Medium, 0.9f),
(new Regex(@"from\s+['""]node:fs['""]", RegexOptions.Compiled), "import node:fs", CapabilityRisk.Medium, 0.9f),
// Read operations
(new Regex(@"fs\s*\.\s*readFile(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.readFile", CapabilityRisk.Medium, 0.85f),
(new Regex(@"fs\s*\.\s*readdir(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.readdir", CapabilityRisk.Medium, 0.8f),
(new Regex(@"fs\s*\.\s*createReadStream\s*\(", RegexOptions.Compiled), "fs.createReadStream", CapabilityRisk.Medium, 0.85f),
// Write operations (higher risk)
(new Regex(@"fs\s*\.\s*writeFile(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.writeFile", CapabilityRisk.High, 0.9f),
(new Regex(@"fs\s*\.\s*appendFile(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.appendFile", CapabilityRisk.High, 0.85f),
(new Regex(@"fs\s*\.\s*createWriteStream\s*\(", RegexOptions.Compiled), "fs.createWriteStream", CapabilityRisk.High, 0.9f),
(new Regex(@"fs\s*\.\s*mkdir(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.mkdir", CapabilityRisk.Medium, 0.8f),
// Delete operations (high risk)
(new Regex(@"fs\s*\.\s*unlink(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.unlink", CapabilityRisk.High, 0.9f),
(new Regex(@"fs\s*\.\s*rmdir(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.rmdir", CapabilityRisk.High, 0.9f),
(new Regex(@"fs\s*\.\s*rm(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.rm", CapabilityRisk.High, 0.9f),
// Permission operations
(new Regex(@"fs\s*\.\s*chmod(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.chmod", CapabilityRisk.High, 0.9f),
(new Regex(@"fs\s*\.\s*chown(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.chown", CapabilityRisk.High, 0.9f),
// Symlink (can be used for path traversal)
(new Regex(@"fs\s*\.\s*symlink(?:Sync)?\s*\(", RegexOptions.Compiled), "fs.symlink", CapabilityRisk.High, 0.85f),
// fs-extra
(new Regex(@"require\s*\(\s*['""]fs-extra['""]", RegexOptions.Compiled), "require('fs-extra')", CapabilityRisk.Medium, 0.85f),
];
// ========================================
// NETWORK - Network I/O
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] NetworkPatterns =
[
// Core modules
(new Regex(@"require\s*\(\s*['""]net['""]", RegexOptions.Compiled), "require('net')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]http['""]", RegexOptions.Compiled), "require('http')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]https['""]", RegexOptions.Compiled), "require('https')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]dgram['""]", RegexOptions.Compiled), "require('dgram')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]tls['""]", RegexOptions.Compiled), "require('tls')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"from\s+['""]node:(?:net|http|https|dgram|tls)['""]", RegexOptions.Compiled), "import node:network", CapabilityRisk.Medium, 0.9f),
// Socket operations
(new Regex(@"net\s*\.\s*createServer\s*\(", RegexOptions.Compiled), "net.createServer", CapabilityRisk.Medium, 0.9f),
(new Regex(@"net\s*\.\s*createConnection\s*\(", RegexOptions.Compiled), "net.createConnection", CapabilityRisk.Medium, 0.85f),
(new Regex(@"net\s*\.\s*connect\s*\(", RegexOptions.Compiled), "net.connect", CapabilityRisk.Medium, 0.85f),
// HTTP operations
(new Regex(@"http\s*\.\s*createServer\s*\(", RegexOptions.Compiled), "http.createServer", CapabilityRisk.Medium, 0.85f),
(new Regex(@"http\s*\.\s*request\s*\(", RegexOptions.Compiled), "http.request", CapabilityRisk.Medium, 0.8f),
(new Regex(@"http\s*\.\s*get\s*\(", RegexOptions.Compiled), "http.get", CapabilityRisk.Medium, 0.8f),
(new Regex(@"https\s*\.\s*request\s*\(", RegexOptions.Compiled), "https.request", CapabilityRisk.Medium, 0.8f),
// Fetch API
(new Regex(@"\bfetch\s*\(", RegexOptions.Compiled), "fetch", CapabilityRisk.Medium, 0.75f),
(new Regex(@"require\s*\(\s*['""]node-fetch['""]", RegexOptions.Compiled), "require('node-fetch')", CapabilityRisk.Medium, 0.85f),
// Axios, got, request
(new Regex(@"require\s*\(\s*['""]axios['""]", RegexOptions.Compiled), "require('axios')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]got['""]", RegexOptions.Compiled), "require('got')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]request['""]", RegexOptions.Compiled), "require('request')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]superagent['""]", RegexOptions.Compiled), "require('superagent')", CapabilityRisk.Medium, 0.85f),
// WebSocket
(new Regex(@"require\s*\(\s*['""]ws['""]", RegexOptions.Compiled), "require('ws')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"new\s+WebSocket\s*\(", RegexOptions.Compiled), "WebSocket", CapabilityRisk.Medium, 0.8f),
// DNS
(new Regex(@"require\s*\(\s*['""]dns['""]", RegexOptions.Compiled), "require('dns')", CapabilityRisk.Low, 0.8f),
];
// ========================================
// ENVIRONMENT - Environment Variables
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] EnvironmentPatterns =
[
(new Regex(@"process\s*\.\s*env\b", RegexOptions.Compiled), "process.env", CapabilityRisk.Medium, 0.85f),
(new Regex(@"process\s*\.\s*env\s*\[", RegexOptions.Compiled), "process.env[]", CapabilityRisk.Medium, 0.9f),
(new Regex(@"process\s*\.\s*env\s*\.\s*\w+", RegexOptions.Compiled), "process.env.*", CapabilityRisk.Medium, 0.85f),
// dotenv
(new Regex(@"require\s*\(\s*['""]dotenv['""]", RegexOptions.Compiled), "require('dotenv')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"dotenv\s*\.\s*config\s*\(", RegexOptions.Compiled), "dotenv.config", CapabilityRisk.Medium, 0.85f),
// process info
(new Regex(@"process\s*\.\s*cwd\s*\(\s*\)", RegexOptions.Compiled), "process.cwd", CapabilityRisk.Low, 0.75f),
(new Regex(@"process\s*\.\s*chdir\s*\(", RegexOptions.Compiled), "process.chdir", CapabilityRisk.Medium, 0.85f),
(new Regex(@"process\s*\.\s*argv\b", RegexOptions.Compiled), "process.argv", CapabilityRisk.Low, 0.7f),
];
// ========================================
// SERIALIZATION - Data Serialization
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] SerializationPatterns =
[
// JSON.parse with reviver (potential code execution)
(new Regex(@"JSON\s*\.\s*parse\s*\([^,)]+,\s*\w+", RegexOptions.Compiled), "JSON.parse with reviver", CapabilityRisk.Medium, 0.7f),
// Dangerous serializers - node-serialize is known vulnerable
(new Regex(@"require\s*\(\s*['""]node-serialize['""]", RegexOptions.Compiled), "require('node-serialize')", CapabilityRisk.Critical, 1.0f),
(new Regex(@"serialize\s*\.\s*unserialize\s*\(", RegexOptions.Compiled), "node-serialize.unserialize", CapabilityRisk.Critical, 1.0f),
// serialize-javascript
(new Regex(@"require\s*\(\s*['""]serialize-javascript['""]", RegexOptions.Compiled), "require('serialize-javascript')", CapabilityRisk.High, 0.85f),
// js-yaml (load is unsafe by default in older versions)
(new Regex(@"require\s*\(\s*['""]js-yaml['""]", RegexOptions.Compiled), "require('js-yaml')", CapabilityRisk.Medium, 0.8f),
(new Regex(@"yaml\s*\.\s*load\s*\(", RegexOptions.Compiled), "yaml.load", CapabilityRisk.High, 0.85f),
// Pickle-like serializers
(new Regex(@"require\s*\(\s*['""]v8['""]", RegexOptions.Compiled), "require('v8')", CapabilityRisk.High, 0.85f),
(new Regex(@"v8\s*\.\s*deserialize\s*\(", RegexOptions.Compiled), "v8.deserialize", CapabilityRisk.High, 0.9f),
(new Regex(@"v8\s*\.\s*serialize\s*\(", RegexOptions.Compiled), "v8.serialize", CapabilityRisk.Medium, 0.8f),
];
// ========================================
// CRYPTO - Cryptographic Operations
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] CryptoPatterns =
[
(new Regex(@"require\s*\(\s*['""]crypto['""]", RegexOptions.Compiled), "require('crypto')", CapabilityRisk.Low, 0.85f),
(new Regex(@"from\s+['""](?:node:)?crypto['""]", RegexOptions.Compiled), "import crypto", CapabilityRisk.Low, 0.85f),
// Specific crypto operations
(new Regex(@"crypto\s*\.\s*createHash\s*\(", RegexOptions.Compiled), "crypto.createHash", CapabilityRisk.Low, 0.85f),
(new Regex(@"crypto\s*\.\s*createCipher(?:iv)?\s*\(", RegexOptions.Compiled), "crypto.createCipher", CapabilityRisk.Low, 0.85f),
(new Regex(@"crypto\s*\.\s*createDecipher(?:iv)?\s*\(", RegexOptions.Compiled), "crypto.createDecipher", CapabilityRisk.Low, 0.85f),
(new Regex(@"crypto\s*\.\s*createSign\s*\(", RegexOptions.Compiled), "crypto.createSign", CapabilityRisk.Low, 0.85f),
(new Regex(@"crypto\s*\.\s*createVerify\s*\(", RegexOptions.Compiled), "crypto.createVerify", CapabilityRisk.Low, 0.85f),
(new Regex(@"crypto\s*\.\s*randomBytes\s*\(", RegexOptions.Compiled), "crypto.randomBytes", CapabilityRisk.Low, 0.8f),
(new Regex(@"crypto\s*\.\s*pbkdf2\s*\(", RegexOptions.Compiled), "crypto.pbkdf2", CapabilityRisk.Low, 0.85f),
// Third-party crypto
(new Regex(@"require\s*\(\s*['""]bcrypt['""]", RegexOptions.Compiled), "require('bcrypt')", CapabilityRisk.Low, 0.85f),
(new Regex(@"require\s*\(\s*['""]argon2['""]", RegexOptions.Compiled), "require('argon2')", CapabilityRisk.Low, 0.85f),
// Weak crypto
(new Regex(@"createHash\s*\(\s*['""](?:md5|sha1)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Weak hash algorithm", CapabilityRisk.High, 0.9f),
];
// ========================================
// DATABASE - Database Access
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] DatabasePatterns =
[
// SQL databases
(new Regex(@"require\s*\(\s*['""]mysql2?['""]", RegexOptions.Compiled), "require('mysql')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]pg['""]", RegexOptions.Compiled), "require('pg')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]sqlite3['""]", RegexOptions.Compiled), "require('sqlite3')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]better-sqlite3['""]", RegexOptions.Compiled), "require('better-sqlite3')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]mssql['""]", RegexOptions.Compiled), "require('mssql')", CapabilityRisk.Medium, 0.9f),
// NoSQL databases
(new Regex(@"require\s*\(\s*['""]mongodb['""]", RegexOptions.Compiled), "require('mongodb')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]mongoose['""]", RegexOptions.Compiled), "require('mongoose')", CapabilityRisk.Medium, 0.9f),
(new Regex(@"require\s*\(\s*['""]redis['""]", RegexOptions.Compiled), "require('redis')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]ioredis['""]", RegexOptions.Compiled), "require('ioredis')", CapabilityRisk.Medium, 0.85f),
// Query execution
(new Regex(@"\.query\s*\(\s*[`'""](?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Raw SQL query", CapabilityRisk.High, 0.9f),
(new Regex(@"\.exec\s*\(\s*[`'""](?:SELECT|INSERT|UPDATE|DELETE)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Raw SQL exec", CapabilityRisk.High, 0.85f),
// SQL injection patterns - string concatenation
(new Regex(@"[`'""](?:SELECT|INSERT|UPDATE|DELETE)\s+.*[`'""]\s*\+", RegexOptions.Compiled | RegexOptions.IgnoreCase), "SQL string concatenation", CapabilityRisk.Critical, 0.9f),
(new Regex(@"\$\{.*\}.*(?:SELECT|INSERT|UPDATE|DELETE)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "SQL template literal injection", CapabilityRisk.Critical, 0.85f),
// ORMs
(new Regex(@"require\s*\(\s*['""]sequelize['""]", RegexOptions.Compiled), "require('sequelize')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]typeorm['""]", RegexOptions.Compiled), "require('typeorm')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]prisma['""]", RegexOptions.Compiled), "require('prisma')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"require\s*\(\s*['""]knex['""]", RegexOptions.Compiled), "require('knex')", CapabilityRisk.Medium, 0.85f),
];
// ========================================
// DYNAMIC CODE - Code Evaluation (Critical)
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] DynamicCodePatterns =
[
// eval - most dangerous
(new Regex(@"\beval\s*\(", RegexOptions.Compiled), "eval", CapabilityRisk.Critical, 1.0f),
// Function constructor
(new Regex(@"new\s+Function\s*\(", RegexOptions.Compiled), "new Function", CapabilityRisk.Critical, 1.0f),
(new Regex(@"Function\s*\(\s*[^)]+\)\s*\(", RegexOptions.Compiled), "Function()()", CapabilityRisk.Critical, 0.95f),
// vm module
(new Regex(@"require\s*\(\s*['""]vm['""]", RegexOptions.Compiled), "require('vm')", CapabilityRisk.Critical, 0.95f),
(new Regex(@"from\s+['""](?:node:)?vm['""]", RegexOptions.Compiled), "import vm", CapabilityRisk.Critical, 0.95f),
(new Regex(@"vm\s*\.\s*runInContext\s*\(", RegexOptions.Compiled), "vm.runInContext", CapabilityRisk.Critical, 1.0f),
(new Regex(@"vm\s*\.\s*runInNewContext\s*\(", RegexOptions.Compiled), "vm.runInNewContext", CapabilityRisk.Critical, 1.0f),
(new Regex(@"vm\s*\.\s*runInThisContext\s*\(", RegexOptions.Compiled), "vm.runInThisContext", CapabilityRisk.Critical, 1.0f),
(new Regex(@"vm\s*\.\s*Script\s*\(", RegexOptions.Compiled), "vm.Script", CapabilityRisk.Critical, 0.95f),
(new Regex(@"new\s+vm\s*\.\s*Script\s*\(", RegexOptions.Compiled), "new vm.Script", CapabilityRisk.Critical, 0.95f),
// setTimeout/setInterval with strings (eval-like)
(new Regex(@"setTimeout\s*\(\s*['""`]", RegexOptions.Compiled), "setTimeout with string", CapabilityRisk.Critical, 0.9f),
(new Regex(@"setInterval\s*\(\s*['""`]", RegexOptions.Compiled), "setInterval with string", CapabilityRisk.Critical, 0.9f),
// Template engines (can execute code)
(new Regex(@"require\s*\(\s*['""]ejs['""]", RegexOptions.Compiled), "require('ejs')", CapabilityRisk.High, 0.8f),
(new Regex(@"require\s*\(\s*['""]pug['""]", RegexOptions.Compiled), "require('pug')", CapabilityRisk.Medium, 0.75f),
(new Regex(@"require\s*\(\s*['""]handlebars['""]", RegexOptions.Compiled), "require('handlebars')", CapabilityRisk.Medium, 0.7f),
// vm2 (sandbox escape vulnerabilities)
(new Regex(@"require\s*\(\s*['""]vm2['""]", RegexOptions.Compiled), "require('vm2')", CapabilityRisk.High, 0.9f),
];
// ========================================
// REFLECTION - Code Introspection
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] ReflectionPatterns =
[
// Reflect API
(new Regex(@"Reflect\s*\.\s*(?:get|set|has|defineProperty|deleteProperty|apply|construct)\s*\(", RegexOptions.Compiled), "Reflect.*", CapabilityRisk.Medium, 0.8f),
// Proxy
(new Regex(@"new\s+Proxy\s*\(", RegexOptions.Compiled), "new Proxy", CapabilityRisk.Medium, 0.8f),
// Property access via bracket notation with variables
(new Regex(@"\[\s*\w+\s*\]\s*\(", RegexOptions.Compiled), "Dynamic property call", CapabilityRisk.Medium, 0.65f),
// Object introspection
(new Regex(@"Object\s*\.\s*getOwnPropertyDescriptor\s*\(", RegexOptions.Compiled), "Object.getOwnPropertyDescriptor", CapabilityRisk.Low, 0.7f),
(new Regex(@"Object\s*\.\s*getPrototypeOf\s*\(", RegexOptions.Compiled), "Object.getPrototypeOf", CapabilityRisk.Low, 0.7f),
(new Regex(@"Object\s*\.\s*setPrototypeOf\s*\(", RegexOptions.Compiled), "Object.setPrototypeOf", CapabilityRisk.High, 0.85f),
(new Regex(@"__proto__", RegexOptions.Compiled), "__proto__", CapabilityRisk.High, 0.9f),
// constructor access
(new Regex(@"\.constructor\s*\(", RegexOptions.Compiled), ".constructor()", CapabilityRisk.High, 0.85f),
(new Regex(@"\[['""]\s*constructor\s*['""]", RegexOptions.Compiled), "['constructor']", CapabilityRisk.High, 0.85f),
];
// ========================================
// NATIVE CODE - Native Addons
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] NativeCodePatterns =
[
// Native addon loading
(new Regex(@"require\s*\([^)]*\.node['""]?\s*\)", RegexOptions.Compiled), "require('.node')", CapabilityRisk.Critical, 0.95f),
(new Regex(@"process\s*\.\s*dlopen\s*\(", RegexOptions.Compiled), "process.dlopen", CapabilityRisk.Critical, 1.0f),
// N-API / node-addon-api
(new Regex(@"require\s*\(\s*['""]node-addon-api['""]", RegexOptions.Compiled), "require('node-addon-api')", CapabilityRisk.High, 0.9f),
(new Regex(@"require\s*\(\s*['""]bindings['""]", RegexOptions.Compiled), "require('bindings')", CapabilityRisk.High, 0.9f),
// FFI
(new Regex(@"require\s*\(\s*['""]ffi-napi['""]", RegexOptions.Compiled), "require('ffi-napi')", CapabilityRisk.Critical, 0.95f),
(new Regex(@"require\s*\(\s*['""]node-ffi['""]", RegexOptions.Compiled), "require('node-ffi')", CapabilityRisk.Critical, 0.95f),
(new Regex(@"require\s*\(\s*['""]ref-napi['""]", RegexOptions.Compiled), "require('ref-napi')", CapabilityRisk.High, 0.9f),
// WebAssembly
(new Regex(@"WebAssembly\s*\.\s*instantiate\s*\(", RegexOptions.Compiled), "WebAssembly.instantiate", CapabilityRisk.High, 0.9f),
(new Regex(@"WebAssembly\s*\.\s*compile\s*\(", RegexOptions.Compiled), "WebAssembly.compile", CapabilityRisk.High, 0.9f),
(new Regex(@"new\s+WebAssembly\s*\.\s*Module\s*\(", RegexOptions.Compiled), "new WebAssembly.Module", CapabilityRisk.High, 0.9f),
(new Regex(@"new\s+WebAssembly\s*\.\s*Instance\s*\(", RegexOptions.Compiled), "new WebAssembly.Instance", CapabilityRisk.High, 0.9f),
];
// ========================================
// OTHER - Worker threads, cluster, etc.
// ========================================
private static readonly (Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] OtherPatterns =
[
// Worker threads
(new Regex(@"require\s*\(\s*['""]worker_threads['""]", RegexOptions.Compiled), "require('worker_threads')", CapabilityRisk.Medium, 0.85f),
(new Regex(@"from\s+['""](?:node:)?worker_threads['""]", RegexOptions.Compiled), "import worker_threads", CapabilityRisk.Medium, 0.85f),
(new Regex(@"new\s+Worker\s*\(", RegexOptions.Compiled), "new Worker", CapabilityRisk.Medium, 0.8f),
// Cluster
(new Regex(@"require\s*\(\s*['""]cluster['""]", RegexOptions.Compiled), "require('cluster')", CapabilityRisk.Medium, 0.8f),
(new Regex(@"cluster\s*\.\s*fork\s*\(", RegexOptions.Compiled), "cluster.fork", CapabilityRisk.Medium, 0.85f),
// Process manipulation
(new Regex(@"process\s*\.\s*exit\s*\(", RegexOptions.Compiled), "process.exit", CapabilityRisk.Medium, 0.8f),
(new Regex(@"process\s*\.\s*kill\s*\(", RegexOptions.Compiled), "process.kill", CapabilityRisk.High, 0.9f),
(new Regex(@"process\s*\.\s*abort\s*\(", RegexOptions.Compiled), "process.abort", CapabilityRisk.High, 0.9f),
// Module loading
(new Regex(@"require\s*\.\s*resolve\s*\(", RegexOptions.Compiled), "require.resolve", CapabilityRisk.Low, 0.7f),
(new Regex(@"import\s*\(", RegexOptions.Compiled), "dynamic import()", CapabilityRisk.Medium, 0.75f),
(new Regex(@"require\s*\(\s*\w+\s*\)", RegexOptions.Compiled), "require(variable)", CapabilityRisk.High, 0.85f),
// Inspector/debugger
(new Regex(@"require\s*\(\s*['""]inspector['""]", RegexOptions.Compiled), "require('inspector')", CapabilityRisk.High, 0.9f),
(new Regex(@"\bdebugger\b", RegexOptions.Compiled), "debugger statement", CapabilityRisk.Medium, 0.75f),
];
/// <summary>
/// Scans a Node.js source file for capability usages.
/// </summary>
public static IEnumerable<NodeCapabilityEvidence> ScanFile(string content, string filePath)
{
if (string.IsNullOrWhiteSpace(content))
{
yield break;
}
// Strip comments for more accurate detection
var cleanedContent = StripComments(content);
var lines = cleanedContent.Split('\n');
for (var lineNumber = 0; lineNumber < lines.Length; lineNumber++)
{
var line = lines[lineNumber];
var lineNum = lineNumber + 1;
// Exec patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, ExecPatterns, CapabilityKind.Exec))
{
yield return evidence;
}
// Filesystem patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, FilesystemPatterns, CapabilityKind.Filesystem))
{
yield return evidence;
}
// Network patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, NetworkPatterns, CapabilityKind.Network))
{
yield return evidence;
}
// Environment patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, EnvironmentPatterns, CapabilityKind.Environment))
{
yield return evidence;
}
// Serialization patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, SerializationPatterns, CapabilityKind.Serialization))
{
yield return evidence;
}
// Crypto patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, CryptoPatterns, CapabilityKind.Crypto))
{
yield return evidence;
}
// Database patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, DatabasePatterns, CapabilityKind.Database))
{
yield return evidence;
}
// Dynamic code patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, DynamicCodePatterns, CapabilityKind.DynamicCode))
{
yield return evidence;
}
// Reflection patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, ReflectionPatterns, CapabilityKind.Reflection))
{
yield return evidence;
}
// Native code patterns
foreach (var evidence in ScanPatterns(line, lineNum, filePath, NativeCodePatterns, CapabilityKind.NativeCode))
{
yield return evidence;
}
// Other patterns (workers, process, etc.)
foreach (var evidence in ScanPatterns(line, lineNum, filePath, OtherPatterns, CapabilityKind.Other))
{
yield return evidence;
}
}
}
private static IEnumerable<NodeCapabilityEvidence> ScanPatterns(
string line,
int lineNumber,
string filePath,
(Regex Pattern, string Name, CapabilityRisk Risk, float Confidence)[] patterns,
CapabilityKind kind)
{
foreach (var (pattern, name, risk, confidence) in patterns)
{
if (pattern.IsMatch(line))
{
yield return new NodeCapabilityEvidence(
kind: kind,
sourceFile: filePath,
sourceLine: lineNumber,
pattern: name,
snippet: line.Trim(),
confidence: confidence,
risk: risk);
}
}
}
/// <summary>
/// Strips single-line (//) and multi-line (/* */) comments from JavaScript source.
/// </summary>
private static string StripComments(string content)
{
var sb = new StringBuilder(content.Length);
var i = 0;
var inString = false;
var inTemplate = false;
var stringChar = '"';
while (i < content.Length)
{
// Handle escape sequences in strings
if ((inString || inTemplate) && content[i] == '\\' && i + 1 < content.Length)
{
sb.Append(content[i]);
sb.Append(content[i + 1]);
i += 2;
continue;
}
// Handle template literals
if (!inString && content[i] == '`')
{
inTemplate = !inTemplate;
sb.Append(content[i]);
i++;
continue;
}
// Handle string literals (but not inside template literals)
if (!inTemplate && (content[i] == '"' || content[i] == '\''))
{
if (!inString)
{
inString = true;
stringChar = content[i];
}
else if (content[i] == stringChar)
{
inString = false;
}
sb.Append(content[i]);
i++;
continue;
}
// Skip comments only when not in string/template
if (!inString && !inTemplate)
{
// Single-line comment
if (i + 1 < content.Length && content[i] == '/' && content[i + 1] == '/')
{
// Skip until end of line
while (i < content.Length && content[i] != '\n')
{
i++;
}
if (i < content.Length)
{
sb.Append('\n');
i++;
}
continue;
}
// Multi-line comment
if (i + 1 < content.Length && content[i] == '/' && content[i + 1] == '*')
{
i += 2;
while (i + 1 < content.Length && !(content[i] == '*' && content[i + 1] == '/'))
{
// Preserve newlines for line number accuracy
if (content[i] == '\n')
{
sb.Append('\n');
}
i++;
}
if (i + 1 < content.Length)
{
i += 2; // Skip */
}
continue;
}
}
sb.Append(content[i]);
i++;
}
return sb.ToString();
}
}

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" />