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();
""";
}