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
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:
@@ -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");
|
||||
// }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user