Add PHP Analyzer Plugin and Composer Lock Data Handling
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented the PhpAnalyzerPlugin to analyze PHP projects.
- Created ComposerLockData class to represent data from composer.lock files.
- Developed ComposerLockReader to load and parse composer.lock files asynchronously.
- Introduced ComposerPackage class to encapsulate package details.
- Added PhpPackage class to represent PHP packages with metadata and evidence.
- Implemented PhpPackageCollector to gather packages from ComposerLockData.
- Created PhpLanguageAnalyzer to perform analysis and emit results.
- Added capability signals for known PHP frameworks and CMS.
- Developed unit tests for the PHP language analyzer and its components.
- Included sample composer.lock and expected output for testing.
- Updated project files for the new PHP analyzer library and tests.
This commit is contained in:
StellaOps Bot
2025-11-22 14:02:49 +02:00
parent a7f3c7869a
commit b6b9ffc050
158 changed files with 16272 additions and 809 deletions

View File

@@ -174,6 +174,9 @@ public sealed class DenoLanguageAnalyzer : ILanguageAnalyzer
metadata: runtimeMeta,
view: "runtime");
analysisStore.Set(ScanAnalysisKeys.DenoRuntimePayload, payload);
// Backward compatibility with early runtime experiments that used a string key.
analysisStore.Set("deno.runtime", payload);
// Also emit policy signals into AnalysisStore for downstream consumption.

View File

@@ -24,123 +24,460 @@ internal static class DenoRuntimeShim
// NOTE: This shim is intentionally self contained and avoids network calls.
private const string ShimSource = """
// @ts-nocheck
// deno-runtime trace shim (offline, deterministic)
// Emits module load, permission use, npm resolution, and wasm load events.
const events: Array<Record<string, unknown>> = [];
const cwd = Deno.cwd().replace(/\\/g, "/");
const entrypointEnv = Deno.env.get("STELLA_DENO_ENTRYPOINT") ?? "";
type ModuleRef = { normalized: string; path_sha256: string };
function nowIso(): string {
return new Date().toISOString();
}
function addEvent(evt: Record<string, unknown>) {
// Deterministic key order via stringify on object literal insertion order.
events.push(evt);
}
function hashPath(input: string): string {
const data = new TextEncoder().encode(input);
const hash = crypto.subtle.digestSync("SHA-256", data);
return Array.from(new Uint8Array(hash))
function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function relPath(abs: string): { normalized: string; path_sha256: string } {
const cwd = Deno.cwd();
const rel = abs.startsWith(cwd) ? abs.slice(cwd.length + 1) : abs;
const normalized = rel.replaceAll("\\", "/");
return { normalized, path_sha256: hashPath(normalized) };
// Minimal synchronous SHA-256 (no async crypto required)
function sha256Hex(value: string): string {
const data = new TextEncoder().encode(value);
const k = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
0xc67178f2,
]);
const h = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const bitLength = data.length * 8;
const paddedLength = (((data.length + 9 + 63) >> 6) << 6);
const buffer = new Uint8Array(paddedLength);
buffer.set(data);
buffer[data.length] = 0x80;
const view = new DataView(buffer.buffer);
const high = Math.floor(bitLength / 0x100000000);
const low = bitLength >>> 0;
view.setUint32(paddedLength - 8, high, false);
view.setUint32(paddedLength - 4, low, false);
const w = new Uint32Array(64);
for (let offset = 0; offset < paddedLength; offset += 64) {
for (let i = 0; i < 16; i++) {
w[i] = view.getUint32(offset + i * 4, false);
}
for (let i = 16; i < 64; i++) {
const s0 = rotateRight(w[i - 15], 7) ^ rotateRight(w[i - 15], 18) ^ (w[i - 15] >>> 3);
const s1 = rotateRight(w[i - 2], 17) ^ rotateRight(w[i - 2], 19) ^ (w[i - 2] >>> 10);
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, hh] = h;
for (let i = 0; i < 64; i++) {
const S1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
const ch = (e & f) ^ (~e & g);
const temp1 = (hh + S1 + ch + k[i] + w[i]) >>> 0;
const S0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + maj) >>> 0;
hh = g;
g = f;
f = e;
e = (d + temp1) >>> 0;
d = c;
c = b;
b = a;
a = (temp1 + temp2) >>> 0;
}
h[0] = (h[0] + a) >>> 0;
h[1] = (h[1] + b) >>> 0;
h[2] = (h[2] + c) >>> 0;
h[3] = (h[3] + d) >>> 0;
h[4] = (h[4] + e) >>> 0;
h[5] = (h[5] + f) >>> 0;
h[6] = (h[6] + g) >>> 0;
h[7] = (h[7] + hh) >>> 0;
}
const out = new Uint8Array(32);
const viewOut = new DataView(out.buffer);
for (let i = 0; i < 8; i++) {
viewOut.setUint32(i * 4, h[i], false);
}
return toHex(out);
}
// Wrap permission requests
const originalPermissions = Deno.permissions;
Deno.permissions = {
...originalPermissions,
request: async (...args: Parameters<typeof originalPermissions.request>) => {
const res = await originalPermissions.request(...args);
const name = args[0]?.name ?? "unknown";
const module = relPath(import.meta.url);
addEvent({
type: "deno.permission.use",
ts: nowIso(),
permission: name,
module,
details: "permissions.request",
});
return res;
},
query: (...args: Parameters<typeof originalPermissions.query>) =>
originalPermissions.query(...args),
revoke: (...args: Parameters<typeof originalPermissions.revoke>) =>
originalPermissions.revoke(...args),
};
function rotateRight(value: number, bits: number): number {
return ((value >>> bits) | (value << (32 - bits))) >>> 0;
}
// Hook dynamic import calls by wrapping import()
const originalImport = globalThis.import ?? ((specifier: string) => import(specifier));
globalThis.import = async (specifier: string) => {
const mod = typeof specifier === "string" ? specifier : String(specifier);
addEvent({
function normalizePermission(name: string | undefined): string {
const normalized = (name ?? "").toLowerCase();
switch (normalized) {
case "read":
case "write":
return "fs";
case "net":
return "net";
case "env":
return "env";
case "ffi":
return "ffi";
case "run":
case "sys":
case "hrtime":
return "process";
case "worker":
return "worker";
default:
return normalized || "unknown";
}
}
const grantedPermissions = new Set<string>();
const originalPermissions = Deno.permissions;
async function primePermissionSnapshot() {
const descriptors: Array<Deno.PermissionDescriptor> = [
{ name: "read" },
{ name: "write" },
{ name: "net" },
{ name: "env" },
{ name: "ffi" },
{ name: "run" as Deno.PermissionName },
{ name: "sys" as Deno.PermissionName },
{ name: "hrtime" as Deno.PermissionName },
];
for (const descriptor of descriptors) {
try {
const status = await originalPermissions.query(descriptor as Deno.PermissionDescriptor);
if (status?.state === "granted") {
grantedPermissions.add(normalizePermission(descriptor.name as string));
}
} catch (_) {
// ignore permission probes that are unsupported in the current runtime
}
}
}
function snapshotPermissions(): string[] {
return Array.from(grantedPermissions).sort();
}
function relativePath(path: string): string {
let candidate = path.replace(/\\/g, "/");
if (candidate.startsWith("file://")) {
candidate = candidate.slice("file://".length);
}
if (!candidate.startsWith("/") && !/^([A-Za-z]:\\\\|[A-Za-z]:\\/)/.test(candidate)) {
candidate = `${cwd}/${candidate}`;
}
if (candidate.startsWith(cwd)) {
const offset = cwd.endsWith("/") ? cwd.length : cwd.length + 1;
candidate = candidate.slice(offset);
}
candidate = candidate.replace(/^\.\//, "").replace(/^\/+/, "");
return candidate.replace(/\\/g, "/") || ".";
}
function toFileUrl(path: string): URL {
const normalized = path.replace(/\\/g, "/");
if (normalized.startsWith("file://")) {
return new URL(normalized);
}
const absolute = normalized.startsWith("/") || /^([A-Za-z]:\\\\|[A-Za-z]:\\/)/.test(normalized)
? normalized
: `${cwd}/${normalized}`;
const prefix = absolute.startsWith("/") ? "file://" : "file:///";
return new URL(prefix + encodeURI(absolute.replace(/#/g, "%23")));
}
function normalizeModule(specifier: string): ModuleRef {
try {
const url = new URL(specifier);
if (url.protocol === "file:") {
const rel = relativePath(decodeURIComponent(url.pathname));
return { normalized: rel, path_sha256: sha256Hex(rel) };
}
if (url.protocol === "http:" || url.protocol === "https:") {
const normalized = `${url.protocol}//${url.host}${url.pathname}`;
return { normalized, path_sha256: sha256Hex(normalized) };
}
if (url.protocol === "npm:") {
const normalized = `npm:${url.pathname.replace(/^\//, "")}`;
return { normalized, path_sha256: sha256Hex(normalized) };
}
} catch (_err) {
// not a URL; treat as path
}
const rel = relativePath(specifier);
return { normalized: rel, path_sha256: sha256Hex(rel) };
}
function extractOrigin(specifier: string): string | undefined {
try {
const url = new URL(specifier);
if (url.protocol === "http:" || url.protocol === "https:") {
return `${url.protocol}//${url.host}${url.pathname}`;
}
if (url.protocol === "npm:") {
return `npm:${url.pathname.replace(/^\//, "")}`;
}
} catch (_) {
return undefined;
}
return undefined;
}
function addEvent(evt: Record<string, unknown>) {
events.push(evt);
}
function recordModuleLoad(specifier: string, reason: string, permissions?: string[]) {
const module = normalizeModule(specifier);
const origin = extractOrigin(specifier);
const event: Record<string, unknown> = {
type: "deno.module.load",
ts: nowIso(),
module: relPath(mod),
reason: "dynamic-import",
permissions: [],
origin: mod.startsWith("http") ? mod : undefined,
});
return originalImport(specifier);
};
module,
reason,
permissions: permissions ?? snapshotPermissions(),
};
if (origin) {
event.origin = origin;
}
addEvent(event);
if (specifier.startsWith("npm:")) {
recordNpmResolution(specifier);
}
}
function recordPermissionUse(permission: string, details: string, module?: ModuleRef) {
const normalizedPermission = normalizePermission(permission);
if (normalizedPermission && normalizedPermission !== "unknown") {
grantedPermissions.add(normalizedPermission);
}
// Hook WebAssembly loads
const originalInstantiate = WebAssembly.instantiate;
WebAssembly.instantiate = async (
bufferSource: BufferSource | WebAssembly.Module,
importObject?: WebAssembly.Imports,
) => {
addEvent({
type: "deno.wasm.load",
type: "deno.permission.use",
ts: nowIso(),
module: relPath("wasm://buffer"),
importer: relPath(import.meta.url).normalized,
reason: "instantiate",
permission: normalizedPermission,
module: module ?? normalizeModule(entrypointEnv || "shim://runtime"),
details,
});
return originalInstantiate(bufferSource, importObject);
};
}
function recordNpmResolution(specifier: string) {
const bare = specifier.replace(/^npm:/, "");
const [pkg, version] = bare.split("@");
const denoDir = (Deno.env.get("DENO_DIR") ?? "").replace(/\\/g, "/");
const resolved = denoDir
? `file://${denoDir}/npm/registry.npmjs.org/${pkg ?? bare}/${version ?? ""}`
: `npm:${bare}`;
// Capture npm resolution hints from env when present
const npmMeta = Deno.env.get("STELLA_NPM_SPECIFIER");
if (npmMeta) {
addEvent({
type: "deno.npm.resolution",
ts: nowIso(),
specifier: npmMeta,
package: npmMeta,
version: "",
resolved: "file://$DENO_DIR/npm",
specifier: `npm:${bare}`,
package: pkg ?? bare,
version: version ?? "",
resolved,
exists: true,
});
}
// Write NDJSON on exit
function flush() {
const sorted = events.sort((a, b) => {
const at = String(a.ts);
const bt = String(b.ts);
if (at === bt) return String(a.type).localeCompare(String(b.type));
return at.localeCompare(bt);
function recordWasmLoad(moduleSpecifier: string, importer: string, reason: string) {
addEvent({
type: "deno.wasm.load",
ts: nowIso(),
module: normalizeModule(moduleSpecifier),
importer,
reason,
});
const data = sorted.map((e) => JSON.stringify(e)).join("\\n") + "\\n";
Deno.writeTextFileSync("deno-runtime.ndjson", data);
}
addEvent({
type: "deno.runtime.start",
ts: nowIso(),
module: relPath(import.meta.url),
reason: "shim-start",
});
function hookModuleLoader(): boolean {
try {
const internal = (Deno as unknown as Record<string, unknown>)[Symbol.for("Deno.internal") as unknown as string]
?? (Deno as unknown as Record<string, unknown>).internal;
const loader = (internal as Record<string, unknown>)?.moduleLoader as Record<string, unknown> | undefined;
if (!loader || typeof loader.load !== "function") {
return false;
}
globalThis.addEventListener("unload", () => {
flush();
});
const originalLoad = loader.load.bind(loader) as (...args: unknown[]) => Promise<unknown>;
loader.load = async (...args: unknown[]) => {
const specifier = String(args[0] ?? "");
const isDynamic = Boolean(args[2]);
const reason = specifier.startsWith("npm:") ? "npm" : isDynamic ? "dynamic-import" : "static-import";
recordModuleLoad(specifier, reason);
return await originalLoad(...args);
};
return true;
} catch (err) {
addEvent({ type: "deno.runtime.error", ts: nowIso(), message: String(err?.message ?? err) });
return false;
}
}
function wrapPermissions(entryModule: ModuleRef) {
Deno.permissions = {
...originalPermissions,
request: async (...args: Parameters<typeof originalPermissions.request>) => {
const status = await originalPermissions.request(...args);
recordPermissionUse(args[0]?.name ?? "unknown", "permissions.request", entryModule);
if (status?.state === "granted") {
grantedPermissions.add(normalizePermission(args[0]?.name));
}
return status;
},
query: async (...args: Parameters<typeof originalPermissions.query>) => {
const status = await originalPermissions.query(...args);
if (status?.state === "granted") {
grantedPermissions.add(normalizePermission(args[0]?.name));
}
return status;
},
revoke: async (...args: Parameters<typeof originalPermissions.revoke>) => {
const status = await originalPermissions.revoke(...args);
grantedPermissions.delete(normalizePermission(args[0]?.name));
return status;
},
} as typeof Deno.permissions;
}
function wrapDlopen(entryModule: ModuleRef) {
const original = (Deno as unknown as Record<string, unknown>).dlopen as
| ((path: string | URL, symbols: Record<string, string | Deno.ForeignFunctionDefinition>) => unknown)
| undefined;
if (typeof original !== "function") {
return;
}
(Deno as unknown as Record<string, unknown>).dlopen = (path: string | URL, symbols: Record<string, string | Deno.ForeignFunctionDefinition>) => {
recordPermissionUse("ffi", "Deno.dlopen", entryModule);
return original(path, symbols);
};
}
function wrapWasm(importer: ModuleRef) {
const originalInstantiate = WebAssembly.instantiate;
WebAssembly.instantiate = async (
bufferSource: BufferSource | WebAssembly.Module,
importObject?: WebAssembly.Imports,
) => {
recordWasmLoad("wasm://buffer", importer.normalized, "instantiate");
return await originalInstantiate(bufferSource, importObject);
};
const originalInstantiateStreaming = WebAssembly.instantiateStreaming;
if (originalInstantiateStreaming) {
WebAssembly.instantiateStreaming = async (
source: Response | Promise<Response>,
importObject?: WebAssembly.Imports,
) => {
try {
const response = await source;
const url = response?.url || "wasm://stream";
recordWasmLoad(url, importer.normalized, "instantiateStreaming");
} catch (_) {
recordWasmLoad("wasm://stream", importer.normalized, "instantiateStreaming");
}
return await originalInstantiateStreaming(source as Response, importObject);
};
}
}
function flush() {
try {
const sorted = events.sort((a, b) => {
const at = String(a.ts);
const bt = String(b.ts);
if (at === bt) return String(a.type).localeCompare(String(b.type));
return at.localeCompare(bt);
});
const data = sorted.map((e) => JSON.stringify(e)).join("
");
Deno.writeTextFileSync("deno-runtime.ndjson", data ? `${data}
` : "");
} catch (err) {
// last-resort logging; avoid throwing
console.error("deno-runtime shim failed to write trace", err);
}
}
async function main() {
if (!entrypointEnv) {
addEvent({ type: "deno.runtime.error", ts: nowIso(), message: "STELLA_DENO_ENTRYPOINT missing" });
flush();
return;
}
const entryUrl = toFileUrl(entrypointEnv);
const entryModule = normalizeModule(entryUrl.href);
addEvent({
type: "deno.runtime.start",
ts: nowIso(),
module: entryModule,
reason: "shim-start",
});
await primePermissionSnapshot();
const loaderHooked = hookModuleLoader();
wrapPermissions(entryModule);
wrapDlopen(entryModule);
wrapWasm(entryModule);
if (!loaderHooked) {
recordModuleLoad(entryUrl.href, "static-import", snapshotPermissions());
}
try {
await import(entryUrl.href);
} catch (err) {
addEvent({ type: "deno.runtime.error", ts: nowIso(), message: String(err?.message ?? err) });
} finally {
flush();
}
}
globalThis.addEventListener("unload", flush);
await main();
""";
}

View File

@@ -0,0 +1,11 @@
global using System;
global using System.Collections.Generic;
global using System.Globalization;
global using System.IO;
global using System.Linq;
global using System.Security.Cryptography;
global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -0,0 +1,48 @@
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
internal sealed class ComposerLockData
{
public ComposerLockData(
string lockPath,
string? contentHash,
string? pluginApiVersion,
IReadOnlyList<ComposerPackage> packages,
IReadOnlyList<ComposerPackage> devPackages,
string? lockSha256)
{
LockPath = lockPath ?? string.Empty;
ContentHash = contentHash;
PluginApiVersion = pluginApiVersion;
Packages = packages ?? Array.Empty<ComposerPackage>();
DevPackages = devPackages ?? Array.Empty<ComposerPackage>();
LockSha256 = lockSha256;
}
public string LockPath { get; }
public string? ContentHash { get; }
public string? PluginApiVersion { get; }
public IReadOnlyList<ComposerPackage> Packages { get; }
public IReadOnlyList<ComposerPackage> DevPackages { get; }
public string? LockSha256 { get; }
public bool IsEmpty => Packages.Count == 0 && DevPackages.Count == 0;
public static ComposerLockData Empty { get; } = new(
lockPath: string.Empty,
contentHash: null,
pluginApiVersion: null,
packages: Array.Empty<ComposerPackage>(),
devPackages: Array.Empty<ComposerPackage>(),
lockSha256: null);
public static ValueTask<ComposerLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
return ComposerLockReader.LoadAsync(context, cancellationToken);
}
}

View File

@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
internal static class ComposerLockReader
{
private const string LockFileName = "composer.lock";
public static async ValueTask<ComposerLockData> LoadAsync(LanguageAnalyzerContext context, CancellationToken cancellationToken)
{
var lockPath = Path.Combine(context.RootPath, LockFileName);
if (!File.Exists(lockPath))
{
return ComposerLockData.Empty;
}
await using var stream = File.Open(lockPath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
var contentHash = TryGetString(root, "content-hash");
var pluginApiVersion = TryGetString(root, "plugin-api-version");
var packages = ParsePackages(root, propertyName: "packages", isDev: false);
var devPackages = ParsePackages(root, propertyName: "packages-dev", isDev: true);
var lockSha = await ComputeSha256Async(lockPath, cancellationToken).ConfigureAwait(false);
return new ComposerLockData(
lockPath,
contentHash,
pluginApiVersion,
packages,
devPackages,
lockSha);
}
private static IReadOnlyList<ComposerPackage> ParsePackages(JsonElement root, string propertyName, bool isDev)
{
if (!root.TryGetProperty(propertyName, out var packagesElement) || packagesElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<ComposerPackage>();
}
var packages = new List<ComposerPackage>();
foreach (var packageElement in packagesElement.EnumerateArray())
{
if (!TryGetString(packageElement, "name", out var name)
|| !TryGetString(packageElement, "version", out var version))
{
continue;
}
var type = TryGetString(packageElement, "type");
var (sourceType, sourceReference) = ParseSource(packageElement);
var (distSha, distUrl) = ParseDist(packageElement);
packages.Add(new ComposerPackage(
name,
version,
type,
isDev,
sourceType,
sourceReference,
distSha,
distUrl));
}
return packages;
}
private static (string? SourceType, string? SourceReference) ParseSource(JsonElement packageElement)
{
if (!packageElement.TryGetProperty("source", out var sourceElement) || sourceElement.ValueKind != JsonValueKind.Object)
{
return (null, null);
}
var sourceType = TryGetString(sourceElement, "type");
var sourceReference = TryGetString(sourceElement, "reference");
return (sourceType, sourceReference);
}
private static (string? DistSha, string? DistUrl) ParseDist(JsonElement packageElement)
{
if (!packageElement.TryGetProperty("dist", out var distElement) || distElement.ValueKind != JsonValueKind.Object)
{
return (null, null);
}
var distUrl = TryGetString(distElement, "url");
var distSha = TryGetString(distElement, "shasum") ?? TryGetString(distElement, "checksum");
return (distSha, distUrl);
}
private static string? TryGetString(JsonElement element, string propertyName)
=> TryGetString(element, propertyName, out var value) ? value : null;
private static bool TryGetString(JsonElement element, string propertyName, out string? value)
{
value = null;
if (!element.TryGetProperty(propertyName, out var property))
{
return false;
}
if (property.ValueKind == JsonValueKind.String)
{
value = property.GetString();
return true;
}
return false;
}
private static async ValueTask<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
internal sealed record ComposerPackage(
string Name,
string Version,
string? Type,
bool IsDev,
string? SourceType,
string? SourceReference,
string? DistSha256,
string? DistUrl);

View File

@@ -0,0 +1,32 @@
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
internal static class PhpCapabilitySignals
{
private static readonly (string Package, string Key, string Value)[] KnownSignals =
{
("laravel/framework", "php.capability.framework", "laravel"),
("symfony/symfony", "php.capability.framework", "symfony"),
("drupal/core", "php.capability.cms", "drupal"),
("wordpress/wordpress", "php.capability.cms", "wordpress"),
("magento/product-community-edition", "php.capability.cms", "magento"),
("cakephp/cakephp", "php.capability.framework", "cakephp"),
("slim/slim", "php.capability.framework", "slim"),
("codeigniter4/framework", "php.capability.framework", "codeigniter"),
("laminas/laminas-mvc", "php.capability.framework", "laminas"),
("phpunit/phpunit", "php.capability.test", "phpunit"),
("behat/behat", "php.capability.test", "behat")
};
public static IEnumerable<KeyValuePair<string, string?>> FromPackage(ComposerPackage package)
{
ArgumentNullException.ThrowIfNull(package);
foreach (var (packageName, key, value) in KnownSignals)
{
if (package.Name.Equals(packageName, StringComparison.OrdinalIgnoreCase))
{
yield return new KeyValuePair<string, string?>(key, value);
}
}
}
}

View File

@@ -0,0 +1,83 @@
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
internal sealed class PhpPackage
{
private readonly ComposerPackage _package;
private readonly ComposerLockData _lockData;
public PhpPackage(ComposerPackage package, ComposerLockData lockData)
{
_package = package ?? throw new ArgumentNullException(nameof(package));
_lockData = lockData ?? throw new ArgumentNullException(nameof(lockData));
}
public string Name => _package.Name;
public string Version => _package.Version;
public string Purl => $"pkg:composer/{Name}@{Version}";
public string ComponentKey => $"purl::{Purl}";
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
{
yield return new KeyValuePair<string, string?>("composer.dev", _package.IsDev ? "true" : "false");
if (!string.IsNullOrWhiteSpace(_package.Type))
{
yield return new KeyValuePair<string, string?>("composer.type", _package.Type);
}
if (!string.IsNullOrWhiteSpace(_package.SourceType))
{
yield return new KeyValuePair<string, string?>("composer.source.type", _package.SourceType);
}
if (!string.IsNullOrWhiteSpace(_package.SourceReference))
{
yield return new KeyValuePair<string, string?>("composer.source.ref", _package.SourceReference);
}
if (!string.IsNullOrWhiteSpace(_package.DistSha256))
{
yield return new KeyValuePair<string, string?>("composer.dist.sha256", _package.DistSha256);
}
if (!string.IsNullOrWhiteSpace(_package.DistUrl))
{
yield return new KeyValuePair<string, string?>("composer.dist.url", _package.DistUrl);
}
if (!string.IsNullOrWhiteSpace(_lockData.PluginApiVersion))
{
yield return new KeyValuePair<string, string?>("composer.plugin_api_version", _lockData.PluginApiVersion);
}
if (!string.IsNullOrWhiteSpace(_lockData.ContentHash))
{
yield return new KeyValuePair<string, string?>("composer.content_hash", _lockData.ContentHash);
}
foreach (var signal in PhpCapabilitySignals.FromPackage(_package))
{
yield return signal;
}
}
public IReadOnlyCollection<LanguageComponentEvidence> CreateEvidence()
{
var locator = string.IsNullOrWhiteSpace(_lockData.LockPath)
? "composer.lock"
: Path.GetFileName(_lockData.LockPath);
return new[]
{
new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"composer.lock",
locator,
Value: $"{Name}@{Version}",
Sha256: _lockData.LockSha256)
};
}
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Scanner.Analyzers.Lang.Php.Internal;
internal static class PhpPackageCollector
{
public static IReadOnlyList<PhpPackage> Collect(ComposerLockData lockData)
{
ArgumentNullException.ThrowIfNull(lockData);
if (lockData.IsEmpty)
{
return Array.Empty<PhpPackage>();
}
var packages = new List<PhpPackage>(lockData.Packages.Count + lockData.DevPackages.Count);
foreach (var package in lockData.Packages)
{
packages.Add(new PhpPackage(package, lockData));
}
foreach (var package in lockData.DevPackages)
{
packages.Add(new PhpPackage(package, lockData));
}
return packages;
}
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Php;
public sealed class PhpAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Php";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new PhpLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,38 @@
using StellaOps.Scanner.Analyzers.Lang.Php.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Php;
public sealed class PhpLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "php";
public string DisplayName => "PHP Analyzer";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var lockData = await ComposerLockData.LoadAsync(context, cancellationToken).ConfigureAwait(false);
var packages = PhpPackageCollector.Collect(lockData);
if (packages.Count == 0)
{
return;
}
foreach (var package in packages.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "composer",
metadata: package.CreateMetadata(),
evidence: package.CreateEvidence(),
usedByEntrypoint: false);
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
</ItemGroup>
</Project>

View File

@@ -22,5 +22,7 @@ public static class ScanAnalysisKeys
public const string DenoObservationPayload = "analysis.lang.deno.observation";
public const string DenoRuntimePayload = "analysis.lang.deno.runtime";
public const string RubyObservationPayload = "analysis.lang.ruby.observation";
}