diff --git a/packages/deepsec/src/commands/scan.ts b/packages/deepsec/src/commands/scan.ts index 4bfb465..41422b7 100644 --- a/packages/deepsec/src/commands/scan.ts +++ b/packages/deepsec/src/commands/scan.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { findProject, getConfigPath, loadAllFileRecords, projectConfigPath } from "@deepsec/core"; +import { findProject, getConfig, getConfigPath, loadAllFileRecords, projectConfigPath } from "@deepsec/core"; import { scan } from "@deepsec/scanner"; import { BOLD, CYAN, DIM, GREEN, RESET, YELLOW } from "../formatters.js"; import { requireExistingDir } from "../require-dir.js"; @@ -58,7 +58,15 @@ export async function scanCommand(opts: { projectId?: string; root?: string; mat console.log(`${BOLD}Scanning${RESET} ${resolvedRoot} for project ${BOLD}${projectId}${RESET}`); if (matcherSlugs) { - console.log(`${DIM}Filtered matchers:${RESET} ${matcherSlugs.join(", ")}`); + console.log(`${DIM}Matchers (--matchers): ${matcherSlugs.join(", ")}${RESET}`); + } else { + const cfgMatchers = getConfig()?.matchers; + if (cfgMatchers?.only?.length) { + console.log(`${DIM}Matchers (config only): ${cfgMatchers.only.join(", ")}${RESET}`); + } + if (cfgMatchers?.exclude?.length) { + console.log(`${DIM}Matchers (config exclude): ${cfgMatchers.exclude.join(", ")}${RESET}`); + } } // Per-matcher hit counts collected from progress events. Used in the diff --git a/packages/scanner/src/__tests__/resolve-matchers.test.ts b/packages/scanner/src/__tests__/resolve-matchers.test.ts new file mode 100644 index 0000000..23023ef --- /dev/null +++ b/packages/scanner/src/__tests__/resolve-matchers.test.ts @@ -0,0 +1,80 @@ +import type { MatcherPlugin } from "@deepsec/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveMatchers } from "../index.js"; +import { MatcherRegistry } from "../matcher-registry.js"; + +function fakeMatcher(slug: string): MatcherPlugin { + return { + slug, + description: slug, + noiseTier: "normal", + filePatterns: ["**/*.ts"], + match: () => [], + }; +} + +function buildRegistry(slugs: string[]): MatcherRegistry { + const r = new MatcherRegistry(); + for (const s of slugs) r.register(fakeMatcher(s)); + return r; +} + +describe("resolveMatchers", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("returns all matchers when no CLI slugs and no config filter", () => { + const r = buildRegistry(["xss", "rce", "ssrf"]); + const result = resolveMatchers(r, undefined, undefined); + expect(result.map((m) => m.slug).sort()).toEqual(["rce", "ssrf", "xss"]); + }); + + it("CLI slugs override config entirely", () => { + const r = buildRegistry(["xss", "rce", "ssrf"]); + const result = resolveMatchers(r, ["xss"], { exclude: ["xss"], only: ["rce"] }); + expect(result.map((m) => m.slug)).toEqual(["xss"]); + }); + + it("config exclude removes matchers (issue #36)", () => { + const r = buildRegistry(["xss", "rce", "ssrf"]); + const result = resolveMatchers(r, undefined, { exclude: ["xss"] }); + expect(result.map((m) => m.slug).sort()).toEqual(["rce", "ssrf"]); + }); + + it("config only restricts the base set", () => { + const r = buildRegistry(["xss", "rce", "ssrf"]); + const result = resolveMatchers(r, undefined, { only: ["xss", "rce"] }); + expect(result.map((m) => m.slug).sort()).toEqual(["rce", "xss"]); + }); + + it("only and exclude compose: exclude subtracts from only", () => { + const r = buildRegistry(["xss", "rce", "ssrf"]); + const result = resolveMatchers(r, undefined, { only: ["xss", "rce"], exclude: ["rce"] }); + expect(result.map((m) => m.slug)).toEqual(["xss"]); + }); + + it("warns on unknown slug in only and ignores it", () => { + const r = buildRegistry(["xss", "rce"]); + const result = resolveMatchers(r, undefined, { only: ["xss", "bogus"] }); + expect(result.map((m) => m.slug)).toEqual(["xss"]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`unknown matcher slug "bogus"`)); + }); + + it("warns on unknown slug in exclude", () => { + const r = buildRegistry(["xss", "rce"]); + resolveMatchers(r, undefined, { exclude: ["nope"] }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(`unknown matcher slug "nope"`)); + }); + + it("empty only with exclude treats base as all matchers", () => { + const r = buildRegistry(["xss", "rce"]); + const result = resolveMatchers(r, undefined, { only: [], exclude: ["xss"] }); + expect(result.map((m) => m.slug)).toEqual(["rce"]); + }); +}); diff --git a/packages/scanner/src/index.ts b/packages/scanner/src/index.ts index f7ac175..eb1d50b 100644 --- a/packages/scanner/src/index.ts +++ b/packages/scanner/src/index.ts @@ -7,6 +7,7 @@ import { createRunMeta, dataDir, ensureProject, + getConfig, getRegistry, readFileRecord, writeFileRecord, @@ -90,6 +91,42 @@ function buildMergedRegistry(): MatcherRegistry { return registry; } +/** + * Resolve which matchers to run given an optional explicit list (e.g. from + * `--matchers` on the CLI) and the loaded config's `matchers.only/exclude`. + * + * Precedence: + * - If `cliSlugs` is given, it is treated as exact: config is ignored. + * - Otherwise, base = `cfg.only ?? all`, then `cfg.exclude` is subtracted. + * + * Unknown slugs in `only`/`exclude` are warned and ignored. + */ +export function resolveMatchers( + registry: MatcherRegistry, + cliSlugs: string[] | undefined, + cfg: { only?: string[]; exclude?: string[] } | undefined, +): MatcherPlugin[] { + if (cliSlugs) { + return registry.getBySlugs(cliSlugs); + } + + const known = new Set(registry.slugs()); + const warnUnknown = (kind: "only" | "exclude", slugs: string[] | undefined) => { + if (!slugs) return; + for (const s of slugs) { + if (!known.has(s)) { + console.warn(`[deepsec] config matchers.${kind}: unknown matcher slug "${s}" — ignoring`); + } + } + }; + warnUnknown("only", cfg?.only); + warnUnknown("exclude", cfg?.exclude); + + const base = cfg?.only?.length ? registry.getBySlugs(cfg.only) : registry.getAll(); + const exclude = new Set(cfg?.exclude ?? []); + return base.filter((m) => !exclude.has(m.slug)); +} + /** Returns the noise tier for a given vulnSlug. Defaults to "normal". */ export function getNoiseTier(slug: string): import("./types.js").NoiseTier { const registry = buildMergedRegistry(); @@ -360,9 +397,7 @@ export async function scan(params: { languageStats: LanguageStats[]; }> { const registry = buildMergedRegistry(); - const allSelected = params.matcherSlugs - ? registry.getBySlugs(params.matcherSlugs) - : registry.getAll(); + const matchers = resolveMatchers(registry, params.matcherSlugs, getConfig()?.matchers); if (allSelected.length === 0) { throw new Error("No matchers selected");