From 64ef3b4a4c49df59d9a852c98d53d2c19ebdc38c Mon Sep 17 00:00:00 2001 From: zacharyr0th <100426704+zacharyr0th@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:44:32 -0700 Subject: [PATCH] Detect JS/TS tech tags from workspace package manifests --- docs/supported-tech.md | 25 ++- packages/scanner/package.json | 3 +- .../scanner/src/__tests__/detect-tech.test.ts | 91 ++++++++- packages/scanner/src/detect-tech.ts | 180 ++++++++++++++++-- pnpm-lock.yaml | 12 +- 5 files changed, 279 insertions(+), 32 deletions(-) diff --git a/docs/supported-tech.md b/docs/supported-tech.md index 58bc428..6f96a26 100644 --- a/docs/supported-tech.md +++ b/docs/supported-tech.md @@ -15,6 +15,12 @@ Detection happens once per scan, with results persisted to `data//tech.json`. Matcher gates and prompt highlights share that single signal. +For JavaScript/TypeScript projects, dependency-based detection reads the +root `package.json` plus package manifests in declared npm/Yarn/Bun +`workspaces` and `pnpm-workspace.yaml` packages. Root package-manager +catalogs alone do not count as usage; a workspace package must actually +depend on the framework. + > **Plugin authors:** before adding a matcher for a framework already on > this list, check whether you can extend the existing matcher instead. > If your framework is missing, add a detector entry + matcher + prompt @@ -23,8 +29,8 @@ that single signal. ## TypeScript / JavaScript (Node, Bun, Deno, Workers) ### Next.js (`nextjs`) -- **Sentinel detection:** `package.json` depends on `next`; or - `next.config.{js,ts,mjs}` is present. +- **Sentinel detection:** root or declared workspace `package.json` + depends on `next`; or `next.config.{js,ts,mjs}` is present. - **Matchers:** `all-route-handlers`, `all-server-actions`, `nextjs-middleware`, `nextjs-middleware-only-auth`, `framework-server-action`, `framework-untrusted-fetch`, @@ -36,31 +42,36 @@ that single signal. cache-tag cross-tenant leaks. ### React (`react`) -- **Sentinel detection:** `react` or `react-dom` in `package.json`. +- **Sentinel detection:** `react` or `react-dom` in root or declared + workspace `package.json`. - **Matchers:** `dangerous-html`, `xss`, `postmessage-origin`. - **Prompt highlights:** `dangerouslySetInnerHTML` with DB-stored HTML, ref/effect-driven open redirects, JSON-in-script escapes. ### Express (`express`) -- **Sentinel detection:** `express` in `package.json`. +- **Sentinel detection:** `express` in root or declared workspace + `package.json`. - **Matchers:** `js-express-route` (gated), plus all generic JS matchers. - **Prompt highlights:** route-vs-middleware ordering, `req.*` injection surfaces, `express.static` traversal, error-leak responses, CORS reflect. ### Fastify (`fastify`) -- **Sentinel detection:** `fastify` in `package.json`. +- **Sentinel detection:** `fastify` in root or declared workspace + `package.json`. - **Matchers:** `js-fastify-route` (gated). - **Prompt highlights:** `preHandler`/`onRequest` auth, schema validation as the FP mitigation, plugin scope inheritance. ### NestJS (`nestjs`) -- **Sentinel detection:** any `@nestjs/*` package in `package.json`. +- **Sentinel detection:** any `@nestjs/*` package in root or declared + workspace `package.json`. - **Matchers:** `js-nestjs-controller` (gated). - **Prompt highlights:** missing `@UseGuards`, untyped `@Body()`, `@Public()` opt-outs of global auth. ### Hono (`hono`) -- **Sentinel detection:** `hono` in `package.json`. +- **Sentinel detection:** `hono` in root or declared workspace + `package.json`. - **Matchers:** `js-hono-route` (gated). - **Prompt highlights:** middleware-before-routes ordering, `c.req.*` trust, edge-runtime trust boundary to backend. diff --git a/packages/scanner/package.json b/packages/scanner/package.json index 580d4b3..ab71919 100644 --- a/packages/scanner/package.json +++ b/packages/scanner/package.json @@ -11,6 +11,7 @@ "dependencies": { "@deepsec/core": "workspace:*", "glob": "^11.0.0", - "minimatch": "^10.0.0" + "minimatch": "^10.0.0", + "yaml": "^2.9.0" } } diff --git a/packages/scanner/src/__tests__/detect-tech.test.ts b/packages/scanner/src/__tests__/detect-tech.test.ts index d48cb26..65d11ab 100644 --- a/packages/scanner/src/__tests__/detect-tech.test.ts +++ b/packages/scanner/src/__tests__/detect-tech.test.ts @@ -53,6 +53,95 @@ describe("detectTech", () => { expect(detectTech(tmpRoot).tags).toContain("nestjs"); }); + it("detects Node tech from package.json workspaces", () => { + write( + "package.json", + JSON.stringify({ + workspaces: ["apps/*", "packages/*"], + catalog: { + hono: "4.0.0", + next: "15.0.0", + }, + }), + ); + write( + "apps/web/package.json", + JSON.stringify({ dependencies: { next: "catalog:", react: "catalog:" } }), + ); + write( + "apps/api/package.json", + JSON.stringify({ dependencies: { hono: "catalog:", "drizzle-orm": "catalog:" } }), + ); + write("node_modules/ignored/package.json", JSON.stringify({ dependencies: { express: "^4" } })); + + const tags = detectTech(tmpRoot).tags; + expect(tags).toEqual(expect.arrayContaining(["node", "nextjs", "react", "hono", "drizzle"])); + expect(tags).not.toContain("express"); + }); + + it("does not treat package manager catalogs as dependency usage", () => { + write( + "package.json", + JSON.stringify({ + workspaces: ["apps/*"], + catalog: { + next: "15.0.0", + hono: "4.0.0", + }, + }), + ); + write("apps/web/package.json", JSON.stringify({ dependencies: { "@acme/ui": "workspace:*" } })); + + const tags = detectTech(tmpRoot).tags; + expect(tags).toContain("node"); + expect(tags).not.toContain("nextjs"); + expect(tags).not.toContain("hono"); + }); + + it("does not scan undeclared nested package manifests", () => { + write("package.json", JSON.stringify({ dependencies: { "@acme/root": "1.0.0" } })); + write("apps/web/package.json", JSON.stringify({ dependencies: { next: "15.0.0" } })); + + const tags = detectTech(tmpRoot).tags; + expect(tags).toContain("node"); + expect(tags).not.toContain("nextjs"); + }); + + it("detects Node tech from object-form package.json workspaces", () => { + write("package.json", JSON.stringify({ workspaces: { packages: ["apps/*"] } })); + write("apps/api/package.json", JSON.stringify({ dependencies: { fastify: "^5" } })); + + const tags = detectTech(tmpRoot).tags; + expect(tags).toEqual(expect.arrayContaining(["node", "fastify"])); + }); + + it("detects Node tech from pnpm workspaces and honors excluded package globs", () => { + write( + "pnpm-workspace.yaml", + `packages: + - "apps/*" + - "!apps/private" +`, + ); + write("apps/web/package.json", JSON.stringify({ dependencies: { next: "15.0.0" } })); + write("apps/private/package.json", JSON.stringify({ dependencies: { express: "^4" } })); + + const detected = detectTech(tmpRoot); + expect(detected.tags).toEqual(expect.arrayContaining(["node", "nextjs"])); + expect(detected.tags).not.toContain("express"); + expect(detected.sentinels).toContain("pnpm-workspace.yaml"); + }); + + it("detects Next.js from next.config files", () => { + write("package.json", JSON.stringify({ workspaces: ["apps/*"] })); + write("apps/web/package.json", JSON.stringify({ name: "web" })); + write("apps/web/next.config.ts", "export default {};\n"); + + const tags = detectTech(tmpRoot).tags; + expect(tags).toContain("node"); + expect(tags).toContain("nextjs"); + }); + it("detects Laravel via composer.json", () => { write( "composer.json", @@ -96,7 +185,7 @@ describe("detectTech", () => { it("detects polyglot repos (Next.js + Django + Rails)", () => { write("apps/web/package.json", JSON.stringify({ dependencies: { next: "15.0.0" } })); - write("package.json", JSON.stringify({ dependencies: { next: "15.0.0" } })); + write("package.json", JSON.stringify({ workspaces: ["apps/*"] })); write("manage.py", ""); write("requirements.txt", "Django==5.0"); write("Gemfile", `gem "rails"`); diff --git a/packages/scanner/src/detect-tech.ts b/packages/scanner/src/detect-tech.ts index 0e22daa..f9d5e83 100644 --- a/packages/scanner/src/detect-tech.ts +++ b/packages/scanner/src/detect-tech.ts @@ -1,6 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import { dataDir } from "@deepsec/core"; +import { globSync } from "glob"; +import { parse as parseYaml } from "yaml"; /** * Outcome of inspecting a project root for known tech. Tags are normalized @@ -57,27 +59,174 @@ function listDir(rootPath: string, rel: string): string[] { */ type Detector = (rootPath: string, cache: Map) => string[]; +const NEXT_CONFIG_FILES = ["next.config.js", "next.config.ts", "next.config.mjs"]; + +const WORKSPACE_PACKAGE_IGNORE = [ + "**/node_modules/**", + "**/.git/**", + "**/.next/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/.turbo/**", +]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonObject(raw: string | null): Record | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function parseYamlObject(raw: string | null): Record | null { + if (!raw) return null; + try { + const parsed = parseYaml(raw); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((v): v is string => typeof v === "string" && v.trim().length > 0); +} + +function normalizeWorkspacePattern(pattern: string): string | null { + let normalized = pattern.trim().replaceAll("\\", "/"); + if (!normalized) return null; + const negated = normalized.startsWith("!"); + if (negated) normalized = normalized.slice(1).trim(); + if (!normalized || path.isAbsolute(normalized)) return null; + normalized = normalized.replace(/^\.\//, "").replace(/\/+$/, ""); + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) { + return null; + } + return negated ? `!${normalized}` : normalized; +} + +function addWorkspacePatterns( + target: { include: Set; exclude: Set }, + patterns: string[], +): void { + for (const raw of patterns) { + const normalized = normalizeWorkspacePattern(raw); + if (!normalized) continue; + if (normalized.startsWith("!")) target.exclude.add(normalized.slice(1)); + else target.include.add(normalized); + } +} + +function readWorkspacePatterns( + rootPath: string, + cache: Map, +): { include: string[]; exclude: string[] } { + const patterns = { include: new Set(), exclude: new Set() }; + + const pkg = parseJsonObject(readSafe(rootPath, "package.json", cache)); + if (pkg) { + const workspaces = pkg.workspaces; + if (Array.isArray(workspaces)) { + addWorkspacePatterns(patterns, toStringArray(workspaces)); + } else if (isRecord(workspaces)) { + addWorkspacePatterns(patterns, toStringArray(workspaces.packages)); + } + } + + const pnpmWorkspace = parseYamlObject(readSafe(rootPath, "pnpm-workspace.yaml", cache)); + if (pnpmWorkspace) { + addWorkspacePatterns(patterns, toStringArray(pnpmWorkspace.packages)); + } + + return { + include: Array.from(patterns.include), + exclude: Array.from(patterns.exclude), + }; +} + +function packageJsonGlob(pattern: string): string { + return pattern === "package.json" || pattern.endsWith("/package.json") + ? pattern + : `${pattern}/package.json`; +} + +function workspacePackageJsonPaths(rootPath: string, cache: Map): string[] { + const paths = new Set(); + if (readSafe(rootPath, "package.json", cache) !== null) paths.add("package.json"); + + const { include, exclude } = readWorkspacePatterns(rootPath, cache); + if (include.length === 0) return Array.from(paths); + + const ignore = [ + ...WORKSPACE_PACKAGE_IGNORE, + ...exclude.flatMap((pattern) => [pattern, `${pattern}/**`, packageJsonGlob(pattern)]), + ]; + + for (const rel of globSync(include.map(packageJsonGlob), { + cwd: rootPath, + ignore, + nodir: true, + absolute: false, + })) { + paths.add(rel.replaceAll("\\", "/")); + } + + return Array.from(paths).sort((a, b) => + a === "package.json" ? -1 : b === "package.json" ? 1 : a.localeCompare(b), + ); +} + +function dependencyNamesFromPackageJson(pkg: Record): Set { + const deps = new Set(); + for (const field of ["dependencies", "devDependencies", "peerDependencies"]) { + const entries = pkg[field]; + if (!isRecord(entries)) continue; + for (const name of Object.keys(entries)) deps.add(name); + } + return deps; +} + +function hasNextConfig(rootPath: string, packageJsonPaths: string[]): boolean { + const dirs = new Set(["."]); + for (const rel of packageJsonPaths) dirs.add(path.posix.dirname(rel)); + + for (const dir of dirs) { + for (const file of NEXT_CONFIG_FILES) { + const rel = dir === "." ? file : `${dir}/${file}`; + if (exists(rootPath, rel)) return true; + } + } + + return false; +} + const detectors: Detector[] = [ // --- Node / TS / JS ecosystems --- (root, cache) => { - const pkg = readSafe(root, "package.json", cache); - if (!pkg) return []; + const packageJsonPaths = workspacePackageJsonPaths(root, cache); + const foundNextConfig = hasNextConfig(root, packageJsonPaths); + if (packageJsonPaths.length === 0 && !foundNextConfig) return []; + const tags: string[] = ["node"]; - let parsed: Record | null = null; - try { - parsed = JSON.parse(pkg) as Record; - } catch { - return tags; + const deps = new Set(); + for (const rel of packageJsonPaths) { + const parsed = parseJsonObject(readSafe(root, rel, cache)); + if (!parsed) continue; + for (const name of dependencyNamesFromPackageJson(parsed)) deps.add(name); } - const deps = { - ...((parsed.dependencies as Record) ?? {}), - ...((parsed.devDependencies as Record) ?? {}), - ...((parsed.peerDependencies as Record) ?? {}), - }; - const has = (name: string) => Object.hasOwn(deps, name); - const startsWith = (prefix: string) => Object.keys(deps).some((k) => k.startsWith(prefix)); + const dependencyNames = Array.from(deps); + const has = (name: string) => deps.has(name); + const startsWith = (prefix: string) => dependencyNames.some((k) => k.startsWith(prefix)); - if (has("next")) tags.push("nextjs"); + if (foundNextConfig || has("next")) tags.push("nextjs"); if (has("react") || has("react-dom")) tags.push("react"); if (has("express")) tags.push("express"); if (has("fastify")) tags.push("fastify"); @@ -392,6 +541,7 @@ export function detectTech(rootPath: string): DetectedTech { const COMMON_SENTINELS = [ "package.json", + "pnpm-workspace.yaml", "composer.json", "artisan", "pyproject.toml", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7976904..58bf82c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: minimatch: specifier: ^10.0.0 version: 10.2.5 + yaml: + specifier: ^2.9.0 + version: 2.9.0 packages: @@ -3232,7 +3235,7 @@ packages: strip-json-comments: 5.0.3 tinyglobby: 0.2.16 unbash: 3.0.0 - yaml: 2.8.3 + yaml: 2.9.0 zod: 4.4.1 transitivePeerDependencies: - '@emnapi/core' @@ -4190,17 +4193,10 @@ packages: engines: {node: '>=18'} dev: false - /yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} - engines: {node: '>= 14.6'} - hasBin: true - dev: true - /yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true - dev: false /yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}