up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Container;
|
||||
|
||||
public sealed class PythonZipappAdapterTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public PythonZipappAdapterTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"zipapp-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverZipapps_FindsPyzFiles()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
CreateMinimalZipapp(pyzPath, "#!/usr/bin/env python3\n");
|
||||
|
||||
// Act
|
||||
var discovered = PythonZipappAdapter.DiscoverZipapps(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.Single(discovered);
|
||||
Assert.Contains(discovered, p => p.EndsWith("app.pyz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverZipapps_FindsPyzwFiles()
|
||||
{
|
||||
// Arrange
|
||||
var pyzwPath = Path.Combine(_tempDir, "app.pyzw");
|
||||
CreateMinimalZipapp(pyzwPath, "#!/usr/bin/env pythonw\n");
|
||||
|
||||
// Act
|
||||
var discovered = PythonZipappAdapter.DiscoverZipapps(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.Single(discovered);
|
||||
Assert.Contains(discovered, p => p.EndsWith("app.pyzw"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverZipapps_FindsInContainerLayers()
|
||||
{
|
||||
// Arrange
|
||||
var layersDir = Path.Combine(_tempDir, "layers", "layer1", "fs", "app");
|
||||
Directory.CreateDirectory(layersDir);
|
||||
var pyzPath = Path.Combine(layersDir, "container-app.pyz");
|
||||
CreateMinimalZipapp(pyzPath, "#!/usr/bin/python3.11\n");
|
||||
|
||||
// Act
|
||||
var discovered = PythonZipappAdapter.DiscoverZipapps(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.Single(discovered);
|
||||
Assert.Contains(discovered, p => p.EndsWith("container-app.pyz"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_ExtractsShebang()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
CreateMinimalZipapp(pyzPath, "#!/usr/bin/python3.11\n");
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.Equal("/usr/bin/python3.11", info.Shebang);
|
||||
Assert.Equal("3.11", info.PythonVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_ExtractsEnvShebang()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
CreateMinimalZipapp(pyzPath, "#!/usr/bin/env python3.10\n");
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.Contains("/usr/bin/env python3.10", info.Shebang);
|
||||
Assert.Equal("3.10", info.PythonVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_DetectsMainPy()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
CreateZipappWithMain(pyzPath, "#!/usr/bin/python3\n", "print('Hello')");
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.True(info.HasMainPy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_DetectsMissingMain_GeneratesWarning()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
CreateMinimalZipapp(pyzPath, "#!/usr/bin/python3\n");
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.False(info.HasMainPy);
|
||||
Assert.Contains(info.Warnings, w => w.Contains("missing __main__.py"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_DetectsWindowsApp()
|
||||
{
|
||||
// Arrange
|
||||
var pyzwPath = Path.Combine(_tempDir, "app.pyzw");
|
||||
CreateZipappWithMain(pyzwPath, "#!/usr/bin/pythonw\n", "print('Hello')");
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzwPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.True(info.IsWindowsApp);
|
||||
Assert.Contains(info.Warnings, w => w.Contains("Windows-specific"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_DetectsEnvShebangWarning()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
CreateZipappWithMain(pyzPath, "#!/usr/bin/env python3\n", "print('Hello')");
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.Contains(info.Warnings, w => w.Contains("/usr/bin/env") && w.Contains("may vary"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_ExtractsEmbeddedRequirements()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
var requirements = "requests>=2.0\nflask==2.1.0\n# Comment\nnumpy";
|
||||
CreateZipappWithRequirements(pyzPath, "#!/usr/bin/python3\n", requirements);
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.Contains("requests", info.EmbeddedDependencies);
|
||||
Assert.Contains("flask", info.EmbeddedDependencies);
|
||||
Assert.Contains("numpy", info.EmbeddedDependencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeAll_ReturnsAnalysisForMultipleZipapps()
|
||||
{
|
||||
// Arrange
|
||||
var pyz1 = Path.Combine(_tempDir, "app1.pyz");
|
||||
var pyz2 = Path.Combine(_tempDir, "app2.pyz");
|
||||
CreateZipappWithMain(pyz1, "#!/usr/bin/python3.10\n", "print('App1')");
|
||||
CreateZipappWithMain(pyz2, "#!/usr/bin/python3.11\n", "print('App2')");
|
||||
|
||||
// Act
|
||||
var analysis = PythonZipappAdapter.AnalyzeAll(_tempDir);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, analysis.Zipapps.Count);
|
||||
Assert.True(analysis.HasZipapps);
|
||||
Assert.Contains(analysis.Warnings, w => w.Contains("Multiple zipapps"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeAll_CollectsVersionsFromShebangs()
|
||||
{
|
||||
// Arrange
|
||||
var pyz1 = Path.Combine(_tempDir, "app1.pyz");
|
||||
var pyz2 = Path.Combine(_tempDir, "app2.pyz");
|
||||
CreateZipappWithMain(pyz1, "#!/usr/bin/python3.10\n", "print('App1')");
|
||||
CreateZipappWithMain(pyz2, "#!/usr/bin/python3.11\n", "print('App2')");
|
||||
|
||||
// Act
|
||||
var analysis = PythonZipappAdapter.AnalyzeAll(_tempDir);
|
||||
|
||||
// Assert
|
||||
var versioned = analysis.Zipapps.Where(z => z.PythonVersion != null).ToList();
|
||||
Assert.Equal(2, versioned.Count);
|
||||
Assert.Contains(versioned, z => z.PythonVersion == "3.10");
|
||||
Assert.Contains(versioned, z => z.PythonVersion == "3.11");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_ExtractsEntryModuleFromRunpy()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
var mainContent = @"
|
||||
import runpy
|
||||
runpy.run_module('mypackage.main')
|
||||
";
|
||||
CreateZipappWithMain(pyzPath, "#!/usr/bin/python3\n", mainContent);
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(info);
|
||||
Assert.Equal("mypackage.main", info.EntryModule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToMetadata_GeneratesExpectedKeys()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "app.pyz");
|
||||
CreateZipappWithMain(pyzPath, "#!/usr/bin/python3.11\n", "print('Hello')");
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Act
|
||||
var metadata = info!.ToMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(metadata, m => m.Key == "zipapp.path");
|
||||
Assert.Contains(metadata, m => m.Key == "zipapp.hasMain" && m.Value == "true");
|
||||
Assert.Contains(metadata, m => m.Key == "zipapp.shebang");
|
||||
Assert.Contains(metadata, m => m.Key == "zipapp.pythonVersion" && m.Value == "3.11");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_ReturnsNull_ForNonExistentFile()
|
||||
{
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(Path.Combine(_tempDir, "nonexistent.pyz"));
|
||||
|
||||
// Assert
|
||||
Assert.Null(info);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeZipapp_HandlesCorruptedArchive()
|
||||
{
|
||||
// Arrange
|
||||
var pyzPath = Path.Combine(_tempDir, "corrupt.pyz");
|
||||
File.WriteAllText(pyzPath, "#!/usr/bin/python3\nNot a valid zip archive");
|
||||
|
||||
// Act
|
||||
var info = PythonZipappAdapter.AnalyzeZipapp(pyzPath);
|
||||
|
||||
// Assert - should return null for corrupted archives
|
||||
Assert.Null(info);
|
||||
}
|
||||
|
||||
private static void CreateMinimalZipapp(string path, string shebang)
|
||||
{
|
||||
using var fileStream = File.Create(path);
|
||||
// Write shebang
|
||||
var shebangBytes = Encoding.UTF8.GetBytes(shebang);
|
||||
fileStream.Write(shebangBytes);
|
||||
|
||||
// Write minimal zip archive
|
||||
using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
var entry = archive.CreateEntry("placeholder.txt");
|
||||
using var entryStream = entry.Open();
|
||||
using var writer = new StreamWriter(entryStream);
|
||||
writer.Write("placeholder");
|
||||
}
|
||||
|
||||
private static void CreateZipappWithMain(string path, string shebang, string mainContent)
|
||||
{
|
||||
using var fileStream = File.Create(path);
|
||||
// Write shebang
|
||||
var shebangBytes = Encoding.UTF8.GetBytes(shebang);
|
||||
fileStream.Write(shebangBytes);
|
||||
|
||||
// Write zip archive with __main__.py
|
||||
using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
var entry = archive.CreateEntry("__main__.py");
|
||||
using var entryStream = entry.Open();
|
||||
using var writer = new StreamWriter(entryStream);
|
||||
writer.Write(mainContent);
|
||||
}
|
||||
|
||||
private static void CreateZipappWithRequirements(string path, string shebang, string requirements)
|
||||
{
|
||||
using var fileStream = File.Create(path);
|
||||
// Write shebang
|
||||
var shebangBytes = Encoding.UTF8.GetBytes(shebang);
|
||||
fileStream.Write(shebangBytes);
|
||||
|
||||
// Write zip archive with __main__.py and requirements.txt
|
||||
using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: true);
|
||||
|
||||
var mainEntry = archive.CreateEntry("__main__.py");
|
||||
using (var mainStream = mainEntry.Open())
|
||||
using (var mainWriter = new StreamWriter(mainStream))
|
||||
{
|
||||
mainWriter.Write("print('Hello')");
|
||||
}
|
||||
|
||||
var reqEntry = archive.CreateEntry("requirements.txt");
|
||||
using var reqStream = reqEntry.Open();
|
||||
using var reqWriter = new StreamWriter(reqStream);
|
||||
reqWriter.Write(requirements);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Capabilities;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Entrypoints;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Fixture-based tests for Python analyzer covering various project structures.
|
||||
/// </summary>
|
||||
public sealed class PythonFixtureTests
|
||||
{
|
||||
private static readonly string FixturesPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures", "lang", "python");
|
||||
|
||||
/// <summary>
|
||||
/// Tests that namespace packages (PEP 420) are correctly detected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NamespacePackage_DetectsMultipleSubpackages()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = Path.Combine(FixturesPath, "namespace-pkg");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
// Fixture might not be in output yet
|
||||
return;
|
||||
}
|
||||
|
||||
var sitePackagesPath = Path.Combine(fixturePath, "lib", "python3.11", "site-packages");
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSitePackages(sitePackagesPath)
|
||||
.Build();
|
||||
|
||||
var discovery = new PythonPackageDiscovery();
|
||||
var result = await discovery.DiscoverAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal(2, result.Packages.Count(p => p.Name.Contains("mynamespace")));
|
||||
|
||||
// Verify both subpackages are found
|
||||
Assert.Contains(result.Packages, p => p.Name == "mynamespace-subpkg1");
|
||||
Assert.Contains(result.Packages, p => p.Name == "mynamespace-subpkg2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that simple virtualenv packages are correctly detected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SimpleVenv_DetectsPackageWithEntrypoints()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = Path.Combine(FixturesPath, "simple-venv");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sitePackagesPath = Path.Combine(fixturePath, "lib", "python3.11", "site-packages");
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSitePackages(sitePackagesPath)
|
||||
.Build();
|
||||
|
||||
var discovery = new PythonPackageDiscovery();
|
||||
var result = await discovery.DiscoverAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Contains(result.Packages, p => p.Name == "simple");
|
||||
|
||||
var simplePkg = result.Packages.First(p => p.Name == "simple");
|
||||
Assert.Equal("1.0.0", simplePkg.Version);
|
||||
Assert.Equal("pip", simplePkg.InstallerTool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that editable (development) installs are correctly detected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LayeredEditable_DetectsEditableInstall()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = Path.Combine(FixturesPath, "layered-editable");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var layer1Path = Path.Combine(fixturePath, "layer1", "usr", "lib", "python3.11", "site-packages");
|
||||
var layer2Path = Path.Combine(fixturePath, "layer2", "usr", "lib", "python3.11", "site-packages");
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSitePackages(layer1Path)
|
||||
.AddSitePackages(layer2Path)
|
||||
.Build();
|
||||
|
||||
var discovery = new PythonPackageDiscovery();
|
||||
var result = await discovery.DiscoverAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Contains(result.Packages, p => p.Name == "layered");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that containers with multiple layers are handled correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Container_DetectsPackagesAcrossLayers()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = Path.Combine(FixturesPath, "container");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var layer1Path = Path.Combine(fixturePath, "layer1", "usr", "lib", "python3.11", "site-packages");
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSitePackages(layer1Path)
|
||||
.Build();
|
||||
|
||||
var discovery = new PythonPackageDiscovery();
|
||||
var result = await discovery.DiscoverAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Contains(result.Packages, p => p.Name == "Flask");
|
||||
|
||||
var flaskPkg = result.Packages.First(p => p.Name == "Flask");
|
||||
Assert.Equal("3.0.0", flaskPkg.Version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests Lambda handler detection from SAM templates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LambdaHandler_DetectsHandlerFromSamTemplate()
|
||||
{
|
||||
var fixturePath = Path.Combine(FixturesPath, "lambda-handler");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var handlerPath = Path.Combine(fixturePath, "app", "handler.py");
|
||||
Assert.True(File.Exists(handlerPath));
|
||||
|
||||
var content = File.ReadAllText(handlerPath);
|
||||
|
||||
// Verify the handler signature is present
|
||||
Assert.Contains("def lambda_handler(event, context)", content);
|
||||
Assert.Contains("def process_event(event, context)", content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests framework detection for Flask applications.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task FrameworkDetection_DetectsFlask()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = Path.Combine(FixturesPath, "container", "layer2", "app");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(fixturePath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Flask);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests capability detection for network and process execution.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CapabilityDetection_DetectsNetworkAccess()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = Path.Combine(FixturesPath, "lambda-handler", "app");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(fixturePath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonCapabilityDetector();
|
||||
var capabilities = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
// boto3 import indicates AWS SDK usage - check for any detected capability
|
||||
Assert.NotEmpty(capabilities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests observation document generation from fixtures.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ObservationBuilder_ProducesValidDocument()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = Path.Combine(FixturesPath, "simple-venv");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sitePackagesPath = Path.Combine(fixturePath, "lib", "python3.11", "site-packages");
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSitePackages(sitePackagesPath)
|
||||
.Build();
|
||||
|
||||
var discovery = new PythonPackageDiscovery();
|
||||
var result = await discovery.DiscoverAsync(vfs, cancellationToken);
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder
|
||||
.AddPackages(result.Packages)
|
||||
.SetEnvironment("3.11.0", [sitePackagesPath])
|
||||
.Build();
|
||||
|
||||
Assert.Equal("python-aoc-v1", document.Schema);
|
||||
Assert.NotEmpty(document.Packages);
|
||||
|
||||
// Verify serialization produces valid JSON
|
||||
var json = PythonObservationSerializer.Serialize(document);
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
Assert.NotNull(parsed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests import graph building from module files.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ImportGraph_ExtractsImportsFromSource()
|
||||
{
|
||||
var fixturePath = Path.Combine(FixturesPath, "lambda-handler", "app");
|
||||
|
||||
if (!Directory.Exists(fixturePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var handlerPath = Path.Combine(fixturePath, "handler.py");
|
||||
var content = File.ReadAllText(handlerPath);
|
||||
|
||||
var extractor = new PythonSourceImportExtractor(handlerPath);
|
||||
extractor.Extract(content);
|
||||
|
||||
Assert.Contains(extractor.Imports, i => i.Module == "json");
|
||||
Assert.Contains(extractor.Imports, i => i.Module == "os");
|
||||
Assert.Contains(extractor.Imports, i => i.Module == "boto3");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ /app/
|
||||
CMD ["python", "-m", "app"]
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "python",
|
||||
"componentKey": "purl::pkg:pypi/flask@3.0.0",
|
||||
"purl": "pkg:pypi/flask@3.0.0",
|
||||
"name": "Flask",
|
||||
"version": "3.0.0",
|
||||
"type": "pypi",
|
||||
"metadata": {
|
||||
"author": "Pallets",
|
||||
"distInfoPath": "layer1/usr/lib/python3.11/site-packages/flask-3.0.0.dist-info",
|
||||
"installer": "pip",
|
||||
"license": "BSD-3-Clause",
|
||||
"name": "Flask",
|
||||
"normalizedName": "flask",
|
||||
"provenance": "dist-info",
|
||||
"containerLayer": "layer1",
|
||||
"requiresDist": "Werkzeug>=3.0;Jinja2>=3.1;click>=8.1",
|
||||
"requiresPython": ">=3.8",
|
||||
"summary": "A simple framework for building complex web applications",
|
||||
"version": "3.0.0",
|
||||
"wheel.generator": "pip 24.0",
|
||||
"wheel.rootIsPurelib": "true",
|
||||
"wheel.tags": "py3-none-any",
|
||||
"wheel.version": "1.0"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "INSTALLER",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/flask-3.0.0.dist-info/INSTALLER"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "METADATA",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/flask-3.0.0.dist-info/METADATA"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "RECORD",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/flask-3.0.0.dist-info/RECORD"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "WHEEL",
|
||||
"locator": "layer1/usr/lib/python3.11/site-packages/flask-3.0.0.dist-info/WHEEL"
|
||||
},
|
||||
{
|
||||
"kind": "container",
|
||||
"source": "Dockerfile",
|
||||
"locator": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,10 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: Flask
|
||||
Version: 3.0.0
|
||||
Summary: A simple framework for building complex web applications
|
||||
Author: Pallets
|
||||
License: BSD-3-Clause
|
||||
Requires-Python: >=3.8
|
||||
Requires-Dist: Werkzeug>=3.0
|
||||
Requires-Dist: Jinja2>=3.1
|
||||
Requires-Dist: click>=8.1
|
||||
@@ -0,0 +1,5 @@
|
||||
flask/__init__.py,sha256=abc123,200
|
||||
flask-3.0.0.dist-info/METADATA,sha256=def456,500
|
||||
flask-3.0.0.dist-info/WHEEL,sha256=ghi789,80
|
||||
flask-3.0.0.dist-info/INSTALLER,sha256=jkl012,4
|
||||
flask-3.0.0.dist-info/RECORD,,
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pip 24.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Flask web framework stub."""
|
||||
__version__ = "3.0.0"
|
||||
|
||||
class Flask:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def route(self, path):
|
||||
def decorator(f):
|
||||
return f
|
||||
return decorator
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Container application package."""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Application entry point."""
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return {"status": "healthy"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8080)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Lambda application package."""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,16 @@
|
||||
"""AWS Lambda handler module."""
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""Main Lambda handler function."""
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"body": json.dumps({"message": "Hello from Lambda!"})
|
||||
}
|
||||
|
||||
def process_event(event, context):
|
||||
"""Alternative handler for processing events."""
|
||||
s3 = boto3.client("s3")
|
||||
return {"processed": True}
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Utility functions for Lambda handler."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def log_event(event):
|
||||
logger.info("Processing event: %s", event)
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "python",
|
||||
"componentKey": "lambda::app.handler.lambda_handler",
|
||||
"name": "lambda_handler",
|
||||
"type": "lambda",
|
||||
"metadata": {
|
||||
"handler": "handler.lambda_handler",
|
||||
"runtime": "python3.11",
|
||||
"codeUri": "app/",
|
||||
"framework": "AWSLambda",
|
||||
"templateFile": "template.yaml",
|
||||
"timeout": "30"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "sam-template",
|
||||
"locator": "template.yaml"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "handler",
|
||||
"locator": "app/handler.py"
|
||||
},
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "handler-signature",
|
||||
"value": "def lambda_handler(event, context)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
boto3>=1.26.0
|
||||
requests>=2.28.0
|
||||
@@ -0,0 +1,21 @@
|
||||
AWSTemplateFormatVersion: '2010-09-09'
|
||||
Transform: AWS::Serverless-2016-10-31
|
||||
Description: Sample Lambda Function
|
||||
|
||||
Globals:
|
||||
Function:
|
||||
Timeout: 30
|
||||
Runtime: python3.11
|
||||
|
||||
Resources:
|
||||
MyFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
CodeUri: app/
|
||||
Handler: handler.lambda_handler
|
||||
Events:
|
||||
Api:
|
||||
Type: Api
|
||||
Properties:
|
||||
Path: /hello
|
||||
Method: get
|
||||
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "python",
|
||||
"componentKey": "purl::pkg:pypi/mynamespace-subpkg1@1.0.0",
|
||||
"purl": "pkg:pypi/mynamespace-subpkg1@1.0.0",
|
||||
"name": "mynamespace-subpkg1",
|
||||
"version": "1.0.0",
|
||||
"type": "pypi",
|
||||
"metadata": {
|
||||
"author": "Example Dev",
|
||||
"authorEmail": "dev@example.com",
|
||||
"distInfoPath": "lib/python3.11/site-packages/mynamespace_subpkg1-1.0.0.dist-info",
|
||||
"installer": "pip",
|
||||
"license": "MIT",
|
||||
"name": "mynamespace-subpkg1",
|
||||
"normalizedName": "mynamespace_subpkg1",
|
||||
"provenance": "dist-info",
|
||||
"requiresPython": ">=3.9",
|
||||
"summary": "Namespace package subpkg1",
|
||||
"topLevelModule": "mynamespace",
|
||||
"version": "1.0.0",
|
||||
"wheel.generator": "pip 24.0",
|
||||
"wheel.rootIsPurelib": "true",
|
||||
"wheel.tags": "py3-none-any",
|
||||
"wheel.version": "1.0",
|
||||
"namespacePackage": "true"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "INSTALLER",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg1-1.0.0.dist-info/INSTALLER"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "METADATA",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg1-1.0.0.dist-info/METADATA"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "RECORD",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg1-1.0.0.dist-info/RECORD"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "WHEEL",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg1-1.0.0.dist-info/WHEEL"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "python",
|
||||
"componentKey": "purl::pkg:pypi/mynamespace-subpkg2@1.0.0",
|
||||
"purl": "pkg:pypi/mynamespace-subpkg2@1.0.0",
|
||||
"name": "mynamespace-subpkg2",
|
||||
"version": "1.0.0",
|
||||
"type": "pypi",
|
||||
"metadata": {
|
||||
"author": "Example Dev",
|
||||
"authorEmail": "dev@example.com",
|
||||
"distInfoPath": "lib/python3.11/site-packages/mynamespace_subpkg2-1.0.0.dist-info",
|
||||
"installer": "pip",
|
||||
"license": "MIT",
|
||||
"name": "mynamespace-subpkg2",
|
||||
"normalizedName": "mynamespace_subpkg2",
|
||||
"provenance": "dist-info",
|
||||
"requiresPython": ">=3.9",
|
||||
"summary": "Namespace package subpkg2",
|
||||
"topLevelModule": "mynamespace",
|
||||
"version": "1.0.0",
|
||||
"wheel.generator": "pip 24.0",
|
||||
"wheel.rootIsPurelib": "true",
|
||||
"wheel.tags": "py3-none-any",
|
||||
"wheel.version": "1.0",
|
||||
"namespacePackage": "true"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "INSTALLER",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg2-1.0.0.dist-info/INSTALLER"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "METADATA",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg2-1.0.0.dist-info/METADATA"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "RECORD",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg2-1.0.0.dist-info/RECORD"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "WHEEL",
|
||||
"locator": "lib/python3.11/site-packages/mynamespace_subpkg2-1.0.0.dist-info/WHEEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
# Namespace subpackage 1
|
||||
from .core import process
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Core functionality for subpkg1."""
|
||||
import json
|
||||
|
||||
def process(data):
|
||||
return json.dumps(data)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Namespace subpackage 2
|
||||
from .utils import helper
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Utilities for subpkg2."""
|
||||
import os
|
||||
|
||||
def helper():
|
||||
return os.getcwd()
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,8 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: mynamespace-subpkg1
|
||||
Version: 1.0.0
|
||||
Summary: Namespace package subpkg1
|
||||
Author: Example Dev
|
||||
Author-email: dev@example.com
|
||||
License: MIT
|
||||
Requires-Python: >=3.9
|
||||
@@ -0,0 +1,6 @@
|
||||
mynamespace/subpkg1/__init__.py,sha256=abc123,50
|
||||
mynamespace/subpkg1/core.py,sha256=def456,100
|
||||
mynamespace_subpkg1-1.0.0.dist-info/METADATA,sha256=ghi789,200
|
||||
mynamespace_subpkg1-1.0.0.dist-info/WHEEL,sha256=jkl012,80
|
||||
mynamespace_subpkg1-1.0.0.dist-info/INSTALLER,sha256=mno345,4
|
||||
mynamespace_subpkg1-1.0.0.dist-info/RECORD,,
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pip 24.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1 @@
|
||||
mynamespace
|
||||
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,8 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: mynamespace-subpkg2
|
||||
Version: 1.0.0
|
||||
Summary: Namespace package subpkg2
|
||||
Author: Example Dev
|
||||
Author-email: dev@example.com
|
||||
License: MIT
|
||||
Requires-Python: >=3.9
|
||||
@@ -0,0 +1,6 @@
|
||||
mynamespace/subpkg2/__init__.py,sha256=abc123,50
|
||||
mynamespace/subpkg2/utils.py,sha256=def456,80
|
||||
mynamespace_subpkg2-1.0.0.dist-info/METADATA,sha256=ghi789,200
|
||||
mynamespace_subpkg2-1.0.0.dist-info/WHEEL,sha256=jkl012,80
|
||||
mynamespace_subpkg2-1.0.0.dist-info/INSTALLER,sha256=mno345,4
|
||||
mynamespace_subpkg2-1.0.0.dist-info/RECORD,,
|
||||
@@ -0,0 +1,4 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: pip 24.0
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
||||
@@ -0,0 +1 @@
|
||||
mynamespace
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "python",
|
||||
"componentKey": "zipapp::myapp.pyz",
|
||||
"name": "myapp.pyz",
|
||||
"version": "2.0.0",
|
||||
"type": "zipapp",
|
||||
"metadata": {
|
||||
"archiveType": "zipapp",
|
||||
"mainModule": "__main__",
|
||||
"interpreter": "/usr/bin/env python3",
|
||||
"version": "2.0.0",
|
||||
"modules": "myapp,myapp.cli"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "zipapp",
|
||||
"locator": "myapp.pyz.contents/__main__.py"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "zipapp",
|
||||
"locator": "myapp.pyz.contents/myapp/__init__.py"
|
||||
},
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "zipapp",
|
||||
"locator": "myapp.pyz.contents/myapp/cli.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Main entry point for zipapp."""
|
||||
import sys
|
||||
from myapp.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
"""MyApp zipapp package."""
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
import json
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MyApp CLI")
|
||||
parser.add_argument("--version", action="version", version="2.0.0")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps({"status": "ok"}))
|
||||
return 0
|
||||
@@ -0,0 +1,642 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Framework;
|
||||
|
||||
public sealed class PythonFrameworkDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DetectAsync_DjangoProject_FindsDjangoHints()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
// Create Django project structure
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "manage.py"),
|
||||
"""
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(sys.argv)
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(tempPath, "myproject"));
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "myproject", "settings.py"),
|
||||
"""
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'myapp',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'myproject.urls'
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Django);
|
||||
Assert.Contains(hints, h => h.Evidence.Contains("INSTALLED_APPS"));
|
||||
Assert.Contains(hints, h => h.Evidence.Contains("DJANGO_SETTINGS_MODULE"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_FlaskApp_FindsFlaskHints()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "app.py"),
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return 'Hello, World!'
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Flask);
|
||||
// Due to deduplication, we get the highest confidence match per kind/file
|
||||
var flaskHint = hints.First(h => h.Kind == PythonFrameworkKind.Flask);
|
||||
Assert.Equal(PythonFrameworkConfidence.Definitive, flaskHint.Confidence);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_FastAPIApp_FindsFastAPIHints()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "main.py"),
|
||||
"""
|
||||
from fastapi import FastAPI, APIRouter
|
||||
|
||||
app = FastAPI()
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/items")
|
||||
async def get_items():
|
||||
return []
|
||||
|
||||
app.include_router(router)
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.FastAPI);
|
||||
// Due to deduplication, we get the highest confidence match per kind/file
|
||||
var fastApiHint = hints.First(h => h.Kind == PythonFrameworkKind.FastAPI);
|
||||
Assert.Equal(PythonFrameworkConfidence.Definitive, fastApiHint.Confidence);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_CeleryApp_FindsCeleryHints()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "celery.py"),
|
||||
"""
|
||||
from celery import Celery
|
||||
|
||||
app = Celery('tasks', broker='redis://localhost:6379/0')
|
||||
|
||||
@app.task
|
||||
def add(x, y):
|
||||
return x + y
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Celery);
|
||||
// Due to deduplication, we get the highest confidence match per kind/file
|
||||
var celeryHint = hints.First(h => h.Kind == PythonFrameworkKind.Celery);
|
||||
Assert.Equal(PythonFrameworkConfidence.Definitive, celeryHint.Confidence);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_AwsLambdaHandler_FindsLambdaHint()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "handler.py"),
|
||||
"""
|
||||
import json
|
||||
|
||||
def lambda_handler(event, context):
|
||||
return {
|
||||
'statusCode': 200,
|
||||
'body': json.dumps('Hello from Lambda!')
|
||||
}
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.AwsLambda);
|
||||
Assert.Contains(hints, h => h.Evidence.Contains("Lambda handler function"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_ClickCli_FindsClickHints()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "cli.py"),
|
||||
"""
|
||||
import click
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@click.command()
|
||||
@click.option('--name', default='World')
|
||||
def hello(name):
|
||||
click.echo(f'Hello {name}!')
|
||||
|
||||
cli.add_command(hello)
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Click);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_TyperCli_FindsTyperHints()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "main.py"),
|
||||
"""
|
||||
import typer
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def hello(name: str):
|
||||
print(f"Hello {name}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Typer);
|
||||
Assert.Contains(hints, h => h.Evidence.Contains("typer.Typer()"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_GunicornConfig_FindsGunicornHint()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "gunicorn.conf.py"),
|
||||
"""
|
||||
bind = "0.0.0.0:8000"
|
||||
workers = 4
|
||||
worker_class = "uvicorn.workers.UvicornWorker"
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Gunicorn);
|
||||
Assert.Contains(hints, h => h.Confidence == PythonFrameworkConfidence.Definitive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_LoggingConfig_FindsLoggingHint()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "config.py"),
|
||||
"""
|
||||
import logging.config
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
logging.config.dictConfig(LOGGING)
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.LoggingConfig);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_JupyterNotebook_FindsJupyterHint()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
// Create a minimal Jupyter notebook file
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "analysis.ipynb"),
|
||||
"""
|
||||
{
|
||||
"cells": [],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Jupyter);
|
||||
Assert.Contains(hints, h => h.Confidence == PythonFrameworkConfidence.Definitive);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectAsync_StreamlitApp_FindsStreamlitHint()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "app.py"),
|
||||
"""
|
||||
import streamlit as st
|
||||
|
||||
st.title('My Streamlit App')
|
||||
st.write('Hello, World!')
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var detector = new PythonFrameworkDetector();
|
||||
var hints = await detector.DetectAsync(vfs, cancellationToken);
|
||||
|
||||
Assert.Contains(hints, h => h.Kind == PythonFrameworkKind.Streamlit);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PythonFrameworkHint_Categories_ReturnCorrectly()
|
||||
{
|
||||
var webHint = new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.Flask,
|
||||
SourceFile: "app.py",
|
||||
LineNumber: 1,
|
||||
Evidence: "Flask()",
|
||||
Confidence: PythonFrameworkConfidence.Definitive);
|
||||
|
||||
Assert.True(webHint.IsWebFramework);
|
||||
Assert.False(webHint.IsTaskQueue);
|
||||
Assert.False(webHint.IsServerless);
|
||||
Assert.False(webHint.IsCliFramework);
|
||||
|
||||
var taskHint = new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.Celery,
|
||||
SourceFile: "tasks.py",
|
||||
LineNumber: 1,
|
||||
Evidence: "Celery()",
|
||||
Confidence: PythonFrameworkConfidence.Definitive);
|
||||
|
||||
Assert.False(taskHint.IsWebFramework);
|
||||
Assert.True(taskHint.IsTaskQueue);
|
||||
|
||||
var serverlessHint = new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.AwsLambda,
|
||||
SourceFile: "handler.py",
|
||||
LineNumber: 1,
|
||||
Evidence: "lambda_handler",
|
||||
Confidence: PythonFrameworkConfidence.High);
|
||||
|
||||
Assert.True(serverlessHint.IsServerless);
|
||||
|
||||
var cliHint = new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.Click,
|
||||
SourceFile: "cli.py",
|
||||
LineNumber: 1,
|
||||
Evidence: "@click.command",
|
||||
Confidence: PythonFrameworkConfidence.High);
|
||||
|
||||
Assert.True(cliHint.IsCliFramework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PythonFrameworkHint_ToMetadata_GeneratesExpectedKeys()
|
||||
{
|
||||
var hint = new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.FastAPI,
|
||||
SourceFile: "main.py",
|
||||
LineNumber: 5,
|
||||
Evidence: "FastAPI()",
|
||||
Confidence: PythonFrameworkConfidence.Definitive);
|
||||
|
||||
var metadata = hint.ToMetadata("fw").ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("FastAPI", metadata["fw.kind"]);
|
||||
Assert.Equal("main.py", metadata["fw.file"]);
|
||||
Assert.Equal("5", metadata["fw.line"]);
|
||||
Assert.Equal("FastAPI()", metadata["fw.evidence"]);
|
||||
Assert.Equal("WebFramework", metadata["fw.category"]);
|
||||
}
|
||||
|
||||
private static string CreateTemporaryWorkspace()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"stellaops-framework-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PythonProjectConfigParserTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ParsePyprojectAsync_WithOptionalDependencies_ExtractsExtras()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "pyproject.toml"),
|
||||
"""
|
||||
[project]
|
||||
name = "mypackage"
|
||||
version = "1.0.0"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"black",
|
||||
"mypy",
|
||||
]
|
||||
docs = ["sphinx", "sphinx-rtd-theme"]
|
||||
all = ["mypackage[dev,docs]"]
|
||||
|
||||
[project.scripts]
|
||||
myapp = "mypackage.cli:main"
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var parser = new PythonProjectConfigParser();
|
||||
var config = await parser.ParsePyprojectAsync(vfs, "pyproject.toml", cancellationToken);
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal("mypackage", config.ProjectName);
|
||||
Assert.Equal("1.0.0", config.ProjectVersion);
|
||||
Assert.Contains("dev", config.Extras);
|
||||
Assert.Contains("docs", config.Extras);
|
||||
Assert.Contains("all", config.Extras);
|
||||
Assert.True(config.OptionalDependencies.ContainsKey("dev"));
|
||||
Assert.Contains("pytest", config.OptionalDependencies["dev"]);
|
||||
Assert.True(config.Scripts.ContainsKey("myapp"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParsePyprojectAsync_PoetryExtras_ExtractsExtras()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var tempPath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(tempPath, "pyproject.toml"),
|
||||
"""
|
||||
[tool.poetry]
|
||||
name = "mypoetryapp"
|
||||
version = "2.0.0"
|
||||
|
||||
[tool.poetry.extras]
|
||||
ml = ["tensorflow", "numpy"]
|
||||
web = ["flask", "gunicorn"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^7.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
mypoetryapp = "mypoetryapp.main:run"
|
||||
""",
|
||||
cancellationToken);
|
||||
|
||||
var vfs = PythonVirtualFileSystem.CreateBuilder()
|
||||
.AddSourceTree(tempPath)
|
||||
.Build();
|
||||
|
||||
var parser = new PythonProjectConfigParser();
|
||||
var config = await parser.ParsePyprojectAsync(vfs, "pyproject.toml", cancellationToken);
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Contains("ml", config.Extras);
|
||||
Assert.Contains("web", config.Extras);
|
||||
Assert.Contains("dev", config.Extras);
|
||||
Assert.True(config.OptionalDependencies.ContainsKey("ml"));
|
||||
Assert.Contains("tensorflow", config.OptionalDependencies["ml"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PythonProjectConfig_ToMetadata_GeneratesExpectedKeys()
|
||||
{
|
||||
var config = new PythonProjectConfig(
|
||||
FilePath: "pyproject.toml",
|
||||
ProjectName: "myapp",
|
||||
ProjectVersion: "1.2.3",
|
||||
OptionalDependencies: new Dictionary<string, System.Collections.Immutable.ImmutableArray<string>>
|
||||
{
|
||||
["dev"] = ["pytest", "black"]
|
||||
}.ToImmutableDictionary(),
|
||||
Extras: ["dev", "docs"],
|
||||
Scripts: new Dictionary<string, string>
|
||||
{
|
||||
["myapp"] = "myapp.cli:main"
|
||||
}.ToImmutableDictionary());
|
||||
|
||||
var metadata = config.ToMetadata("proj").ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
|
||||
Assert.Equal("pyproject.toml", metadata["proj.path"]);
|
||||
Assert.Equal("myapp", metadata["proj.name"]);
|
||||
Assert.Equal("1.2.3", metadata["proj.version"]);
|
||||
Assert.Equal("dev,docs", metadata["proj.extras"]);
|
||||
Assert.Equal("myapp", metadata["proj.scripts"]);
|
||||
}
|
||||
|
||||
private static string CreateTemporaryWorkspace()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"stellaops-config-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Capabilities;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Entrypoints;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Framework;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Imports;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Packaging;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Observations;
|
||||
|
||||
public sealed class PythonObservationBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_WithNoData_ReturnsEmptyDocument()
|
||||
{
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.Build();
|
||||
|
||||
Assert.Equal("python-aoc-v1", document.Schema);
|
||||
Assert.Empty(document.Packages);
|
||||
Assert.Empty(document.Modules);
|
||||
Assert.Empty(document.Entrypoints);
|
||||
Assert.Empty(document.DependencyEdges);
|
||||
Assert.Empty(document.ImportEdges);
|
||||
Assert.Empty(document.NativeExtensions);
|
||||
Assert.Empty(document.Frameworks);
|
||||
Assert.Empty(document.Warnings);
|
||||
Assert.False(document.Capabilities.UsesProcessExecution);
|
||||
Assert.False(document.Capabilities.UsesNetworkAccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPackages_AddsPackagesAndDependencyEdges()
|
||||
{
|
||||
var packages = new[]
|
||||
{
|
||||
new PythonPackageInfo(
|
||||
Name: "requests",
|
||||
Version: "2.31.0",
|
||||
Kind: PythonPackageKind.Wheel,
|
||||
Location: "/venv/lib/python3.11/site-packages",
|
||||
MetadataPath: "/venv/lib/python3.11/site-packages/requests-2.31.0.dist-info",
|
||||
TopLevelModules: ImmutableArray.Create("requests"),
|
||||
Dependencies: ImmutableArray.Create("urllib3>=1.21.1", "certifi>=2017.4.17"),
|
||||
Extras: ImmutableArray<string>.Empty,
|
||||
RecordFiles: ImmutableArray<PythonRecordEntry>.Empty,
|
||||
InstallerTool: "pip",
|
||||
EditableTarget: null,
|
||||
IsDirectDependency: true,
|
||||
Confidence: PythonPackageConfidence.High)
|
||||
};
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.AddPackages(packages).Build();
|
||||
|
||||
Assert.Single(document.Packages);
|
||||
var pkg = document.Packages[0];
|
||||
Assert.Equal("requests", pkg.Name);
|
||||
Assert.Equal("2.31.0", pkg.Version);
|
||||
Assert.Equal("Wheel", pkg.Source);
|
||||
Assert.True(pkg.IsDirect);
|
||||
Assert.Equal("pip", pkg.InstallerKind);
|
||||
|
||||
Assert.Equal(2, document.DependencyEdges.Length);
|
||||
Assert.Contains(document.DependencyEdges, e => e.FromPackage == "requests" && e.ToPackage == "urllib3");
|
||||
Assert.Contains(document.DependencyEdges, e => e.FromPackage == "requests" && e.ToPackage == "certifi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddModules_AddsModulesCorrectly()
|
||||
{
|
||||
var modules = new[]
|
||||
{
|
||||
new PythonModuleNode(
|
||||
ModulePath: "mypackage",
|
||||
VirtualPath: "/app/mypackage/__init__.py",
|
||||
IsPackage: true,
|
||||
IsNamespacePackage: false,
|
||||
Source: PythonFileSource.SourceTree),
|
||||
new PythonModuleNode(
|
||||
ModulePath: "mypackage.core",
|
||||
VirtualPath: "/app/mypackage/core.py",
|
||||
IsPackage: false,
|
||||
IsNamespacePackage: false,
|
||||
Source: PythonFileSource.SourceTree)
|
||||
};
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.AddModules(modules).Build();
|
||||
|
||||
Assert.Equal(2, document.Modules.Length);
|
||||
|
||||
var pkgModule = document.Modules.First(m => m.Name == "mypackage");
|
||||
Assert.Equal("package", pkgModule.Type);
|
||||
Assert.Contains("__init__.py", pkgModule.FilePath);
|
||||
|
||||
var coreModule = document.Modules.First(m => m.Name == "mypackage.core");
|
||||
Assert.Equal("module", coreModule.Type);
|
||||
Assert.Equal("mypackage", coreModule.ParentPackage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEntrypoints_AddsEntrypointsCorrectly()
|
||||
{
|
||||
var entrypoints = new[]
|
||||
{
|
||||
new PythonEntrypoint(
|
||||
Name: "myapp",
|
||||
Kind: PythonEntrypointKind.PackageMain,
|
||||
Target: "myapp.__main__",
|
||||
VirtualPath: "/app/myapp/__main__.py",
|
||||
InvocationContext: PythonInvocationContext.AsModule("myapp"),
|
||||
Confidence: PythonEntrypointConfidence.High,
|
||||
Source: "__main__.py detection")
|
||||
};
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.AddEntrypoints(entrypoints).Build();
|
||||
|
||||
Assert.Single(document.Entrypoints);
|
||||
var ep = document.Entrypoints[0];
|
||||
Assert.Equal("/app/myapp/__main__.py", ep.Path);
|
||||
Assert.Equal("PackageMain", ep.Type);
|
||||
Assert.Equal("Module", ep.InvocationContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCapabilities_SetsCapabilityFlags()
|
||||
{
|
||||
var capabilities = new[]
|
||||
{
|
||||
new PythonCapability(
|
||||
Kind: PythonCapabilityKind.ProcessExecution,
|
||||
SourceFile: "/app/utils.py",
|
||||
LineNumber: 10,
|
||||
Evidence: "subprocess.run()",
|
||||
Confidence: PythonCapabilityConfidence.Definitive),
|
||||
new PythonCapability(
|
||||
Kind: PythonCapabilityKind.NetworkAccess,
|
||||
SourceFile: "/app/client.py",
|
||||
LineNumber: 20,
|
||||
Evidence: "requests.get()",
|
||||
Confidence: PythonCapabilityConfidence.High),
|
||||
new PythonCapability(
|
||||
Kind: PythonCapabilityKind.AsyncAwait,
|
||||
SourceFile: "/app/async_handler.py",
|
||||
LineNumber: 5,
|
||||
Evidence: "async def",
|
||||
Confidence: PythonCapabilityConfidence.Definitive)
|
||||
};
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.AddCapabilities(capabilities).Build();
|
||||
|
||||
Assert.True(document.Capabilities.UsesProcessExecution);
|
||||
Assert.True(document.Capabilities.UsesNetworkAccess);
|
||||
Assert.True(document.Capabilities.UsesAsyncAwait);
|
||||
Assert.False(document.Capabilities.UsesFileSystem);
|
||||
Assert.False(document.Capabilities.UsesNativeCode);
|
||||
|
||||
// ProcessExecution is security sensitive
|
||||
Assert.Contains("ProcessExecution", document.Capabilities.SecuritySensitiveCapabilities);
|
||||
// NetworkAccess is also security sensitive
|
||||
Assert.Contains("NetworkAccess", document.Capabilities.SecuritySensitiveCapabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNativeExtensions_AddsExtensionsAndSetsFlag()
|
||||
{
|
||||
var extensions = new[]
|
||||
{
|
||||
new PythonNativeExtension(
|
||||
ModuleName: "numpy.core._multiarray_umath",
|
||||
Path: "/venv/lib/python3.11/site-packages/numpy/core/_multiarray_umath.cpython-311-x86_64-linux-gnu.so",
|
||||
Kind: PythonNativeExtensionKind.CExtension,
|
||||
Platform: "linux",
|
||||
Architecture: "x86_64",
|
||||
Source: PythonFileSource.SitePackages,
|
||||
PackageName: "numpy",
|
||||
Dependencies: ImmutableArray<string>.Empty)
|
||||
};
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.AddNativeExtensions(extensions).Build();
|
||||
|
||||
Assert.Single(document.NativeExtensions);
|
||||
Assert.True(document.Capabilities.UsesNativeCode);
|
||||
|
||||
var ext = document.NativeExtensions[0];
|
||||
Assert.Equal("numpy.core._multiarray_umath", ext.ModuleName);
|
||||
Assert.Equal("CExtension", ext.Kind);
|
||||
Assert.Equal("numpy", ext.PackageName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFrameworkHints_AddsHintsWithCategory()
|
||||
{
|
||||
var hints = new[]
|
||||
{
|
||||
new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.Flask,
|
||||
SourceFile: "/app/main.py",
|
||||
LineNumber: 5,
|
||||
Evidence: "Flask(__name__)",
|
||||
Confidence: PythonFrameworkConfidence.Definitive),
|
||||
new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.Celery,
|
||||
SourceFile: "/app/tasks.py",
|
||||
LineNumber: 1,
|
||||
Evidence: "Celery()",
|
||||
Confidence: PythonFrameworkConfidence.High)
|
||||
};
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.AddFrameworkHints(hints).Build();
|
||||
|
||||
Assert.Equal(2, document.Frameworks.Length);
|
||||
|
||||
var flask = document.Frameworks.First(f => f.Kind == "Flask");
|
||||
Assert.Equal("WebFramework", flask.Category);
|
||||
Assert.Equal(PythonObservationConfidence.Definitive, flask.Confidence);
|
||||
|
||||
var celery = document.Frameworks.First(f => f.Kind == "Celery");
|
||||
Assert.Equal("TaskQueue", celery.Category);
|
||||
|
||||
Assert.Contains("Flask", document.Capabilities.DetectedFrameworks);
|
||||
Assert.Contains("Celery", document.Capabilities.DetectedFrameworks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetEnvironment_SetsEnvironmentCorrectly()
|
||||
{
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder
|
||||
.SetEnvironment(
|
||||
pythonVersion: "3.11.4",
|
||||
sitePackagesPaths: ["/venv/lib/python3.11/site-packages"],
|
||||
requirementsFiles: ["/app/requirements.txt"],
|
||||
pyprojectFiles: ["/app/pyproject.toml"],
|
||||
virtualenvPath: "/venv",
|
||||
condaPrefix: null,
|
||||
isContainer: true)
|
||||
.Build();
|
||||
|
||||
Assert.Equal("3.11.4", document.Environment.PythonVersion);
|
||||
Assert.Single(document.Environment.SitePackagesPaths);
|
||||
Assert.Single(document.Environment.RequirementsFiles);
|
||||
Assert.Single(document.Environment.PyprojectFiles);
|
||||
Assert.Equal("/venv", document.Environment.VirtualenvPath);
|
||||
Assert.Null(document.Environment.CondaPrefix);
|
||||
Assert.True(document.Environment.IsContainer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddWarning_AddsWarningsCorrectly()
|
||||
{
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder
|
||||
.AddWarning("PY001", "Unresolved import: missing_module", "/app/main.py", 15)
|
||||
.AddWarning("PY002", "Deprecated package usage", severity: "info")
|
||||
.Build();
|
||||
|
||||
Assert.Equal(2, document.Warnings.Length);
|
||||
|
||||
var warning1 = document.Warnings.First(w => w.Code == "PY001");
|
||||
Assert.Equal("Unresolved import: missing_module", warning1.Message);
|
||||
Assert.Equal("/app/main.py", warning1.FilePath);
|
||||
Assert.Equal(15, warning1.Line);
|
||||
Assert.Equal("warning", warning1.Severity);
|
||||
|
||||
var warning2 = document.Warnings.First(w => w.Code == "PY002");
|
||||
Assert.Null(warning2.FilePath);
|
||||
Assert.Equal("info", warning2.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetRuntimeEvidence_SetsEvidenceCorrectly()
|
||||
{
|
||||
var evidence = new PythonObservationRuntimeEvidence(
|
||||
HasEvidence: true,
|
||||
RuntimePythonVersion: "3.11.4",
|
||||
RuntimePlatform: "linux",
|
||||
LoadedModulesCount: 50,
|
||||
LoadedPackages: ImmutableArray.Create("numpy", "pandas"),
|
||||
LoadedModules: ImmutableArray.Create("myapp", "myapp.core"),
|
||||
PathHashes: ImmutableDictionary<string, string>.Empty.Add("/app/main.py", "abc123"),
|
||||
RuntimeCapabilities: ImmutableArray.Create("network", "filesystem"),
|
||||
Errors: ImmutableArray<PythonObservationRuntimeError>.Empty);
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.SetRuntimeEvidence(evidence).Build();
|
||||
|
||||
Assert.NotNull(document.RuntimeEvidence);
|
||||
Assert.True(document.RuntimeEvidence.HasEvidence);
|
||||
Assert.Equal("3.11.4", document.RuntimeEvidence.RuntimePythonVersion);
|
||||
Assert.Equal(50, document.RuntimeEvidence.LoadedModulesCount);
|
||||
Assert.Contains("numpy", document.RuntimeEvidence.LoadedPackages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddImportEdges_AddsEdgesCorrectly()
|
||||
{
|
||||
var import1 = new PythonImport(
|
||||
Module: "requests",
|
||||
Names: null,
|
||||
Alias: null,
|
||||
Kind: PythonImportKind.Import,
|
||||
RelativeLevel: 0,
|
||||
SourceFile: "/app/client.py",
|
||||
LineNumber: 1,
|
||||
Confidence: PythonImportConfidence.Definitive);
|
||||
|
||||
var import2 = new PythonImport(
|
||||
Module: "json",
|
||||
Names: [new PythonImportedName("loads"), new PythonImportedName("dumps")],
|
||||
Alias: null,
|
||||
Kind: PythonImportKind.FromImport,
|
||||
RelativeLevel: 0,
|
||||
SourceFile: "/app/client.py",
|
||||
LineNumber: 2,
|
||||
Confidence: PythonImportConfidence.High);
|
||||
|
||||
var edges = new[]
|
||||
{
|
||||
new PythonImportEdge("myapp.client", "requests", import1),
|
||||
new PythonImportEdge("myapp.client", "json", import2)
|
||||
};
|
||||
|
||||
var builder = new PythonObservationBuilder();
|
||||
var document = builder.AddImportEdges(edges).Build();
|
||||
|
||||
Assert.Equal(2, document.ImportEdges.Length);
|
||||
|
||||
var requestsEdge = document.ImportEdges.First(e => e.ToModule == "requests");
|
||||
Assert.Equal("myapp.client", requestsEdge.FromModule);
|
||||
Assert.Equal(PythonObservationImportKind.Import, requestsEdge.Kind);
|
||||
Assert.Equal(PythonObservationConfidence.Definitive, requestsEdge.Confidence);
|
||||
|
||||
var jsonEdge = document.ImportEdges.First(e => e.ToModule == "json");
|
||||
Assert.Equal(PythonObservationImportKind.FromImport, jsonEdge.Kind);
|
||||
Assert.Equal(PythonObservationConfidence.High, jsonEdge.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FluentBuilder_ChainsCorrectly()
|
||||
{
|
||||
var packages = new[]
|
||||
{
|
||||
new PythonPackageInfo(
|
||||
Name: "flask",
|
||||
Version: "3.0.0",
|
||||
Kind: PythonPackageKind.Wheel,
|
||||
Location: "/venv/lib/python3.11/site-packages",
|
||||
MetadataPath: null,
|
||||
TopLevelModules: ImmutableArray.Create("flask"),
|
||||
Dependencies: ImmutableArray<string>.Empty,
|
||||
Extras: ImmutableArray<string>.Empty,
|
||||
RecordFiles: ImmutableArray<PythonRecordEntry>.Empty,
|
||||
InstallerTool: "pip",
|
||||
EditableTarget: null,
|
||||
IsDirectDependency: true,
|
||||
Confidence: PythonPackageConfidence.High)
|
||||
};
|
||||
|
||||
var capabilities = new[]
|
||||
{
|
||||
new PythonCapability(
|
||||
Kind: PythonCapabilityKind.NetworkAccess,
|
||||
SourceFile: "/app/app.py",
|
||||
LineNumber: 10,
|
||||
Evidence: "http server",
|
||||
Confidence: PythonCapabilityConfidence.High)
|
||||
};
|
||||
|
||||
var frameworks = new[]
|
||||
{
|
||||
new PythonFrameworkHint(
|
||||
Kind: PythonFrameworkKind.Flask,
|
||||
SourceFile: "/app/app.py",
|
||||
LineNumber: 3,
|
||||
Evidence: "Flask(__name__)",
|
||||
Confidence: PythonFrameworkConfidence.Definitive)
|
||||
};
|
||||
|
||||
var document = new PythonObservationBuilder()
|
||||
.AddPackages(packages)
|
||||
.AddCapabilities(capabilities)
|
||||
.AddFrameworkHints(frameworks)
|
||||
.SetEnvironment("3.11.0", isContainer: false)
|
||||
.AddWarning("PY100", "Test warning")
|
||||
.Build();
|
||||
|
||||
Assert.Single(document.Packages);
|
||||
Assert.Single(document.Frameworks);
|
||||
Assert.Single(document.Warnings);
|
||||
Assert.True(document.Capabilities.UsesNetworkAccess);
|
||||
Assert.Equal("3.11.0", document.Environment.PythonVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.Observations;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.Observations;
|
||||
|
||||
public sealed class PythonObservationSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_EmptyDocument_ProducesValidJson()
|
||||
{
|
||||
var document = new PythonObservationBuilder().Build();
|
||||
|
||||
var json = PythonObservationSerializer.Serialize(document);
|
||||
|
||||
Assert.NotEmpty(json);
|
||||
Assert.Contains("\"schema\":", json);
|
||||
Assert.Contains("\"python-aoc-v1\"", json);
|
||||
Assert.Contains("\"packages\":", json);
|
||||
Assert.Contains("\"modules\":", json);
|
||||
Assert.Contains("\"capabilities\":", json);
|
||||
|
||||
// Validate it's parseable JSON
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
Assert.NotNull(parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_Compact_ProducesMinifiedJson()
|
||||
{
|
||||
var document = new PythonObservationBuilder()
|
||||
.AddWarning("PY001", "Test warning")
|
||||
.Build();
|
||||
|
||||
var pretty = PythonObservationSerializer.Serialize(document, compact: false);
|
||||
var compact = PythonObservationSerializer.Serialize(document, compact: true);
|
||||
|
||||
Assert.True(compact.Length < pretty.Length);
|
||||
Assert.DoesNotContain("\n", compact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_UsesCamelCase()
|
||||
{
|
||||
var document = new PythonObservationBuilder()
|
||||
.SetEnvironment("3.11.0", isContainer: true)
|
||||
.Build();
|
||||
|
||||
var json = PythonObservationSerializer.Serialize(document);
|
||||
|
||||
Assert.Contains("\"pythonVersion\":", json);
|
||||
Assert.Contains("\"sitePackagesPaths\":", json);
|
||||
Assert.Contains("\"isContainer\":", json);
|
||||
Assert.DoesNotContain("\"PythonVersion\":", json);
|
||||
Assert.DoesNotContain("\"SitePackagesPaths\":", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_OmitsNullValues()
|
||||
{
|
||||
var document = new PythonObservationBuilder().Build();
|
||||
|
||||
var json = PythonObservationSerializer.Serialize(document);
|
||||
|
||||
// RuntimeEvidence is null by default
|
||||
Assert.DoesNotContain("\"runtimeEvidence\":", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_EnumsAsStrings()
|
||||
{
|
||||
var edges = ImmutableArray.Create(
|
||||
new PythonObservationImportEdge(
|
||||
FromModule: "app",
|
||||
ToModule: "requests",
|
||||
Kind: PythonObservationImportKind.Import,
|
||||
Confidence: PythonObservationConfidence.High,
|
||||
ResolvedPath: null,
|
||||
SourceFile: "/app/main.py",
|
||||
Line: 1,
|
||||
ResolverTrace: ImmutableArray<string>.Empty));
|
||||
|
||||
var document = new PythonObservationDocument(
|
||||
Schema: "python-aoc-v1",
|
||||
Packages: ImmutableArray<PythonObservationPackage>.Empty,
|
||||
Modules: ImmutableArray<PythonObservationModule>.Empty,
|
||||
Entrypoints: ImmutableArray<PythonObservationEntrypoint>.Empty,
|
||||
DependencyEdges: ImmutableArray<PythonObservationDependencyEdge>.Empty,
|
||||
ImportEdges: edges,
|
||||
NativeExtensions: ImmutableArray<PythonObservationNativeExtension>.Empty,
|
||||
Frameworks: ImmutableArray<PythonObservationFrameworkHint>.Empty,
|
||||
Warnings: ImmutableArray<PythonObservationWarning>.Empty,
|
||||
Environment: new PythonObservationEnvironment(
|
||||
PythonVersion: null,
|
||||
SitePackagesPaths: ImmutableArray<string>.Empty,
|
||||
VersionSources: ImmutableArray<PythonObservationVersionSource>.Empty,
|
||||
RequirementsFiles: ImmutableArray<string>.Empty,
|
||||
PyprojectFiles: ImmutableArray<string>.Empty,
|
||||
VirtualenvPath: null,
|
||||
CondaPrefix: null,
|
||||
IsContainer: false),
|
||||
Capabilities: new PythonObservationCapabilitySummary(
|
||||
UsesProcessExecution: false,
|
||||
UsesNetworkAccess: false,
|
||||
UsesFileSystem: false,
|
||||
UsesCodeExecution: false,
|
||||
UsesDeserialization: false,
|
||||
UsesNativeCode: false,
|
||||
UsesAsyncAwait: false,
|
||||
UsesMultiprocessing: false,
|
||||
DetectedFrameworks: ImmutableArray<string>.Empty,
|
||||
SecuritySensitiveCapabilities: ImmutableArray<string>.Empty));
|
||||
|
||||
var json = PythonObservationSerializer.Serialize(document);
|
||||
|
||||
// Check that enums are serialized as camelCase strings
|
||||
Assert.Contains("\"import\"", json);
|
||||
Assert.Contains("\"high\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_RoundTrips()
|
||||
{
|
||||
var original = new PythonObservationBuilder()
|
||||
.SetEnvironment("3.11.0", ["/venv/lib/python3.11/site-packages"], isContainer: true)
|
||||
.AddWarning("PY001", "Test warning", "/app/main.py", 10)
|
||||
.Build();
|
||||
|
||||
var json = PythonObservationSerializer.Serialize(original);
|
||||
var deserialized = PythonObservationSerializer.Deserialize(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(original.Schema, deserialized.Schema);
|
||||
Assert.Equal(original.Environment.PythonVersion, deserialized.Environment.PythonVersion);
|
||||
Assert.Equal(original.Environment.IsContainer, deserialized.Environment.IsContainer);
|
||||
Assert.Equal(original.Warnings.Length, deserialized.Warnings.Length);
|
||||
Assert.Equal(original.Warnings[0].Code, deserialized.Warnings[0].Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SerializeAsync_WritesToStream()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var document = new PythonObservationBuilder()
|
||||
.AddWarning("PY001", "Test")
|
||||
.Build();
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
await PythonObservationSerializer.SerializeAsync(document, stream, compact: false, cancellationToken);
|
||||
|
||||
stream.Position = 0;
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
Assert.Contains("\"python-aoc-v1\"", json);
|
||||
Assert.Contains("\"PY001\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeserializeAsync_ReadsFromStream()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var json = """
|
||||
{
|
||||
"schema": "python-aoc-v1",
|
||||
"packages": [],
|
||||
"modules": [],
|
||||
"entrypoints": [],
|
||||
"dependencyEdges": [],
|
||||
"importEdges": [],
|
||||
"nativeExtensions": [],
|
||||
"frameworks": [],
|
||||
"warnings": [{"code": "PY001", "message": "Test", "severity": "warning"}],
|
||||
"environment": {
|
||||
"sitePackagesPaths": [],
|
||||
"versionSources": [],
|
||||
"requirementsFiles": [],
|
||||
"pyprojectFiles": [],
|
||||
"isContainer": false
|
||||
},
|
||||
"capabilities": {
|
||||
"usesProcessExecution": false,
|
||||
"usesNetworkAccess": false,
|
||||
"usesFileSystem": false,
|
||||
"usesCodeExecution": false,
|
||||
"usesDeserialization": false,
|
||||
"usesNativeCode": false,
|
||||
"usesAsyncAwait": false,
|
||||
"usesMultiprocessing": false,
|
||||
"detectedFrameworks": [],
|
||||
"securitySensitiveCapabilities": []
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
|
||||
var document = await PythonObservationSerializer.DeserializeAsync(stream, cancellationToken);
|
||||
|
||||
Assert.NotNull(document);
|
||||
Assert.Equal("python-aoc-v1", document.Schema);
|
||||
Assert.Single(document.Warnings);
|
||||
Assert.Equal("PY001", document.Warnings[0].Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_FullDocument_ProducesExpectedStructure()
|
||||
{
|
||||
var document = new PythonObservationDocument(
|
||||
Schema: "python-aoc-v1",
|
||||
Packages: ImmutableArray.Create(
|
||||
new PythonObservationPackage(
|
||||
Name: "requests",
|
||||
Version: "2.31.0",
|
||||
Source: "Wheel",
|
||||
Platform: null,
|
||||
IsDirect: true,
|
||||
InstallerKind: "pip",
|
||||
DistInfoPath: "/site-packages/requests-2.31.0.dist-info",
|
||||
Groups: ImmutableArray<string>.Empty,
|
||||
Extras: ImmutableArray<string>.Empty)),
|
||||
Modules: ImmutableArray.Create(
|
||||
new PythonObservationModule(
|
||||
Name: "myapp",
|
||||
Type: "package",
|
||||
FilePath: "/app/myapp/__init__.py",
|
||||
Line: null,
|
||||
IsNamespacePackage: false,
|
||||
ParentPackage: null,
|
||||
Imports: ImmutableArray.Create("requests", "json"))),
|
||||
Entrypoints: ImmutableArray.Create(
|
||||
new PythonObservationEntrypoint(
|
||||
Path: "/app/myapp/__main__.py",
|
||||
Type: "PackageMain",
|
||||
Handler: null,
|
||||
RequiredPackages: ImmutableArray<string>.Empty,
|
||||
InvocationContext: "Module")),
|
||||
DependencyEdges: ImmutableArray.Create(
|
||||
new PythonObservationDependencyEdge(
|
||||
FromPackage: "myapp",
|
||||
ToPackage: "requests",
|
||||
VersionConstraint: ">=2.28.0",
|
||||
Extra: null,
|
||||
IsOptional: false)),
|
||||
ImportEdges: ImmutableArray<PythonObservationImportEdge>.Empty,
|
||||
NativeExtensions: ImmutableArray<PythonObservationNativeExtension>.Empty,
|
||||
Frameworks: ImmutableArray<PythonObservationFrameworkHint>.Empty,
|
||||
Warnings: ImmutableArray<PythonObservationWarning>.Empty,
|
||||
Environment: new PythonObservationEnvironment(
|
||||
PythonVersion: "3.11.4",
|
||||
SitePackagesPaths: ImmutableArray.Create("/app/.venv/lib/python3.11/site-packages"),
|
||||
VersionSources: ImmutableArray<PythonObservationVersionSource>.Empty,
|
||||
RequirementsFiles: ImmutableArray.Create("/app/requirements.txt"),
|
||||
PyprojectFiles: ImmutableArray<string>.Empty,
|
||||
VirtualenvPath: "/app/.venv",
|
||||
CondaPrefix: null,
|
||||
IsContainer: true),
|
||||
Capabilities: new PythonObservationCapabilitySummary(
|
||||
UsesProcessExecution: false,
|
||||
UsesNetworkAccess: true,
|
||||
UsesFileSystem: false,
|
||||
UsesCodeExecution: false,
|
||||
UsesDeserialization: false,
|
||||
UsesNativeCode: false,
|
||||
UsesAsyncAwait: true,
|
||||
UsesMultiprocessing: false,
|
||||
DetectedFrameworks: ImmutableArray.Create("FastAPI"),
|
||||
SecuritySensitiveCapabilities: ImmutableArray<string>.Empty));
|
||||
|
||||
var json = PythonObservationSerializer.Serialize(document);
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
|
||||
// Verify structure
|
||||
var root = parsed.RootElement;
|
||||
Assert.Equal("python-aoc-v1", root.GetProperty("schema").GetString());
|
||||
Assert.Equal(1, root.GetProperty("packages").GetArrayLength());
|
||||
Assert.Equal("requests", root.GetProperty("packages")[0].GetProperty("name").GetString());
|
||||
Assert.True(root.GetProperty("capabilities").GetProperty("usesNetworkAccess").GetBoolean());
|
||||
Assert.Equal("3.11.4", root.GetProperty("environment").GetProperty("pythonVersion").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.RuntimeEvidence;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.RuntimeEvidence;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PythonPathHasher path scrubbing and hashing functionality.
|
||||
/// </summary>
|
||||
public sealed class PythonPathHasherTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/home/user/project/main.py", "[HOME]/project/main.py")]
|
||||
[InlineData("/Users/developer/code/app.py", "[HOME]/code/app.py")]
|
||||
[InlineData("C:\\Users\\admin\\Documents\\script.py", "[HOME]/Documents/script.py")]
|
||||
[InlineData("/root/.local/lib/python3.11/site-packages/flask/__init__.py", "[ROOT]/.local/lib/python3.11/site-packages/flask/__init__.py")]
|
||||
[InlineData("/tmp/abc123/temp.py", "[TEMP]/temp.py")]
|
||||
public void ScrubPath_ReplacesSensitiveComponents(string input, string expected)
|
||||
{
|
||||
var result = PythonPathHasher.ScrubPath(input);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrubPath_NullInput_ReturnsEmpty()
|
||||
{
|
||||
var result = PythonPathHasher.ScrubPath(null);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrubPath_EmptyInput_ReturnsEmpty()
|
||||
{
|
||||
var result = PythonPathHasher.ScrubPath(string.Empty);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrubPath_NormalizesPathSeparators()
|
||||
{
|
||||
var result = PythonPathHasher.ScrubPath("C:\\Users\\test\\project\\main.py");
|
||||
Assert.Contains("/", result);
|
||||
Assert.DoesNotContain("\\", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashPath_ReturnsDeterministicHash()
|
||||
{
|
||||
var path = "/usr/lib/python3.11/site-packages/flask/__init__.py";
|
||||
var hash1 = PythonPathHasher.HashPath(path);
|
||||
var hash2 = PythonPathHasher.HashPath(path);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.Equal(64, hash1.Length); // SHA-256 produces 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashPath_NullInput_ReturnsEmpty()
|
||||
{
|
||||
var result = PythonPathHasher.HashPath(null);
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashPath_DifferentPaths_ProduceDifferentHashes()
|
||||
{
|
||||
var hash1 = PythonPathHasher.HashPath("/path/to/module1.py");
|
||||
var hash2 = PythonPathHasher.HashPath("/path/to/module2.py");
|
||||
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashPath_CaseInsensitiveNormalization()
|
||||
{
|
||||
// Windows paths with different case should hash the same
|
||||
var hash1 = PythonPathHasher.HashPath("/Path/To/Module.py");
|
||||
var hash2 = PythonPathHasher.HashPath("/path/to/module.py");
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrubAndHash_ReturnsBothValues()
|
||||
{
|
||||
var path = "/home/user/project/main.py";
|
||||
var (scrubbed, hash) = PythonPathHasher.ScrubAndHash(path);
|
||||
|
||||
Assert.Equal("[HOME]/project/main.py", scrubbed);
|
||||
Assert.NotEmpty(hash);
|
||||
Assert.Equal(64, hash.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/usr/lib/python3.11/site-packages/flask/__init__.py", "flask")]
|
||||
[InlineData("/usr/lib/python3.11/site-packages/requests/api.py", "requests.api")]
|
||||
[InlineData("/usr/lib/python3.11/site-packages/numpy/core/__init__.py", "numpy.core")]
|
||||
[InlineData("/usr/lib/python3.11/dist-packages/django/views.py", "django.views")]
|
||||
public void ExtractModuleName_ExtractsFromSitePackages(string path, string expected)
|
||||
{
|
||||
var result = PythonPathHasher.ExtractModuleName(path);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/usr/lib/python3.11/site-packages/numpy/core/multiarray.cpython-311-x86_64-linux-gnu.so", "numpy.core.multiarray")]
|
||||
[InlineData("/usr/lib/python3.11/site-packages/_ssl.cpython-311-x86_64-linux-gnu.so", "_ssl")]
|
||||
public void ExtractModuleName_HandlesNativeExtensions(string path, string expected)
|
||||
{
|
||||
var result = PythonPathHasher.ExtractModuleName(path);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractModuleName_NullInput_ReturnsNull()
|
||||
{
|
||||
var result = PythonPathHasher.ExtractModuleName(null);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractModuleName_FallbackToFilename()
|
||||
{
|
||||
var result = PythonPathHasher.ExtractModuleName("/some/other/path/mymodule.py");
|
||||
Assert.Equal("mymodule", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python.Internal.RuntimeEvidence;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Python.Tests.RuntimeEvidence;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for PythonRuntimeEvidenceCollector.
|
||||
/// </summary>
|
||||
public sealed class PythonRuntimeEvidenceCollectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseLine_InterpreterStart_CapturesVersion()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "interpreter_start", "python_version": "3.11.5", "platform": "linux", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.True(evidence.HasEvidence);
|
||||
Assert.Equal("3.11.5", evidence.RuntimePythonVersion);
|
||||
Assert.Equal("linux", evidence.RuntimePlatform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ModuleImport_TracksModule()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "module_import", "module": "flask", "path": "/usr/lib/python3.11/site-packages/flask/__init__.py", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.Contains("flask", evidence.LoadedModules);
|
||||
Assert.Contains("flask", evidence.LoadedPackages);
|
||||
Assert.Equal(1, evidence.LoadedModulesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_NestedModule_ExtractsTopLevelPackage()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "module_import", "module": "flask.views", "path": "/usr/lib/python3.11/site-packages/flask/views.py", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.Contains("flask.views", evidence.LoadedModules);
|
||||
Assert.Contains("flask", evidence.LoadedPackages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_NativeLoad_TracksCapability()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "native_load", "module": "numpy.core.multiarray", "path": "/usr/lib/python3.11/site-packages/numpy/core/multiarray.cpython-311-x86_64-linux-gnu.so", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.Contains("numpy.core.multiarray", evidence.LoadedModules);
|
||||
Assert.Contains("native_code", evidence.RuntimeCapabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DynamicImport_TracksCapability()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "dynamic_import", "module": "plugin_module", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.Contains("plugin_module", evidence.LoadedModules);
|
||||
Assert.Contains("dynamic_import", evidence.RuntimeCapabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ProcessSpawn_TracksCapability()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "process_spawn", "spawn_type": "subprocess", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.Contains("process_spawn", evidence.RuntimeCapabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultiprocessingSpawn_TracksMultiprocessingCapability()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "process_spawn", "spawn_type": "multiprocessing", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.Contains("process_spawn", evidence.RuntimeCapabilities);
|
||||
Assert.Contains("multiprocessing", evidence.RuntimeCapabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ModuleError_CapturesError()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "module_error", "module": "missing_module", "error": "ModuleNotFoundError: No module named 'missing_module'", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.NotEmpty(evidence.Errors);
|
||||
Assert.Contains(evidence.Errors, e => e.Message.Contains("missing_module"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_PathModification_AddsHash()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "path_modification", "path": "/usr/lib/python3.11/site-packages", "action": "append", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.NotEmpty(evidence.PathHashes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseOutput_MultipleLines_ParsesAll()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var ndjson = """
|
||||
{"type": "interpreter_start", "python_version": "3.11.5", "platform": "linux", "pid": 12345}
|
||||
{"type": "module_import", "module": "os", "path": "/usr/lib/python3.11/os.py", "pid": 12345}
|
||||
{"type": "module_import", "module": "sys", "path": null, "pid": 12345}
|
||||
{"type": "module_import", "module": "json", "path": "/usr/lib/python3.11/json/__init__.py", "pid": 12345}
|
||||
""";
|
||||
|
||||
collector.ParseOutput(ndjson);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.True(evidence.HasEvidence);
|
||||
Assert.Equal("3.11.5", evidence.RuntimePythonVersion);
|
||||
Assert.Contains("os", evidence.LoadedModules);
|
||||
Assert.Contains("sys", evidence.LoadedModules);
|
||||
Assert.Contains("json", evidence.LoadedModules);
|
||||
Assert.Equal(3, evidence.LoadedModulesCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MalformedJson_Ignored()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
collector.ParseLine("not valid json");
|
||||
collector.ParseLine("{incomplete");
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.False(evidence.HasEvidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_EmptyLine_Ignored()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
collector.ParseLine(string.Empty);
|
||||
collector.ParseLine(" ");
|
||||
collector.ParseLine(null!);
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.False(evidence.HasEvidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NoEvents_ReturnsEmptyEvidence()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.False(evidence.HasEvidence);
|
||||
Assert.Null(evidence.RuntimePythonVersion);
|
||||
Assert.Null(evidence.RuntimePlatform);
|
||||
Assert.Equal(0, evidence.LoadedModulesCount);
|
||||
Assert.Empty(evidence.LoadedModules);
|
||||
Assert.Empty(evidence.LoadedPackages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ModulesAreSorted()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
collector.ParseLine("""{"type": "module_import", "module": "zebra", "pid": 1}""");
|
||||
collector.ParseLine("""{"type": "module_import", "module": "alpha", "pid": 1}""");
|
||||
collector.ParseLine("""{"type": "module_import", "module": "middle", "pid": 1}""");
|
||||
|
||||
var evidence = collector.Build();
|
||||
|
||||
Assert.Equal(["alpha", "middle", "zebra"], evidence.LoadedModules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Events_ReturnsAllCapturedEvents()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
collector.ParseLine("""{"type": "interpreter_start", "python_version": "3.11.5", "pid": 1}""");
|
||||
collector.ParseLine("""{"type": "module_import", "module": "os", "pid": 1}""");
|
||||
|
||||
Assert.Equal(2, collector.Events.Count);
|
||||
Assert.Equal(PythonRuntimeEventKind.InterpreterStart, collector.Events[0].Kind);
|
||||
Assert.Equal(PythonRuntimeEventKind.ModuleImport, collector.Events[1].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ScrubbsPathsInEvents()
|
||||
{
|
||||
var collector = new PythonRuntimeEvidenceCollector();
|
||||
|
||||
var json = """{"type": "module_import", "module": "mymodule", "path": "/home/user/project/mymodule.py", "pid": 12345}""";
|
||||
collector.ParseLine(json);
|
||||
|
||||
var moduleEvent = collector.Events[0];
|
||||
Assert.Equal("[HOME]/project/mymodule.py", moduleEvent.ModulePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "jq",
|
||||
"versions": {
|
||||
"stable": "1.7"
|
||||
},
|
||||
"revision": 0,
|
||||
"tap": "homebrew/core",
|
||||
"poured_from_bottle": true,
|
||||
"time": 1700000000,
|
||||
"installed_as_dependency": false,
|
||||
"installed_on_request": true,
|
||||
"runtime_dependencies": [],
|
||||
"build_dependencies": [],
|
||||
"source": {
|
||||
"url": "https://github.com/jqlang/jq/releases/download/jq-1.7/jq-1.7.tar.gz",
|
||||
"checksum": "sha256:jq17hash"
|
||||
},
|
||||
"desc": "Lightweight and flexible command-line JSON processor",
|
||||
"homepage": "https://jqlang.github.io/jq/",
|
||||
"license": "MIT",
|
||||
"arch": "arm64"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "openssl@3",
|
||||
"versions": {
|
||||
"stable": "3.1.0"
|
||||
},
|
||||
"revision": 0,
|
||||
"tap": "homebrew/core",
|
||||
"poured_from_bottle": true,
|
||||
"time": 1699000000,
|
||||
"installed_as_dependency": false,
|
||||
"installed_on_request": true,
|
||||
"runtime_dependencies": [
|
||||
{
|
||||
"full_name": "ca-certificates",
|
||||
"version": "2023-01-10"
|
||||
}
|
||||
],
|
||||
"build_dependencies": [],
|
||||
"source": {
|
||||
"url": "https://www.openssl.org/source/openssl-3.1.0.tar.gz",
|
||||
"checksum": "sha256:aafde89dd0e91c3d0e87c4b4e3f4d4c9f8f5a6e2b3d4c5a6f7e8d9c0a1b2c3d4e5"
|
||||
},
|
||||
"desc": "Cryptography and SSL/TLS Toolkit",
|
||||
"homepage": "https://openssl.org/",
|
||||
"license": "Apache-2.0",
|
||||
"arch": "x86_64"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "wget",
|
||||
"versions": {
|
||||
"stable": "1.21.4"
|
||||
},
|
||||
"revision": 1,
|
||||
"tap": "homebrew/core",
|
||||
"poured_from_bottle": true,
|
||||
"time": 1698500000,
|
||||
"installed_as_dependency": true,
|
||||
"installed_on_request": false,
|
||||
"runtime_dependencies": [
|
||||
{
|
||||
"full_name": "openssl@3",
|
||||
"version": "3.1.0"
|
||||
},
|
||||
{
|
||||
"full_name": "gettext",
|
||||
"version": "0.21.1"
|
||||
}
|
||||
],
|
||||
"build_dependencies": [],
|
||||
"source": {
|
||||
"url": "https://ftp.gnu.org/gnu/wget/wget-1.21.4.tar.gz",
|
||||
"checksum": "sha256:abc123def456"
|
||||
},
|
||||
"desc": "Internet file retriever",
|
||||
"homepage": "https://www.gnu.org/software/wget/",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"arch": "x86_64"
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Homebrew;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Homebrew.Tests;
|
||||
|
||||
public sealed class HomebrewPackageAnalyzerTests
|
||||
{
|
||||
private static readonly string FixturesRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures");
|
||||
|
||||
private readonly HomebrewPackageAnalyzer _analyzer;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public HomebrewPackageAnalyzerTests()
|
||||
{
|
||||
_logger = NullLoggerFactory.Instance.CreateLogger<HomebrewPackageAnalyzer>();
|
||||
_analyzer = new HomebrewPackageAnalyzer((ILogger<HomebrewPackageAnalyzer>)_logger);
|
||||
}
|
||||
|
||||
private OSPackageAnalyzerContext CreateContext(string rootPath)
|
||||
{
|
||||
return new OSPackageAnalyzerContext(
|
||||
rootPath,
|
||||
workspacePath: null,
|
||||
TimeProvider.System,
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerId_ReturnsHomebrew()
|
||||
{
|
||||
Assert.Equal("homebrew", _analyzer.AnalyzerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithValidCellar_ReturnsPackages()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("homebrew", result.AnalyzerId);
|
||||
Assert.True(result.Packages.Count > 0, "Expected at least one package");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FindsIntelCellarPackages()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var openssl = result.Packages.FirstOrDefault(p => p.Name == "openssl@3");
|
||||
Assert.NotNull(openssl);
|
||||
Assert.Equal("3.1.0", openssl.Version);
|
||||
Assert.Equal("x86_64", openssl.Architecture);
|
||||
Assert.Contains("pkg:brew/homebrew%2Fcore/openssl%403@3.1.0", openssl.PackageUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FindsAppleSiliconCellarPackages()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var jq = result.Packages.FirstOrDefault(p => p.Name == "jq");
|
||||
Assert.NotNull(jq);
|
||||
Assert.Equal("1.7", jq.Version);
|
||||
Assert.Equal("arm64", jq.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_PackageWithRevision_IncludesRevisionInPurl()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var wget = result.Packages.FirstOrDefault(p => p.Name == "wget");
|
||||
Assert.NotNull(wget);
|
||||
Assert.Contains("?revision=1", wget.PackageUrl);
|
||||
Assert.Equal("1", wget.Release);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsDependencies()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var wget = result.Packages.FirstOrDefault(p => p.Name == "wget");
|
||||
Assert.NotNull(wget);
|
||||
Assert.Contains("openssl@3", wget.Depends);
|
||||
Assert.Contains("gettext", wget.Depends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVendorMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var openssl = result.Packages.FirstOrDefault(p => p.Name == "openssl@3");
|
||||
Assert.NotNull(openssl);
|
||||
Assert.Equal("homebrew/core", openssl.VendorMetadata["brew:tap"]);
|
||||
Assert.Equal("true", openssl.VendorMetadata["brew:poured_from_bottle"]);
|
||||
Assert.Equal("Cryptography and SSL/TLS Toolkit", openssl.VendorMetadata["description"]);
|
||||
Assert.Equal("https://openssl.org/", openssl.VendorMetadata["homepage"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_SetsEvidenceSourceToHomebrewCellar()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
foreach (var package in result.Packages)
|
||||
{
|
||||
Assert.Equal(PackageEvidenceSource.HomebrewCellar, package.EvidenceSource);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DiscoversBinFiles()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var wget = result.Packages.FirstOrDefault(p => p.Name == "wget");
|
||||
Assert.NotNull(wget);
|
||||
Assert.Contains(wget.Files, f => f.Path.Contains("wget"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ResultsAreDeterministicallySorted()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result1 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
var result2 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(result1.Packages.Count, result2.Packages.Count);
|
||||
for (int i = 0; i < result1.Packages.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Packages[i].PackageUrl, result2.Packages[i].PackageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_NoCellar_ReturnsEmptyPackages()
|
||||
{
|
||||
// Arrange - use temp directory without Cellar structure
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempPath);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempPath);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_PopulatesTelemetry()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Telemetry);
|
||||
Assert.True(result.Telemetry.PackageCount > 0);
|
||||
Assert.True(result.Telemetry.Duration > TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.Analyzers.OS.Homebrew;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Homebrew.Tests;
|
||||
|
||||
public sealed class HomebrewReceiptParserTests
|
||||
{
|
||||
private readonly HomebrewReceiptParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidReceipt_ReturnsExpectedValues()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"name": "openssl@3",
|
||||
"versions": { "stable": "3.1.0" },
|
||||
"revision": 0,
|
||||
"tap": "homebrew/core",
|
||||
"poured_from_bottle": true,
|
||||
"time": 1699000000,
|
||||
"installed_as_dependency": false,
|
||||
"installed_on_request": true,
|
||||
"runtime_dependencies": [{ "full_name": "ca-certificates", "version": "2023-01-10" }],
|
||||
"desc": "Cryptography and SSL/TLS Toolkit",
|
||||
"homepage": "https://openssl.org/",
|
||||
"license": "Apache-2.0",
|
||||
"arch": "x86_64"
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("openssl@3", receipt.Name);
|
||||
Assert.Equal("3.1.0", receipt.Version);
|
||||
Assert.Equal(0, receipt.Revision);
|
||||
Assert.Equal("homebrew/core", receipt.Tap);
|
||||
Assert.True(receipt.PouredFromBottle);
|
||||
Assert.False(receipt.InstalledAsDependency);
|
||||
Assert.True(receipt.InstalledOnRequest);
|
||||
Assert.Single(receipt.RuntimeDependencies);
|
||||
Assert.Equal("ca-certificates", receipt.RuntimeDependencies[0]);
|
||||
Assert.Equal("Cryptography and SSL/TLS Toolkit", receipt.Description);
|
||||
Assert.Equal("https://openssl.org/", receipt.Homepage);
|
||||
Assert.Equal("Apache-2.0", receipt.License);
|
||||
Assert.Equal("x86_64", receipt.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithRevision_ReturnsCorrectRevision()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"name": "wget",
|
||||
"versions": { "stable": "1.21.4" },
|
||||
"revision": 1,
|
||||
"tap": "homebrew/core",
|
||||
"poured_from_bottle": true,
|
||||
"arch": "x86_64"
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("wget", receipt.Name);
|
||||
Assert.Equal("1.21.4", receipt.Version);
|
||||
Assert.Equal(1, receipt.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_AppleSilicon_ReturnsArm64Architecture()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"name": "jq",
|
||||
"versions": { "stable": "1.7" },
|
||||
"revision": 0,
|
||||
"tap": "homebrew/core",
|
||||
"arch": "arm64"
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("arm64", receipt.Architecture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithSourceInfo_ExtractsSourceUrlAndChecksum()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"name": "test",
|
||||
"versions": { "stable": "1.0.0" },
|
||||
"tap": "homebrew/core",
|
||||
"source": {
|
||||
"url": "https://example.com/test-1.0.0.tar.gz",
|
||||
"checksum": "sha256:abcdef123456"
|
||||
}
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("https://example.com/test-1.0.0.tar.gz", receipt.SourceUrl);
|
||||
Assert.Equal("sha256:abcdef123456", receipt.SourceChecksum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MultipleDependencies_SortsAlphabetically()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"name": "test",
|
||||
"versions": { "stable": "1.0.0" },
|
||||
"tap": "homebrew/core",
|
||||
"runtime_dependencies": [
|
||||
{ "full_name": "zlib" },
|
||||
{ "full_name": "openssl" },
|
||||
{ "full_name": "libpng" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal(3, receipt.RuntimeDependencies.Count);
|
||||
Assert.Equal("libpng", receipt.RuntimeDependencies[0]);
|
||||
Assert.Equal("openssl", receipt.RuntimeDependencies[1]);
|
||||
Assert.Equal("zlib", receipt.RuntimeDependencies[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidJson_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var invalidJson = "{ invalid json }";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(invalidJson));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.Null(receipt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EmptyJson_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var emptyJson = "{}";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(emptyJson));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.Null(receipt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingName_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"versions": { "stable": "1.0.0" },
|
||||
"tap": "homebrew/core"
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.Null(receipt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TappedFrom_UsesTappedFromOverTap()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"name": "test",
|
||||
"versions": { "stable": "1.0.0" },
|
||||
"tap": "homebrew/core",
|
||||
"tapped_from": "custom/tap"
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("custom/tap", receipt.Tap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_FallbackVersion_UsesVersionFieldWhenVersionsStableMissing()
|
||||
{
|
||||
// Arrange - older receipt format uses version field directly
|
||||
var json = """
|
||||
{
|
||||
"name": "test",
|
||||
"version": "2.0.0",
|
||||
"tap": "homebrew/core"
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("2.0.0", receipt.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NormalizesArchitecture_AArch64ToArm64()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"name": "test",
|
||||
"versions": { "stable": "1.0.0" },
|
||||
"tap": "homebrew/core",
|
||||
"arch": "aarch64"
|
||||
}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
// Act
|
||||
var receipt = _parser.Parse(stream);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(receipt);
|
||||
Assert.Equal("arm64", receipt.Architecture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Homebrew/StellaOps.Scanner.Analyzers.OS.Homebrew.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,132 @@
|
||||
using StellaOps.Scanner.Analyzers.OS.MacOsBundle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.MacOsBundle.Tests;
|
||||
|
||||
public sealed class EntitlementsParserTests
|
||||
{
|
||||
private static readonly string FixturesRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures");
|
||||
|
||||
private readonly EntitlementsParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidEntitlements_ReturnsEntitlements()
|
||||
{
|
||||
// Arrange
|
||||
var entPath = Path.Combine(FixturesRoot, "Applications", "SandboxedApp.app", "Contents", "_CodeSignature", "test.xcent");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(entPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsSandboxed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DetectsHighRiskEntitlements()
|
||||
{
|
||||
// Arrange
|
||||
var entPath = Path.Combine(FixturesRoot, "Applications", "SandboxedApp.app", "Contents", "_CodeSignature", "test.xcent");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(entPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result.HighRiskEntitlements);
|
||||
Assert.Contains("com.apple.security.device.camera", result.HighRiskEntitlements);
|
||||
Assert.Contains("com.apple.security.device.microphone", result.HighRiskEntitlements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CategorizeEntitlements()
|
||||
{
|
||||
// Arrange
|
||||
var entPath = Path.Combine(FixturesRoot, "Applications", "SandboxedApp.app", "Contents", "_CodeSignature", "test.xcent");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(entPath);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("network", result.Categories);
|
||||
Assert.Contains("camera", result.Categories);
|
||||
Assert.Contains("microphone", result.Categories);
|
||||
Assert.Contains("filesystem", result.Categories);
|
||||
Assert.Contains("sandbox", result.Categories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NonExistentFile_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var entPath = Path.Combine(FixturesRoot, "nonexistent.xcent");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(entPath);
|
||||
|
||||
// Assert
|
||||
Assert.Same(BundleEntitlements.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindEntitlementsFile_FindsXcentFile()
|
||||
{
|
||||
// Arrange
|
||||
var bundlePath = Path.Combine(FixturesRoot, "Applications", "SandboxedApp.app");
|
||||
|
||||
// Act
|
||||
var result = _parser.FindEntitlementsFile(bundlePath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.EndsWith(".xcent", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindEntitlementsFile_NoBundlePath_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _parser.FindEntitlementsFile("");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindEntitlementsFile_NoEntitlements_ReturnsNull()
|
||||
{
|
||||
// Arrange - bundle without entitlements
|
||||
var bundlePath = Path.Combine(FixturesRoot, "Applications", "TestApp.app");
|
||||
|
||||
// Act
|
||||
var result = _parser.FindEntitlementsFile(bundlePath);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasEntitlement_ReturnsTrueForExistingEntitlement()
|
||||
{
|
||||
// Arrange
|
||||
var entPath = Path.Combine(FixturesRoot, "Applications", "SandboxedApp.app", "Contents", "_CodeSignature", "test.xcent");
|
||||
var result = _parser.Parse(entPath);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(result.HasEntitlement("com.apple.security.app-sandbox"));
|
||||
Assert.True(result.HasEntitlement("com.apple.security.device.camera"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasEntitlement_ReturnsFalseForMissingEntitlement()
|
||||
{
|
||||
// Arrange
|
||||
var entPath = Path.Combine(FixturesRoot, "Applications", "SandboxedApp.app", "Contents", "_CodeSignature", "test.xcent");
|
||||
var result = _parser.Parse(entPath);
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(result.HasEntitlement("com.apple.security.nonexistent"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.stellaops.sandboxed</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>SandboxedApp</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>100</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>SandboxedApp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Placeholder executable
|
||||
echo "SandboxedApp"
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Contents/Info.plist</key>
|
||||
<data>aGFzaA==</data>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.stellaops.testapp</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>TestApp</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Test Application</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>123</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.3</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>TestApp</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Placeholder executable
|
||||
echo "TestApp"
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Contents/Info.plist</key>
|
||||
<data>aGFzaA==</data>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,115 @@
|
||||
using StellaOps.Scanner.Analyzers.OS.MacOsBundle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.MacOsBundle.Tests;
|
||||
|
||||
public sealed class InfoPlistParserTests
|
||||
{
|
||||
private static readonly string FixturesRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures");
|
||||
|
||||
private readonly InfoPlistParser _parser = new();
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidInfoPlist_ReturnsBundleInfo()
|
||||
{
|
||||
// Arrange
|
||||
var plistPath = Path.Combine(FixturesRoot, "Applications", "TestApp.app", "Contents", "Info.plist");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(plistPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("com.stellaops.testapp", result.BundleIdentifier);
|
||||
Assert.Equal("TestApp", result.BundleName);
|
||||
Assert.Equal("Test Application", result.BundleDisplayName);
|
||||
Assert.Equal("123", result.Version);
|
||||
Assert.Equal("1.2.3", result.ShortVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ExtractsMinimumSystemVersion()
|
||||
{
|
||||
// Arrange
|
||||
var plistPath = Path.Combine(FixturesRoot, "Applications", "TestApp.app", "Contents", "Info.plist");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(plistPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("12.0", result.MinimumSystemVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ExtractsExecutable()
|
||||
{
|
||||
// Arrange
|
||||
var plistPath = Path.Combine(FixturesRoot, "Applications", "TestApp.app", "Contents", "Info.plist");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(plistPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("TestApp", result.Executable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ExtractsSupportedPlatforms()
|
||||
{
|
||||
// Arrange
|
||||
var plistPath = Path.Combine(FixturesRoot, "Applications", "TestApp.app", "Contents", "Info.plist");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(plistPath);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.SupportedPlatforms);
|
||||
Assert.Contains("MacOSX", result.SupportedPlatforms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NonExistentFile_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var plistPath = Path.Combine(FixturesRoot, "nonexistent.plist");
|
||||
|
||||
// Act
|
||||
var result = _parser.Parse(plistPath);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingBundleIdentifier_ReturnsNull()
|
||||
{
|
||||
// Arrange - Create a temp file without CFBundleIdentifier
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.plist");
|
||||
File.WriteAllText(tempPath, @"<?xml version=""1.0"" encoding=""UTF-8""?>
|
||||
<!DOCTYPE plist PUBLIC ""-//Apple//DTD PLIST 1.0//EN"" ""http://www.apple.com/DTDs/PropertyList-1.0.dtd"">
|
||||
<plist version=""1.0"">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>TestApp</string>
|
||||
</dict>
|
||||
</plist>");
|
||||
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var result = _parser.Parse(tempPath);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.MacOsBundle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.MacOsBundle.Tests;
|
||||
|
||||
public sealed class MacOsBundleAnalyzerTests
|
||||
{
|
||||
private static readonly string FixturesRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures");
|
||||
|
||||
private readonly MacOsBundleAnalyzer _analyzer;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public MacOsBundleAnalyzerTests()
|
||||
{
|
||||
_logger = NullLoggerFactory.Instance.CreateLogger<MacOsBundleAnalyzer>();
|
||||
_analyzer = new MacOsBundleAnalyzer((ILogger<MacOsBundleAnalyzer>)_logger);
|
||||
}
|
||||
|
||||
private OSPackageAnalyzerContext CreateContext(string rootPath)
|
||||
{
|
||||
return new OSPackageAnalyzerContext(
|
||||
rootPath,
|
||||
workspacePath: null,
|
||||
TimeProvider.System,
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerId_ReturnsMacosBundleIdentifier()
|
||||
{
|
||||
Assert.Equal("macos-bundle", _analyzer.AnalyzerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithValidBundles_ReturnsPackages()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("macos-bundle", result.AnalyzerId);
|
||||
Assert.True(result.Packages.Count > 0, "Expected at least one bundle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FindsTestApp()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var testApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.testapp");
|
||||
Assert.NotNull(testApp);
|
||||
Assert.Equal("1.2.3", testApp.Version);
|
||||
Assert.Equal("Test Application", testApp.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVersionCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var testApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.testapp");
|
||||
Assert.NotNull(testApp);
|
||||
// ShortVersion takes precedence
|
||||
Assert.Equal("1.2.3", testApp.Version);
|
||||
// Build number goes to release
|
||||
Assert.Equal("123", testApp.Release);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_BuildsCorrectPurl()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var testApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.testapp");
|
||||
Assert.NotNull(testApp);
|
||||
Assert.Contains("pkg:generic/macos-app/com.stellaops.testapp@1.2.3", testApp.PackageUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVendorFromBundleId()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var testApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.testapp");
|
||||
Assert.NotNull(testApp);
|
||||
Assert.Equal("stellaops", testApp.SourcePackage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_SetsEvidenceSourceToMacOsBundle()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
foreach (var package in result.Packages)
|
||||
{
|
||||
Assert.Equal(PackageEvidenceSource.MacOsBundle, package.EvidenceSource);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVendorMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var testApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.testapp");
|
||||
Assert.NotNull(testApp);
|
||||
Assert.Equal("com.stellaops.testapp", testApp.VendorMetadata["macos:bundle_id"]);
|
||||
Assert.Equal("APPL", testApp.VendorMetadata["macos:bundle_type"]);
|
||||
Assert.Equal("12.0", testApp.VendorMetadata["macos:min_os_version"]);
|
||||
Assert.Equal("TestApp", testApp.VendorMetadata["macos:executable"]);
|
||||
Assert.Equal("MacOSX", testApp.VendorMetadata["macos:platforms"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_IncludesCodeResourcesHash()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var testApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.testapp");
|
||||
Assert.NotNull(testApp);
|
||||
Assert.True(testApp.VendorMetadata.ContainsKey("macos:code_resources_hash"));
|
||||
var hash = testApp.VendorMetadata["macos:code_resources_hash"];
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DetectsSandboxedApp()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var sandboxedApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.sandboxed");
|
||||
Assert.NotNull(sandboxedApp);
|
||||
Assert.Equal("true", sandboxedApp.VendorMetadata["macos:sandboxed"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DetectsHighRiskEntitlements()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var sandboxedApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.sandboxed");
|
||||
Assert.NotNull(sandboxedApp);
|
||||
Assert.True(sandboxedApp.VendorMetadata.ContainsKey("macos:high_risk_entitlements"));
|
||||
var highRisk = sandboxedApp.VendorMetadata["macos:high_risk_entitlements"];
|
||||
// Full entitlement keys are stored
|
||||
Assert.Contains("com.apple.security.device.camera", highRisk);
|
||||
Assert.Contains("com.apple.security.device.microphone", highRisk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DetectsCapabilityCategories()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var sandboxedApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.sandboxed");
|
||||
Assert.NotNull(sandboxedApp);
|
||||
Assert.True(sandboxedApp.VendorMetadata.ContainsKey("macos:capability_categories"));
|
||||
var categories = sandboxedApp.VendorMetadata["macos:capability_categories"];
|
||||
Assert.Contains("network", categories);
|
||||
Assert.Contains("camera", categories);
|
||||
Assert.Contains("microphone", categories);
|
||||
Assert.Contains("filesystem", categories);
|
||||
Assert.Contains("sandbox", categories);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_IncludesFileEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var testApp = result.Packages.FirstOrDefault(p =>
|
||||
p.VendorMetadata.TryGetValue("macos:bundle_id", out var id) &&
|
||||
id == "com.stellaops.testapp");
|
||||
Assert.NotNull(testApp);
|
||||
Assert.True(testApp.Files.Count > 0);
|
||||
|
||||
var executable = testApp.Files.FirstOrDefault(f => f.Path.Contains("MacOS/TestApp"));
|
||||
Assert.NotNull(executable);
|
||||
Assert.False(executable.IsConfigFile);
|
||||
|
||||
var infoPlist = testApp.Files.FirstOrDefault(f => f.Path.Contains("Info.plist"));
|
||||
Assert.NotNull(infoPlist);
|
||||
Assert.True(infoPlist.IsConfigFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ResultsAreDeterministicallySorted()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result1 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
var result2 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(result1.Packages.Count, result2.Packages.Count);
|
||||
for (int i = 0; i < result1.Packages.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Packages[i].PackageUrl, result2.Packages[i].PackageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_NoApplicationsDirectory_ReturnsEmptyPackages()
|
||||
{
|
||||
// Arrange - use temp directory without Applications
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempPath);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempPath);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_PopulatesTelemetry()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Telemetry);
|
||||
Assert.True(result.Telemetry.PackageCount > 0);
|
||||
Assert.True(result.Telemetry.Duration > TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.MacOsBundle/StellaOps.Scanner.Analyzers.OS.MacOsBundle.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PackageIdentifier</key>
|
||||
<string>com.apple.pkg.Safari</string>
|
||||
<key>PackageVersion</key>
|
||||
<string>17.1</string>
|
||||
<key>InstallDate</key>
|
||||
<date>2024-01-15T12:00:00Z</date>
|
||||
<key>InstallPrefixPath</key>
|
||||
<string>/</string>
|
||||
<key>VolumePath</key>
|
||||
<string>/</string>
|
||||
<key>InstallProcessName</key>
|
||||
<string>installer</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PackageIdentifier</key>
|
||||
<string>com.example.app</string>
|
||||
<key>PackageVersion</key>
|
||||
<string>2.5.0</string>
|
||||
<key>VolumePath</key>
|
||||
<string>/</string>
|
||||
<key>InstallProcessName</key>
|
||||
<string>installer</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,171 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Analyzers.OS.Pkgutil;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.OS.Pkgutil.Tests;
|
||||
|
||||
public sealed class PkgutilPackageAnalyzerTests
|
||||
{
|
||||
private static readonly string FixturesRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"Fixtures");
|
||||
|
||||
private readonly PkgutilPackageAnalyzer _analyzer;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public PkgutilPackageAnalyzerTests()
|
||||
{
|
||||
_logger = NullLoggerFactory.Instance.CreateLogger<PkgutilPackageAnalyzer>();
|
||||
_analyzer = new PkgutilPackageAnalyzer((ILogger<PkgutilPackageAnalyzer>)_logger);
|
||||
}
|
||||
|
||||
private OSPackageAnalyzerContext CreateContext(string rootPath)
|
||||
{
|
||||
return new OSPackageAnalyzerContext(
|
||||
rootPath,
|
||||
workspacePath: null,
|
||||
TimeProvider.System,
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzerId_ReturnsPkgutil()
|
||||
{
|
||||
Assert.Equal("pkgutil", _analyzer.AnalyzerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_WithValidReceipts_ReturnsPackages()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("pkgutil", result.AnalyzerId);
|
||||
Assert.True(result.Packages.Count > 0, "Expected at least one package");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FindsSafariPackage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var safari = result.Packages.FirstOrDefault(p => p.Name == "Safari");
|
||||
Assert.NotNull(safari);
|
||||
Assert.Equal("17.1", safari.Version);
|
||||
Assert.Contains("pkg:generic/apple/com.apple.pkg.Safari@17.1", safari.PackageUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVendorFromIdentifier()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var safari = result.Packages.FirstOrDefault(p => p.Name == "Safari");
|
||||
Assert.NotNull(safari);
|
||||
Assert.Equal("apple", safari.SourcePackage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_SetsEvidenceSourceToPkgutilReceipt()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
foreach (var package in result.Packages)
|
||||
{
|
||||
Assert.Equal(PackageEvidenceSource.PkgutilReceipt, package.EvidenceSource);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ExtractsVendorMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var safari = result.Packages.FirstOrDefault(p => p.Name == "Safari");
|
||||
Assert.NotNull(safari);
|
||||
Assert.Equal("com.apple.pkg.Safari", safari.VendorMetadata["pkgutil:identifier"]);
|
||||
Assert.Equal("/", safari.VendorMetadata["pkgutil:volume"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ResultsAreDeterministicallySorted()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result1 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
var result2 = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(result1.Packages.Count, result2.Packages.Count);
|
||||
for (int i = 0; i < result1.Packages.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Packages[i].PackageUrl, result2.Packages[i].PackageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_NoReceiptsDirectory_ReturnsEmptyPackages()
|
||||
{
|
||||
// Arrange - use temp directory without receipts
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempPath);
|
||||
|
||||
try
|
||||
{
|
||||
var context = CreateContext(tempPath);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.Packages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_PopulatesTelemetry()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(FixturesRoot);
|
||||
|
||||
// Act
|
||||
var result = await _analyzer.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.Telemetry);
|
||||
Assert.True(result.Telemetry.PackageCount > 0);
|
||||
Assert.True(result.Telemetry.Duration > TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS/StellaOps.Scanner.Analyzers.OS.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Analyzers.OS.Pkgutil/StellaOps.Scanner.Analyzers.OS.Pkgutil.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user