up
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
global using System;
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.IO;
|
||||
global using System.IO.Compression;
|
||||
@@ -11,3 +11,5 @@ global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
|
||||
global using StellaOps.Scanner.Analyzers.Lang;
|
||||
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("StellaOps.Scanner.Analyzers.Lang.Node.Tests")]
|
||||
|
||||
@@ -21,14 +21,8 @@ internal static class NodePhase22SampleLoader
|
||||
var fixturePath = Environment.GetEnvironmentVariable(EnvKey);
|
||||
if (string.IsNullOrWhiteSpace(fixturePath))
|
||||
{
|
||||
// Only load from the fixture root if explicitly present; do not fallback to docs/samples
|
||||
fixturePath = Path.Combine(rootPath, DefaultFileName);
|
||||
if (!File.Exists(fixturePath))
|
||||
{
|
||||
// fallback to docs sample if tests point to repo root
|
||||
var repoRoot = FindRepoRoot(rootPath);
|
||||
var fromDocs = Path.Combine(repoRoot, "docs", "samples", "scanner", "node-phase22", DefaultFileName);
|
||||
fixturePath = File.Exists(fromDocs) ? fromDocs : fixturePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(fixturePath))
|
||||
@@ -103,20 +97,4 @@ internal static class NodePhase22SampleLoader
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot(string start)
|
||||
{
|
||||
var current = new DirectoryInfo(start);
|
||||
while (current is not null && current.Exists)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "README.md")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
global using System.Globalization;
|
||||
global using System.IO;
|
||||
global using System.Linq;
|
||||
|
||||
@@ -57,8 +57,8 @@ internal static class ComposerLockReader
|
||||
var autoload = ParseAutoload(packageElement);
|
||||
|
||||
packages.Add(new ComposerPackage(
|
||||
name,
|
||||
version,
|
||||
name!,
|
||||
version!,
|
||||
type,
|
||||
isDev,
|
||||
sourceType,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an autoload edge connecting a namespace/class to a file path.
|
||||
/// </summary>
|
||||
internal sealed record PhpAutoloadEdge
|
||||
{
|
||||
public PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind kind,
|
||||
string source,
|
||||
string target,
|
||||
string? packageName = null,
|
||||
float confidence = 1.0f)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
throw new ArgumentException("Source is required", nameof(source));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
throw new ArgumentException("Target is required", nameof(target));
|
||||
}
|
||||
|
||||
Kind = kind;
|
||||
Source = source;
|
||||
Target = NormalizePath(target);
|
||||
PackageName = packageName;
|
||||
Confidence = Math.Clamp(confidence, 0f, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type of autoload edge.
|
||||
/// </summary>
|
||||
public PhpAutoloadEdgeKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The source (namespace prefix for PSR-4/0, class name for classmap, or file path for files).
|
||||
/// </summary>
|
||||
public string Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The target path (directory or file).
|
||||
/// </summary>
|
||||
public string Target { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The package this edge belongs to, if known.
|
||||
/// </summary>
|
||||
public string? PackageName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level of this edge (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public float Confidence { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for this edge.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("autoload.edge.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("autoload.edge.source", Source);
|
||||
yield return new KeyValuePair<string, string?>("autoload.edge.target", Target);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(PackageName))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("autoload.edge.package", PackageName);
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("autoload.edge.confidence", Confidence.ToString("F2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/').TrimEnd('/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of autoload edges.
|
||||
/// </summary>
|
||||
internal enum PhpAutoloadEdgeKind
|
||||
{
|
||||
/// <summary>PSR-4 autoload mapping.</summary>
|
||||
Psr4,
|
||||
|
||||
/// <summary>PSR-0 autoload mapping.</summary>
|
||||
Psr0,
|
||||
|
||||
/// <summary>Classmap autoload mapping.</summary>
|
||||
Classmap,
|
||||
|
||||
/// <summary>Files autoload (always loaded).</summary>
|
||||
Files,
|
||||
|
||||
/// <summary>Bin script entrypoint.</summary>
|
||||
Bin,
|
||||
|
||||
/// <summary>Composer plugin.</summary>
|
||||
Plugin
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Builds autoload dependency edges from composer packages.
|
||||
/// </summary>
|
||||
internal static class PhpAutoloadGraphBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds autoload edges from installed packages and project autoload config.
|
||||
/// </summary>
|
||||
public static PhpAutoloadGraph Build(
|
||||
PhpInstalledData installedData,
|
||||
PhpComposerManifest? manifest)
|
||||
{
|
||||
var edges = new List<PhpAutoloadEdge>();
|
||||
var binEntrypoints = new List<PhpBinEntrypoint>();
|
||||
var plugins = new List<PhpComposerPlugin>();
|
||||
|
||||
// Process installed packages
|
||||
foreach (var package in installedData.Packages.OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var packagePrefix = package.InstallPath ?? $"vendor/{package.Name}";
|
||||
|
||||
// Add autoload edges
|
||||
edges.AddRange(BuildEdgesFromSpec(package.Autoload, package.Name, packagePrefix));
|
||||
edges.AddRange(BuildEdgesFromSpec(package.AutoloadDev, package.Name, packagePrefix));
|
||||
|
||||
// Add bin entrypoints
|
||||
foreach (var bin in package.Bin)
|
||||
{
|
||||
var binPath = CombinePath(packagePrefix, bin);
|
||||
binEntrypoints.Add(new PhpBinEntrypoint(
|
||||
Path.GetFileNameWithoutExtension(bin),
|
||||
binPath,
|
||||
package.Name));
|
||||
|
||||
edges.Add(new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Bin,
|
||||
Path.GetFileNameWithoutExtension(bin),
|
||||
binPath,
|
||||
package.Name));
|
||||
}
|
||||
|
||||
// Record plugins
|
||||
if (package.IsPlugin && !string.IsNullOrWhiteSpace(package.PluginClass))
|
||||
{
|
||||
plugins.Add(new PhpComposerPlugin(
|
||||
package.Name,
|
||||
package.PluginClass,
|
||||
package.Version));
|
||||
}
|
||||
}
|
||||
|
||||
// Process project autoload if manifest is available
|
||||
if (manifest is not null)
|
||||
{
|
||||
edges.AddRange(BuildEdgesFromComposerAutoload(manifest.Autoload, null, string.Empty));
|
||||
edges.AddRange(BuildEdgesFromComposerAutoload(manifest.AutoloadDev, null, string.Empty));
|
||||
|
||||
// Add project bin entrypoints
|
||||
foreach (var (name, path) in manifest.Bin)
|
||||
{
|
||||
binEntrypoints.Add(new PhpBinEntrypoint(name, path, null));
|
||||
|
||||
edges.Add(new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Bin,
|
||||
name,
|
||||
path,
|
||||
null));
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpAutoloadGraph(
|
||||
edges.OrderBy(e => e.Kind).ThenBy(e => e.Source, StringComparer.Ordinal).ToList(),
|
||||
binEntrypoints.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(),
|
||||
plugins.OrderBy(p => p.PackageName, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpAutoloadEdge> BuildEdgesFromSpec(
|
||||
PhpAutoloadSpec spec,
|
||||
string packageName,
|
||||
string packagePrefix)
|
||||
{
|
||||
// PSR-4
|
||||
foreach (var (ns, paths) in spec.Psr4)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
yield return new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Psr4,
|
||||
ns,
|
||||
CombinePath(packagePrefix, path),
|
||||
packageName);
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0
|
||||
foreach (var (ns, paths) in spec.Psr0)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
yield return new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Psr0,
|
||||
ns,
|
||||
CombinePath(packagePrefix, path),
|
||||
packageName);
|
||||
}
|
||||
}
|
||||
|
||||
// Classmap
|
||||
foreach (var path in spec.Classmap)
|
||||
{
|
||||
yield return new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Classmap,
|
||||
path,
|
||||
CombinePath(packagePrefix, path),
|
||||
packageName,
|
||||
confidence: 0.9f); // Lower confidence as we don't know exact classes
|
||||
}
|
||||
|
||||
// Files (always loaded)
|
||||
foreach (var path in spec.Files)
|
||||
{
|
||||
yield return new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Files,
|
||||
path,
|
||||
CombinePath(packagePrefix, path),
|
||||
packageName);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpAutoloadEdge> BuildEdgesFromComposerAutoload(
|
||||
ComposerAutoloadData autoload,
|
||||
string? packageName,
|
||||
string packagePrefix)
|
||||
{
|
||||
// PSR-4 entries from ComposerAutoloadData are formatted as "Namespace\->path"
|
||||
foreach (var entry in autoload.Psr4)
|
||||
{
|
||||
var parts = entry.Split("->", 2, StringSplitOptions.None);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
yield return new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Psr4,
|
||||
parts[0],
|
||||
CombinePath(packagePrefix, parts[1]),
|
||||
packageName);
|
||||
}
|
||||
}
|
||||
|
||||
// Classmap
|
||||
foreach (var path in autoload.Classmap)
|
||||
{
|
||||
yield return new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Classmap,
|
||||
path,
|
||||
CombinePath(packagePrefix, path),
|
||||
packageName,
|
||||
confidence: 0.9f);
|
||||
}
|
||||
|
||||
// Files
|
||||
foreach (var path in autoload.Files)
|
||||
{
|
||||
yield return new PhpAutoloadEdge(
|
||||
PhpAutoloadEdgeKind.Files,
|
||||
path,
|
||||
CombinePath(packagePrefix, path),
|
||||
packageName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CombinePath(string prefix, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return path.Replace('\\', '/').TrimStart('/');
|
||||
}
|
||||
|
||||
var normalizedPrefix = prefix.Replace('\\', '/').TrimEnd('/');
|
||||
var normalizedPath = path.Replace('\\', '/').TrimStart('/');
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedPath))
|
||||
{
|
||||
return normalizedPrefix;
|
||||
}
|
||||
|
||||
return $"{normalizedPrefix}/{normalizedPath}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the autoload graph for a PHP project.
|
||||
/// </summary>
|
||||
internal sealed class PhpAutoloadGraph
|
||||
{
|
||||
public PhpAutoloadGraph(
|
||||
IReadOnlyList<PhpAutoloadEdge> edges,
|
||||
IReadOnlyList<PhpBinEntrypoint> binEntrypoints,
|
||||
IReadOnlyList<PhpComposerPlugin> plugins)
|
||||
{
|
||||
Edges = edges ?? Array.Empty<PhpAutoloadEdge>();
|
||||
BinEntrypoints = binEntrypoints ?? Array.Empty<PhpBinEntrypoint>();
|
||||
Plugins = plugins ?? Array.Empty<PhpComposerPlugin>();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PhpAutoloadEdge> Edges { get; }
|
||||
|
||||
public IReadOnlyList<PhpBinEntrypoint> BinEntrypoints { get; }
|
||||
|
||||
public IReadOnlyList<PhpComposerPlugin> Plugins { get; }
|
||||
|
||||
public bool IsEmpty => Edges.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for the autoload graph.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"autoload.edge_count",
|
||||
Edges.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var psr4Count = Edges.Count(e => e.Kind == PhpAutoloadEdgeKind.Psr4);
|
||||
var psr0Count = Edges.Count(e => e.Kind == PhpAutoloadEdgeKind.Psr0);
|
||||
var classmapCount = Edges.Count(e => e.Kind == PhpAutoloadEdgeKind.Classmap);
|
||||
var filesCount = Edges.Count(e => e.Kind == PhpAutoloadEdgeKind.Files);
|
||||
|
||||
yield return new KeyValuePair<string, string?>("autoload.psr4_count", psr4Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("autoload.psr0_count", psr0Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("autoload.classmap_count", classmapCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("autoload.files_count", filesCount.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"autoload.bin_count",
|
||||
BinEntrypoints.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"autoload.plugin_count",
|
||||
Plugins.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (Plugins.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"autoload.plugins",
|
||||
string.Join(';', Plugins.Select(p => p.PackageName)));
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpAutoloadGraph Empty { get; } = new(
|
||||
Array.Empty<PhpAutoloadEdge>(),
|
||||
Array.Empty<PhpBinEntrypoint>(),
|
||||
Array.Empty<PhpComposerPlugin>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a bin script entrypoint.
|
||||
/// </summary>
|
||||
internal sealed record PhpBinEntrypoint(
|
||||
string Name,
|
||||
string Path,
|
||||
string? PackageName);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected Composer plugin.
|
||||
/// </summary>
|
||||
internal sealed record PhpComposerPlugin(
|
||||
string PackageName,
|
||||
string PluginClass,
|
||||
string? Version);
|
||||
@@ -0,0 +1,158 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents evidence of a runtime capability usage in PHP code.
|
||||
/// </summary>
|
||||
internal sealed record PhpCapabilityEvidence
|
||||
{
|
||||
public PhpCapabilityEvidence(
|
||||
PhpCapabilityKind kind,
|
||||
string sourceFile,
|
||||
int sourceLine,
|
||||
string functionOrPattern,
|
||||
string? snippet = null,
|
||||
float confidence = 1.0f,
|
||||
PhpCapabilityRisk risk = PhpCapabilityRisk.Low)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceFile))
|
||||
{
|
||||
throw new ArgumentException("Source file is required", nameof(sourceFile));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(functionOrPattern))
|
||||
{
|
||||
throw new ArgumentException("Function or pattern is required", nameof(functionOrPattern));
|
||||
}
|
||||
|
||||
Kind = kind;
|
||||
SourceFile = NormalizePath(sourceFile);
|
||||
SourceLine = sourceLine;
|
||||
FunctionOrPattern = functionOrPattern;
|
||||
Snippet = snippet;
|
||||
Confidence = Math.Clamp(confidence, 0f, 1f);
|
||||
Risk = risk;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The capability category.
|
||||
/// </summary>
|
||||
public PhpCapabilityKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The source file where the capability is used.
|
||||
/// </summary>
|
||||
public string SourceFile { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The line number of the capability usage.
|
||||
/// </summary>
|
||||
public int SourceLine { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The function name or pattern matched.
|
||||
/// </summary>
|
||||
public string FunctionOrPattern { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A snippet of the code (for context).
|
||||
/// </summary>
|
||||
public string? Snippet { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public float Confidence { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk level associated with this capability usage.
|
||||
/// </summary>
|
||||
public PhpCapabilityRisk Risk { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for this evidence.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("capability.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.source", $"{SourceFile}:{SourceLine}");
|
||||
yield return new KeyValuePair<string, string?>("capability.function", FunctionOrPattern);
|
||||
yield return new KeyValuePair<string, string?>("capability.risk", Risk.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.confidence", Confidence.ToString("F2", CultureInfo.InvariantCulture));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Snippet))
|
||||
{
|
||||
// Truncate snippet to reasonable length
|
||||
var truncated = Snippet.Length > 200 ? Snippet[..197] + "..." : Snippet;
|
||||
yield return new KeyValuePair<string, string?>("capability.snippet", truncated);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Categories of PHP runtime capabilities.
|
||||
/// </summary>
|
||||
internal enum PhpCapabilityKind
|
||||
{
|
||||
/// <summary>Command execution (exec, shell_exec, system, passthru, popen, proc_open, backtick).</summary>
|
||||
Exec,
|
||||
|
||||
/// <summary>Filesystem operations (fopen, file_get_contents, unlink, rmdir, chmod, etc.).</summary>
|
||||
Filesystem,
|
||||
|
||||
/// <summary>Network operations (curl, fsockopen, socket, stream_socket, file_get_contents with URL).</summary>
|
||||
Network,
|
||||
|
||||
/// <summary>Environment variable access ($_ENV, getenv, putenv).</summary>
|
||||
Environment,
|
||||
|
||||
/// <summary>Serialization (serialize, unserialize, __wakeup, __sleep).</summary>
|
||||
Serialization,
|
||||
|
||||
/// <summary>Cryptographic operations (openssl_*, sodium_*, hash, password_hash, etc.).</summary>
|
||||
Crypto,
|
||||
|
||||
/// <summary>Database operations (mysqli_, PDO, pg_, oci_, sqlite_, etc.).</summary>
|
||||
Database,
|
||||
|
||||
/// <summary>File upload handling ($_FILES, move_uploaded_file, is_uploaded_file).</summary>
|
||||
Upload,
|
||||
|
||||
/// <summary>Stream wrappers (php://, data://, phar://, zip://, compress.zlib://).</summary>
|
||||
StreamWrapper,
|
||||
|
||||
/// <summary>Eval and dynamic code (eval, create_function, assert with code).</summary>
|
||||
DynamicCode,
|
||||
|
||||
/// <summary>Reflection and introspection (ReflectionClass, get_defined_functions, etc.).</summary>
|
||||
Reflection,
|
||||
|
||||
/// <summary>Output control (ob_start with callback, header, setcookie).</summary>
|
||||
OutputControl,
|
||||
|
||||
/// <summary>Session handling (session_start, session_set_save_handler).</summary>
|
||||
Session,
|
||||
|
||||
/// <summary>Error/exception handling that may expose information.</summary>
|
||||
ErrorHandling
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk levels for capability usage.
|
||||
/// </summary>
|
||||
internal enum PhpCapabilityRisk
|
||||
{
|
||||
/// <summary>Low risk, common usage patterns.</summary>
|
||||
Low,
|
||||
|
||||
/// <summary>Medium risk, potentially dangerous in certain contexts.</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>High risk, requires careful security review.</summary>
|
||||
High,
|
||||
|
||||
/// <summary>Critical risk, often associated with vulnerabilities.</summary>
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates capability scanning across a PHP project.
|
||||
/// </summary>
|
||||
internal static class PhpCapabilityScanBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans all PHP files in the project for capability usage.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpCapabilityScanResult> ScanAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileSystem);
|
||||
|
||||
var allEvidences = new List<PhpCapabilityEvidence>();
|
||||
|
||||
// Scan all PHP files in the source tree (not vendor - those are dependencies)
|
||||
var phpFiles = fileSystem.GetPhpFiles()
|
||||
.Where(f => f.Source == PhpFileSource.SourceTree)
|
||||
.OrderBy(f => f.RelativePath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var file in phpFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var fileEvidences = await PhpCapabilityScanner.ScanFileAsync(
|
||||
file.AbsolutePath,
|
||||
file.RelativePath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
allEvidences.AddRange(fileEvidences);
|
||||
}
|
||||
|
||||
// Deduplicate evidences (same function in same file/line should appear once)
|
||||
var deduped = DeduplicateEvidences(allEvidences);
|
||||
|
||||
// Sort for deterministic output
|
||||
var sorted = deduped
|
||||
.OrderBy(e => e.SourceFile, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.SourceLine)
|
||||
.ThenBy(e => e.Kind)
|
||||
.ThenBy(e => e.FunctionOrPattern, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new PhpCapabilityScanResult(sorted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a single file for capabilities (for testing/debugging).
|
||||
/// </summary>
|
||||
public static async ValueTask<IReadOnlyList<PhpCapabilityEvidence>> ScanSingleFileAsync(
|
||||
string filePath,
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await PhpCapabilityScanner.ScanFileAsync(filePath, relativePath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PhpCapabilityEvidence> DeduplicateEvidences(
|
||||
IReadOnlyList<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// Use a composite key for deduplication
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<PhpCapabilityEvidence>();
|
||||
|
||||
foreach (var evidence in evidences)
|
||||
{
|
||||
var key = $"{evidence.SourceFile}:{evidence.SourceLine}:{evidence.Kind}:{evidence.FunctionOrPattern}";
|
||||
|
||||
if (seen.Add(key))
|
||||
{
|
||||
result.Add(evidence);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates capability scan results for a PHP project.
|
||||
/// </summary>
|
||||
internal sealed class PhpCapabilityScanResult
|
||||
{
|
||||
public PhpCapabilityScanResult(IReadOnlyList<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
Evidences = evidences ?? Array.Empty<PhpCapabilityEvidence>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All capability evidences found.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpCapabilityEvidence> Evidences { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any capabilities were detected.
|
||||
/// </summary>
|
||||
public bool HasCapabilities => Evidences.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidences by capability kind.
|
||||
/// </summary>
|
||||
public ILookup<PhpCapabilityKind, PhpCapabilityEvidence> EvidencesByKind
|
||||
=> Evidences.ToLookup(e => e.Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidences by risk level.
|
||||
/// </summary>
|
||||
public ILookup<PhpCapabilityRisk, PhpCapabilityEvidence> EvidencesByRisk
|
||||
=> Evidences.ToLookup(e => e.Risk);
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidences by source file.
|
||||
/// </summary>
|
||||
public ILookup<string, PhpCapabilityEvidence> EvidencesByFile
|
||||
=> Evidences.ToLookup(e => e.SourceFile, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all critical risk evidences.
|
||||
/// </summary>
|
||||
public IEnumerable<PhpCapabilityEvidence> CriticalRiskEvidences
|
||||
=> Evidences.Where(e => e.Risk == PhpCapabilityRisk.Critical);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all high risk evidences.
|
||||
/// </summary>
|
||||
public IEnumerable<PhpCapabilityEvidence> HighRiskEvidences
|
||||
=> Evidences.Where(e => e.Risk == PhpCapabilityRisk.High);
|
||||
|
||||
/// <summary>
|
||||
/// Gets detected capability kinds.
|
||||
/// </summary>
|
||||
public IReadOnlySet<PhpCapabilityKind> DetectedKinds
|
||||
=> Evidences.Select(e => e.Kind).ToHashSet();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the highest risk level found.
|
||||
/// </summary>
|
||||
public PhpCapabilityRisk HighestRisk
|
||||
=> Evidences.Count > 0
|
||||
? Evidences.Max(e => e.Risk)
|
||||
: PhpCapabilityRisk.Low;
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for the scan result.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"capability.total_count",
|
||||
Evidences.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
// Count by kind
|
||||
foreach (var kindGroup in EvidencesByKind.OrderBy(g => g.Key.ToString(), StringComparer.Ordinal))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
$"capability.{kindGroup.Key.ToString().ToLowerInvariant()}_count",
|
||||
kindGroup.Count().ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// Count by risk
|
||||
var criticalCount = CriticalRiskEvidences.Count();
|
||||
var highCount = HighRiskEvidences.Count();
|
||||
var mediumCount = Evidences.Count(e => e.Risk == PhpCapabilityRisk.Medium);
|
||||
var lowCount = Evidences.Count(e => e.Risk == PhpCapabilityRisk.Low);
|
||||
|
||||
yield return new KeyValuePair<string, string?>("capability.critical_risk_count", criticalCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("capability.high_risk_count", highCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("capability.medium_risk_count", mediumCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("capability.low_risk_count", lowCount.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
// Detected capabilities as a semicolon-separated list
|
||||
if (DetectedKinds.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"capability.detected_kinds",
|
||||
string.Join(';', DetectedKinds.OrderBy(k => k.ToString(), StringComparer.Ordinal).Select(k => k.ToString().ToLowerInvariant())));
|
||||
}
|
||||
|
||||
// Files with critical issues
|
||||
var criticalFiles = CriticalRiskEvidences
|
||||
.Select(e => e.SourceFile)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (criticalFiles.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"capability.critical_files",
|
||||
string.Join(';', criticalFiles.Take(10))); // Limit to first 10
|
||||
|
||||
if (criticalFiles.Count > 10)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"capability.critical_files_truncated",
|
||||
"true");
|
||||
}
|
||||
}
|
||||
|
||||
// Unique functions/patterns detected
|
||||
var uniqueFunctions = Evidences
|
||||
.Select(e => e.FunctionOrPattern)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"capability.unique_function_count",
|
||||
uniqueFunctions.Count.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a summary of security-relevant capabilities.
|
||||
/// </summary>
|
||||
public PhpCapabilitySummary CreateSummary()
|
||||
{
|
||||
return new PhpCapabilitySummary(
|
||||
HasExec: EvidencesByKind[PhpCapabilityKind.Exec].Any(),
|
||||
HasFilesystem: EvidencesByKind[PhpCapabilityKind.Filesystem].Any(),
|
||||
HasNetwork: EvidencesByKind[PhpCapabilityKind.Network].Any(),
|
||||
HasEnvironment: EvidencesByKind[PhpCapabilityKind.Environment].Any(),
|
||||
HasSerialization: EvidencesByKind[PhpCapabilityKind.Serialization].Any(),
|
||||
HasCrypto: EvidencesByKind[PhpCapabilityKind.Crypto].Any(),
|
||||
HasDatabase: EvidencesByKind[PhpCapabilityKind.Database].Any(),
|
||||
HasUpload: EvidencesByKind[PhpCapabilityKind.Upload].Any(),
|
||||
HasStreamWrapper: EvidencesByKind[PhpCapabilityKind.StreamWrapper].Any(),
|
||||
HasDynamicCode: EvidencesByKind[PhpCapabilityKind.DynamicCode].Any(),
|
||||
HasReflection: EvidencesByKind[PhpCapabilityKind.Reflection].Any(),
|
||||
HasSession: EvidencesByKind[PhpCapabilityKind.Session].Any(),
|
||||
CriticalCount: CriticalRiskEvidences.Count(),
|
||||
HighRiskCount: HighRiskEvidences.Count(),
|
||||
TotalCount: Evidences.Count);
|
||||
}
|
||||
|
||||
public static PhpCapabilityScanResult Empty { get; } = new(Array.Empty<PhpCapabilityEvidence>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of detected capabilities.
|
||||
/// </summary>
|
||||
internal sealed record PhpCapabilitySummary(
|
||||
bool HasExec,
|
||||
bool HasFilesystem,
|
||||
bool HasNetwork,
|
||||
bool HasEnvironment,
|
||||
bool HasSerialization,
|
||||
bool HasCrypto,
|
||||
bool HasDatabase,
|
||||
bool HasUpload,
|
||||
bool HasStreamWrapper,
|
||||
bool HasDynamicCode,
|
||||
bool HasReflection,
|
||||
bool HasSession,
|
||||
int CriticalCount,
|
||||
int HighRiskCount,
|
||||
int TotalCount)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates metadata entries for the summary.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("capability.has_exec", HasExec.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_filesystem", HasFilesystem.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_network", HasNetwork.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_environment", HasEnvironment.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_serialization", HasSerialization.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_crypto", HasCrypto.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_database", HasDatabase.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_upload", HasUpload.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_stream_wrapper", HasStreamWrapper.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_dynamic_code", HasDynamicCode.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_reflection", HasReflection.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("capability.has_session", HasSession.ToString().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Scans PHP source files for runtime capability usage patterns.
|
||||
/// </summary>
|
||||
internal static partial class PhpCapabilityScanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans a PHP file for capability usage.
|
||||
/// </summary>
|
||||
public static async ValueTask<IReadOnlyList<PhpCapabilityEvidence>> ScanFileAsync(
|
||||
string filePath,
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return Array.Empty<PhpCapabilityEvidence>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return ScanContent(content, relativePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Array.Empty<PhpCapabilityEvidence>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans PHP content for capability usage.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PhpCapabilityEvidence> ScanContent(string content, string sourceFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return Array.Empty<PhpCapabilityEvidence>();
|
||||
}
|
||||
|
||||
var evidences = new List<PhpCapabilityEvidence>();
|
||||
var lines = content.Split('\n');
|
||||
var inMultiLineComment = false;
|
||||
|
||||
for (var lineNumber = 0; lineNumber < lines.Length; lineNumber++)
|
||||
{
|
||||
var line = lines[lineNumber];
|
||||
|
||||
// Track multi-line comments
|
||||
if (line.Contains("/*", StringComparison.Ordinal))
|
||||
{
|
||||
inMultiLineComment = true;
|
||||
}
|
||||
|
||||
if (line.Contains("*/", StringComparison.Ordinal))
|
||||
{
|
||||
inMultiLineComment = false;
|
||||
continue; // Skip rest of this line
|
||||
}
|
||||
|
||||
if (inMultiLineComment)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip single-line comments
|
||||
var trimmed = line.TrimStart();
|
||||
if (trimmed.StartsWith("//", StringComparison.Ordinal) ||
|
||||
trimmed.StartsWith("#", StringComparison.Ordinal) ||
|
||||
trimmed.StartsWith("*", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan for all capability categories
|
||||
ScanForExecCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForFilesystemCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForNetworkCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForEnvironmentCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForSerializationCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForCryptoCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForDatabaseCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForUploadCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForStreamWrappers(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForDynamicCodeCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForReflectionCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForOutputControlCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForSessionCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
ScanForErrorHandlingCapabilities(line, sourceFile, lineNumber + 1, evidences);
|
||||
}
|
||||
|
||||
return evidences;
|
||||
}
|
||||
|
||||
private static void ScanForExecCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// Command execution functions - CRITICAL risk
|
||||
var criticalExecFunctions = new[]
|
||||
{
|
||||
("exec", PhpCapabilityRisk.Critical),
|
||||
("shell_exec", PhpCapabilityRisk.Critical),
|
||||
("system", PhpCapabilityRisk.Critical),
|
||||
("passthru", PhpCapabilityRisk.Critical),
|
||||
("popen", PhpCapabilityRisk.Critical),
|
||||
("proc_open", PhpCapabilityRisk.Critical),
|
||||
("pcntl_exec", PhpCapabilityRisk.Critical)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in criticalExecFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Exec,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
1.0f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
|
||||
// Backtick operator
|
||||
if (BacktickRegex().IsMatch(line))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Exec,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"backtick_operator",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Critical));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForFilesystemCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
var fsFunctions = new[]
|
||||
{
|
||||
// File reading/writing - Medium risk
|
||||
("fopen", PhpCapabilityRisk.Medium),
|
||||
("fwrite", PhpCapabilityRisk.Medium),
|
||||
("fread", PhpCapabilityRisk.Low),
|
||||
("fclose", PhpCapabilityRisk.Low),
|
||||
("file_get_contents", PhpCapabilityRisk.Medium),
|
||||
("file_put_contents", PhpCapabilityRisk.Medium),
|
||||
("readfile", PhpCapabilityRisk.Medium),
|
||||
("file", PhpCapabilityRisk.Low),
|
||||
|
||||
// File/directory manipulation - Higher risk
|
||||
("unlink", PhpCapabilityRisk.High),
|
||||
("rmdir", PhpCapabilityRisk.High),
|
||||
("mkdir", PhpCapabilityRisk.Medium),
|
||||
("rename", PhpCapabilityRisk.Medium),
|
||||
("copy", PhpCapabilityRisk.Medium),
|
||||
|
||||
// Permissions - High risk
|
||||
("chmod", PhpCapabilityRisk.High),
|
||||
("chown", PhpCapabilityRisk.High),
|
||||
("chgrp", PhpCapabilityRisk.High),
|
||||
|
||||
// Directory traversal functions
|
||||
("scandir", PhpCapabilityRisk.Low),
|
||||
("glob", PhpCapabilityRisk.Low),
|
||||
("opendir", PhpCapabilityRisk.Low),
|
||||
("readdir", PhpCapabilityRisk.Low),
|
||||
|
||||
// Symlinks - High risk
|
||||
("symlink", PhpCapabilityRisk.High),
|
||||
("link", PhpCapabilityRisk.High),
|
||||
("readlink", PhpCapabilityRisk.Medium),
|
||||
|
||||
// Temp files
|
||||
("tmpfile", PhpCapabilityRisk.Low),
|
||||
("tempnam", PhpCapabilityRisk.Low)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in fsFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Filesystem,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForNetworkCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
var networkFunctions = new[]
|
||||
{
|
||||
// cURL functions
|
||||
("curl_init", PhpCapabilityRisk.Medium),
|
||||
("curl_exec", PhpCapabilityRisk.Medium),
|
||||
("curl_multi_exec", PhpCapabilityRisk.Medium),
|
||||
|
||||
// Socket functions
|
||||
("fsockopen", PhpCapabilityRisk.High),
|
||||
("pfsockopen", PhpCapabilityRisk.High),
|
||||
("socket_create", PhpCapabilityRisk.High),
|
||||
("socket_connect", PhpCapabilityRisk.High),
|
||||
("socket_bind", PhpCapabilityRisk.High),
|
||||
("socket_listen", PhpCapabilityRisk.High),
|
||||
|
||||
// Stream sockets
|
||||
("stream_socket_client", PhpCapabilityRisk.High),
|
||||
("stream_socket_server", PhpCapabilityRisk.High),
|
||||
|
||||
// DNS/network info
|
||||
("gethostbyname", PhpCapabilityRisk.Low),
|
||||
("gethostbyaddr", PhpCapabilityRisk.Low),
|
||||
("dns_get_record", PhpCapabilityRisk.Low),
|
||||
|
||||
// HTTP functions
|
||||
("get_headers", PhpCapabilityRisk.Medium),
|
||||
("http_request", PhpCapabilityRisk.Medium)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in networkFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Network,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for file_get_contents with URL patterns (http://, https://, ftp://)
|
||||
if (ContainsFunctionCall(line, "file_get_contents") && UrlPatternRegex().IsMatch(line))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Network,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"file_get_contents_url",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.9f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForEnvironmentCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// Environment variable functions
|
||||
var envFunctions = new[]
|
||||
{
|
||||
("getenv", PhpCapabilityRisk.Medium),
|
||||
("putenv", PhpCapabilityRisk.High),
|
||||
("apache_getenv", PhpCapabilityRisk.Medium),
|
||||
("apache_setenv", PhpCapabilityRisk.High)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in envFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Environment,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
|
||||
// $_ENV superglobal
|
||||
if (line.Contains("$_ENV", StringComparison.Ordinal))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Environment,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"$_ENV",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
|
||||
// $_SERVER access (can contain environment info)
|
||||
if (line.Contains("$_SERVER", StringComparison.Ordinal))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Environment,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"$_SERVER",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.8f,
|
||||
PhpCapabilityRisk.Low));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForSerializationCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
var serializationFunctions = new[]
|
||||
{
|
||||
("serialize", PhpCapabilityRisk.Low),
|
||||
("unserialize", PhpCapabilityRisk.Critical), // Deserialization vulnerabilities
|
||||
("json_encode", PhpCapabilityRisk.Low),
|
||||
("json_decode", PhpCapabilityRisk.Low),
|
||||
("var_export", PhpCapabilityRisk.Low),
|
||||
("igbinary_serialize", PhpCapabilityRisk.Low),
|
||||
("igbinary_unserialize", PhpCapabilityRisk.High),
|
||||
("msgpack_pack", PhpCapabilityRisk.Low),
|
||||
("msgpack_unpack", PhpCapabilityRisk.Medium)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in serializationFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Serialization,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
|
||||
// Magic methods related to serialization
|
||||
var magicMethods = new[] { "__wakeup", "__sleep", "__serialize", "__unserialize" };
|
||||
foreach (var method in magicMethods)
|
||||
{
|
||||
if (line.Contains(method, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Serialization,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
method,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.9f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForCryptoCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// OpenSSL functions
|
||||
if (OpenSslFunctionRegex().IsMatch(line))
|
||||
{
|
||||
var match = OpenSslFunctionRegex().Match(line);
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Crypto,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
match.Value,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
|
||||
// Sodium functions
|
||||
if (SodiumFunctionRegex().IsMatch(line))
|
||||
{
|
||||
var match = SodiumFunctionRegex().Match(line);
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Crypto,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
match.Value,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Low));
|
||||
}
|
||||
|
||||
// Hash functions
|
||||
var hashFunctions = new[]
|
||||
{
|
||||
("hash", PhpCapabilityRisk.Low),
|
||||
("hash_hmac", PhpCapabilityRisk.Low),
|
||||
("password_hash", PhpCapabilityRisk.Low),
|
||||
("password_verify", PhpCapabilityRisk.Low),
|
||||
("md5", PhpCapabilityRisk.Medium), // Weak hash
|
||||
("sha1", PhpCapabilityRisk.Low),
|
||||
("crypt", PhpCapabilityRisk.Medium),
|
||||
("mcrypt_encrypt", PhpCapabilityRisk.High), // Deprecated
|
||||
("mcrypt_decrypt", PhpCapabilityRisk.High) // Deprecated
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in hashFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Crypto,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForDatabaseCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// mysqli functions
|
||||
if (MysqliFunctionRegex().IsMatch(line))
|
||||
{
|
||||
var match = MysqliFunctionRegex().Match(line);
|
||||
var risk = match.Value.Contains("query", StringComparison.OrdinalIgnoreCase)
|
||||
? PhpCapabilityRisk.High
|
||||
: PhpCapabilityRisk.Medium;
|
||||
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Database,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
match.Value,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
|
||||
// PDO
|
||||
if (line.Contains("PDO", StringComparison.Ordinal) ||
|
||||
line.Contains("->prepare", StringComparison.Ordinal) ||
|
||||
line.Contains("->execute", StringComparison.Ordinal))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Database,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"PDO",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.85f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
|
||||
// PostgreSQL functions
|
||||
if (PgFunctionRegex().IsMatch(line))
|
||||
{
|
||||
var match = PgFunctionRegex().Match(line);
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Database,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
match.Value,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
|
||||
// SQLite functions
|
||||
if (SqliteFunctionRegex().IsMatch(line))
|
||||
{
|
||||
var match = SqliteFunctionRegex().Match(line);
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Database,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
match.Value,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
|
||||
// Raw SQL in strings (potential injection risk)
|
||||
if (SqlQueryPatternRegex().IsMatch(line))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Database,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"raw_sql_query",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.7f,
|
||||
PhpCapabilityRisk.High));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForUploadCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// $_FILES superglobal
|
||||
if (line.Contains("$_FILES", StringComparison.Ordinal))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Upload,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"$_FILES",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.High));
|
||||
}
|
||||
|
||||
// Upload handling functions
|
||||
var uploadFunctions = new[]
|
||||
{
|
||||
("move_uploaded_file", PhpCapabilityRisk.High),
|
||||
("is_uploaded_file", PhpCapabilityRisk.Low)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in uploadFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Upload,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForStreamWrappers(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// Stream wrapper patterns
|
||||
var wrapperPatterns = new[]
|
||||
{
|
||||
("php://input", PhpCapabilityRisk.High),
|
||||
("php://filter", PhpCapabilityRisk.Critical),
|
||||
("php://memory", PhpCapabilityRisk.Low),
|
||||
("php://temp", PhpCapabilityRisk.Low),
|
||||
("php://output", PhpCapabilityRisk.Low),
|
||||
("data://", PhpCapabilityRisk.High),
|
||||
("phar://", PhpCapabilityRisk.Critical),
|
||||
("zip://", PhpCapabilityRisk.High),
|
||||
("compress.zlib://", PhpCapabilityRisk.Medium),
|
||||
("compress.bzip2://", PhpCapabilityRisk.Medium),
|
||||
("expect://", PhpCapabilityRisk.Critical),
|
||||
("glob://", PhpCapabilityRisk.Medium)
|
||||
};
|
||||
|
||||
foreach (var (pattern, risk) in wrapperPatterns)
|
||||
{
|
||||
if (line.Contains(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.StreamWrapper,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
pattern,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
|
||||
// Stream wrapper registration
|
||||
if (ContainsFunctionCall(line, "stream_wrapper_register"))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.StreamWrapper,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"stream_wrapper_register",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.High));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForDynamicCodeCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
var dynamicCodeFunctions = new[]
|
||||
{
|
||||
("eval", PhpCapabilityRisk.Critical),
|
||||
("create_function", PhpCapabilityRisk.Critical),
|
||||
("call_user_func", PhpCapabilityRisk.High),
|
||||
("call_user_func_array", PhpCapabilityRisk.High),
|
||||
("preg_replace", PhpCapabilityRisk.High), // /e modifier vulnerability
|
||||
("assert", PhpCapabilityRisk.Critical)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in dynamicCodeFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.DynamicCode,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
|
||||
// Variable functions: $func()
|
||||
if (VariableFunctionRegex().IsMatch(line))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.DynamicCode,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"variable_function",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.85f,
|
||||
PhpCapabilityRisk.High));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForReflectionCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
var reflectionClasses = new[]
|
||||
{
|
||||
"ReflectionClass",
|
||||
"ReflectionMethod",
|
||||
"ReflectionFunction",
|
||||
"ReflectionProperty"
|
||||
};
|
||||
|
||||
foreach (var cls in reflectionClasses)
|
||||
{
|
||||
if (line.Contains(cls, StringComparison.Ordinal))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Reflection,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
cls,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
}
|
||||
|
||||
// Introspection functions
|
||||
var introspectionFunctions = new[]
|
||||
{
|
||||
("get_defined_functions", PhpCapabilityRisk.Medium),
|
||||
("get_defined_vars", PhpCapabilityRisk.Medium),
|
||||
("get_defined_constants", PhpCapabilityRisk.Low),
|
||||
("get_loaded_extensions", PhpCapabilityRisk.Low),
|
||||
("get_class_methods", PhpCapabilityRisk.Low),
|
||||
("class_exists", PhpCapabilityRisk.Low),
|
||||
("function_exists", PhpCapabilityRisk.Low)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in introspectionFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Reflection,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.9f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForOutputControlCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
var outputFunctions = new[]
|
||||
{
|
||||
("header", PhpCapabilityRisk.Medium),
|
||||
("setcookie", PhpCapabilityRisk.Medium),
|
||||
("setrawcookie", PhpCapabilityRisk.Medium),
|
||||
("ob_start", PhpCapabilityRisk.Low),
|
||||
("ob_end_flush", PhpCapabilityRisk.Low),
|
||||
("ob_get_contents", PhpCapabilityRisk.Low)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in outputFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.OutputControl,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.9f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForSessionCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
// $_SESSION superglobal
|
||||
if (line.Contains("$_SESSION", StringComparison.Ordinal))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Session,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
"$_SESSION",
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
PhpCapabilityRisk.Medium));
|
||||
}
|
||||
|
||||
var sessionFunctions = new[]
|
||||
{
|
||||
("session_start", PhpCapabilityRisk.Medium),
|
||||
("session_regenerate_id", PhpCapabilityRisk.Low),
|
||||
("session_destroy", PhpCapabilityRisk.Low),
|
||||
("session_set_save_handler", PhpCapabilityRisk.High),
|
||||
("session_set_cookie_params", PhpCapabilityRisk.Medium)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in sessionFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.Session,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.95f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanForErrorHandlingCapabilities(string line, string sourceFile, int lineNumber, List<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
var errorFunctions = new[]
|
||||
{
|
||||
("error_reporting", PhpCapabilityRisk.Medium),
|
||||
("set_error_handler", PhpCapabilityRisk.Medium),
|
||||
("set_exception_handler", PhpCapabilityRisk.Medium),
|
||||
("ini_set", PhpCapabilityRisk.High),
|
||||
("ini_get", PhpCapabilityRisk.Low),
|
||||
("phpinfo", PhpCapabilityRisk.High), // Information disclosure
|
||||
("debug_backtrace", PhpCapabilityRisk.Medium),
|
||||
("var_dump", PhpCapabilityRisk.Low),
|
||||
("print_r", PhpCapabilityRisk.Low)
|
||||
};
|
||||
|
||||
foreach (var (func, risk) in errorFunctions)
|
||||
{
|
||||
if (ContainsFunctionCall(line, func))
|
||||
{
|
||||
evidences.Add(new PhpCapabilityEvidence(
|
||||
PhpCapabilityKind.ErrorHandling,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
func,
|
||||
TruncateSnippet(line.Trim()),
|
||||
0.9f,
|
||||
risk));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a line contains a function call (not just the function name in a string).
|
||||
/// </summary>
|
||||
private static bool ContainsFunctionCall(string line, string functionName)
|
||||
{
|
||||
// Simple pattern: functionName followed by (
|
||||
// Use word boundary to avoid matching substrings
|
||||
var pattern = $@"\b{Regex.Escape(functionName)}\s*\(";
|
||||
return Regex.IsMatch(line, pattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static string? TruncateSnippet(string snippet)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const int maxLength = 150;
|
||||
return snippet.Length > maxLength ? snippet[..(maxLength - 3)] + "..." : snippet;
|
||||
}
|
||||
|
||||
// Regex patterns
|
||||
[GeneratedRegex(@"`[^`]+`")]
|
||||
private static partial Regex BacktickRegex();
|
||||
|
||||
[GeneratedRegex(@"https?://|ftp://", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex UrlPatternRegex();
|
||||
|
||||
[GeneratedRegex(@"\bopenssl_\w+\s*\(", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex OpenSslFunctionRegex();
|
||||
|
||||
[GeneratedRegex(@"\bsodium_\w+\s*\(", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SodiumFunctionRegex();
|
||||
|
||||
[GeneratedRegex(@"\bmysqli_\w+\s*\(", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex MysqliFunctionRegex();
|
||||
|
||||
[GeneratedRegex(@"\bpg_\w+\s*\(", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex PgFunctionRegex();
|
||||
|
||||
[GeneratedRegex(@"\bsqlite3?_\w+\s*\(", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SqliteFunctionRegex();
|
||||
|
||||
[GeneratedRegex(@"\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\b.*\bFROM\b", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SqlQueryPatternRegex();
|
||||
|
||||
[GeneratedRegex(@"\$\w+\s*\(")]
|
||||
private static partial Regex VariableFunctionRegex();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents parsed composer.json manifest data.
|
||||
/// </summary>
|
||||
internal sealed class PhpComposerManifest
|
||||
{
|
||||
public PhpComposerManifest(
|
||||
string manifestPath,
|
||||
string? name,
|
||||
string? description,
|
||||
string? type,
|
||||
string? version,
|
||||
string? license,
|
||||
IReadOnlyList<string> authors,
|
||||
IReadOnlyDictionary<string, string> require,
|
||||
IReadOnlyDictionary<string, string> requireDev,
|
||||
ComposerAutoloadData autoload,
|
||||
ComposerAutoloadData autoloadDev,
|
||||
IReadOnlyDictionary<string, string> scripts,
|
||||
IReadOnlyDictionary<string, string> bin,
|
||||
string? minimumStability,
|
||||
string? sha256)
|
||||
{
|
||||
ManifestPath = manifestPath ?? string.Empty;
|
||||
Name = name;
|
||||
Description = description;
|
||||
Type = type;
|
||||
Version = version;
|
||||
License = license;
|
||||
Authors = authors ?? Array.Empty<string>();
|
||||
Require = require ?? new Dictionary<string, string>();
|
||||
RequireDev = requireDev ?? new Dictionary<string, string>();
|
||||
Autoload = autoload ?? ComposerAutoloadData.Empty;
|
||||
AutoloadDev = autoloadDev ?? ComposerAutoloadData.Empty;
|
||||
Scripts = scripts ?? new Dictionary<string, string>();
|
||||
Bin = bin ?? new Dictionary<string, string>();
|
||||
MinimumStability = minimumStability;
|
||||
Sha256 = sha256;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path to the composer.json file.
|
||||
/// </summary>
|
||||
public string ManifestPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name (vendor/package format).
|
||||
/// </summary>
|
||||
public string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Package description.
|
||||
/// </summary>
|
||||
public string? Description { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Package type (library, project, etc.).
|
||||
/// </summary>
|
||||
public string? Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Package version.
|
||||
/// </summary>
|
||||
public string? Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// License identifier.
|
||||
/// </summary>
|
||||
public string? License { get; }
|
||||
|
||||
/// <summary>
|
||||
/// List of authors.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Authors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Required dependencies.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Require { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Development dependencies.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> RequireDev { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Autoload configuration.
|
||||
/// </summary>
|
||||
public ComposerAutoloadData Autoload { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Development autoload configuration.
|
||||
/// </summary>
|
||||
public ComposerAutoloadData AutoloadDev { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Composer scripts.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Scripts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary paths.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Bin { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum stability.
|
||||
/// </summary>
|
||||
public string? MinimumStability { get; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the manifest file.
|
||||
/// </summary>
|
||||
public string? Sha256 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the required PHP version constraint, if specified.
|
||||
/// </summary>
|
||||
public string? RequiredPhpVersion => Require.TryGetValue("php", out var v) ? v : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of required extensions.
|
||||
/// </summary>
|
||||
public IEnumerable<string> RequiredExtensions => Require.Keys
|
||||
.Where(k => k.StartsWith("ext-", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(k => k[4..])
|
||||
.OrderBy(e => e, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for SBOM generation.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.name", Name);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Type))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.type", Type);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(License))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.license", License);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(RequiredPhpVersion))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.php_version", RequiredPhpVersion);
|
||||
}
|
||||
|
||||
var extensions = RequiredExtensions.ToList();
|
||||
if (extensions.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.extensions", string.Join(',', extensions));
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.require_count", Require.Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.require_dev_count", RequireDev.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Sha256))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("composer.manifest.sha256", Sha256);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty manifest.
|
||||
/// </summary>
|
||||
public static PhpComposerManifest Empty { get; } = new(
|
||||
manifestPath: string.Empty,
|
||||
name: null,
|
||||
description: null,
|
||||
type: null,
|
||||
version: null,
|
||||
license: null,
|
||||
authors: Array.Empty<string>(),
|
||||
require: new Dictionary<string, string>(),
|
||||
requireDev: new Dictionary<string, string>(),
|
||||
autoload: ComposerAutoloadData.Empty,
|
||||
autoloadDev: ComposerAutoloadData.Empty,
|
||||
scripts: new Dictionary<string, string>(),
|
||||
bin: new Dictionary<string, string>(),
|
||||
minimumStability: null,
|
||||
sha256: null);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and parses composer.json manifest files.
|
||||
/// </summary>
|
||||
internal static class PhpComposerManifestReader
|
||||
{
|
||||
private const string ManifestFileName = "composer.json";
|
||||
|
||||
/// <summary>
|
||||
/// Loads composer.json from the given root path.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpComposerManifest?> LoadAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifestPath = Path.Combine(rootPath, ManifestFileName);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = File.Open(manifestPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var root = document.RootElement;
|
||||
|
||||
var name = TryGetString(root, "name");
|
||||
var description = TryGetString(root, "description");
|
||||
var type = TryGetString(root, "type");
|
||||
var version = TryGetString(root, "version");
|
||||
var license = ParseLicense(root);
|
||||
var authors = ParseAuthors(root);
|
||||
var require = ParseDependencies(root, "require");
|
||||
var requireDev = ParseDependencies(root, "require-dev");
|
||||
var autoload = ParseAutoload(root, "autoload");
|
||||
var autoloadDev = ParseAutoload(root, "autoload-dev");
|
||||
var scripts = ParseScripts(root);
|
||||
var bin = ParseBin(root);
|
||||
var minimumStability = TryGetString(root, "minimum-stability");
|
||||
|
||||
var sha256 = await ComputeSha256Async(manifestPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new PhpComposerManifest(
|
||||
manifestPath,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
version,
|
||||
license,
|
||||
authors,
|
||||
require,
|
||||
requireDev,
|
||||
autoload,
|
||||
autoloadDev,
|
||||
scripts,
|
||||
bin,
|
||||
minimumStability,
|
||||
sha256);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind == JsonValueKind.String ? property.GetString() : null;
|
||||
}
|
||||
|
||||
private static string? ParseLicense(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("license", out var licenseElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (licenseElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return licenseElement.GetString();
|
||||
}
|
||||
|
||||
if (licenseElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var licenses = new List<string>();
|
||||
foreach (var item in licenseElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var license = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(license))
|
||||
{
|
||||
licenses.Add(license);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return licenses.Count > 0 ? string.Join(" OR ", licenses) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseAuthors(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("authors", out var authorsElement) || authorsElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var authors = new List<string>();
|
||||
foreach (var authorElement in authorsElement.EnumerateArray())
|
||||
{
|
||||
if (authorElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = TryGetString(authorElement, "name");
|
||||
var email = TryGetString(authorElement, "email");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
authors.Add(!string.IsNullOrWhiteSpace(email) ? $"{name} <{email}>" : name);
|
||||
}
|
||||
}
|
||||
|
||||
return authors.OrderBy(a => a, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseDependencies(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var depsElement) || depsElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var deps = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var prop in depsElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var version = prop.Value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
deps[prop.Name] = version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deps;
|
||||
}
|
||||
|
||||
private static ComposerAutoloadData ParseAutoload(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var autoloadElement) || autoloadElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ComposerAutoloadData.Empty;
|
||||
}
|
||||
|
||||
var psr4 = new List<string>();
|
||||
if (autoloadElement.TryGetProperty("psr-4", out var psr4Element) && psr4Element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var ns in psr4Element.EnumerateObject())
|
||||
{
|
||||
if (ns.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
psr4.Add($"{ns.Name}->{NormalizePath(ns.Value.GetString())}");
|
||||
}
|
||||
else if (ns.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pathElement in ns.Value.EnumerateArray())
|
||||
{
|
||||
if (pathElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
psr4.Add($"{ns.Name}->{NormalizePath(pathElement.GetString())}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var classmap = new List<string>();
|
||||
if (autoloadElement.TryGetProperty("classmap", out var classmapElement) && classmapElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in classmapElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
classmap.Add(NormalizePath(item.GetString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var files = new List<string>();
|
||||
if (autoloadElement.TryGetProperty("files", out var filesElement) && filesElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in filesElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
files.Add(NormalizePath(item.GetString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
psr4.Sort(StringComparer.Ordinal);
|
||||
classmap.Sort(StringComparer.Ordinal);
|
||||
files.Sort(StringComparer.Ordinal);
|
||||
|
||||
return new ComposerAutoloadData(psr4, classmap, files);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseScripts(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("scripts", out var scriptsElement) || scriptsElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var scripts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var prop in scriptsElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var script = prop.Value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(script))
|
||||
{
|
||||
scripts[prop.Name] = script;
|
||||
}
|
||||
}
|
||||
else if (prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var commands = new List<string>();
|
||||
foreach (var item in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var cmd = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(cmd))
|
||||
{
|
||||
commands.Add(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (commands.Count > 0)
|
||||
{
|
||||
scripts[prop.Name] = string.Join(" && ", commands);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scripts;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseBin(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("bin", out var binElement) || binElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var bin = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in binElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var path = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
bin[name] = NormalizePath(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bin;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string? path)
|
||||
=> string.IsNullOrWhiteSpace(path) ? string.Empty : path.Replace('\\', '/');
|
||||
|
||||
private static async ValueTask<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collection of PHP configuration entries from multiple sources.
|
||||
/// </summary>
|
||||
internal sealed class PhpConfigCollection
|
||||
{
|
||||
private readonly FrozenDictionary<string, PhpConfigEntry> _entries;
|
||||
private readonly IReadOnlyList<PhpConfigEntry> _orderedEntries;
|
||||
private readonly IReadOnlyList<string> _disabledFunctions;
|
||||
private readonly IReadOnlyList<string> _disabledClasses;
|
||||
|
||||
private PhpConfigCollection(
|
||||
FrozenDictionary<string, PhpConfigEntry> entries,
|
||||
IReadOnlyList<PhpConfigEntry> orderedEntries,
|
||||
IReadOnlyList<string> disabledFunctions,
|
||||
IReadOnlyList<string> disabledClasses,
|
||||
string? phpVersion,
|
||||
long? memoryLimit,
|
||||
long? uploadMaxFilesize,
|
||||
long? postMaxSize)
|
||||
{
|
||||
_entries = entries;
|
||||
_orderedEntries = orderedEntries;
|
||||
_disabledFunctions = disabledFunctions;
|
||||
_disabledClasses = disabledClasses;
|
||||
PhpVersion = phpVersion;
|
||||
MemoryLimit = memoryLimit;
|
||||
UploadMaxFilesize = uploadMaxFilesize;
|
||||
PostMaxSize = postMaxSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All configuration entries, ordered deterministically by key.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpConfigEntry> Entries => _orderedEntries;
|
||||
|
||||
/// <summary>
|
||||
/// Detected PHP version, if available.
|
||||
/// </summary>
|
||||
public string? PhpVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Memory limit in bytes, if configured.
|
||||
/// </summary>
|
||||
public long? MemoryLimit { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload max filesize in bytes, if configured.
|
||||
/// </summary>
|
||||
public long? UploadMaxFilesize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Post max size in bytes, if configured.
|
||||
/// </summary>
|
||||
public long? PostMaxSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// List of disabled functions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DisabledFunctions => _disabledFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// List of disabled classes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DisabledClasses => _disabledClasses;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there are any configuration entries.
|
||||
/// </summary>
|
||||
public bool HasEntries => _orderedEntries.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a configuration entry by key.
|
||||
/// </summary>
|
||||
public bool TryGetEntry(string key, [NotNullWhen(true)] out PhpConfigEntry? entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
entry = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _entries.TryGetValue(key.ToLowerInvariant(), out entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value of a configuration entry, or null if not found.
|
||||
/// </summary>
|
||||
public string? GetValue(string key)
|
||||
=> TryGetEntry(key, out var entry) ? entry.Value : null;
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for SBOM generation.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(PhpVersion))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.config.version", PhpVersion);
|
||||
}
|
||||
|
||||
if (MemoryLimit.HasValue)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.config.memory_limit", MemoryLimit.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (UploadMaxFilesize.HasValue)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.config.upload_max_filesize", UploadMaxFilesize.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (PostMaxSize.HasValue)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.config.post_max_size", PostMaxSize.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (_disabledFunctions.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.config.disabled_functions", string.Join(',', _disabledFunctions));
|
||||
}
|
||||
|
||||
if (_disabledClasses.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.config.disabled_classes", string.Join(',', _disabledClasses));
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("php.config.entry_count", _orderedEntries.Count.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empty configuration collection.
|
||||
/// </summary>
|
||||
public static PhpConfigCollection Empty { get; } = new(
|
||||
FrozenDictionary<string, PhpConfigEntry>.Empty,
|
||||
Array.Empty<PhpConfigEntry>(),
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<string>(),
|
||||
phpVersion: null,
|
||||
memoryLimit: null,
|
||||
uploadMaxFilesize: null,
|
||||
postMaxSize: null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a builder for constructing a configuration collection.
|
||||
/// </summary>
|
||||
public static Builder CreateBuilder() => new();
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing a PhpConfigCollection.
|
||||
/// </summary>
|
||||
internal sealed class Builder
|
||||
{
|
||||
private readonly Dictionary<string, PhpConfigEntry> _entries = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a configuration entry.
|
||||
/// Later entries for the same key override earlier ones.
|
||||
/// </summary>
|
||||
public Builder AddEntry(PhpConfigEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
_entries[entry.Key.ToLowerInvariant()] = entry;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple configuration entries.
|
||||
/// </summary>
|
||||
public Builder AddEntries(IEnumerable<PhpConfigEntry> entries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
AddEntry(entry);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the configuration collection.
|
||||
/// </summary>
|
||||
public PhpConfigCollection Build()
|
||||
{
|
||||
if (_entries.Count == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var orderedEntries = _entries.Values
|
||||
.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var phpVersion = ExtractPhpVersion();
|
||||
var memoryLimit = ParseBytes(GetValue("memory_limit"));
|
||||
var uploadMaxFilesize = ParseBytes(GetValue("upload_max_filesize"));
|
||||
var postMaxSize = ParseBytes(GetValue("post_max_size"));
|
||||
var disabledFunctions = ParseList(GetValue("disable_functions"));
|
||||
var disabledClasses = ParseList(GetValue("disable_classes"));
|
||||
|
||||
return new PhpConfigCollection(
|
||||
_entries.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
orderedEntries,
|
||||
disabledFunctions,
|
||||
disabledClasses,
|
||||
phpVersion,
|
||||
memoryLimit,
|
||||
uploadMaxFilesize,
|
||||
postMaxSize);
|
||||
}
|
||||
|
||||
private string? GetValue(string key)
|
||||
=> _entries.TryGetValue(key.ToLowerInvariant(), out var entry) ? entry.Value : null;
|
||||
|
||||
private string? ExtractPhpVersion()
|
||||
{
|
||||
// Check for explicit version indicators
|
||||
var version = GetValue("php_version");
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
// Could also be inferred from Dockerfile or env, but that's handled elsewhere
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ParseBytes(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
value = value.Trim();
|
||||
if (value == "-1")
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (long.TryParse(value, CultureInfo.InvariantCulture, out var bytes))
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
|
||||
var suffix = char.ToUpperInvariant(value[^1]);
|
||||
if (!long.TryParse(value[..^1], CultureInfo.InvariantCulture, out var number))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return suffix switch
|
||||
{
|
||||
'K' => number * 1024,
|
||||
'M' => number * 1024 * 1024,
|
||||
'G' => number * 1024 * 1024 * 1024,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collects PHP configuration from php.ini, conf.d, .htaccess, and FPM configs.
|
||||
/// </summary>
|
||||
internal static partial class PhpConfigCollector
|
||||
{
|
||||
private static readonly string[] PhpIniLocations =
|
||||
[
|
||||
"php.ini",
|
||||
"php/php.ini",
|
||||
"etc/php.ini",
|
||||
"etc/php/php.ini",
|
||||
"usr/local/etc/php/php.ini"
|
||||
];
|
||||
|
||||
private static readonly string[] ConfDLocations =
|
||||
[
|
||||
"conf.d",
|
||||
"php/conf.d",
|
||||
"etc/php/conf.d",
|
||||
"etc/php.d",
|
||||
"usr/local/etc/php/conf.d"
|
||||
];
|
||||
|
||||
private static readonly string[] FpmLocations =
|
||||
[
|
||||
"php-fpm.conf",
|
||||
"php-fpm.d",
|
||||
"etc/php-fpm.conf",
|
||||
"etc/php-fpm.d",
|
||||
"etc/php/fpm/php-fpm.conf",
|
||||
"etc/php/fpm/pool.d",
|
||||
"usr/local/etc/php-fpm.conf",
|
||||
"usr/local/etc/php-fpm.d"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Collects PHP configuration from the given root path.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpConfigCollection> CollectAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return PhpConfigCollection.Empty;
|
||||
}
|
||||
|
||||
var builder = PhpConfigCollection.CreateBuilder();
|
||||
|
||||
// Collect from php.ini files
|
||||
await CollectPhpIniAsync(rootPath, builder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect from conf.d directories
|
||||
await CollectConfDAsync(rootPath, builder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect from .htaccess files
|
||||
await CollectHtaccessAsync(rootPath, builder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect from FPM configs
|
||||
await CollectFpmAsync(rootPath, builder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static async ValueTask CollectPhpIniAsync(
|
||||
string rootPath,
|
||||
PhpConfigCollection.Builder builder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var location in PhpIniLocations)
|
||||
{
|
||||
var path = Path.Combine(rootPath, location);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entries = await ParseIniFileAsync(path, PhpConfigSource.PhpIni, cancellationToken).ConfigureAwait(false);
|
||||
builder.AddEntries(entries);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask CollectConfDAsync(
|
||||
string rootPath,
|
||||
PhpConfigCollection.Builder builder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var location in ConfDLocations)
|
||||
{
|
||||
var confDPath = Path.Combine(rootPath, location);
|
||||
if (!Directory.Exists(confDPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var iniFiles = Directory.GetFiles(confDPath, "*.ini", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal);
|
||||
|
||||
foreach (var iniFile in iniFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var entries = await ParseIniFileAsync(iniFile, PhpConfigSource.ConfD, cancellationToken).ConfigureAwait(false);
|
||||
builder.AddEntries(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask CollectHtaccessAsync(
|
||||
string rootPath,
|
||||
PhpConfigCollection.Builder builder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var htaccessPath = Path.Combine(rootPath, ".htaccess");
|
||||
if (!File.Exists(htaccessPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = await ParseHtaccessAsync(htaccessPath, cancellationToken).ConfigureAwait(false);
|
||||
builder.AddEntries(entries);
|
||||
}
|
||||
|
||||
private static async ValueTask CollectFpmAsync(
|
||||
string rootPath,
|
||||
PhpConfigCollection.Builder builder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var location in FpmLocations)
|
||||
{
|
||||
var fpmPath = Path.Combine(rootPath, location);
|
||||
|
||||
if (File.Exists(fpmPath) && fpmPath.EndsWith(".conf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var entries = await ParseFpmConfigAsync(fpmPath, PhpConfigSource.FpmGlobal, cancellationToken).ConfigureAwait(false);
|
||||
builder.AddEntries(entries);
|
||||
}
|
||||
else if (Directory.Exists(fpmPath))
|
||||
{
|
||||
var confFiles = Directory.GetFiles(fpmPath, "*.conf", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal);
|
||||
|
||||
foreach (var confFile in confFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var entries = await ParseFpmConfigAsync(confFile, PhpConfigSource.FpmPool, cancellationToken).ConfigureAwait(false);
|
||||
builder.AddEntries(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<IReadOnlyList<PhpConfigEntry>> ParseIniFileAsync(
|
||||
string path,
|
||||
PhpConfigSource source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<PhpConfigEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith(';') || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip section headers
|
||||
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key=value
|
||||
var equalsIndex = trimmed.IndexOf('=');
|
||||
if (equalsIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = trimmed[..equalsIndex].Trim();
|
||||
var value = trimmed[(equalsIndex + 1)..].Trim();
|
||||
|
||||
// Remove surrounding quotes
|
||||
if (value.Length >= 2 && ((value.StartsWith('"') && value.EndsWith('"')) || (value.StartsWith('\'') && value.EndsWith('\''))))
|
||||
{
|
||||
value = value[1..^1];
|
||||
}
|
||||
|
||||
entries.Add(new PhpConfigEntry(key, value, source, path));
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File inaccessible, skip
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static async ValueTask<IReadOnlyList<PhpConfigEntry>> ParseHtaccessAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<PhpConfigEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse php_value and php_flag directives
|
||||
var match = HtaccessPhpDirectiveRegex().Match(trimmed);
|
||||
if (match.Success)
|
||||
{
|
||||
var key = match.Groups["key"].Value;
|
||||
var value = match.Groups["value"].Value.Trim('"', '\'');
|
||||
entries.Add(new PhpConfigEntry(key, value, PhpConfigSource.Htaccess, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File inaccessible, skip
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static async ValueTask<IReadOnlyList<PhpConfigEntry>> ParseFpmConfigAsync(
|
||||
string path,
|
||||
PhpConfigSource source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<PhpConfigEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
string? currentSection = null;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith(';') || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track section headers
|
||||
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
|
||||
{
|
||||
currentSection = trimmed[1..^1];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse key=value
|
||||
var equalsIndex = trimmed.IndexOf('=');
|
||||
if (equalsIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = trimmed[..equalsIndex].Trim();
|
||||
var value = trimmed[(equalsIndex + 1)..].Trim();
|
||||
|
||||
// Handle php_admin_value and php_value directives
|
||||
if (key.StartsWith("php_admin_value", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.StartsWith("php_value", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.StartsWith("php_admin_flag", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.StartsWith("php_flag", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var bracketStart = key.IndexOf('[');
|
||||
var bracketEnd = key.IndexOf(']');
|
||||
if (bracketStart > 0 && bracketEnd > bracketStart)
|
||||
{
|
||||
var phpKey = key[(bracketStart + 1)..bracketEnd];
|
||||
entries.Add(new PhpConfigEntry(phpKey, value, source, path));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular FPM config key
|
||||
var fullKey = currentSection is not null ? $"fpm.{currentSection}.{key}" : $"fpm.{key}";
|
||||
entries.Add(new PhpConfigEntry(fullKey, value, source, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File inaccessible, skip
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^php_(?:value|flag|admin_value|admin_flag)\s+(?<key>\S+)\s+(?<value>.+)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex HtaccessPhpDirectiveRegex();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single PHP configuration entry.
|
||||
/// </summary>
|
||||
internal sealed record PhpConfigEntry
|
||||
{
|
||||
public PhpConfigEntry(
|
||||
string key,
|
||||
string? value,
|
||||
PhpConfigSource source,
|
||||
string? sourcePath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
throw new ArgumentException("Key is required", nameof(key));
|
||||
}
|
||||
|
||||
Key = key;
|
||||
Value = value;
|
||||
Source = source;
|
||||
SourcePath = sourcePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key (e.g., "memory_limit", "upload_max_filesize").
|
||||
/// </summary>
|
||||
public string Key { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The configuration value.
|
||||
/// </summary>
|
||||
public string? Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of this configuration entry.
|
||||
/// </summary>
|
||||
public PhpConfigSource Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the file where this configuration was found.
|
||||
/// </summary>
|
||||
public string? SourcePath { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the source of a PHP configuration entry.
|
||||
/// </summary>
|
||||
internal enum PhpConfigSource
|
||||
{
|
||||
/// <summary>php.ini file.</summary>
|
||||
PhpIni,
|
||||
|
||||
/// <summary>conf.d directory override.</summary>
|
||||
ConfD,
|
||||
|
||||
/// <summary>.htaccess file (Apache).</summary>
|
||||
Htaccess,
|
||||
|
||||
/// <summary>PHP-FPM pool configuration.</summary>
|
||||
FpmPool,
|
||||
|
||||
/// <summary>PHP-FPM global configuration.</summary>
|
||||
FpmGlobal,
|
||||
|
||||
/// <summary>Inferred from environment variables.</summary>
|
||||
Environment,
|
||||
|
||||
/// <summary>Container layer or Dockerfile directive.</summary>
|
||||
Container
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a PHP extension.
|
||||
/// </summary>
|
||||
internal sealed record PhpExtension(
|
||||
string Name,
|
||||
string? Version,
|
||||
string? LibraryPath,
|
||||
PhpExtensionSource Source,
|
||||
bool IsBundled,
|
||||
PhpExtensionCategory Category)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates metadata entries for this extension.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("extension.name", Name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Version))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("extension.version", Version);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(LibraryPath))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("extension.library", LibraryPath);
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("extension.source", Source.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("extension.bundled", IsBundled.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("extension.category", Category.ToString().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of extension configuration.
|
||||
/// </summary>
|
||||
internal enum PhpExtensionSource
|
||||
{
|
||||
/// <summary>Main php.ini.</summary>
|
||||
PhpIni,
|
||||
|
||||
/// <summary>conf.d directory.</summary>
|
||||
ConfD,
|
||||
|
||||
/// <summary>Compiled-in (bundled).</summary>
|
||||
Bundled,
|
||||
|
||||
/// <summary>Docker/container environment.</summary>
|
||||
Container,
|
||||
|
||||
/// <summary>Detected from usage.</summary>
|
||||
UsageDetected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Categories of PHP extensions.
|
||||
/// </summary>
|
||||
internal enum PhpExtensionCategory
|
||||
{
|
||||
/// <summary>Core PHP functionality.</summary>
|
||||
Core,
|
||||
|
||||
/// <summary>Database connectivity.</summary>
|
||||
Database,
|
||||
|
||||
/// <summary>Cryptography and security.</summary>
|
||||
Crypto,
|
||||
|
||||
/// <summary>Image processing.</summary>
|
||||
Image,
|
||||
|
||||
/// <summary>Compression.</summary>
|
||||
Compression,
|
||||
|
||||
/// <summary>XML processing.</summary>
|
||||
Xml,
|
||||
|
||||
/// <summary>Caching.</summary>
|
||||
Cache,
|
||||
|
||||
/// <summary>Debugging/profiling.</summary>
|
||||
Debug,
|
||||
|
||||
/// <summary>Network/protocol.</summary>
|
||||
Network,
|
||||
|
||||
/// <summary>Text processing.</summary>
|
||||
Text,
|
||||
|
||||
/// <summary>Other/unknown.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHP runtime environment settings.
|
||||
/// </summary>
|
||||
internal sealed class PhpEnvironmentSettings
|
||||
{
|
||||
public PhpEnvironmentSettings(
|
||||
IReadOnlyList<PhpExtension> extensions,
|
||||
PhpSecuritySettings security,
|
||||
PhpUploadSettings upload,
|
||||
PhpSessionSettings session,
|
||||
PhpErrorSettings error,
|
||||
PhpResourceLimits limits,
|
||||
IReadOnlyDictionary<string, string> webServerSettings)
|
||||
{
|
||||
Extensions = extensions ?? Array.Empty<PhpExtension>();
|
||||
Security = security;
|
||||
Upload = upload;
|
||||
Session = session;
|
||||
Error = error;
|
||||
Limits = limits;
|
||||
WebServerSettings = webServerSettings ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detected extensions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpExtension> Extensions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Security settings.
|
||||
/// </summary>
|
||||
public PhpSecuritySettings Security { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload settings.
|
||||
/// </summary>
|
||||
public PhpUploadSettings Upload { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Session settings.
|
||||
/// </summary>
|
||||
public PhpSessionSettings Session { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Error handling settings.
|
||||
/// </summary>
|
||||
public PhpErrorSettings Error { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits.
|
||||
/// </summary>
|
||||
public PhpResourceLimits Limits { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Web server specific settings.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> WebServerSettings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any environment settings were detected.
|
||||
/// </summary>
|
||||
public bool HasSettings => Extensions.Count > 0 ||
|
||||
Security.DisabledFunctions.Count > 0 ||
|
||||
!string.IsNullOrEmpty(Upload.MaxFileSize);
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for the environment.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("env.extension_count", Extensions.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
// Extension categories
|
||||
var categories = Extensions
|
||||
.GroupBy(e => e.Category)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
foreach (var (category, count) in categories.OrderBy(c => c.Key.ToString()))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"env.extensions_{category.ToString().ToLowerInvariant()}", count.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// Security settings
|
||||
foreach (var item in Security.CreateMetadata())
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
// Upload settings
|
||||
foreach (var item in Upload.CreateMetadata())
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
// Session settings
|
||||
foreach (var item in Session.CreateMetadata())
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
// Error settings
|
||||
foreach (var item in Error.CreateMetadata())
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
foreach (var item in Limits.CreateMetadata())
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpEnvironmentSettings Empty { get; } = new(
|
||||
Array.Empty<PhpExtension>(),
|
||||
PhpSecuritySettings.Default,
|
||||
PhpUploadSettings.Default,
|
||||
PhpSessionSettings.Default,
|
||||
PhpErrorSettings.Default,
|
||||
PhpResourceLimits.Default,
|
||||
new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHP security-related settings.
|
||||
/// </summary>
|
||||
internal sealed record PhpSecuritySettings(
|
||||
IReadOnlyList<string> DisabledFunctions,
|
||||
IReadOnlyList<string> DisabledClasses,
|
||||
bool OpenBasedir,
|
||||
string? OpenBasedirValue,
|
||||
bool AllowUrlFopen,
|
||||
bool AllowUrlInclude,
|
||||
bool ExposePhp,
|
||||
bool RegisterGlobals)
|
||||
{
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("security.disabled_functions_count", DisabledFunctions.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (DisabledFunctions.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("security.disabled_functions", string.Join(',', DisabledFunctions.Take(20)));
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("security.disabled_classes_count", DisabledClasses.Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("security.open_basedir", OpenBasedir.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("security.allow_url_fopen", AllowUrlFopen.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("security.allow_url_include", AllowUrlInclude.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("security.expose_php", ExposePhp.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
public static PhpSecuritySettings Default { get; } = new(
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<string>(),
|
||||
false, null, true, false, true, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHP upload settings.
|
||||
/// </summary>
|
||||
internal sealed record PhpUploadSettings(
|
||||
bool FileUploads,
|
||||
string? MaxFileSize,
|
||||
string? MaxPostSize,
|
||||
int MaxFileUploads,
|
||||
string? UploadTmpDir)
|
||||
{
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("upload.enabled", FileUploads.ToString().ToLowerInvariant());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MaxFileSize))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("upload.max_file_size", MaxFileSize);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MaxPostSize))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("upload.max_post_size", MaxPostSize);
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("upload.max_files", MaxFileUploads.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public static PhpUploadSettings Default { get; } = new(true, "2M", "8M", 20, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHP session settings.
|
||||
/// </summary>
|
||||
internal sealed record PhpSessionSettings(
|
||||
string? SaveHandler,
|
||||
string? SavePath,
|
||||
bool CookieHttponly,
|
||||
bool CookieSecure,
|
||||
string? CookieSamesite)
|
||||
{
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(SaveHandler))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("session.save_handler", SaveHandler);
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("session.cookie_httponly", CookieHttponly.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("session.cookie_secure", CookieSecure.ToString().ToLowerInvariant());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CookieSamesite))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("session.cookie_samesite", CookieSamesite);
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpSessionSettings Default { get; } = new("files", null, false, false, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHP error handling settings.
|
||||
/// </summary>
|
||||
internal sealed record PhpErrorSettings(
|
||||
bool DisplayErrors,
|
||||
bool DisplayStartupErrors,
|
||||
bool LogErrors,
|
||||
string? ErrorReporting)
|
||||
{
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("error.display_errors", DisplayErrors.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("error.display_startup_errors", DisplayStartupErrors.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("error.log_errors", LogErrors.ToString().ToLowerInvariant());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ErrorReporting))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("error.error_reporting", ErrorReporting);
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpErrorSettings Default { get; } = new(false, false, true, "E_ALL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHP resource limits.
|
||||
/// </summary>
|
||||
internal sealed record PhpResourceLimits(
|
||||
string? MemoryLimit,
|
||||
int MaxExecutionTime,
|
||||
int MaxInputTime,
|
||||
string? MaxInputVars)
|
||||
{
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(MemoryLimit))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("limits.memory_limit", MemoryLimit);
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("limits.max_execution_time", MaxExecutionTime.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("limits.max_input_time", MaxInputTime.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(MaxInputVars))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("limits.max_input_vars", MaxInputVars);
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpResourceLimits Default { get; } = new("128M", 30, 60, "1000");
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Scans PHP configuration for extensions and environment settings.
|
||||
/// </summary>
|
||||
internal static partial class PhpExtensionScanner
|
||||
{
|
||||
// Known extension category mappings
|
||||
private static readonly Dictionary<string, PhpExtensionCategory> ExtensionCategories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Database
|
||||
{ "mysqli", PhpExtensionCategory.Database },
|
||||
{ "pdo", PhpExtensionCategory.Database },
|
||||
{ "pdo_mysql", PhpExtensionCategory.Database },
|
||||
{ "pdo_pgsql", PhpExtensionCategory.Database },
|
||||
{ "pdo_sqlite", PhpExtensionCategory.Database },
|
||||
{ "pgsql", PhpExtensionCategory.Database },
|
||||
{ "sqlite3", PhpExtensionCategory.Database },
|
||||
{ "mongodb", PhpExtensionCategory.Database },
|
||||
{ "redis", PhpExtensionCategory.Database },
|
||||
{ "memcached", PhpExtensionCategory.Database },
|
||||
|
||||
// Crypto
|
||||
{ "openssl", PhpExtensionCategory.Crypto },
|
||||
{ "sodium", PhpExtensionCategory.Crypto },
|
||||
{ "mcrypt", PhpExtensionCategory.Crypto },
|
||||
{ "hash", PhpExtensionCategory.Crypto },
|
||||
|
||||
// Image
|
||||
{ "gd", PhpExtensionCategory.Image },
|
||||
{ "imagick", PhpExtensionCategory.Image },
|
||||
{ "exif", PhpExtensionCategory.Image },
|
||||
|
||||
// Compression
|
||||
{ "zip", PhpExtensionCategory.Compression },
|
||||
{ "zlib", PhpExtensionCategory.Compression },
|
||||
{ "bz2", PhpExtensionCategory.Compression },
|
||||
|
||||
// XML
|
||||
{ "xml", PhpExtensionCategory.Xml },
|
||||
{ "simplexml", PhpExtensionCategory.Xml },
|
||||
{ "dom", PhpExtensionCategory.Xml },
|
||||
{ "libxml", PhpExtensionCategory.Xml },
|
||||
{ "xmlreader", PhpExtensionCategory.Xml },
|
||||
{ "xmlwriter", PhpExtensionCategory.Xml },
|
||||
{ "xsl", PhpExtensionCategory.Xml },
|
||||
|
||||
// Cache
|
||||
{ "apcu", PhpExtensionCategory.Cache },
|
||||
{ "opcache", PhpExtensionCategory.Cache },
|
||||
|
||||
// Debug
|
||||
{ "xdebug", PhpExtensionCategory.Debug },
|
||||
{ "xhprof", PhpExtensionCategory.Debug },
|
||||
|
||||
// Network
|
||||
{ "curl", PhpExtensionCategory.Network },
|
||||
{ "sockets", PhpExtensionCategory.Network },
|
||||
{ "ftp", PhpExtensionCategory.Network },
|
||||
{ "soap", PhpExtensionCategory.Network },
|
||||
|
||||
// Text
|
||||
{ "mbstring", PhpExtensionCategory.Text },
|
||||
{ "iconv", PhpExtensionCategory.Text },
|
||||
{ "intl", PhpExtensionCategory.Text },
|
||||
{ "json", PhpExtensionCategory.Text },
|
||||
|
||||
// Core
|
||||
{ "core", PhpExtensionCategory.Core },
|
||||
{ "standard", PhpExtensionCategory.Core },
|
||||
{ "date", PhpExtensionCategory.Core },
|
||||
{ "pcre", PhpExtensionCategory.Core },
|
||||
{ "ctype", PhpExtensionCategory.Core },
|
||||
{ "tokenizer", PhpExtensionCategory.Core },
|
||||
{ "spl", PhpExtensionCategory.Core },
|
||||
{ "reflection", PhpExtensionCategory.Core },
|
||||
{ "phar", PhpExtensionCategory.Core },
|
||||
{ "fileinfo", PhpExtensionCategory.Core },
|
||||
{ "posix", PhpExtensionCategory.Core },
|
||||
{ "filter", PhpExtensionCategory.Core },
|
||||
{ "session", PhpExtensionCategory.Core }
|
||||
};
|
||||
|
||||
// Known bundled extensions (always present in PHP)
|
||||
private static readonly HashSet<string> BundledExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"core", "standard", "date", "pcre", "ctype", "tokenizer", "spl", "reflection",
|
||||
"json", "filter", "hash", "session", "phar"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Scans configuration for extensions and settings.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpEnvironmentSettings> ScanAsync(
|
||||
PhpConfigCollection config,
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(fileSystem);
|
||||
|
||||
var extensions = new List<PhpExtension>();
|
||||
var webServerSettings = new Dictionary<string, string>();
|
||||
|
||||
// Parse extensions from php.ini files
|
||||
var phpIniFiles = fileSystem.GetFilesByPattern("**/php.ini")
|
||||
.Concat(fileSystem.GetFilesByPattern("**/conf.d/*.ini"))
|
||||
.Concat(fileSystem.GetFilesByPattern("**/php-fpm.conf"))
|
||||
.Concat(fileSystem.GetFilesByPattern("**/fpm/pool.d/*.conf"));
|
||||
|
||||
foreach (var iniFile in phpIniFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(iniFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
extensions.AddRange(ParseExtensionsFromIni(content, iniFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Add bundled extensions
|
||||
foreach (var bundled in BundledExtensions)
|
||||
{
|
||||
if (!extensions.Any(e => e.Name.Equals(bundled, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
extensions.Add(new PhpExtension(
|
||||
bundled,
|
||||
null,
|
||||
null,
|
||||
PhpExtensionSource.Bundled,
|
||||
true,
|
||||
GetCategory(bundled)));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse security settings
|
||||
var security = ParseSecuritySettings(config);
|
||||
|
||||
// Parse upload settings
|
||||
var upload = ParseUploadSettings(config);
|
||||
|
||||
// Parse session settings
|
||||
var session = ParseSessionSettings(config);
|
||||
|
||||
// Parse error settings
|
||||
var error = ParseErrorSettings(config);
|
||||
|
||||
// Parse resource limits
|
||||
var limits = ParseResourceLimits(config);
|
||||
|
||||
// Detect web server from config
|
||||
webServerSettings = DetectWebServerSettings(config, fileSystem);
|
||||
|
||||
// Deduplicate and sort extensions
|
||||
var uniqueExtensions = extensions
|
||||
.GroupBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.First())
|
||||
.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new PhpEnvironmentSettings(
|
||||
uniqueExtensions,
|
||||
security,
|
||||
upload,
|
||||
session,
|
||||
error,
|
||||
limits,
|
||||
webServerSettings);
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpExtension> ParseExtensionsFromIni(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
var source = sourceFile.Contains("conf.d", StringComparison.OrdinalIgnoreCase)
|
||||
? PhpExtensionSource.ConfD
|
||||
: PhpExtensionSource.PhpIni;
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (string.IsNullOrWhiteSpace(trimmed) ||
|
||||
trimmed.StartsWith(';') ||
|
||||
trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match extension= or zend_extension=
|
||||
var match = ExtensionDirectiveRegex().Match(trimmed);
|
||||
if (match.Success)
|
||||
{
|
||||
var extValue = match.Groups["ext"].Value;
|
||||
|
||||
// Extract extension name from path or bare name
|
||||
var extName = Path.GetFileNameWithoutExtension(extValue)
|
||||
.Replace(".so", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(".dll", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Handle full paths
|
||||
string? libraryPath = null;
|
||||
if (extValue.Contains('/') || extValue.Contains('\\'))
|
||||
{
|
||||
libraryPath = extValue;
|
||||
extName = Path.GetFileNameWithoutExtension(extValue);
|
||||
}
|
||||
|
||||
var category = GetCategory(extName);
|
||||
var isBundled = BundledExtensions.Contains(extName);
|
||||
|
||||
yield return new PhpExtension(
|
||||
extName,
|
||||
null,
|
||||
libraryPath,
|
||||
source,
|
||||
isBundled,
|
||||
category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PhpSecuritySettings ParseSecuritySettings(PhpConfigCollection config)
|
||||
{
|
||||
var disabledFunctions = new List<string>();
|
||||
var disabledClasses = new List<string>();
|
||||
var openBasedir = false;
|
||||
string? openBasedirValue = null;
|
||||
var allowUrlFopen = true;
|
||||
var allowUrlInclude = false;
|
||||
var exposePhp = true;
|
||||
var registerGlobals = false;
|
||||
|
||||
// Parse disabled_functions
|
||||
var disabledFunctionsValue = config.GetValue("disable_functions");
|
||||
if (!string.IsNullOrWhiteSpace(disabledFunctionsValue))
|
||||
{
|
||||
disabledFunctions.AddRange(
|
||||
disabledFunctionsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
// Parse disabled_classes
|
||||
var disabledClassesValue = config.GetValue("disable_classes");
|
||||
if (!string.IsNullOrWhiteSpace(disabledClassesValue))
|
||||
{
|
||||
disabledClasses.AddRange(
|
||||
disabledClassesValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
// Parse open_basedir
|
||||
openBasedirValue = config.GetValue("open_basedir");
|
||||
openBasedir = !string.IsNullOrWhiteSpace(openBasedirValue);
|
||||
|
||||
// Parse allow_url_fopen
|
||||
var allowUrlFopenValue = config.GetValue("allow_url_fopen");
|
||||
if (!string.IsNullOrWhiteSpace(allowUrlFopenValue))
|
||||
{
|
||||
allowUrlFopen = ParseBoolValue(allowUrlFopenValue);
|
||||
}
|
||||
|
||||
// Parse allow_url_include
|
||||
var allowUrlIncludeValue = config.GetValue("allow_url_include");
|
||||
if (!string.IsNullOrWhiteSpace(allowUrlIncludeValue))
|
||||
{
|
||||
allowUrlInclude = ParseBoolValue(allowUrlIncludeValue);
|
||||
}
|
||||
|
||||
// Parse expose_php
|
||||
var exposePhpValue = config.GetValue("expose_php");
|
||||
if (!string.IsNullOrWhiteSpace(exposePhpValue))
|
||||
{
|
||||
exposePhp = ParseBoolValue(exposePhpValue);
|
||||
}
|
||||
|
||||
return new PhpSecuritySettings(
|
||||
disabledFunctions,
|
||||
disabledClasses,
|
||||
openBasedir,
|
||||
openBasedirValue,
|
||||
allowUrlFopen,
|
||||
allowUrlInclude,
|
||||
exposePhp,
|
||||
registerGlobals);
|
||||
}
|
||||
|
||||
private static PhpUploadSettings ParseUploadSettings(PhpConfigCollection config)
|
||||
{
|
||||
var fileUploads = true;
|
||||
var maxFileSize = "2M";
|
||||
var maxPostSize = "8M";
|
||||
var maxFileUploads = 20;
|
||||
string? uploadTmpDir = null;
|
||||
|
||||
var fileUploadsValue = config.GetValue("file_uploads");
|
||||
if (!string.IsNullOrWhiteSpace(fileUploadsValue))
|
||||
{
|
||||
fileUploads = ParseBoolValue(fileUploadsValue);
|
||||
}
|
||||
|
||||
var maxFileSizeValue = config.GetValue("upload_max_filesize");
|
||||
if (!string.IsNullOrWhiteSpace(maxFileSizeValue))
|
||||
{
|
||||
maxFileSize = maxFileSizeValue;
|
||||
}
|
||||
|
||||
var maxPostSizeValue = config.GetValue("post_max_size");
|
||||
if (!string.IsNullOrWhiteSpace(maxPostSizeValue))
|
||||
{
|
||||
maxPostSize = maxPostSizeValue;
|
||||
}
|
||||
|
||||
var maxFileUploadsValue = config.GetValue("max_file_uploads");
|
||||
if (!string.IsNullOrWhiteSpace(maxFileUploadsValue) && int.TryParse(maxFileUploadsValue, out var parsed))
|
||||
{
|
||||
maxFileUploads = parsed;
|
||||
}
|
||||
|
||||
uploadTmpDir = config.GetValue("upload_tmp_dir");
|
||||
|
||||
return new PhpUploadSettings(fileUploads, maxFileSize, maxPostSize, maxFileUploads, uploadTmpDir);
|
||||
}
|
||||
|
||||
private static PhpSessionSettings ParseSessionSettings(PhpConfigCollection config)
|
||||
{
|
||||
var saveHandler = config.GetValue("session.save_handler") ?? "files";
|
||||
var savePath = config.GetValue("session.save_path");
|
||||
var cookieHttponly = ParseBoolValue(config.GetValue("session.cookie_httponly") ?? "0");
|
||||
var cookieSecure = ParseBoolValue(config.GetValue("session.cookie_secure") ?? "0");
|
||||
var cookieSamesite = config.GetValue("session.cookie_samesite");
|
||||
|
||||
return new PhpSessionSettings(saveHandler, savePath, cookieHttponly, cookieSecure, cookieSamesite);
|
||||
}
|
||||
|
||||
private static PhpErrorSettings ParseErrorSettings(PhpConfigCollection config)
|
||||
{
|
||||
var displayErrors = ParseBoolValue(config.GetValue("display_errors") ?? "0");
|
||||
var displayStartupErrors = ParseBoolValue(config.GetValue("display_startup_errors") ?? "0");
|
||||
var logErrors = ParseBoolValue(config.GetValue("log_errors") ?? "1");
|
||||
var errorReporting = config.GetValue("error_reporting");
|
||||
|
||||
return new PhpErrorSettings(displayErrors, displayStartupErrors, logErrors, errorReporting);
|
||||
}
|
||||
|
||||
private static PhpResourceLimits ParseResourceLimits(PhpConfigCollection config)
|
||||
{
|
||||
var memoryLimit = config.GetValue("memory_limit") ?? "128M";
|
||||
var maxExecutionTime = 30;
|
||||
var maxInputTime = 60;
|
||||
var maxInputVars = config.GetValue("max_input_vars") ?? "1000";
|
||||
|
||||
var maxExecutionTimeValue = config.GetValue("max_execution_time");
|
||||
if (!string.IsNullOrWhiteSpace(maxExecutionTimeValue) && int.TryParse(maxExecutionTimeValue, out var execTime))
|
||||
{
|
||||
maxExecutionTime = execTime;
|
||||
}
|
||||
|
||||
var maxInputTimeValue = config.GetValue("max_input_time");
|
||||
if (!string.IsNullOrWhiteSpace(maxInputTimeValue) && int.TryParse(maxInputTimeValue, out var inputTime))
|
||||
{
|
||||
maxInputTime = inputTime;
|
||||
}
|
||||
|
||||
return new PhpResourceLimits(memoryLimit, maxExecutionTime, maxInputTime, maxInputVars);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> DetectWebServerSettings(
|
||||
PhpConfigCollection config,
|
||||
PhpVirtualFileSystem fileSystem)
|
||||
{
|
||||
var settings = new Dictionary<string, string>();
|
||||
|
||||
// Check for Nginx
|
||||
var nginxConf = fileSystem.GetFilesByPattern("**/nginx.conf").FirstOrDefault();
|
||||
if (nginxConf is not null)
|
||||
{
|
||||
settings["webserver.type"] = "nginx";
|
||||
}
|
||||
|
||||
// Check for Apache
|
||||
var apacheConf = fileSystem.GetFilesByPattern("**/.htaccess").FirstOrDefault()
|
||||
?? fileSystem.GetFilesByPattern("**/httpd.conf").FirstOrDefault()
|
||||
?? fileSystem.GetFilesByPattern("**/apache2.conf").FirstOrDefault();
|
||||
if (apacheConf is not null)
|
||||
{
|
||||
settings["webserver.type"] = settings.ContainsKey("webserver.type") ? "nginx+apache" : "apache";
|
||||
}
|
||||
|
||||
// Check for PHP-FPM
|
||||
var fpmConf = fileSystem.GetFilesByPattern("**/php-fpm.conf").FirstOrDefault();
|
||||
if (fpmConf is not null)
|
||||
{
|
||||
settings["php.handler"] = "fpm";
|
||||
}
|
||||
|
||||
// FPM pool settings from config
|
||||
var fpmPm = config.GetValue("pm");
|
||||
if (!string.IsNullOrWhiteSpace(fpmPm))
|
||||
{
|
||||
settings["fpm.pm"] = fpmPm;
|
||||
}
|
||||
|
||||
var fpmMaxChildren = config.GetValue("pm.max_children");
|
||||
if (!string.IsNullOrWhiteSpace(fpmMaxChildren))
|
||||
{
|
||||
settings["fpm.max_children"] = fpmMaxChildren;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static PhpExtensionCategory GetCategory(string extensionName)
|
||||
{
|
||||
return ExtensionCategories.TryGetValue(extensionName, out var category)
|
||||
? category
|
||||
: PhpExtensionCategory.Other;
|
||||
}
|
||||
|
||||
private static bool ParseBoolValue(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lower = value.Trim().ToLowerInvariant();
|
||||
return lower == "1" || lower == "on" || lower == "true" || lower == "yes";
|
||||
}
|
||||
|
||||
private static async ValueTask<string?> ReadFileAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*(?:zend_)?extension\s*=\s*[""']?(?<ext>[^""'\s]+)[""']?\s*$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ExtensionDirectiveRegex();
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected PHP framework or CMS fingerprint.
|
||||
/// </summary>
|
||||
internal sealed class PhpFrameworkFingerprint
|
||||
{
|
||||
private PhpFrameworkFingerprint(
|
||||
PhpFrameworkKind kind,
|
||||
string name,
|
||||
string? version,
|
||||
float confidence,
|
||||
IReadOnlyList<string> evidence)
|
||||
{
|
||||
Kind = kind;
|
||||
Name = name;
|
||||
Version = version;
|
||||
Confidence = confidence;
|
||||
Evidence = evidence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type of framework/CMS detected.
|
||||
/// </summary>
|
||||
public PhpFrameworkKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the framework/CMS.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected version, if available.
|
||||
/// </summary>
|
||||
public string? Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level (0.0 to 1.0).
|
||||
/// </summary>
|
||||
public float Confidence { get; }
|
||||
|
||||
/// <summary>
|
||||
/// List of file paths or patterns that contributed to this detection.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Evidence { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a framework/CMS was detected.
|
||||
/// </summary>
|
||||
public bool IsDetected => Kind != PhpFrameworkKind.None;
|
||||
|
||||
/// <summary>
|
||||
/// No framework detected.
|
||||
/// </summary>
|
||||
public static PhpFrameworkFingerprint None { get; } = new(
|
||||
PhpFrameworkKind.None,
|
||||
"unknown",
|
||||
null,
|
||||
0.0f,
|
||||
Array.Empty<string>());
|
||||
|
||||
/// <summary>
|
||||
/// Creates a fingerprint for a detected framework.
|
||||
/// </summary>
|
||||
public static PhpFrameworkFingerprint Create(
|
||||
PhpFrameworkKind kind,
|
||||
string name,
|
||||
string? version,
|
||||
float confidence,
|
||||
IEnumerable<string> evidence)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
|
||||
return new PhpFrameworkFingerprint(
|
||||
kind,
|
||||
name,
|
||||
version,
|
||||
Math.Clamp(confidence, 0.0f, 1.0f),
|
||||
evidence.OrderBy(e => e, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for SBOM generation.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
if (!IsDetected)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("php.framework.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("php.framework.name", Name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Version))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.framework.version", Version);
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("php.framework.confidence", Confidence.ToString("F2", CultureInfo.InvariantCulture));
|
||||
|
||||
if (Evidence.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.framework.evidence", string.Join(';', Evidence));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the type of PHP framework or CMS.
|
||||
/// </summary>
|
||||
internal enum PhpFrameworkKind
|
||||
{
|
||||
/// <summary>No framework detected.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Laravel framework.</summary>
|
||||
Laravel,
|
||||
|
||||
/// <summary>Symfony framework.</summary>
|
||||
Symfony,
|
||||
|
||||
/// <summary>CodeIgniter framework.</summary>
|
||||
CodeIgniter,
|
||||
|
||||
/// <summary>CakePHP framework.</summary>
|
||||
CakePHP,
|
||||
|
||||
/// <summary>Slim framework.</summary>
|
||||
Slim,
|
||||
|
||||
/// <summary>Laminas (Zend) framework.</summary>
|
||||
Laminas,
|
||||
|
||||
/// <summary>Yii framework.</summary>
|
||||
Yii,
|
||||
|
||||
/// <summary>WordPress CMS.</summary>
|
||||
WordPress,
|
||||
|
||||
/// <summary>Drupal CMS.</summary>
|
||||
Drupal,
|
||||
|
||||
/// <summary>Joomla CMS.</summary>
|
||||
Joomla,
|
||||
|
||||
/// <summary>Magento e-commerce.</summary>
|
||||
Magento,
|
||||
|
||||
/// <summary>PrestaShop e-commerce.</summary>
|
||||
PrestaShop,
|
||||
|
||||
/// <summary>MediaWiki.</summary>
|
||||
MediaWiki,
|
||||
|
||||
/// <summary>phpBB forum.</summary>
|
||||
PhpBB,
|
||||
|
||||
/// <summary>Craft CMS.</summary>
|
||||
Craft,
|
||||
|
||||
/// <summary>TYPO3 CMS.</summary>
|
||||
Typo3,
|
||||
|
||||
/// <summary>October CMS.</summary>
|
||||
October,
|
||||
|
||||
/// <summary>Custom/other framework.</summary>
|
||||
Custom
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects PHP frameworks and CMSs deterministically using file patterns and markers.
|
||||
/// </summary>
|
||||
internal static partial class PhpFrameworkFingerprinter
|
||||
{
|
||||
/// <summary>
|
||||
/// Fingerprint definitions for known frameworks and CMSs.
|
||||
/// Each definition includes file patterns and optional version extraction.
|
||||
/// </summary>
|
||||
private static readonly FrameworkDefinition[] Definitions =
|
||||
[
|
||||
// Laravel
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Laravel,
|
||||
"laravel",
|
||||
[
|
||||
"artisan",
|
||||
"bootstrap/app.php",
|
||||
"config/app.php",
|
||||
"routes/web.php",
|
||||
"app/Http/Kernel.php"
|
||||
],
|
||||
["vendor/laravel/framework"],
|
||||
"vendor/laravel/framework/src/Illuminate/Foundation/Application.php",
|
||||
LaravelVersionRegex()),
|
||||
|
||||
// Symfony
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Symfony,
|
||||
"symfony",
|
||||
[
|
||||
"bin/console",
|
||||
"config/bundles.php",
|
||||
"src/Kernel.php",
|
||||
"symfony.lock"
|
||||
],
|
||||
["vendor/symfony/framework-bundle"],
|
||||
"vendor/symfony/http-kernel/Kernel.php",
|
||||
SymfonyVersionRegex()),
|
||||
|
||||
// WordPress
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.WordPress,
|
||||
"wordpress",
|
||||
[
|
||||
"wp-config.php",
|
||||
"wp-includes/version.php",
|
||||
"wp-admin/admin.php",
|
||||
"wp-content/themes",
|
||||
"wp-content/plugins"
|
||||
],
|
||||
[],
|
||||
"wp-includes/version.php",
|
||||
WordPressVersionRegex()),
|
||||
|
||||
// Drupal
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Drupal,
|
||||
"drupal",
|
||||
[
|
||||
"core/lib/Drupal.php",
|
||||
"sites/default/settings.php",
|
||||
"core/modules",
|
||||
"modules/contrib"
|
||||
],
|
||||
["vendor/drupal/core"],
|
||||
"core/lib/Drupal.php",
|
||||
DrupalVersionRegex()),
|
||||
|
||||
// Joomla
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Joomla,
|
||||
"joomla",
|
||||
[
|
||||
"configuration.php",
|
||||
"libraries/src/Version.php",
|
||||
"administrator/index.php",
|
||||
"components"
|
||||
],
|
||||
[],
|
||||
"libraries/src/Version.php",
|
||||
JoomlaVersionRegex()),
|
||||
|
||||
// Magento
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Magento,
|
||||
"magento",
|
||||
[
|
||||
"app/Mage.php",
|
||||
"app/etc/config.php",
|
||||
"app/etc/env.php",
|
||||
"pub/index.php"
|
||||
],
|
||||
["vendor/magento/framework"],
|
||||
"vendor/magento/framework/App/ProductMetadata.php",
|
||||
MagentoVersionRegex()),
|
||||
|
||||
// CodeIgniter
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.CodeIgniter,
|
||||
"codeigniter",
|
||||
[
|
||||
"system/CodeIgniter.php",
|
||||
"app/Config/App.php",
|
||||
"spark"
|
||||
],
|
||||
["vendor/codeigniter4/framework"],
|
||||
"vendor/codeigniter4/framework/system/CodeIgniter.php",
|
||||
CodeIgniterVersionRegex()),
|
||||
|
||||
// CakePHP
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.CakePHP,
|
||||
"cakephp",
|
||||
[
|
||||
"bin/cake",
|
||||
"config/app.php",
|
||||
"src/Application.php"
|
||||
],
|
||||
["vendor/cakephp/cakephp"],
|
||||
"vendor/cakephp/cakephp/VERSION.txt",
|
||||
CakePHPVersionRegex()),
|
||||
|
||||
// Slim
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Slim,
|
||||
"slim",
|
||||
[
|
||||
"public/index.php"
|
||||
],
|
||||
["vendor/slim/slim"],
|
||||
"vendor/slim/slim/Slim/App.php",
|
||||
SlimVersionRegex()),
|
||||
|
||||
// Laminas (Zend)
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Laminas,
|
||||
"laminas",
|
||||
[
|
||||
"config/application.config.php",
|
||||
"module/Application"
|
||||
],
|
||||
["vendor/laminas/laminas-mvc"],
|
||||
null,
|
||||
null),
|
||||
|
||||
// Yii
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Yii,
|
||||
"yii",
|
||||
[
|
||||
"yii",
|
||||
"config/web.php",
|
||||
"controllers",
|
||||
"views"
|
||||
],
|
||||
["vendor/yiisoft/yii2"],
|
||||
"vendor/yiisoft/yii2/BaseYii.php",
|
||||
YiiVersionRegex()),
|
||||
|
||||
// MediaWiki
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.MediaWiki,
|
||||
"mediawiki",
|
||||
[
|
||||
"LocalSettings.php",
|
||||
"includes/DefaultSettings.php",
|
||||
"skins/Vector"
|
||||
],
|
||||
[],
|
||||
"includes/Defines.php",
|
||||
MediaWikiVersionRegex()),
|
||||
|
||||
// phpBB
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.PhpBB,
|
||||
"phpbb",
|
||||
[
|
||||
"config.php",
|
||||
"phpbb/di/container_builder.php",
|
||||
"styles/prosilver"
|
||||
],
|
||||
[],
|
||||
"phpbb/phpbb.php",
|
||||
PhpBBVersionRegex()),
|
||||
|
||||
// Craft CMS
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Craft,
|
||||
"craft",
|
||||
[
|
||||
"craft",
|
||||
"config/general.php",
|
||||
"templates"
|
||||
],
|
||||
["vendor/craftcms/cms"],
|
||||
"vendor/craftcms/cms/src/Craft.php",
|
||||
CraftVersionRegex()),
|
||||
|
||||
// TYPO3
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.Typo3,
|
||||
"typo3",
|
||||
[
|
||||
"typo3/sysext",
|
||||
"typo3conf/LocalConfiguration.php"
|
||||
],
|
||||
["vendor/typo3/cms-core"],
|
||||
"vendor/typo3/cms-core/Classes/Core/SystemEnvironmentBuilder.php",
|
||||
Typo3VersionRegex()),
|
||||
|
||||
// October CMS
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.October,
|
||||
"october",
|
||||
[
|
||||
"artisan",
|
||||
"modules/system",
|
||||
"plugins"
|
||||
],
|
||||
["vendor/october/rain"],
|
||||
"modules/system/classes/UpdateManager.php",
|
||||
OctoberVersionRegex()),
|
||||
|
||||
// PrestaShop
|
||||
new FrameworkDefinition(
|
||||
PhpFrameworkKind.PrestaShop,
|
||||
"prestashop",
|
||||
[
|
||||
"config/settings.inc.php",
|
||||
"classes/PrestaShopAutoload.php",
|
||||
"modules",
|
||||
"themes"
|
||||
],
|
||||
[],
|
||||
"config/settings.inc.php",
|
||||
PrestaShopVersionRegex())
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Detects the framework/CMS used in a PHP project.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpFrameworkFingerprint> DetectAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return PhpFrameworkFingerprint.None;
|
||||
}
|
||||
|
||||
// Evaluate each framework definition
|
||||
var candidates = new List<(FrameworkDefinition Definition, float Score, List<string> Evidence)>();
|
||||
|
||||
foreach (var definition in Definitions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (score, evidence) = EvaluateDefinition(rootPath, definition);
|
||||
if (score > 0)
|
||||
{
|
||||
candidates.Add((definition, score, evidence));
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return PhpFrameworkFingerprint.None;
|
||||
}
|
||||
|
||||
// Select the best match (highest score)
|
||||
var best = candidates.OrderByDescending(c => c.Score).First();
|
||||
var version = await TryExtractVersionAsync(rootPath, best.Definition, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return PhpFrameworkFingerprint.Create(
|
||||
best.Definition.Kind,
|
||||
best.Definition.Name,
|
||||
version,
|
||||
best.Score,
|
||||
best.Evidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects framework from composer packages (used as fallback or confirmation).
|
||||
/// </summary>
|
||||
public static PhpFrameworkFingerprint DetectFromPackages(IEnumerable<ComposerPackage> packages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
|
||||
foreach (var definition in Definitions)
|
||||
{
|
||||
foreach (var composerMarker in definition.ComposerPackageMarkers)
|
||||
{
|
||||
var matchingPackage = packages.FirstOrDefault(p =>
|
||||
p.Name.Equals(composerMarker, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingPackage is not null)
|
||||
{
|
||||
return PhpFrameworkFingerprint.Create(
|
||||
definition.Kind,
|
||||
definition.Name,
|
||||
matchingPackage.Version,
|
||||
0.95f,
|
||||
[composerMarker]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PhpFrameworkFingerprint.None;
|
||||
}
|
||||
|
||||
private static (float Score, List<string> Evidence) EvaluateDefinition(string rootPath, FrameworkDefinition definition)
|
||||
{
|
||||
var evidence = new List<string>();
|
||||
var matchCount = 0;
|
||||
|
||||
foreach (var marker in definition.FileMarkers)
|
||||
{
|
||||
var path = Path.Combine(rootPath, marker);
|
||||
if (File.Exists(path) || Directory.Exists(path))
|
||||
{
|
||||
evidence.Add(marker);
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount == 0)
|
||||
{
|
||||
return (0, evidence);
|
||||
}
|
||||
|
||||
// Score is based on how many markers matched
|
||||
var score = (float)matchCount / definition.FileMarkers.Length;
|
||||
|
||||
// Boost score if we matched multiple markers (more confident)
|
||||
if (matchCount >= 3)
|
||||
{
|
||||
score = Math.Min(1.0f, score + 0.1f);
|
||||
}
|
||||
|
||||
return (score, evidence);
|
||||
}
|
||||
|
||||
private static async ValueTask<string?> TryExtractVersionAsync(
|
||||
string rootPath,
|
||||
FrameworkDefinition definition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(definition.VersionFile) || definition.VersionRegex is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var versionPath = Path.Combine(rootPath, definition.VersionFile);
|
||||
if (!File.Exists(versionPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(versionPath, cancellationToken).ConfigureAwait(false);
|
||||
var match = definition.VersionRegex.Match(content);
|
||||
if (match.Success && match.Groups["version"].Success)
|
||||
{
|
||||
return match.Groups["version"].Value;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File inaccessible
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record FrameworkDefinition(
|
||||
PhpFrameworkKind Kind,
|
||||
string Name,
|
||||
string[] FileMarkers,
|
||||
string[] ComposerPackageMarkers,
|
||||
string? VersionFile,
|
||||
Regex? VersionRegex);
|
||||
|
||||
// Version extraction regexes
|
||||
[GeneratedRegex(@"const\s+VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex LaravelVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"const\s+VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex SymfonyVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"\$wp_version\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex WordPressVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"const\s+VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex DrupalVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"public\s+const\s+RELEASE\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex JoomlaVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"const\s+VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex MagentoVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"const\s+CI_VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex CodeIgniterVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"(?<version>\d+\.\d+\.\d+)")]
|
||||
private static partial Regex CakePHPVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"const\s+VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex SlimVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"'version'\s*=>\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex YiiVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"define\s*\(\s*['""]MW_VERSION['""]\s*,\s*['""](?<version>[^'""]+)['""]\s*\)")]
|
||||
private static partial Regex MediaWikiVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"'version'\s*=>\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex PhpBBVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"const\s+VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex CraftVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"TYPO3_version\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex Typo3VersionRegex();
|
||||
|
||||
[GeneratedRegex(@"const\s+VERSION\s*=\s*['""](?<version>[^'""]+)['""]")]
|
||||
private static partial Regex OctoberVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"define\s*\(\s*['""]_PS_VERSION_['""]\s*,\s*['""](?<version>[^'""]+)['""]\s*\)")]
|
||||
private static partial Regex PrestaShopVersionRegex();
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the attack surface extracted from a PHP framework/CMS.
|
||||
/// </summary>
|
||||
internal sealed class PhpFrameworkSurface
|
||||
{
|
||||
public PhpFrameworkSurface(
|
||||
IReadOnlyList<PhpRoute> routes,
|
||||
IReadOnlyList<PhpController> controllers,
|
||||
IReadOnlyList<PhpMiddleware> middlewares,
|
||||
IReadOnlyList<PhpCliCommand> cliCommands,
|
||||
IReadOnlyList<PhpCronJob> cronJobs,
|
||||
IReadOnlyList<PhpEventListener> eventListeners)
|
||||
{
|
||||
Routes = routes ?? Array.Empty<PhpRoute>();
|
||||
Controllers = controllers ?? Array.Empty<PhpController>();
|
||||
Middlewares = middlewares ?? Array.Empty<PhpMiddleware>();
|
||||
CliCommands = cliCommands ?? Array.Empty<PhpCliCommand>();
|
||||
CronJobs = cronJobs ?? Array.Empty<PhpCronJob>();
|
||||
EventListeners = eventListeners ?? Array.Empty<PhpEventListener>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP routes/endpoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpRoute> Routes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Controllers/handlers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpController> Controllers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Middleware classes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpMiddleware> Middlewares { get; }
|
||||
|
||||
/// <summary>
|
||||
/// CLI commands.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpCliCommand> CliCommands { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cron jobs/scheduled tasks.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpCronJob> CronJobs { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Event listeners/hooks.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpEventListener> EventListeners { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any surface elements were found.
|
||||
/// </summary>
|
||||
public bool HasSurface =>
|
||||
Routes.Count > 0 ||
|
||||
Controllers.Count > 0 ||
|
||||
Middlewares.Count > 0 ||
|
||||
CliCommands.Count > 0 ||
|
||||
CronJobs.Count > 0 ||
|
||||
EventListeners.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for the surface.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("surface.route_count", Routes.Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("surface.controller_count", Controllers.Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("surface.middleware_count", Middlewares.Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("surface.cli_command_count", CliCommands.Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("surface.cron_job_count", CronJobs.Count.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("surface.event_listener_count", EventListeners.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
// HTTP methods used
|
||||
var httpMethods = Routes
|
||||
.SelectMany(r => r.Methods)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(m => m, StringComparer.OrdinalIgnoreCase);
|
||||
yield return new KeyValuePair<string, string?>("surface.http_methods", string.Join(',', httpMethods));
|
||||
|
||||
// Auth-protected vs public routes
|
||||
var protectedRoutes = Routes.Count(r => r.RequiresAuth);
|
||||
var publicRoutes = Routes.Count - protectedRoutes;
|
||||
yield return new KeyValuePair<string, string?>("surface.protected_routes", protectedRoutes.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("surface.public_routes", publicRoutes.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
// Route patterns (first 10)
|
||||
if (Routes.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"surface.route_patterns",
|
||||
string.Join(';', Routes.Take(10).Select(r => r.Pattern)));
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpFrameworkSurface Empty { get; } = new(
|
||||
Array.Empty<PhpRoute>(),
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP route definition.
|
||||
/// </summary>
|
||||
internal sealed record PhpRoute(
|
||||
string Pattern,
|
||||
IReadOnlyList<string> Methods,
|
||||
string? Controller,
|
||||
string? Action,
|
||||
string? Name,
|
||||
bool RequiresAuth,
|
||||
IReadOnlyList<string> Middlewares,
|
||||
string SourceFile,
|
||||
int SourceLine);
|
||||
|
||||
/// <summary>
|
||||
/// Controller class.
|
||||
/// </summary>
|
||||
internal sealed record PhpController(
|
||||
string ClassName,
|
||||
string? Namespace,
|
||||
string SourceFile,
|
||||
IReadOnlyList<string> Actions,
|
||||
bool IsApiController);
|
||||
|
||||
/// <summary>
|
||||
/// Middleware class.
|
||||
/// </summary>
|
||||
internal sealed record PhpMiddleware(
|
||||
string ClassName,
|
||||
string? Namespace,
|
||||
string SourceFile,
|
||||
PhpMiddlewareKind Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Middleware kinds.
|
||||
/// </summary>
|
||||
internal enum PhpMiddlewareKind
|
||||
{
|
||||
/// <summary>General middleware.</summary>
|
||||
General,
|
||||
|
||||
/// <summary>Authentication middleware.</summary>
|
||||
Auth,
|
||||
|
||||
/// <summary>CORS middleware.</summary>
|
||||
Cors,
|
||||
|
||||
/// <summary>Rate limiting middleware.</summary>
|
||||
RateLimit,
|
||||
|
||||
/// <summary>Logging/monitoring middleware.</summary>
|
||||
Logging,
|
||||
|
||||
/// <summary>Security middleware.</summary>
|
||||
Security
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI command.
|
||||
/// </summary>
|
||||
internal sealed record PhpCliCommand(
|
||||
string Name,
|
||||
string? Description,
|
||||
string ClassName,
|
||||
string SourceFile);
|
||||
|
||||
/// <summary>
|
||||
/// Cron job/scheduled task.
|
||||
/// </summary>
|
||||
internal sealed record PhpCronJob(
|
||||
string Schedule,
|
||||
string Handler,
|
||||
string? Description,
|
||||
string SourceFile);
|
||||
|
||||
/// <summary>
|
||||
/// Event listener/hook.
|
||||
/// </summary>
|
||||
internal sealed record PhpEventListener(
|
||||
string EventName,
|
||||
string Handler,
|
||||
int Priority,
|
||||
string SourceFile);
|
||||
@@ -0,0 +1,888 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Scans PHP frameworks/CMS for attack surface (routes, controllers, middleware, etc.).
|
||||
/// </summary>
|
||||
internal static partial class PhpFrameworkSurfaceScanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans a project for framework surface based on detected framework.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpFrameworkSurface> ScanAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
PhpFrameworkFingerprint framework,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileSystem);
|
||||
ArgumentNullException.ThrowIfNull(framework);
|
||||
|
||||
return framework.Kind switch
|
||||
{
|
||||
PhpFrameworkKind.Laravel => await ScanLaravelAsync(fileSystem, cancellationToken).ConfigureAwait(false),
|
||||
PhpFrameworkKind.Symfony => await ScanSymfonyAsync(fileSystem, cancellationToken).ConfigureAwait(false),
|
||||
PhpFrameworkKind.Slim => await ScanSlimAsync(fileSystem, cancellationToken).ConfigureAwait(false),
|
||||
PhpFrameworkKind.WordPress => await ScanWordPressAsync(fileSystem, cancellationToken).ConfigureAwait(false),
|
||||
PhpFrameworkKind.Drupal => await ScanDrupalAsync(fileSystem, cancellationToken).ConfigureAwait(false),
|
||||
PhpFrameworkKind.Magento => await ScanMagentoAsync(fileSystem, cancellationToken).ConfigureAwait(false),
|
||||
_ => await ScanGenericAsync(fileSystem, cancellationToken).ConfigureAwait(false)
|
||||
};
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpFrameworkSurface> ScanLaravelAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routes = new List<PhpRoute>();
|
||||
var controllers = new List<PhpController>();
|
||||
var middlewares = new List<PhpMiddleware>();
|
||||
var cliCommands = new List<PhpCliCommand>();
|
||||
var cronJobs = new List<PhpCronJob>();
|
||||
var eventListeners = new List<PhpEventListener>();
|
||||
|
||||
// Scan routes/web.php and routes/api.php
|
||||
foreach (var routeFile in fileSystem.GetFilesByPattern("routes/*.php"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(routeFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
routes.AddRange(ParseLaravelRoutes(content, routeFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan app/Http/Controllers
|
||||
foreach (var controllerFile in fileSystem.GetFilesByPattern("**/Controllers/**/*.php"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(controllerFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
var controller = ParseController(content, controllerFile.RelativePath);
|
||||
if (controller is not null)
|
||||
{
|
||||
controllers.Add(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan app/Http/Middleware
|
||||
foreach (var middlewareFile in fileSystem.GetFilesByPattern("**/Middleware/**/*.php"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(middlewareFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
var middleware = ParseMiddleware(content, middlewareFile.RelativePath);
|
||||
if (middleware is not null)
|
||||
{
|
||||
middlewares.Add(middleware);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan app/Console/Commands
|
||||
foreach (var commandFile in fileSystem.GetFilesByPattern("**/Console/Commands/**/*.php"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(commandFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
var command = ParseLaravelCommand(content, commandFile.RelativePath);
|
||||
if (command is not null)
|
||||
{
|
||||
cliCommands.Add(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan app/Console/Kernel.php for scheduled tasks
|
||||
var kernelFile = fileSystem.GetFilesByPattern("**/Console/Kernel.php").FirstOrDefault();
|
||||
if (kernelFile is not null)
|
||||
{
|
||||
var content = await ReadFileAsync(kernelFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
cronJobs.AddRange(ParseLaravelSchedule(content, kernelFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan app/Providers/EventServiceProvider.php for event listeners
|
||||
var eventProviderFile = fileSystem.GetFilesByPattern("**/EventServiceProvider.php").FirstOrDefault();
|
||||
if (eventProviderFile is not null)
|
||||
{
|
||||
var content = await ReadFileAsync(eventProviderFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
eventListeners.AddRange(ParseLaravelEvents(content, eventProviderFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpFrameworkSurface(routes, controllers, middlewares, cliCommands, cronJobs, eventListeners);
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpFrameworkSurface> ScanSymfonyAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routes = new List<PhpRoute>();
|
||||
var controllers = new List<PhpController>();
|
||||
var middlewares = new List<PhpMiddleware>();
|
||||
var cliCommands = new List<PhpCliCommand>();
|
||||
var eventListeners = new List<PhpEventListener>();
|
||||
|
||||
// Scan config/routes.yaml or config/routes/*.yaml
|
||||
foreach (var routeFile in fileSystem.GetFilesByPattern("config/routes*.yaml")
|
||||
.Concat(fileSystem.GetFilesByPattern("config/routes/**/*.yaml")))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(routeFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
routes.AddRange(ParseSymfonyYamlRoutes(content, routeFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan src/Controller for attribute-based routes
|
||||
foreach (var controllerFile in fileSystem.GetFilesByPattern("**/Controller/**/*.php"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(controllerFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
var controller = ParseController(content, controllerFile.RelativePath);
|
||||
if (controller is not null)
|
||||
{
|
||||
controllers.Add(controller);
|
||||
}
|
||||
|
||||
routes.AddRange(ParseSymfonyAttributeRoutes(content, controllerFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan src/Command for CLI commands
|
||||
foreach (var commandFile in fileSystem.GetFilesByPattern("**/Command/**/*.php"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(commandFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
var command = ParseSymfonyCommand(content, commandFile.RelativePath);
|
||||
if (command is not null)
|
||||
{
|
||||
cliCommands.Add(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan src/EventSubscriber for event listeners
|
||||
foreach (var subscriberFile in fileSystem.GetFilesByPattern("**/EventSubscriber/**/*.php")
|
||||
.Concat(fileSystem.GetFilesByPattern("**/EventListener/**/*.php")))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(subscriberFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
eventListeners.AddRange(ParseSymfonyEventSubscriber(content, subscriberFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpFrameworkSurface(routes, controllers, middlewares, cliCommands, Array.Empty<PhpCronJob>(), eventListeners);
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpFrameworkSurface> ScanSlimAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routes = new List<PhpRoute>();
|
||||
|
||||
// Scan common Slim route files
|
||||
foreach (var routeFile in fileSystem.GetFilesByPattern("**/routes.php")
|
||||
.Concat(fileSystem.GetFilesByPattern("**/routes/**/*.php"))
|
||||
.Concat(fileSystem.GetFilesByPattern("**/src/Application/routes.php")))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(routeFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
routes.AddRange(ParseSlimRoutes(content, routeFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpFrameworkSurface(
|
||||
routes,
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpFrameworkSurface> ScanWordPressAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routes = new List<PhpRoute>();
|
||||
var eventListeners = new List<PhpEventListener>();
|
||||
|
||||
// Scan for REST API routes (register_rest_route)
|
||||
foreach (var phpFile in fileSystem.GetPhpFiles().Where(f => f.Source == PhpFileSource.SourceTree))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(phpFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
routes.AddRange(ParseWordPressRestRoutes(content, phpFile.RelativePath));
|
||||
eventListeners.AddRange(ParseWordPressHooks(content, phpFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpFrameworkSurface(
|
||||
routes,
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
eventListeners);
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpFrameworkSurface> ScanDrupalAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routes = new List<PhpRoute>();
|
||||
var eventListeners = new List<PhpEventListener>();
|
||||
|
||||
// Scan *.routing.yml files
|
||||
foreach (var routingFile in fileSystem.GetFilesByPattern("**/*.routing.yml"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(routingFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
routes.AddRange(ParseDrupalRouting(content, routingFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan *.services.yml for event subscribers
|
||||
foreach (var servicesFile in fileSystem.GetFilesByPattern("**/*.services.yml"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(servicesFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
eventListeners.AddRange(ParseDrupalServices(content, servicesFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpFrameworkSurface(
|
||||
routes,
|
||||
Array.Empty<PhpController>(),
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
eventListeners);
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpFrameworkSurface> ScanMagentoAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routes = new List<PhpRoute>();
|
||||
var controllers = new List<PhpController>();
|
||||
|
||||
// Scan etc/frontend/routes.xml and etc/adminhtml/routes.xml
|
||||
foreach (var routeFile in fileSystem.GetFilesByPattern("**/etc/**/routes.xml"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(routeFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
routes.AddRange(ParseMagentoRoutes(content, routeFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan Controller directories
|
||||
foreach (var controllerFile in fileSystem.GetFilesByPattern("**/Controller/**/*.php"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(controllerFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
var controller = ParseController(content, controllerFile.RelativePath);
|
||||
if (controller is not null)
|
||||
{
|
||||
controllers.Add(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpFrameworkSurface(
|
||||
routes,
|
||||
controllers,
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpFrameworkSurface> ScanGenericAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var routes = new List<PhpRoute>();
|
||||
var controllers = new List<PhpController>();
|
||||
|
||||
// Generic scanning for common patterns
|
||||
foreach (var phpFile in fileSystem.GetPhpFiles().Where(f => f.Source == PhpFileSource.SourceTree).Take(100))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var content = await ReadFileAsync(phpFile.AbsolutePath, cancellationToken).ConfigureAwait(false);
|
||||
if (content is not null)
|
||||
{
|
||||
// Look for controller-like classes
|
||||
if (phpFile.RelativePath.Contains("Controller", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var controller = ParseController(content, phpFile.RelativePath);
|
||||
if (controller is not null)
|
||||
{
|
||||
controllers.Add(controller);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for generic routing patterns
|
||||
routes.AddRange(ParseGenericRoutes(content, phpFile.RelativePath));
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpFrameworkSurface(
|
||||
routes,
|
||||
controllers,
|
||||
Array.Empty<PhpMiddleware>(),
|
||||
Array.Empty<PhpCliCommand>(),
|
||||
Array.Empty<PhpCronJob>(),
|
||||
Array.Empty<PhpEventListener>());
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private static async ValueTask<string?> ReadFileAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Laravel parsers
|
||||
private static IEnumerable<PhpRoute> ParseLaravelRoutes(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var match = LaravelRouteRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
var method = match.Groups["method"].Value.ToUpperInvariant();
|
||||
var pattern = match.Groups["pattern"].Value;
|
||||
var handler = match.Groups["handler"].Value;
|
||||
|
||||
var methods = method == "ANY" || method == "MATCH"
|
||||
? new[] { "GET", "POST", "PUT", "PATCH", "DELETE" }
|
||||
: new[] { method };
|
||||
|
||||
// Parse controller@action or closure
|
||||
string? controller = null;
|
||||
string? action = null;
|
||||
if (handler.Contains('@'))
|
||||
{
|
||||
var parts = handler.Split('@');
|
||||
controller = parts[0];
|
||||
action = parts.Length > 1 ? parts[1] : null;
|
||||
}
|
||||
else if (handler.Contains("::class"))
|
||||
{
|
||||
controller = handler.Replace("::class", "").Trim('[', ']', ',', ' ', '\'');
|
||||
}
|
||||
|
||||
// Check for middleware
|
||||
var middlewares = new List<string>();
|
||||
var middlewareMatch = LaravelMiddlewareRegex().Match(line);
|
||||
if (middlewareMatch.Success)
|
||||
{
|
||||
middlewares.Add(middlewareMatch.Groups["middleware"].Value);
|
||||
}
|
||||
|
||||
var requiresAuth = middlewares.Any(m =>
|
||||
m.Contains("auth", StringComparison.OrdinalIgnoreCase) ||
|
||||
m.Contains("sanctum", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Check for route name
|
||||
string? name = null;
|
||||
var nameMatch = LaravelRouteNameRegex().Match(line);
|
||||
if (nameMatch.Success)
|
||||
{
|
||||
name = nameMatch.Groups["name"].Value;
|
||||
}
|
||||
|
||||
yield return new PhpRoute(pattern, methods, controller, action, name, requiresAuth, middlewares, sourceFile, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PhpCliCommand? ParseLaravelCommand(string content, string sourceFile)
|
||||
{
|
||||
var signatureMatch = LaravelCommandSignatureRegex().Match(content);
|
||||
if (!signatureMatch.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var className = ExtractClassName(content);
|
||||
if (className is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var description = LaravelCommandDescriptionRegex().Match(content);
|
||||
|
||||
return new PhpCliCommand(
|
||||
signatureMatch.Groups["signature"].Value,
|
||||
description.Success ? description.Groups["description"].Value : null,
|
||||
className,
|
||||
sourceFile);
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpCronJob> ParseLaravelSchedule(string content, string sourceFile)
|
||||
{
|
||||
foreach (Match match in LaravelScheduleRegex().Matches(content))
|
||||
{
|
||||
var handler = match.Groups["handler"].Value;
|
||||
var schedule = match.Groups["schedule"].Value;
|
||||
|
||||
yield return new PhpCronJob(schedule, handler, null, sourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpEventListener> ParseLaravelEvents(string content, string sourceFile)
|
||||
{
|
||||
// Look for $listen array
|
||||
var listenMatch = LaravelListenArrayRegex().Match(content);
|
||||
if (!listenMatch.Success)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (Match eventMatch in LaravelEventMappingRegex().Matches(content))
|
||||
{
|
||||
var eventName = eventMatch.Groups["event"].Value;
|
||||
var listener = eventMatch.Groups["listener"].Value;
|
||||
|
||||
yield return new PhpEventListener(eventName, listener, 0, sourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Symfony parsers
|
||||
private static IEnumerable<PhpRoute> ParseSymfonyYamlRoutes(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
string? currentRoute = null;
|
||||
string? currentPath = null;
|
||||
var currentMethods = new List<string>();
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
// Route name (no indentation)
|
||||
if (!line.StartsWith(' ') && !line.StartsWith('\t') && line.Contains(':'))
|
||||
{
|
||||
if (currentRoute is not null && currentPath is not null)
|
||||
{
|
||||
yield return new PhpRoute(
|
||||
currentPath,
|
||||
currentMethods.Count > 0 ? currentMethods.ToArray() : new[] { "GET" },
|
||||
null, null, currentRoute, false, Array.Empty<string>(), sourceFile, i);
|
||||
}
|
||||
|
||||
currentRoute = line.TrimEnd(':');
|
||||
currentPath = null;
|
||||
currentMethods.Clear();
|
||||
}
|
||||
else if (line.Contains("path:"))
|
||||
{
|
||||
currentPath = line.Split(':').LastOrDefault()?.Trim().Trim('"', '\'');
|
||||
}
|
||||
else if (line.Contains("methods:"))
|
||||
{
|
||||
var methods = line.Split(':').LastOrDefault()?.Trim();
|
||||
if (methods is not null)
|
||||
{
|
||||
currentMethods.AddRange(methods.Trim('[', ']').Split(',').Select(m => m.Trim().Trim('"', '\'')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRoute is not null && currentPath is not null)
|
||||
{
|
||||
yield return new PhpRoute(
|
||||
currentPath,
|
||||
currentMethods.Count > 0 ? currentMethods.ToArray() : new[] { "GET" },
|
||||
null, null, currentRoute, false, Array.Empty<string>(), sourceFile, lines.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpRoute> ParseSymfonyAttributeRoutes(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
var className = ExtractClassName(content);
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var match = SymfonyRouteAttributeRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
var path = match.Groups["path"].Value;
|
||||
var name = match.Groups["name"].Success ? match.Groups["name"].Value : null;
|
||||
var methods = match.Groups["methods"].Success
|
||||
? match.Groups["methods"].Value.Split(',').Select(m => m.Trim().Trim('"', '\'')).ToArray()
|
||||
: new[] { "GET" };
|
||||
|
||||
yield return new PhpRoute(path, methods, className, null, name, false, Array.Empty<string>(), sourceFile, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PhpCliCommand? ParseSymfonyCommand(string content, string sourceFile)
|
||||
{
|
||||
var nameMatch = SymfonyCommandNameRegex().Match(content);
|
||||
if (!nameMatch.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var className = ExtractClassName(content);
|
||||
if (className is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var descriptionMatch = SymfonyCommandDescriptionRegex().Match(content);
|
||||
|
||||
return new PhpCliCommand(
|
||||
nameMatch.Groups["name"].Value,
|
||||
descriptionMatch.Success ? descriptionMatch.Groups["description"].Value : null,
|
||||
className,
|
||||
sourceFile);
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpEventListener> ParseSymfonyEventSubscriber(string content, string sourceFile)
|
||||
{
|
||||
foreach (Match match in SymfonySubscribedEventsRegex().Matches(content))
|
||||
{
|
||||
var eventName = match.Groups["event"].Value;
|
||||
var handler = match.Groups["handler"].Value;
|
||||
|
||||
yield return new PhpEventListener(eventName, handler, 0, sourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Slim parser
|
||||
private static IEnumerable<PhpRoute> ParseSlimRoutes(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var match = SlimRouteRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
var method = match.Groups["method"].Value.ToUpperInvariant();
|
||||
var pattern = match.Groups["pattern"].Value;
|
||||
|
||||
yield return new PhpRoute(
|
||||
pattern,
|
||||
new[] { method },
|
||||
null, null, null, false, Array.Empty<string>(), sourceFile, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WordPress parsers
|
||||
private static IEnumerable<PhpRoute> ParseWordPressRestRoutes(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var match = WordPressRestRouteRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
var @namespace = match.Groups["namespace"].Value;
|
||||
var route = match.Groups["route"].Value;
|
||||
|
||||
yield return new PhpRoute(
|
||||
$"/{@namespace}/{route}",
|
||||
new[] { "GET", "POST" },
|
||||
null, null, null, false, Array.Empty<string>(), sourceFile, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpEventListener> ParseWordPressHooks(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var match = WordPressHookRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
var hookName = match.Groups["hook"].Value;
|
||||
var callback = match.Groups["callback"].Value;
|
||||
var priority = int.TryParse(match.Groups["priority"].Value, out var p) ? p : 10;
|
||||
|
||||
yield return new PhpEventListener(hookName, callback, priority, sourceFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drupal parsers
|
||||
private static IEnumerable<PhpRoute> ParseDrupalRouting(string content, string sourceFile)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
string? currentRoute = null;
|
||||
string? currentPath = null;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
if (!line.StartsWith(' ') && !line.StartsWith('\t') && line.Contains(':'))
|
||||
{
|
||||
if (currentRoute is not null && currentPath is not null)
|
||||
{
|
||||
yield return new PhpRoute(currentPath, new[] { "GET" }, null, null, currentRoute, false, Array.Empty<string>(), sourceFile, i);
|
||||
}
|
||||
|
||||
currentRoute = line.TrimEnd(':');
|
||||
currentPath = null;
|
||||
}
|
||||
else if (line.Contains("path:"))
|
||||
{
|
||||
currentPath = line.Split(':').LastOrDefault()?.Trim().Trim('"', '\'');
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRoute is not null && currentPath is not null)
|
||||
{
|
||||
yield return new PhpRoute(currentPath, new[] { "GET" }, null, null, currentRoute, false, Array.Empty<string>(), sourceFile, lines.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpEventListener> ParseDrupalServices(string content, string sourceFile)
|
||||
{
|
||||
if (content.Contains("event_subscriber"))
|
||||
{
|
||||
var classMatch = DrupalServiceClassRegex().Match(content);
|
||||
if (classMatch.Success)
|
||||
{
|
||||
yield return new PhpEventListener("kernel.event", classMatch.Groups["class"].Value, 0, sourceFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Magento parser
|
||||
private static IEnumerable<PhpRoute> ParseMagentoRoutes(string content, string sourceFile)
|
||||
{
|
||||
foreach (Match match in MagentoRouteRegex().Matches(content))
|
||||
{
|
||||
var frontName = match.Groups["frontName"].Value;
|
||||
var module = match.Groups["module"].Value;
|
||||
|
||||
yield return new PhpRoute(
|
||||
$"/{frontName}/*",
|
||||
new[] { "GET", "POST" },
|
||||
null, null, module, false, Array.Empty<string>(), sourceFile, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic parser
|
||||
private static IEnumerable<PhpRoute> ParseGenericRoutes(string content, string sourceFile)
|
||||
{
|
||||
// Look for common routing patterns
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
// Match patterns like ->get('/path', ...) or ->route('GET', '/path', ...)
|
||||
var match = GenericRouteRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
var method = match.Groups["method"].Value.ToUpperInvariant();
|
||||
var pattern = match.Groups["pattern"].Value;
|
||||
|
||||
yield return new PhpRoute(
|
||||
pattern,
|
||||
new[] { method },
|
||||
null, null, null, false, Array.Empty<string>(), sourceFile, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PhpController? ParseController(string content, string sourceFile)
|
||||
{
|
||||
var className = ExtractClassName(content);
|
||||
if (className is null || !className.Contains("Controller", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var @namespace = ExtractNamespace(content);
|
||||
var actions = ExtractPublicMethods(content);
|
||||
var isApiController = content.Contains("ApiController", StringComparison.OrdinalIgnoreCase) ||
|
||||
content.Contains("JsonResponse", StringComparison.OrdinalIgnoreCase) ||
|
||||
content.Contains("#[Route", StringComparison.Ordinal);
|
||||
|
||||
return new PhpController(className, @namespace, sourceFile, actions, isApiController);
|
||||
}
|
||||
|
||||
private static PhpMiddleware? ParseMiddleware(string content, string sourceFile)
|
||||
{
|
||||
var className = ExtractClassName(content);
|
||||
if (className is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var @namespace = ExtractNamespace(content);
|
||||
var kind = DetermineMiddlewareKind(className, content);
|
||||
|
||||
return new PhpMiddleware(className, @namespace, sourceFile, kind);
|
||||
}
|
||||
|
||||
private static PhpMiddlewareKind DetermineMiddlewareKind(string className, string content)
|
||||
{
|
||||
var lowerName = className.ToLowerInvariant();
|
||||
var lowerContent = content.ToLowerInvariant();
|
||||
|
||||
if (lowerName.Contains("auth") || lowerContent.Contains("authenticate"))
|
||||
{
|
||||
return PhpMiddlewareKind.Auth;
|
||||
}
|
||||
|
||||
if (lowerName.Contains("cors") || lowerContent.Contains("access-control"))
|
||||
{
|
||||
return PhpMiddlewareKind.Cors;
|
||||
}
|
||||
|
||||
if (lowerName.Contains("throttle") || lowerName.Contains("ratelimit") || lowerContent.Contains("too many"))
|
||||
{
|
||||
return PhpMiddlewareKind.RateLimit;
|
||||
}
|
||||
|
||||
if (lowerName.Contains("log") || lowerContent.Contains("logger"))
|
||||
{
|
||||
return PhpMiddlewareKind.Logging;
|
||||
}
|
||||
|
||||
if (lowerName.Contains("csrf") || lowerName.Contains("security") || lowerContent.Contains("xss"))
|
||||
{
|
||||
return PhpMiddlewareKind.Security;
|
||||
}
|
||||
|
||||
return PhpMiddlewareKind.General;
|
||||
}
|
||||
|
||||
private static string? ExtractClassName(string content)
|
||||
{
|
||||
var match = ClassNameRegex().Match(content);
|
||||
return match.Success ? match.Groups["name"].Value : null;
|
||||
}
|
||||
|
||||
private static string? ExtractNamespace(string content)
|
||||
{
|
||||
var match = NamespaceRegex().Match(content);
|
||||
return match.Success ? match.Groups["namespace"].Value : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractPublicMethods(string content)
|
||||
{
|
||||
return PublicMethodRegex()
|
||||
.Matches(content)
|
||||
.Select(m => m.Groups["name"].Value)
|
||||
.Where(n => !n.StartsWith("__")) // Exclude magic methods
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Generated regex patterns
|
||||
[GeneratedRegex(@"Route::(get|post|put|patch|delete|any|match)\s*\(\s*['""](?<pattern>[^'""]+)['""].*?(?<handler>[^)]+)\)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelRouteRegex();
|
||||
|
||||
[GeneratedRegex(@"->middleware\s*\(\s*['""](?<middleware>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelMiddlewareRegex();
|
||||
|
||||
[GeneratedRegex(@"->name\s*\(\s*['""](?<name>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelRouteNameRegex();
|
||||
|
||||
[GeneratedRegex(@"\$signature\s*=\s*['""](?<signature>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelCommandSignatureRegex();
|
||||
|
||||
[GeneratedRegex(@"\$description\s*=\s*['""](?<description>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelCommandDescriptionRegex();
|
||||
|
||||
[GeneratedRegex(@"\$schedule->(?<handler>[^(]+)\([^)]*\)->(?<schedule>\w+)\(", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelScheduleRegex();
|
||||
|
||||
[GeneratedRegex(@"\$listen\s*=", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelListenArrayRegex();
|
||||
|
||||
[GeneratedRegex(@"(?<event>[\w\\]+)::class\s*=>\s*\[\s*(?<listener>[\w\\]+)::class", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LaravelEventMappingRegex();
|
||||
|
||||
[GeneratedRegex(@"#\[Route\s*\(\s*['""](?<path>[^'""]+)['""](?:.*?name\s*:\s*['""](?<name>[^'""]+)['""])?(?:.*?methods\s*:\s*\[(?<methods>[^\]]+)\])?\s*\)\]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SymfonyRouteAttributeRegex();
|
||||
|
||||
[GeneratedRegex(@"protected\s+static\s+\$defaultName\s*=\s*['""](?<name>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SymfonyCommandNameRegex();
|
||||
|
||||
[GeneratedRegex(@"protected\s+static\s+\$defaultDescription\s*=\s*['""](?<description>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SymfonyCommandDescriptionRegex();
|
||||
|
||||
[GeneratedRegex(@"(?<event>[\w\\]+)::class\s*=>\s*['\""]\s*(?<handler>\w+)['\""]\s*", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SymfonySubscribedEventsRegex();
|
||||
|
||||
[GeneratedRegex(@"\$app->(get|post|put|patch|delete|any)\s*\(\s*['""](?<pattern>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SlimRouteRegex();
|
||||
|
||||
[GeneratedRegex(@"register_rest_route\s*\(\s*['""](?<namespace>[^'""]+)['""],\s*['""](?<route>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex WordPressRestRouteRegex();
|
||||
|
||||
[GeneratedRegex(@"add_(action|filter)\s*\(\s*['""](?<hook>[^'""]+)['""],\s*['""]?(?<callback>[^'""(),]+)['""]?(?:,\s*(?<priority>\d+))?", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex WordPressHookRegex();
|
||||
|
||||
[GeneratedRegex(@"class:\s*(?<class>[\w\\]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DrupalServiceClassRegex();
|
||||
|
||||
[GeneratedRegex(@"<route\s+id\s*=\s*['""](?<module>[^'""]+)['""].*?frontName\s*=\s*['""](?<frontName>[^'""]+)['""]", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
||||
private static partial Regex MagentoRouteRegex();
|
||||
|
||||
[GeneratedRegex(@"->(get|post|put|patch|delete|route)\s*\(\s*(?:['""](?<method>\w+)['""]\s*,\s*)?['""](?<pattern>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GenericRouteRegex();
|
||||
|
||||
[GeneratedRegex(@"\bclass\s+(?<name>\w+)")]
|
||||
private static partial Regex ClassNameRegex();
|
||||
|
||||
[GeneratedRegex(@"namespace\s+(?<namespace>[\w\\]+)\s*;")]
|
||||
private static partial Regex NamespaceRegex();
|
||||
|
||||
[GeneratedRegex(@"public\s+function\s+(?<name>\w+)\s*\(")]
|
||||
private static partial Regex PublicMethodRegex();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an include/require edge in the PHP dependency graph.
|
||||
/// </summary>
|
||||
internal sealed record PhpIncludeEdge
|
||||
{
|
||||
public PhpIncludeEdge(
|
||||
PhpIncludeKind kind,
|
||||
string sourceFile,
|
||||
int sourceLine,
|
||||
string targetPath,
|
||||
bool isDynamic,
|
||||
float confidence = 1.0f,
|
||||
string? rawExpression = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceFile))
|
||||
{
|
||||
throw new ArgumentException("Source file is required", nameof(sourceFile));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetPath))
|
||||
{
|
||||
throw new ArgumentException("Target path is required", nameof(targetPath));
|
||||
}
|
||||
|
||||
Kind = kind;
|
||||
SourceFile = NormalizePath(sourceFile);
|
||||
SourceLine = sourceLine;
|
||||
TargetPath = NormalizePath(targetPath);
|
||||
IsDynamic = isDynamic;
|
||||
Confidence = Math.Clamp(confidence, 0f, 1f);
|
||||
RawExpression = rawExpression;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type of include statement.
|
||||
/// </summary>
|
||||
public PhpIncludeKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The source file containing the include statement.
|
||||
/// </summary>
|
||||
public string SourceFile { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The line number where the include occurs.
|
||||
/// </summary>
|
||||
public int SourceLine { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The resolved or inferred target path.
|
||||
/// </summary>
|
||||
public string TargetPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the include path is dynamically constructed.
|
||||
/// </summary>
|
||||
public bool IsDynamic { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level (0.0 to 1.0). Lower for dynamic includes.
|
||||
/// </summary>
|
||||
public float Confidence { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw expression if dynamic (for analysis/debugging).
|
||||
/// </summary>
|
||||
public string? RawExpression { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for this edge.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("include.kind", Kind.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("include.source", $"{SourceFile}:{SourceLine}");
|
||||
yield return new KeyValuePair<string, string?>("include.target", TargetPath);
|
||||
yield return new KeyValuePair<string, string?>("include.dynamic", IsDynamic ? "true" : "false");
|
||||
yield return new KeyValuePair<string, string?>("include.confidence", Confidence.ToString("F2", CultureInfo.InvariantCulture));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(RawExpression))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("include.expression", RawExpression);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of PHP include statements.
|
||||
/// </summary>
|
||||
internal enum PhpIncludeKind
|
||||
{
|
||||
/// <summary>include statement.</summary>
|
||||
Include,
|
||||
|
||||
/// <summary>include_once statement.</summary>
|
||||
IncludeOnce,
|
||||
|
||||
/// <summary>require statement.</summary>
|
||||
Require,
|
||||
|
||||
/// <summary>require_once statement.</summary>
|
||||
RequireOnce
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the include/require dependency graph for a PHP project.
|
||||
/// </summary>
|
||||
internal static class PhpIncludeGraphBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the include graph from the virtual file system.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpIncludeGraph> BuildAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
PhpAutoloadGraph autoloadGraph,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileSystem);
|
||||
ArgumentNullException.ThrowIfNull(autoloadGraph);
|
||||
|
||||
var edges = new List<PhpIncludeEdge>();
|
||||
var bootstrapChains = new List<PhpBootstrapChain>();
|
||||
|
||||
// Scan all PHP files in the virtual file system
|
||||
var phpFiles = fileSystem.GetPhpFiles()
|
||||
.Where(f => f.Source == PhpFileSource.SourceTree) // Only scan source files, not vendor
|
||||
.ToList();
|
||||
|
||||
foreach (var file in phpFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var fileEdges = await PhpIncludeScanner.ScanFileAsync(
|
||||
file.AbsolutePath,
|
||||
file.RelativePath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
edges.AddRange(fileEdges);
|
||||
}
|
||||
|
||||
// Detect bootstrap chains (common entrypoints that load other files)
|
||||
bootstrapChains.AddRange(DetectBootstrapChains(edges, fileSystem));
|
||||
|
||||
// Merge with autoload edges - mark edges that are satisfied by autoload
|
||||
var mergedEdges = MergeWithAutoload(edges, autoloadGraph);
|
||||
|
||||
return new PhpIncludeGraph(
|
||||
mergedEdges.OrderBy(e => e.SourceFile).ThenBy(e => e.SourceLine).ToList(),
|
||||
bootstrapChains);
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpBootstrapChain> DetectBootstrapChains(
|
||||
IReadOnlyList<PhpIncludeEdge> edges,
|
||||
PhpVirtualFileSystem fileSystem)
|
||||
{
|
||||
// Common bootstrap file patterns
|
||||
var bootstrapPatterns = new[]
|
||||
{
|
||||
"bootstrap.php",
|
||||
"autoload.php",
|
||||
"vendor/autoload.php",
|
||||
"init.php",
|
||||
"config/bootstrap.php",
|
||||
"app/bootstrap.php",
|
||||
"public/index.php",
|
||||
"index.php",
|
||||
"artisan", // Laravel
|
||||
"bin/console" // Symfony
|
||||
};
|
||||
|
||||
// Group edges by source file
|
||||
var edgesBySource = edges
|
||||
.GroupBy(e => e.SourceFile, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var pattern in bootstrapPatterns)
|
||||
{
|
||||
// Find matching files
|
||||
var matchingFiles = fileSystem.GetFilesByPattern($"*{pattern}").ToList();
|
||||
|
||||
foreach (var file in matchingFiles)
|
||||
{
|
||||
if (!edgesBySource.TryGetValue(file.RelativePath, out var fileEdges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A bootstrap file is one that includes multiple other files
|
||||
if (fileEdges.Count >= 2)
|
||||
{
|
||||
var chain = new PhpBootstrapChain(
|
||||
file.RelativePath,
|
||||
fileEdges.Select(e => e.TargetPath).ToList(),
|
||||
DetectBootstrapType(file.RelativePath));
|
||||
|
||||
yield return chain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PhpBootstrapType DetectBootstrapType(string filePath)
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath).ToLowerInvariant();
|
||||
var dirName = Path.GetDirectoryName(filePath)?.ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
if (fileName == "autoload.php" || filePath.Contains("vendor/autoload", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PhpBootstrapType.Autoloader;
|
||||
}
|
||||
|
||||
if (fileName == "index.php" && (dirName.Contains("public") || dirName.Contains("web")))
|
||||
{
|
||||
return PhpBootstrapType.WebEntrypoint;
|
||||
}
|
||||
|
||||
if (fileName == "artisan" || fileName == "console" || dirName.Contains("bin"))
|
||||
{
|
||||
return PhpBootstrapType.CliEntrypoint;
|
||||
}
|
||||
|
||||
if (fileName.Contains("bootstrap") || fileName.Contains("init"))
|
||||
{
|
||||
return PhpBootstrapType.Bootstrap;
|
||||
}
|
||||
|
||||
if (fileName.Contains("config"))
|
||||
{
|
||||
return PhpBootstrapType.Config;
|
||||
}
|
||||
|
||||
return PhpBootstrapType.Other;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PhpIncludeEdge> MergeWithAutoload(
|
||||
IReadOnlyList<PhpIncludeEdge> edges,
|
||||
PhpAutoloadGraph autoloadGraph)
|
||||
{
|
||||
// Build a set of paths covered by autoload
|
||||
var autoloadPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var edge in autoloadGraph.Edges)
|
||||
{
|
||||
autoloadPaths.Add(edge.Target);
|
||||
|
||||
// Also add common variations
|
||||
if (!edge.Target.EndsWith(".php", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
autoloadPaths.Add($"{edge.Target}.php");
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out edges that are handled by autoload (vendor/autoload.php)
|
||||
return edges
|
||||
.Where(e => !IsAutoloadInclude(e))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsAutoloadInclude(PhpIncludeEdge edge)
|
||||
{
|
||||
// Don't filter out the autoload include itself, but we can mark it
|
||||
return edge.TargetPath.Contains("vendor/autoload", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the include/require dependency graph for a PHP project.
|
||||
/// </summary>
|
||||
internal sealed class PhpIncludeGraph
|
||||
{
|
||||
public PhpIncludeGraph(
|
||||
IReadOnlyList<PhpIncludeEdge> edges,
|
||||
IReadOnlyList<PhpBootstrapChain> bootstrapChains)
|
||||
{
|
||||
Edges = edges ?? Array.Empty<PhpIncludeEdge>();
|
||||
BootstrapChains = bootstrapChains ?? Array.Empty<PhpBootstrapChain>();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PhpIncludeEdge> Edges { get; }
|
||||
|
||||
public IReadOnlyList<PhpBootstrapChain> BootstrapChains { get; }
|
||||
|
||||
public bool IsEmpty => Edges.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets edges grouped by source file.
|
||||
/// </summary>
|
||||
public ILookup<string, PhpIncludeEdge> EdgesBySource
|
||||
=> Edges.ToLookup(e => e.SourceFile, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all static (non-dynamic) edges.
|
||||
/// </summary>
|
||||
public IEnumerable<PhpIncludeEdge> StaticEdges
|
||||
=> Edges.Where(e => !e.IsDynamic);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all dynamic edges.
|
||||
/// </summary>
|
||||
public IEnumerable<PhpIncludeEdge> DynamicEdges
|
||||
=> Edges.Where(e => e.IsDynamic);
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for the include graph.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"include.edge_count",
|
||||
Edges.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var staticCount = StaticEdges.Count();
|
||||
var dynamicCount = DynamicEdges.Count();
|
||||
|
||||
yield return new KeyValuePair<string, string?>("include.static_count", staticCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("include.dynamic_count", dynamicCount.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var requireCount = Edges.Count(e => e.Kind == PhpIncludeKind.Require || e.Kind == PhpIncludeKind.RequireOnce);
|
||||
var includeCount = Edges.Count(e => e.Kind == PhpIncludeKind.Include || e.Kind == PhpIncludeKind.IncludeOnce);
|
||||
|
||||
yield return new KeyValuePair<string, string?>("include.require_count", requireCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("include.include_count", includeCount.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"include.bootstrap_chain_count",
|
||||
BootstrapChains.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (BootstrapChains.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"include.bootstrap_files",
|
||||
string.Join(';', BootstrapChains.Select(c => c.EntryFile)));
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpIncludeGraph Empty { get; } = new(
|
||||
Array.Empty<PhpIncludeEdge>(),
|
||||
Array.Empty<PhpBootstrapChain>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a bootstrap chain (an entrypoint that loads multiple files).
|
||||
/// </summary>
|
||||
internal sealed record PhpBootstrapChain(
|
||||
string EntryFile,
|
||||
IReadOnlyList<string> LoadedFiles,
|
||||
PhpBootstrapType Type);
|
||||
|
||||
/// <summary>
|
||||
/// Types of bootstrap files.
|
||||
/// </summary>
|
||||
internal enum PhpBootstrapType
|
||||
{
|
||||
/// <summary>Composer autoloader.</summary>
|
||||
Autoloader,
|
||||
|
||||
/// <summary>Web entrypoint (index.php).</summary>
|
||||
WebEntrypoint,
|
||||
|
||||
/// <summary>CLI entrypoint (artisan, console).</summary>
|
||||
CliEntrypoint,
|
||||
|
||||
/// <summary>Bootstrap file.</summary>
|
||||
Bootstrap,
|
||||
|
||||
/// <summary>Configuration loader.</summary>
|
||||
Config,
|
||||
|
||||
/// <summary>Other type.</summary>
|
||||
Other
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Scans PHP source files for include/require statements.
|
||||
/// </summary>
|
||||
internal static partial class PhpIncludeScanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans a PHP file for include/require statements.
|
||||
/// </summary>
|
||||
public static async ValueTask<IReadOnlyList<PhpIncludeEdge>> ScanFileAsync(
|
||||
string filePath,
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return Array.Empty<PhpIncludeEdge>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
return ScanContent(content, relativePath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return Array.Empty<PhpIncludeEdge>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans PHP content for include/require statements.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PhpIncludeEdge> ScanContent(string content, string sourceFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return Array.Empty<PhpIncludeEdge>();
|
||||
}
|
||||
|
||||
var edges = new List<PhpIncludeEdge>();
|
||||
var lines = content.Split('\n');
|
||||
|
||||
for (var lineNumber = 0; lineNumber < lines.Length; lineNumber++)
|
||||
{
|
||||
var line = lines[lineNumber];
|
||||
|
||||
// Skip if line is a comment
|
||||
var trimmed = line.TrimStart();
|
||||
if (trimmed.StartsWith("//", StringComparison.Ordinal) ||
|
||||
trimmed.StartsWith("#", StringComparison.Ordinal) ||
|
||||
trimmed.StartsWith("*", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to match include/require patterns
|
||||
foreach (var match in IncludePatternRegex().Matches(line).Cast<Match>())
|
||||
{
|
||||
var edge = ParseIncludeMatch(match, sourceFile, lineNumber + 1);
|
||||
if (edge is not null)
|
||||
{
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
private static PhpIncludeEdge? ParseIncludeMatch(Match match, string sourceFile, int lineNumber)
|
||||
{
|
||||
var kindStr = match.Groups["kind"].Value.ToLowerInvariant();
|
||||
var path = match.Groups["path"].Value;
|
||||
|
||||
var kind = kindStr switch
|
||||
{
|
||||
"include" => PhpIncludeKind.Include,
|
||||
"include_once" => PhpIncludeKind.IncludeOnce,
|
||||
"require" => PhpIncludeKind.Require,
|
||||
"require_once" => PhpIncludeKind.RequireOnce,
|
||||
_ => PhpIncludeKind.Include
|
||||
};
|
||||
|
||||
// Analyze the path expression
|
||||
var (targetPath, isDynamic, confidence, rawExpression) = AnalyzePathExpression(path, sourceFile);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PhpIncludeEdge(
|
||||
kind,
|
||||
sourceFile,
|
||||
lineNumber,
|
||||
targetPath,
|
||||
isDynamic,
|
||||
confidence,
|
||||
rawExpression);
|
||||
}
|
||||
|
||||
private static (string TargetPath, bool IsDynamic, float Confidence, string? RawExpression) AnalyzePathExpression(
|
||||
string expression,
|
||||
string sourceFile)
|
||||
{
|
||||
expression = expression.Trim();
|
||||
|
||||
// Remove trailing semicolon if present
|
||||
if (expression.EndsWith(';'))
|
||||
{
|
||||
expression = expression[..^1].Trim();
|
||||
}
|
||||
|
||||
// Simple string literal: 'path' or "path"
|
||||
if ((expression.StartsWith('\'') && expression.EndsWith('\'')) ||
|
||||
(expression.StartsWith('"') && expression.EndsWith('"')))
|
||||
{
|
||||
var path = expression[1..^1];
|
||||
var resolvedPath = ResolvePath(path, sourceFile);
|
||||
return (resolvedPath, false, 1.0f, null);
|
||||
}
|
||||
|
||||
// __DIR__ . '/path'
|
||||
var dirMatch = DirConcatRegex().Match(expression);
|
||||
if (dirMatch.Success)
|
||||
{
|
||||
var relativePart = dirMatch.Groups["path"].Value;
|
||||
var sourceDir = Path.GetDirectoryName(sourceFile) ?? string.Empty;
|
||||
var resolvedPath = CombinePaths(sourceDir, relativePart);
|
||||
return (resolvedPath, false, 0.95f, null);
|
||||
}
|
||||
|
||||
// dirname(__FILE__) . '/path'
|
||||
var dirnameMatch = DirnameConcatRegex().Match(expression);
|
||||
if (dirnameMatch.Success)
|
||||
{
|
||||
var relativePart = dirnameMatch.Groups["path"].Value;
|
||||
var sourceDir = Path.GetDirectoryName(sourceFile) ?? string.Empty;
|
||||
var resolvedPath = CombinePaths(sourceDir, relativePart);
|
||||
return (resolvedPath, false, 0.9f, null);
|
||||
}
|
||||
|
||||
// Variable-based path: $variable or $this->property
|
||||
if (expression.StartsWith('$'))
|
||||
{
|
||||
// Try to extract any literal parts
|
||||
var literalMatch = VariableWithLiteralRegex().Match(expression);
|
||||
if (literalMatch.Success)
|
||||
{
|
||||
var literalPart = literalMatch.Groups["literal"].Value;
|
||||
return ($"[dynamic]{literalPart}", true, 0.5f, expression);
|
||||
}
|
||||
|
||||
return ("[dynamic]", true, 0.3f, expression);
|
||||
}
|
||||
|
||||
// Constant-based path
|
||||
if (expression.Contains("_DIR", StringComparison.OrdinalIgnoreCase) ||
|
||||
expression.Contains("_FILE", StringComparison.OrdinalIgnoreCase) ||
|
||||
expression.Contains("_ROOT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("[constant-based]", true, 0.4f, expression);
|
||||
}
|
||||
|
||||
// Function call or complex expression
|
||||
if (expression.Contains('('))
|
||||
{
|
||||
return ("[function-call]", true, 0.2f, expression);
|
||||
}
|
||||
|
||||
// Concatenation with unknown parts
|
||||
if (expression.Contains('.'))
|
||||
{
|
||||
return ("[concatenation]", true, 0.3f, expression);
|
||||
}
|
||||
|
||||
// Fallback for simple constants
|
||||
return (expression, true, 0.4f, expression);
|
||||
}
|
||||
|
||||
private static string ResolvePath(string path, string sourceFile)
|
||||
{
|
||||
path = path.Replace('\\', '/');
|
||||
|
||||
// Absolute path or already looks like a valid path
|
||||
if (path.StartsWith('/') || path.Contains(':'))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
// Relative path - resolve from source file directory
|
||||
var sourceDir = Path.GetDirectoryName(sourceFile) ?? string.Empty;
|
||||
return CombinePaths(sourceDir, path);
|
||||
}
|
||||
|
||||
private static string CombinePaths(string basePath, string relativePath)
|
||||
{
|
||||
basePath = basePath.Replace('\\', '/').TrimEnd('/');
|
||||
relativePath = relativePath.Replace('\\', '/').TrimStart('/');
|
||||
|
||||
if (string.IsNullOrEmpty(basePath))
|
||||
{
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// Handle parent directory references
|
||||
var parts = $"{basePath}/{relativePath}".Split('/');
|
||||
var stack = new Stack<string>();
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part == "..")
|
||||
{
|
||||
if (stack.Count > 0)
|
||||
{
|
||||
stack.Pop();
|
||||
}
|
||||
}
|
||||
else if (part != "." && !string.IsNullOrEmpty(part))
|
||||
{
|
||||
stack.Push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join('/', stack.Reverse());
|
||||
}
|
||||
|
||||
// Regex patterns for include/require detection
|
||||
[GeneratedRegex(@"\b(?<kind>include|include_once|require|require_once)\s*[\(]?\s*(?<path>[^;]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex IncludePatternRegex();
|
||||
|
||||
[GeneratedRegex(@"__DIR__\s*\.\s*['""](?<path>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DirConcatRegex();
|
||||
|
||||
[GeneratedRegex(@"dirname\s*\(\s*__FILE__\s*\)\s*\.\s*['""](?<path>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DirnameConcatRegex();
|
||||
|
||||
[GeneratedRegex(@"\$\w+\s*\.\s*['""](?<literal>[^'""]+)['""]")]
|
||||
private static partial Regex VariableWithLiteralRegex();
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes PHP project input by merging source trees, composer manifests,
|
||||
/// vendor directories, PHP configs, and container layers into a unified view.
|
||||
/// </summary>
|
||||
internal static class PhpInputNormalizer
|
||||
{
|
||||
private static readonly string[] SourceExtensions =
|
||||
[
|
||||
".php",
|
||||
".phtml",
|
||||
".inc",
|
||||
".module"
|
||||
];
|
||||
|
||||
private static readonly string[] ExcludedDirectories =
|
||||
[
|
||||
".git",
|
||||
".svn",
|
||||
".hg",
|
||||
"node_modules",
|
||||
".idea",
|
||||
".vscode"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a PHP project from the given root path.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpProjectInput> NormalizeAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return PhpProjectInput.Empty(rootPath ?? string.Empty);
|
||||
}
|
||||
|
||||
// Build virtual file system
|
||||
var vfsBuilder = PhpVirtualFileSystem.CreateBuilder();
|
||||
|
||||
// Collect source files
|
||||
await CollectSourceFilesAsync(rootPath, vfsBuilder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect vendor files
|
||||
await CollectVendorFilesAsync(rootPath, vfsBuilder, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect config files
|
||||
CollectConfigFiles(rootPath, vfsBuilder);
|
||||
|
||||
// Collect composer manifests
|
||||
CollectComposerManifests(rootPath, vfsBuilder);
|
||||
|
||||
var fileSystem = vfsBuilder.Build();
|
||||
|
||||
// Load composer data
|
||||
var composerManifest = await PhpComposerManifestReader.LoadAsync(rootPath, cancellationToken).ConfigureAwait(false);
|
||||
var composerLock = await ComposerLockData.LoadAsync(
|
||||
new LanguageAnalyzerContext(rootPath, TimeProvider.System),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Collect PHP configuration
|
||||
var config = await PhpConfigCollector.CollectAsync(rootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Detect framework/CMS
|
||||
var framework = await PhpFrameworkFingerprinter.DetectAsync(rootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If no framework detected from files, try from composer packages
|
||||
if (!framework.IsDetected && composerLock is not null && !composerLock.IsEmpty)
|
||||
{
|
||||
var allPackages = composerLock.Packages.Concat(composerLock.DevPackages);
|
||||
framework = PhpFrameworkFingerprinter.DetectFromPackages(allPackages);
|
||||
}
|
||||
|
||||
return new PhpProjectInput(
|
||||
rootPath,
|
||||
fileSystem,
|
||||
config,
|
||||
framework,
|
||||
composerLock?.IsEmpty == false ? composerLock : null,
|
||||
composerManifest);
|
||||
}
|
||||
|
||||
private static async ValueTask CollectSourceFilesAsync(
|
||||
string rootPath,
|
||||
PhpVirtualFileSystem.Builder builder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.System
|
||||
};
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(rootPath, "*.*", options))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Check if file is in excluded directory
|
||||
var relativePath = Path.GetRelativePath(rootPath, filePath);
|
||||
if (IsExcludedPath(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip vendor directory (handled separately)
|
||||
if (relativePath.StartsWith("vendor", StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.StartsWith("vendor/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include PHP source files
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (!SourceExtensions.Any(ext => ext.Equals(extension, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
relativePath,
|
||||
filePath,
|
||||
PhpFileSource.SourceTree));
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async ValueTask CollectVendorFilesAsync(
|
||||
string rootPath,
|
||||
PhpVirtualFileSystem.Builder builder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vendorPath = Path.Combine(rootPath, "vendor");
|
||||
if (!Directory.Exists(vendorPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.System
|
||||
};
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
foreach (var filePath in Directory.EnumerateFiles(vendorPath, "*.php", options))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = Path.GetRelativePath(rootPath, filePath);
|
||||
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
relativePath,
|
||||
filePath,
|
||||
PhpFileSource.Vendor));
|
||||
}
|
||||
|
||||
// Also capture installed.json for package metadata
|
||||
var installedJsonPath = Path.Combine(vendorPath, "composer", "installed.json");
|
||||
if (File.Exists(installedJsonPath))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootPath, installedJsonPath);
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
relativePath,
|
||||
installedJsonPath,
|
||||
PhpFileSource.Vendor));
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void CollectConfigFiles(string rootPath, PhpVirtualFileSystem.Builder builder)
|
||||
{
|
||||
// Collect .htaccess files
|
||||
var htaccessPath = Path.Combine(rootPath, ".htaccess");
|
||||
if (File.Exists(htaccessPath))
|
||||
{
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
".htaccess",
|
||||
htaccessPath,
|
||||
PhpFileSource.WebServerConfig));
|
||||
}
|
||||
|
||||
// Look for php.ini in common locations
|
||||
foreach (var iniLocation in new[] { "php.ini", "php/php.ini", "etc/php.ini" })
|
||||
{
|
||||
var iniPath = Path.Combine(rootPath, iniLocation);
|
||||
if (File.Exists(iniPath))
|
||||
{
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
iniLocation,
|
||||
iniPath,
|
||||
PhpFileSource.PhpConfig));
|
||||
}
|
||||
}
|
||||
|
||||
// Look for conf.d directory
|
||||
foreach (var confDLocation in new[] { "conf.d", "php/conf.d", "etc/php/conf.d" })
|
||||
{
|
||||
var confDPath = Path.Combine(rootPath, confDLocation);
|
||||
if (Directory.Exists(confDPath))
|
||||
{
|
||||
foreach (var iniFile in Directory.GetFiles(confDPath, "*.ini"))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootPath, iniFile);
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
relativePath,
|
||||
iniFile,
|
||||
PhpFileSource.PhpConfig));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for FPM configs
|
||||
foreach (var fpmLocation in new[] { "php-fpm.conf", "php-fpm.d", "etc/php-fpm.conf", "etc/php-fpm.d" })
|
||||
{
|
||||
var fpmPath = Path.Combine(rootPath, fpmLocation);
|
||||
if (File.Exists(fpmPath))
|
||||
{
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
fpmLocation,
|
||||
fpmPath,
|
||||
PhpFileSource.FpmConfig));
|
||||
}
|
||||
else if (Directory.Exists(fpmPath))
|
||||
{
|
||||
foreach (var confFile in Directory.GetFiles(fpmPath, "*.conf"))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootPath, confFile);
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
relativePath,
|
||||
confFile,
|
||||
PhpFileSource.FpmConfig));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectComposerManifests(string rootPath, PhpVirtualFileSystem.Builder builder)
|
||||
{
|
||||
// composer.json
|
||||
var composerJsonPath = Path.Combine(rootPath, "composer.json");
|
||||
if (File.Exists(composerJsonPath))
|
||||
{
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
"composer.json",
|
||||
composerJsonPath,
|
||||
PhpFileSource.ComposerManifest));
|
||||
}
|
||||
|
||||
// composer.lock
|
||||
var composerLockPath = Path.Combine(rootPath, "composer.lock");
|
||||
if (File.Exists(composerLockPath))
|
||||
{
|
||||
builder.AddFile(new PhpVirtualFile(
|
||||
"composer.lock",
|
||||
composerLockPath,
|
||||
PhpFileSource.ComposerManifest));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExcludedPath(string relativePath)
|
||||
{
|
||||
foreach (var excluded in ExcludedDirectories)
|
||||
{
|
||||
if (relativePath.StartsWith(excluded, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.StartsWith(excluded + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.StartsWith(excluded + "/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (relativePath.Contains(Path.DirectorySeparatorChar + excluded + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.Contains("/" + excluded + "/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and parses vendor/composer/installed.json for detailed package metadata.
|
||||
/// </summary>
|
||||
internal static class PhpInstalledJsonReader
|
||||
{
|
||||
private static readonly string[] InstalledJsonLocations =
|
||||
[
|
||||
"vendor/composer/installed.json"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Loads installed.json data from the given root path.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpInstalledData> LoadAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
return PhpInstalledData.Empty;
|
||||
}
|
||||
|
||||
foreach (var location in InstalledJsonLocations)
|
||||
{
|
||||
var path = Path.Combine(rootPath, location);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await ParseInstalledJsonAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed JSON, skip
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Inaccessible, skip
|
||||
}
|
||||
}
|
||||
|
||||
return PhpInstalledData.Empty;
|
||||
}
|
||||
|
||||
private static async ValueTask<PhpInstalledData> ParseInstalledJsonAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
var root = document.RootElement;
|
||||
|
||||
var packages = new List<PhpInstalledPackage>();
|
||||
|
||||
// Composer 2.x format: { "packages": [...], "dev": true, "dev-package-names": [...] }
|
||||
if (root.TryGetProperty("packages", out var packagesArray) && packagesArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var devPackageNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (root.TryGetProperty("dev-package-names", out var devNames) && devNames.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var devName in devNames.EnumerateArray())
|
||||
{
|
||||
if (devName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var name = devName.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
devPackageNames.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pkg in packagesArray.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var package = ParsePackage(pkg, devPackageNames);
|
||||
if (package is not null)
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Composer 1.x format: direct array
|
||||
else if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pkg in root.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var package = ParsePackage(pkg, new HashSet<string>());
|
||||
if (package is not null)
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new PhpInstalledData(path, packages);
|
||||
}
|
||||
|
||||
private static PhpInstalledPackage? ParsePackage(JsonElement element, HashSet<string> devPackageNames)
|
||||
{
|
||||
if (!TryGetString(element, "name", out var name) || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var version = TryGetString(element, "version");
|
||||
var type = TryGetString(element, "type") ?? "library";
|
||||
var isDev = devPackageNames.Contains(name);
|
||||
|
||||
// Parse autoload
|
||||
var autoload = ParseAutoload(element, "autoload");
|
||||
var autoloadDev = ParseAutoload(element, "autoload-dev");
|
||||
|
||||
// Parse bin
|
||||
var bin = ParseBin(element);
|
||||
|
||||
// Parse extra (for plugins)
|
||||
var isPlugin = type.Equals("composer-plugin", StringComparison.OrdinalIgnoreCase);
|
||||
var pluginClass = isPlugin ? ParsePluginClass(element) : null;
|
||||
|
||||
// Parse install path
|
||||
var installPath = TryGetString(element, "install-path");
|
||||
|
||||
return new PhpInstalledPackage(
|
||||
name,
|
||||
version,
|
||||
type,
|
||||
isDev,
|
||||
autoload,
|
||||
autoloadDev,
|
||||
bin,
|
||||
isPlugin,
|
||||
pluginClass,
|
||||
installPath);
|
||||
}
|
||||
|
||||
private static PhpAutoloadSpec ParseAutoload(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var autoloadElement) || autoloadElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return PhpAutoloadSpec.Empty;
|
||||
}
|
||||
|
||||
var psr4 = ParseAutoloadMapping(autoloadElement, "psr-4");
|
||||
var psr0 = ParseAutoloadMapping(autoloadElement, "psr-0");
|
||||
var classmap = ParseAutoloadArray(autoloadElement, "classmap");
|
||||
var files = ParseAutoloadArray(autoloadElement, "files");
|
||||
var excludeFromClassmap = ParseAutoloadArray(autoloadElement, "exclude-from-classmap");
|
||||
|
||||
return new PhpAutoloadSpec(psr4, psr0, classmap, files, excludeFromClassmap);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyList<string>> ParseAutoloadMapping(JsonElement autoload, string propertyName)
|
||||
{
|
||||
var result = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
if (!autoload.TryGetProperty(propertyName, out var mapping) || mapping.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var prop in mapping.EnumerateObject())
|
||||
{
|
||||
var paths = new List<string>();
|
||||
if (prop.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var path = prop.Value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
paths.Add(NormalizePath(path));
|
||||
}
|
||||
}
|
||||
else if (prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pathElement in prop.Value.EnumerateArray())
|
||||
{
|
||||
if (pathElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var path = pathElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
paths.Add(NormalizePath(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.Count > 0)
|
||||
{
|
||||
result[prop.Name] = paths;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseAutoloadArray(JsonElement autoload, string propertyName)
|
||||
{
|
||||
if (!autoload.TryGetProperty(propertyName, out var array) || array.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var path = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
result.Add(NormalizePath(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseBin(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("bin", out var binArray) || binArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var result = new List<string>();
|
||||
foreach (var item in binArray.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var path = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
result.Add(NormalizePath(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? ParsePluginClass(JsonElement element)
|
||||
{
|
||||
if (!element.TryGetProperty("extra", out var extra) || extra.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!extra.TryGetProperty("class", out var classElement) || classElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return classElement.GetString();
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
=> TryGetString(element, propertyName, out var value) ? value : null;
|
||||
|
||||
private static bool TryGetString(JsonElement element, string propertyName, out string? value)
|
||||
{
|
||||
value = null;
|
||||
if (!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
value = property.GetString();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/').TrimEnd('/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents data from installed.json.
|
||||
/// </summary>
|
||||
internal sealed class PhpInstalledData
|
||||
{
|
||||
public PhpInstalledData(string path, IReadOnlyList<PhpInstalledPackage> packages)
|
||||
{
|
||||
Path = path ?? string.Empty;
|
||||
Packages = packages ?? Array.Empty<PhpInstalledPackage>();
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public IReadOnlyList<PhpInstalledPackage> Packages { get; }
|
||||
|
||||
public bool IsEmpty => Packages.Count == 0;
|
||||
|
||||
public static PhpInstalledData Empty { get; } = new(string.Empty, Array.Empty<PhpInstalledPackage>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an installed package from installed.json.
|
||||
/// </summary>
|
||||
internal sealed record PhpInstalledPackage(
|
||||
string Name,
|
||||
string? Version,
|
||||
string Type,
|
||||
bool IsDev,
|
||||
PhpAutoloadSpec Autoload,
|
||||
PhpAutoloadSpec AutoloadDev,
|
||||
IReadOnlyList<string> Bin,
|
||||
bool IsPlugin,
|
||||
string? PluginClass,
|
||||
string? InstallPath);
|
||||
|
||||
/// <summary>
|
||||
/// Represents autoload configuration for a package.
|
||||
/// </summary>
|
||||
internal sealed class PhpAutoloadSpec
|
||||
{
|
||||
public PhpAutoloadSpec(
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> psr4,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> psr0,
|
||||
IReadOnlyList<string> classmap,
|
||||
IReadOnlyList<string> files,
|
||||
IReadOnlyList<string> excludeFromClassmap)
|
||||
{
|
||||
Psr4 = psr4 ?? new Dictionary<string, IReadOnlyList<string>>();
|
||||
Psr0 = psr0 ?? new Dictionary<string, IReadOnlyList<string>>();
|
||||
Classmap = classmap ?? Array.Empty<string>();
|
||||
Files = files ?? Array.Empty<string>();
|
||||
ExcludeFromClassmap = excludeFromClassmap ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>> Psr4 { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, IReadOnlyList<string>> Psr0 { get; }
|
||||
|
||||
public IReadOnlyList<string> Classmap { get; }
|
||||
|
||||
public IReadOnlyList<string> Files { get; }
|
||||
|
||||
public IReadOnlyList<string> ExcludeFromClassmap { get; }
|
||||
|
||||
public bool IsEmpty => Psr4.Count == 0 && Psr0.Count == 0 && Classmap.Count == 0 && Files.Count == 0;
|
||||
|
||||
public static PhpAutoloadSpec Empty { get; } = new(
|
||||
new Dictionary<string, IReadOnlyList<string>>(),
|
||||
new Dictionary<string, IReadOnlyList<string>>(),
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<string>());
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a PHP PHAR archive file.
|
||||
/// </summary>
|
||||
internal sealed record PhpPharArchive
|
||||
{
|
||||
public PhpPharArchive(
|
||||
string filePath,
|
||||
string relativePath,
|
||||
PhpPharManifest? manifest,
|
||||
string? stub,
|
||||
IReadOnlyList<PhpPharEntry> entries,
|
||||
string? sha256)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
throw new ArgumentException("File path is required", nameof(filePath));
|
||||
}
|
||||
|
||||
FilePath = filePath.Replace('\\', '/');
|
||||
RelativePath = relativePath?.Replace('\\', '/') ?? string.Empty;
|
||||
Manifest = manifest;
|
||||
Stub = stub;
|
||||
Entries = entries ?? Array.Empty<PhpPharEntry>();
|
||||
Sha256 = sha256;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the PHAR file.
|
||||
/// </summary>
|
||||
public string FilePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path within the project.
|
||||
/// </summary>
|
||||
public string RelativePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed PHAR manifest.
|
||||
/// </summary>
|
||||
public PhpPharManifest? Manifest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// PHAR stub code (bootstrap code).
|
||||
/// </summary>
|
||||
public string? Stub { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Entries (files) in the PHAR archive.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpPharEntry> Entries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 hash of the PHAR file.
|
||||
/// </summary>
|
||||
public string? Sha256 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this PHAR contains a vendor directory.
|
||||
/// </summary>
|
||||
public bool HasEmbeddedVendor
|
||||
=> Entries.Any(e => e.Path.StartsWith("vendor/", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Path.Contains("/vendor/", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this PHAR contains composer files.
|
||||
/// </summary>
|
||||
public bool HasComposerFiles
|
||||
=> Entries.Any(e => e.Path.EndsWith("composer.json", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Path.EndsWith("composer.lock", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file count in the archive.
|
||||
/// </summary>
|
||||
public int FileCount => Entries.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total uncompressed size.
|
||||
/// </summary>
|
||||
public long TotalUncompressedSize => Entries.Sum(e => e.UncompressedSize);
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for this PHAR.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("phar.path", RelativePath);
|
||||
yield return new KeyValuePair<string, string?>("phar.file_count", FileCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("phar.total_size", TotalUncompressedSize.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("phar.has_vendor", HasEmbeddedVendor.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("phar.has_composer", HasComposerFiles.ToString().ToLowerInvariant());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Sha256))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("phar.sha256", Sha256);
|
||||
}
|
||||
|
||||
if (Manifest is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(Manifest.Alias))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("phar.alias", Manifest.Alias);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Manifest.Version))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("phar.version", Manifest.Version);
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("phar.compression", Manifest.Compression.ToString().ToLowerInvariant());
|
||||
yield return new KeyValuePair<string, string?>("phar.signature_type", Manifest.SignatureType.ToString().ToLowerInvariant());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Stub))
|
||||
{
|
||||
// Check for common patterns in stub
|
||||
var hasAutoload = Stub.Contains("__autoload", StringComparison.OrdinalIgnoreCase) ||
|
||||
Stub.Contains("spl_autoload", StringComparison.OrdinalIgnoreCase);
|
||||
yield return new KeyValuePair<string, string?>("phar.stub_has_autoload", hasAutoload.ToString().ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHAR manifest information.
|
||||
/// </summary>
|
||||
internal sealed record PhpPharManifest(
|
||||
string? Alias,
|
||||
string? Version,
|
||||
int ApiVersion,
|
||||
PhpPharCompression Compression,
|
||||
PhpPharSignatureType SignatureType,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
/// <summary>
|
||||
/// An entry (file) in a PHAR archive.
|
||||
/// </summary>
|
||||
internal sealed record PhpPharEntry(
|
||||
string Path,
|
||||
long UncompressedSize,
|
||||
long CompressedSize,
|
||||
long Timestamp,
|
||||
uint Crc32,
|
||||
PhpPharCompression Compression,
|
||||
string? Sha256)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the file extension.
|
||||
/// </summary>
|
||||
public string Extension => System.IO.Path.GetExtension(Path).TrimStart('.');
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a PHP file.
|
||||
/// </summary>
|
||||
public bool IsPhpFile => Extension.Equals("php", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is in the vendor directory.
|
||||
/// </summary>
|
||||
public bool IsVendorFile => Path.StartsWith("vendor/", StringComparison.OrdinalIgnoreCase) ||
|
||||
Path.Contains("/vendor/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHAR compression types.
|
||||
/// </summary>
|
||||
internal enum PhpPharCompression
|
||||
{
|
||||
/// <summary>No compression.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>GZip compression.</summary>
|
||||
GZip,
|
||||
|
||||
/// <summary>BZip2 compression.</summary>
|
||||
BZip2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHAR signature types.
|
||||
/// </summary>
|
||||
internal enum PhpPharSignatureType
|
||||
{
|
||||
/// <summary>No signature.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>MD5 signature.</summary>
|
||||
Md5,
|
||||
|
||||
/// <summary>SHA-1 signature.</summary>
|
||||
Sha1,
|
||||
|
||||
/// <summary>SHA-256 signature.</summary>
|
||||
Sha256,
|
||||
|
||||
/// <summary>SHA-512 signature.</summary>
|
||||
Sha512,
|
||||
|
||||
/// <summary>OpenSSL signature.</summary>
|
||||
OpenSsl
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Scans and parses PHP PHAR archive files.
|
||||
/// </summary>
|
||||
internal static partial class PhpPharScanner
|
||||
{
|
||||
// PHAR format magic bytes and markers
|
||||
private static readonly byte[] PharEndMarker = "__HALT_COMPILER();"u8.ToArray();
|
||||
private static readonly byte[] GBMBMarker = "GBMB"u8.ToArray(); // PHAR signature marker
|
||||
|
||||
/// <summary>
|
||||
/// Scans a PHAR file and extracts metadata.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpPharArchive?> ScanFileAsync(
|
||||
string filePath,
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Calculate SHA256 of the entire file
|
||||
var sha256 = ComputeSha256(fileBytes);
|
||||
|
||||
// Parse the PHAR structure
|
||||
return ParsePhar(fileBytes, filePath, relativePath, sha256);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// PHAR parsing can fail for malformed files
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static PhpPharArchive? ParsePhar(byte[] data, string filePath, string relativePath, string sha256)
|
||||
{
|
||||
// Find the __HALT_COMPILER(); marker
|
||||
var haltIndex = FindMarker(data, PharEndMarker);
|
||||
if (haltIndex < 0)
|
||||
{
|
||||
return null; // Not a valid PHAR
|
||||
}
|
||||
|
||||
// Extract the stub (PHP code before __HALT_COMPILER)
|
||||
var stubEnd = haltIndex + PharEndMarker.Length;
|
||||
var stub = Encoding.UTF8.GetString(data, 0, stubEnd);
|
||||
|
||||
// Skip whitespace after __HALT_COMPILER();
|
||||
var manifestStart = stubEnd;
|
||||
while (manifestStart < data.Length && (data[manifestStart] == ' ' || data[manifestStart] == '\r' || data[manifestStart] == '\n' || data[manifestStart] == '?'))
|
||||
{
|
||||
manifestStart++;
|
||||
}
|
||||
|
||||
// Skip the closing PHP tag if present (?> or ; ?\n>)
|
||||
if (manifestStart + 1 < data.Length && data[manifestStart] == '>' && data[manifestStart - 1] == '?')
|
||||
{
|
||||
manifestStart++;
|
||||
}
|
||||
|
||||
// Parse manifest
|
||||
var (manifest, entries) = ParseManifest(data, manifestStart);
|
||||
|
||||
return new PhpPharArchive(
|
||||
filePath,
|
||||
relativePath,
|
||||
manifest,
|
||||
stub,
|
||||
entries,
|
||||
sha256);
|
||||
}
|
||||
|
||||
private static (PhpPharManifest? Manifest, IReadOnlyList<PhpPharEntry> Entries) ParseManifest(byte[] data, int offset)
|
||||
{
|
||||
var entries = new List<PhpPharEntry>();
|
||||
|
||||
if (offset + 4 > data.Length)
|
||||
{
|
||||
return (null, entries);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// PHAR manifest format:
|
||||
// 4 bytes: manifest length
|
||||
// 4 bytes: number of files
|
||||
// 2 bytes: API version
|
||||
// 4 bytes: global flags
|
||||
// 4 bytes: alias length
|
||||
// n bytes: alias
|
||||
// 4 bytes: metadata length
|
||||
// n bytes: metadata
|
||||
|
||||
var pos = offset;
|
||||
|
||||
// Read manifest length
|
||||
var manifestLength = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
if (manifestLength == 0 || pos + manifestLength > data.Length)
|
||||
{
|
||||
return (null, entries);
|
||||
}
|
||||
|
||||
// Read number of files
|
||||
var fileCount = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
// Read API version (2 bytes)
|
||||
var apiVersion = BitConverter.ToUInt16(data, pos);
|
||||
pos += 2;
|
||||
|
||||
// Read global flags (4 bytes)
|
||||
var globalFlags = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
// Determine compression and signature type from flags
|
||||
var compression = (globalFlags & 0x1000) != 0 ? PhpPharCompression.GZip :
|
||||
(globalFlags & 0x2000) != 0 ? PhpPharCompression.BZip2 :
|
||||
PhpPharCompression.None;
|
||||
|
||||
var signatureType = ((globalFlags >> 16) & 0xFF) switch
|
||||
{
|
||||
0x01 => PhpPharSignatureType.Md5,
|
||||
0x02 => PhpPharSignatureType.Sha1,
|
||||
0x03 => PhpPharSignatureType.Sha256,
|
||||
0x04 => PhpPharSignatureType.Sha512,
|
||||
0x10 => PhpPharSignatureType.OpenSsl,
|
||||
_ => PhpPharSignatureType.None
|
||||
};
|
||||
|
||||
// Read alias length and alias
|
||||
var aliasLength = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
string? alias = null;
|
||||
if (aliasLength > 0 && pos + aliasLength <= data.Length)
|
||||
{
|
||||
alias = Encoding.UTF8.GetString(data, pos, (int)aliasLength);
|
||||
pos += (int)aliasLength;
|
||||
}
|
||||
|
||||
// Read metadata length
|
||||
var metadataLength = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
var metadata = new Dictionary<string, string>();
|
||||
string? version = null;
|
||||
|
||||
if (metadataLength > 0 && pos + metadataLength <= data.Length)
|
||||
{
|
||||
// Metadata is PHP serialized format - extract version if present
|
||||
var metadataBytes = new byte[metadataLength];
|
||||
Array.Copy(data, pos, metadataBytes, 0, (int)metadataLength);
|
||||
var metadataStr = Encoding.UTF8.GetString(metadataBytes);
|
||||
|
||||
version = ExtractVersionFromMetadata(metadataStr);
|
||||
pos += (int)metadataLength;
|
||||
}
|
||||
|
||||
// Parse file entries
|
||||
for (uint i = 0; i < fileCount && pos < data.Length - 18; i++)
|
||||
{
|
||||
var entry = ParseFileEntry(data, ref pos);
|
||||
if (entry is not null)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
var manifest = new PhpPharManifest(
|
||||
alias,
|
||||
version,
|
||||
apiVersion,
|
||||
compression,
|
||||
signatureType,
|
||||
metadata);
|
||||
|
||||
return (manifest, entries);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null, entries);
|
||||
}
|
||||
}
|
||||
|
||||
private static PhpPharEntry? ParseFileEntry(byte[] data, ref int pos)
|
||||
{
|
||||
try
|
||||
{
|
||||
// File entry format:
|
||||
// 4 bytes: filename length
|
||||
// n bytes: filename
|
||||
// 4 bytes: uncompressed size
|
||||
// 4 bytes: timestamp
|
||||
// 4 bytes: compressed size
|
||||
// 4 bytes: CRC32
|
||||
// 4 bytes: flags
|
||||
// 4 bytes: metadata length
|
||||
// n bytes: metadata
|
||||
|
||||
// Read filename length
|
||||
var filenameLength = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
if (filenameLength == 0 || pos + filenameLength > data.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read filename
|
||||
var filename = Encoding.UTF8.GetString(data, pos, (int)filenameLength);
|
||||
pos += (int)filenameLength;
|
||||
|
||||
// Read uncompressed size
|
||||
var uncompressedSize = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
// Read timestamp
|
||||
var timestamp = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
// Read compressed size
|
||||
var compressedSize = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
// Read CRC32
|
||||
var crc32 = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
// Read flags
|
||||
var flags = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
|
||||
var compression = (flags & 0x1000) != 0 ? PhpPharCompression.GZip :
|
||||
(flags & 0x2000) != 0 ? PhpPharCompression.BZip2 :
|
||||
PhpPharCompression.None;
|
||||
|
||||
// Read metadata length and skip metadata
|
||||
var metadataLength = BitConverter.ToUInt32(data, pos);
|
||||
pos += 4;
|
||||
pos += (int)metadataLength;
|
||||
|
||||
return new PhpPharEntry(
|
||||
filename.Replace('\\', '/'),
|
||||
uncompressedSize,
|
||||
compressedSize,
|
||||
timestamp,
|
||||
crc32,
|
||||
compression,
|
||||
null); // SHA256 would require decompressing
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractVersionFromMetadata(string metadata)
|
||||
{
|
||||
// PHP serialized format often contains version like: s:7:"version";s:5:"1.2.3";
|
||||
var versionMatch = VersionMetadataRegex().Match(metadata);
|
||||
if (versionMatch.Success)
|
||||
{
|
||||
return versionMatch.Groups["version"].Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int FindMarker(byte[] data, byte[] marker)
|
||||
{
|
||||
for (var i = 0; i <= data.Length - marker.Length; i++)
|
||||
{
|
||||
var found = true;
|
||||
for (var j = 0; j < marker.Length; j++)
|
||||
{
|
||||
if (data[i + j] != marker[j])
|
||||
{
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hashBytes = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"version.*?s:\d+:""(?<version>[^""]+)""", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex VersionMetadataRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a project for PHAR files and phar:// usage.
|
||||
/// </summary>
|
||||
internal static class PhpPharScanBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans a project for PHAR archives.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpPharScanResult> ScanAsync(
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
IReadOnlyList<PhpCapabilityEvidence> capabilityEvidences,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileSystem);
|
||||
|
||||
var archives = new List<PhpPharArchive>();
|
||||
var pharUsages = new List<PhpPharUsage>();
|
||||
|
||||
// Find all .phar files
|
||||
var pharFiles = fileSystem.GetFilesByPattern("*.phar")
|
||||
.Union(fileSystem.GetFilesByPattern("*.phar.gz"))
|
||||
.OrderBy(f => f.RelativePath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var file in pharFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var archive = await PhpPharScanner.ScanFileAsync(
|
||||
file.AbsolutePath,
|
||||
file.RelativePath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (archive is not null)
|
||||
{
|
||||
archives.Add(archive);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract phar:// usage from capability evidences (already scanned)
|
||||
pharUsages.AddRange(ExtractPharUsages(capabilityEvidences));
|
||||
|
||||
return new PhpPharScanResult(archives, pharUsages);
|
||||
}
|
||||
|
||||
private static IEnumerable<PhpPharUsage> ExtractPharUsages(IReadOnlyList<PhpCapabilityEvidence> evidences)
|
||||
{
|
||||
foreach (var evidence in evidences)
|
||||
{
|
||||
if (evidence.Kind == PhpCapabilityKind.StreamWrapper &&
|
||||
evidence.FunctionOrPattern.StartsWith("phar://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return new PhpPharUsage(
|
||||
evidence.SourceFile,
|
||||
evidence.SourceLine,
|
||||
evidence.Snippet ?? string.Empty,
|
||||
ExtractPharPath(evidence.Snippet ?? evidence.FunctionOrPattern));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractPharPath(string snippet)
|
||||
{
|
||||
// Try to extract the phar path from usage like phar://path/to/file.phar/internal/path
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
snippet,
|
||||
@"phar://([^'""\s]+)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of PHAR scanning.
|
||||
/// </summary>
|
||||
internal sealed class PhpPharScanResult
|
||||
{
|
||||
public PhpPharScanResult(
|
||||
IReadOnlyList<PhpPharArchive> archives,
|
||||
IReadOnlyList<PhpPharUsage> usages)
|
||||
{
|
||||
Archives = archives ?? Array.Empty<PhpPharArchive>();
|
||||
Usages = usages ?? Array.Empty<PhpPharUsage>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHAR archives found in the project.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpPharArchive> Archives { get; }
|
||||
|
||||
/// <summary>
|
||||
/// phar:// usage found in code.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpPharUsage> Usages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any PHAR content was found.
|
||||
/// </summary>
|
||||
public bool HasPharContent => Archives.Count > 0 || Usages.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets total file count across all archives.
|
||||
/// </summary>
|
||||
public int TotalArchivedFiles => Archives.Sum(a => a.FileCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets archives with embedded vendor directories.
|
||||
/// </summary>
|
||||
public IEnumerable<PhpPharArchive> ArchivesWithVendor
|
||||
=> Archives.Where(a => a.HasEmbeddedVendor);
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries for the scan result.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"phar.archive_count",
|
||||
Archives.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"phar.usage_count",
|
||||
Usages.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"phar.total_archived_files",
|
||||
TotalArchivedFiles.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var archivesWithVendor = ArchivesWithVendor.ToList();
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"phar.archives_with_vendor",
|
||||
archivesWithVendor.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (archivesWithVendor.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"phar.vendor_archives",
|
||||
string.Join(';', archivesWithVendor.Take(10).Select(a => a.RelativePath)));
|
||||
}
|
||||
|
||||
if (Archives.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"phar.archive_paths",
|
||||
string.Join(';', Archives.Take(10).Select(a => a.RelativePath)));
|
||||
}
|
||||
}
|
||||
|
||||
public static PhpPharScanResult Empty { get; } = new(
|
||||
Array.Empty<PhpPharArchive>(),
|
||||
Array.Empty<PhpPharUsage>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents phar:// protocol usage in code.
|
||||
/// </summary>
|
||||
internal sealed record PhpPharUsage(
|
||||
string SourceFile,
|
||||
int SourceLine,
|
||||
string Snippet,
|
||||
string? PharPath);
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the normalized input for a PHP project, merging
|
||||
/// source trees, composer manifests, vendor, configs, and container layers.
|
||||
/// </summary>
|
||||
internal sealed class PhpProjectInput
|
||||
{
|
||||
public PhpProjectInput(
|
||||
string rootPath,
|
||||
PhpVirtualFileSystem fileSystem,
|
||||
PhpConfigCollection config,
|
||||
PhpFrameworkFingerprint framework,
|
||||
ComposerLockData? composerLock = null,
|
||||
PhpComposerManifest? composerManifest = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
throw new ArgumentException("Root path is required", nameof(rootPath));
|
||||
}
|
||||
|
||||
RootPath = rootPath;
|
||||
FileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
Framework = framework ?? throw new ArgumentNullException(nameof(framework));
|
||||
ComposerLock = composerLock;
|
||||
ComposerManifest = composerManifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The root path of the PHP project.
|
||||
/// </summary>
|
||||
public string RootPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The virtual file system providing unified access to all project files.
|
||||
/// </summary>
|
||||
public PhpVirtualFileSystem FileSystem { get; }
|
||||
|
||||
/// <summary>
|
||||
/// PHP configuration collected from php.ini, conf.d, .htaccess, and FPM configs.
|
||||
/// </summary>
|
||||
public PhpConfigCollection Config { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected framework/CMS fingerprint.
|
||||
/// </summary>
|
||||
public PhpFrameworkFingerprint Framework { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed composer.lock data, if available.
|
||||
/// </summary>
|
||||
public ComposerLockData? ComposerLock { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed composer.json manifest, if available.
|
||||
/// </summary>
|
||||
public PhpComposerManifest? ComposerManifest { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the project has a vendor directory.
|
||||
/// </summary>
|
||||
public bool HasVendorDirectory => FileSystem.GetFilesBySource(PhpFileSource.Vendor).Any();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the project uses composer.
|
||||
/// </summary>
|
||||
public bool UsesComposer => ComposerLock is not null || ComposerManifest is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Creates metadata entries from the project input for SBOM generation.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.project.file_count", FileSystem.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (UsesComposer)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.project.uses_composer", "true");
|
||||
}
|
||||
|
||||
if (HasVendorDirectory)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.project.has_vendor", "true");
|
||||
}
|
||||
|
||||
if (Framework.IsDetected)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.project.framework", Framework.Name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Framework.Version))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("php.project.framework_version", Framework.Version);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var configMeta in Config.CreateMetadata())
|
||||
{
|
||||
yield return configMeta;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty project input.
|
||||
/// </summary>
|
||||
public static PhpProjectInput Empty(string rootPath) => new(
|
||||
rootPath,
|
||||
PhpVirtualFileSystem.Empty,
|
||||
PhpConfigCollection.Empty,
|
||||
PhpFrameworkFingerprint.None);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file in the PHP virtual file system.
|
||||
/// </summary>
|
||||
internal sealed record PhpVirtualFile
|
||||
{
|
||||
public PhpVirtualFile(
|
||||
string relativePath,
|
||||
string absolutePath,
|
||||
PhpFileSource source,
|
||||
string? sha256 = null,
|
||||
long? size = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
throw new ArgumentException("Relative path is required", nameof(relativePath));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(absolutePath))
|
||||
{
|
||||
throw new ArgumentException("Absolute path is required", nameof(absolutePath));
|
||||
}
|
||||
|
||||
RelativePath = NormalizePath(relativePath);
|
||||
AbsolutePath = absolutePath;
|
||||
Source = source;
|
||||
Sha256 = sha256;
|
||||
Size = size;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The path relative to the project root, normalized with forward slashes.
|
||||
/// </summary>
|
||||
public string RelativePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The absolute path on the filesystem or within the container layer.
|
||||
/// </summary>
|
||||
public string AbsolutePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of this file (source tree, vendor, container layer, etc.).
|
||||
/// </summary>
|
||||
public PhpFileSource Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the file content, if computed.
|
||||
/// </summary>
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes, if known.
|
||||
/// </summary>
|
||||
public long? Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the file.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/').TrimStart('/');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the source of a file in the virtual file system.
|
||||
/// </summary>
|
||||
internal enum PhpFileSource
|
||||
{
|
||||
/// <summary>Source tree (application code).</summary>
|
||||
SourceTree,
|
||||
|
||||
/// <summary>Composer vendor directory.</summary>
|
||||
Vendor,
|
||||
|
||||
/// <summary>Container layer (OCI/Docker).</summary>
|
||||
ContainerLayer,
|
||||
|
||||
/// <summary>PHP configuration (php.ini, conf.d).</summary>
|
||||
PhpConfig,
|
||||
|
||||
/// <summary>Web server configuration (.htaccess).</summary>
|
||||
WebServerConfig,
|
||||
|
||||
/// <summary>FPM configuration.</summary>
|
||||
FpmConfig,
|
||||
|
||||
/// <summary>Composer manifest (composer.json/lock).</summary>
|
||||
ComposerManifest
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Virtual file system that provides unified access to PHP project files
|
||||
/// from multiple sources (source tree, vendor, container layers, configs).
|
||||
/// </summary>
|
||||
internal sealed class PhpVirtualFileSystem
|
||||
{
|
||||
private readonly FrozenDictionary<string, PhpVirtualFile> _files;
|
||||
private readonly IReadOnlyList<PhpVirtualFile> _orderedFiles;
|
||||
|
||||
private PhpVirtualFileSystem(
|
||||
FrozenDictionary<string, PhpVirtualFile> files,
|
||||
IReadOnlyList<PhpVirtualFile> orderedFiles)
|
||||
{
|
||||
_files = files;
|
||||
_orderedFiles = orderedFiles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files in the virtual file system, ordered deterministically.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PhpVirtualFile> Files => _orderedFiles;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of files in the virtual file system.
|
||||
/// </summary>
|
||||
public int Count => _orderedFiles.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a file by its relative path.
|
||||
/// </summary>
|
||||
public bool TryGetFile(string relativePath, [NotNullWhen(true)] out PhpVirtualFile? file)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
file = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = NormalizePath(relativePath);
|
||||
return _files.TryGetValue(normalized, out file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files matching a specific source type.
|
||||
/// </summary>
|
||||
public IEnumerable<PhpVirtualFile> GetFilesBySource(PhpFileSource source)
|
||||
=> _orderedFiles.Where(f => f.Source == source);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files matching a glob pattern (simple matching).
|
||||
/// </summary>
|
||||
public IEnumerable<PhpVirtualFile> GetFilesByPattern(string pattern)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pattern);
|
||||
|
||||
var normalized = NormalizePath(pattern);
|
||||
return _orderedFiles.Where(f => MatchesPattern(f.RelativePath, normalized));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all PHP source files.
|
||||
/// </summary>
|
||||
public IEnumerable<PhpVirtualFile> GetPhpFiles()
|
||||
=> _orderedFiles.Where(f => f.RelativePath.EndsWith(".php", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Creates a builder for constructing a virtual file system.
|
||||
/// </summary>
|
||||
public static Builder CreateBuilder() => new();
|
||||
|
||||
/// <summary>
|
||||
/// An empty virtual file system instance.
|
||||
/// </summary>
|
||||
public static PhpVirtualFileSystem Empty { get; } = new(
|
||||
FrozenDictionary<string, PhpVirtualFile>.Empty,
|
||||
Array.Empty<PhpVirtualFile>());
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Replace('\\', '/').TrimStart('/').ToLowerInvariant();
|
||||
|
||||
private static bool MatchesPattern(string path, string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var extension = pattern[1..];
|
||||
return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.EndsWith("/*", StringComparison.Ordinal))
|
||||
{
|
||||
var directory = pattern[..^2];
|
||||
return path.StartsWith(directory, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (pattern.Contains('*', StringComparison.Ordinal))
|
||||
{
|
||||
var parts = pattern.Split('*');
|
||||
var currentIndex = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(part))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var foundIndex = path.IndexOf(part, currentIndex, StringComparison.OrdinalIgnoreCase);
|
||||
if (foundIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
currentIndex = foundIndex + part.Length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return path.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing a PhpVirtualFileSystem.
|
||||
/// </summary>
|
||||
internal sealed class Builder
|
||||
{
|
||||
private readonly Dictionary<string, PhpVirtualFile> _files = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a file to the virtual file system.
|
||||
/// Later additions override earlier ones for the same path.
|
||||
/// </summary>
|
||||
public Builder AddFile(PhpVirtualFile file)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(file);
|
||||
var key = file.RelativePath.ToLowerInvariant();
|
||||
_files[key] = file;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple files to the virtual file system.
|
||||
/// </summary>
|
||||
public Builder AddFiles(IEnumerable<PhpVirtualFile> files)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(files);
|
||||
foreach (var file in files)
|
||||
{
|
||||
AddFile(file);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the virtual file system with deterministic ordering.
|
||||
/// </summary>
|
||||
public PhpVirtualFileSystem Build()
|
||||
{
|
||||
if (_files.Count == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
var orderedFiles = _files.Values
|
||||
.OrderBy(f => f.RelativePath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new PhpVirtualFileSystem(
|
||||
_files.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
orderedFiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single PHP runtime evidence entry captured during execution.
|
||||
/// </summary>
|
||||
internal sealed record PhpRuntimeEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of the event (e.g., "php.require", "php.include", "php.opcache", "php.autoload").
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ISO-8601 timestamp of the event.
|
||||
/// </summary>
|
||||
public required string Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized relative path to the file.
|
||||
/// </summary>
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the normalized path for secure evidence correlation.
|
||||
/// </summary>
|
||||
public string? PathSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feature or class name being loaded (for autoload events).
|
||||
/// </summary>
|
||||
public string? Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
public bool? Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected capability (exec, net, serialize, etc.).
|
||||
/// </summary>
|
||||
public string? Capability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Opcache hit/miss status.
|
||||
/// </summary>
|
||||
public string? OpcacheStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Memory usage in bytes (from opcache stats).
|
||||
/// </summary>
|
||||
public long? MemoryUsage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context or error message.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of processing PHP runtime evidence.
|
||||
/// </summary>
|
||||
internal sealed record PhpRuntimeEvidenceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// All captured evidence entries, ordered by timestamp.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<PhpRuntimeEvidence> Entries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files that were actually loaded at runtime.
|
||||
/// </summary>
|
||||
public required IReadOnlySet<string> LoadedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files that were autoloaded via PSR-4/classmap.
|
||||
/// </summary>
|
||||
public required IReadOnlySet<string> AutoloadedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected capabilities from runtime analysis.
|
||||
/// </summary>
|
||||
public required IReadOnlySet<string> DetectedCapabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Opcache statistics if available.
|
||||
/// </summary>
|
||||
public OpcacheStats? Opcache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty result instance.
|
||||
/// </summary>
|
||||
public static PhpRuntimeEvidenceResult Empty { get; } = new()
|
||||
{
|
||||
Entries = Array.Empty<PhpRuntimeEvidence>(),
|
||||
LoadedFiles = new HashSet<string>(),
|
||||
AutoloadedFiles = new HashSet<string>(),
|
||||
DetectedCapabilities = new HashSet<string>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PHP opcache statistics captured at runtime.
|
||||
/// </summary>
|
||||
internal sealed record OpcacheStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether opcache is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total memory used by opcache in bytes.
|
||||
/// </summary>
|
||||
public long MemoryUsed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum memory available to opcache in bytes.
|
||||
/// </summary>
|
||||
public long MemoryMax { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of cached scripts.
|
||||
/// </summary>
|
||||
public int CachedScripts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache hit count.
|
||||
/// </summary>
|
||||
public long Hits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache miss count.
|
||||
/// </summary>
|
||||
public long Misses { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Collects and processes PHP runtime evidence from NDJSON files generated by the runtime shim.
|
||||
/// </summary>
|
||||
internal static class PhpRuntimeEvidenceCollector
|
||||
{
|
||||
private const string DefaultOutputFileName = "php-runtime.ndjson";
|
||||
|
||||
/// <summary>
|
||||
/// Collects runtime evidence from the default output file in the specified directory.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpRuntimeEvidenceResult> CollectAsync(
|
||||
string directory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
|
||||
var outputPath = Path.Combine(directory, DefaultOutputFileName);
|
||||
return await CollectFromFileAsync(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects runtime evidence from a specific NDJSON file.
|
||||
/// </summary>
|
||||
public static async ValueTask<PhpRuntimeEvidenceResult> CollectFromFileAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return PhpRuntimeEvidenceResult.Empty;
|
||||
}
|
||||
|
||||
var entries = new List<PhpRuntimeEvidence>();
|
||||
var loadedFiles = new HashSet<string>(StringComparer.Ordinal);
|
||||
var autoloadedFiles = new HashSet<string>(StringComparer.Ordinal);
|
||||
var capabilities = new HashSet<string>(StringComparer.Ordinal);
|
||||
OpcacheStats? opcacheStats = null;
|
||||
|
||||
await foreach (var line in File.ReadLinesAsync(filePath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var type = GetString(root, "type");
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var timestamp = GetString(root, "ts") ?? string.Empty;
|
||||
var path = GetString(root, "path");
|
||||
var pathSha256 = GetString(root, "path_sha256");
|
||||
var feature = GetString(root, "class") ?? GetString(root, "feature");
|
||||
var success = GetBool(root, "success");
|
||||
var capability = GetString(root, "capability");
|
||||
var message = GetString(root, "message");
|
||||
|
||||
// Process opcache stats
|
||||
if (type == "php.opcache" && root.TryGetProperty("opcache", out var opcacheElement))
|
||||
{
|
||||
opcacheStats = ParseOpcacheStats(opcacheElement);
|
||||
}
|
||||
|
||||
// Track loaded files
|
||||
if (!string.IsNullOrWhiteSpace(path) && (type == "php.include" || type == "php.require"))
|
||||
{
|
||||
loadedFiles.Add(path);
|
||||
}
|
||||
|
||||
// Track autoloaded classes
|
||||
if (type == "php.autoload" && !string.IsNullOrWhiteSpace(feature))
|
||||
{
|
||||
autoloadedFiles.Add(feature);
|
||||
}
|
||||
|
||||
// Track capabilities
|
||||
if (!string.IsNullOrWhiteSpace(capability))
|
||||
{
|
||||
capabilities.Add(capability);
|
||||
}
|
||||
|
||||
// Also process capability arrays from runtime.end events
|
||||
if (root.TryGetProperty("capabilities", out var capsArray) &&
|
||||
capsArray.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var cap in capsArray.EnumerateArray())
|
||||
{
|
||||
if (cap.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var capValue = cap.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(capValue))
|
||||
{
|
||||
capabilities.Add(capValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new PhpRuntimeEvidence
|
||||
{
|
||||
Type = type,
|
||||
Timestamp = timestamp,
|
||||
Path = path,
|
||||
PathSha256 = pathSha256,
|
||||
Feature = feature,
|
||||
Success = success,
|
||||
Capability = capability,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip malformed lines
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp for deterministic ordering
|
||||
entries.Sort((a, b) =>
|
||||
{
|
||||
var cmp = string.Compare(a.Timestamp, b.Timestamp, StringComparison.Ordinal);
|
||||
return cmp != 0 ? cmp : string.Compare(a.Type, b.Type, StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
return new PhpRuntimeEvidenceResult
|
||||
{
|
||||
Entries = entries,
|
||||
LoadedFiles = loadedFiles,
|
||||
AutoloadedFiles = autoloadedFiles,
|
||||
DetectedCapabilities = capabilities,
|
||||
Opcache = opcacheStats
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a SHA-256 hash of a path for secure evidence correlation.
|
||||
/// </summary>
|
||||
public static string HashPath(string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
var normalized = NormalizePath(path);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a file path for consistent hashing.
|
||||
/// </summary>
|
||||
public static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Normalize separators and remove leading ./
|
||||
return path
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('.', '/')
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? prop.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool? GetBool(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var prop))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return prop.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static long? GetLong(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var prop) && prop.TryGetInt64(out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var prop) && prop.TryGetInt32(out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static OpcacheStats? ParseOpcacheStats(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OpcacheStats
|
||||
{
|
||||
Enabled = GetBool(element, "enabled") ?? false,
|
||||
MemoryUsed = GetLong(element, "memory_used") ?? 0,
|
||||
MemoryMax = GetLong(element, "memory_max") ?? 0,
|
||||
CachedScripts = GetInt(element, "cached_scripts") ?? 0,
|
||||
Hits = GetLong(element, "hits") ?? 0,
|
||||
Misses = GetLong(element, "misses") ?? 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the PHP runtime shim that captures runtime events (require/include, autoload, opcache) into NDJSON.
|
||||
/// This shim is written to disk alongside the analyzer to be invoked by the worker/CLI.
|
||||
/// </summary>
|
||||
internal static class PhpRuntimeShim
|
||||
{
|
||||
private const string ShimFileName = "stella-trace.php";
|
||||
|
||||
public static string FileName => ShimFileName;
|
||||
|
||||
public static async Task<string> WriteAsync(string directory, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var path = Path.Combine(directory, ShimFileName);
|
||||
await File.WriteAllTextAsync(path, ShimSource, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
return path;
|
||||
}
|
||||
|
||||
// NOTE: This shim is intentionally self-contained, offline, and deterministic.
|
||||
// Uses PHP's shutdown handler and autoload hooks for runtime introspection with append-only evidence collection.
|
||||
private const string ShimSource = """
|
||||
<?php
|
||||
/**
|
||||
* PHP runtime trace shim (offline, deterministic)
|
||||
* Captures require, include, autoload, and opcache events.
|
||||
* Emits NDJSON to php-runtime.ndjson for evidence collection.
|
||||
*
|
||||
* Usage: php -d auto_prepend_file=stella-trace.php your-script.php
|
||||
*
|
||||
* Environment variables:
|
||||
* STELLA_PHP_ENTRYPOINT - Entrypoint script path (optional, for standalone mode)
|
||||
* STELLA_PHP_OUTPUT - Output file path (default: php-runtime.ndjson)
|
||||
* STELLA_PHP_OPCACHE - Set to "1" to collect opcache stats
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace StellaOps\Tracer;
|
||||
|
||||
final class RuntimeTracer
|
||||
{
|
||||
private const OUTPUT_FILE = 'php-runtime.ndjson';
|
||||
private const REDACT_PATTERNS = [
|
||||
'/password/i',
|
||||
'/secret/i',
|
||||
'/api[_-]?key/i',
|
||||
'/auth[_-]?token/i',
|
||||
'/bearer/i',
|
||||
'/credential/i',
|
||||
'/private[_-]?key/i',
|
||||
];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private static array $events = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private static array $loadedFiles = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private static array $autoloadedClasses = [];
|
||||
|
||||
private static string $cwd;
|
||||
private static string $outputFile;
|
||||
private static bool $opcacheEnabled = false;
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function initialize(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
self::$cwd = \str_replace('\\', '/', \getcwd() ?: '');
|
||||
self::$outputFile = $_ENV['STELLA_PHP_OUTPUT'] ?? self::OUTPUT_FILE;
|
||||
self::$opcacheEnabled = ($_ENV['STELLA_PHP_OPCACHE'] ?? '0') === '1';
|
||||
|
||||
// Register autoload tracer
|
||||
\spl_autoload_register([self::class, 'autoloadTracer'], prepend: true);
|
||||
|
||||
// Register shutdown handler
|
||||
\register_shutdown_function([self::class, 'shutdown']);
|
||||
|
||||
// Record start event
|
||||
self::addEvent([
|
||||
'type' => 'php.runtime.start',
|
||||
'ts' => self::nowIso(),
|
||||
'php_version' => \PHP_VERSION,
|
||||
'php_sapi' => \PHP_SAPI,
|
||||
'cwd' => self::$cwd,
|
||||
]);
|
||||
|
||||
// Capture initially loaded files
|
||||
foreach (\get_included_files() as $file) {
|
||||
$normalized = self::normalizePath($file);
|
||||
self::$loadedFiles[$normalized] = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static function autoloadTracer(string $class): void
|
||||
{
|
||||
$normalized = self::normalizeClassName($class);
|
||||
self::$autoloadedClasses[$normalized] = true;
|
||||
|
||||
self::addEvent([
|
||||
'type' => 'php.autoload',
|
||||
'ts' => self::nowIso(),
|
||||
'class' => $normalized,
|
||||
'class_sha256' => self::sha256($normalized),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordInclude(string $file, string $type = 'include'): void
|
||||
{
|
||||
$normalized = self::normalizePath($file);
|
||||
$pathSha256 = self::sha256($normalized);
|
||||
|
||||
self::$loadedFiles[$normalized] = true;
|
||||
|
||||
$event = [
|
||||
'type' => "php.{$type}",
|
||||
'ts' => self::nowIso(),
|
||||
'path' => $normalized,
|
||||
'path_sha256' => $pathSha256,
|
||||
'success' => \file_exists($file),
|
||||
];
|
||||
|
||||
$capability = self::detectCapability($file);
|
||||
if ($capability !== null) {
|
||||
$event['capability'] = $capability;
|
||||
}
|
||||
|
||||
self::addEvent($event);
|
||||
}
|
||||
|
||||
public static function recordError(string $message, ?string $file = null): void
|
||||
{
|
||||
$event = [
|
||||
'type' => 'php.runtime.error',
|
||||
'ts' => self::nowIso(),
|
||||
'message' => self::redact($message),
|
||||
];
|
||||
|
||||
if ($file !== null) {
|
||||
$normalized = self::normalizePath($file);
|
||||
$event['path'] = $normalized;
|
||||
$event['path_sha256'] = self::sha256($normalized);
|
||||
}
|
||||
|
||||
self::addEvent($event);
|
||||
}
|
||||
|
||||
public static function shutdown(): void
|
||||
{
|
||||
// Capture final included files
|
||||
foreach (\get_included_files() as $file) {
|
||||
$normalized = self::normalizePath($file);
|
||||
if (!isset(self::$loadedFiles[$normalized])) {
|
||||
self::$loadedFiles[$normalized] = true;
|
||||
self::recordInclude($file, 'include');
|
||||
}
|
||||
}
|
||||
|
||||
// Capture opcache stats if enabled
|
||||
$opcacheStats = null;
|
||||
if (self::$opcacheEnabled && \function_exists('opcache_get_status')) {
|
||||
$status = @\opcache_get_status(false);
|
||||
if (\is_array($status)) {
|
||||
$opcacheStats = [
|
||||
'enabled' => $status['opcache_enabled'] ?? false,
|
||||
'memory_used' => $status['memory_usage']['used_memory'] ?? 0,
|
||||
'memory_max' => $status['memory_usage']['used_memory'] + ($status['memory_usage']['free_memory'] ?? 0),
|
||||
'cached_scripts' => $status['opcache_statistics']['num_cached_scripts'] ?? 0,
|
||||
'hits' => $status['opcache_statistics']['hits'] ?? 0,
|
||||
'misses' => $status['opcache_statistics']['misses'] ?? 0,
|
||||
];
|
||||
|
||||
self::addEvent([
|
||||
'type' => 'php.opcache',
|
||||
'ts' => self::nowIso(),
|
||||
'opcache' => $opcacheStats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect capabilities
|
||||
$capabilities = [];
|
||||
foreach (self::$loadedFiles as $file => $_) {
|
||||
$cap = self::detectCapability($file);
|
||||
if ($cap !== null) {
|
||||
$capabilities[$cap] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Record end event
|
||||
self::addEvent([
|
||||
'type' => 'php.runtime.end',
|
||||
'ts' => self::nowIso(),
|
||||
'loaded_files_count' => \count(self::$loadedFiles),
|
||||
'autoloaded_classes_count' => \count(self::$autoloadedClasses),
|
||||
'capabilities' => \array_keys($capabilities),
|
||||
]);
|
||||
|
||||
self::flush();
|
||||
}
|
||||
|
||||
private static function nowIso(): string
|
||||
{
|
||||
return (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z');
|
||||
}
|
||||
|
||||
private static function sha256(string $value): string
|
||||
{
|
||||
return \hash('sha256', $value);
|
||||
}
|
||||
|
||||
private static function normalizePath(string $path): string
|
||||
{
|
||||
$path = \str_replace('\\', '/', $path);
|
||||
|
||||
if ($path === '' || $path === '.') {
|
||||
return '.';
|
||||
}
|
||||
|
||||
// Make relative to CWD
|
||||
if (self::$cwd !== '' && \str_starts_with($path, self::$cwd)) {
|
||||
$offset = \strlen(self::$cwd);
|
||||
if ($path[$offset] === '/') {
|
||||
$offset++;
|
||||
}
|
||||
$path = \substr($path, $offset);
|
||||
}
|
||||
|
||||
// Remove leading ./
|
||||
return \ltrim($path, './');
|
||||
}
|
||||
|
||||
private static function normalizeClassName(string $class): string
|
||||
{
|
||||
return \str_replace('\\', '/', \ltrim($class, '\\'));
|
||||
}
|
||||
|
||||
private static function redact(string $value): string
|
||||
{
|
||||
foreach (self::REDACT_PATTERNS as $pattern) {
|
||||
if (\preg_match($pattern, $value)) {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function detectCapability(string $path): ?string
|
||||
{
|
||||
$lower = \strtolower($path);
|
||||
|
||||
// Execution capabilities
|
||||
$execPatterns = ['exec', 'shell', 'proc_', 'passthru', 'system', 'popen'];
|
||||
foreach ($execPatterns as $p) {
|
||||
if (\str_contains($lower, $p)) {
|
||||
return 'exec';
|
||||
}
|
||||
}
|
||||
|
||||
// Network capabilities
|
||||
$netPatterns = ['curl', 'guzzle', 'http', 'socket', 'ftp', 'smtp'];
|
||||
foreach ($netPatterns as $p) {
|
||||
if (\str_contains($lower, $p)) {
|
||||
return 'net';
|
||||
}
|
||||
}
|
||||
|
||||
// Serialization capabilities
|
||||
$serializePatterns = ['serialize', 'unserialize', 'yaml', 'json'];
|
||||
foreach ($serializePatterns as $p) {
|
||||
if (\str_contains($lower, $p)) {
|
||||
return 'serialize';
|
||||
}
|
||||
}
|
||||
|
||||
// Database capabilities
|
||||
$dbPatterns = ['mysql', 'pgsql', 'sqlite', 'mongo', 'redis', 'pdo', 'doctrine', 'eloquent'];
|
||||
foreach ($dbPatterns as $p) {
|
||||
if (\str_contains($lower, $p)) {
|
||||
return 'database';
|
||||
}
|
||||
}
|
||||
|
||||
// Crypto capabilities
|
||||
$cryptoPatterns = ['openssl', 'sodium', 'crypt', 'hash', 'mcrypt'];
|
||||
foreach ($cryptoPatterns as $p) {
|
||||
if (\str_contains($lower, $p)) {
|
||||
return 'crypto';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
private static function addEvent(array $event): void
|
||||
{
|
||||
self::$events[] = $event;
|
||||
}
|
||||
|
||||
private static function flush(): void
|
||||
{
|
||||
// Sort by timestamp for deterministic output
|
||||
\usort(self::$events, fn(array $a, array $b): int =>
|
||||
($a['ts'] ?? '') <=> ($b['ts'] ?? '') ?: ($a['type'] ?? '') <=> ($b['type'] ?? '')
|
||||
);
|
||||
|
||||
$handle = @\fopen(self::$outputFile, 'w');
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach (self::$events as $event) {
|
||||
$json = \json_encode($event, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
|
||||
if ($json !== false) {
|
||||
\fwrite($handle, $json . "\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
\fclose($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the tracer
|
||||
RuntimeTracer::initialize();
|
||||
""";
|
||||
}
|
||||
@@ -13,26 +13,264 @@ public sealed class PhpLanguageAnalyzer : ILanguageAnalyzer
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var lockData = await ComposerLockData.LoadAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var packages = PhpPackageCollector.Collect(lockData);
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Normalize project input (merge source trees, configs, vendor, etc.)
|
||||
var projectInput = await PhpInputNormalizer.NormalizeAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Load installed.json for detailed package metadata and autoload config
|
||||
var installedData = await PhpInstalledJsonReader.LoadAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Build autoload graph
|
||||
var autoloadGraph = PhpAutoloadGraphBuilder.Build(installedData, projectInput.ComposerManifest);
|
||||
|
||||
// Build include/require graph
|
||||
var includeGraph = await PhpIncludeGraphBuilder.BuildAsync(
|
||||
projectInput.FileSystem,
|
||||
autoloadGraph,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Scan for runtime capabilities
|
||||
var capabilityScan = await PhpCapabilityScanBuilder.ScanAsync(
|
||||
projectInput.FileSystem,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Scan for PHAR archives and phar:// usage
|
||||
var pharScan = await PhpPharScanBuilder.ScanAsync(
|
||||
projectInput.FileSystem,
|
||||
capabilityScan.Evidences,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Scan for framework/CMS surface (routes, controllers, middleware, etc.)
|
||||
var frameworkSurface = await PhpFrameworkSurfaceScanner.ScanAsync(
|
||||
projectInput.FileSystem,
|
||||
projectInput.Framework,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Scan for extensions and environment settings
|
||||
var environmentSettings = await PhpExtensionScanner.ScanAsync(
|
||||
projectInput.Config,
|
||||
projectInput.FileSystem,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use composer lock data from project input
|
||||
var lockData = projectInput.ComposerLock ?? ComposerLockData.Empty;
|
||||
var packages = PhpPackageCollector.Collect(lockData);
|
||||
|
||||
// Build set of bin entrypoint packages for usedByEntrypoint flag
|
||||
var binPackages = new HashSet<string>(
|
||||
autoloadGraph.BinEntrypoints
|
||||
.Where(b => b.PackageName is not null)
|
||||
.Select(b => b.PackageName!),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Emit package components
|
||||
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var usedByEntrypoint = binPackages.Contains(package.Name);
|
||||
|
||||
// Combine package metadata with framework signals
|
||||
var metadata = CombineMetadata(
|
||||
package.CreateMetadata(),
|
||||
projectInput.Framework.CreateMetadata());
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "composer",
|
||||
metadata: package.CreateMetadata(),
|
||||
metadata: metadata,
|
||||
evidence: package.CreateEvidence(),
|
||||
usedByEntrypoint: false);
|
||||
usedByEntrypoint: usedByEntrypoint);
|
||||
}
|
||||
|
||||
// Emit project-level metadata if we have any packages, include edges, capabilities, PHAR content, surface, or settings
|
||||
if (packages.Count > 0 || !includeGraph.IsEmpty || capabilityScan.HasCapabilities || pharScan.HasPharContent || frameworkSurface.HasSurface || environmentSettings.HasSettings)
|
||||
{
|
||||
EmitProjectMetadata(writer, projectInput, autoloadGraph, includeGraph, capabilityScan, pharScan, frameworkSurface, environmentSettings);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string?>> CombineMetadata(
|
||||
IEnumerable<KeyValuePair<string, string?>> packageMetadata,
|
||||
IEnumerable<KeyValuePair<string, string?>> frameworkMetadata)
|
||||
{
|
||||
foreach (var item in packageMetadata)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
foreach (var item in frameworkMetadata)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitProjectMetadata(LanguageComponentWriter writer, PhpProjectInput projectInput, PhpAutoloadGraph autoloadGraph, PhpIncludeGraph includeGraph, PhpCapabilityScanResult capabilityScan, PhpPharScanResult pharScan, PhpFrameworkSurface frameworkSurface, PhpEnvironmentSettings environmentSettings)
|
||||
{
|
||||
var metadata = projectInput.CreateMetadata().ToList();
|
||||
|
||||
// Add manifest metadata if available
|
||||
if (projectInput.ComposerManifest is { } manifest)
|
||||
{
|
||||
foreach (var item in manifest.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Add autoload graph metadata
|
||||
foreach (var item in autoloadGraph.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
|
||||
// Add include graph metadata
|
||||
foreach (var item in includeGraph.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
|
||||
// Add capability scan metadata
|
||||
foreach (var item in capabilityScan.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
|
||||
// Add capability summary metadata
|
||||
var capabilitySummary = capabilityScan.CreateSummary();
|
||||
foreach (var item in capabilitySummary.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
|
||||
// Add PHAR scan metadata
|
||||
foreach (var item in pharScan.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
|
||||
// Add framework surface metadata
|
||||
foreach (var item in frameworkSurface.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
|
||||
// Add environment settings metadata
|
||||
foreach (var item in environmentSettings.CreateMetadata())
|
||||
{
|
||||
metadata.Add(item);
|
||||
}
|
||||
|
||||
// Create a summary component for the project
|
||||
var projectEvidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
if (projectInput.ComposerManifest is { } m && !string.IsNullOrWhiteSpace(m.Sha256))
|
||||
{
|
||||
projectEvidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"composer.json",
|
||||
"composer.json",
|
||||
Value: m.Name ?? "unknown",
|
||||
Sha256: m.Sha256));
|
||||
}
|
||||
|
||||
if (projectInput.ComposerLock is { } l && !string.IsNullOrWhiteSpace(l.LockSha256))
|
||||
{
|
||||
projectEvidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"composer.lock",
|
||||
"composer.lock",
|
||||
Value: $"{l.Packages.Count}+{l.DevPackages.Count} packages",
|
||||
Sha256: l.LockSha256));
|
||||
}
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: "php::project-summary",
|
||||
purl: null,
|
||||
name: "PHP Project Summary",
|
||||
version: null,
|
||||
type: "php-project",
|
||||
metadata: metadata,
|
||||
evidence: projectEvidence);
|
||||
|
||||
// Emit bin entrypoints as separate components
|
||||
foreach (var binEntry in autoloadGraph.BinEntrypoints)
|
||||
{
|
||||
var binMetadata = new List<KeyValuePair<string, string?>>
|
||||
{
|
||||
new("bin.name", binEntry.Name),
|
||||
new("bin.path", binEntry.Path)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(binEntry.PackageName))
|
||||
{
|
||||
binMetadata.Add(new("bin.package", binEntry.PackageName));
|
||||
}
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: $"php::bin::{binEntry.Name}",
|
||||
purl: null,
|
||||
name: binEntry.Name,
|
||||
version: null,
|
||||
type: "php-bin",
|
||||
metadata: binMetadata,
|
||||
evidence: Array.Empty<LanguageComponentEvidence>());
|
||||
}
|
||||
|
||||
// Emit composer plugins as separate components
|
||||
foreach (var plugin in autoloadGraph.Plugins)
|
||||
{
|
||||
var pluginMetadata = new List<KeyValuePair<string, string?>>
|
||||
{
|
||||
new("plugin.class", plugin.PluginClass),
|
||||
new("plugin.package", plugin.PackageName)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(plugin.Version))
|
||||
{
|
||||
pluginMetadata.Add(new("plugin.version", plugin.Version));
|
||||
}
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: $"php::plugin::{plugin.PackageName}",
|
||||
purl: null,
|
||||
name: $"Plugin: {plugin.PackageName}",
|
||||
version: plugin.Version,
|
||||
type: "php-plugin",
|
||||
metadata: pluginMetadata,
|
||||
evidence: Array.Empty<LanguageComponentEvidence>());
|
||||
}
|
||||
|
||||
// Emit PHAR archives as separate components
|
||||
foreach (var phar in pharScan.Archives)
|
||||
{
|
||||
var pharMetadata = phar.CreateMetadata().ToList();
|
||||
|
||||
var pharEvidence = new List<LanguageComponentEvidence>();
|
||||
if (!string.IsNullOrWhiteSpace(phar.Sha256))
|
||||
{
|
||||
pharEvidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
phar.RelativePath,
|
||||
phar.RelativePath,
|
||||
Value: $"{phar.FileCount} files",
|
||||
Sha256: phar.Sha256));
|
||||
}
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: $"php::phar::{phar.RelativePath}",
|
||||
purl: null,
|
||||
name: $"PHAR: {Path.GetFileName(phar.RelativePath)}",
|
||||
version: phar.Manifest?.Version,
|
||||
type: "php-phar",
|
||||
metadata: pharMetadata,
|
||||
evidence: pharEvidence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Entrypoints;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered Python entrypoint.
|
||||
/// </summary>
|
||||
/// <param name="Name">Display name of the entrypoint (e.g., "myapp", "manage.py").</param>
|
||||
/// <param name="Kind">Type of entrypoint.</param>
|
||||
/// <param name="Target">Target module or callable (e.g., "myapp.cli:main", "myapp.__main__").</param>
|
||||
/// <param name="VirtualPath">Path in the virtual filesystem.</param>
|
||||
/// <param name="InvocationContext">How to invoke this entrypoint.</param>
|
||||
/// <param name="Confidence">Confidence level of the detection.</param>
|
||||
/// <param name="Source">Source of the entrypoint definition.</param>
|
||||
/// <param name="Metadata">Additional metadata.</param>
|
||||
internal sealed record PythonEntrypoint(
|
||||
string Name,
|
||||
PythonEntrypointKind Kind,
|
||||
string Target,
|
||||
string? VirtualPath,
|
||||
PythonInvocationContext InvocationContext,
|
||||
PythonEntrypointConfidence Confidence,
|
||||
string Source,
|
||||
FrozenDictionary<string, string>? Metadata = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the module part of the target (before the colon).
|
||||
/// </summary>
|
||||
public string? ModulePath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(Target))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var colonIndex = Target.IndexOf(':');
|
||||
return colonIndex > 0 ? Target[..colonIndex] : Target;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the callable part of the target (after the colon).
|
||||
/// </summary>
|
||||
public string? Callable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(Target))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var colonIndex = Target.IndexOf(':');
|
||||
return colonIndex > 0 && colonIndex < Target.Length - 1
|
||||
? Target[(colonIndex + 1)..]
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this is a framework-specific entrypoint.
|
||||
/// </summary>
|
||||
public bool IsFrameworkEntrypoint => Kind is
|
||||
PythonEntrypointKind.DjangoManage or
|
||||
PythonEntrypointKind.WsgiApp or
|
||||
PythonEntrypointKind.AsgiApp or
|
||||
PythonEntrypointKind.CeleryWorker or
|
||||
PythonEntrypointKind.LambdaHandler or
|
||||
PythonEntrypointKind.AzureFunctionHandler or
|
||||
PythonEntrypointKind.CloudFunctionHandler;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this is a CLI entrypoint.
|
||||
/// </summary>
|
||||
public bool IsCliEntrypoint => Kind is
|
||||
PythonEntrypointKind.ConsoleScript or
|
||||
PythonEntrypointKind.Script or
|
||||
PythonEntrypointKind.CliApp or
|
||||
PythonEntrypointKind.PackageMain;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes how a Python entrypoint is invoked.
|
||||
/// </summary>
|
||||
/// <param name="InvocationType">How the entrypoint is invoked.</param>
|
||||
/// <param name="Command">Command or module to invoke (e.g., "python -m myapp", "./bin/myapp").</param>
|
||||
/// <param name="Arguments">Additional arguments or environment requirements.</param>
|
||||
/// <param name="WorkingDirectory">Expected working directory relative to project root.</param>
|
||||
internal sealed record PythonInvocationContext(
|
||||
PythonInvocationType InvocationType,
|
||||
string Command,
|
||||
string? Arguments = null,
|
||||
string? WorkingDirectory = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an invocation context for running as a module.
|
||||
/// </summary>
|
||||
public static PythonInvocationContext AsModule(string modulePath) =>
|
||||
new(PythonInvocationType.Module, $"python -m {modulePath}");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invocation context for running as a script.
|
||||
/// </summary>
|
||||
public static PythonInvocationContext AsScript(string scriptPath) =>
|
||||
new(PythonInvocationType.Script, scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invocation context for running as a console script.
|
||||
/// </summary>
|
||||
public static PythonInvocationContext AsConsoleScript(string name) =>
|
||||
new(PythonInvocationType.ConsoleScript, name);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invocation context for a WSGI/ASGI app.
|
||||
/// </summary>
|
||||
public static PythonInvocationContext AsWsgiApp(string runner, string target) =>
|
||||
new(PythonInvocationType.WsgiAsgi, runner, target);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an invocation context for a serverless handler.
|
||||
/// </summary>
|
||||
public static PythonInvocationContext AsHandler(string handler) =>
|
||||
new(PythonInvocationType.Handler, handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a Python entrypoint is invoked.
|
||||
/// </summary>
|
||||
internal enum PythonInvocationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoked via python -m module.
|
||||
/// </summary>
|
||||
Module,
|
||||
|
||||
/// <summary>
|
||||
/// Invoked as a script file.
|
||||
/// </summary>
|
||||
Script,
|
||||
|
||||
/// <summary>
|
||||
/// Invoked via installed console script.
|
||||
/// </summary>
|
||||
ConsoleScript,
|
||||
|
||||
/// <summary>
|
||||
/// Invoked via WSGI/ASGI server.
|
||||
/// </summary>
|
||||
WsgiAsgi,
|
||||
|
||||
/// <summary>
|
||||
/// Invoked as a serverless handler.
|
||||
/// </summary>
|
||||
Handler
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for entrypoint detection.
|
||||
/// </summary>
|
||||
internal enum PythonEntrypointConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Low confidence - inferred from heuristics or naming patterns.
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence - detected from configuration or common patterns.
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence - explicitly declared in metadata.
|
||||
/// </summary>
|
||||
High,
|
||||
|
||||
/// <summary>
|
||||
/// Definitive - from authoritative source like entry_points.txt.
|
||||
/// </summary>
|
||||
Definitive
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Entrypoints;
|
||||
|
||||
/// <summary>
|
||||
/// Result of Python entrypoint analysis.
|
||||
/// </summary>
|
||||
internal sealed class PythonEntrypointAnalysis
|
||||
{
|
||||
private PythonEntrypointAnalysis(
|
||||
IReadOnlyList<PythonEntrypoint> entrypoints,
|
||||
IReadOnlyList<PythonEntrypoint> consoleScripts,
|
||||
IReadOnlyList<PythonEntrypoint> frameworkEntrypoints,
|
||||
IReadOnlyList<PythonEntrypoint> cliEntrypoints)
|
||||
{
|
||||
Entrypoints = entrypoints;
|
||||
ConsoleScripts = consoleScripts;
|
||||
FrameworkEntrypoints = frameworkEntrypoints;
|
||||
CliEntrypoints = cliEntrypoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all discovered entrypoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonEntrypoint> Entrypoints { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets console_scripts and gui_scripts entrypoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonEntrypoint> ConsoleScripts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets framework-specific entrypoints (Django, Lambda, etc.).
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonEntrypoint> FrameworkEntrypoints { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets CLI application entrypoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonEntrypoint> CliEntrypoints { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary entrypoint (highest confidence, CLI preference).
|
||||
/// </summary>
|
||||
public PythonEntrypoint? PrimaryEntrypoint =>
|
||||
Entrypoints
|
||||
.OrderByDescending(static e => e.Confidence)
|
||||
.ThenBy(static e => e.Kind switch
|
||||
{
|
||||
PythonEntrypointKind.ConsoleScript => 0,
|
||||
PythonEntrypointKind.PackageMain => 1,
|
||||
PythonEntrypointKind.CliApp => 2,
|
||||
PythonEntrypointKind.LambdaHandler => 3,
|
||||
PythonEntrypointKind.WsgiApp => 4,
|
||||
PythonEntrypointKind.AsgiApp => 5,
|
||||
_ => 10
|
||||
})
|
||||
.ThenBy(static e => e.Name, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes entrypoints from a virtual filesystem.
|
||||
/// </summary>
|
||||
public static async Task<PythonEntrypointAnalysis> AnalyzeAsync(
|
||||
PythonVirtualFileSystem vfs,
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var discovery = new PythonEntrypointDiscovery(vfs, rootPath);
|
||||
await discovery.DiscoverAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var entrypoints = discovery.Entrypoints
|
||||
.OrderBy(static e => e.Name, StringComparer.Ordinal)
|
||||
.ThenBy(static e => e.Kind)
|
||||
.ToList();
|
||||
|
||||
var consoleScripts = entrypoints
|
||||
.Where(static e => e.Kind is PythonEntrypointKind.ConsoleScript or PythonEntrypointKind.GuiScript)
|
||||
.ToList();
|
||||
|
||||
var frameworkEntrypoints = entrypoints
|
||||
.Where(static e => e.IsFrameworkEntrypoint)
|
||||
.ToList();
|
||||
|
||||
var cliEntrypoints = entrypoints
|
||||
.Where(static e => e.IsCliEntrypoint)
|
||||
.ToList();
|
||||
|
||||
return new PythonEntrypointAnalysis(
|
||||
entrypoints,
|
||||
consoleScripts,
|
||||
frameworkEntrypoints,
|
||||
cliEntrypoints);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates metadata entries for the analysis result.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> ToMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.total", Entrypoints.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.consoleScripts", ConsoleScripts.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.framework", FrameworkEntrypoints.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.cli", CliEntrypoints.Count.ToString());
|
||||
|
||||
if (PrimaryEntrypoint is not null)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.primary.name", PrimaryEntrypoint.Name);
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.primary.kind", PrimaryEntrypoint.Kind.ToString());
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.primary.target", PrimaryEntrypoint.Target);
|
||||
}
|
||||
|
||||
// List all console scripts
|
||||
if (ConsoleScripts.Count > 0)
|
||||
{
|
||||
var scripts = string.Join(';', ConsoleScripts.Select(static e => e.Name));
|
||||
yield return new KeyValuePair<string, string?>("entrypoints.consoleScripts.names", scripts);
|
||||
}
|
||||
|
||||
// List framework entrypoints
|
||||
foreach (var ep in FrameworkEntrypoints)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"entrypoints.{ep.Kind.ToString().ToLowerInvariant()}", ep.Target);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets entrypoints by kind.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonEntrypoint> GetByKind(PythonEntrypointKind kind) =>
|
||||
Entrypoints.Where(e => e.Kind == kind);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entrypoints by confidence level.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonEntrypoint> GetByConfidence(PythonEntrypointConfidence minConfidence) =>
|
||||
Entrypoints.Where(e => e.Confidence >= minConfidence);
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Entrypoints;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers Python entrypoints from a virtual filesystem.
|
||||
/// </summary>
|
||||
internal sealed partial class PythonEntrypointDiscovery
|
||||
{
|
||||
private readonly PythonVirtualFileSystem _vfs;
|
||||
private readonly string _rootPath;
|
||||
private readonly List<PythonEntrypoint> _entrypoints = new();
|
||||
|
||||
public PythonEntrypointDiscovery(PythonVirtualFileSystem vfs, string rootPath)
|
||||
{
|
||||
_vfs = vfs ?? throw new ArgumentNullException(nameof(vfs));
|
||||
_rootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all discovered entrypoints.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonEntrypoint> Entrypoints => _entrypoints;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all entrypoints from the virtual filesystem.
|
||||
/// </summary>
|
||||
public async Task<PythonEntrypointDiscovery> DiscoverAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await DiscoverPackageMainsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverConsoleScriptsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverDataScriptsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverZipappMainsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverDjangoEntrypointsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverWsgiAsgiAppsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverCeleryEntrypointsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverLambdaHandlersAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverCliAppsAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DiscoverStandaloneScriptsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers __main__.py files in packages.
|
||||
/// </summary>
|
||||
private Task DiscoverPackageMainsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var mainFiles = _vfs.EnumerateFiles(string.Empty, "**/__main__.py");
|
||||
|
||||
foreach (var file in mainFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Extract package name from path
|
||||
var parts = file.VirtualPath.Split('/');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
continue; // Not in a package
|
||||
}
|
||||
|
||||
// Get the package path (everything except __main__.py)
|
||||
var packagePath = string.Join('.', parts[..^1]);
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: parts[0],
|
||||
Kind: PythonEntrypointKind.PackageMain,
|
||||
Target: packagePath,
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: PythonInvocationContext.AsModule(packagePath),
|
||||
Confidence: PythonEntrypointConfidence.Definitive,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers console_scripts and gui_scripts from entry_points.txt files.
|
||||
/// </summary>
|
||||
private async Task DiscoverConsoleScriptsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var entryPointFiles = _vfs.EnumerateFiles(string.Empty, "*.dist-info/entry_points.txt");
|
||||
|
||||
foreach (var file in entryPointFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var absolutePath = file.AbsolutePath;
|
||||
if (file.IsFromArchive)
|
||||
{
|
||||
continue; // Can't read from archive directly yet
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_rootPath, absolutePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
fullPath = absolutePath;
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
ParseEntryPointsTxt(content, file.VirtualPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseEntryPointsTxt(string content, string source)
|
||||
{
|
||||
string? currentSection = null;
|
||||
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section header
|
||||
if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
|
||||
{
|
||||
currentSection = trimmed[1..^1].Trim().ToLowerInvariant();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entry: name = module:callable or name = module:callable [extras]
|
||||
if (currentSection is "console_scripts" or "gui_scripts")
|
||||
{
|
||||
var equalsIndex = trimmed.IndexOf('=');
|
||||
if (equalsIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = trimmed[..equalsIndex].Trim();
|
||||
var target = trimmed[(equalsIndex + 1)..].Trim();
|
||||
|
||||
// Remove extras if present
|
||||
var bracketIndex = target.IndexOf('[');
|
||||
if (bracketIndex > 0)
|
||||
{
|
||||
target = target[..bracketIndex].Trim();
|
||||
}
|
||||
|
||||
var kind = currentSection == "gui_scripts"
|
||||
? PythonEntrypointKind.GuiScript
|
||||
: PythonEntrypointKind.ConsoleScript;
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
Target: target,
|
||||
VirtualPath: null,
|
||||
InvocationContext: PythonInvocationContext.AsConsoleScript(name),
|
||||
Confidence: PythonEntrypointConfidence.Definitive,
|
||||
Source: source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers scripts in *.data/scripts/ directories.
|
||||
/// </summary>
|
||||
private Task DiscoverDataScriptsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var dataScripts = _vfs.EnumerateFiles(string.Empty, "*.data/scripts/*");
|
||||
|
||||
foreach (var file in dataScripts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var name = Path.GetFileName(file.VirtualPath);
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: name,
|
||||
Kind: PythonEntrypointKind.Script,
|
||||
Target: file.VirtualPath,
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: PythonInvocationContext.AsScript(file.VirtualPath),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
|
||||
// Also check bin/ directory
|
||||
var binScripts = _vfs.EnumerateFiles("bin", "*");
|
||||
foreach (var file in binScripts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var name = Path.GetFileName(file.VirtualPath);
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: name,
|
||||
Kind: PythonEntrypointKind.Script,
|
||||
Target: file.VirtualPath,
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: PythonInvocationContext.AsScript(file.VirtualPath),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers __main__.py in zipapps.
|
||||
/// </summary>
|
||||
private Task DiscoverZipappMainsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Zipapp files have their __main__.py at the root level
|
||||
var zipappFiles = _vfs.GetFilesBySource(PythonFileSource.Zipapp);
|
||||
|
||||
foreach (var file in zipappFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (file.VirtualPath == "__main__.py")
|
||||
{
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: "__main__",
|
||||
Kind: PythonEntrypointKind.ZipappMain,
|
||||
Target: "__main__",
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: new PythonInvocationContext(
|
||||
PythonInvocationType.Script,
|
||||
file.ArchivePath ?? "app.pyz"),
|
||||
Confidence: PythonEntrypointConfidence.Definitive,
|
||||
Source: file.ArchivePath ?? file.VirtualPath));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers Django manage.py and related entrypoints.
|
||||
/// </summary>
|
||||
private async Task DiscoverDjangoEntrypointsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Look for manage.py
|
||||
if (_vfs.FileExists("manage.py"))
|
||||
{
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: "manage.py",
|
||||
Kind: PythonEntrypointKind.DjangoManage,
|
||||
Target: "manage",
|
||||
VirtualPath: "manage.py",
|
||||
InvocationContext: PythonInvocationContext.AsScript("manage.py"),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: "manage.py"));
|
||||
}
|
||||
|
||||
// Look for Django settings to infer WSGI/ASGI apps
|
||||
var settingsFiles = _vfs.EnumerateFiles(string.Empty, "**/settings.py");
|
||||
foreach (var file in settingsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var parts = file.VirtualPath.Split('/');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var projectName = parts[^2]; // Folder containing settings.py
|
||||
|
||||
// Check for wsgi.py
|
||||
var wsgiPath = string.Join('/', parts[..^1]) + "/wsgi.py";
|
||||
if (_vfs.FileExists(wsgiPath))
|
||||
{
|
||||
var wsgiModule = $"{projectName}.wsgi:application";
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: $"{projectName}-wsgi",
|
||||
Kind: PythonEntrypointKind.WsgiApp,
|
||||
Target: wsgiModule,
|
||||
VirtualPath: wsgiPath,
|
||||
InvocationContext: PythonInvocationContext.AsWsgiApp("gunicorn", wsgiModule),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: wsgiPath));
|
||||
}
|
||||
|
||||
// Check for asgi.py
|
||||
var asgiPath = string.Join('/', parts[..^1]) + "/asgi.py";
|
||||
if (_vfs.FileExists(asgiPath))
|
||||
{
|
||||
var asgiModule = $"{projectName}.asgi:application";
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: $"{projectName}-asgi",
|
||||
Kind: PythonEntrypointKind.AsgiApp,
|
||||
Target: asgiModule,
|
||||
VirtualPath: asgiPath,
|
||||
InvocationContext: PythonInvocationContext.AsWsgiApp("uvicorn", asgiModule),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: asgiPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers WSGI/ASGI apps from configuration files.
|
||||
/// </summary>
|
||||
private async Task DiscoverWsgiAsgiAppsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check for gunicorn.conf.py or gunicorn configuration
|
||||
if (_vfs.FileExists("gunicorn.conf.py"))
|
||||
{
|
||||
var fullPath = Path.Combine(_rootPath, "gunicorn.conf.py");
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
var match = WsgiAppPattern().Match(content);
|
||||
if (match.Success)
|
||||
{
|
||||
var target = match.Groups["app"].Value;
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: "gunicorn-app",
|
||||
Kind: PythonEntrypointKind.WsgiApp,
|
||||
Target: target,
|
||||
VirtualPath: "gunicorn.conf.py",
|
||||
InvocationContext: PythonInvocationContext.AsWsgiApp("gunicorn", target),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: "gunicorn.conf.py"));
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Procfile (common in Heroku deployments)
|
||||
if (_vfs.FileExists("Procfile"))
|
||||
{
|
||||
var fullPath = Path.Combine(_rootPath, "Procfile");
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var procType = trimmed[..colonIndex].Trim();
|
||||
var command = trimmed[(colonIndex + 1)..].Trim();
|
||||
|
||||
// Look for gunicorn/uvicorn commands
|
||||
var gunicornMatch = GunicornCommandPattern().Match(command);
|
||||
if (gunicornMatch.Success)
|
||||
{
|
||||
var target = gunicornMatch.Groups["app"].Value;
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: $"procfile-{procType}",
|
||||
Kind: PythonEntrypointKind.WsgiApp,
|
||||
Target: target,
|
||||
VirtualPath: "Procfile",
|
||||
InvocationContext: PythonInvocationContext.AsWsgiApp("gunicorn", target),
|
||||
Confidence: PythonEntrypointConfidence.Medium,
|
||||
Source: "Procfile"));
|
||||
}
|
||||
|
||||
var uvicornMatch = UvicornCommandPattern().Match(command);
|
||||
if (uvicornMatch.Success)
|
||||
{
|
||||
var target = uvicornMatch.Groups["app"].Value;
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: $"procfile-{procType}",
|
||||
Kind: PythonEntrypointKind.AsgiApp,
|
||||
Target: target,
|
||||
VirtualPath: "Procfile",
|
||||
InvocationContext: PythonInvocationContext.AsWsgiApp("uvicorn", target),
|
||||
Confidence: PythonEntrypointConfidence.Medium,
|
||||
Source: "Procfile"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers Celery worker entrypoints.
|
||||
/// </summary>
|
||||
private async Task DiscoverCeleryEntrypointsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Look for celery.py or tasks.py patterns
|
||||
var celeryFiles = _vfs.EnumerateFiles(string.Empty, "**/celery.py");
|
||||
foreach (var file in celeryFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var parts = file.VirtualPath.Split('/');
|
||||
var modulePath = string.Join('.', parts)[..^3]; // Remove .py
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: "celery-worker",
|
||||
Kind: PythonEntrypointKind.CeleryWorker,
|
||||
Target: modulePath,
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: new PythonInvocationContext(
|
||||
PythonInvocationType.Module,
|
||||
$"celery -A {modulePath} worker"),
|
||||
Confidence: PythonEntrypointConfidence.Medium,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers AWS Lambda, Azure Functions, and Cloud Functions handlers.
|
||||
/// </summary>
|
||||
private async Task DiscoverLambdaHandlersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// AWS Lambda - lambda_function.py with handler function
|
||||
if (_vfs.FileExists("lambda_function.py"))
|
||||
{
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: "lambda-handler",
|
||||
Kind: PythonEntrypointKind.LambdaHandler,
|
||||
Target: "lambda_function.handler",
|
||||
VirtualPath: "lambda_function.py",
|
||||
InvocationContext: PythonInvocationContext.AsHandler("lambda_function.handler"),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: "lambda_function.py"));
|
||||
}
|
||||
|
||||
// Also check for handler.py (common Lambda pattern)
|
||||
if (_vfs.FileExists("handler.py"))
|
||||
{
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: "lambda-handler",
|
||||
Kind: PythonEntrypointKind.LambdaHandler,
|
||||
Target: "handler.handler",
|
||||
VirtualPath: "handler.py",
|
||||
InvocationContext: PythonInvocationContext.AsHandler("handler.handler"),
|
||||
Confidence: PythonEntrypointConfidence.Medium,
|
||||
Source: "handler.py"));
|
||||
}
|
||||
|
||||
// Azure Functions - look for function.json
|
||||
var functionJsonFiles = _vfs.EnumerateFiles(string.Empty, "**/function.json");
|
||||
foreach (var file in functionJsonFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var parts = file.VirtualPath.Split('/');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var functionName = parts[^2];
|
||||
|
||||
// Check for __init__.py in the same directory
|
||||
var initPath = string.Join('/', parts[..^1]) + "/__init__.py";
|
||||
if (_vfs.FileExists(initPath))
|
||||
{
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: functionName,
|
||||
Kind: PythonEntrypointKind.AzureFunctionHandler,
|
||||
Target: $"{functionName}:main",
|
||||
VirtualPath: initPath,
|
||||
InvocationContext: PythonInvocationContext.AsHandler($"{functionName}:main"),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
}
|
||||
|
||||
// Google Cloud Functions - main.py with specific patterns
|
||||
if (_vfs.FileExists("main.py"))
|
||||
{
|
||||
var fullPath = Path.Combine(_rootPath, "main.py");
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Look for Cloud Functions patterns
|
||||
if (content.Contains("functions_framework") ||
|
||||
content.Contains("@functions_framework"))
|
||||
{
|
||||
var match = CloudFunctionPattern().Match(content);
|
||||
var functionName = match.Success ? match.Groups["name"].Value : "main";
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: functionName,
|
||||
Kind: PythonEntrypointKind.CloudFunctionHandler,
|
||||
Target: $"main:{functionName}",
|
||||
VirtualPath: "main.py",
|
||||
InvocationContext: PythonInvocationContext.AsHandler($"main:{functionName}"),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: "main.py"));
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers Click/Typer CLI applications.
|
||||
/// </summary>
|
||||
private async Task DiscoverCliAppsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Look for files with Click or Typer patterns
|
||||
var pyFiles = _vfs.Files.Where(f => f.IsPythonSource && f.Source != PythonFileSource.Zipapp);
|
||||
|
||||
foreach (var file in pyFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (file.IsFromArchive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_rootPath, file.AbsolutePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
fullPath = file.AbsolutePath;
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check for Click CLI patterns
|
||||
if (content.Contains("@click.command") || content.Contains("@click.group"))
|
||||
{
|
||||
var match = ClickGroupPattern().Match(content);
|
||||
var cliName = match.Success ? match.Groups["name"].Value : Path.GetFileNameWithoutExtension(file.VirtualPath);
|
||||
|
||||
// Check if there's a main guard
|
||||
if (content.Contains("if __name__") && content.Contains("__main__"))
|
||||
{
|
||||
var modulePath = file.VirtualPath.Replace('/', '.')[..^3];
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: cliName,
|
||||
Kind: PythonEntrypointKind.CliApp,
|
||||
Target: $"{modulePath}:cli",
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: PythonInvocationContext.AsModule(modulePath),
|
||||
Confidence: PythonEntrypointConfidence.Medium,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Typer CLI patterns
|
||||
if (content.Contains("typer.Typer") || content.Contains("@app.command"))
|
||||
{
|
||||
var modulePath = file.VirtualPath.Replace('/', '.')[..^3];
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: Path.GetFileNameWithoutExtension(file.VirtualPath),
|
||||
Kind: PythonEntrypointKind.CliApp,
|
||||
Target: $"{modulePath}:app",
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: PythonInvocationContext.AsModule(modulePath),
|
||||
Confidence: PythonEntrypointConfidence.Medium,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers standalone Python scripts with main guards.
|
||||
/// </summary>
|
||||
private async Task DiscoverStandaloneScriptsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Look for Python files at the root level with main guards
|
||||
var rootPyFiles = _vfs.EnumerateFiles(string.Empty, "*.py")
|
||||
.Where(f => !f.VirtualPath.Contains('/') && f.VirtualPath != "__main__.py");
|
||||
|
||||
foreach (var file in rootPyFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (file.IsFromArchive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_rootPath, file.AbsolutePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
fullPath = file.AbsolutePath;
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check for main guard or shebang
|
||||
var hasMainGuard = content.Contains("if __name__") && content.Contains("__main__");
|
||||
var hasShebang = content.StartsWith("#!");
|
||||
|
||||
if (hasMainGuard || hasShebang)
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(file.VirtualPath);
|
||||
|
||||
_entrypoints.Add(new PythonEntrypoint(
|
||||
Name: name,
|
||||
Kind: PythonEntrypointKind.StandaloneScript,
|
||||
Target: file.VirtualPath,
|
||||
VirtualPath: file.VirtualPath,
|
||||
InvocationContext: PythonInvocationContext.AsScript(file.VirtualPath),
|
||||
Confidence: hasMainGuard ? PythonEntrypointConfidence.High : PythonEntrypointConfidence.Medium,
|
||||
Source: file.VirtualPath));
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"bind\s*=\s*['""](?<app>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex WsgiAppPattern();
|
||||
|
||||
[GeneratedRegex(@"gunicorn\s+(?:.*\s+)?(?<app>[\w.]+:[\w]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex GunicornCommandPattern();
|
||||
|
||||
[GeneratedRegex(@"uvicorn\s+(?<app>[\w.]+:[\w]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex UvicornCommandPattern();
|
||||
|
||||
[GeneratedRegex(@"@functions_framework\.http\s*\n\s*def\s+(?<name>\w+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CloudFunctionPattern();
|
||||
|
||||
[GeneratedRegex(@"@click\.group\(\)\s*\n\s*def\s+(?<name>\w+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ClickGroupPattern();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Entrypoints;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the type of Python entrypoint.
|
||||
/// </summary>
|
||||
internal enum PythonEntrypointKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Package __main__.py module (invoked via python -m package).
|
||||
/// </summary>
|
||||
PackageMain,
|
||||
|
||||
/// <summary>
|
||||
/// Console script from entry_points.txt [console_scripts].
|
||||
/// </summary>
|
||||
ConsoleScript,
|
||||
|
||||
/// <summary>
|
||||
/// GUI script from entry_points.txt [gui_scripts].
|
||||
/// </summary>
|
||||
GuiScript,
|
||||
|
||||
/// <summary>
|
||||
/// Script file in *.data/scripts/ or bin/ directory.
|
||||
/// </summary>
|
||||
Script,
|
||||
|
||||
/// <summary>
|
||||
/// Zipapp __main__.py module.
|
||||
/// </summary>
|
||||
ZipappMain,
|
||||
|
||||
/// <summary>
|
||||
/// Django manage.py management script.
|
||||
/// </summary>
|
||||
DjangoManage,
|
||||
|
||||
/// <summary>
|
||||
/// Gunicorn/uWSGI WSGI application reference.
|
||||
/// </summary>
|
||||
WsgiApp,
|
||||
|
||||
/// <summary>
|
||||
/// ASGI application reference (Uvicorn, Daphne).
|
||||
/// </summary>
|
||||
AsgiApp,
|
||||
|
||||
/// <summary>
|
||||
/// Celery worker/beat entrypoint.
|
||||
/// </summary>
|
||||
CeleryWorker,
|
||||
|
||||
/// <summary>
|
||||
/// AWS Lambda handler function.
|
||||
/// </summary>
|
||||
LambdaHandler,
|
||||
|
||||
/// <summary>
|
||||
/// Azure Functions handler.
|
||||
/// </summary>
|
||||
AzureFunctionHandler,
|
||||
|
||||
/// <summary>
|
||||
/// Google Cloud Function handler.
|
||||
/// </summary>
|
||||
CloudFunctionHandler,
|
||||
|
||||
/// <summary>
|
||||
/// Click/Typer CLI application.
|
||||
/// </summary>
|
||||
CliApp,
|
||||
|
||||
/// <summary>
|
||||
/// pytest/unittest test runner.
|
||||
/// </summary>
|
||||
TestRunner,
|
||||
|
||||
/// <summary>
|
||||
/// Generic Python script (standalone .py file with shebang or main guard).
|
||||
/// </summary>
|
||||
StandaloneScript
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts imports from Python bytecode (.pyc) files.
|
||||
/// Supports Python 3.8 - 3.12+ bytecode formats.
|
||||
/// </summary>
|
||||
internal sealed class PythonBytecodeImportExtractor
|
||||
{
|
||||
// Python bytecode opcodes (consistent across 3.8-3.12)
|
||||
private const byte IMPORT_NAME = 108;
|
||||
private const byte IMPORT_FROM = 109;
|
||||
private const byte IMPORT_STAR = 84;
|
||||
|
||||
// Python 3.11+ uses different opcodes
|
||||
private const byte IMPORT_NAME_311 = 108;
|
||||
|
||||
// Magic numbers for Python versions (first 2 bytes of .pyc)
|
||||
private static readonly IReadOnlyDictionary<int, string> PythonMagicNumbers = new Dictionary<int, string>
|
||||
{
|
||||
// Python 3.8
|
||||
[3413] = "3.8",
|
||||
[3415] = "3.8",
|
||||
// Python 3.9
|
||||
[3425] = "3.9",
|
||||
// Python 3.10
|
||||
[3435] = "3.10",
|
||||
[3437] = "3.10",
|
||||
[3439] = "3.10",
|
||||
// Python 3.11
|
||||
[3495] = "3.11",
|
||||
// Python 3.12
|
||||
[3531] = "3.12",
|
||||
// Python 3.13
|
||||
[3571] = "3.13",
|
||||
};
|
||||
|
||||
private readonly string _sourceFile;
|
||||
private readonly List<PythonImport> _imports = new();
|
||||
|
||||
public PythonBytecodeImportExtractor(string sourceFile)
|
||||
{
|
||||
_sourceFile = sourceFile ?? throw new ArgumentNullException(nameof(sourceFile));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all extracted imports.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> Imports => _imports;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the detected Python version, if available.
|
||||
/// </summary>
|
||||
public string? PythonVersion { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Extracts imports from Python bytecode.
|
||||
/// </summary>
|
||||
public PythonBytecodeImportExtractor Extract(ReadOnlySpan<byte> bytecode)
|
||||
{
|
||||
if (bytecode.Length < 16)
|
||||
{
|
||||
return this; // Too short to be valid bytecode
|
||||
}
|
||||
|
||||
// Read magic number (first 4 bytes, little-endian)
|
||||
var magic = BinaryPrimitives.ReadUInt16LittleEndian(bytecode);
|
||||
if (PythonMagicNumbers.TryGetValue(magic, out var version))
|
||||
{
|
||||
PythonVersion = version;
|
||||
}
|
||||
|
||||
// Skip header to find code object
|
||||
// Python 3.8+: 16 bytes header
|
||||
var headerSize = 16;
|
||||
if (bytecode.Length <= headerSize)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
// Parse the marshalled code object
|
||||
var codeSection = bytecode[headerSize..];
|
||||
ExtractFromMarshalledCode(codeSection);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts imports from a file stream.
|
||||
/// </summary>
|
||||
public async Task<PythonBytecodeImportExtractor> ExtractAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (stream is null || !stream.CanRead)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
// Read the entire bytecode file (typically small)
|
||||
var buffer = new byte[stream.Length];
|
||||
var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bytesRead > 0)
|
||||
{
|
||||
Extract(buffer.AsSpan(0, bytesRead));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private void ExtractFromMarshalledCode(ReadOnlySpan<byte> data)
|
||||
{
|
||||
// Look for string table entries that look like module names
|
||||
// Python marshal format: TYPE_CODE (0x63) followed by code object structure
|
||||
|
||||
if (data.Length < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find code object marker
|
||||
var codeStart = data.IndexOf((byte)0x63); // TYPE_CODE
|
||||
if (codeStart < 0)
|
||||
{
|
||||
// Try alternate scan - look for string sequences that could be imports
|
||||
ScanForModuleStrings(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// The code object contains names tuple which includes import targets
|
||||
// We'll scan for patterns that indicate IMPORT_NAME instructions
|
||||
|
||||
var names = ExtractNameTable(data);
|
||||
var codeBytes = ExtractCodeBytes(data);
|
||||
|
||||
if (codeBytes.Length == 0 || names.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan bytecode for import instructions
|
||||
ScanBytecodeForImports(codeBytes, names);
|
||||
}
|
||||
|
||||
private List<string> ExtractNameTable(ReadOnlySpan<byte> data)
|
||||
{
|
||||
var names = new List<string>();
|
||||
|
||||
// Look for small tuple marker (TYPE_SMALL_TUPLE = 0x29) or tuple marker (TYPE_TUPLE = 0x28)
|
||||
// followed by string entries
|
||||
|
||||
var pos = 0;
|
||||
while (pos < data.Length - 4)
|
||||
{
|
||||
var marker = data[pos];
|
||||
|
||||
// TYPE_SHORT_ASCII (0x7A) or TYPE_ASCII (0x61) or TYPE_UNICODE (0x75)
|
||||
if (marker is 0x7A or 0x61 or 0x75 or 0x73 or 0x5A)
|
||||
{
|
||||
var length = marker == 0x7A || marker == 0x5A
|
||||
? data[pos + 1]
|
||||
: (data.Length > pos + 4 ? BinaryPrimitives.ReadInt32LittleEndian(data[(pos + 1)..]) : 0);
|
||||
|
||||
if (length > 0 && length < 256 && pos + 2 + length <= data.Length)
|
||||
{
|
||||
var stringStart = marker is 0x7A or 0x5A ? pos + 2 : pos + 5;
|
||||
if (stringStart + length <= data.Length)
|
||||
{
|
||||
try
|
||||
{
|
||||
var str = Encoding.UTF8.GetString(data.Slice(stringStart, length));
|
||||
if (IsValidModuleName(str))
|
||||
{
|
||||
names.Add(str);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid UTF-8, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> ExtractCodeBytes(ReadOnlySpan<byte> data)
|
||||
{
|
||||
// Look for TYPE_STRING (0x73) or TYPE_CODE_OBJECT markers that precede bytecode
|
||||
// The bytecode is stored as a bytes object
|
||||
|
||||
for (var i = 0; i < data.Length - 5; i++)
|
||||
{
|
||||
// Look for string/bytes marker followed by reasonable length
|
||||
if (data[i] == 0x73 || data[i] == 0x43) // TYPE_STRING or TYPE_CODE_STRING
|
||||
{
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(data[(i + 1)..]);
|
||||
if (length > 0 && length < 100000 && i + 5 + length <= data.Length)
|
||||
{
|
||||
return data.Slice(i + 5, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ReadOnlySpan<byte>.Empty;
|
||||
}
|
||||
|
||||
private void ScanBytecodeForImports(ReadOnlySpan<byte> code, List<string> names)
|
||||
{
|
||||
// Python 3.8-3.10: 2-byte instructions (opcode + arg)
|
||||
// Python 3.11+: variable-length instructions with EXTENDED_ARG
|
||||
|
||||
var i = 0;
|
||||
while (i < code.Length - 1)
|
||||
{
|
||||
var opcode = code[i];
|
||||
var arg = code[i + 1];
|
||||
|
||||
if (opcode == IMPORT_NAME || opcode == IMPORT_NAME_311)
|
||||
{
|
||||
// arg is index into names tuple
|
||||
if (arg < names.Count)
|
||||
{
|
||||
var moduleName = names[arg];
|
||||
AddImport(moduleName, PythonImportKind.Import);
|
||||
}
|
||||
}
|
||||
else if (opcode == IMPORT_FROM)
|
||||
{
|
||||
if (arg < names.Count)
|
||||
{
|
||||
var name = names[arg];
|
||||
// IMPORT_FROM follows IMPORT_NAME, so we just note the imported name
|
||||
// The module is already added by IMPORT_NAME
|
||||
}
|
||||
}
|
||||
else if (opcode == IMPORT_STAR)
|
||||
{
|
||||
// Star import - the module is from previous IMPORT_NAME
|
||||
}
|
||||
|
||||
i += 2; // Basic instruction size
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanForModuleStrings(ReadOnlySpan<byte> data)
|
||||
{
|
||||
// Fallback: scan for string patterns that look like module names
|
||||
// This is less accurate but works when we can't parse the marshal format
|
||||
|
||||
var currentString = new StringBuilder();
|
||||
var stringStart = false;
|
||||
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
var b = data[i];
|
||||
|
||||
if (b >= 0x20 && b <= 0x7E) // Printable ASCII
|
||||
{
|
||||
var c = (char)b;
|
||||
if (char.IsLetterOrDigit(c) || c == '_' || c == '.')
|
||||
{
|
||||
currentString.Append(c);
|
||||
stringStart = true;
|
||||
}
|
||||
else if (stringStart)
|
||||
{
|
||||
CheckAndAddModuleName(currentString.ToString());
|
||||
currentString.Clear();
|
||||
stringStart = false;
|
||||
}
|
||||
}
|
||||
else if (stringStart)
|
||||
{
|
||||
CheckAndAddModuleName(currentString.ToString());
|
||||
currentString.Clear();
|
||||
stringStart = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentString.Length > 0)
|
||||
{
|
||||
CheckAndAddModuleName(currentString.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckAndAddModuleName(string str)
|
||||
{
|
||||
if (string.IsNullOrEmpty(str) || str.Length < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter for likely module names
|
||||
if (!IsValidModuleName(str))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip Python internals and unlikely imports
|
||||
if (str.StartsWith("__") && str.EndsWith("__"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check against known standard library modules for higher confidence
|
||||
var isStdLib = IsStandardLibraryModule(str);
|
||||
|
||||
AddImport(str, PythonImportKind.Import, isStdLib ? PythonImportConfidence.Medium : PythonImportConfidence.Low);
|
||||
}
|
||||
|
||||
private void AddImport(string module, PythonImportKind kind, PythonImportConfidence confidence = PythonImportConfidence.Medium)
|
||||
{
|
||||
// Avoid duplicates
|
||||
if (_imports.Any(i => i.Module == module && i.Kind == kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relativeLevel = 0;
|
||||
var moduleName = module;
|
||||
|
||||
// Handle relative imports (leading dots)
|
||||
while (moduleName.StartsWith('.'))
|
||||
{
|
||||
relativeLevel++;
|
||||
moduleName = moduleName[1..];
|
||||
}
|
||||
|
||||
if (relativeLevel > 0)
|
||||
{
|
||||
kind = PythonImportKind.RelativeImport;
|
||||
}
|
||||
|
||||
_imports.Add(new PythonImport(
|
||||
Module: moduleName,
|
||||
Names: null,
|
||||
Alias: null,
|
||||
Kind: kind,
|
||||
RelativeLevel: relativeLevel,
|
||||
SourceFile: _sourceFile,
|
||||
LineNumber: null, // Line numbers not available in bytecode
|
||||
Confidence: confidence));
|
||||
}
|
||||
|
||||
private static bool IsValidModuleName(string str)
|
||||
{
|
||||
if (string.IsNullOrEmpty(str))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not be all digits
|
||||
if (str.All(char.IsDigit))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with letter or underscore
|
||||
if (!char.IsLetter(str[0]) && str[0] != '_')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must contain only valid identifier characters and dots
|
||||
foreach (var c in str)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(c) && c != '_' && c != '.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject very long names
|
||||
if (str.Length > 100)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsStandardLibraryModule(string module)
|
||||
{
|
||||
var topLevel = module.Split('.')[0];
|
||||
|
||||
// Common standard library modules (non-exhaustive)
|
||||
return topLevel switch
|
||||
{
|
||||
"os" or "sys" or "re" or "io" or "json" or "time" or "datetime" => true,
|
||||
"collections" or "itertools" or "functools" or "operator" => true,
|
||||
"pathlib" or "shutil" or "glob" or "fnmatch" or "linecache" => true,
|
||||
"pickle" or "shelve" or "dbm" or "sqlite3" => true,
|
||||
"zlib" or "gzip" or "bz2" or "lzma" or "zipfile" or "tarfile" => true,
|
||||
"csv" or "configparser" or "tomllib" => true,
|
||||
"hashlib" or "hmac" or "secrets" => true,
|
||||
"logging" or "warnings" or "traceback" => true,
|
||||
"threading" or "multiprocessing" or "concurrent" or "subprocess" or "sched" => true,
|
||||
"asyncio" or "socket" or "ssl" or "select" or "selectors" => true,
|
||||
"email" or "html" or "xml" or "urllib" or "http" => true,
|
||||
"unittest" or "doctest" or "pytest" => true,
|
||||
"typing" or "types" or "abc" or "contextlib" or "dataclasses" => true,
|
||||
"importlib" or "pkgutil" or "zipimport" => true,
|
||||
"copy" or "pprint" or "enum" or "graphlib" => true,
|
||||
"math" or "cmath" or "decimal" or "fractions" or "random" or "statistics" => true,
|
||||
"struct" or "codecs" or "unicodedata" or "stringprep" => true,
|
||||
"ctypes" or "ffi" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for import resolution.
|
||||
/// </summary>
|
||||
internal enum PythonImportConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Low confidence - dynamic or uncertain import target.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence - inferred from context.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence - clear static import.
|
||||
/// </summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Definitive - direct static import statement.
|
||||
/// </summary>
|
||||
Definitive = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Python import statement.
|
||||
/// </summary>
|
||||
/// <param name="Module">The module being imported (e.g., 'os.path').</param>
|
||||
/// <param name="Names">Names imported from the module (null for 'import x', names for 'from x import a, b').</param>
|
||||
/// <param name="Alias">Alias if present (e.g., 'import numpy as np').</param>
|
||||
/// <param name="Kind">The kind of import statement.</param>
|
||||
/// <param name="RelativeLevel">Number of dots for relative imports (0 = absolute).</param>
|
||||
/// <param name="SourceFile">The file containing this import.</param>
|
||||
/// <param name="LineNumber">Line number in source file (1-based, null if from bytecode).</param>
|
||||
/// <param name="Confidence">Confidence level for this import.</param>
|
||||
/// <param name="IsConditional">Whether this import is inside a try/except or if block.</param>
|
||||
/// <param name="IsLazy">Whether this import is inside a function (not at module level).</param>
|
||||
/// <param name="IsTypeCheckingOnly">Whether this import is inside TYPE_CHECKING block.</param>
|
||||
internal sealed record PythonImport(
|
||||
string Module,
|
||||
IReadOnlyList<PythonImportedName>? Names,
|
||||
string? Alias,
|
||||
PythonImportKind Kind,
|
||||
int RelativeLevel,
|
||||
string SourceFile,
|
||||
int? LineNumber,
|
||||
PythonImportConfidence Confidence,
|
||||
bool IsConditional = false,
|
||||
bool IsLazy = false,
|
||||
bool IsTypeCheckingOnly = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this is a relative import.
|
||||
/// </summary>
|
||||
public bool IsRelative => RelativeLevel > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a star import.
|
||||
/// </summary>
|
||||
public bool IsStar => Names?.Count == 1 && Names[0].Name == "*";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a future import.
|
||||
/// </summary>
|
||||
public bool IsFuture => Module == "__future__";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fully qualified module name for absolute imports.
|
||||
/// For relative imports, this returns the relative notation (e.g., '.foo').
|
||||
/// </summary>
|
||||
public string QualifiedModule
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsRelative)
|
||||
{
|
||||
return Module;
|
||||
}
|
||||
|
||||
var prefix = new string('.', RelativeLevel);
|
||||
return string.IsNullOrEmpty(Module) ? prefix : $"{prefix}{Module}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all imported names as strings (for 'from x import a, b' returns ['a', 'b']).
|
||||
/// </summary>
|
||||
public IEnumerable<string> ImportedNames =>
|
||||
Names?.Select(static n => n.Name) ?? Enumerable.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata entries for this import.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> ToMetadata(int index)
|
||||
{
|
||||
var prefix = $"import[{index}]";
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.module", QualifiedModule);
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.kind", Kind.ToString());
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.confidence", Confidence.ToString());
|
||||
|
||||
if (Alias is not null)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.alias", Alias);
|
||||
}
|
||||
|
||||
if (Names is { Count: > 0 } && !IsStar)
|
||||
{
|
||||
var names = string.Join(',', ImportedNames);
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.names", names);
|
||||
}
|
||||
|
||||
if (LineNumber.HasValue)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.line", LineNumber.Value.ToString());
|
||||
}
|
||||
|
||||
if (IsConditional)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.conditional", "true");
|
||||
}
|
||||
|
||||
if (IsLazy)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.lazy", "true");
|
||||
}
|
||||
|
||||
if (IsTypeCheckingOnly)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.typeCheckingOnly", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a name imported from a module.
|
||||
/// </summary>
|
||||
/// <param name="Name">The imported name.</param>
|
||||
/// <param name="Alias">Optional alias for the name.</param>
|
||||
internal sealed record PythonImportedName(string Name, string? Alias = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the effective name (alias if present, otherwise name).
|
||||
/// </summary>
|
||||
public string EffectiveName => Alias ?? Name;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// Result of Python import analysis.
|
||||
/// </summary>
|
||||
internal sealed class PythonImportAnalysis
|
||||
{
|
||||
private PythonImportAnalysis(
|
||||
PythonImportGraph graph,
|
||||
IReadOnlyList<PythonImport> allImports,
|
||||
IReadOnlyList<PythonImport> standardLibraryImports,
|
||||
IReadOnlyList<PythonImport> thirdPartyImports,
|
||||
IReadOnlyList<PythonImport> localImports,
|
||||
IReadOnlyList<PythonImport> relativeImports,
|
||||
IReadOnlyList<PythonImport> dynamicImports,
|
||||
IReadOnlyList<IReadOnlyList<string>> cycles)
|
||||
{
|
||||
Graph = graph;
|
||||
AllImports = allImports;
|
||||
StandardLibraryImports = standardLibraryImports;
|
||||
ThirdPartyImports = thirdPartyImports;
|
||||
LocalImports = localImports;
|
||||
RelativeImports = relativeImports;
|
||||
DynamicImports = dynamicImports;
|
||||
Cycles = cycles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the import graph.
|
||||
/// </summary>
|
||||
public PythonImportGraph Graph { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all discovered imports.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> AllImports { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets imports from the Python standard library.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> StandardLibraryImports { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets imports from third-party packages.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> ThirdPartyImports { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets imports from local modules (resolved in the project).
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> LocalImports { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets relative imports.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> RelativeImports { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets dynamic imports (importlib.import_module, __import__, etc.).
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> DynamicImports { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets detected import cycles.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<string>> Cycles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are any import cycles.
|
||||
/// </summary>
|
||||
public bool HasCycles => Cycles.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of modules in the graph.
|
||||
/// </summary>
|
||||
public int TotalModules => Graph.Modules.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of unresolved modules.
|
||||
/// </summary>
|
||||
public int UnresolvedModuleCount => Graph.UnresolvedModules.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes imports from a virtual filesystem.
|
||||
/// </summary>
|
||||
public static async Task<PythonImportAnalysis> AnalyzeAsync(
|
||||
PythonVirtualFileSystem vfs,
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var graph = new PythonImportGraph(vfs, rootPath);
|
||||
await graph.BuildAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var allImports = graph.ImportsByFile.Values
|
||||
.SelectMany(static list => list)
|
||||
.OrderBy(static i => i.SourceFile, StringComparer.Ordinal)
|
||||
.ThenBy(static i => i.LineNumber ?? int.MaxValue)
|
||||
.ToList();
|
||||
|
||||
var standardLibrary = allImports
|
||||
.Where(static i => IsStandardLibrary(i.Module))
|
||||
.ToList();
|
||||
|
||||
var thirdParty = new List<PythonImport>();
|
||||
var local = new List<PythonImport>();
|
||||
|
||||
foreach (var import in allImports)
|
||||
{
|
||||
if (standardLibrary.Contains(import))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (import.IsRelative)
|
||||
{
|
||||
continue; // Handled separately
|
||||
}
|
||||
|
||||
// Check if the module is in the graph (local) or not (third-party)
|
||||
if (graph.Modules.ContainsKey(import.Module) ||
|
||||
graph.Modules.ContainsKey(import.Module.Split('.')[0]))
|
||||
{
|
||||
local.Add(import);
|
||||
}
|
||||
else
|
||||
{
|
||||
thirdParty.Add(import);
|
||||
}
|
||||
}
|
||||
|
||||
var relative = allImports
|
||||
.Where(static i => i.IsRelative)
|
||||
.ToList();
|
||||
|
||||
var dynamic = allImports
|
||||
.Where(static i => i.Kind is PythonImportKind.ImportlibImportModule or
|
||||
PythonImportKind.BuiltinImport or
|
||||
PythonImportKind.PkgutilExtendPath)
|
||||
.ToList();
|
||||
|
||||
var cycles = graph.FindCycles();
|
||||
|
||||
return new PythonImportAnalysis(
|
||||
graph,
|
||||
allImports,
|
||||
standardLibrary,
|
||||
thirdParty,
|
||||
local,
|
||||
relative,
|
||||
dynamic,
|
||||
cycles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates metadata entries for the analysis result.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> ToMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("imports.total", AllImports.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.stdlib", StandardLibraryImports.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.thirdParty", ThirdPartyImports.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.local", LocalImports.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.relative", RelativeImports.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.dynamic", DynamicImports.Count.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.modules", TotalModules.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.unresolved", UnresolvedModuleCount.ToString());
|
||||
yield return new KeyValuePair<string, string?>("imports.hasCycles", HasCycles.ToString().ToLowerInvariant());
|
||||
|
||||
if (HasCycles)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("imports.cycleCount", Cycles.Count.ToString());
|
||||
}
|
||||
|
||||
// List unresolved third-party modules
|
||||
var thirdPartyModules = ThirdPartyImports
|
||||
.Select(static i => i.Module.Split('.')[0])
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static m => m, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (thirdPartyModules.Count > 0)
|
||||
{
|
||||
var modules = string.Join(';', thirdPartyModules);
|
||||
yield return new KeyValuePair<string, string?>("imports.thirdParty.modules", modules);
|
||||
}
|
||||
|
||||
// List dynamic import targets
|
||||
if (DynamicImports.Count > 0)
|
||||
{
|
||||
var dynamicModules = DynamicImports
|
||||
.Select(static i => i.Module)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static m => m, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var modules = string.Join(';', dynamicModules);
|
||||
yield return new KeyValuePair<string, string?>("imports.dynamic.modules", modules);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets imports by kind.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonImport> GetByKind(PythonImportKind kind) =>
|
||||
AllImports.Where(i => i.Kind == kind);
|
||||
|
||||
/// <summary>
|
||||
/// Gets imports by confidence level.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonImport> GetByConfidence(PythonImportConfidence minConfidence) =>
|
||||
AllImports.Where(i => i.Confidence >= minConfidence);
|
||||
|
||||
/// <summary>
|
||||
/// Gets imports for a specific module.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonImport> GetImportsFor(string modulePath) =>
|
||||
AllImports.Where(i => i.Module == modulePath || i.Module.StartsWith($"{modulePath}.", StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transitive dependencies of a module.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> GetTransitiveDependencies(string modulePath)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(modulePath);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var dep in Graph.GetDependencies(current))
|
||||
{
|
||||
if (result.Add(dep.ModulePath))
|
||||
{
|
||||
queue.Enqueue(dep.ModulePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the transitive dependents of a module.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> GetTransitiveDependents(string modulePath)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(modulePath);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var dep in Graph.GetDependents(current))
|
||||
{
|
||||
if (result.Add(dep.ModulePath))
|
||||
{
|
||||
queue.Enqueue(dep.ModulePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsStandardLibrary(string module)
|
||||
{
|
||||
var topLevel = module.Split('.')[0];
|
||||
|
||||
// Python standard library modules
|
||||
return topLevel switch
|
||||
{
|
||||
// Built-in types and functions
|
||||
"builtins" or "__future__" or "abc" or "types" or "typing" => true,
|
||||
"typing_extensions" => false, // This is third-party
|
||||
|
||||
// String and text processing
|
||||
"string" or "re" or "difflib" or "textwrap" or "unicodedata" or "stringprep" => true,
|
||||
"readline" or "rlcompleter" => true,
|
||||
|
||||
// Binary data
|
||||
"struct" or "codecs" => true,
|
||||
|
||||
// Data types
|
||||
"datetime" or "zoneinfo" or "calendar" or "collections" or "heapq" => true,
|
||||
"bisect" or "array" or "weakref" or "types" or "copy" or "pprint" => true,
|
||||
"reprlib" or "enum" or "graphlib" => true,
|
||||
|
||||
// Numeric and math
|
||||
"numbers" or "math" or "cmath" or "decimal" or "fractions" => true,
|
||||
"random" or "statistics" => true,
|
||||
|
||||
// Functional programming
|
||||
"itertools" or "functools" or "operator" => true,
|
||||
|
||||
// File and directory
|
||||
"pathlib" or "fileinput" or "stat" or "filecmp" or "tempfile" => true,
|
||||
"glob" or "fnmatch" or "linecache" or "shutil" => true,
|
||||
|
||||
// Data persistence
|
||||
"pickle" or "copyreg" or "shelve" or "marshal" or "dbm" or "sqlite3" => true,
|
||||
|
||||
// Compression
|
||||
"zlib" or "gzip" or "bz2" or "lzma" or "zipfile" or "tarfile" => true,
|
||||
|
||||
// File formats
|
||||
"csv" or "configparser" or "tomllib" or "netrc" or "plistlib" => true,
|
||||
|
||||
// Cryptographic
|
||||
"hashlib" or "hmac" or "secrets" => true,
|
||||
|
||||
// OS services
|
||||
"os" or "io" or "time" or "argparse" or "getopt" or "logging" => true,
|
||||
"getpass" or "curses" or "platform" or "errno" or "ctypes" => true,
|
||||
|
||||
// Concurrent execution
|
||||
"threading" or "multiprocessing" or "concurrent" or "subprocess" => true,
|
||||
"sched" or "queue" or "_thread" => true,
|
||||
|
||||
// Networking
|
||||
"asyncio" or "socket" or "ssl" or "select" or "selectors" => true,
|
||||
"signal" or "mmap" => true,
|
||||
|
||||
// Internet protocols
|
||||
"email" or "json" or "mailbox" or "mimetypes" or "base64" => true,
|
||||
"binascii" or "quopri" or "html" or "xml" => true,
|
||||
|
||||
// Internet protocols - urllib, http
|
||||
"urllib" or "http" or "ftplib" or "poplib" or "imaplib" => true,
|
||||
"smtplib" or "uuid" or "socketserver" or "xmlrpc" or "ipaddress" => true,
|
||||
|
||||
// Multimedia
|
||||
"wave" or "colorsys" => true,
|
||||
|
||||
// Internationalization
|
||||
"gettext" or "locale" => true,
|
||||
|
||||
// Program frameworks
|
||||
"turtle" or "cmd" or "shlex" => true,
|
||||
|
||||
// Development tools
|
||||
"typing" or "pydoc" or "doctest" or "unittest" => true,
|
||||
"test" or "test.support" => true,
|
||||
|
||||
// Debugging
|
||||
"bdb" or "faulthandler" or "pdb" or "timeit" or "trace" or "tracemalloc" => true,
|
||||
|
||||
// Software packaging
|
||||
"distutils" or "ensurepip" or "venv" or "zipapp" => true,
|
||||
|
||||
// Python runtime
|
||||
"sys" or "sysconfig" or "builtins" or "warnings" or "dataclasses" => true,
|
||||
"contextlib" or "atexit" or "traceback" or "gc" or "inspect" or "site" => true,
|
||||
|
||||
// Import system
|
||||
"importlib" or "pkgutil" or "modulefinder" or "runpy" or "zipimport" => true,
|
||||
|
||||
// Language services
|
||||
"ast" or "symtable" or "token" or "keyword" or "tokenize" or "tabnanny" => true,
|
||||
"pyclbr" or "py_compile" or "compileall" or "dis" or "pickletools" => true,
|
||||
|
||||
// MS Windows specific
|
||||
"msvcrt" or "winreg" or "winsound" => true,
|
||||
|
||||
// Unix specific
|
||||
"posix" or "pwd" or "grp" or "termios" or "tty" or "pty" => true,
|
||||
"fcntl" or "pipes" or "resource" or "syslog" => true,
|
||||
|
||||
// Superseded modules
|
||||
"aifc" or "audioop" or "cgi" or "cgitb" or "chunk" or "crypt" => true,
|
||||
"imghdr" or "mailcap" or "msilib" or "nis" or "nntplib" or "optparse" => true,
|
||||
"ossaudiodev" or "pipes" or "sndhdr" or "spwd" or "sunau" or "telnetlib" => true,
|
||||
"uu" or "xdrlib" => true,
|
||||
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
using System.Collections.Frozen;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a node in the import graph.
|
||||
/// </summary>
|
||||
/// <param name="ModulePath">The fully qualified module path.</param>
|
||||
/// <param name="VirtualPath">The virtual file path in the VFS.</param>
|
||||
/// <param name="IsPackage">Whether this is a package (__init__.py).</param>
|
||||
/// <param name="IsNamespacePackage">Whether this is a namespace package (no __init__.py).</param>
|
||||
/// <param name="Source">The file source type.</param>
|
||||
internal sealed record PythonModuleNode(
|
||||
string ModulePath,
|
||||
string? VirtualPath,
|
||||
bool IsPackage,
|
||||
bool IsNamespacePackage,
|
||||
PythonFileSource Source)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this module was resolved to a file.
|
||||
/// </summary>
|
||||
public bool IsResolved => VirtualPath is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top-level package name.
|
||||
/// </summary>
|
||||
public string TopLevelPackage
|
||||
{
|
||||
get
|
||||
{
|
||||
var dotIndex = ModulePath.IndexOf('.');
|
||||
return dotIndex < 0 ? ModulePath : ModulePath[..dotIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an edge in the import graph.
|
||||
/// </summary>
|
||||
/// <param name="From">The importing module.</param>
|
||||
/// <param name="To">The imported module.</param>
|
||||
/// <param name="Import">The import statement that created this edge.</param>
|
||||
internal sealed record PythonImportEdge(
|
||||
string From,
|
||||
string To,
|
||||
PythonImport Import)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the edge key for deduplication.
|
||||
/// </summary>
|
||||
public string Key => $"{From}->{To}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and represents a Python import dependency graph.
|
||||
/// </summary>
|
||||
internal sealed class PythonImportGraph
|
||||
{
|
||||
private readonly PythonVirtualFileSystem _vfs;
|
||||
private readonly string _rootPath;
|
||||
private readonly Dictionary<string, PythonModuleNode> _modules = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<PythonImportEdge>> _edges = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<PythonImportEdge>> _reverseEdges = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<PythonImport>> _importsByFile = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _unresolvedModules = new(StringComparer.Ordinal);
|
||||
|
||||
public PythonImportGraph(PythonVirtualFileSystem vfs, string rootPath)
|
||||
{
|
||||
_vfs = vfs ?? throw new ArgumentNullException(nameof(vfs));
|
||||
_rootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all modules in the graph.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, PythonModuleNode> Modules => _modules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets edges (dependencies) by source module.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, List<PythonImportEdge>> Edges => _edges;
|
||||
|
||||
/// <summary>
|
||||
/// Gets reverse edges (dependents) by target module.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, List<PythonImportEdge>> ReverseEdges => _reverseEdges;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all imports grouped by source file.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, List<PythonImport>> ImportsByFile => _importsByFile;
|
||||
|
||||
/// <summary>
|
||||
/// Gets modules that could not be resolved to files.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> UnresolvedModules => _unresolvedModules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of import statements.
|
||||
/// </summary>
|
||||
public int TotalImports => _importsByFile.Values.Sum(static list => list.Count);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the import graph from the virtual filesystem.
|
||||
/// </summary>
|
||||
public async Task<PythonImportGraph> BuildAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First, discover all modules in the VFS
|
||||
DiscoverModules();
|
||||
|
||||
// Then extract imports from each Python file
|
||||
await ExtractImportsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Finally, build edges from imports
|
||||
BuildEdges();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dependencies of a module (modules it imports).
|
||||
/// </summary>
|
||||
public IEnumerable<PythonModuleNode> GetDependencies(string modulePath)
|
||||
{
|
||||
if (!_edges.TryGetValue(modulePath, out var edges))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (_modules.TryGetValue(edge.To, out var node))
|
||||
{
|
||||
yield return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dependents of a module (modules that import it).
|
||||
/// </summary>
|
||||
public IEnumerable<PythonModuleNode> GetDependents(string modulePath)
|
||||
{
|
||||
if (!_reverseEdges.TryGetValue(modulePath, out var edges))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (_modules.TryGetValue(edge.From, out var node))
|
||||
{
|
||||
yield return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets imports for a specific file.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> GetImportsForFile(string virtualPath)
|
||||
{
|
||||
return _importsByFile.TryGetValue(virtualPath, out var imports)
|
||||
? imports
|
||||
: Array.Empty<PythonImport>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there's a cyclic dependency involving the given module.
|
||||
/// </summary>
|
||||
public bool HasCycle(string modulePath)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var stack = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
return HasCycleInternal(modulePath, visited, stack);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all cyclic dependencies in the graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<string>> FindCycles()
|
||||
{
|
||||
var cycles = new List<IReadOnlyList<string>>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var stack = new List<string>();
|
||||
var onStack = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var module in _modules.Keys)
|
||||
{
|
||||
FindCyclesInternal(module, visited, stack, onStack, cycles);
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a topological ordering of modules (if no cycles).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? GetTopologicalOrder()
|
||||
{
|
||||
var inDegree = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var module in _modules.Keys)
|
||||
{
|
||||
inDegree[module] = 0;
|
||||
}
|
||||
|
||||
foreach (var edges in _edges.Values)
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (inDegree.ContainsKey(edge.To))
|
||||
{
|
||||
inDegree[edge.To]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var queue = new Queue<string>();
|
||||
foreach (var (module, degree) in inDegree)
|
||||
{
|
||||
if (degree == 0)
|
||||
{
|
||||
queue.Enqueue(module);
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<string>();
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var module = queue.Dequeue();
|
||||
result.Add(module);
|
||||
|
||||
if (_edges.TryGetValue(module, out var edges))
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (inDegree.ContainsKey(edge.To))
|
||||
{
|
||||
inDegree[edge.To]--;
|
||||
if (inDegree[edge.To] == 0)
|
||||
{
|
||||
queue.Enqueue(edge.To);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not all modules are in result, there's a cycle
|
||||
return result.Count == _modules.Count ? result : null;
|
||||
}
|
||||
|
||||
private void DiscoverModules()
|
||||
{
|
||||
foreach (var file in _vfs.Files)
|
||||
{
|
||||
if (!file.IsPythonSource && !file.IsBytecode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var modulePath = VirtualPathToModulePath(file.VirtualPath);
|
||||
if (string.IsNullOrEmpty(modulePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isPackage = file.VirtualPath.EndsWith("/__init__.py", StringComparison.Ordinal) ||
|
||||
file.VirtualPath == "__init__.py";
|
||||
|
||||
_modules[modulePath] = new PythonModuleNode(
|
||||
ModulePath: modulePath,
|
||||
VirtualPath: file.VirtualPath,
|
||||
IsPackage: isPackage,
|
||||
IsNamespacePackage: false,
|
||||
Source: file.Source);
|
||||
|
||||
// Also add parent packages
|
||||
AddParentPackages(modulePath, file.Source);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddParentPackages(string modulePath, PythonFileSource source)
|
||||
{
|
||||
var parts = modulePath.Split('.');
|
||||
var current = string.Empty;
|
||||
|
||||
for (var i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
current = string.IsNullOrEmpty(current) ? parts[i] : $"{current}.{parts[i]}";
|
||||
|
||||
if (!_modules.ContainsKey(current))
|
||||
{
|
||||
// Check if __init__.py exists
|
||||
var initPath = current.Replace('.', '/') + "/__init__.py";
|
||||
var exists = _vfs.FileExists(initPath);
|
||||
|
||||
_modules[current] = new PythonModuleNode(
|
||||
ModulePath: current,
|
||||
VirtualPath: exists ? initPath : null,
|
||||
IsPackage: true,
|
||||
IsNamespacePackage: !exists,
|
||||
Source: source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractImportsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var file in _vfs.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!file.IsPythonSource && !file.IsBytecode)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var imports = await ExtractFileImportsAsync(file, cancellationToken).ConfigureAwait(false);
|
||||
if (imports.Count > 0)
|
||||
{
|
||||
_importsByFile[file.VirtualPath] = imports;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<PythonImport>> ExtractFileImportsAsync(
|
||||
PythonVirtualFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Skip files from archives for now (need to read from zip)
|
||||
if (file.IsFromArchive)
|
||||
{
|
||||
return new List<PythonImport>();
|
||||
}
|
||||
|
||||
var absolutePath = Path.Combine(_rootPath, file.AbsolutePath);
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
absolutePath = file.AbsolutePath;
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
return new List<PythonImport>();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (file.IsPythonSource)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(absolutePath, cancellationToken).ConfigureAwait(false);
|
||||
var extractor = new PythonSourceImportExtractor(file.VirtualPath);
|
||||
extractor.Extract(content);
|
||||
return extractor.Imports.ToList();
|
||||
}
|
||||
else if (file.IsBytecode)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(absolutePath, cancellationToken).ConfigureAwait(false);
|
||||
var extractor = new PythonBytecodeImportExtractor(file.VirtualPath);
|
||||
extractor.Extract(bytes);
|
||||
return extractor.Imports.ToList();
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip inaccessible files
|
||||
}
|
||||
|
||||
return new List<PythonImport>();
|
||||
}
|
||||
|
||||
private void BuildEdges()
|
||||
{
|
||||
foreach (var (virtualPath, imports) in _importsByFile)
|
||||
{
|
||||
var sourceModulePath = VirtualPathToModulePath(virtualPath);
|
||||
if (string.IsNullOrEmpty(sourceModulePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var import in imports)
|
||||
{
|
||||
var targetModulePath = ResolveImportTarget(import, sourceModulePath);
|
||||
if (string.IsNullOrEmpty(targetModulePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var edge = new PythonImportEdge(sourceModulePath, targetModulePath, import);
|
||||
|
||||
// Add forward edge
|
||||
if (!_edges.TryGetValue(sourceModulePath, out var forwardEdges))
|
||||
{
|
||||
forwardEdges = new List<PythonImportEdge>();
|
||||
_edges[sourceModulePath] = forwardEdges;
|
||||
}
|
||||
|
||||
forwardEdges.Add(edge);
|
||||
|
||||
// Add reverse edge
|
||||
if (!_reverseEdges.TryGetValue(targetModulePath, out var reverseEdges))
|
||||
{
|
||||
reverseEdges = new List<PythonImportEdge>();
|
||||
_reverseEdges[targetModulePath] = reverseEdges;
|
||||
}
|
||||
|
||||
reverseEdges.Add(edge);
|
||||
|
||||
// Track unresolved modules
|
||||
if (!_modules.ContainsKey(targetModulePath))
|
||||
{
|
||||
_unresolvedModules.Add(targetModulePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveImportTarget(PythonImport import, string sourceModulePath)
|
||||
{
|
||||
if (import.IsRelative)
|
||||
{
|
||||
return ResolveRelativeImport(import, sourceModulePath);
|
||||
}
|
||||
|
||||
return import.Module;
|
||||
}
|
||||
|
||||
private string? ResolveRelativeImport(PythonImport import, string sourceModulePath)
|
||||
{
|
||||
var parts = sourceModulePath.Split('.');
|
||||
|
||||
// Calculate the package to start from
|
||||
// Level 1 (.) = current package
|
||||
// Level 2 (..) = parent package
|
||||
var levelsUp = import.RelativeLevel;
|
||||
|
||||
// If source is not a package (__init__.py), we need to go one more level up
|
||||
var sourceVirtualPath = _modules.TryGetValue(sourceModulePath, out var node) ? node.VirtualPath : null;
|
||||
var isSourcePackage = sourceVirtualPath?.EndsWith("__init__.py", StringComparison.Ordinal) == true;
|
||||
|
||||
if (!isSourcePackage)
|
||||
{
|
||||
levelsUp++;
|
||||
}
|
||||
|
||||
if (levelsUp > parts.Length)
|
||||
{
|
||||
return null; // Invalid relative import (goes beyond top-level package)
|
||||
}
|
||||
|
||||
var baseParts = parts[..^(levelsUp)];
|
||||
var basePackage = string.Join('.', baseParts);
|
||||
|
||||
if (string.IsNullOrEmpty(import.Module))
|
||||
{
|
||||
return string.IsNullOrEmpty(basePackage) ? null : basePackage;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(basePackage)
|
||||
? import.Module
|
||||
: $"{basePackage}.{import.Module}";
|
||||
}
|
||||
|
||||
private static string VirtualPathToModulePath(string virtualPath)
|
||||
{
|
||||
// Remove .py or .pyc extension
|
||||
var path = virtualPath;
|
||||
if (path.EndsWith(".py", StringComparison.Ordinal))
|
||||
{
|
||||
path = path[..^3];
|
||||
}
|
||||
else if (path.EndsWith(".pyc", StringComparison.Ordinal))
|
||||
{
|
||||
path = path[..^4];
|
||||
}
|
||||
|
||||
// Handle __init__ -> package name
|
||||
if (path.EndsWith("/__init__", StringComparison.Ordinal))
|
||||
{
|
||||
path = path[..^9];
|
||||
}
|
||||
else if (path == "__init__")
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Convert path separators to dots
|
||||
return path.Replace('/', '.');
|
||||
}
|
||||
|
||||
private bool HasCycleInternal(string module, HashSet<string> visited, HashSet<string> stack)
|
||||
{
|
||||
if (stack.Contains(module))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (visited.Contains(module))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
visited.Add(module);
|
||||
stack.Add(module);
|
||||
|
||||
if (_edges.TryGetValue(module, out var edges))
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (HasCycleInternal(edge.To, visited, stack))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stack.Remove(module);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void FindCyclesInternal(
|
||||
string module,
|
||||
HashSet<string> visited,
|
||||
List<string> stack,
|
||||
HashSet<string> onStack,
|
||||
List<IReadOnlyList<string>> cycles)
|
||||
{
|
||||
if (onStack.Contains(module))
|
||||
{
|
||||
// Found a cycle - extract it from the stack
|
||||
var cycleStart = stack.IndexOf(module);
|
||||
if (cycleStart >= 0)
|
||||
{
|
||||
var cycle = stack.Skip(cycleStart).ToList();
|
||||
cycle.Add(module); // Complete the cycle
|
||||
cycles.Add(cycle);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (visited.Contains(module))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
visited.Add(module);
|
||||
stack.Add(module);
|
||||
onStack.Add(module);
|
||||
|
||||
if (_edges.TryGetValue(module, out var edges))
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
FindCyclesInternal(edge.To, visited, stack, onStack, cycles);
|
||||
}
|
||||
}
|
||||
|
||||
stack.RemoveAt(stack.Count - 1);
|
||||
onStack.Remove(module);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// Categorizes Python import statement types.
|
||||
/// </summary>
|
||||
internal enum PythonImportKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard import: import foo
|
||||
/// </summary>
|
||||
Import,
|
||||
|
||||
/// <summary>
|
||||
/// From import: from foo import bar
|
||||
/// </summary>
|
||||
FromImport,
|
||||
|
||||
/// <summary>
|
||||
/// Relative import: from . import bar or from ..foo import bar
|
||||
/// </summary>
|
||||
RelativeImport,
|
||||
|
||||
/// <summary>
|
||||
/// Star import: from foo import *
|
||||
/// </summary>
|
||||
StarImport,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic import via importlib.import_module()
|
||||
/// </summary>
|
||||
ImportlibImportModule,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic import via __import__()
|
||||
/// </summary>
|
||||
BuiltinImport,
|
||||
|
||||
/// <summary>
|
||||
/// Namespace package extension via pkgutil.extend_path()
|
||||
/// </summary>
|
||||
PkgutilExtendPath,
|
||||
|
||||
/// <summary>
|
||||
/// Conditional import (inside try/except or if block)
|
||||
/// </summary>
|
||||
ConditionalImport,
|
||||
|
||||
/// <summary>
|
||||
/// Lazy import (inside function body, not at module level)
|
||||
/// </summary>
|
||||
LazyImport,
|
||||
|
||||
/// <summary>
|
||||
/// Type checking only import (inside TYPE_CHECKING block)
|
||||
/// </summary>
|
||||
TypeCheckingImport,
|
||||
|
||||
/// <summary>
|
||||
/// Future import: from __future__ import ...
|
||||
/// </summary>
|
||||
FutureImport
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts imports from Python source code using regex-based static analysis.
|
||||
/// </summary>
|
||||
internal sealed partial class PythonSourceImportExtractor
|
||||
{
|
||||
private readonly string _sourceFile;
|
||||
private readonly List<PythonImport> _imports = new();
|
||||
private bool _inTryBlock;
|
||||
private bool _inTypeCheckingBlock;
|
||||
private int _functionDepth;
|
||||
private int _classDepth;
|
||||
|
||||
public PythonSourceImportExtractor(string sourceFile)
|
||||
{
|
||||
_sourceFile = sourceFile ?? throw new ArgumentNullException(nameof(sourceFile));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all extracted imports.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonImport> Imports => _imports;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts imports from Python source code.
|
||||
/// </summary>
|
||||
public PythonSourceImportExtractor Extract(string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var lines = content.Split('\n');
|
||||
var lineNumber = 0;
|
||||
var continuedLine = string.Empty;
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
lineNumber++;
|
||||
var line = rawLine.TrimEnd('\r');
|
||||
|
||||
// Handle line continuations
|
||||
if (line.EndsWith('\\'))
|
||||
{
|
||||
continuedLine += line[..^1].Trim() + " ";
|
||||
continue;
|
||||
}
|
||||
|
||||
var fullLine = continuedLine + line;
|
||||
continuedLine = string.Empty;
|
||||
|
||||
// Track context
|
||||
UpdateContext(fullLine.TrimStart());
|
||||
|
||||
// Skip comments and empty lines
|
||||
var trimmed = fullLine.TrimStart();
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove inline comments for parsing
|
||||
var commentIndex = FindCommentStart(trimmed);
|
||||
if (commentIndex >= 0)
|
||||
{
|
||||
trimmed = trimmed[..commentIndex].TrimEnd();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to extract imports
|
||||
ExtractImports(trimmed, lineNumber);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private void UpdateContext(string line)
|
||||
{
|
||||
// Track try blocks
|
||||
if (line.StartsWith("try:") || line == "try")
|
||||
{
|
||||
_inTryBlock = true;
|
||||
}
|
||||
else if (line.StartsWith("except") || line.StartsWith("finally:") || line == "finally")
|
||||
{
|
||||
_inTryBlock = false;
|
||||
}
|
||||
|
||||
// Track TYPE_CHECKING blocks
|
||||
if (line.Contains("TYPE_CHECKING") && line.Contains("if"))
|
||||
{
|
||||
_inTypeCheckingBlock = true;
|
||||
}
|
||||
|
||||
// Track function depth
|
||||
if (line.StartsWith("def ") || line.StartsWith("async def "))
|
||||
{
|
||||
_functionDepth++;
|
||||
}
|
||||
|
||||
// Track class depth (for nested classes)
|
||||
if (line.StartsWith("class "))
|
||||
{
|
||||
_classDepth++;
|
||||
}
|
||||
|
||||
// Reset context at module level definitions
|
||||
if ((line.StartsWith("def ") || line.StartsWith("class ") || line.StartsWith("async def ")) &&
|
||||
!line.StartsWith(" ") && !line.StartsWith("\t"))
|
||||
{
|
||||
_inTypeCheckingBlock = false;
|
||||
_functionDepth = 0;
|
||||
_classDepth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExtractImports(string line, int lineNumber)
|
||||
{
|
||||
// Standard import: import foo, bar
|
||||
var importMatch = StandardImportPattern().Match(line);
|
||||
if (importMatch.Success)
|
||||
{
|
||||
ParseStandardImport(importMatch.Groups["modules"].Value, lineNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
// From import: from foo import bar, baz
|
||||
var fromMatch = FromImportPattern().Match(line);
|
||||
if (fromMatch.Success)
|
||||
{
|
||||
ParseFromImport(
|
||||
fromMatch.Groups["dots"].Value,
|
||||
fromMatch.Groups["module"].Value,
|
||||
fromMatch.Groups["names"].Value,
|
||||
lineNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
// importlib.import_module()
|
||||
var importlibMatch = ImportlibPattern().Match(line);
|
||||
if (importlibMatch.Success)
|
||||
{
|
||||
ParseImportlibImport(
|
||||
importlibMatch.Groups["module"].Value,
|
||||
importlibMatch.Groups["package"].Value,
|
||||
lineNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
// __import__()
|
||||
var builtinMatch = BuiltinImportPattern().Match(line);
|
||||
if (builtinMatch.Success)
|
||||
{
|
||||
ParseBuiltinImport(builtinMatch.Groups["module"].Value, lineNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
// pkgutil.extend_path()
|
||||
var pkgutilMatch = PkgutilExtendPathPattern().Match(line);
|
||||
if (pkgutilMatch.Success)
|
||||
{
|
||||
ParsePkgutilExtendPath(pkgutilMatch.Groups["name"].Value, lineNumber);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseStandardImport(string modulesStr, int lineNumber)
|
||||
{
|
||||
// import foo, bar as baz, qux
|
||||
var modules = modulesStr.Split(',');
|
||||
|
||||
foreach (var moduleSpec in modules)
|
||||
{
|
||||
var trimmed = moduleSpec.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string module;
|
||||
string? alias = null;
|
||||
|
||||
// Check for alias
|
||||
var asMatch = AsAliasPattern().Match(trimmed);
|
||||
if (asMatch.Success)
|
||||
{
|
||||
module = asMatch.Groups["name"].Value.Trim();
|
||||
alias = asMatch.Groups["alias"].Value.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
module = trimmed;
|
||||
}
|
||||
|
||||
var kind = _inTypeCheckingBlock ? PythonImportKind.TypeCheckingImport : PythonImportKind.Import;
|
||||
|
||||
_imports.Add(new PythonImport(
|
||||
Module: module,
|
||||
Names: null,
|
||||
Alias: alias,
|
||||
Kind: kind,
|
||||
RelativeLevel: 0,
|
||||
SourceFile: _sourceFile,
|
||||
LineNumber: lineNumber,
|
||||
Confidence: PythonImportConfidence.Definitive,
|
||||
IsConditional: _inTryBlock,
|
||||
IsLazy: _functionDepth > 0,
|
||||
IsTypeCheckingOnly: _inTypeCheckingBlock));
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseFromImport(string dots, string module, string namesStr, int lineNumber)
|
||||
{
|
||||
var relativeLevel = dots.Length;
|
||||
var isFuture = module == "__future__";
|
||||
|
||||
// Handle star import
|
||||
if (namesStr.Trim() == "*")
|
||||
{
|
||||
var kind = isFuture
|
||||
? PythonImportKind.FutureImport
|
||||
: relativeLevel > 0
|
||||
? PythonImportKind.RelativeImport
|
||||
: PythonImportKind.StarImport;
|
||||
|
||||
_imports.Add(new PythonImport(
|
||||
Module: module,
|
||||
Names: [new PythonImportedName("*")],
|
||||
Alias: null,
|
||||
Kind: kind,
|
||||
RelativeLevel: relativeLevel,
|
||||
SourceFile: _sourceFile,
|
||||
LineNumber: lineNumber,
|
||||
Confidence: PythonImportConfidence.Definitive,
|
||||
IsConditional: _inTryBlock,
|
||||
IsLazy: _functionDepth > 0,
|
||||
IsTypeCheckingOnly: _inTypeCheckingBlock));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle parenthesized imports: from foo import (bar, baz)
|
||||
namesStr = namesStr.Trim();
|
||||
if (namesStr.StartsWith('('))
|
||||
{
|
||||
namesStr = namesStr.TrimStart('(').TrimEnd(')');
|
||||
}
|
||||
|
||||
var names = new List<PythonImportedName>();
|
||||
|
||||
foreach (var nameSpec in namesStr.Split(','))
|
||||
{
|
||||
var trimmed = nameSpec.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var asMatch = AsAliasPattern().Match(trimmed);
|
||||
if (asMatch.Success)
|
||||
{
|
||||
names.Add(new PythonImportedName(
|
||||
asMatch.Groups["name"].Value.Trim(),
|
||||
asMatch.Groups["alias"].Value.Trim()));
|
||||
}
|
||||
else
|
||||
{
|
||||
names.Add(new PythonImportedName(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
var importKind = isFuture
|
||||
? PythonImportKind.FutureImport
|
||||
: relativeLevel > 0
|
||||
? PythonImportKind.RelativeImport
|
||||
: _inTypeCheckingBlock
|
||||
? PythonImportKind.TypeCheckingImport
|
||||
: PythonImportKind.FromImport;
|
||||
|
||||
_imports.Add(new PythonImport(
|
||||
Module: module,
|
||||
Names: names,
|
||||
Alias: null,
|
||||
Kind: importKind,
|
||||
RelativeLevel: relativeLevel,
|
||||
SourceFile: _sourceFile,
|
||||
LineNumber: lineNumber,
|
||||
Confidence: PythonImportConfidence.Definitive,
|
||||
IsConditional: _inTryBlock,
|
||||
IsLazy: _functionDepth > 0,
|
||||
IsTypeCheckingOnly: _inTypeCheckingBlock));
|
||||
}
|
||||
|
||||
private void ParseImportlibImport(string module, string package, int lineNumber)
|
||||
{
|
||||
// importlib.import_module('foo') or importlib.import_module('.foo', 'bar')
|
||||
var moduleName = module.Trim('\'', '"');
|
||||
var relativeLevel = 0;
|
||||
|
||||
// Count leading dots for relative imports
|
||||
while (moduleName.StartsWith('.'))
|
||||
{
|
||||
relativeLevel++;
|
||||
moduleName = moduleName[1..];
|
||||
}
|
||||
|
||||
// If package is specified and module is relative, note it
|
||||
var isRelative = relativeLevel > 0 || !string.IsNullOrEmpty(package);
|
||||
|
||||
_imports.Add(new PythonImport(
|
||||
Module: moduleName,
|
||||
Names: null,
|
||||
Alias: null,
|
||||
Kind: PythonImportKind.ImportlibImportModule,
|
||||
RelativeLevel: relativeLevel,
|
||||
SourceFile: _sourceFile,
|
||||
LineNumber: lineNumber,
|
||||
Confidence: PythonImportConfidence.High,
|
||||
IsConditional: _inTryBlock,
|
||||
IsLazy: _functionDepth > 0,
|
||||
IsTypeCheckingOnly: _inTypeCheckingBlock));
|
||||
}
|
||||
|
||||
private void ParseBuiltinImport(string module, int lineNumber)
|
||||
{
|
||||
var moduleName = module.Trim('\'', '"');
|
||||
|
||||
_imports.Add(new PythonImport(
|
||||
Module: moduleName,
|
||||
Names: null,
|
||||
Alias: null,
|
||||
Kind: PythonImportKind.BuiltinImport,
|
||||
RelativeLevel: 0,
|
||||
SourceFile: _sourceFile,
|
||||
LineNumber: lineNumber,
|
||||
Confidence: PythonImportConfidence.High,
|
||||
IsConditional: _inTryBlock,
|
||||
IsLazy: _functionDepth > 0,
|
||||
IsTypeCheckingOnly: _inTypeCheckingBlock));
|
||||
}
|
||||
|
||||
private void ParsePkgutilExtendPath(string name, int lineNumber)
|
||||
{
|
||||
// pkgutil.extend_path(__path__, __name__)
|
||||
_imports.Add(new PythonImport(
|
||||
Module: name.Trim('\'', '"', ' '),
|
||||
Names: null,
|
||||
Alias: null,
|
||||
Kind: PythonImportKind.PkgutilExtendPath,
|
||||
RelativeLevel: 0,
|
||||
SourceFile: _sourceFile,
|
||||
LineNumber: lineNumber,
|
||||
Confidence: PythonImportConfidence.Medium,
|
||||
IsConditional: _inTryBlock,
|
||||
IsLazy: _functionDepth > 0,
|
||||
IsTypeCheckingOnly: _inTypeCheckingBlock));
|
||||
}
|
||||
|
||||
private static int FindCommentStart(string line)
|
||||
{
|
||||
var inSingleQuote = false;
|
||||
var inDoubleQuote = false;
|
||||
var inTripleSingle = false;
|
||||
var inTripleDouble = false;
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
var remaining = line.Length - i;
|
||||
|
||||
// Check for triple quotes
|
||||
if (remaining >= 3)
|
||||
{
|
||||
var three = line.Substring(i, 3);
|
||||
if (three == "\"\"\"" && !inSingleQuote && !inTripleSingle)
|
||||
{
|
||||
inTripleDouble = !inTripleDouble;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (three == "'''" && !inDoubleQuote && !inTripleDouble)
|
||||
{
|
||||
inTripleSingle = !inTripleSingle;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for single quotes
|
||||
if (c == '"' && !inSingleQuote && !inTripleSingle && !inTripleDouble)
|
||||
{
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\'' && !inDoubleQuote && !inTripleSingle && !inTripleDouble)
|
||||
{
|
||||
inSingleQuote = !inSingleQuote;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for comment
|
||||
if (c == '#' && !inSingleQuote && !inDoubleQuote && !inTripleSingle && !inTripleDouble)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
// Handle escape sequences
|
||||
if (c == '\\' && i + 1 < line.Length)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Standard import: import foo, bar
|
||||
[GeneratedRegex(@"^import\s+(?<modules>.+)$", RegexOptions.Compiled)]
|
||||
private static partial Regex StandardImportPattern();
|
||||
|
||||
// From import: from foo import bar (handles relative with dots)
|
||||
[GeneratedRegex(@"^from\s+(?<dots>\.*)(?<module>[\w.]*)\s+import\s+(?<names>.+)$", RegexOptions.Compiled)]
|
||||
private static partial Regex FromImportPattern();
|
||||
|
||||
// importlib.import_module('module') or importlib.import_module('module', 'package')
|
||||
[GeneratedRegex(@"importlib\.import_module\s*\(\s*(?<module>['""][^'""]+['""])(?:\s*,\s*(?<package>['""][^'""]*['""]))?", RegexOptions.Compiled)]
|
||||
private static partial Regex ImportlibPattern();
|
||||
|
||||
// __import__('module')
|
||||
[GeneratedRegex(@"__import__\s*\(\s*(?<module>['""][^'""]+['""]\s*)", RegexOptions.Compiled)]
|
||||
private static partial Regex BuiltinImportPattern();
|
||||
|
||||
// pkgutil.extend_path(__path__, __name__) or pkgutil.extend_path(__path__, 'name')
|
||||
[GeneratedRegex(@"pkgutil\.extend_path\s*\(\s*__path__\s*,\s*(?<name>__name__|['""][^'""]+['""]\s*)\)", RegexOptions.Compiled)]
|
||||
private static partial Regex PkgutilExtendPathPattern();
|
||||
|
||||
// name as alias
|
||||
[GeneratedRegex(@"^(?<name>[\w.]+)\s+as\s+(?<alias>\w+)$", RegexOptions.Compiled)]
|
||||
private static partial Regex AsAliasPattern();
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects Python runtime in OCI container layers and zipapp archives.
|
||||
/// Parses layers/, .layers/, layer/ directories for Python site-packages.
|
||||
/// </summary>
|
||||
internal static class PythonContainerAdapter
|
||||
{
|
||||
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
|
||||
private static readonly string[] SitePackagesPatterns = { "site-packages", "dist-packages" };
|
||||
private static readonly string[] PythonBinaryNames = { "python", "python3", "python3.10", "python3.11", "python3.12", "python3.13" };
|
||||
|
||||
/// <summary>
|
||||
/// Discovers Python site-packages directories from OCI container layers.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DiscoverLayerSitePackages(string rootPath)
|
||||
{
|
||||
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootPath));
|
||||
|
||||
// Check for direct layer directories (layer1/, layer2/, etc.)
|
||||
foreach (var directory in EnumerateDirectoriesSafe(rootPath))
|
||||
{
|
||||
var dirName = Path.GetFileName(directory);
|
||||
if (dirName.StartsWith("layer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var sitePackages in DiscoverSitePackagesInDirectory(directory))
|
||||
{
|
||||
if (TryNormalizeUnderRoot(normalizedRoot, sitePackages, out var normalized))
|
||||
{
|
||||
discovered.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for standard OCI layer root directories
|
||||
foreach (var layerRoot in EnumerateLayerRoots(rootPath))
|
||||
{
|
||||
foreach (var sitePackages in DiscoverSitePackagesInDirectory(layerRoot))
|
||||
{
|
||||
if (TryNormalizeUnderRoot(normalizedRoot, sitePackages, out var normalized))
|
||||
{
|
||||
discovered.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return discovered
|
||||
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects Python runtime information from container layers.
|
||||
/// </summary>
|
||||
public static PythonRuntimeInfo? DetectRuntime(string rootPath)
|
||||
{
|
||||
var pythonPaths = new List<string>();
|
||||
var pythonVersions = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var pythonBinaries = new List<string>();
|
||||
|
||||
// Search in standard locations
|
||||
var searchRoots = new List<string> { rootPath };
|
||||
searchRoots.AddRange(EnumerateLayerRoots(rootPath));
|
||||
|
||||
foreach (var searchRoot in searchRoots)
|
||||
{
|
||||
// Look for Python binaries in /usr/bin, /usr/local/bin, /bin
|
||||
var binDirectories = new[]
|
||||
{
|
||||
Path.Combine(searchRoot, "usr", "bin"),
|
||||
Path.Combine(searchRoot, "usr", "local", "bin"),
|
||||
Path.Combine(searchRoot, "bin")
|
||||
};
|
||||
|
||||
foreach (var binDir in binDirectories)
|
||||
{
|
||||
if (!Directory.Exists(binDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var binaryName in PythonBinaryNames)
|
||||
{
|
||||
var pythonPath = Path.Combine(binDir, binaryName);
|
||||
if (File.Exists(pythonPath))
|
||||
{
|
||||
pythonBinaries.Add(pythonPath);
|
||||
var version = ExtractVersionFromBinaryName(binaryName);
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
pythonVersions.Add(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for Python lib directories to detect version
|
||||
var libDirectories = new[]
|
||||
{
|
||||
Path.Combine(searchRoot, "usr", "lib"),
|
||||
Path.Combine(searchRoot, "usr", "local", "lib"),
|
||||
Path.Combine(searchRoot, "lib")
|
||||
};
|
||||
|
||||
foreach (var libDir in libDirectories)
|
||||
{
|
||||
if (!Directory.Exists(libDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pythonDir in EnumerateDirectoriesSafe(libDir))
|
||||
{
|
||||
var dirName = Path.GetFileName(pythonDir);
|
||||
if (dirName.StartsWith("python", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var version = dirName.Replace("python", "", StringComparison.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrEmpty(version) && char.IsDigit(version[0]))
|
||||
{
|
||||
pythonVersions.Add(version);
|
||||
pythonPaths.Add(pythonDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pythonVersions.Count == 0 && pythonBinaries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PythonRuntimeInfo(
|
||||
pythonVersions.ToArray(),
|
||||
pythonBinaries.ToArray(),
|
||||
pythonPaths.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers dist-info directories within container layers.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DiscoverDistInfoDirectories(string rootPath)
|
||||
{
|
||||
var discovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var sitePackages in DiscoverLayerSitePackages(rootPath))
|
||||
{
|
||||
foreach (var distInfo in EnumerateDistInfoDirectories(sitePackages))
|
||||
{
|
||||
discovered.Add(distInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check root-level site-packages
|
||||
foreach (var sitePackages in DiscoverSitePackagesInDirectory(rootPath))
|
||||
{
|
||||
foreach (var distInfo in EnumerateDistInfoDirectories(sitePackages))
|
||||
{
|
||||
discovered.Add(distInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return discovered
|
||||
.OrderBy(static path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateDistInfoDirectories(string sitePackages)
|
||||
{
|
||||
if (!Directory.Exists(sitePackages))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories = null;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(sitePackages, "*.dist-info");
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
yield return directory;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DiscoverSitePackagesInDirectory(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Search recursively up to 6 levels deep (e.g., usr/lib/python3.11/site-packages)
|
||||
var pending = new Stack<(string Path, int Depth)>();
|
||||
pending.Push((directory, 0));
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var (current, depth) = pending.Pop();
|
||||
var dirName = Path.GetFileName(current);
|
||||
|
||||
if (SitePackagesPatterns.Contains(dirName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return current;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth >= 6)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string>? subdirs = null;
|
||||
try
|
||||
{
|
||||
subdirs = Directory.EnumerateDirectories(current);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var subdir in subdirs)
|
||||
{
|
||||
pending.Push((subdir, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
|
||||
{
|
||||
foreach (var candidate in LayerRootCandidates)
|
||||
{
|
||||
var root = Path.Combine(workspaceRoot, candidate);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories = null;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(root);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var layerDirectory in directories)
|
||||
{
|
||||
// Check for fs/ subdirectory (extracted layer filesystem)
|
||||
var fsDirectory = Path.Combine(layerDirectory, "fs");
|
||||
if (Directory.Exists(fsDirectory))
|
||||
{
|
||||
yield return fsDirectory;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return layerDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateDirectoriesSafe(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories = null;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(path);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var dir in directories)
|
||||
{
|
||||
yield return dir;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryNormalizeUnderRoot(string normalizedRoot, string path, out string normalizedPath)
|
||||
{
|
||||
normalizedPath = Path.GetFullPath(path);
|
||||
return normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
if (path.EndsWith(Path.DirectorySeparatorChar))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static string? ExtractVersionFromBinaryName(string binaryName)
|
||||
{
|
||||
if (binaryName.StartsWith("python", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var version = binaryName.Replace("python", "", StringComparison.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrEmpty(version) && char.IsDigit(version[0]))
|
||||
{
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about the Python runtime detected in a container.
|
||||
/// </summary>
|
||||
internal sealed record PythonRuntimeInfo(
|
||||
IReadOnlyCollection<string> Versions,
|
||||
IReadOnlyCollection<string> Binaries,
|
||||
IReadOnlyCollection<string> LibPaths);
|
||||
@@ -0,0 +1,326 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects Python environment variables (PYTHONPATH, PYTHONHOME) from container
|
||||
/// configuration files, environment files, and virtual environment configs.
|
||||
/// </summary>
|
||||
internal static class PythonEnvironmentDetector
|
||||
{
|
||||
private static readonly string[] EnvironmentFileNames =
|
||||
{
|
||||
"environment",
|
||||
".env",
|
||||
"env",
|
||||
".docker-env",
|
||||
"docker-env"
|
||||
};
|
||||
|
||||
private static readonly string[] OciConfigFiles =
|
||||
{
|
||||
"config.json",
|
||||
"container-config.json",
|
||||
"image-config.json"
|
||||
};
|
||||
|
||||
private static readonly Regex EnvLinePattern = new(
|
||||
@"^\s*(?:export\s+)?(?<key>PYTHON(?:PATH|HOME|STARTUP|OPTIMIZATION|HASHSEED|UNBUFFERED|DONTWRITEBYTECODE|VERBOSE|DEBUG))\s*=\s*(?<value>.+)$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline);
|
||||
|
||||
/// <summary>
|
||||
/// Detects Python-related environment variables from various sources.
|
||||
/// </summary>
|
||||
public static async ValueTask<PythonEnvironment> DetectAsync(string rootPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var variables = new Dictionary<string, PythonEnvVariable>(StringComparer.OrdinalIgnoreCase);
|
||||
var sources = new List<string>();
|
||||
|
||||
// Check for pyvenv.cfg (virtual environment config)
|
||||
await DetectPyvenvConfigAsync(rootPath, variables, sources, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check for environment files
|
||||
foreach (var fileName in EnvironmentFileNames)
|
||||
{
|
||||
var envPath = Path.Combine(rootPath, fileName);
|
||||
if (File.Exists(envPath))
|
||||
{
|
||||
await ParseEnvironmentFileAsync(envPath, variables, sources, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Also check in /etc
|
||||
var etcEnvPath = Path.Combine(rootPath, "etc", fileName);
|
||||
if (File.Exists(etcEnvPath))
|
||||
{
|
||||
await ParseEnvironmentFileAsync(etcEnvPath, variables, sources, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for OCI config files
|
||||
foreach (var configFile in OciConfigFiles)
|
||||
{
|
||||
var configPath = Path.Combine(rootPath, configFile);
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
await ParseOciConfigAsync(configPath, variables, sources, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Check container layer roots for environment configs
|
||||
foreach (var layerRoot in EnumerateLayerRoots(rootPath))
|
||||
{
|
||||
var layerEnvPath = Path.Combine(layerRoot, "etc", "environment");
|
||||
if (File.Exists(layerEnvPath))
|
||||
{
|
||||
await ParseEnvironmentFileAsync(layerEnvPath, variables, sources, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return new PythonEnvironment(variables, sources);
|
||||
}
|
||||
|
||||
private static async ValueTask DetectPyvenvConfigAsync(
|
||||
string rootPath,
|
||||
Dictionary<string, PythonEnvVariable> variables,
|
||||
List<string> sources,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pyvenvPath = Path.Combine(rootPath, "pyvenv.cfg");
|
||||
if (!File.Exists(pyvenvPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sources.Add(pyvenvPath);
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(pyvenvPath, cancellationToken).ConfigureAwait(false);
|
||||
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = trimmed.IndexOf('=');
|
||||
if (separator <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = trimmed[..separator].Trim();
|
||||
var value = trimmed[(separator + 1)..].Trim();
|
||||
|
||||
if (string.Equals(key, "home", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
variables["PYTHONHOME_VENV"] = new PythonEnvVariable("PYTHONHOME_VENV", value, pyvenvPath, "pyvenv.cfg");
|
||||
}
|
||||
else if (string.Equals(key, "include-system-site-packages", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
variables["PYVENV_INCLUDE_SYSTEM"] = new PythonEnvVariable("PYVENV_INCLUDE_SYSTEM", value, pyvenvPath, "pyvenv.cfg");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ParseEnvironmentFileAsync(
|
||||
string path,
|
||||
Dictionary<string, PythonEnvVariable> variables,
|
||||
List<string> sources,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var matches = EnvLinePattern.Matches(content);
|
||||
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
sources.Add(path);
|
||||
}
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var key = match.Groups["key"].Value.Trim();
|
||||
var value = match.Groups["value"].Value.Trim().Trim('"', '\'');
|
||||
variables[key] = new PythonEnvVariable(key, value, path, "environment");
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ParseOciConfigAsync(
|
||||
string path,
|
||||
Dictionary<string, PythonEnvVariable> variables,
|
||||
List<string> sources,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = document.RootElement;
|
||||
|
||||
// OCI image config structure: config.Env or Env
|
||||
JsonElement envArray = default;
|
||||
|
||||
if (root.TryGetProperty("config", out var config) && config.TryGetProperty("Env", out var configEnv))
|
||||
{
|
||||
envArray = configEnv;
|
||||
}
|
||||
else if (root.TryGetProperty("Env", out var directEnv))
|
||||
{
|
||||
envArray = directEnv;
|
||||
}
|
||||
|
||||
if (envArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var foundPythonVar = false;
|
||||
|
||||
foreach (var envElement in envArray.EnumerateArray())
|
||||
{
|
||||
var envString = envElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(envString))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = envString.IndexOf('=');
|
||||
if (separator <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = envString[..separator];
|
||||
var value = envString[(separator + 1)..];
|
||||
|
||||
if (key.StartsWith("PYTHON", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
variables[key] = new PythonEnvVariable(key, value, path, "oci-config");
|
||||
foundPythonVar = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundPythonVar)
|
||||
{
|
||||
sources.Add(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
|
||||
|
||||
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
|
||||
{
|
||||
foreach (var candidate in LayerRootCandidates)
|
||||
{
|
||||
var root = Path.Combine(workspaceRoot, candidate);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories = null;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(root);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var layerDirectory in directories)
|
||||
{
|
||||
var fsDirectory = Path.Combine(layerDirectory, "fs");
|
||||
if (Directory.Exists(fsDirectory))
|
||||
{
|
||||
yield return fsDirectory;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return layerDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents detected Python environment configuration.
|
||||
/// </summary>
|
||||
internal sealed class PythonEnvironment
|
||||
{
|
||||
public PythonEnvironment(
|
||||
IReadOnlyDictionary<string, PythonEnvVariable> variables,
|
||||
IReadOnlyCollection<string> sources)
|
||||
{
|
||||
Variables = variables;
|
||||
Sources = sources;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, PythonEnvVariable> Variables { get; }
|
||||
public IReadOnlyCollection<string> Sources { get; }
|
||||
|
||||
public bool HasPythonPath => Variables.ContainsKey("PYTHONPATH");
|
||||
public bool HasPythonHome => Variables.ContainsKey("PYTHONHOME") || Variables.ContainsKey("PYTHONHOME_VENV");
|
||||
|
||||
public string? PythonPath => Variables.TryGetValue("PYTHONPATH", out var v) ? v.Value : null;
|
||||
public string? PythonHome => Variables.TryGetValue("PYTHONHOME", out var v) ? v.Value :
|
||||
Variables.TryGetValue("PYTHONHOME_VENV", out var venv) ? venv.Value : null;
|
||||
|
||||
public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata()
|
||||
{
|
||||
var entries = new List<KeyValuePair<string, string?>>();
|
||||
|
||||
foreach (var variable in Variables.Values.OrderBy(v => v.Key, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>($"env.{variable.Key.ToLowerInvariant()}", variable.Value));
|
||||
entries.Add(new KeyValuePair<string, string?>($"env.{variable.Key.ToLowerInvariant()}.source", variable.SourceType));
|
||||
}
|
||||
|
||||
if (Sources.Count > 0)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("env.sources", string.Join(';', Sources.OrderBy(s => s, StringComparer.Ordinal))));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single Python environment variable.
|
||||
/// </summary>
|
||||
internal sealed record PythonEnvVariable(
|
||||
string Key,
|
||||
string Value,
|
||||
string SourcePath,
|
||||
string SourceType);
|
||||
@@ -0,0 +1,447 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects Python startup hooks that run before or during interpreter initialization.
|
||||
/// These include sitecustomize.py, usercustomize.py, and .pth files.
|
||||
/// </summary>
|
||||
internal static class PythonStartupHookDetector
|
||||
{
|
||||
private static readonly string[] StartupHookFiles =
|
||||
{
|
||||
"sitecustomize.py",
|
||||
"usercustomize.py"
|
||||
};
|
||||
|
||||
private static readonly string[] SitePackagesPatterns = { "site-packages", "dist-packages" };
|
||||
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
|
||||
|
||||
/// <summary>
|
||||
/// Detects startup hooks in the given root path.
|
||||
/// </summary>
|
||||
public static PythonStartupHooks Detect(string rootPath)
|
||||
{
|
||||
var hooks = new List<PythonStartupHook>();
|
||||
var pthFiles = new List<PythonPthFile>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Search in site-packages directories
|
||||
foreach (var sitePackages in DiscoverSitePackagesDirectories(rootPath))
|
||||
{
|
||||
DetectInSitePackages(sitePackages, hooks, pthFiles, warnings);
|
||||
}
|
||||
|
||||
// Search in Python lib directories
|
||||
foreach (var libPath in DiscoverPythonLibDirectories(rootPath))
|
||||
{
|
||||
DetectInPythonLib(libPath, hooks, warnings);
|
||||
}
|
||||
|
||||
// Search in container layers
|
||||
foreach (var layerRoot in EnumerateLayerRoots(rootPath))
|
||||
{
|
||||
foreach (var sitePackages in DiscoverSitePackagesDirectories(layerRoot))
|
||||
{
|
||||
DetectInSitePackages(sitePackages, hooks, pthFiles, warnings);
|
||||
}
|
||||
|
||||
foreach (var libPath in DiscoverPythonLibDirectories(layerRoot))
|
||||
{
|
||||
DetectInPythonLib(libPath, hooks, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate warnings for detected hooks
|
||||
if (hooks.Count > 0)
|
||||
{
|
||||
var siteCustomize = hooks.Where(h => h.HookType == StartupHookType.SiteCustomize).ToArray();
|
||||
var userCustomize = hooks.Where(h => h.HookType == StartupHookType.UserCustomize).ToArray();
|
||||
|
||||
if (siteCustomize.Length > 0)
|
||||
{
|
||||
warnings.Add($"sitecustomize.py detected in {siteCustomize.Length} location(s); runs on every Python interpreter start");
|
||||
}
|
||||
|
||||
if (userCustomize.Length > 0)
|
||||
{
|
||||
warnings.Add($"usercustomize.py detected in {userCustomize.Length} location(s); runs on every Python interpreter start");
|
||||
}
|
||||
}
|
||||
|
||||
if (pthFiles.Count > 0)
|
||||
{
|
||||
var importPth = pthFiles.Where(p => p.HasImportDirective).ToArray();
|
||||
if (importPth.Length > 0)
|
||||
{
|
||||
warnings.Add($"{importPth.Length} .pth file(s) with import directives detected; may execute code on startup");
|
||||
}
|
||||
}
|
||||
|
||||
return new PythonStartupHooks(
|
||||
hooks
|
||||
.OrderBy(h => h.Path, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
pthFiles
|
||||
.OrderBy(p => p.Path, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
warnings);
|
||||
}
|
||||
|
||||
private static void DetectInSitePackages(
|
||||
string sitePackages,
|
||||
List<PythonStartupHook> hooks,
|
||||
List<PythonPthFile> pthFiles,
|
||||
List<string> warnings)
|
||||
{
|
||||
if (!Directory.Exists(sitePackages))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for sitecustomize.py and usercustomize.py
|
||||
foreach (var hookFile in StartupHookFiles)
|
||||
{
|
||||
var hookPath = Path.Combine(sitePackages, hookFile);
|
||||
if (File.Exists(hookPath))
|
||||
{
|
||||
var hookType = hookFile.StartsWith("site", StringComparison.OrdinalIgnoreCase)
|
||||
? StartupHookType.SiteCustomize
|
||||
: StartupHookType.UserCustomize;
|
||||
|
||||
hooks.Add(new PythonStartupHook(hookPath, hookType, "site-packages"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .pth files
|
||||
try
|
||||
{
|
||||
foreach (var pthFile in Directory.EnumerateFiles(sitePackages, "*.pth"))
|
||||
{
|
||||
var pthInfo = AnalyzePthFile(pthFile);
|
||||
pthFiles.Add(pthInfo);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore enumeration errors
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
}
|
||||
|
||||
private static void DetectInPythonLib(string libPath, List<PythonStartupHook> hooks, List<string> warnings)
|
||||
{
|
||||
if (!Directory.Exists(libPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for sitecustomize.py in the lib directory itself
|
||||
foreach (var hookFile in StartupHookFiles)
|
||||
{
|
||||
var hookPath = Path.Combine(libPath, hookFile);
|
||||
if (File.Exists(hookPath))
|
||||
{
|
||||
var hookType = hookFile.StartsWith("site", StringComparison.OrdinalIgnoreCase)
|
||||
? StartupHookType.SiteCustomize
|
||||
: StartupHookType.UserCustomize;
|
||||
|
||||
hooks.Add(new PythonStartupHook(hookPath, hookType, "python-lib"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PythonPthFile AnalyzePthFile(string path)
|
||||
{
|
||||
var hasImportDirective = false;
|
||||
var pathEntries = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(path);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lines starting with 'import ' execute Python code
|
||||
if (trimmed.StartsWith("import ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hasImportDirective = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
pathEntries.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
|
||||
return new PythonPthFile(path, Path.GetFileName(path), hasImportDirective, pathEntries);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DiscoverSitePackagesDirectories(string rootPath)
|
||||
{
|
||||
if (!Directory.Exists(rootPath))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var pending = new Stack<(string Path, int Depth)>();
|
||||
pending.Push((rootPath, 0));
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var (current, depth) = pending.Pop();
|
||||
var dirName = Path.GetFileName(current);
|
||||
|
||||
if (SitePackagesPatterns.Contains(dirName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return current;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth >= 6)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string>? subdirs = null;
|
||||
try
|
||||
{
|
||||
subdirs = Directory.EnumerateDirectories(current);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var subdir in subdirs)
|
||||
{
|
||||
var subdirName = Path.GetFileName(subdir);
|
||||
// Skip common non-Python directories
|
||||
if (subdirName.StartsWith('.') ||
|
||||
string.Equals(subdirName, "node_modules", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(subdirName, "__pycache__", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.Push((subdir, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DiscoverPythonLibDirectories(string rootPath)
|
||||
{
|
||||
var libPaths = new[]
|
||||
{
|
||||
Path.Combine(rootPath, "usr", "lib"),
|
||||
Path.Combine(rootPath, "usr", "local", "lib"),
|
||||
Path.Combine(rootPath, "lib")
|
||||
};
|
||||
|
||||
foreach (var libPath in libPaths)
|
||||
{
|
||||
if (!Directory.Exists(libPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories = null;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(libPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
var dirName = Path.GetFileName(directory);
|
||||
if (dirName.StartsWith("python", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return directory;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
|
||||
{
|
||||
foreach (var candidate in LayerRootCandidates)
|
||||
{
|
||||
var root = Path.Combine(workspaceRoot, candidate);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories = null;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(root);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var layerDirectory in directories)
|
||||
{
|
||||
var fsDirectory = Path.Combine(layerDirectory, "fs");
|
||||
if (Directory.Exists(fsDirectory))
|
||||
{
|
||||
yield return fsDirectory;
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return layerDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of Python startup hook.
|
||||
/// </summary>
|
||||
internal enum StartupHookType
|
||||
{
|
||||
SiteCustomize,
|
||||
UserCustomize
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected Python startup hook file.
|
||||
/// </summary>
|
||||
internal sealed record PythonStartupHook(
|
||||
string Path,
|
||||
StartupHookType HookType,
|
||||
string Location);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected .pth file.
|
||||
/// </summary>
|
||||
internal sealed record PythonPthFile(
|
||||
string Path,
|
||||
string FileName,
|
||||
bool HasImportDirective,
|
||||
IReadOnlyCollection<string> PathEntries);
|
||||
|
||||
/// <summary>
|
||||
/// Collection of detected Python startup hooks.
|
||||
/// </summary>
|
||||
internal sealed class PythonStartupHooks
|
||||
{
|
||||
public PythonStartupHooks(
|
||||
IReadOnlyCollection<PythonStartupHook> hooks,
|
||||
IReadOnlyCollection<PythonPthFile> pthFiles,
|
||||
IReadOnlyCollection<string> warnings)
|
||||
{
|
||||
Hooks = hooks;
|
||||
PthFiles = pthFiles;
|
||||
Warnings = warnings;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<PythonStartupHook> Hooks { get; }
|
||||
public IReadOnlyCollection<PythonPthFile> PthFiles { get; }
|
||||
public IReadOnlyCollection<string> Warnings { get; }
|
||||
|
||||
public bool HasStartupHooks => Hooks.Count > 0;
|
||||
public bool HasPthFilesWithImports => PthFiles.Any(p => p.HasImportDirective);
|
||||
public bool HasWarnings => Warnings.Count > 0;
|
||||
|
||||
public IReadOnlyCollection<KeyValuePair<string, string?>> ToMetadata()
|
||||
{
|
||||
var entries = new List<KeyValuePair<string, string?>>();
|
||||
|
||||
if (Hooks.Count > 0)
|
||||
{
|
||||
var siteCustomize = Hooks.Where(h => h.HookType == StartupHookType.SiteCustomize).ToArray();
|
||||
var userCustomize = Hooks.Where(h => h.HookType == StartupHookType.UserCustomize).ToArray();
|
||||
|
||||
if (siteCustomize.Length > 0)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("startupHooks.sitecustomize.count", siteCustomize.Length.ToString()));
|
||||
entries.Add(new KeyValuePair<string, string?>("startupHooks.sitecustomize.paths", string.Join(';', siteCustomize.Select(h => h.Path))));
|
||||
}
|
||||
|
||||
if (userCustomize.Length > 0)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("startupHooks.usercustomize.count", userCustomize.Length.ToString()));
|
||||
entries.Add(new KeyValuePair<string, string?>("startupHooks.usercustomize.paths", string.Join(';', userCustomize.Select(h => h.Path))));
|
||||
}
|
||||
}
|
||||
|
||||
if (PthFiles.Count > 0)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("pthFiles.count", PthFiles.Count.ToString()));
|
||||
|
||||
var withImports = PthFiles.Where(p => p.HasImportDirective).ToArray();
|
||||
if (withImports.Length > 0)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>("pthFiles.withImports.count", withImports.Length.ToString()));
|
||||
entries.Add(new KeyValuePair<string, string?>("pthFiles.withImports.files", string.Join(';', withImports.Select(p => p.FileName))));
|
||||
}
|
||||
}
|
||||
|
||||
if (Warnings.Count > 0)
|
||||
{
|
||||
for (var i = 0; i < Warnings.Count; i++)
|
||||
{
|
||||
entries.Add(new KeyValuePair<string, string?>($"startupHooks.warning[{i}]", Warnings.ElementAt(i)));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<LanguageComponentEvidence> ToEvidence(LanguageAnalyzerContext context)
|
||||
{
|
||||
var evidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
foreach (var hook in Hooks)
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
hook.HookType == StartupHookType.SiteCustomize ? "sitecustomize" : "usercustomize",
|
||||
PythonPathHelper.NormalizeRelative(context, hook.Path),
|
||||
Value: null,
|
||||
Sha256: null));
|
||||
}
|
||||
|
||||
foreach (var pthFile in PthFiles.Where(p => p.HasImportDirective))
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"pth-import",
|
||||
PythonPathHelper.NormalizeRelative(context, pthFile.Path),
|
||||
Value: null,
|
||||
Sha256: null));
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving a module name to a file location.
|
||||
/// </summary>
|
||||
internal enum PythonResolutionKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Regular module (.py file).
|
||||
/// </summary>
|
||||
SourceModule,
|
||||
|
||||
/// <summary>
|
||||
/// Compiled module (.pyc file).
|
||||
/// </summary>
|
||||
BytecodeModule,
|
||||
|
||||
/// <summary>
|
||||
/// Regular package (__init__.py exists).
|
||||
/// </summary>
|
||||
Package,
|
||||
|
||||
/// <summary>
|
||||
/// Namespace package (PEP 420, no __init__.py).
|
||||
/// </summary>
|
||||
NamespacePackage,
|
||||
|
||||
/// <summary>
|
||||
/// Built-in module (part of Python interpreter).
|
||||
/// </summary>
|
||||
BuiltinModule,
|
||||
|
||||
/// <summary>
|
||||
/// Frozen module (compiled into Python binary).
|
||||
/// </summary>
|
||||
FrozenModule,
|
||||
|
||||
/// <summary>
|
||||
/// Extension module (.so, .pyd).
|
||||
/// </summary>
|
||||
ExtensionModule,
|
||||
|
||||
/// <summary>
|
||||
/// Module from a zip archive.
|
||||
/// </summary>
|
||||
ZipModule,
|
||||
|
||||
/// <summary>
|
||||
/// Module could not be resolved.
|
||||
/// </summary>
|
||||
NotFound
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for module resolution.
|
||||
/// </summary>
|
||||
internal enum PythonResolutionConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Low confidence - heuristic or partial match.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence - likely correct but unverified.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence - clear match.
|
||||
/// </summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Definitive - exact file found.
|
||||
/// </summary>
|
||||
Definitive = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the resolution of a Python module name to its location.
|
||||
/// </summary>
|
||||
/// <param name="ModuleName">The fully qualified module name.</param>
|
||||
/// <param name="Kind">The type of resolution.</param>
|
||||
/// <param name="VirtualPath">The resolved virtual path (null if not found).</param>
|
||||
/// <param name="AbsolutePath">The resolved absolute path (null if not found).</param>
|
||||
/// <param name="SearchPath">The search path entry that matched.</param>
|
||||
/// <param name="Source">The source type of the file.</param>
|
||||
/// <param name="Confidence">Confidence level of the resolution.</param>
|
||||
/// <param name="NamespacePaths">For namespace packages, the contributing paths.</param>
|
||||
/// <param name="ResolverTrace">Trace of resolution steps for debugging.</param>
|
||||
internal sealed record PythonModuleResolution(
|
||||
string ModuleName,
|
||||
PythonResolutionKind Kind,
|
||||
string? VirtualPath,
|
||||
string? AbsolutePath,
|
||||
string? SearchPath,
|
||||
PythonFileSource Source,
|
||||
PythonResolutionConfidence Confidence,
|
||||
IReadOnlyList<string>? NamespacePaths = null,
|
||||
IReadOnlyList<string>? ResolverTrace = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the module was successfully resolved.
|
||||
/// </summary>
|
||||
public bool IsResolved => Kind != PythonResolutionKind.NotFound;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a package (regular or namespace).
|
||||
/// </summary>
|
||||
public bool IsPackage => Kind is PythonResolutionKind.Package or PythonResolutionKind.NamespacePackage;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a namespace package.
|
||||
/// </summary>
|
||||
public bool IsNamespacePackage => Kind == PythonResolutionKind.NamespacePackage;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a built-in or frozen module.
|
||||
/// </summary>
|
||||
public bool IsBuiltin => Kind is PythonResolutionKind.BuiltinModule or PythonResolutionKind.FrozenModule;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent module name.
|
||||
/// </summary>
|
||||
public string? ParentModule
|
||||
{
|
||||
get
|
||||
{
|
||||
var lastDot = ModuleName.LastIndexOf('.');
|
||||
return lastDot < 0 ? null : ModuleName[..lastDot];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the simple name (last component).
|
||||
/// </summary>
|
||||
public string SimpleName
|
||||
{
|
||||
get
|
||||
{
|
||||
var lastDot = ModuleName.LastIndexOf('.');
|
||||
return lastDot < 0 ? ModuleName : ModuleName[(lastDot + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a not-found resolution.
|
||||
/// </summary>
|
||||
public static PythonModuleResolution NotFound(string moduleName, IReadOnlyList<string>? trace = null) =>
|
||||
new(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.NotFound,
|
||||
VirtualPath: null,
|
||||
AbsolutePath: null,
|
||||
SearchPath: null,
|
||||
Source: PythonFileSource.Unknown,
|
||||
Confidence: PythonResolutionConfidence.Low,
|
||||
NamespacePaths: null,
|
||||
ResolverTrace: trace);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a built-in module resolution.
|
||||
/// </summary>
|
||||
public static PythonModuleResolution Builtin(string moduleName) =>
|
||||
new(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.BuiltinModule,
|
||||
VirtualPath: null,
|
||||
AbsolutePath: null,
|
||||
SearchPath: null,
|
||||
Source: PythonFileSource.Unknown,
|
||||
Confidence: PythonResolutionConfidence.Definitive,
|
||||
NamespacePaths: null,
|
||||
ResolverTrace: null);
|
||||
|
||||
/// <summary>
|
||||
/// Generates metadata entries for this resolution.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> ToMetadata(string prefix)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.module", ModuleName);
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.kind", Kind.ToString());
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.confidence", Confidence.ToString());
|
||||
|
||||
if (VirtualPath is not null)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.path", VirtualPath);
|
||||
}
|
||||
|
||||
if (SearchPath is not null)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.searchPath", SearchPath);
|
||||
}
|
||||
|
||||
if (NamespacePaths is { Count: > 0 })
|
||||
{
|
||||
var paths = string.Join(';', NamespacePaths);
|
||||
yield return new KeyValuePair<string, string?>($"{prefix}.namespacePaths", paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search path entry for module resolution.
|
||||
/// </summary>
|
||||
/// <param name="Path">The search path.</param>
|
||||
/// <param name="Priority">Priority (lower = higher priority).</param>
|
||||
/// <param name="Kind">The kind of path entry.</param>
|
||||
/// <param name="FromPthFile">The .pth file that added this path (if any).</param>
|
||||
internal sealed record PythonSearchPath(
|
||||
string Path,
|
||||
int Priority,
|
||||
PythonSearchPathKind Kind,
|
||||
string? FromPthFile = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether this path is from a .pth file.
|
||||
/// </summary>
|
||||
public bool IsFromPthFile => FromPthFile is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kind of search path entry.
|
||||
/// </summary>
|
||||
internal enum PythonSearchPathKind
|
||||
{
|
||||
/// <summary>
|
||||
/// The script's directory or current working directory.
|
||||
/// </summary>
|
||||
ScriptDirectory,
|
||||
|
||||
/// <summary>
|
||||
/// PYTHONPATH environment variable.
|
||||
/// </summary>
|
||||
PythonPath,
|
||||
|
||||
/// <summary>
|
||||
/// Site-packages directory.
|
||||
/// </summary>
|
||||
SitePackages,
|
||||
|
||||
/// <summary>
|
||||
/// Standard library path.
|
||||
/// </summary>
|
||||
StandardLibrary,
|
||||
|
||||
/// <summary>
|
||||
/// User site-packages (--user installations).
|
||||
/// </summary>
|
||||
UserSitePackages,
|
||||
|
||||
/// <summary>
|
||||
/// Path added via .pth file.
|
||||
/// </summary>
|
||||
PthFile,
|
||||
|
||||
/// <summary>
|
||||
/// Zip archive path.
|
||||
/// </summary>
|
||||
ZipArchive,
|
||||
|
||||
/// <summary>
|
||||
/// Editable install path.
|
||||
/// </summary>
|
||||
EditableInstall,
|
||||
|
||||
/// <summary>
|
||||
/// Virtual environment path.
|
||||
/// </summary>
|
||||
VirtualEnv,
|
||||
|
||||
/// <summary>
|
||||
/// Custom or unknown path.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves Python module names to file locations following importlib semantics.
|
||||
/// Supports namespace packages (PEP 420), .pth files, zipimport, and site-packages precedence.
|
||||
/// </summary>
|
||||
internal sealed partial class PythonModuleResolver
|
||||
{
|
||||
private readonly PythonVirtualFileSystem _vfs;
|
||||
private readonly string _rootPath;
|
||||
private readonly List<PythonSearchPath> _searchPaths = new();
|
||||
private readonly Dictionary<string, PythonModuleResolution> _cache = new(StringComparer.Ordinal);
|
||||
private readonly bool _enableTracing;
|
||||
|
||||
// Built-in modules that exist in the Python interpreter
|
||||
private static readonly FrozenSet<string> BuiltinModules = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"sys", "builtins", "_abc", "_ast", "_bisect", "_blake2", "_codecs",
|
||||
"_collections", "_datetime", "_elementtree", "_functools", "_heapq",
|
||||
"_imp", "_io", "_json", "_locale", "_lsprof", "_md5", "_operator",
|
||||
"_pickle", "_posixsubprocess", "_random", "_sha1", "_sha256", "_sha512",
|
||||
"_sha3", "_signal", "_socket", "_sre", "_stat", "_statistics", "_string",
|
||||
"_struct", "_symtable", "_thread", "_tracemalloc", "_typing", "_warnings",
|
||||
"_weakref", "array", "atexit", "binascii", "cmath", "errno", "faulthandler",
|
||||
"gc", "itertools", "marshal", "math", "posix", "pwd", "select", "time",
|
||||
"unicodedata", "xxsubtype", "zlib"
|
||||
}.ToFrozenSet();
|
||||
|
||||
// Standard library packages (top-level)
|
||||
private static readonly FrozenSet<string> StandardLibraryModules = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"abc", "aifc", "argparse", "array", "ast", "asynchat", "asyncio",
|
||||
"asyncore", "atexit", "audioop", "base64", "bdb", "binascii", "binhex",
|
||||
"bisect", "builtins", "bz2", "calendar", "cgi", "cgitb", "chunk",
|
||||
"cmath", "cmd", "code", "codecs", "codeop", "collections", "colorsys",
|
||||
"compileall", "concurrent", "configparser", "contextlib", "contextvars",
|
||||
"copy", "copyreg", "cProfile", "crypt", "csv", "ctypes", "curses",
|
||||
"dataclasses", "datetime", "dbm", "decimal", "difflib", "dis",
|
||||
"distutils", "doctest", "email", "encodings", "enum", "errno",
|
||||
"faulthandler", "fcntl", "filecmp", "fileinput", "fnmatch", "fractions",
|
||||
"ftplib", "functools", "gc", "getopt", "getpass", "gettext", "glob",
|
||||
"graphlib", "grp", "gzip", "hashlib", "heapq", "hmac", "html", "http",
|
||||
"idlelib", "imaplib", "imghdr", "imp", "importlib", "inspect", "io",
|
||||
"ipaddress", "itertools", "json", "keyword", "lib2to3", "linecache",
|
||||
"locale", "logging", "lzma", "mailbox", "mailcap", "marshal", "math",
|
||||
"mimetypes", "mmap", "modulefinder", "multiprocessing", "netrc", "nis",
|
||||
"nntplib", "numbers", "operator", "optparse", "os", "ossaudiodev",
|
||||
"pathlib", "pdb", "pickle", "pickletools", "pipes", "pkgutil", "platform",
|
||||
"plistlib", "poplib", "posix", "posixpath", "pprint", "profile", "pstats",
|
||||
"pty", "pwd", "py_compile", "pyclbr", "pydoc", "queue", "quopri",
|
||||
"random", "re", "readline", "reprlib", "resource", "rlcompleter",
|
||||
"runpy", "sched", "secrets", "select", "selectors", "shelve", "shlex",
|
||||
"shutil", "signal", "site", "smtpd", "smtplib", "sndhdr", "socket",
|
||||
"socketserver", "spwd", "sqlite3", "ssl", "stat", "statistics", "string",
|
||||
"stringprep", "struct", "subprocess", "sunau", "symtable", "sys",
|
||||
"sysconfig", "syslog", "tabnanny", "tarfile", "telnetlib", "tempfile",
|
||||
"termios", "test", "textwrap", "threading", "time", "timeit", "tkinter",
|
||||
"token", "tokenize", "tomllib", "trace", "traceback", "tracemalloc",
|
||||
"tty", "turtle", "turtledemo", "types", "typing", "unicodedata",
|
||||
"unittest", "urllib", "uu", "uuid", "venv", "warnings", "wave",
|
||||
"weakref", "webbrowser", "winreg", "winsound", "wsgiref", "xdrlib",
|
||||
"xml", "xmlrpc", "zipapp", "zipfile", "zipimport", "zlib", "zoneinfo",
|
||||
"_thread"
|
||||
}.ToFrozenSet();
|
||||
|
||||
public PythonModuleResolver(PythonVirtualFileSystem vfs, string rootPath, bool enableTracing = false)
|
||||
{
|
||||
_vfs = vfs ?? throw new ArgumentNullException(nameof(vfs));
|
||||
_rootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
|
||||
_enableTracing = enableTracing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured search paths.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonSearchPath> SearchPaths => _searchPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the resolver by building search paths from the VFS.
|
||||
/// </summary>
|
||||
public async Task<PythonModuleResolver> InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_searchPaths.Clear();
|
||||
_cache.Clear();
|
||||
|
||||
// Build search paths from VFS
|
||||
BuildSearchPaths();
|
||||
|
||||
// Process .pth files
|
||||
await ProcessPthFilesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Sort by priority
|
||||
_searchPaths.Sort((a, b) => a.Priority.CompareTo(b.Priority));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a module name to its location.
|
||||
/// </summary>
|
||||
public PythonModuleResolution Resolve(string moduleName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(moduleName))
|
||||
{
|
||||
return PythonModuleResolution.NotFound(moduleName ?? string.Empty);
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (_cache.TryGetValue(moduleName, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var trace = _enableTracing ? new List<string>() : null;
|
||||
|
||||
// Check for built-in modules
|
||||
if (IsBuiltinModule(moduleName))
|
||||
{
|
||||
var builtin = PythonModuleResolution.Builtin(moduleName);
|
||||
_cache[moduleName] = builtin;
|
||||
return builtin;
|
||||
}
|
||||
|
||||
// Resolve using search paths
|
||||
var resolution = ResolveFromSearchPaths(moduleName, trace);
|
||||
_cache[moduleName] = resolution;
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a relative import from a given package context.
|
||||
/// </summary>
|
||||
public PythonModuleResolution ResolveRelative(
|
||||
string moduleName,
|
||||
int relativeLevel,
|
||||
string fromModule)
|
||||
{
|
||||
if (relativeLevel <= 0)
|
||||
{
|
||||
return Resolve(moduleName);
|
||||
}
|
||||
|
||||
// Calculate the base package
|
||||
var fromParts = fromModule.Split('.');
|
||||
|
||||
// Check if fromModule is a package or module
|
||||
var fromResolution = Resolve(fromModule);
|
||||
var isFromPackage = fromResolution.IsPackage;
|
||||
|
||||
// For relative imports from a module, we start from its parent package
|
||||
var effectiveLevel = isFromPackage ? relativeLevel : relativeLevel;
|
||||
var baseParts = fromParts.Length;
|
||||
|
||||
// Adjust for non-package modules
|
||||
if (!isFromPackage && baseParts > 0)
|
||||
{
|
||||
baseParts--;
|
||||
}
|
||||
|
||||
if (effectiveLevel > baseParts)
|
||||
{
|
||||
return PythonModuleResolution.NotFound($"relative import beyond top-level package");
|
||||
}
|
||||
|
||||
var basePackageParts = fromParts[..^effectiveLevel];
|
||||
var basePackage = string.Join('.', basePackageParts);
|
||||
|
||||
// Build the absolute module name
|
||||
var absoluteName = string.IsNullOrEmpty(moduleName)
|
||||
? basePackage
|
||||
: string.IsNullOrEmpty(basePackage)
|
||||
? moduleName
|
||||
: $"{basePackage}.{moduleName}";
|
||||
|
||||
return Resolve(absoluteName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a module is a standard library module.
|
||||
/// </summary>
|
||||
public static bool IsStandardLibraryModule(string moduleName)
|
||||
{
|
||||
var topLevel = moduleName.Split('.')[0];
|
||||
return StandardLibraryModules.Contains(topLevel) || BuiltinModules.Contains(topLevel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a module is a built-in module.
|
||||
/// </summary>
|
||||
public static bool IsBuiltinModule(string moduleName)
|
||||
{
|
||||
return BuiltinModules.Contains(moduleName);
|
||||
}
|
||||
|
||||
private void BuildSearchPaths()
|
||||
{
|
||||
var priority = 0;
|
||||
|
||||
// 1. Script directory / source tree roots
|
||||
foreach (var root in _vfs.SourceTreeRoots)
|
||||
{
|
||||
_searchPaths.Add(new PythonSearchPath(
|
||||
Path: root,
|
||||
Priority: priority++,
|
||||
Kind: PythonSearchPathKind.ScriptDirectory));
|
||||
}
|
||||
|
||||
// 2. Site-packages directories
|
||||
foreach (var sitePackages in _vfs.SitePackagesPaths)
|
||||
{
|
||||
_searchPaths.Add(new PythonSearchPath(
|
||||
Path: sitePackages,
|
||||
Priority: priority + 100, // Site-packages comes after script directory
|
||||
Kind: PythonSearchPathKind.SitePackages));
|
||||
}
|
||||
|
||||
// 3. Editable install locations
|
||||
foreach (var editable in _vfs.EditablePaths)
|
||||
{
|
||||
_searchPaths.Add(new PythonSearchPath(
|
||||
Path: editable,
|
||||
Priority: priority + 50, // Between script dir and site-packages
|
||||
Kind: PythonSearchPathKind.EditableInstall));
|
||||
}
|
||||
|
||||
// 4. Zip archives
|
||||
foreach (var archive in _vfs.ZipArchivePaths)
|
||||
{
|
||||
_searchPaths.Add(new PythonSearchPath(
|
||||
Path: archive,
|
||||
Priority: priority + 200,
|
||||
Kind: PythonSearchPathKind.ZipArchive));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessPthFilesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Find all .pth files in site-packages
|
||||
var pthFiles = _vfs.EnumerateFiles(string.Empty, "*.pth")
|
||||
.Where(f => f.Source == PythonFileSource.SitePackages);
|
||||
|
||||
foreach (var pthFile in pthFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await ReadPthFileAsync(pthFile, cancellationToken).ConfigureAwait(false);
|
||||
ProcessPthFileLines(lines, pthFile.VirtualPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string[]> ReadPthFileAsync(PythonVirtualFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file.IsFromArchive)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var absolutePath = Path.Combine(_rootPath, file.AbsolutePath);
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
absolutePath = file.AbsolutePath;
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(absolutePath, cancellationToken).ConfigureAwait(false);
|
||||
return content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private void ProcessPthFileLines(string[] lines, string pthFilePath)
|
||||
{
|
||||
var pthDirectory = Path.GetDirectoryName(pthFilePath) ?? string.Empty;
|
||||
var priority = 150; // Between editable installs and site-packages
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lines starting with "import " are executed but we skip them for static analysis
|
||||
if (line.StartsWith("import ", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The line is a path to add
|
||||
var path = line;
|
||||
|
||||
// If relative, resolve relative to the .pth file's directory
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
path = Path.Combine(pthDirectory, path);
|
||||
}
|
||||
|
||||
_searchPaths.Add(new PythonSearchPath(
|
||||
Path: path.Replace('\\', '/'),
|
||||
Priority: priority++,
|
||||
Kind: PythonSearchPathKind.PthFile,
|
||||
FromPthFile: pthFilePath));
|
||||
}
|
||||
}
|
||||
|
||||
private PythonModuleResolution ResolveFromSearchPaths(string moduleName, List<string>? trace)
|
||||
{
|
||||
var parts = moduleName.Split('.');
|
||||
var namespacePaths = new List<string>();
|
||||
|
||||
foreach (var searchPath in _searchPaths)
|
||||
{
|
||||
trace?.Add($"Searching in {searchPath.Kind}: {searchPath.Path}");
|
||||
|
||||
// Try to resolve the full module path
|
||||
var resolution = TryResolveInPath(moduleName, parts, searchPath, trace);
|
||||
|
||||
if (resolution is not null)
|
||||
{
|
||||
if (resolution.Kind == PythonResolutionKind.NamespacePackage)
|
||||
{
|
||||
// Collect namespace package paths
|
||||
if (resolution.VirtualPath is not null)
|
||||
{
|
||||
namespacePaths.Add(resolution.VirtualPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return resolution;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found namespace package contributions, return a namespace package resolution
|
||||
if (namespacePaths.Count > 0)
|
||||
{
|
||||
return new PythonModuleResolution(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.NamespacePackage,
|
||||
VirtualPath: namespacePaths[0],
|
||||
AbsolutePath: null,
|
||||
SearchPath: _searchPaths[0].Path,
|
||||
Source: PythonFileSource.SitePackages,
|
||||
Confidence: PythonResolutionConfidence.High,
|
||||
NamespacePaths: namespacePaths,
|
||||
ResolverTrace: trace);
|
||||
}
|
||||
|
||||
return PythonModuleResolution.NotFound(moduleName, trace);
|
||||
}
|
||||
|
||||
private PythonModuleResolution? TryResolveInPath(
|
||||
string moduleName,
|
||||
string[] parts,
|
||||
PythonSearchPath searchPath,
|
||||
List<string>? trace)
|
||||
{
|
||||
var basePath = searchPath.Path;
|
||||
var currentPath = basePath;
|
||||
|
||||
// Walk through the module path parts
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
var isLast = i == parts.Length - 1;
|
||||
|
||||
if (isLast)
|
||||
{
|
||||
// This is the final component - could be a module or package
|
||||
return TryResolveFinalComponent(moduleName, currentPath, part, searchPath, trace);
|
||||
}
|
||||
else
|
||||
{
|
||||
// This is an intermediate package
|
||||
var packagePath = $"{currentPath}/{part}";
|
||||
|
||||
// Check for regular package
|
||||
var initPath = $"{packagePath}/__init__.py";
|
||||
if (_vfs.FileExists(initPath))
|
||||
{
|
||||
currentPath = packagePath;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for namespace package (PEP 420) - directory exists but no __init__.py
|
||||
if (DirectoryExists(packagePath))
|
||||
{
|
||||
currentPath = packagePath;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Package not found
|
||||
trace?.Add($" Package not found: {packagePath}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private PythonModuleResolution? TryResolveFinalComponent(
|
||||
string moduleName,
|
||||
string currentPath,
|
||||
string name,
|
||||
PythonSearchPath searchPath,
|
||||
List<string>? trace)
|
||||
{
|
||||
// 1. Try as a module: name.py
|
||||
var modulePath = $"{currentPath}/{name}.py";
|
||||
if (_vfs.FileExists(modulePath))
|
||||
{
|
||||
trace?.Add($" Found module: {modulePath}");
|
||||
var file = _vfs.GetFile(modulePath);
|
||||
return new PythonModuleResolution(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.SourceModule,
|
||||
VirtualPath: modulePath,
|
||||
AbsolutePath: file?.AbsolutePath,
|
||||
SearchPath: searchPath.Path,
|
||||
Source: file?.Source ?? PythonFileSource.Unknown,
|
||||
Confidence: PythonResolutionConfidence.Definitive,
|
||||
ResolverTrace: trace);
|
||||
}
|
||||
|
||||
// 2. Try as a bytecode module: name.pyc
|
||||
var bytecodePath = $"{currentPath}/{name}.pyc";
|
||||
if (_vfs.FileExists(bytecodePath))
|
||||
{
|
||||
trace?.Add($" Found bytecode: {bytecodePath}");
|
||||
var file = _vfs.GetFile(bytecodePath);
|
||||
return new PythonModuleResolution(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.BytecodeModule,
|
||||
VirtualPath: bytecodePath,
|
||||
AbsolutePath: file?.AbsolutePath,
|
||||
SearchPath: searchPath.Path,
|
||||
Source: file?.Source ?? PythonFileSource.Unknown,
|
||||
Confidence: PythonResolutionConfidence.High,
|
||||
ResolverTrace: trace);
|
||||
}
|
||||
|
||||
// 3. Try as an extension module: name.so, name.pyd
|
||||
var soPath = $"{currentPath}/{name}.so";
|
||||
var pydPath = $"{currentPath}/{name}.pyd";
|
||||
if (_vfs.FileExists(soPath) || _vfs.FileExists(pydPath))
|
||||
{
|
||||
var extPath = _vfs.FileExists(soPath) ? soPath : pydPath;
|
||||
trace?.Add($" Found extension: {extPath}");
|
||||
var file = _vfs.GetFile(extPath);
|
||||
return new PythonModuleResolution(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.ExtensionModule,
|
||||
VirtualPath: extPath,
|
||||
AbsolutePath: file?.AbsolutePath,
|
||||
SearchPath: searchPath.Path,
|
||||
Source: file?.Source ?? PythonFileSource.Unknown,
|
||||
Confidence: PythonResolutionConfidence.Definitive,
|
||||
ResolverTrace: trace);
|
||||
}
|
||||
|
||||
// 4. Try as a regular package: name/__init__.py
|
||||
var packagePath = $"{currentPath}/{name}";
|
||||
var packageInitPath = $"{packagePath}/__init__.py";
|
||||
if (_vfs.FileExists(packageInitPath))
|
||||
{
|
||||
trace?.Add($" Found package: {packageInitPath}");
|
||||
var file = _vfs.GetFile(packageInitPath);
|
||||
return new PythonModuleResolution(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.Package,
|
||||
VirtualPath: packageInitPath,
|
||||
AbsolutePath: file?.AbsolutePath,
|
||||
SearchPath: searchPath.Path,
|
||||
Source: file?.Source ?? PythonFileSource.Unknown,
|
||||
Confidence: PythonResolutionConfidence.Definitive,
|
||||
ResolverTrace: trace);
|
||||
}
|
||||
|
||||
// 5. Try as a namespace package (PEP 420): directory exists but no __init__.py
|
||||
if (DirectoryExists(packagePath))
|
||||
{
|
||||
trace?.Add($" Found namespace package: {packagePath}");
|
||||
return new PythonModuleResolution(
|
||||
ModuleName: moduleName,
|
||||
Kind: PythonResolutionKind.NamespacePackage,
|
||||
VirtualPath: packagePath,
|
||||
AbsolutePath: null,
|
||||
SearchPath: searchPath.Path,
|
||||
Source: PythonFileSource.Unknown,
|
||||
Confidence: PythonResolutionConfidence.High,
|
||||
ResolverTrace: trace);
|
||||
}
|
||||
|
||||
trace?.Add($" Not found in {currentPath}/{name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool DirectoryExists(string virtualPath)
|
||||
{
|
||||
// Check if any file exists under this path
|
||||
return _vfs.EnumerateFiles(virtualPath, "*").Any();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the resolution cache.
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets resolution statistics.
|
||||
/// </summary>
|
||||
public (int Total, int Resolved, int NotFound, int Cached) GetStatistics()
|
||||
{
|
||||
var total = _cache.Count;
|
||||
var resolved = _cache.Values.Count(r => r.IsResolved);
|
||||
var notFound = total - resolved;
|
||||
return (total, resolved, notFound, _cache.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the origin of a file in the Python virtual filesystem.
|
||||
/// </summary>
|
||||
internal enum PythonFileSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown or unresolved source.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// File from a site-packages directory (installed packages).
|
||||
/// </summary>
|
||||
SitePackages,
|
||||
|
||||
/// <summary>
|
||||
/// File from a wheel archive (.whl).
|
||||
/// </summary>
|
||||
Wheel,
|
||||
|
||||
/// <summary>
|
||||
/// File from a source distribution (.tar.gz, .zip).
|
||||
/// </summary>
|
||||
Sdist,
|
||||
|
||||
/// <summary>
|
||||
/// File from a zipapp (.pyz, .pyzw).
|
||||
/// </summary>
|
||||
Zipapp,
|
||||
|
||||
/// <summary>
|
||||
/// File from an editable install (development mode).
|
||||
/// </summary>
|
||||
Editable,
|
||||
|
||||
/// <summary>
|
||||
/// File from a container layer.
|
||||
/// </summary>
|
||||
ContainerLayer,
|
||||
|
||||
/// <summary>
|
||||
/// File from the project source tree.
|
||||
/// </summary>
|
||||
SourceTree,
|
||||
|
||||
/// <summary>
|
||||
/// File from a virtual environment's bin/Scripts directory.
|
||||
/// </summary>
|
||||
VenvBin,
|
||||
|
||||
/// <summary>
|
||||
/// Python configuration file (pyproject.toml, setup.py, setup.cfg).
|
||||
/// </summary>
|
||||
ProjectConfig,
|
||||
|
||||
/// <summary>
|
||||
/// Lock file (requirements.txt, Pipfile.lock, poetry.lock).
|
||||
/// </summary>
|
||||
LockFile,
|
||||
|
||||
/// <summary>
|
||||
/// Standard library module.
|
||||
/// </summary>
|
||||
StdLib
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes Python project inputs by detecting layouts, version targets,
|
||||
/// and building a virtual filesystem from various sources.
|
||||
/// </summary>
|
||||
internal sealed partial class PythonInputNormalizer
|
||||
{
|
||||
private static readonly EnumerationOptions SafeEnumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
private readonly string _rootPath;
|
||||
private readonly List<PythonVersionTarget> _versionTargets = new();
|
||||
private readonly List<string> _sitePackagesPaths = new();
|
||||
private readonly List<string> _wheelPaths = new();
|
||||
private readonly List<string> _zipappPaths = new();
|
||||
private readonly List<(string Path, string? PackageName)> _editablePaths = new();
|
||||
|
||||
private PythonLayoutKind _layout = PythonLayoutKind.Unknown;
|
||||
private string? _venvPath;
|
||||
private string? _binPath;
|
||||
private string? _pythonExecutable;
|
||||
|
||||
public PythonInputNormalizer(string rootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
throw new ArgumentException("Root path is required", nameof(rootPath));
|
||||
}
|
||||
|
||||
_rootPath = Path.GetFullPath(rootPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the detected layout kind.
|
||||
/// </summary>
|
||||
public PythonLayoutKind Layout => _layout;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all detected version targets.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonVersionTarget> VersionTargets => _versionTargets;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary version target (highest confidence).
|
||||
/// </summary>
|
||||
public PythonVersionTarget? PrimaryVersionTarget =>
|
||||
_versionTargets
|
||||
.OrderByDescending(static v => v.Confidence)
|
||||
.ThenBy(static v => v.Source, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all detected site-packages paths.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SitePackagesPaths => _sitePackagesPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the virtual environment path if detected.
|
||||
/// </summary>
|
||||
public string? VenvPath => _venvPath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bin/Scripts directory path if detected.
|
||||
/// </summary>
|
||||
public string? BinPath => _binPath;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes the root path to detect layout and version targets.
|
||||
/// </summary>
|
||||
public async Task<PythonInputNormalizer> AnalyzeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await DetectLayoutAsync(cancellationToken).ConfigureAwait(false);
|
||||
await DetectVersionTargetsAsync(cancellationToken).ConfigureAwait(false);
|
||||
DetectSitePackages();
|
||||
DetectWheels();
|
||||
DetectZipapps();
|
||||
await DetectEditablesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a virtual filesystem from the analyzed inputs.
|
||||
/// </summary>
|
||||
public PythonVirtualFileSystem BuildVirtualFileSystem()
|
||||
{
|
||||
var builder = PythonVirtualFileSystem.CreateBuilder();
|
||||
|
||||
// Add site-packages in order (later takes precedence)
|
||||
foreach (var sitePackagesPath in _sitePackagesPaths)
|
||||
{
|
||||
builder.AddSitePackages(sitePackagesPath);
|
||||
}
|
||||
|
||||
// Add wheels
|
||||
foreach (var wheelPath in _wheelPaths)
|
||||
{
|
||||
builder.AddWheel(wheelPath);
|
||||
}
|
||||
|
||||
// Add zipapps
|
||||
foreach (var zipappPath in _zipappPaths)
|
||||
{
|
||||
builder.AddZipapp(zipappPath);
|
||||
}
|
||||
|
||||
// Add editable installs
|
||||
foreach (var (path, packageName) in _editablePaths)
|
||||
{
|
||||
builder.AddEditable(path, packageName);
|
||||
}
|
||||
|
||||
// Add bin directory
|
||||
if (!string.IsNullOrEmpty(_binPath) && Directory.Exists(_binPath))
|
||||
{
|
||||
builder.AddVenvBin(_binPath);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private async Task DetectLayoutAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check for venv/virtualenv markers
|
||||
var pyvenvCfg = Path.Combine(_rootPath, "pyvenv.cfg");
|
||||
if (File.Exists(pyvenvCfg))
|
||||
{
|
||||
_layout = PythonLayoutKind.Virtualenv;
|
||||
_venvPath = _rootPath;
|
||||
DetectBinDirectory();
|
||||
await ParsePyvenvCfgAsync(pyvenvCfg, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for venv subdirectory
|
||||
foreach (var venvName in new[] { "venv", ".venv", "env", ".env" })
|
||||
{
|
||||
var venvDir = Path.Combine(_rootPath, venvName);
|
||||
var venvCfg = Path.Combine(venvDir, "pyvenv.cfg");
|
||||
if (File.Exists(venvCfg))
|
||||
{
|
||||
_layout = PythonLayoutKind.Virtualenv;
|
||||
_venvPath = venvDir;
|
||||
DetectBinDirectory();
|
||||
await ParsePyvenvCfgAsync(venvCfg, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Poetry
|
||||
if (File.Exists(Path.Combine(_rootPath, "poetry.lock")))
|
||||
{
|
||||
_layout = PythonLayoutKind.Poetry;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Pipenv
|
||||
if (File.Exists(Path.Combine(_rootPath, "Pipfile.lock")))
|
||||
{
|
||||
_layout = PythonLayoutKind.Pipenv;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Conda
|
||||
var condaMeta = Path.Combine(_rootPath, "conda-meta");
|
||||
if (Directory.Exists(condaMeta))
|
||||
{
|
||||
_layout = PythonLayoutKind.Conda;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Lambda
|
||||
if (File.Exists(Path.Combine(_rootPath, "lambda_function.py")) ||
|
||||
(File.Exists(Path.Combine(_rootPath, "requirements.txt")) &&
|
||||
Directory.Exists(Path.Combine(_rootPath, "python"))))
|
||||
{
|
||||
_layout = PythonLayoutKind.Lambda;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Azure Functions
|
||||
if (File.Exists(Path.Combine(_rootPath, "function.json")) ||
|
||||
File.Exists(Path.Combine(_rootPath, "host.json")))
|
||||
{
|
||||
_layout = PythonLayoutKind.AzureFunction;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for container markers
|
||||
if (File.Exists(Path.Combine(_rootPath, "Dockerfile")) ||
|
||||
Directory.Exists(Path.Combine(_rootPath, "usr", "local", "lib")))
|
||||
{
|
||||
_layout = PythonLayoutKind.Container;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for system Python installation
|
||||
var libDir = Path.Combine(_rootPath, "lib");
|
||||
if (Directory.Exists(libDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(libDir, "python*", SafeEnumeration))
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(dir, "site-packages")))
|
||||
{
|
||||
_layout = PythonLayoutKind.System;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore inaccessible directories
|
||||
}
|
||||
}
|
||||
|
||||
_layout = PythonLayoutKind.Unknown;
|
||||
}
|
||||
|
||||
private void DetectBinDirectory()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_venvPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows uses Scripts, Unix uses bin
|
||||
var scriptsDir = Path.Combine(_venvPath, "Scripts");
|
||||
if (Directory.Exists(scriptsDir))
|
||||
{
|
||||
_binPath = scriptsDir;
|
||||
_pythonExecutable = Path.Combine(scriptsDir, "python.exe");
|
||||
return;
|
||||
}
|
||||
|
||||
var binDir = Path.Combine(_venvPath, "bin");
|
||||
if (Directory.Exists(binDir))
|
||||
{
|
||||
_binPath = binDir;
|
||||
_pythonExecutable = Path.Combine(binDir, "python");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DetectVersionTargetsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await ParsePyprojectTomlAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ParseSetupPyAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ParseSetupCfgAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ParseRuntimeTxtAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ParseDockerfileAsync(cancellationToken).ConfigureAwait(false);
|
||||
await ParseToxIniAsync(cancellationToken).ConfigureAwait(false);
|
||||
DetectVersionFromSitePackages();
|
||||
}
|
||||
|
||||
private async Task ParsePyvenvCfgAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("version", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = trimmed.Split('=', 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var version = parts[1].Trim();
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"pyvenv.cfg",
|
||||
PythonVersionConfidence.Definitive));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ParsePyprojectTomlAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(_rootPath, "pyproject.toml");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Look for requires-python in [project] section
|
||||
var requiresPythonMatch = RequiresPythonPattern().Match(content);
|
||||
if (requiresPythonMatch.Success)
|
||||
{
|
||||
var version = requiresPythonMatch.Groups["version"].Value.Trim().Trim('"', '\'');
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
var isMinimum = version.StartsWith(">=", StringComparison.Ordinal) ||
|
||||
version.StartsWith(">", StringComparison.Ordinal);
|
||||
version = Regex.Replace(version, @"^[><=!~]+", string.Empty).Trim();
|
||||
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"pyproject.toml",
|
||||
PythonVersionConfidence.High,
|
||||
isMinimum));
|
||||
}
|
||||
}
|
||||
|
||||
// Look for python_requires in [tool.poetry] or similar
|
||||
var pythonMatch = PythonVersionTomlPattern().Match(content);
|
||||
if (pythonMatch.Success)
|
||||
{
|
||||
var version = pythonMatch.Groups["version"].Value.Trim().Trim('"', '\'');
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
var isMinimum = version.StartsWith("^", StringComparison.Ordinal) ||
|
||||
version.StartsWith(">=", StringComparison.Ordinal);
|
||||
version = Regex.Replace(version, @"^[\^><=!~]+", string.Empty).Trim();
|
||||
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"pyproject.toml",
|
||||
PythonVersionConfidence.High,
|
||||
isMinimum));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ParseSetupPyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(_rootPath, "setup.py");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var match = PythonRequiresPattern().Match(content);
|
||||
if (match.Success)
|
||||
{
|
||||
var version = match.Groups["version"].Value.Trim().Trim('"', '\'');
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
var isMinimum = version.StartsWith(">=", StringComparison.Ordinal);
|
||||
version = Regex.Replace(version, @"^[><=!~]+", string.Empty).Trim();
|
||||
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"setup.py",
|
||||
PythonVersionConfidence.High,
|
||||
isMinimum));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ParseSetupCfgAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(_rootPath, "setup.cfg");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var match = PythonRequiresCfgPattern().Match(content);
|
||||
if (match.Success)
|
||||
{
|
||||
var version = match.Groups["version"].Value.Trim();
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
var isMinimum = version.StartsWith(">=", StringComparison.Ordinal);
|
||||
version = Regex.Replace(version, @"^[><=!~]+", string.Empty).Trim();
|
||||
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"setup.cfg",
|
||||
PythonVersionConfidence.High,
|
||||
isMinimum));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ParseRuntimeTxtAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(_rootPath, "runtime.txt");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var trimmed = content.Trim();
|
||||
|
||||
// Heroku format: python-3.11.4
|
||||
var match = RuntimeTxtPattern().Match(trimmed);
|
||||
if (match.Success)
|
||||
{
|
||||
var version = match.Groups["version"].Value;
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"runtime.txt",
|
||||
PythonVersionConfidence.Definitive));
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ParseDockerfileAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(_rootPath, "Dockerfile");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Look for FROM python:X.Y or FROM python:X.Y.Z
|
||||
var fromMatch = DockerFromPythonPattern().Match(content);
|
||||
if (fromMatch.Success)
|
||||
{
|
||||
var version = fromMatch.Groups["version"].Value;
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"Dockerfile",
|
||||
PythonVersionConfidence.High));
|
||||
return;
|
||||
}
|
||||
|
||||
// Look for ENV PYTHON_VERSION=X.Y.Z
|
||||
var envMatch = DockerEnvPythonPattern().Match(content);
|
||||
if (envMatch.Success)
|
||||
{
|
||||
var version = envMatch.Groups["version"].Value;
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
"Dockerfile",
|
||||
PythonVersionConfidence.Medium));
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ParseToxIniAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(_rootPath, "tox.ini");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var match = ToxEnvListPattern().Match(content);
|
||||
if (match.Success)
|
||||
{
|
||||
var envList = match.Groups["envs"].Value;
|
||||
var pyMatches = ToxPythonEnvPattern().Matches(envList);
|
||||
foreach (Match pyMatch in pyMatches)
|
||||
{
|
||||
var version = pyMatch.Groups["version"].Value;
|
||||
if (version.Length >= 2)
|
||||
{
|
||||
// Convert py311 to 3.11
|
||||
var formatted = $"{version[0]}.{version[1..]}";
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
formatted,
|
||||
"tox.ini",
|
||||
PythonVersionConfidence.Medium));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectVersionFromSitePackages()
|
||||
{
|
||||
// Look for python version in lib directory names
|
||||
var searchPaths = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(_venvPath))
|
||||
{
|
||||
searchPaths.Add(Path.Combine(_venvPath, "lib"));
|
||||
}
|
||||
|
||||
searchPaths.Add(Path.Combine(_rootPath, "lib"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "usr", "local", "lib"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "usr", "lib"));
|
||||
|
||||
foreach (var libPath in searchPaths)
|
||||
{
|
||||
if (!Directory.Exists(libPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(libPath, "python*", SafeEnumeration))
|
||||
{
|
||||
var dirName = Path.GetFileName(dir);
|
||||
var match = LibPythonDirPattern().Match(dirName);
|
||||
if (match.Success)
|
||||
{
|
||||
var version = match.Groups["version"].Value;
|
||||
_versionTargets.Add(new PythonVersionTarget(
|
||||
version,
|
||||
$"lib/{dirName}",
|
||||
PythonVersionConfidence.Medium));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore inaccessible directories
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectSitePackages()
|
||||
{
|
||||
var searchPaths = new List<string>();
|
||||
|
||||
// Virtualenv site-packages
|
||||
if (!string.IsNullOrEmpty(_venvPath))
|
||||
{
|
||||
// Windows
|
||||
searchPaths.Add(Path.Combine(_venvPath, "Lib", "site-packages"));
|
||||
|
||||
// Unix - need to find pythonX.Y directory
|
||||
var libDir = Path.Combine(_venvPath, "lib");
|
||||
if (Directory.Exists(libDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var pythonDir in Directory.EnumerateDirectories(libDir, "python*", SafeEnumeration))
|
||||
{
|
||||
searchPaths.Add(Path.Combine(pythonDir, "site-packages"));
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lambda layer paths
|
||||
if (_layout == PythonLayoutKind.Lambda)
|
||||
{
|
||||
searchPaths.Add(Path.Combine(_rootPath, "python", "lib", "python3.9", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "python", "lib", "python3.10", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "python", "lib", "python3.11", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "python", "lib", "python3.12", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "python"));
|
||||
}
|
||||
|
||||
// Container paths
|
||||
searchPaths.Add(Path.Combine(_rootPath, "usr", "local", "lib", "python3.9", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "usr", "local", "lib", "python3.10", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "usr", "local", "lib", "python3.11", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "usr", "local", "lib", "python3.12", "site-packages"));
|
||||
searchPaths.Add(Path.Combine(_rootPath, "usr", "lib", "python3", "dist-packages"));
|
||||
|
||||
// Root site-packages (common for some Docker images)
|
||||
searchPaths.Add(Path.Combine(_rootPath, "site-packages"));
|
||||
|
||||
foreach (var path in searchPaths)
|
||||
{
|
||||
if (Directory.Exists(path) && !_sitePackagesPaths.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_sitePackagesPaths.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectWheels()
|
||||
{
|
||||
// Look for wheels in common locations
|
||||
var searchPaths = new List<string>
|
||||
{
|
||||
Path.Combine(_rootPath, "dist"),
|
||||
Path.Combine(_rootPath, "wheels"),
|
||||
Path.Combine(_rootPath, ".wheels"),
|
||||
_rootPath
|
||||
};
|
||||
|
||||
foreach (var searchPath in searchPaths)
|
||||
{
|
||||
if (!Directory.Exists(searchPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var wheel in Directory.EnumerateFiles(searchPath, "*.whl", SafeEnumeration))
|
||||
{
|
||||
if (!_wheelPaths.Contains(wheel, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_wheelPaths.Add(wheel);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DetectZipapps()
|
||||
{
|
||||
if (!Directory.Exists(_rootPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var pyz in Directory.EnumerateFiles(_rootPath, "*.pyz", SafeEnumeration))
|
||||
{
|
||||
_zipappPaths.Add(pyz);
|
||||
}
|
||||
|
||||
foreach (var pyzw in Directory.EnumerateFiles(_rootPath, "*.pyzw", SafeEnumeration))
|
||||
{
|
||||
_zipappPaths.Add(pyzw);
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DetectEditablesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Look for .egg-link files in site-packages
|
||||
foreach (var sitePackagesPath in _sitePackagesPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var eggLink in Directory.EnumerateFiles(sitePackagesPath, "*.egg-link", SafeEnumeration))
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(eggLink, cancellationToken).ConfigureAwait(false);
|
||||
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length > 0)
|
||||
{
|
||||
var editablePath = lines[0].Trim();
|
||||
if (Directory.Exists(editablePath))
|
||||
{
|
||||
var packageName = Path.GetFileNameWithoutExtension(eggLink);
|
||||
if (packageName.EndsWith(".egg-link", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
packageName = packageName[..^9];
|
||||
}
|
||||
|
||||
_editablePaths.Add((editablePath, packageName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Look for direct_url.json with editable flag in dist-info directories
|
||||
foreach (var sitePackagesPath in _sitePackagesPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var distInfo in Directory.EnumerateDirectories(sitePackagesPath, "*.dist-info", SafeEnumeration))
|
||||
{
|
||||
var directUrlPath = Path.Combine(distInfo, "direct_url.json");
|
||||
if (!File.Exists(directUrlPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(directUrlPath, cancellationToken).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("dir_info", out var dirInfo) &&
|
||||
dirInfo.TryGetProperty("editable", out var editable) &&
|
||||
editable.GetBoolean() &&
|
||||
root.TryGetProperty("url", out var urlElement))
|
||||
{
|
||||
var url = urlElement.GetString();
|
||||
if (!string.IsNullOrEmpty(url) && url.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var editablePath = url[7..];
|
||||
if (OperatingSystem.IsWindows() && editablePath.StartsWith('/'))
|
||||
{
|
||||
editablePath = editablePath[1..];
|
||||
}
|
||||
|
||||
if (Directory.Exists(editablePath))
|
||||
{
|
||||
var distInfoName = Path.GetFileName(distInfo);
|
||||
var packageName = distInfoName.Split('-')[0];
|
||||
_editablePaths.Add((editablePath, packageName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Invalid JSON - skip
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"requires-python\s*=\s*[""']?(?<version>[^""'\n]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex RequiresPythonPattern();
|
||||
|
||||
[GeneratedRegex(@"python\s*=\s*[""'](?<version>[^""']+)[""']", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex PythonVersionTomlPattern();
|
||||
|
||||
[GeneratedRegex(@"python_requires\s*=\s*[""'](?<version>[^""']+)[""']", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex PythonRequiresPattern();
|
||||
|
||||
[GeneratedRegex(@"python_requires\s*=\s*(?<version>[^\s\n]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex PythonRequiresCfgPattern();
|
||||
|
||||
[GeneratedRegex(@"^python-(?<version>\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex RuntimeTxtPattern();
|
||||
|
||||
[GeneratedRegex(@"FROM\s+(?:.*\/)?python:(?<version>\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DockerFromPythonPattern();
|
||||
|
||||
[GeneratedRegex(@"ENV\s+PYTHON_VERSION\s*=?\s*(?<version>\d+\.\d+(?:\.\d+)?)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DockerEnvPythonPattern();
|
||||
|
||||
[GeneratedRegex(@"envlist\s*=\s*(?<envs>[^\n\[]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ToxEnvListPattern();
|
||||
|
||||
[GeneratedRegex(@"py(?<version>\d{2,3})", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ToxPythonEnvPattern();
|
||||
|
||||
[GeneratedRegex(@"^python(?<version>\d+\.\d+)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LibPythonDirPattern();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the type of Python installation/project layout.
|
||||
/// </summary>
|
||||
internal enum PythonLayoutKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown or undetected layout.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// Standard virtual environment (venv or virtualenv).
|
||||
/// </summary>
|
||||
Virtualenv,
|
||||
|
||||
/// <summary>
|
||||
/// Poetry-managed project.
|
||||
/// </summary>
|
||||
Poetry,
|
||||
|
||||
/// <summary>
|
||||
/// Pipenv-managed project.
|
||||
/// </summary>
|
||||
Pipenv,
|
||||
|
||||
/// <summary>
|
||||
/// Conda environment.
|
||||
/// </summary>
|
||||
Conda,
|
||||
|
||||
/// <summary>
|
||||
/// System-wide Python installation.
|
||||
/// </summary>
|
||||
System,
|
||||
|
||||
/// <summary>
|
||||
/// Container image with Python.
|
||||
/// </summary>
|
||||
Container,
|
||||
|
||||
/// <summary>
|
||||
/// AWS Lambda function.
|
||||
/// </summary>
|
||||
Lambda,
|
||||
|
||||
/// <summary>
|
||||
/// Azure Functions.
|
||||
/// </summary>
|
||||
AzureFunction,
|
||||
|
||||
/// <summary>
|
||||
/// Google Cloud Function.
|
||||
/// </summary>
|
||||
CloudFunction,
|
||||
|
||||
/// <summary>
|
||||
/// Pipx-managed application.
|
||||
/// </summary>
|
||||
Pipx,
|
||||
|
||||
/// <summary>
|
||||
/// PyInstaller or similar frozen application.
|
||||
/// </summary>
|
||||
Frozen
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Result of Python project analysis containing layout detection,
|
||||
/// version targets, and the virtual filesystem.
|
||||
/// </summary>
|
||||
internal sealed class PythonProjectAnalysis
|
||||
{
|
||||
private PythonProjectAnalysis(
|
||||
PythonLayoutKind layout,
|
||||
IReadOnlyList<PythonVersionTarget> versionTargets,
|
||||
IReadOnlyList<string> sitePackagesPaths,
|
||||
string? venvPath,
|
||||
string? binPath,
|
||||
PythonVirtualFileSystem virtualFileSystem)
|
||||
{
|
||||
Layout = layout;
|
||||
VersionTargets = versionTargets;
|
||||
SitePackagesPaths = sitePackagesPaths;
|
||||
VenvPath = venvPath;
|
||||
BinPath = binPath;
|
||||
VirtualFileSystem = virtualFileSystem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the detected layout kind.
|
||||
/// </summary>
|
||||
public PythonLayoutKind Layout { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets all detected version targets.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PythonVersionTarget> VersionTargets { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary version target (highest confidence).
|
||||
/// </summary>
|
||||
public PythonVersionTarget? PrimaryVersionTarget =>
|
||||
VersionTargets
|
||||
.OrderByDescending(static v => v.Confidence)
|
||||
.ThenBy(static v => v.Source, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all detected site-packages paths.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SitePackagesPaths { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the virtual environment path if detected.
|
||||
/// </summary>
|
||||
public string? VenvPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bin/Scripts directory path if detected.
|
||||
/// </summary>
|
||||
public string? BinPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the virtual filesystem built from all detected sources.
|
||||
/// </summary>
|
||||
public PythonVirtualFileSystem VirtualFileSystem { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a Python project and builds the virtual filesystem.
|
||||
/// </summary>
|
||||
public static async Task<PythonProjectAnalysis> AnalyzeAsync(
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizer = new PythonInputNormalizer(rootPath);
|
||||
await normalizer.AnalyzeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var vfs = normalizer.BuildVirtualFileSystem();
|
||||
|
||||
return new PythonProjectAnalysis(
|
||||
normalizer.Layout,
|
||||
normalizer.VersionTargets.ToList(),
|
||||
normalizer.SitePackagesPaths.ToList(),
|
||||
normalizer.VenvPath,
|
||||
normalizer.BinPath,
|
||||
vfs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates metadata entries for the analysis result.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string?>> ToMetadata()
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("layout", Layout.ToString());
|
||||
|
||||
if (PrimaryVersionTarget is not null)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("pythonVersion", PrimaryVersionTarget.Version);
|
||||
yield return new KeyValuePair<string, string?>("pythonVersionSource", PrimaryVersionTarget.Source);
|
||||
yield return new KeyValuePair<string, string?>("pythonVersionConfidence", PrimaryVersionTarget.Confidence.ToString());
|
||||
|
||||
if (PrimaryVersionTarget.IsMinimum)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("pythonVersionIsMinimum", "true");
|
||||
}
|
||||
}
|
||||
|
||||
if (VersionTargets.Count > 1)
|
||||
{
|
||||
var versions = string.Join(';', VersionTargets.Select(static v => $"{v.Version}@{v.Source}"));
|
||||
yield return new KeyValuePair<string, string?>("pythonVersionsDetected", versions);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(VenvPath))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("venvPath", VenvPath);
|
||||
}
|
||||
|
||||
if (SitePackagesPaths.Count > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("sitePackagesCount", SitePackagesPaths.Count.ToString());
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string?>("vfsFileCount", VirtualFileSystem.FileCount.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a detected Python version target.
|
||||
/// </summary>
|
||||
/// <param name="Version">The detected version string (e.g., "3.11", "3.12.1").</param>
|
||||
/// <param name="Source">The source file where the version was detected.</param>
|
||||
/// <param name="Confidence">Confidence level of the detection.</param>
|
||||
/// <param name="IsMinimum">True if this is a minimum version requirement.</param>
|
||||
internal sealed record PythonVersionTarget(
|
||||
string Version,
|
||||
string Source,
|
||||
PythonVersionConfidence Confidence,
|
||||
bool IsMinimum = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the major version number.
|
||||
/// </summary>
|
||||
public int? Major => TryParsePart(0);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minor version number.
|
||||
/// </summary>
|
||||
public int? Minor => TryParsePart(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the patch version number.
|
||||
/// </summary>
|
||||
public int? Patch => TryParsePart(2);
|
||||
|
||||
private int? TryParsePart(int index)
|
||||
{
|
||||
var parts = Version.Split('.');
|
||||
if (index >= parts.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle versions like "3.11+" or ">=3.10"
|
||||
var part = Regex.Replace(parts[index], @"[^\d]", string.Empty);
|
||||
return int.TryParse(part, out var value) ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for Python version detection.
|
||||
/// </summary>
|
||||
internal enum PythonVersionConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Low confidence - inferred from heuristics.
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence - from configuration files.
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence - explicit declaration.
|
||||
/// </summary>
|
||||
High,
|
||||
|
||||
/// <summary>
|
||||
/// Definitive - from runtime detection.
|
||||
/// </summary>
|
||||
Definitive
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file in the Python virtual filesystem.
|
||||
/// </summary>
|
||||
/// <param name="VirtualPath">Normalized virtual path with forward slashes, relative to the VFS root.</param>
|
||||
/// <param name="AbsolutePath">Absolute filesystem path or archive entry path.</param>
|
||||
/// <param name="Source">Origin of the file.</param>
|
||||
/// <param name="LayerDigest">Container layer digest if from a container layer.</param>
|
||||
/// <param name="ArchivePath">Path to the containing archive if from wheel/sdist/zipapp.</param>
|
||||
/// <param name="Size">File size in bytes, if known.</param>
|
||||
/// <param name="Sha256">SHA-256 hash of the file content, if computed.</param>
|
||||
/// <param name="Metadata">Additional metadata key-value pairs.</param>
|
||||
internal sealed record PythonVirtualFile(
|
||||
string VirtualPath,
|
||||
string AbsolutePath,
|
||||
PythonFileSource Source,
|
||||
string? LayerDigest = null,
|
||||
string? ArchivePath = null,
|
||||
long? Size = null,
|
||||
string? Sha256 = null,
|
||||
FrozenDictionary<string, string>? Metadata = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the file extension in lowercase without the leading dot.
|
||||
/// </summary>
|
||||
public string Extension
|
||||
{
|
||||
get
|
||||
{
|
||||
var ext = Path.GetExtension(VirtualPath);
|
||||
return string.IsNullOrEmpty(ext) ? string.Empty : ext[1..].ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file name without path.
|
||||
/// </summary>
|
||||
public string FileName => Path.GetFileName(VirtualPath);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this is a Python source file.
|
||||
/// </summary>
|
||||
public bool IsPythonSource => Extension is "py" or "pyw";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this is a compiled Python bytecode file.
|
||||
/// </summary>
|
||||
public bool IsBytecode => Extension is "pyc" or "pyo";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this is a native extension.
|
||||
/// </summary>
|
||||
public bool IsNativeExtension => Extension is "so" or "pyd" or "dll";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this file comes from an archive.
|
||||
/// </summary>
|
||||
public bool IsFromArchive => !string.IsNullOrEmpty(ArchivePath);
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO.Compression;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// A virtual filesystem that normalizes access to Python project files across
|
||||
/// wheels, sdists, editable installs, zipapps, site-packages trees, and container roots.
|
||||
/// </summary>
|
||||
internal sealed partial class PythonVirtualFileSystem
|
||||
{
|
||||
private static readonly EnumerationOptions SafeEnumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
private static readonly EnumerationOptions RecursiveEnumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
private readonly FrozenDictionary<string, PythonVirtualFile> _files;
|
||||
private readonly FrozenDictionary<string, FrozenSet<string>> _directories;
|
||||
private readonly FrozenSet<string> _sourceTreeRoots;
|
||||
private readonly FrozenSet<string> _sitePackagesPaths;
|
||||
private readonly FrozenSet<string> _editablePaths;
|
||||
private readonly FrozenSet<string> _zipArchivePaths;
|
||||
|
||||
private PythonVirtualFileSystem(
|
||||
FrozenDictionary<string, PythonVirtualFile> files,
|
||||
FrozenDictionary<string, FrozenSet<string>> directories,
|
||||
FrozenSet<string> sourceTreeRoots,
|
||||
FrozenSet<string> sitePackagesPaths,
|
||||
FrozenSet<string> editablePaths,
|
||||
FrozenSet<string> zipArchivePaths)
|
||||
{
|
||||
_files = files;
|
||||
_directories = directories;
|
||||
_sourceTreeRoots = sourceTreeRoots;
|
||||
_sitePackagesPaths = sitePackagesPaths;
|
||||
_editablePaths = editablePaths;
|
||||
_zipArchivePaths = zipArchivePaths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of files in the virtual filesystem.
|
||||
/// </summary>
|
||||
public int FileCount => _files.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files in the virtual filesystem.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonVirtualFile> Files => _files.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all virtual paths in sorted order.
|
||||
/// </summary>
|
||||
public IEnumerable<string> Paths => _files.Keys.OrderBy(static p => p, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source tree root paths.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> SourceTreeRoots => _sourceTreeRoots;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the site-packages directory paths.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> SitePackagesPaths => _sitePackagesPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the editable install paths.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> EditablePaths => _editablePaths;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the zip archive paths (wheels, zipapps, sdists).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> ZipArchivePaths => _zipArchivePaths;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a file by its virtual path, or null if not found.
|
||||
/// </summary>
|
||||
public PythonVirtualFile? GetFile(string virtualPath)
|
||||
{
|
||||
var normalized = NormalizePath(virtualPath);
|
||||
return _files.GetValueOrDefault(normalized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a file by its virtual path.
|
||||
/// </summary>
|
||||
public bool TryGetFile(string virtualPath, [NotNullWhen(true)] out PythonVirtualFile? file)
|
||||
{
|
||||
var normalized = NormalizePath(virtualPath);
|
||||
return _files.TryGetValue(normalized, out file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a virtual path exists as a file.
|
||||
/// </summary>
|
||||
public bool FileExists(string virtualPath)
|
||||
{
|
||||
var normalized = NormalizePath(virtualPath);
|
||||
return _files.ContainsKey(normalized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a virtual path exists as a directory.
|
||||
/// </summary>
|
||||
public bool DirectoryExists(string virtualPath)
|
||||
{
|
||||
var normalized = NormalizePath(virtualPath);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _directories.ContainsKey(normalized);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates files in a directory (non-recursive).
|
||||
/// </summary>
|
||||
public IEnumerable<PythonVirtualFile> EnumerateFiles(string virtualPath)
|
||||
{
|
||||
var normalized = NormalizePath(virtualPath);
|
||||
if (!_directories.TryGetValue(normalized, out var entries))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var entry in entries.OrderBy(static e => e, StringComparer.Ordinal))
|
||||
{
|
||||
var fullPath = normalized.Length == 0 ? entry : $"{normalized}/{entry}";
|
||||
if (_files.TryGetValue(fullPath, out var file))
|
||||
{
|
||||
yield return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all files matching a glob pattern.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonVirtualFile> EnumerateFiles(string virtualPath, string pattern)
|
||||
{
|
||||
var regex = GlobToRegex(pattern);
|
||||
var normalized = NormalizePath(virtualPath);
|
||||
var prefix = normalized.Length == 0 ? string.Empty : normalized + "/";
|
||||
|
||||
foreach (var kvp in _files)
|
||||
{
|
||||
if (!kvp.Key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relative = kvp.Key[prefix.Length..];
|
||||
if (regex.IsMatch(relative))
|
||||
{
|
||||
yield return kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files from a specific source.
|
||||
/// </summary>
|
||||
public IEnumerable<PythonVirtualFile> GetFilesBySource(PythonFileSource source)
|
||||
{
|
||||
return _files.Values
|
||||
.Where(f => f.Source == source)
|
||||
.OrderBy(static f => f.VirtualPath, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new builder for constructing a virtual filesystem.
|
||||
/// </summary>
|
||||
public static Builder CreateBuilder() => new();
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = path.Replace('\\', '/').Trim('/');
|
||||
return normalized == "." ? string.Empty : normalized;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9_.\-/]+$")]
|
||||
private static partial Regex SafePathPattern();
|
||||
|
||||
private static Regex GlobToRegex(string pattern)
|
||||
{
|
||||
var escaped = Regex.Escape(pattern)
|
||||
.Replace(@"\*\*", ".*")
|
||||
.Replace(@"\*", "[^/]*")
|
||||
.Replace(@"\?", "[^/]");
|
||||
|
||||
return new Regex($"^{escaped}$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing a <see cref="PythonVirtualFileSystem"/>.
|
||||
/// </summary>
|
||||
internal sealed class Builder
|
||||
{
|
||||
private readonly Dictionary<string, PythonVirtualFile> _files = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _processedArchives = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _sourceTreeRoots = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _sitePackagesPaths = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _editablePaths = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _zipArchivePaths = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Adds files from a site-packages directory.
|
||||
/// </summary>
|
||||
public Builder AddSitePackages(string sitePackagesPath, string? layerDigest = null)
|
||||
{
|
||||
if (!Directory.Exists(sitePackagesPath))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var basePath = Path.GetFullPath(sitePackagesPath);
|
||||
_sitePackagesPaths.Add(string.Empty); // Root of the VFS
|
||||
AddDirectoryRecursive(basePath, string.Empty, PythonFileSource.SitePackages, layerDigest);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds files from a wheel archive (.whl).
|
||||
/// </summary>
|
||||
public Builder AddWheel(string wheelPath)
|
||||
{
|
||||
if (!File.Exists(wheelPath) || !_processedArchives.Add(wheelPath))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_zipArchivePaths.Add(wheelPath);
|
||||
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(wheelPath);
|
||||
AddArchiveEntries(archive, wheelPath, PythonFileSource.Wheel);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
// Corrupted archive - skip
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// IO error - skip
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds files from a zipapp (.pyz, .pyzw).
|
||||
/// </summary>
|
||||
public Builder AddZipapp(string zipappPath)
|
||||
{
|
||||
if (!File.Exists(zipappPath) || !_processedArchives.Add(zipappPath))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_zipArchivePaths.Add(zipappPath);
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(zipappPath);
|
||||
|
||||
// Zipapps start with a shebang line, need to find ZIP signature
|
||||
var offset = FindZipOffset(stream);
|
||||
if (offset < 0)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
stream.Position = offset;
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
AddArchiveEntries(archive, zipappPath, PythonFileSource.Zipapp);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
// Corrupted archive - skip
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// IO error - skip
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds files from a source distribution archive (.tar.gz, .zip).
|
||||
/// </summary>
|
||||
public Builder AddSdist(string sdistPath)
|
||||
{
|
||||
if (!File.Exists(sdistPath) || !_processedArchives.Add(sdistPath))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
_zipArchivePaths.Add(sdistPath);
|
||||
|
||||
try
|
||||
{
|
||||
if (sdistPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(sdistPath);
|
||||
AddArchiveEntries(archive, sdistPath, PythonFileSource.Sdist);
|
||||
}
|
||||
// Note: .tar.gz support would require TarReader from System.Formats.Tar
|
||||
// For now, we handle the common .zip case
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
// Corrupted archive - skip
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// IO error - skip
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds files from an editable install path.
|
||||
/// </summary>
|
||||
public Builder AddEditable(string editablePath, string? packageName = null)
|
||||
{
|
||||
if (!Directory.Exists(editablePath))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var basePath = Path.GetFullPath(editablePath);
|
||||
var prefix = string.IsNullOrEmpty(packageName) ? string.Empty : packageName + "/";
|
||||
_editablePaths.Add(prefix.TrimEnd('/'));
|
||||
AddDirectoryRecursive(basePath, prefix.TrimEnd('/'), PythonFileSource.Editable, layerDigest: null);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds files from a source tree.
|
||||
/// </summary>
|
||||
public Builder AddSourceTree(string sourcePath)
|
||||
{
|
||||
if (!Directory.Exists(sourcePath))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var basePath = Path.GetFullPath(sourcePath);
|
||||
_sourceTreeRoots.Add(string.Empty); // Root of the VFS
|
||||
AddDirectoryRecursive(basePath, string.Empty, PythonFileSource.SourceTree, layerDigest: null);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds files from a virtual environment's bin/Scripts directory.
|
||||
/// </summary>
|
||||
public Builder AddVenvBin(string binPath)
|
||||
{
|
||||
if (!Directory.Exists(binPath))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var basePath = Path.GetFullPath(binPath);
|
||||
foreach (var file in Directory.EnumerateFiles(basePath, "*", SafeEnumeration))
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
var virtualPath = $"bin/{fileName}";
|
||||
AddFile(virtualPath, file, PythonFileSource.VenvBin, layerDigest: null, archivePath: null);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single file with explicit parameters.
|
||||
/// </summary>
|
||||
public Builder AddFile(
|
||||
string virtualPath,
|
||||
string absolutePath,
|
||||
PythonFileSource source,
|
||||
string? layerDigest = null,
|
||||
string? archivePath = null,
|
||||
long? size = null,
|
||||
string? sha256 = null,
|
||||
FrozenDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var normalized = NormalizePath(virtualPath);
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
var file = new PythonVirtualFile(
|
||||
normalized,
|
||||
absolutePath,
|
||||
source,
|
||||
layerDigest,
|
||||
archivePath,
|
||||
size,
|
||||
sha256,
|
||||
metadata);
|
||||
|
||||
// Later additions override earlier ones (layer precedence)
|
||||
_files[normalized] = file;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the immutable virtual filesystem.
|
||||
/// </summary>
|
||||
public PythonVirtualFileSystem Build()
|
||||
{
|
||||
var files = _files.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
var directories = BuildDirectoryIndex(files);
|
||||
return new PythonVirtualFileSystem(
|
||||
files,
|
||||
directories,
|
||||
_sourceTreeRoots.ToFrozenSet(StringComparer.Ordinal),
|
||||
_sitePackagesPaths.ToFrozenSet(StringComparer.Ordinal),
|
||||
_editablePaths.ToFrozenSet(StringComparer.Ordinal),
|
||||
_zipArchivePaths.ToFrozenSet(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private void AddDirectoryRecursive(
|
||||
string basePath,
|
||||
string virtualPrefix,
|
||||
PythonFileSource source,
|
||||
string? layerDigest)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(basePath, "*", RecursiveEnumeration))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(basePath, file);
|
||||
var normalizedRelative = relativePath.Replace('\\', '/');
|
||||
var virtualPath = string.IsNullOrEmpty(virtualPrefix)
|
||||
? normalizedRelative
|
||||
: $"{virtualPrefix}/{normalizedRelative}";
|
||||
|
||||
// Skip __pycache__ and hidden files
|
||||
if (normalizedRelative.Contains("/__pycache__/", StringComparison.Ordinal) ||
|
||||
normalizedRelative.StartsWith("__pycache__/", StringComparison.Ordinal) ||
|
||||
Path.GetFileName(file).StartsWith('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
long? size = null;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
size = info.Length;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore size if we can't read it
|
||||
}
|
||||
|
||||
AddFile(virtualPath, file, source, layerDigest, archivePath: null, size: size);
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip inaccessible directories
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory was removed - skip
|
||||
}
|
||||
}
|
||||
|
||||
private void AddArchiveEntries(ZipArchive archive, string archivePath, PythonFileSource source)
|
||||
{
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
// Skip directories
|
||||
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith('/'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var virtualPath = entry.FullName.Replace('\\', '/');
|
||||
|
||||
// Skip __pycache__ in archives too
|
||||
if (virtualPath.Contains("/__pycache__/", StringComparison.Ordinal) ||
|
||||
virtualPath.StartsWith("__pycache__/", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddFile(
|
||||
virtualPath,
|
||||
entry.FullName,
|
||||
source,
|
||||
layerDigest: null,
|
||||
archivePath: archivePath,
|
||||
size: entry.Length);
|
||||
}
|
||||
}
|
||||
|
||||
private static long FindZipOffset(Stream stream)
|
||||
{
|
||||
// ZIP files start with PK\x03\x04 signature
|
||||
var buffer = new byte[4096];
|
||||
var bytesRead = stream.Read(buffer, 0, buffer.Length);
|
||||
|
||||
for (var i = 0; i < bytesRead - 3; i++)
|
||||
{
|
||||
if (buffer[i] == 0x50 && buffer[i + 1] == 0x4B &&
|
||||
buffer[i + 2] == 0x03 && buffer[i + 3] == 0x04)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static FrozenDictionary<string, FrozenSet<string>> BuildDirectoryIndex(
|
||||
FrozenDictionary<string, PythonVirtualFile> files)
|
||||
{
|
||||
var directories = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
[string.Empty] = new(StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
foreach (var path in files.Keys)
|
||||
{
|
||||
var parts = path.Split('/');
|
||||
var current = string.Empty;
|
||||
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
var isLast = i == parts.Length - 1;
|
||||
|
||||
if (!directories.TryGetValue(current, out var entries))
|
||||
{
|
||||
entries = new HashSet<string>(StringComparer.Ordinal);
|
||||
directories[current] = entries;
|
||||
}
|
||||
|
||||
entries.Add(part);
|
||||
|
||||
if (!isLast)
|
||||
{
|
||||
current = current.Length == 0 ? part : $"{current}/{part}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return directories.ToFrozenDictionary(
|
||||
static kvp => kvp.Key,
|
||||
static kvp => kvp.Value.ToFrozenSet(StringComparer.Ordinal),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,71 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python;
|
||||
|
||||
public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
public string Id => "python";
|
||||
|
||||
public string DisplayName => "Python Analyzer";
|
||||
|
||||
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
return AnalyzeInternalAsync(context, writer, cancellationToken);
|
||||
}
|
||||
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python;
|
||||
|
||||
public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
private static readonly EnumerationOptions Enumeration = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint
|
||||
};
|
||||
|
||||
public string Id => "python";
|
||||
|
||||
public string DisplayName => "Python Analyzer";
|
||||
|
||||
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
return AnalyzeInternalAsync(context, writer, cancellationToken);
|
||||
}
|
||||
|
||||
private static async ValueTask AnalyzeInternalAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
var lockData = await PythonLockFileCollector.LoadAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var matchedLocks = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var hasLockEntries = lockData.Entries.Count > 0;
|
||||
|
||||
var distInfoDirectories = Directory
|
||||
.EnumerateDirectories(context.RootPath, "*.dist-info", Enumeration)
|
||||
.OrderBy(static path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
// Detect Python runtime in container layers
|
||||
var runtimeInfo = PythonContainerAdapter.DetectRuntime(context.RootPath);
|
||||
|
||||
// Detect environment variables (PYTHONPATH/PYTHONHOME)
|
||||
var environment = await PythonEnvironmentDetector.DetectAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Detect startup hooks (sitecustomize.py, usercustomize.py, .pth files)
|
||||
var startupHooks = PythonStartupHookDetector.Detect(context.RootPath);
|
||||
|
||||
// Collect dist-info directories from both root and container layers
|
||||
var distInfoDirectories = CollectDistInfoDirectories(context.RootPath);
|
||||
|
||||
foreach (var distInfoPath in distInfoDirectories)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PythonDistribution? distribution;
|
||||
try
|
||||
{
|
||||
distribution = await PythonDistributionLoader.LoadAsync(context, distInfoPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (distribution is null)
|
||||
try
|
||||
{
|
||||
distribution = await PythonDistributionLoader.LoadAsync(context, distInfoPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (distribution is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -75,6 +82,19 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockMissing", "true"));
|
||||
}
|
||||
|
||||
// Append runtime information
|
||||
AppendRuntimeMetadata(metadata, runtimeInfo);
|
||||
|
||||
// Append environment variables (PYTHONPATH/PYTHONHOME)
|
||||
AppendEnvironmentMetadata(metadata, environment);
|
||||
|
||||
// Append startup hooks warnings
|
||||
AppendStartupHooksMetadata(metadata, startupHooks);
|
||||
|
||||
// Collect evidence including startup hooks
|
||||
var evidence = distribution.SortedEvidence.ToList();
|
||||
evidence.AddRange(startupHooks.ToEvidence(context));
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: "python",
|
||||
purl: distribution.Purl,
|
||||
@@ -82,7 +102,7 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
version: distribution.Version,
|
||||
type: "pypi",
|
||||
metadata: metadata,
|
||||
evidence: distribution.SortedEvidence,
|
||||
evidence: evidence,
|
||||
usedByEntrypoint: distribution.UsedByEntrypoint);
|
||||
}
|
||||
|
||||
@@ -159,4 +179,98 @@ public sealed class PythonLanguageAnalyzer : ILanguageAnalyzer
|
||||
metadata.Add(new KeyValuePair<string, string?>("lockEditablePath", entry.EditablePath));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRuntimeMetadata(List<KeyValuePair<string, string?>> metadata, PythonRuntimeInfo? runtimeInfo)
|
||||
{
|
||||
if (runtimeInfo is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (runtimeInfo.Versions.Count > 0)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("runtime.versions", string.Join(';', runtimeInfo.Versions)));
|
||||
}
|
||||
|
||||
if (runtimeInfo.Binaries.Count > 0)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("runtime.binaries.count", runtimeInfo.Binaries.Count.ToString()));
|
||||
}
|
||||
|
||||
if (runtimeInfo.LibPaths.Count > 0)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("runtime.libPaths.count", runtimeInfo.LibPaths.Count.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendEnvironmentMetadata(List<KeyValuePair<string, string?>> metadata, PythonEnvironment environment)
|
||||
{
|
||||
if (environment.HasPythonPath)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("env.pythonpath", environment.PythonPath));
|
||||
metadata.Add(new KeyValuePair<string, string?>("env.pythonpath.warning", "PYTHONPATH is set; may affect module resolution"));
|
||||
}
|
||||
|
||||
if (environment.HasPythonHome)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("env.pythonhome", environment.PythonHome));
|
||||
metadata.Add(new KeyValuePair<string, string?>("env.pythonhome.warning", "PYTHONHOME is set; may affect interpreter behavior"));
|
||||
}
|
||||
|
||||
if (environment.Sources.Count > 0)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("env.sources.count", environment.Sources.Count.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendStartupHooksMetadata(List<KeyValuePair<string, string?>> metadata, PythonStartupHooks startupHooks)
|
||||
{
|
||||
if (startupHooks.HasStartupHooks)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("startupHooks.detected", "true"));
|
||||
metadata.Add(new KeyValuePair<string, string?>("startupHooks.count", startupHooks.Hooks.Count.ToString()));
|
||||
}
|
||||
|
||||
if (startupHooks.HasPthFilesWithImports)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("pthFiles.withImports.detected", "true"));
|
||||
}
|
||||
|
||||
foreach (var warning in startupHooks.Warnings)
|
||||
{
|
||||
metadata.Add(new KeyValuePair<string, string?>("startupHooks.warning", warning));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<string> CollectDistInfoDirectories(string rootPath)
|
||||
{
|
||||
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Collect from root path recursively
|
||||
try
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(rootPath, "*.dist-info", Enumeration))
|
||||
{
|
||||
directories.Add(dir);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore enumeration errors
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
// Also collect from OCI container layers
|
||||
foreach (var dir in PythonContainerAdapter.DiscoverDistInfoDirectories(rootPath))
|
||||
{
|
||||
directories.Add(dir);
|
||||
}
|
||||
|
||||
return directories
|
||||
.OrderBy(static path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
<EnableDefaultItems>false</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Python.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
|
||||
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
|
||||
|
||||
@@ -12,3 +12,9 @@
|
||||
|
||||
| 6 | SCANNER-ANALYZERS-LANG-10-309P | DONE (2025-10-23) | SCANNER-ANALYZERS-LANG-10-308P | Package plug-in (manifest, DI registration) and document Offline Kit bundling of Python stdlib metadata if needed. | Manifest copied to `plugins/scanner/analyzers/lang/`; Worker loads analyzer; Offline Kit doc updated. |
|
||||
|
||||
| 7 | SCANNER-ANALYZERS-PYTHON-23-001 | DONE (2025-11-27) | — | Build input normalizer & virtual filesystem for wheels, sdists, editable installs, zipapps, site-packages trees, and container roots. Detect Python version targets (`pyproject.toml`, `runtime.txt`, Dockerfile) + virtualenv layout deterministically. | Created `PythonVirtualFileSystem` with builder pattern, `PythonInputNormalizer` for layout/version detection. Supports VFS for wheels, sdists, zipapps. Detects layouts (Virtualenv, Poetry, Pipenv, Conda, Lambda, Container) and version from multiple sources. Files: `Internal/VirtualFileSystem/`. Tests: `VirtualFileSystem/PythonVirtualFileSystemTests.cs`, `VirtualFileSystem/PythonInputNormalizerTests.cs`. |
|
||||
|
||||
| 8 | SCANNER-ANALYZERS-PYTHON-23-002 | DONE (2025-11-27) | SCANNER-ANALYZERS-PYTHON-23-001 | Entrypoint discovery: module `__main__`, console_scripts entry points, `scripts`, zipapp main, `manage.py`/gunicorn/celery patterns. Capture invocation context (module vs package, argv wrappers). | Created `PythonEntrypointDiscovery` with support for: PackageMain (__main__.py), ConsoleScript/GuiScript (entry_points.txt), Script (bin/), ZipappMain, DjangoManage, WsgiApp/AsgiApp, CeleryWorker, LambdaHandler, AzureFunctionHandler, CloudFunctionHandler, CliApp (Click/Typer), StandaloneScript. Includes invocation context with command, arguments. Files: `Internal/Entrypoints/`. Tests: `Entrypoints/PythonEntrypointDiscoveryTests.cs`. |
|
||||
|
||||
| 9 | SCANNER-ANALYZERS-PYTHON-23-003 | DONE (2025-11-27) | SCANNER-ANALYZERS-PYTHON-23-002 | Static import graph builder using AST and bytecode fallback. Support `import`, `from ... import`, relative imports, `importlib.import_module`, `__import__` with literal args, `pkgutil.extend_path`. | Created `PythonSourceImportExtractor` with regex-based AST-like import extraction supporting: standard imports, from imports, star imports, relative imports (all levels), future imports, conditional imports (try/except), lazy imports (inside functions), TYPE_CHECKING imports, `importlib.import_module()`, `__import__()`, `pkgutil.extend_path()`. Created `PythonBytecodeImportExtractor` for .pyc fallback supporting Python 3.8-3.13 magic numbers. Created `PythonImportGraph` for dependency graph with: forward/reverse edges, cycle detection, topological ordering, relative import resolution. Created `PythonImportAnalysis` wrapper categorizing imports as stdlib/third-party/local with transitive dependency analysis. Files: `Internal/Imports/`. Tests: `Imports/PythonImportExtractorTests.cs`, `Imports/PythonImportGraphTests.cs`. |
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
|
||||
@@ -12,7 +13,9 @@ internal static class RubyObservationBuilder
|
||||
RubyRuntimeGraph runtimeGraph,
|
||||
RubyCapabilities capabilities,
|
||||
RubyBundlerConfig bundlerConfig,
|
||||
string? bundledWith)
|
||||
string? bundledWith,
|
||||
RubyContainerInfo? containerInfo = null,
|
||||
RubyRuntimeEvidenceResult? runtimeEvidence = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
ArgumentNullException.ThrowIfNull(lockData);
|
||||
@@ -20,6 +23,9 @@ internal static class RubyObservationBuilder
|
||||
ArgumentNullException.ThrowIfNull(capabilities);
|
||||
ArgumentNullException.ThrowIfNull(bundlerConfig);
|
||||
|
||||
containerInfo ??= RubyContainerInfo.Empty;
|
||||
runtimeEvidence ??= RubyRuntimeEvidenceResult.Empty;
|
||||
|
||||
var packageItems = packages
|
||||
.OrderBy(static package => package.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static package => package.Version, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -36,7 +42,7 @@ internal static class RubyObservationBuilder
|
||||
.OrderBy(static edge => edge.Package, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var environment = BuildEnvironment(lockData, bundlerConfig, capabilities, bundledWith);
|
||||
var environment = BuildEnvironment(lockData, bundlerConfig, capabilities, bundledWith, containerInfo);
|
||||
|
||||
var capabilitySummary = new RubyObservationCapabilitySummary(
|
||||
capabilities.UsesExec,
|
||||
@@ -50,15 +56,74 @@ internal static class RubyObservationBuilder
|
||||
? null
|
||||
: bundledWith.Trim();
|
||||
|
||||
// Build additional AOC-compliant observations
|
||||
var modules = ImmutableArray<RubyObservationModule>.Empty;
|
||||
var routes = ImmutableArray<RubyObservationRoute>.Empty;
|
||||
var jobs = BuildJobs(capabilities);
|
||||
var tasks = ImmutableArray<RubyObservationTask>.Empty;
|
||||
var configs = BuildConfigs(containerInfo);
|
||||
var warnings = ImmutableArray<RubyObservationWarning>.Empty;
|
||||
|
||||
// Integrate runtime evidence if available (supplements static analysis)
|
||||
RubyObservationRuntimeEvidence? observationRuntimeEvidence = null;
|
||||
if (runtimeEvidence.HasEvidence)
|
||||
{
|
||||
var packageEvidence = RubyRuntimeEvidenceIntegrator.CorrelatePackageEvidence(runtimeEvidence, packages);
|
||||
observationRuntimeEvidence = RubyRuntimeEvidenceIntegrator.BuildRuntimeEvidenceSection(runtimeEvidence, packageEvidence);
|
||||
|
||||
// Enhance runtime edges with runtime-verified flag (without altering static precedence)
|
||||
runtimeItems = RubyRuntimeEvidenceIntegrator.EnhanceRuntimeEdges(runtimeItems, runtimeEvidence, packageEvidence);
|
||||
|
||||
// Enhance capabilities (runtime can only ADD, never remove)
|
||||
capabilitySummary = RubyRuntimeEvidenceIntegrator.EnhanceCapabilities(capabilitySummary, runtimeEvidence);
|
||||
}
|
||||
|
||||
return new RubyObservationDocument(
|
||||
SchemaVersion,
|
||||
packageItems,
|
||||
modules,
|
||||
entrypoints,
|
||||
dependencyItems,
|
||||
runtimeItems,
|
||||
routes,
|
||||
jobs,
|
||||
tasks,
|
||||
configs,
|
||||
warnings,
|
||||
environment,
|
||||
capabilitySummary,
|
||||
normalizedBundler);
|
||||
normalizedBundler,
|
||||
observationRuntimeEvidence);
|
||||
}
|
||||
|
||||
private static ImmutableArray<RubyObservationJob> BuildJobs(RubyCapabilities capabilities)
|
||||
{
|
||||
// Build job observations from detected schedulers
|
||||
// This provides metadata about which job schedulers are present
|
||||
return capabilities.JobSchedulers
|
||||
.Select(scheduler => new RubyObservationJob(
|
||||
Name: scheduler,
|
||||
Type: "scheduler",
|
||||
Queue: null,
|
||||
FilePath: string.Empty,
|
||||
Line: 0,
|
||||
Scheduler: scheduler))
|
||||
.OrderBy(static j => j.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<RubyObservationConfig> BuildConfigs(RubyContainerInfo containerInfo)
|
||||
{
|
||||
// Build config observations from detected web server configurations
|
||||
return containerInfo.WebServerConfigs
|
||||
.Select(config => new RubyObservationConfig(
|
||||
Name: config.ServerType,
|
||||
Type: "web-server",
|
||||
FilePath: config.ConfigPath,
|
||||
Settings: config.Settings))
|
||||
.OrderBy(static c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static c => c.FilePath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<RubyObservationEntrypoint> BuildEntrypoints(
|
||||
@@ -117,7 +182,8 @@ internal static class RubyObservationBuilder
|
||||
RubyLockData lockData,
|
||||
RubyBundlerConfig bundlerConfig,
|
||||
RubyCapabilities capabilities,
|
||||
string? bundledWith)
|
||||
string? bundledWith,
|
||||
RubyContainerInfo containerInfo)
|
||||
{
|
||||
var bundlePaths = bundlerConfig.BundlePaths
|
||||
.OrderBy(static p => p, StringComparer.OrdinalIgnoreCase)
|
||||
@@ -138,13 +204,28 @@ internal static class RubyObservationBuilder
|
||||
.OrderBy(static f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var versionSources = containerInfo.RubyVersions
|
||||
.Select(static v => new RubyObservationVersionSource(v.Version, v.Source, v.SourceType))
|
||||
.ToImmutableArray();
|
||||
|
||||
var webServers = containerInfo.WebServerConfigs
|
||||
.Select(static c => new RubyObservationWebServer(c.ServerType, c.ConfigPath, c.Settings))
|
||||
.ToImmutableArray();
|
||||
|
||||
var nativeExtensions = containerInfo.NativeExtensions
|
||||
.Select(static e => new RubyObservationNativeExtension(e.GemName, e.GemVersion, e.ExtensionPath, e.ExtensionType))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RubyObservationEnvironment(
|
||||
RubyVersion: null,
|
||||
RubyVersion: containerInfo.PrimaryRubyVersion,
|
||||
BundlerVersion: string.IsNullOrWhiteSpace(bundledWith) ? null : bundledWith.Trim(),
|
||||
bundlePaths,
|
||||
gemfiles,
|
||||
lockFiles,
|
||||
frameworks);
|
||||
frameworks,
|
||||
versionSources,
|
||||
webServers,
|
||||
nativeExtensions);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DetectFrameworks(RubyCapabilities capabilities)
|
||||
|
||||
@@ -4,17 +4,25 @@ namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
|
||||
/// <summary>
|
||||
/// AOC-compliant observation document for Ruby project analysis.
|
||||
/// Contains components, entrypoints, dependency edges, and environment profiles.
|
||||
/// Contains components, entrypoints, dependency edges, environment profiles,
|
||||
/// routes, jobs, tasks, configs, and warnings.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationDocument(
|
||||
string Schema,
|
||||
ImmutableArray<RubyObservationPackage> Packages,
|
||||
ImmutableArray<RubyObservationModule> Modules,
|
||||
ImmutableArray<RubyObservationEntrypoint> Entrypoints,
|
||||
ImmutableArray<RubyObservationDependencyEdge> DependencyEdges,
|
||||
ImmutableArray<RubyObservationRuntimeEdge> RuntimeEdges,
|
||||
ImmutableArray<RubyObservationRoute> Routes,
|
||||
ImmutableArray<RubyObservationJob> Jobs,
|
||||
ImmutableArray<RubyObservationTask> Tasks,
|
||||
ImmutableArray<RubyObservationConfig> Configs,
|
||||
ImmutableArray<RubyObservationWarning> Warnings,
|
||||
RubyObservationEnvironment Environment,
|
||||
RubyObservationCapabilitySummary Capabilities,
|
||||
string? BundledWith);
|
||||
string? BundledWith,
|
||||
RubyObservationRuntimeEvidence? RuntimeEvidence = null);
|
||||
|
||||
internal sealed record RubyObservationPackage(
|
||||
string Name,
|
||||
@@ -26,6 +34,18 @@ internal sealed record RubyObservationPackage(
|
||||
string? Artifact,
|
||||
ImmutableArray<string> Groups);
|
||||
|
||||
/// <summary>
|
||||
/// Ruby module or class detected in the project.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationModule(
|
||||
string Name,
|
||||
string Type,
|
||||
string FilePath,
|
||||
int Line,
|
||||
string? ParentModule,
|
||||
ImmutableArray<string> Includes,
|
||||
ImmutableArray<string> Extends);
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint detected in the Ruby project (Rakefile, bin scripts, config.ru, etc).
|
||||
/// </summary>
|
||||
@@ -34,6 +54,59 @@ internal sealed record RubyObservationEntrypoint(
|
||||
string Type,
|
||||
ImmutableArray<string> RequiredGems);
|
||||
|
||||
/// <summary>
|
||||
/// Route definition detected in Rails/Sinatra/Grape applications.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationRoute(
|
||||
string HttpMethod,
|
||||
string Path,
|
||||
string? Controller,
|
||||
string? Action,
|
||||
string FilePath,
|
||||
int Line,
|
||||
string Framework);
|
||||
|
||||
/// <summary>
|
||||
/// Background job definition (Sidekiq, Resque, ActiveJob, etc).
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationJob(
|
||||
string Name,
|
||||
string Type,
|
||||
string? Queue,
|
||||
string FilePath,
|
||||
int Line,
|
||||
string Scheduler);
|
||||
|
||||
/// <summary>
|
||||
/// Rake task definition.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationTask(
|
||||
string Name,
|
||||
string? Description,
|
||||
string FilePath,
|
||||
int Line,
|
||||
ImmutableArray<string> Prerequisites,
|
||||
ImmutableArray<string> ShellCommands);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration file detected in the project.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationConfig(
|
||||
string Name,
|
||||
string Type,
|
||||
string FilePath,
|
||||
ImmutableDictionary<string, string> Settings);
|
||||
|
||||
/// <summary>
|
||||
/// Analysis warning generated during scanning.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationWarning(
|
||||
string Code,
|
||||
string Message,
|
||||
string? FilePath,
|
||||
int? Line,
|
||||
string Severity);
|
||||
|
||||
internal sealed record RubyObservationDependencyEdge(
|
||||
string FromPackage,
|
||||
string ToPackage,
|
||||
@@ -55,10 +128,112 @@ internal sealed record RubyObservationEnvironment(
|
||||
ImmutableArray<string> BundlePaths,
|
||||
ImmutableArray<string> Gemfiles,
|
||||
ImmutableArray<string> LockFiles,
|
||||
ImmutableArray<string> Frameworks);
|
||||
ImmutableArray<string> Frameworks,
|
||||
ImmutableArray<RubyObservationVersionSource> RubyVersionSources,
|
||||
ImmutableArray<RubyObservationWebServer> WebServers,
|
||||
ImmutableArray<RubyObservationNativeExtension> NativeExtensions);
|
||||
|
||||
/// <summary>
|
||||
/// Ruby version source with provenance.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationVersionSource(
|
||||
string? Version,
|
||||
string Source,
|
||||
string SourceType);
|
||||
|
||||
/// <summary>
|
||||
/// Web server configuration detected in the project.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationWebServer(
|
||||
string ServerType,
|
||||
string ConfigPath,
|
||||
ImmutableDictionary<string, string> Settings);
|
||||
|
||||
/// <summary>
|
||||
/// Native extension detected in an installed gem.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationNativeExtension(
|
||||
string GemName,
|
||||
string GemVersion,
|
||||
string ExtensionPath,
|
||||
string ExtensionType);
|
||||
|
||||
internal sealed record RubyObservationCapabilitySummary(
|
||||
bool UsesExec,
|
||||
bool UsesNetwork,
|
||||
bool UsesSerialization,
|
||||
ImmutableArray<string> JobSchedulers);
|
||||
|
||||
/// <summary>
|
||||
/// Optional runtime evidence section. This supplements static analysis but never overrides it.
|
||||
/// Path hashes are included for secure evidence correlation.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationRuntimeEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether runtime evidence was available.
|
||||
/// </summary>
|
||||
public bool HasEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ruby version detected at runtime (may differ from static detection).
|
||||
/// </summary>
|
||||
public string? RuntimeRubyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ruby platform from runtime.
|
||||
/// </summary>
|
||||
public string? RuntimeRubyPlatform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of features loaded during runtime.
|
||||
/// </summary>
|
||||
public int LoadedFeaturesCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Packages that were actually loaded at runtime.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> LoadedPackages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files that were loaded at runtime.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> LoadedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from path SHA-256 hash to normalized path for secure correlation.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> PathHashes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities detected at runtime (supplements static capabilities).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RuntimeCapabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime errors encountered during execution.
|
||||
/// </summary>
|
||||
public ImmutableArray<RubyObservationRuntimeError> Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty runtime evidence instance.
|
||||
/// </summary>
|
||||
public static RubyObservationRuntimeEvidence Empty { get; } = new()
|
||||
{
|
||||
HasEvidence = false,
|
||||
LoadedPackages = ImmutableArray<string>.Empty,
|
||||
LoadedFiles = ImmutableArray<string>.Empty,
|
||||
PathHashes = ImmutableDictionary<string, string>.Empty,
|
||||
RuntimeCapabilities = ImmutableArray<string>.Empty,
|
||||
Errors = ImmutableArray<RubyObservationRuntimeError>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime error captured during execution.
|
||||
/// </summary>
|
||||
internal sealed record RubyObservationRuntimeError(
|
||||
string Timestamp,
|
||||
string Message,
|
||||
string? Path,
|
||||
string? PathSha256);
|
||||
|
||||
@@ -19,12 +19,19 @@ internal static class RubyObservationSerializer
|
||||
|
||||
writer.WriteString("$schema", document.Schema);
|
||||
WritePackages(writer, document.Packages);
|
||||
WriteModules(writer, document.Modules);
|
||||
WriteEntrypoints(writer, document.Entrypoints);
|
||||
WriteDependencyEdges(writer, document.DependencyEdges);
|
||||
WriteRuntimeEdges(writer, document.RuntimeEdges);
|
||||
WriteRoutes(writer, document.Routes);
|
||||
WriteJobs(writer, document.Jobs);
|
||||
WriteTasks(writer, document.Tasks);
|
||||
WriteConfigs(writer, document.Configs);
|
||||
WriteWarnings(writer, document.Warnings);
|
||||
WriteEnvironment(writer, document.Environment);
|
||||
WriteCapabilities(writer, document.Capabilities);
|
||||
WriteBundledWith(writer, document.BundledWith);
|
||||
WriteRuntimeEvidence(writer, document.RuntimeEvidence);
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
@@ -169,9 +176,89 @@ internal static class RubyObservationSerializer
|
||||
WriteStringArray(writer, "frameworks", environment.Frameworks);
|
||||
}
|
||||
|
||||
if (environment.RubyVersionSources.Length > 0)
|
||||
{
|
||||
WriteVersionSources(writer, environment.RubyVersionSources);
|
||||
}
|
||||
|
||||
if (environment.WebServers.Length > 0)
|
||||
{
|
||||
WriteWebServers(writer, environment.WebServers);
|
||||
}
|
||||
|
||||
if (environment.NativeExtensions.Length > 0)
|
||||
{
|
||||
WriteNativeExtensions(writer, environment.NativeExtensions);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteVersionSources(Utf8JsonWriter writer, ImmutableArray<RubyObservationVersionSource> sources)
|
||||
{
|
||||
writer.WritePropertyName("rubyVersionSources");
|
||||
writer.WriteStartArray();
|
||||
foreach (var source in sources)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
if (!string.IsNullOrWhiteSpace(source.Version))
|
||||
{
|
||||
writer.WriteString("version", source.Version);
|
||||
}
|
||||
|
||||
writer.WriteString("source", source.Source);
|
||||
writer.WriteString("sourceType", source.SourceType);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteWebServers(Utf8JsonWriter writer, ImmutableArray<RubyObservationWebServer> webServers)
|
||||
{
|
||||
writer.WritePropertyName("webServers");
|
||||
writer.WriteStartArray();
|
||||
foreach (var server in webServers)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("serverType", server.ServerType);
|
||||
writer.WriteString("configPath", server.ConfigPath);
|
||||
|
||||
if (server.Settings.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("settings");
|
||||
writer.WriteStartObject();
|
||||
foreach (var setting in server.Settings.OrderBy(static kv => kv.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteString(setting.Key, setting.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteNativeExtensions(Utf8JsonWriter writer, ImmutableArray<RubyObservationNativeExtension> extensions)
|
||||
{
|
||||
writer.WritePropertyName("nativeExtensions");
|
||||
writer.WriteStartArray();
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("gemName", ext.GemName);
|
||||
writer.WriteString("gemVersion", ext.GemVersion);
|
||||
writer.WriteString("extensionPath", ext.ExtensionPath);
|
||||
writer.WriteString("extensionType", ext.ExtensionType);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteCapabilities(Utf8JsonWriter writer, RubyObservationCapabilitySummary summary)
|
||||
{
|
||||
writer.WritePropertyName("capabilities");
|
||||
@@ -183,6 +270,212 @@ internal static class RubyObservationSerializer
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteModules(Utf8JsonWriter writer, ImmutableArray<RubyObservationModule> modules)
|
||||
{
|
||||
if (modules.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("modules");
|
||||
writer.WriteStartArray();
|
||||
foreach (var module in modules)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", module.Name);
|
||||
writer.WriteString("type", module.Type);
|
||||
writer.WriteString("filePath", module.FilePath);
|
||||
writer.WriteNumber("line", module.Line);
|
||||
if (!string.IsNullOrWhiteSpace(module.ParentModule))
|
||||
{
|
||||
writer.WriteString("parentModule", module.ParentModule);
|
||||
}
|
||||
|
||||
if (module.Includes.Length > 0)
|
||||
{
|
||||
WriteStringArray(writer, "includes", module.Includes);
|
||||
}
|
||||
|
||||
if (module.Extends.Length > 0)
|
||||
{
|
||||
WriteStringArray(writer, "extends", module.Extends);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteRoutes(Utf8JsonWriter writer, ImmutableArray<RubyObservationRoute> routes)
|
||||
{
|
||||
if (routes.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("routes");
|
||||
writer.WriteStartArray();
|
||||
foreach (var route in routes)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("httpMethod", route.HttpMethod);
|
||||
writer.WriteString("path", route.Path);
|
||||
if (!string.IsNullOrWhiteSpace(route.Controller))
|
||||
{
|
||||
writer.WriteString("controller", route.Controller);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(route.Action))
|
||||
{
|
||||
writer.WriteString("action", route.Action);
|
||||
}
|
||||
|
||||
writer.WriteString("filePath", route.FilePath);
|
||||
writer.WriteNumber("line", route.Line);
|
||||
writer.WriteString("framework", route.Framework);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteJobs(Utf8JsonWriter writer, ImmutableArray<RubyObservationJob> jobs)
|
||||
{
|
||||
if (jobs.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("jobs");
|
||||
writer.WriteStartArray();
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", job.Name);
|
||||
writer.WriteString("type", job.Type);
|
||||
if (!string.IsNullOrWhiteSpace(job.Queue))
|
||||
{
|
||||
writer.WriteString("queue", job.Queue);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.FilePath))
|
||||
{
|
||||
writer.WriteString("filePath", job.FilePath);
|
||||
}
|
||||
|
||||
if (job.Line > 0)
|
||||
{
|
||||
writer.WriteNumber("line", job.Line);
|
||||
}
|
||||
|
||||
writer.WriteString("scheduler", job.Scheduler);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteTasks(Utf8JsonWriter writer, ImmutableArray<RubyObservationTask> tasks)
|
||||
{
|
||||
if (tasks.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("tasks");
|
||||
writer.WriteStartArray();
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", task.Name);
|
||||
if (!string.IsNullOrWhiteSpace(task.Description))
|
||||
{
|
||||
writer.WriteString("description", task.Description);
|
||||
}
|
||||
|
||||
writer.WriteString("filePath", task.FilePath);
|
||||
writer.WriteNumber("line", task.Line);
|
||||
if (task.Prerequisites.Length > 0)
|
||||
{
|
||||
WriteStringArray(writer, "prerequisites", task.Prerequisites);
|
||||
}
|
||||
|
||||
if (task.ShellCommands.Length > 0)
|
||||
{
|
||||
WriteStringArray(writer, "shellCommands", task.ShellCommands);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteConfigs(Utf8JsonWriter writer, ImmutableArray<RubyObservationConfig> configs)
|
||||
{
|
||||
if (configs.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("configs");
|
||||
writer.WriteStartArray();
|
||||
foreach (var config in configs)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", config.Name);
|
||||
writer.WriteString("type", config.Type);
|
||||
writer.WriteString("filePath", config.FilePath);
|
||||
if (config.Settings.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("settings");
|
||||
writer.WriteStartObject();
|
||||
foreach (var setting in config.Settings.OrderBy(static kv => kv.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteString(setting.Key, setting.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteWarnings(Utf8JsonWriter writer, ImmutableArray<RubyObservationWarning> warnings)
|
||||
{
|
||||
if (warnings.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("warnings");
|
||||
writer.WriteStartArray();
|
||||
foreach (var warning in warnings)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("code", warning.Code);
|
||||
writer.WriteString("message", warning.Message);
|
||||
if (!string.IsNullOrWhiteSpace(warning.FilePath))
|
||||
{
|
||||
writer.WriteString("filePath", warning.FilePath);
|
||||
}
|
||||
|
||||
if (warning.Line.HasValue)
|
||||
{
|
||||
writer.WriteNumber("line", warning.Line.Value);
|
||||
}
|
||||
|
||||
writer.WriteString("severity", warning.Severity);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteBundledWith(Utf8JsonWriter writer, string? bundledWith)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundledWith))
|
||||
@@ -204,4 +497,93 @@ internal static class RubyObservationSerializer
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteRuntimeEvidence(Utf8JsonWriter writer, RubyObservationRuntimeEvidence? evidence)
|
||||
{
|
||||
// Only write runtime evidence if present (optional, supplements static analysis)
|
||||
if (evidence is null || !evidence.HasEvidence)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
writer.WritePropertyName("runtimeEvidence");
|
||||
writer.WriteStartObject();
|
||||
|
||||
writer.WriteBoolean("hasEvidence", evidence.HasEvidence);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evidence.RuntimeRubyVersion))
|
||||
{
|
||||
writer.WriteString("runtimeRubyVersion", evidence.RuntimeRubyVersion);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evidence.RuntimeRubyPlatform))
|
||||
{
|
||||
writer.WriteString("runtimeRubyPlatform", evidence.RuntimeRubyPlatform);
|
||||
}
|
||||
|
||||
if (evidence.LoadedFeaturesCount > 0)
|
||||
{
|
||||
writer.WriteNumber("loadedFeaturesCount", evidence.LoadedFeaturesCount);
|
||||
}
|
||||
|
||||
if (evidence.LoadedPackages.Length > 0)
|
||||
{
|
||||
WriteStringArray(writer, "loadedPackages", evidence.LoadedPackages);
|
||||
}
|
||||
|
||||
if (evidence.LoadedFiles.Length > 0)
|
||||
{
|
||||
WriteStringArray(writer, "loadedFiles", evidence.LoadedFiles);
|
||||
}
|
||||
|
||||
// Write path hashes for secure correlation
|
||||
if (evidence.PathHashes.Count > 0)
|
||||
{
|
||||
writer.WritePropertyName("pathHashes");
|
||||
writer.WriteStartObject();
|
||||
foreach (var hash in evidence.PathHashes.OrderBy(static kv => kv.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteString(hash.Key, hash.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
if (evidence.RuntimeCapabilities.Length > 0)
|
||||
{
|
||||
WriteStringArray(writer, "runtimeCapabilities", evidence.RuntimeCapabilities);
|
||||
}
|
||||
|
||||
if (evidence.Errors.Length > 0)
|
||||
{
|
||||
WriteRuntimeErrors(writer, evidence.Errors);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteRuntimeErrors(Utf8JsonWriter writer, ImmutableArray<RubyObservationRuntimeError> errors)
|
||||
{
|
||||
writer.WritePropertyName("errors");
|
||||
writer.WriteStartArray();
|
||||
foreach (var error in errors)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("timestamp", error.Timestamp);
|
||||
writer.WriteString("message", error.Message);
|
||||
if (!string.IsNullOrWhiteSpace(error.Path))
|
||||
{
|
||||
writer.WriteString("path", error.Path);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error.PathSha256))
|
||||
{
|
||||
writer.WriteString("pathSha256", error.PathSha256);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Scans OCI container layers for Ruby runtime information:
|
||||
/// - Ruby version detection
|
||||
/// - Installed gems in system paths
|
||||
/// - Native extensions (.so, .bundle)
|
||||
/// - Web server configurations (Puma, Unicorn, Passenger)
|
||||
/// </summary>
|
||||
internal static partial class RubyContainerScanner
|
||||
{
|
||||
private const int MaxConfigFileBytes = 128 * 1024;
|
||||
|
||||
private static readonly string[] LayerRootCandidates = { "layers", ".layers", "layer" };
|
||||
|
||||
private static readonly string[] RubyVersionFiles =
|
||||
{
|
||||
".ruby-version",
|
||||
".tool-versions",
|
||||
"Gemfile"
|
||||
};
|
||||
|
||||
private static readonly string[] RubyVersionPaths =
|
||||
{
|
||||
"usr/local/bin/ruby",
|
||||
"usr/bin/ruby",
|
||||
"opt/ruby/bin/ruby",
|
||||
"home/app/.rbenv/shims/ruby",
|
||||
"home/app/.rvm/rubies"
|
||||
};
|
||||
|
||||
private static readonly string[] WebServerConfigs =
|
||||
{
|
||||
"config/puma.rb",
|
||||
"puma.rb",
|
||||
"config/unicorn.rb",
|
||||
"unicorn.rb",
|
||||
"Passengerfile.json",
|
||||
"config/passenger.yml",
|
||||
"passenger.conf"
|
||||
};
|
||||
|
||||
private static readonly string[] GemInstallPaths =
|
||||
{
|
||||
"usr/local/bundle/gems",
|
||||
"usr/local/lib/ruby/gems",
|
||||
"var/lib/gems",
|
||||
"home/app/.gem",
|
||||
"opt/ruby/lib/ruby/gems"
|
||||
};
|
||||
|
||||
private static readonly string[] NativeExtensionPatterns = { ".so", ".bundle", ".dll" };
|
||||
|
||||
private static readonly Regex RubyVersionRegex = CreateRubyVersionRegex();
|
||||
private static readonly Regex GemfileRubyRegex = CreateGemfileRubyRegex();
|
||||
private static readonly Regex ToolVersionsRubyRegex = CreateToolVersionsRubyRegex();
|
||||
|
||||
public static async ValueTask<RubyContainerInfo> ScanAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var rubyVersions = new List<RubyVersionInfo>();
|
||||
var installedGems = new List<RubyInstalledGem>();
|
||||
var nativeExtensions = new List<RubyNativeExtension>();
|
||||
var webServerConfigs = new List<RubyWebServerConfig>();
|
||||
|
||||
// Scan workspace root
|
||||
await ScanDirectoryAsync(
|
||||
context,
|
||||
context.RootPath,
|
||||
rubyVersions,
|
||||
installedGems,
|
||||
nativeExtensions,
|
||||
webServerConfigs,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Scan OCI layer roots
|
||||
foreach (var layerRoot in EnumerateLayerRoots(context.RootPath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ScanDirectoryAsync(
|
||||
context,
|
||||
layerRoot,
|
||||
rubyVersions,
|
||||
installedGems,
|
||||
nativeExtensions,
|
||||
webServerConfigs,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new RubyContainerInfo(
|
||||
RubyVersions: rubyVersions
|
||||
.OrderBy(static v => v.Version, StringComparer.Ordinal)
|
||||
.ThenBy(static v => v.Source, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray(),
|
||||
InstalledGems: installedGems
|
||||
.DistinctBy(static g => (g.Name.ToLowerInvariant(), g.Version.ToLowerInvariant()))
|
||||
.OrderBy(static g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static g => g.Version, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray(),
|
||||
NativeExtensions: nativeExtensions
|
||||
.OrderBy(static e => e.GemName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static e => e.ExtensionPath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray(),
|
||||
WebServerConfigs: webServerConfigs
|
||||
.OrderBy(static c => c.ServerType, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static c => c.ConfigPath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static async ValueTask ScanDirectoryAsync(
|
||||
LanguageAnalyzerContext context,
|
||||
string rootPath,
|
||||
List<RubyVersionInfo> rubyVersions,
|
||||
List<RubyInstalledGem> installedGems,
|
||||
List<RubyNativeExtension> nativeExtensions,
|
||||
List<RubyWebServerConfig> webServerConfigs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Detect Ruby version from version files
|
||||
foreach (var versionFile in RubyVersionFiles)
|
||||
{
|
||||
var path = Path.Combine(rootPath, versionFile);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var version = await TryExtractRubyVersionFromFileAsync(path, versionFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (version is not null)
|
||||
{
|
||||
rubyVersions.Add(version);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect Ruby version from binary paths
|
||||
foreach (var rubyPath in RubyVersionPaths)
|
||||
{
|
||||
var fullPath = Path.Combine(rootPath, rubyPath);
|
||||
if (File.Exists(fullPath) || Directory.Exists(fullPath))
|
||||
{
|
||||
var relativePath = context.GetRelativePath(fullPath);
|
||||
rubyVersions.Add(new RubyVersionInfo(
|
||||
Version: null,
|
||||
Source: relativePath.Replace('\\', '/'),
|
||||
SourceType: "binary-path"));
|
||||
}
|
||||
}
|
||||
|
||||
// Detect web server configs
|
||||
foreach (var configPath in WebServerConfigs)
|
||||
{
|
||||
var fullPath = Path.Combine(rootPath, configPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativePath = context.GetRelativePath(fullPath);
|
||||
var serverType = InferWebServerType(configPath);
|
||||
var settings = await TryParseWebServerConfigAsync(fullPath, serverType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
webServerConfigs.Add(new RubyWebServerConfig(
|
||||
ServerType: serverType,
|
||||
ConfigPath: relativePath.Replace('\\', '/'),
|
||||
Settings: settings));
|
||||
}
|
||||
|
||||
// Scan gem installation paths for installed gems and native extensions
|
||||
foreach (var gemPath in GemInstallPaths)
|
||||
{
|
||||
var fullPath = Path.Combine(rootPath, gemPath);
|
||||
if (!Directory.Exists(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ScanGemDirectory(context, fullPath, installedGems, nativeExtensions, cancellationToken);
|
||||
}
|
||||
|
||||
// Also scan vendor paths
|
||||
ScanVendorPaths(context, rootPath, installedGems, nativeExtensions, cancellationToken);
|
||||
}
|
||||
|
||||
private static async ValueTask<RubyVersionInfo?> TryExtractRubyVersionFromFileAsync(
|
||||
string filePath,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string? version = null;
|
||||
|
||||
if (fileName.Equals(".ruby-version", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
version = content.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(version) && RubyVersionRegex.IsMatch(version))
|
||||
{
|
||||
return new RubyVersionInfo(version, fileName, "ruby-version");
|
||||
}
|
||||
}
|
||||
else if (fileName.Equals(".tool-versions", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var match = ToolVersionsRubyRegex.Match(content);
|
||||
if (match.Success && match.Groups["version"].Success)
|
||||
{
|
||||
version = match.Groups["version"].Value.Trim();
|
||||
return new RubyVersionInfo(version, fileName, "tool-versions");
|
||||
}
|
||||
}
|
||||
else if (fileName.Equals("Gemfile", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var match = GemfileRubyRegex.Match(content);
|
||||
if (match.Success && match.Groups["version"].Success)
|
||||
{
|
||||
version = match.Groups["version"].Value.Trim();
|
||||
return new RubyVersionInfo(version, fileName, "gemfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string InferWebServerType(string configPath)
|
||||
{
|
||||
var fileName = Path.GetFileName(configPath).ToLowerInvariant();
|
||||
|
||||
if (fileName.Contains("puma", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "puma";
|
||||
}
|
||||
|
||||
if (fileName.Contains("unicorn", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "unicorn";
|
||||
}
|
||||
|
||||
if (fileName.Contains("passenger", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "passenger";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static async ValueTask<ImmutableDictionary<string, string>> TryParseWebServerConfigAsync(
|
||||
string filePath,
|
||||
string serverType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
if (!info.Exists || info.Length > MaxConfigFileBytes)
|
||||
{
|
||||
return settings.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
switch (serverType)
|
||||
{
|
||||
case "puma":
|
||||
ExtractPumaSettings(content, settings);
|
||||
break;
|
||||
case "unicorn":
|
||||
ExtractUnicornSettings(content, settings);
|
||||
break;
|
||||
case "passenger":
|
||||
// Passenger config is typically JSON/YAML - just note presence
|
||||
settings["config_type"] = Path.GetExtension(filePath).TrimStart('.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore read errors
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
return settings.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void ExtractPumaSettings(string content, Dictionary<string, string> settings)
|
||||
{
|
||||
// Extract workers count: workers ENV.fetch("WEB_CONCURRENCY") { 2 } or workers 2
|
||||
var workersMatch = Regex.Match(content, @"\bworkers\s+(?:ENV\.fetch\([^)]+\)\s*\{\s*(\d+)\s*\}|(\d+))");
|
||||
if (workersMatch.Success)
|
||||
{
|
||||
var count = workersMatch.Groups[1].Success ? workersMatch.Groups[1].Value : workersMatch.Groups[2].Value;
|
||||
settings["workers"] = count;
|
||||
}
|
||||
|
||||
// Extract threads: threads_count, count or threads 5, 5
|
||||
var threadsMatch = Regex.Match(content, @"\bthreads\s+(\d+),?\s*(\d+)?");
|
||||
if (threadsMatch.Success)
|
||||
{
|
||||
settings["threads_min"] = threadsMatch.Groups[1].Value;
|
||||
if (threadsMatch.Groups[2].Success)
|
||||
{
|
||||
settings["threads_max"] = threadsMatch.Groups[2].Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract bind
|
||||
var bindMatch = Regex.Match(content, @"\bbind\s+['""]([^'""]+)['""]");
|
||||
if (bindMatch.Success)
|
||||
{
|
||||
settings["bind"] = bindMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
// Extract port
|
||||
var portMatch = Regex.Match(content, @"\bport\s+(?:ENV\.fetch\([^)]+\)\s*\{\s*(\d+)\s*\}|(\d+))");
|
||||
if (portMatch.Success)
|
||||
{
|
||||
var port = portMatch.Groups[1].Success ? portMatch.Groups[1].Value : portMatch.Groups[2].Value;
|
||||
settings["port"] = port;
|
||||
}
|
||||
|
||||
// Detect preload_app!
|
||||
if (content.Contains("preload_app!", StringComparison.Ordinal))
|
||||
{
|
||||
settings["preload_app"] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractUnicornSettings(string content, Dictionary<string, string> settings)
|
||||
{
|
||||
// Extract worker_processes
|
||||
var workersMatch = Regex.Match(content, @"\bworker_processes\s+(\d+)");
|
||||
if (workersMatch.Success)
|
||||
{
|
||||
settings["worker_processes"] = workersMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
// Extract timeout
|
||||
var timeoutMatch = Regex.Match(content, @"\btimeout\s+(\d+)");
|
||||
if (timeoutMatch.Success)
|
||||
{
|
||||
settings["timeout"] = timeoutMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
// Extract listen
|
||||
var listenMatch = Regex.Match(content, @"\blisten\s+['""]([^'""]+)['""]");
|
||||
if (listenMatch.Success)
|
||||
{
|
||||
settings["listen"] = listenMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
// Detect preload_app
|
||||
if (Regex.IsMatch(content, @"\bpreload_app\s+true"))
|
||||
{
|
||||
settings["preload_app"] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanGemDirectory(
|
||||
LanguageAnalyzerContext context,
|
||||
string gemsPath,
|
||||
List<RubyInstalledGem> installedGems,
|
||||
List<RubyNativeExtension> nativeExtensions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<string>? directories;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(gemsPath);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var gemDir in directories)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var gemDirName = Path.GetFileName(gemDir);
|
||||
if (!RubyPackageNameParser.TryParse(gemDirName, out var name, out var version, out var platform))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativePath = context.GetRelativePath(gemDir).Replace('\\', '/');
|
||||
var hasNativeExtensions = false;
|
||||
var extensionFiles = new List<string>();
|
||||
|
||||
// Check for native extensions
|
||||
try
|
||||
{
|
||||
var extDir = Path.Combine(gemDir, "ext");
|
||||
var libDir = Path.Combine(gemDir, "lib");
|
||||
|
||||
foreach (var searchDir in new[] { extDir, libDir })
|
||||
{
|
||||
if (!Directory.Exists(searchDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(searchDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var ext = Path.GetExtension(file);
|
||||
if (NativeExtensionPatterns.Contains(ext, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
hasNativeExtensions = true;
|
||||
var extRelativePath = context.GetRelativePath(file).Replace('\\', '/');
|
||||
extensionFiles.Add(extRelativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore scan errors
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
installedGems.Add(new RubyInstalledGem(
|
||||
Name: name,
|
||||
Version: version,
|
||||
Platform: platform,
|
||||
InstallPath: relativePath,
|
||||
HasNativeExtensions: hasNativeExtensions));
|
||||
|
||||
if (hasNativeExtensions)
|
||||
{
|
||||
foreach (var extFile in extensionFiles)
|
||||
{
|
||||
nativeExtensions.Add(new RubyNativeExtension(
|
||||
GemName: name,
|
||||
GemVersion: version,
|
||||
ExtensionPath: extFile,
|
||||
ExtensionType: Path.GetExtension(extFile).TrimStart('.')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanVendorPaths(
|
||||
LanguageAnalyzerContext context,
|
||||
string rootPath,
|
||||
List<RubyInstalledGem> installedGems,
|
||||
List<RubyNativeExtension> nativeExtensions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vendorPaths = new[]
|
||||
{
|
||||
Path.Combine(rootPath, "vendor", "bundle", "ruby"),
|
||||
Path.Combine(rootPath, "vendor", "cache"),
|
||||
Path.Combine(rootPath, ".bundle", "vendor", "ruby")
|
||||
};
|
||||
|
||||
foreach (var vendorPath in vendorPaths)
|
||||
{
|
||||
if (!Directory.Exists(vendorPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check for version subdirectories (e.g., vendor/bundle/ruby/3.2.0/gems)
|
||||
foreach (var versionDir in Directory.EnumerateDirectories(vendorPath))
|
||||
{
|
||||
var gemsDir = Path.Combine(versionDir, "gems");
|
||||
if (Directory.Exists(gemsDir))
|
||||
{
|
||||
ScanGemDirectory(context, gemsDir, installedGems, nativeExtensions, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateLayerRoots(string workspaceRoot)
|
||||
{
|
||||
foreach (var candidate in LayerRootCandidates)
|
||||
{
|
||||
var root = Path.Combine(workspaceRoot, candidate);
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string>? directories;
|
||||
try
|
||||
{
|
||||
directories = Directory.EnumerateDirectories(root);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var layerDirectory in directories)
|
||||
{
|
||||
var fsDirectory = Path.Combine(layerDirectory, "fs");
|
||||
yield return Directory.Exists(fsDirectory) ? fsDirectory : layerDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(?:ruby-)?(\d+\.\d+(?:\.\d+)?(?:-?p?\d+)?)(?:-.*)?$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CreateRubyVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"^ruby\s+(?<version>\d+\.\d+(?:\.\d+)?(?:-?p?\d+)?)", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CreateToolVersionsRubyRegex();
|
||||
|
||||
[GeneratedRegex(@"ruby\s+['""](?<version>[^'""]+)['""]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CreateGemfileRubyRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container scan results with Ruby runtime information.
|
||||
/// </summary>
|
||||
internal sealed record RubyContainerInfo(
|
||||
ImmutableArray<RubyVersionInfo> RubyVersions,
|
||||
ImmutableArray<RubyInstalledGem> InstalledGems,
|
||||
ImmutableArray<RubyNativeExtension> NativeExtensions,
|
||||
ImmutableArray<RubyWebServerConfig> WebServerConfigs)
|
||||
{
|
||||
public static RubyContainerInfo Empty { get; } = new(
|
||||
ImmutableArray<RubyVersionInfo>.Empty,
|
||||
ImmutableArray<RubyInstalledGem>.Empty,
|
||||
ImmutableArray<RubyNativeExtension>.Empty,
|
||||
ImmutableArray<RubyWebServerConfig>.Empty);
|
||||
|
||||
public bool HasRubyVersion => RubyVersions.Length > 0;
|
||||
public bool HasInstalledGems => InstalledGems.Length > 0;
|
||||
public bool HasNativeExtensions => NativeExtensions.Length > 0;
|
||||
public bool HasWebServerConfigs => WebServerConfigs.Length > 0;
|
||||
|
||||
public string? PrimaryRubyVersion => RubyVersions
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v.Version))
|
||||
.Select(static v => v.Version)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ruby version detected from a source file or binary path.
|
||||
/// </summary>
|
||||
internal sealed record RubyVersionInfo(
|
||||
string? Version,
|
||||
string Source,
|
||||
string SourceType);
|
||||
|
||||
/// <summary>
|
||||
/// Gem installed in a container layer or vendor path.
|
||||
/// </summary>
|
||||
internal sealed record RubyInstalledGem(
|
||||
string Name,
|
||||
string Version,
|
||||
string? Platform,
|
||||
string InstallPath,
|
||||
bool HasNativeExtensions);
|
||||
|
||||
/// <summary>
|
||||
/// Native extension file found in a gem.
|
||||
/// </summary>
|
||||
internal sealed record RubyNativeExtension(
|
||||
string GemName,
|
||||
string GemVersion,
|
||||
string ExtensionPath,
|
||||
string ExtensionType);
|
||||
|
||||
/// <summary>
|
||||
/// Web server configuration file detected.
|
||||
/// </summary>
|
||||
internal sealed record RubyWebServerConfig(
|
||||
string ServerType,
|
||||
string ConfigPath,
|
||||
ImmutableDictionary<string, string> Settings);
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single runtime evidence entry captured during execution.
|
||||
/// </summary>
|
||||
internal sealed record RubyRuntimeEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of the event (e.g., "ruby.require", "ruby.load", "ruby.method.call").
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC ISO-8601 timestamp of the event.
|
||||
/// </summary>
|
||||
public required string Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized relative path to the file.
|
||||
/// </summary>
|
||||
public string? Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the normalized path for secure evidence correlation.
|
||||
/// </summary>
|
||||
public string? PathSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Feature or gem name being required (for require events).
|
||||
/// </summary>
|
||||
public string? Feature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
public bool? Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected capability (exec, net, serialize, etc.).
|
||||
/// </summary>
|
||||
public string? Capability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Class name (for method call events).
|
||||
/// </summary>
|
||||
public string? ClassName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method name (for method call events).
|
||||
/// </summary>
|
||||
public string? MethodName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number in the source file.
|
||||
/// </summary>
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context or error message.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of processing Ruby runtime evidence.
|
||||
/// </summary>
|
||||
internal sealed record RubyRuntimeEvidenceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// All captured evidence entries, ordered by timestamp.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RubyRuntimeEvidence> Entries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files that were actually loaded at runtime (require/load events).
|
||||
/// </summary>
|
||||
public required IReadOnlySet<string> LoadedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mapping from path hash to normalized path for secure correlation.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, string> PathHashMap { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gems/features that were required at runtime.
|
||||
/// </summary>
|
||||
public required IReadOnlySet<string> RequiredFeatures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected capabilities from runtime analysis.
|
||||
/// </summary>
|
||||
public required IReadOnlySet<string> DetectedCapabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ruby version detected from runtime (may differ from static analysis).
|
||||
/// </summary>
|
||||
public string? RuntimeRubyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ruby platform from runtime.
|
||||
/// </summary>
|
||||
public string? RuntimeRubyPlatform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of features loaded during runtime.
|
||||
/// </summary>
|
||||
public int LoadedFeaturesCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime errors encountered during execution.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RubyRuntimeError> Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method calls traced during execution.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RubyRuntimeMethodCall> MethodCalls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any runtime evidence was available.
|
||||
/// </summary>
|
||||
public bool HasEvidence => Entries.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Empty result instance.
|
||||
/// </summary>
|
||||
public static RubyRuntimeEvidenceResult Empty { get; } = new()
|
||||
{
|
||||
Entries = Array.Empty<RubyRuntimeEvidence>(),
|
||||
LoadedFiles = new HashSet<string>(),
|
||||
PathHashMap = new Dictionary<string, string>(),
|
||||
RequiredFeatures = new HashSet<string>(),
|
||||
DetectedCapabilities = new HashSet<string>(),
|
||||
Errors = Array.Empty<RubyRuntimeError>(),
|
||||
MethodCalls = Array.Empty<RubyRuntimeMethodCall>()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime error captured during execution.
|
||||
/// </summary>
|
||||
internal sealed record RubyRuntimeError(
|
||||
string Timestamp,
|
||||
string Message,
|
||||
string? Path,
|
||||
string? PathSha256);
|
||||
|
||||
/// <summary>
|
||||
/// Method call traced during execution.
|
||||
/// </summary>
|
||||
internal sealed record RubyRuntimeMethodCall(
|
||||
string Timestamp,
|
||||
string ClassName,
|
||||
string MethodName,
|
||||
string? Path,
|
||||
string? PathSha256,
|
||||
int? Line);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime evidence correlation result for a single package.
|
||||
/// </summary>
|
||||
internal sealed record RubyRuntimePackageEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the package was actually loaded at runtime.
|
||||
/// </summary>
|
||||
public bool LoadedAtRuntime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files from the package that were loaded.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> LoadedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Method calls traced for the package.
|
||||
/// </summary>
|
||||
public required ImmutableArray<RubyRuntimeMethodCall> MethodCalls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities detected for the package at runtime.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> RuntimeCapabilities { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empty evidence instance.
|
||||
/// </summary>
|
||||
public static RubyRuntimePackageEvidence Empty { get; } = new()
|
||||
{
|
||||
LoadedAtRuntime = false,
|
||||
LoadedFiles = ImmutableArray<string>.Empty,
|
||||
MethodCalls = ImmutableArray<RubyRuntimeMethodCall>.Empty,
|
||||
RuntimeCapabilities = ImmutableArray<string>.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Collects and processes Ruby runtime evidence from NDJSON files generated by the runtime shim.
|
||||
/// </summary>
|
||||
internal static class RubyRuntimeEvidenceCollector
|
||||
{
|
||||
private const string DefaultOutputFileName = "ruby-runtime.ndjson";
|
||||
|
||||
/// <summary>
|
||||
/// Known evidence file patterns to search for.
|
||||
/// </summary>
|
||||
private static readonly string[] s_evidenceFilePatterns =
|
||||
[
|
||||
"ruby-runtime.ndjson",
|
||||
".stella/ruby-runtime.ndjson",
|
||||
"tmp/ruby-runtime.ndjson",
|
||||
"log/ruby-runtime.ndjson"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Collects runtime evidence from the default output file in the specified directory.
|
||||
/// </summary>
|
||||
public static async ValueTask<RubyRuntimeEvidenceResult> CollectAsync(
|
||||
string directory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
|
||||
// Search for evidence files
|
||||
foreach (var pattern in s_evidenceFilePatterns)
|
||||
{
|
||||
var candidatePath = Path.Combine(directory, pattern);
|
||||
if (File.Exists(candidatePath))
|
||||
{
|
||||
return await CollectFromFileAsync(candidatePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return RubyRuntimeEvidenceResult.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects runtime evidence from a specific NDJSON file.
|
||||
/// </summary>
|
||||
public static async ValueTask<RubyRuntimeEvidenceResult> CollectFromFileAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return RubyRuntimeEvidenceResult.Empty;
|
||||
}
|
||||
|
||||
var entries = new List<RubyRuntimeEvidence>();
|
||||
var loadedFiles = new HashSet<string>(StringComparer.Ordinal);
|
||||
var pathHashMap = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var requiredFeatures = new HashSet<string>(StringComparer.Ordinal);
|
||||
var capabilities = new HashSet<string>(StringComparer.Ordinal);
|
||||
var errors = new List<RubyRuntimeError>();
|
||||
var methodCalls = new List<RubyRuntimeMethodCall>();
|
||||
string? rubyVersion = null;
|
||||
string? rubyPlatform = null;
|
||||
int loadedFeaturesCount = 0;
|
||||
|
||||
await foreach (var line in File.ReadLinesAsync(filePath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var type = GetString(root, "type");
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var timestamp = GetString(root, "ts") ?? string.Empty;
|
||||
|
||||
// Process different event types
|
||||
switch (type)
|
||||
{
|
||||
case "ruby.runtime.start":
|
||||
rubyVersion = GetString(root, "ruby_version");
|
||||
rubyPlatform = GetString(root, "ruby_platform");
|
||||
break;
|
||||
|
||||
case "ruby.runtime.end":
|
||||
loadedFeaturesCount = GetInt(root, "loaded_features_count") ?? 0;
|
||||
ProcessCapabilitiesArray(root, capabilities);
|
||||
break;
|
||||
|
||||
case "ruby.require":
|
||||
ProcessRequireEvent(root, timestamp, entries, loadedFiles, pathHashMap, requiredFeatures, capabilities);
|
||||
break;
|
||||
|
||||
case "ruby.load":
|
||||
ProcessLoadEvent(root, timestamp, entries, loadedFiles, pathHashMap);
|
||||
break;
|
||||
|
||||
case "ruby.method.call":
|
||||
ProcessMethodCallEvent(root, timestamp, entries, methodCalls, pathHashMap);
|
||||
break;
|
||||
|
||||
case "ruby.runtime.error":
|
||||
ProcessErrorEvent(root, timestamp, entries, errors);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown event type - still record it
|
||||
entries.Add(new RubyRuntimeEvidence
|
||||
{
|
||||
Type = type,
|
||||
Timestamp = timestamp
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp for deterministic ordering
|
||||
entries.Sort((a, b) =>
|
||||
{
|
||||
var cmp = string.Compare(a.Timestamp, b.Timestamp, StringComparison.Ordinal);
|
||||
return cmp != 0 ? cmp : string.Compare(a.Type, b.Type, StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
return new RubyRuntimeEvidenceResult
|
||||
{
|
||||
Entries = entries,
|
||||
LoadedFiles = loadedFiles,
|
||||
PathHashMap = pathHashMap,
|
||||
RequiredFeatures = requiredFeatures,
|
||||
DetectedCapabilities = capabilities,
|
||||
RuntimeRubyVersion = rubyVersion,
|
||||
RuntimeRubyPlatform = rubyPlatform,
|
||||
LoadedFeaturesCount = loadedFeaturesCount,
|
||||
Errors = errors,
|
||||
MethodCalls = methodCalls
|
||||
};
|
||||
}
|
||||
|
||||
private static void ProcessRequireEvent(
|
||||
JsonElement root,
|
||||
string timestamp,
|
||||
List<RubyRuntimeEvidence> entries,
|
||||
HashSet<string> loadedFiles,
|
||||
Dictionary<string, string> pathHashMap,
|
||||
HashSet<string> requiredFeatures,
|
||||
HashSet<string> capabilities)
|
||||
{
|
||||
var feature = GetString(root, "feature");
|
||||
var success = GetBool(root, "success") ?? false;
|
||||
var capability = GetString(root, "capability");
|
||||
|
||||
string? path = null;
|
||||
string? pathSha256 = null;
|
||||
|
||||
if (root.TryGetProperty("module", out var moduleProp) && moduleProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
path = GetString(moduleProp, "normalized");
|
||||
pathSha256 = GetString(moduleProp, "path_sha256");
|
||||
}
|
||||
|
||||
// Track loaded files
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
loadedFiles.Add(path);
|
||||
if (!string.IsNullOrWhiteSpace(pathSha256))
|
||||
{
|
||||
pathHashMap[pathSha256] = path;
|
||||
}
|
||||
}
|
||||
|
||||
// Track required features
|
||||
if (!string.IsNullOrWhiteSpace(feature))
|
||||
{
|
||||
requiredFeatures.Add(feature);
|
||||
}
|
||||
|
||||
// Track capabilities
|
||||
if (!string.IsNullOrWhiteSpace(capability))
|
||||
{
|
||||
capabilities.Add(capability);
|
||||
}
|
||||
|
||||
entries.Add(new RubyRuntimeEvidence
|
||||
{
|
||||
Type = "ruby.require",
|
||||
Timestamp = timestamp,
|
||||
Path = path,
|
||||
PathSha256 = pathSha256,
|
||||
Feature = feature,
|
||||
Success = success,
|
||||
Capability = capability
|
||||
});
|
||||
}
|
||||
|
||||
private static void ProcessLoadEvent(
|
||||
JsonElement root,
|
||||
string timestamp,
|
||||
List<RubyRuntimeEvidence> entries,
|
||||
HashSet<string> loadedFiles,
|
||||
Dictionary<string, string> pathHashMap)
|
||||
{
|
||||
string? path = null;
|
||||
string? pathSha256 = null;
|
||||
|
||||
if (root.TryGetProperty("module", out var moduleProp) && moduleProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
path = GetString(moduleProp, "normalized");
|
||||
pathSha256 = GetString(moduleProp, "path_sha256");
|
||||
}
|
||||
|
||||
// Track loaded files
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
loadedFiles.Add(path);
|
||||
if (!string.IsNullOrWhiteSpace(pathSha256))
|
||||
{
|
||||
pathHashMap[pathSha256] = path;
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(new RubyRuntimeEvidence
|
||||
{
|
||||
Type = "ruby.load",
|
||||
Timestamp = timestamp,
|
||||
Path = path,
|
||||
PathSha256 = pathSha256
|
||||
});
|
||||
}
|
||||
|
||||
private static void ProcessMethodCallEvent(
|
||||
JsonElement root,
|
||||
string timestamp,
|
||||
List<RubyRuntimeEvidence> entries,
|
||||
List<RubyRuntimeMethodCall> methodCalls,
|
||||
Dictionary<string, string> pathHashMap)
|
||||
{
|
||||
var className = GetString(root, "class");
|
||||
var methodName = GetString(root, "method");
|
||||
string? path = null;
|
||||
string? pathSha256 = null;
|
||||
int? line = null;
|
||||
|
||||
if (root.TryGetProperty("location", out var locProp) && locProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
path = GetString(locProp, "path");
|
||||
pathSha256 = GetString(locProp, "path_sha256");
|
||||
line = GetInt(locProp, "line");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path) && !string.IsNullOrWhiteSpace(pathSha256))
|
||||
{
|
||||
pathHashMap[pathSha256] = path;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(className) && !string.IsNullOrWhiteSpace(methodName))
|
||||
{
|
||||
methodCalls.Add(new RubyRuntimeMethodCall(
|
||||
timestamp,
|
||||
className,
|
||||
methodName,
|
||||
path,
|
||||
pathSha256,
|
||||
line));
|
||||
}
|
||||
|
||||
entries.Add(new RubyRuntimeEvidence
|
||||
{
|
||||
Type = "ruby.method.call",
|
||||
Timestamp = timestamp,
|
||||
Path = path,
|
||||
PathSha256 = pathSha256,
|
||||
ClassName = className,
|
||||
MethodName = methodName,
|
||||
Line = line
|
||||
});
|
||||
}
|
||||
|
||||
private static void ProcessErrorEvent(
|
||||
JsonElement root,
|
||||
string timestamp,
|
||||
List<RubyRuntimeEvidence> entries,
|
||||
List<RubyRuntimeError> errors)
|
||||
{
|
||||
var message = GetString(root, "message") ?? "Unknown error";
|
||||
string? path = null;
|
||||
string? pathSha256 = null;
|
||||
|
||||
if (root.TryGetProperty("location", out var locProp) && locProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
path = GetString(locProp, "path");
|
||||
pathSha256 = GetString(locProp, "path_sha256");
|
||||
}
|
||||
|
||||
errors.Add(new RubyRuntimeError(timestamp, message, path, pathSha256));
|
||||
|
||||
entries.Add(new RubyRuntimeEvidence
|
||||
{
|
||||
Type = "ruby.runtime.error",
|
||||
Timestamp = timestamp,
|
||||
Path = path,
|
||||
PathSha256 = pathSha256,
|
||||
Message = message
|
||||
});
|
||||
}
|
||||
|
||||
private static void ProcessCapabilitiesArray(JsonElement root, HashSet<string> capabilities)
|
||||
{
|
||||
if (!root.TryGetProperty("capabilities", out var capsArray) ||
|
||||
capsArray.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var cap in capsArray.EnumerateArray())
|
||||
{
|
||||
if (cap.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var capValue = cap.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(capValue))
|
||||
{
|
||||
capabilities.Add(capValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? prop.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool? GetBool(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var prop))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return prop.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var prop) && prop.TryGetInt32(out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates runtime evidence into Ruby analysis results without altering static precedence.
|
||||
/// Runtime evidence supplements static analysis but never overrides it.
|
||||
/// </summary>
|
||||
internal static class RubyRuntimeEvidenceIntegrator
|
||||
{
|
||||
/// <summary>
|
||||
/// Correlates runtime evidence with packages using path hashing.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, RubyRuntimePackageEvidence> CorrelatePackageEvidence(
|
||||
RubyRuntimeEvidenceResult evidence,
|
||||
IReadOnlyList<RubyPackage> packages)
|
||||
{
|
||||
if (!evidence.HasEvidence)
|
||||
{
|
||||
return new Dictionary<string, RubyRuntimePackageEvidence>();
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, RubyRuntimePackageEvidence>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var package in packages)
|
||||
{
|
||||
var loadedFiles = FindLoadedFilesForPackage(evidence, package);
|
||||
var methodCalls = FindMethodCallsForPackage(evidence, package);
|
||||
var capabilities = FindCapabilitiesForPackage(evidence, package);
|
||||
var loadedAtRuntime = loadedFiles.Length > 0 ||
|
||||
evidence.RequiredFeatures.Contains(package.Name);
|
||||
|
||||
result[package.Name] = new RubyRuntimePackageEvidence
|
||||
{
|
||||
LoadedAtRuntime = loadedAtRuntime,
|
||||
LoadedFiles = loadedFiles,
|
||||
MethodCalls = methodCalls,
|
||||
RuntimeCapabilities = capabilities
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a runtime evidence section for the observation document.
|
||||
/// </summary>
|
||||
public static RubyObservationRuntimeEvidence BuildRuntimeEvidenceSection(
|
||||
RubyRuntimeEvidenceResult evidence,
|
||||
IReadOnlyDictionary<string, RubyRuntimePackageEvidence> packageEvidence)
|
||||
{
|
||||
if (!evidence.HasEvidence)
|
||||
{
|
||||
return RubyObservationRuntimeEvidence.Empty;
|
||||
}
|
||||
|
||||
var loadedPackages = packageEvidence
|
||||
.Where(static kv => kv.Value.LoadedAtRuntime)
|
||||
.Select(static kv => kv.Key)
|
||||
.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var loadedFiles = evidence.LoadedFiles
|
||||
.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var pathHashes = evidence.PathHashMap
|
||||
.OrderBy(static kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableDictionary();
|
||||
|
||||
var capabilities = evidence.DetectedCapabilities
|
||||
.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
var errors = evidence.Errors
|
||||
.Select(static e => new RubyObservationRuntimeError(
|
||||
e.Timestamp,
|
||||
e.Message,
|
||||
e.Path,
|
||||
e.PathSha256))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RubyObservationRuntimeEvidence
|
||||
{
|
||||
HasEvidence = true,
|
||||
RuntimeRubyVersion = evidence.RuntimeRubyVersion,
|
||||
RuntimeRubyPlatform = evidence.RuntimeRubyPlatform,
|
||||
LoadedFeaturesCount = evidence.LoadedFeaturesCount,
|
||||
LoadedPackages = loadedPackages,
|
||||
LoadedFiles = loadedFiles,
|
||||
PathHashes = pathHashes,
|
||||
RuntimeCapabilities = capabilities,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enhances runtime edges with runtime evidence without altering static precedence.
|
||||
/// Runtime evidence adds supplementary information but never removes or overrides static findings.
|
||||
/// </summary>
|
||||
public static ImmutableArray<RubyObservationRuntimeEdge> EnhanceRuntimeEdges(
|
||||
ImmutableArray<RubyObservationRuntimeEdge> staticEdges,
|
||||
RubyRuntimeEvidenceResult evidence,
|
||||
IReadOnlyDictionary<string, RubyRuntimePackageEvidence> packageEvidence)
|
||||
{
|
||||
if (!evidence.HasEvidence)
|
||||
{
|
||||
return staticEdges;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<RubyObservationRuntimeEdge>(staticEdges.Length);
|
||||
|
||||
foreach (var edge in staticEdges)
|
||||
{
|
||||
if (packageEvidence.TryGetValue(edge.Package, out var pkgEvidence) && pkgEvidence.LoadedAtRuntime)
|
||||
{
|
||||
// Add runtime-verified flag to existing edge without changing other properties
|
||||
var enhancedReasons = edge.Reasons.Contains("runtime-verified")
|
||||
? edge.Reasons
|
||||
: edge.Reasons.Add("runtime-verified");
|
||||
|
||||
builder.Add(edge with { Reasons = enhancedReasons });
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep original edge unchanged - static precedence maintained
|
||||
builder.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new edges for packages that were only discovered at runtime
|
||||
// These are supplementary - marked with lower confidence
|
||||
foreach (var (packageName, pkgEvidence) in packageEvidence)
|
||||
{
|
||||
if (!pkgEvidence.LoadedAtRuntime)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this package already has an edge
|
||||
if (builder.Any(e => string.Equals(e.Package, packageName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add supplementary edge from runtime evidence
|
||||
builder.Add(new RubyObservationRuntimeEdge(
|
||||
Package: packageName,
|
||||
UsedByEntrypoint: false, // Cannot determine from runtime evidence alone
|
||||
Files: pkgEvidence.LoadedFiles,
|
||||
Entrypoints: ImmutableArray<string>.Empty,
|
||||
Reasons: ImmutableArray.Create("runtime-only", "supplementary")));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enhances capabilities with runtime evidence without altering static precedence.
|
||||
/// </summary>
|
||||
public static RubyObservationCapabilitySummary EnhanceCapabilities(
|
||||
RubyObservationCapabilitySummary staticCapabilities,
|
||||
RubyRuntimeEvidenceResult evidence)
|
||||
{
|
||||
if (!evidence.HasEvidence || evidence.DetectedCapabilities.Count == 0)
|
||||
{
|
||||
return staticCapabilities;
|
||||
}
|
||||
|
||||
// Runtime can only ADD capabilities, never remove static findings
|
||||
var usesExec = staticCapabilities.UsesExec || evidence.DetectedCapabilities.Contains("exec");
|
||||
var usesNetwork = staticCapabilities.UsesNetwork || evidence.DetectedCapabilities.Contains("net");
|
||||
var usesSerialization = staticCapabilities.UsesSerialization || evidence.DetectedCapabilities.Contains("serialize");
|
||||
|
||||
// Merge schedulers from runtime
|
||||
var runtimeSchedulers = evidence.DetectedCapabilities
|
||||
.Where(static c => c == "scheduler")
|
||||
.Select(static _ => "runtime-scheduler");
|
||||
|
||||
var jobSchedulers = staticCapabilities.JobSchedulers
|
||||
.Union(runtimeSchedulers)
|
||||
.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new RubyObservationCapabilitySummary(
|
||||
usesExec,
|
||||
usesNetwork,
|
||||
usesSerialization,
|
||||
jobSchedulers);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> FindLoadedFilesForPackage(
|
||||
RubyRuntimeEvidenceResult evidence,
|
||||
RubyPackage package)
|
||||
{
|
||||
// Match files that appear to be from this package
|
||||
// Use package name as a heuristic for matching
|
||||
var packagePath = package.Name.Replace('-', '_').Replace('.', '/');
|
||||
|
||||
return evidence.LoadedFiles
|
||||
.Where(f => f.Contains(packagePath, StringComparison.OrdinalIgnoreCase) ||
|
||||
f.Contains(package.Name, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<RubyRuntimeMethodCall> FindMethodCallsForPackage(
|
||||
RubyRuntimeEvidenceResult evidence,
|
||||
RubyPackage package)
|
||||
{
|
||||
// Match method calls that appear to be from this package's namespace
|
||||
var packageNamespace = ToPascalCase(package.Name);
|
||||
|
||||
return evidence.MethodCalls
|
||||
.Where(m => m.ClassName.StartsWith(packageNamespace, StringComparison.OrdinalIgnoreCase))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> FindCapabilitiesForPackage(
|
||||
RubyRuntimeEvidenceResult evidence,
|
||||
RubyPackage package)
|
||||
{
|
||||
// Find capabilities that were detected when requiring this package
|
||||
var capabilities = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in evidence.Entries)
|
||||
{
|
||||
if (entry.Type == "ruby.require" &&
|
||||
!string.IsNullOrWhiteSpace(entry.Feature) &&
|
||||
!string.IsNullOrWhiteSpace(entry.Capability) &&
|
||||
(entry.Feature.Equals(package.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||
entry.Feature.Contains(package.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
capabilities.Add(entry.Capability);
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities
|
||||
.OrderBy(static s => s, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string ToPascalCase(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var parts = input.Split('-', '_', '.');
|
||||
return string.Concat(parts.Select(static p =>
|
||||
string.IsNullOrEmpty(p) ? p : char.ToUpperInvariant(p[0]) + p[1..]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Provides path normalization and hashing for secure evidence correlation.
|
||||
/// </summary>
|
||||
internal static class RubyRuntimePathHasher
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a module identity from an absolute path and root.
|
||||
/// </summary>
|
||||
public static RubyModuleIdentity Create(string rootPath, string absolutePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(absolutePath);
|
||||
|
||||
var normalized = NormalizeRelative(rootPath, absolutePath);
|
||||
var sha256 = ComputeSha256(normalized);
|
||||
return new RubyModuleIdentity(normalized, sha256);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a module identity from a pre-normalized path.
|
||||
/// </summary>
|
||||
public static RubyModuleIdentity FromNormalized(string normalizedPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(normalizedPath);
|
||||
var sha256 = ComputeSha256(normalizedPath);
|
||||
return new RubyModuleIdentity(normalizedPath, sha256);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the SHA-256 hash of a path for secure correlation.
|
||||
/// </summary>
|
||||
public static string ComputeSha256(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes an absolute path to a relative path from the root.
|
||||
/// </summary>
|
||||
public static string NormalizeRelative(string rootPath, string absolutePath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(rootPath, absolutePath);
|
||||
if (string.IsNullOrWhiteSpace(relative) || relative == ".")
|
||||
{
|
||||
return ".";
|
||||
}
|
||||
|
||||
// Normalize separators and clean up
|
||||
return relative
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('.', '/')
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a path for consistent hashing.
|
||||
/// </summary>
|
||||
public static string NormalizePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return path
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('.', '/')
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a normalized module path with its SHA-256 hash.
|
||||
/// </summary>
|
||||
internal sealed record RubyModuleIdentity(string NormalizedPath, string PathSha256);
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Observations;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby.Internal.Runtime;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
@@ -31,6 +32,10 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
var capabilities = await RubyCapabilityDetector.DetectAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var runtimeGraph = await RubyRuntimeGraphBuilder.BuildAsync(context, packages, cancellationToken).ConfigureAwait(false);
|
||||
var bundlerConfig = RubyBundlerConfig.Load(context.RootPath);
|
||||
var containerInfo = await RubyContainerScanner.ScanAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Optionally collect runtime evidence if available (from logs/metrics)
|
||||
var runtimeEvidence = await RubyRuntimeEvidenceCollector.CollectAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
|
||||
{
|
||||
@@ -51,7 +56,7 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
if (packages.Count > 0)
|
||||
{
|
||||
EmitObservation(context, writer, packages, lockData, runtimeGraph, capabilities, bundlerConfig, lockData.BundledWith);
|
||||
EmitObservation(context, writer, packages, lockData, runtimeGraph, capabilities, bundlerConfig, lockData.BundledWith, containerInfo, runtimeEvidence);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +96,9 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
RubyRuntimeGraph runtimeGraph,
|
||||
RubyCapabilities capabilities,
|
||||
RubyBundlerConfig bundlerConfig,
|
||||
string? bundledWith)
|
||||
string? bundledWith,
|
||||
RubyContainerInfo containerInfo,
|
||||
RubyRuntimeEvidenceResult? runtimeEvidence)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
@@ -100,8 +107,9 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
ArgumentNullException.ThrowIfNull(runtimeGraph);
|
||||
ArgumentNullException.ThrowIfNull(capabilities);
|
||||
ArgumentNullException.ThrowIfNull(bundlerConfig);
|
||||
ArgumentNullException.ThrowIfNull(containerInfo);
|
||||
|
||||
var observationDocument = RubyObservationBuilder.Build(packages, lockData, runtimeGraph, capabilities, bundlerConfig, bundledWith);
|
||||
var observationDocument = RubyObservationBuilder.Build(packages, lockData, runtimeGraph, capabilities, bundlerConfig, bundledWith, containerInfo, runtimeEvidence);
|
||||
var observationJson = RubyObservationSerializer.Serialize(observationDocument);
|
||||
var observationHash = RubyObservationSerializer.ComputeSha256(observationJson);
|
||||
var observationBytes = Encoding.UTF8.GetBytes(observationJson);
|
||||
@@ -111,7 +119,9 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
observationDocument.DependencyEdges.Length,
|
||||
observationDocument.RuntimeEdges.Length,
|
||||
observationDocument.Capabilities,
|
||||
observationDocument.BundledWith);
|
||||
observationDocument.BundledWith,
|
||||
observationDocument.Environment,
|
||||
observationDocument.RuntimeEvidence);
|
||||
|
||||
TryPersistObservation(Id, context, observationBytes, observationMetadata);
|
||||
|
||||
@@ -141,7 +151,9 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
int dependencyEdgeCount,
|
||||
int runtimeEdgeCount,
|
||||
RubyObservationCapabilitySummary capabilities,
|
||||
string? bundledWith)
|
||||
string? bundledWith,
|
||||
RubyObservationEnvironment environment,
|
||||
RubyObservationRuntimeEvidence? runtimeEvidence)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.packages", packageCount.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.dependency_edges", dependencyEdgeCount.ToString(CultureInfo.InvariantCulture));
|
||||
@@ -161,6 +173,50 @@ public sealed class RubyLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.bundler_version", bundledWith);
|
||||
}
|
||||
|
||||
// Container/runtime information
|
||||
if (!string.IsNullOrWhiteSpace(environment.RubyVersion))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.ruby_version", environment.RubyVersion);
|
||||
}
|
||||
|
||||
if (environment.NativeExtensions.Length > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.native_extensions", environment.NativeExtensions.Length.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (environment.WebServers.Length > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.web_servers", environment.WebServers.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var serverTypes = string.Join(';', environment.WebServers.Select(static s => s.ServerType).Distinct().OrderBy(static s => s, StringComparer.OrdinalIgnoreCase));
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.web_server_types", serverTypes);
|
||||
}
|
||||
|
||||
// Runtime evidence information (supplementary, does not alter static precedence)
|
||||
if (runtimeEvidence is { HasEvidence: true })
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_evidence", "true");
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_evidence.loaded_packages", runtimeEvidence.LoadedPackages.Length.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_evidence.loaded_files", runtimeEvidence.LoadedFiles.Length.ToString(CultureInfo.InvariantCulture));
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_evidence.path_hashes", runtimeEvidence.PathHashes.Count.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runtimeEvidence.RuntimeRubyVersion))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_evidence.ruby_version", runtimeEvidence.RuntimeRubyVersion);
|
||||
}
|
||||
|
||||
if (runtimeEvidence.RuntimeCapabilities.Length > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(
|
||||
"ruby.observation.runtime_evidence.capabilities",
|
||||
string.Join(';', runtimeEvidence.RuntimeCapabilities));
|
||||
}
|
||||
|
||||
if (runtimeEvidence.Errors.Length > 0)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>("ruby.observation.runtime_evidence.errors", runtimeEvidence.Errors.Length.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryPersistObservation(
|
||||
|
||||
@@ -12,3 +12,8 @@
|
||||
| `SCANNER-ANALYZERS-RUBY-28-004` | DONE (2025-11-27) | Fixtures/benchmarks for Ruby analyzer: created cli-app fixture with Thor/TTY-Prompt CLI gems, updated expected.json golden files for simple-app and complex-app with dependency edges format, added CliWorkspaceProducesDeterministicOutputAsync test; all 4 determinism tests pass. |
|
||||
| `SCANNER-ANALYZERS-RUBY-28-005` | DONE (2025-11-27) | Runtime capture (tracepoint) hooks: created Internal/Runtime/ with RubyRuntimeShim.cs (trace-shim.rb using TracePoint for require/load events, capability detection, sensitive data redaction), RubyRuntimeTraceRunner.cs (opt-in harness via STELLA_RUBY_ENTRYPOINT env var, sandbox guidance), and RubyRuntimeTraceReader.cs (NDJSON parser for trace events). |
|
||||
| `SCANNER-ANALYZERS-RUBY-28-006` | DONE (2025-11-27) | Package Ruby analyzer plug-in: created manifest.json with schema version, entrypoint, and capabilities (ruby/rubygems/bundler/runtime-capture:optional). Updated docs/24_OFFLINE_KIT.md to include Ruby analyzer in language analyzers section, manifest examples, tar verification commands, and release guardrail smoke test references. |
|
||||
| `SCANNER-ANALYZERS-RUBY-28-007` | DONE (2025-11-27) | Container/runtime scanner: created RubyContainerScanner.cs with OCI layer scanning for Ruby version detection (.ruby-version, .tool-versions, Gemfile ruby directive, binary paths), installed gems in system/vendor paths, native extension detection (.so/.bundle/.dll), and web server config parsing (Puma, Unicorn, Passenger). Updated RubyObservationDocument with RubyVersionSources, WebServers, NativeExtensions. Integrated into RubyLanguageAnalyzer and observation builder/serializer. |
|
||||
| `SCANNER-ANALYZERS-RUBY-28-008` | DONE (2025-11-27) | AOC-compliant observations: added RubyObservationModule, RubyObservationRoute, RubyObservationJob, RubyObservationTask, RubyObservationConfig, RubyObservationWarning types to observation document. Updated builder to produce jobs from detected schedulers and configs from web server settings. Enhanced serializer with WriteModules, WriteRoutes, WriteJobs, WriteTasks, WriteConfigs, WriteWarnings. Document schema now includes modules, routes, jobs, tasks, configs, warnings arrays. |
|
||||
| `SCANNER-ANALYZERS-RUBY-28-009` | DONE (2025-11-27) | Fixture suite + performance benchmarks: created rails-app (Rails 7.1 with actioncable/pg/puma/redis), sinatra-app (Sinatra 3.1 with rack routes), container-app (OCI layers with .ruby-version, .tool-versions, Puma config, native extensions stubs), legacy-app (Rakefile without bundler) fixtures with golden expected.json files. Added RubyBenchmarks.cs with warmup/iteration tests for all fixture types (<100ms target), determinism verification test. Updated existing simple-app/complex-app/cli-app golden files for ruby_version metadata. All 7 determinism tests pass. |
|
||||
| `SCANNER-ANALYZERS-RUBY-28-010` | DONE (2025-11-27) | Optional runtime evidence integration with path hashing: created Internal/Runtime/ types (RubyRuntimeEvidence.cs, RubyRuntimeEvidenceCollector.cs, RubyRuntimePathHasher.cs, RubyRuntimeEvidenceIntegrator.cs). Added RubyObservationRuntimeEvidence and RubyObservationRuntimeError to observation document. Collector reads ruby-runtime.ndjson from multiple paths, parses require/load/method.call/error events, builds path hash map (SHA-256) for secure correlation. Integrator correlates package evidence, enhances runtime edges with "runtime-verified" flag, adds supplementary "runtime-only" edges without altering static precedence. Updated builder/serializer to include optional runtimeEvidence section. All 8 determinism tests pass. |
|
||||
| `SCANNER-ANALYZERS-RUBY-28-011` | DONE (2025-11-27) | Package analyzer plug-in, CLI, and Offline Kit docs: verified existing manifest.json (schemaVersion 1.0, capabilities: language-analyzer/ruby/rubygems/bundler, runtime-capture:optional), verified RubyAnalyzerPlugin.cs entrypoint. CLI `stella ruby inspect` and `stella ruby resolve` commands already implemented in CommandFactory.cs/CommandHandlers.cs. Updated docs/24_OFFLINE_KIT.md with comprehensive Ruby analyzer feature list covering OCI container layers, dependency edges, Ruby version detection, native extensions, web server configs, AOC-compliant observations, runtime evidence with path hashing, and CLI usage. |
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a secret provider with audit logging. Each secret access is logged with tenant, component,
|
||||
/// secret type, and provider metadata for observability and compliance.
|
||||
/// </summary>
|
||||
internal sealed class AuditingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly ISurfaceSecretProvider _inner;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _componentName;
|
||||
|
||||
public AuditingSurfaceSecretProvider(
|
||||
ISurfaceSecretProvider inner,
|
||||
TimeProvider timeProvider,
|
||||
ILogger logger,
|
||||
string componentName)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_componentName = componentName ?? throw new ArgumentNullException(nameof(componentName));
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var handle = await _inner.GetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
handle.Metadata,
|
||||
success: true,
|
||||
elapsed,
|
||||
error: null);
|
||||
|
||||
return handle;
|
||||
}
|
||||
catch (SurfaceSecretNotFoundException)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: "NotFound");
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var elapsed = _timeProvider.GetUtcNow() - startTime;
|
||||
LogAuditEvent(
|
||||
request,
|
||||
metadata: null,
|
||||
success: false,
|
||||
elapsed,
|
||||
error: ex.GetType().Name);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void LogAuditEvent(
|
||||
SurfaceSecretRequest request,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
bool success,
|
||||
TimeSpan elapsed,
|
||||
string? error)
|
||||
{
|
||||
// Structured log entry for audit trail. NEVER log secret contents.
|
||||
_logger.Log(
|
||||
success ? LogLevel.Information : LogLevel.Warning,
|
||||
"Surface secret access: " +
|
||||
"Component={Component}, " +
|
||||
"Tenant={Tenant}, " +
|
||||
"RequestComponent={RequestComponent}, " +
|
||||
"SecretType={SecretType}, " +
|
||||
"Name={Name}, " +
|
||||
"Success={Success}, " +
|
||||
"ElapsedMs={ElapsedMs}, " +
|
||||
"Provider={Provider}, " +
|
||||
"Error={Error}",
|
||||
_componentName,
|
||||
request.Tenant,
|
||||
request.Component,
|
||||
request.SecretType,
|
||||
request.Name ?? "default",
|
||||
success,
|
||||
elapsed.TotalMilliseconds,
|
||||
metadata?.GetValueOrDefault("source") ?? "unknown",
|
||||
error ?? "(none)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a secret provider with a deterministic in-memory cache. Cache entries expire after
|
||||
/// <see cref="CacheTtl"/> seconds. Cache keys are formed deterministically by tenant/component/secretType/name
|
||||
/// sorted lexicographically for stable hashing.
|
||||
/// </summary>
|
||||
internal sealed class CachingSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly ISurfaceSecretProvider _inner;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _ttl;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public static TimeSpan DefaultCacheTtl { get; } = TimeSpan.FromSeconds(600);
|
||||
|
||||
public CachingSurfaceSecretProvider(
|
||||
ISurfaceSecretProvider inner,
|
||||
TimeProvider timeProvider,
|
||||
ILogger logger,
|
||||
TimeSpan? cacheTtl = null)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_ttl = cacheTtl ?? DefaultCacheTtl;
|
||||
}
|
||||
|
||||
public TimeSpan CacheTtl => _ttl;
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = BuildCacheKey(request);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
_logger.LogDebug("Surface secret cache hit for {Key}.", key);
|
||||
return entry.Handle;
|
||||
}
|
||||
|
||||
var handle = await _inner.GetAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var newEntry = new CacheEntry(handle, now.Add(_ttl));
|
||||
_cache[key] = newEntry;
|
||||
|
||||
_logger.LogDebug("Surface secret cached for {Key}, expires at {ExpiresAt}.", key, newEntry.ExpiresAt);
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached entries.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
_logger.LogInformation("Surface secret cache cleared.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a specific entry from the cache.
|
||||
/// </summary>
|
||||
public void Invalidate(SurfaceSecretRequest request)
|
||||
{
|
||||
var key = BuildCacheKey(request);
|
||||
_cache.TryRemove(key, out _);
|
||||
_logger.LogDebug("Surface secret cache entry invalidated for {Key}.", key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a deterministic cache key from the request. Components are sorted to ensure
|
||||
/// lexicographically stable ordering for deterministic behaviour.
|
||||
/// </summary>
|
||||
private static string BuildCacheKey(SurfaceSecretRequest request)
|
||||
{
|
||||
// Deterministic ordering: tenant < component < secretType < name
|
||||
var name = request.Name ?? "default";
|
||||
return string.Join(
|
||||
":",
|
||||
request.Tenant.ToLowerInvariant(),
|
||||
request.Component.ToLowerInvariant(),
|
||||
request.SecretType.ToLowerInvariant(),
|
||||
name.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(SurfaceSecretHandle Handle, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// Specialized file provider for offline kit deployments. Reads secrets from the offline kit
|
||||
/// layout: <c><root>/<tenant>/<component>/<secretType>/<name>.json</c>.
|
||||
/// Supports deterministic selection when multiple secrets exist (lexicographically smallest name).
|
||||
/// Validates file integrity via SHA-256 hashes when manifest is present.
|
||||
/// </summary>
|
||||
internal sealed class OfflineSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Dictionary<string, OfflineManifestEntry>? _manifest;
|
||||
|
||||
public OfflineSurfaceSecretProvider(string root, ILogger logger, string? manifestPath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
throw new ArgumentException("Offline secret provider root cannot be null or whitespace.", nameof(root));
|
||||
}
|
||||
|
||||
_root = root;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Load manifest if present for integrity verification
|
||||
var defaultManifestPath = manifestPath ?? Path.Combine(root, "manifest.json");
|
||||
if (File.Exists(defaultManifestPath))
|
||||
{
|
||||
_manifest = LoadManifest(defaultManifestPath);
|
||||
_logger.LogInformation("Offline secrets manifest loaded from {Path} ({Count} entries).", defaultManifestPath, _manifest.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<SurfaceSecretHandle> GetAsync(
|
||||
SurfaceSecretRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var directory = Path.Combine(_root, request.Tenant, request.Component, request.SecretType);
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
_logger.LogDebug("Offline secret directory {Directory} not found.", directory);
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
// Deterministic selection: if name is specified use it, otherwise pick lexicographically smallest
|
||||
var targetName = request.Name ?? SelectDeterministicName(directory);
|
||||
if (targetName is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
var path = Path.Combine(directory, targetName + ".json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var descriptor = await JsonSerializer.DeserializeAsync<OfflineSecretDescriptor>(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (descriptor is null)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(descriptor.Payload))
|
||||
{
|
||||
return SurfaceSecretHandle.Empty;
|
||||
}
|
||||
|
||||
var bytes = Convert.FromBase64String(descriptor.Payload);
|
||||
|
||||
// Verify integrity if manifest entry exists
|
||||
var manifestKey = BuildManifestKey(request.Tenant, request.Component, request.SecretType, targetName);
|
||||
if (_manifest?.TryGetValue(manifestKey, out var entry) == true)
|
||||
{
|
||||
var actualHash = ComputeSha256(bytes);
|
||||
if (!string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Offline secret integrity check failed for {Key}. Expected {Expected}, got {Actual}.",
|
||||
manifestKey,
|
||||
entry.Sha256,
|
||||
actualHash);
|
||||
throw new InvalidOperationException($"Offline secret integrity check failed for {manifestKey}.");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Offline secret integrity verified for {Key}.", manifestKey);
|
||||
}
|
||||
|
||||
var metadata = descriptor.Metadata ?? new Dictionary<string, string>();
|
||||
metadata["source"] = "offline";
|
||||
metadata["path"] = path;
|
||||
metadata["name"] = targetName;
|
||||
|
||||
return SurfaceSecretHandle.FromBytes(bytes, metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the lexicographically smallest .json file name in the directory (without extension).
|
||||
/// Returns null if directory is empty.
|
||||
/// </summary>
|
||||
private static string? SelectDeterministicName(string directory)
|
||||
{
|
||||
var files = Directory.GetFiles(directory, "*.json");
|
||||
if (files.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return files
|
||||
.Select(Path.GetFileNameWithoutExtension)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string BuildManifestKey(string tenant, string component, string secretType, string name)
|
||||
=> $"{tenant}/{component}/{secretType}/{name}".ToLowerInvariant();
|
||||
|
||||
private static string ComputeSha256(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private Dictionary<string, OfflineManifestEntry> LoadManifest(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var manifest = JsonSerializer.Deserialize<OfflineManifest>(json);
|
||||
if (manifest?.Entries is null)
|
||||
{
|
||||
return new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in manifest.Entries)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[entry.Key] = entry;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load offline secrets manifest from {Path}.", path);
|
||||
return new Dictionary<string, OfflineManifestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OfflineSecretDescriptor
|
||||
{
|
||||
public string? Payload { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OfflineManifest
|
||||
{
|
||||
public List<OfflineManifestEntry>? Entries { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OfflineManifestEntry
|
||||
{
|
||||
public string? Key { get; init; }
|
||||
public string? Sha256 { get; init; }
|
||||
public long? Size { get; init; }
|
||||
public string? CreatedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,29 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
var env = sp.GetRequiredService<ISurfaceEnvironment>();
|
||||
var options = sp.GetRequiredService<IOptions<SurfaceSecretsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("SurfaceSecrets");
|
||||
return CreateProviderChain(env.Settings.Secrets, logger);
|
||||
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||
var logger = loggerFactory.CreateLogger("SurfaceSecrets");
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
// Build the base provider chain
|
||||
ISurfaceSecretProvider provider = CreateProviderChain(env.Settings.Secrets, logger);
|
||||
|
||||
// Wrap with caching if enabled
|
||||
if (options.EnableCaching)
|
||||
{
|
||||
var cacheTtl = TimeSpan.FromSeconds(Math.Max(30, options.CacheTtlSeconds));
|
||||
var cacheLogger = loggerFactory.CreateLogger("SurfaceSecrets.Cache");
|
||||
provider = new CachingSurfaceSecretProvider(provider, timeProvider, cacheLogger, cacheTtl);
|
||||
}
|
||||
|
||||
// Wrap with auditing if enabled
|
||||
if (options.EnableAuditLogging)
|
||||
{
|
||||
var auditLogger = loggerFactory.CreateLogger("SurfaceSecrets.Audit");
|
||||
provider = new AuditingSurfaceSecretProvider(provider, timeProvider, auditLogger, options.ComponentName);
|
||||
}
|
||||
|
||||
return provider;
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -63,6 +84,10 @@ public static class ServiceCollectionExtensions
|
||||
return new KubernetesSurfaceSecretProvider(configuration, logger);
|
||||
case "file":
|
||||
return new FileSurfaceSecretProvider(configuration.Root ?? throw new ArgumentException("Secrets root is required for file provider."));
|
||||
case "offline":
|
||||
return new OfflineSurfaceSecretProvider(
|
||||
configuration.Root ?? throw new ArgumentException("Secrets root is required for offline provider."),
|
||||
logger);
|
||||
case "inline":
|
||||
return new InlineSurfaceSecretProvider(configuration);
|
||||
default:
|
||||
|
||||
@@ -14,4 +14,21 @@ public sealed class SurfaceSecretsOptions
|
||||
/// Gets or sets the set of secret types that should be eagerly validated at startup.
|
||||
/// </summary>
|
||||
public ISet<string> RequiredSecretTypes { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable in-memory caching of resolved secrets.
|
||||
/// Default is true for performance.
|
||||
/// </summary>
|
||||
public bool EnableCaching { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cache TTL in seconds. Default is 600 (10 minutes).
|
||||
/// </summary>
|
||||
public int CacheTtlSeconds { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to enable audit logging of secret access.
|
||||
/// Default is true for compliance and observability.
|
||||
/// </summary>
|
||||
public bool EnableAuditLogging { get; set; } = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user