Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions docs/supported-tech.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ Detection happens once per scan, with results persisted to
`data/<projectId>/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
Expand All @@ -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`,
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion packages/scanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dependencies": {
"@deepsec/core": "workspace:*",
"glob": "^11.0.0",
"minimatch": "^10.0.0"
"minimatch": "^10.0.0",
"yaml": "^2.9.0"
}
}
91 changes: 90 additions & 1 deletion packages/scanner/src/__tests__/detect-tech.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"`);
Expand Down
180 changes: 165 additions & 15 deletions packages/scanner/src/detect-tech.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -57,27 +59,174 @@ function listDir(rootPath: string, rel: string): string[] {
*/
type Detector = (rootPath: string, cache: Map<string, string | null>) => 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseJsonObject(raw: string | null): Record<string, unknown> | 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<string, unknown> | 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<string>; exclude: Set<string> },
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<string, string | null>,
): { include: string[]; exclude: string[] } {
const patterns = { include: new Set<string>(), exclude: new Set<string>() };

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, string | null>): string[] {
const paths = new Set<string>();
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<string, unknown>): Set<string> {
const deps = new Set<string>();
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<string>(["."]);
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<string, unknown> | null = null;
try {
parsed = JSON.parse(pkg) as Record<string, unknown>;
} catch {
return tags;
const deps = new Set<string>();
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<string, string>) ?? {}),
...((parsed.devDependencies as Record<string, string>) ?? {}),
...((parsed.peerDependencies as Record<string, string>) ?? {}),
};
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");
Expand Down Expand Up @@ -392,6 +541,7 @@ export function detectTech(rootPath: string): DetectedTech {

const COMMON_SENTINELS = [
"package.json",
"pnpm-workspace.yaml",
"composer.json",
"artisan",
"pyproject.toml",
Expand Down
Loading