Add PHP Analyzer Plugin and Composer Lock Data Handling
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
""";
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user