diff --git a/apps/marketing/src/components/Header.astro b/apps/marketing/src/components/Header.astro index 4d685f94..3dd970f5 100644 --- a/apps/marketing/src/components/Header.astro +++ b/apps/marketing/src/components/Header.astro @@ -74,7 +74,7 @@ const appUrl = (import.meta.env.PUBLIC_APP_URL as string | undefined)?.replace(/ id="mobile-menu" aria-label="Mobile navigation" aria-modal="true" - class="fixed m-0 w-56 max-w-[calc(100%-2rem)] grid gap-1 rounded-xl border border-border bg-bg-raised p-2 shadow-2xl shadow-black/20 open:grid" + class="fixed m-0 w-56 max-w-[calc(100%-2rem)] gap-1 rounded-xl border border-border bg-bg-raised p-2 shadow-2xl shadow-black/20 md:hidden" style="top: 4.5rem; right: 1rem; bottom: auto; left: auto;" > Methodology @@ -151,6 +151,17 @@ const appUrl = (import.meta.env.PUBLIC_APP_URL as string | undefined)?.replace(/ menu.querySelectorAll(".menu-link").forEach((link) => { link.addEventListener("click", () => menu.close()) }) + + // The top navbar is visible from md up, so the mobile menu must never stay open + // there. If the viewport crosses to desktop while the menu is open (e.g. the + // window is widened, or a desktop browser restores a small-window state), close + // it so the two navs never render at once. + const desktopMq = window.matchMedia("(min-width: 48rem)") // Tailwind md + const closeOnDesktop = () => { + if (desktopMq.matches && menu.open) menu.close() + } + desktopMq.addEventListener("change", closeOnDesktop) + closeOnDesktop() } } @@ -159,4 +170,9 @@ const appUrl = (import.meta.env.PUBLIC_APP_URL as string | undefined)?.replace(/ #mobile-menu::backdrop { background: transparent; } + /* The menu is mobile-only. When open (and only on small screens) lay it out as a + vertical grid; on md+ it is hidden entirely by the md:hidden utility above. */ + #mobile-menu[open] { + display: grid; + } diff --git a/apps/web/src/app/(public)/api/og/score/[slug]/route.tsx b/apps/web/src/app/(public)/api/og/score/[slug]/route.tsx index 86dca41d..f2b4975d 100644 --- a/apps/web/src/app/(public)/api/og/score/[slug]/route.tsx +++ b/apps/web/src/app/(public)/api/og/score/[slug]/route.tsx @@ -148,7 +148,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug width, height, headers: { - "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", + // Scorecards can be revoked or superseded; keep the shared/CDN cache window + // short so a revoked card stops being served quickly. Browser cache stays at + // 5 minutes; CDN revalidates after 5 minutes rather than holding for a day. + "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=60", "Content-Disposition": `inline; filename="lyrashield-${variant}-${format}.png"`, }, } diff --git a/apps/worker/src/engine/runner.ts b/apps/worker/src/engine/runner.ts index 9ed38cb8..f81ed142 100644 --- a/apps/worker/src/engine/runner.ts +++ b/apps/worker/src/engine/runner.ts @@ -523,15 +523,16 @@ export async function findRunOutputDir(workDir: string): Promise async function readTextFileBounded(path: string, maxBytes: number): Promise { // The artifact location is selected only from a validated engine output directory. - // eslint-disable-next-line security/detect-non-literal-fs-filename - const fileStat = await lstat(path) - if (!fileStat.isFile()) { - throw new Error(`Engine artifact is not a regular file: ${path}`) - } - + // Open first, then fstat the live handle: this avoids the TOCTOU window where an + // attacker swaps the path for a symlink between a prior lstat and the open. // eslint-disable-next-line security/detect-non-literal-fs-filename const handle = await open(path, "r") try { + const fileStat = await handle.stat() + if (!fileStat.isFile()) { + throw new Error(`Engine artifact is not a regular file: ${path}`) + } + const buffer = Buffer.allocUnsafe(maxBytes + 1) let offset = 0 while (offset <= maxBytes) { diff --git a/packages/cli/src/installers/atomic-write.ts b/packages/cli/src/installers/atomic-write.ts index 29a478eb..17f72c0d 100644 --- a/packages/cli/src/installers/atomic-write.ts +++ b/packages/cli/src/installers/atomic-write.ts @@ -1,22 +1,47 @@ -import { lstat, rename, writeFile } from "node:fs/promises" +import { lstat, open, rename, realpath } from "node:fs/promises" import { randomUUID } from "node:crypto" -import { resolve } from "node:path" +import { dirname, resolve } from "node:path" /** * Atomically write a file with a temp-and-rename pattern. The temp file is - * created with O_EXCL so a pre-existing file or symlink cannot be hijacked, and - * the final path is re-validated with lstat after the rename to ensure it is a - * regular file and not a dangling or followed symlink. + * created with O_EXCL so a pre-existing file or symlink cannot be hijacked, + * fsynced before the rename for durability, and the final path is re-validated + * with lstat after the rename to ensure it is a regular file and not a dangling + * or followed symlink. The destination directory is resolved and checked so a + * parent-path symlink cannot redirect the write outside the intended location. */ export async function atomicWrite(filePath: string, content: string): Promise { const absolutePath = resolve(filePath) + + // Validate the parent directory chain: if any parent is a symlink, the write + // could land outside the intended target (e.g. /tmp/link -> /etc). Resolve the + // directory and confirm the real path matches the requested directory. + const dir = dirname(absolutePath) + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + const realDir = await realpath(dir) + if (realDir !== dir) { + throw new Error(`Refusing to write through a symlinked directory: ${dir}`) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + // Parent does not exist yet (callers mkdir recursively); nothing to validate. + } + const tmp = `${absolutePath}.${randomUUID()}.lyrashield-tmp` // O_EXCL: fail if the temp path already exists (including as a symlink). // This prevents a symlink attack where an attacker points the temp path at - // another file and we overwrite the target. + // another file and we overwrite the target. fsync before rename so the data is + // durable on disk before it becomes visible at the final path. // eslint-disable-next-line security/detect-non-literal-fs-filename - await writeFile(tmp, content, { encoding: "utf-8", flag: "wx" }) + const handle = await open(tmp, "wx") + try { + await handle.writeFile(content, "utf-8") + await handle.sync() + } finally { + await handle.close() + } // filePath is the resolved installer target path selected for this workspace. // eslint-disable-next-line security/detect-non-literal-fs-filename diff --git a/packages/db/src/scan-service-operations.test.ts b/packages/db/src/scan-service-operations.test.ts index 3d2337f2..a8747d65 100644 --- a/packages/db/src/scan-service-operations.test.ts +++ b/packages/db/src/scan-service-operations.test.ts @@ -18,8 +18,17 @@ vi.mock("./client", () => ({ vi.mock("@lyrashield/logger", () => ({ logger: { info: vi.fn() } })) +const mockGetWorkspaceContext = vi.fn() +vi.mock("./extension", () => ({ getWorkspaceContext: () => mockGetWorkspaceContext() })) + import { prisma } from "./client" -import { createScan, getScanWithEvents, listScans, updateScanStatus } from "./scan-service" +import { + addScanEvent, + createScan, + getScanWithEvents, + listScans, + updateScanStatus, +} from "./scan-service" const mockPrisma = prisma as unknown as { $transaction: ReturnType @@ -64,6 +73,57 @@ describe("updateScanStatus", () => { }) }) +describe("addScanEvent — cross-tenant guard", () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.scanEvent.create.mockResolvedValue({ id: "event-1" }) + }) + + it("rejects when there is no workspace context", async () => { + mockGetWorkspaceContext.mockReturnValue(null) + await expect(addScanEvent("scan-1", "queued", "info", "msg")).rejects.toThrow( + "workspace context is required" + ) + expect(mockPrisma.scanEvent.create).not.toHaveBeenCalled() + }) + + it("rejects a scanId that belongs to a different workspace (cross-tenant injection)", async () => { + mockGetWorkspaceContext.mockReturnValue("ws-attacker") + mockPrisma.scan.findUnique.mockResolvedValue({ workspaceId: "ws-victim" }) + + await expect(addScanEvent("scan-victim", "queued", "info", "msg")).rejects.toThrow( + "Scan not found in workspace" + ) + // The ownership comparison must happen before any event row is written. + expect(mockPrisma.scan.findUnique).toHaveBeenCalledWith({ + where: { id: "scan-victim" }, + select: { workspaceId: true }, + }) + expect(mockPrisma.scanEvent.create).not.toHaveBeenCalled() + }) + + it("rejects a scanId that does not exist", async () => { + mockGetWorkspaceContext.mockReturnValue("ws-1") + mockPrisma.scan.findUnique.mockResolvedValue(null) + await expect(addScanEvent("scan-missing", "queued", "info", "msg")).rejects.toThrow( + "Scan not found in workspace" + ) + expect(mockPrisma.scanEvent.create).not.toHaveBeenCalled() + }) + + it("writes the event when the scan belongs to the active workspace", async () => { + mockGetWorkspaceContext.mockReturnValue("ws-1") + mockPrisma.scan.findUnique.mockResolvedValue({ workspaceId: "ws-1" }) + + await expect(addScanEvent("scan-1", "queued", "info", "msg")).resolves.toEqual({ + id: "event-1", + }) + expect(mockPrisma.scanEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scanId: "scan-1", stage: "queued", level: "info" }), + }) + }) +}) + describe("createScan", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/packages/db/src/scan-service.ts b/packages/db/src/scan-service.ts index eb8629a3..a1cc5252 100644 --- a/packages/db/src/scan-service.ts +++ b/packages/db/src/scan-service.ts @@ -203,9 +203,13 @@ export async function addScanEvent( // Defense-in-depth: verify the scan belongs to the current workspace before // writing an event. ScanEvent is a child table without its own workspaceId, so // this prevents cross-tenant event injection if a caller has a valid scanId - // from another workspace. - const scan = await prisma.scan.findUnique({ where: { id: scanId } }) - if (!scan) { + // from another workspace. The existence check alone is not sufficient — we must + // compare the scan's workspaceId to the active workspace context. + const scan = await prisma.scan.findUnique({ + where: { id: scanId }, + select: { workspaceId: true }, + }) + if (!scan || scan.workspaceId !== workspaceId) { throw new Error(`Scan not found in workspace: ${scanId}`) }