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