A deepsec plugin can fill any of five slots:
| Slot | Purpose |
|---|---|
matchers |
Additional regex matchers, registered alongside the built-ins |
notifiers |
Where findings get reported (Slack, GitHub Issues, webhooks…) |
ownership |
Map files to owning teams/people (e.g. an internal directory) |
people |
Look up a person by email/name (managers, on-call, contact info) |
executor |
Run a deepsec command on remote infrastructure |
A single plugin can fill any subset.
The plugin contract lives in
packages/core/src/plugin.ts:
export interface DeepsecPlugin {
name: string;
matchers?: MatcherPlugin[];
notifiers?: NotifierPlugin[];
ownership?: OwnershipProvider;
people?: PeopleProvider;
executor?: ExecutorProvider;
agents?: AgentPluginRef[];
commands?: (program: unknown) => void; // commander program
}Plugins are loaded from deepsec.config.ts:
import { defineConfig } from "deepsec/config";
import myPlugin from "@my-org/deepsec-plugin";
export default defineConfig({
projects: [{ id: "my-app", root: "../my-app" }],
plugins: [myPlugin({ /* options */ })],
});For an org-internal plugin: a workspace package inside this repo, or a sibling repo. Either works; pnpm/npm workspaces handle the resolution. For a shared plugin: publish to npm under your scope.
Naming convention: @<scope>/plugin-<thing> (Vite style),
e.g. @my-org/plugin-internal-services.
Most common. Same shape as a built-in matcher; see writing-matchers.md for how to write one.
// my-plugin/src/matchers/internal-rpc.ts
import type { MatcherPlugin, CandidateMatch } from "deepsec/config";
import { regexMatcher } from "deepsec/config";
export const internalRpcMatcher: MatcherPlugin = {
slug: "internal-rpc-no-auth",
description: "Internal RPC handler without auth interceptor",
noiseTier: "precise",
filePatterns: ["**/*.go"],
match(content, filePath) {
return regexMatcher("internal-rpc-no-auth", [
{ regex: /NewMyServiceHandler\s*\([^)]*\)/, label: "service handler" },
], content);
},
};// my-plugin/src/index.ts
import type { DeepsecPlugin } from "deepsec/config";
import { internalRpcMatcher } from "./matchers/internal-rpc.js";
export default function myPlugin(): DeepsecPlugin {
return {
name: "@my-org/plugin-internal-services",
matchers: [internalRpcMatcher],
};
}Activate it:
// deepsec.config.ts
import myPlugin from "@my-org/plugin-internal-services";
export default defineConfig({
projects: [/* … */],
plugins: [myPlugin()],
});The plugin's matchers are registered alongside deepsec's built-ins. Slugs are unique. If your slug collides with a built-in, the plugin wins (last-registered overrides). Useful for swapping a built-in matcher for a tighter org-specific version.
A complete inline-plugin example with two real matchers lives at
samples/webapp/deepsec.config.ts and
samples/webapp/matchers/ — the same
shape as a published plugin, just defined in the user's config file.
ownership maps a file to the team or person that owns it. deepsec enrich attaches this data to findings. Useful for routing notifications
and prioritizing review.
The contract:
interface OwnershipProvider {
name: string;
fetchOwnership(args: { filePath: string; repo: string }): Promise<OwnershipData | null>;
}OwnershipData covers contributors, escalation teams, manager email,
on-call info. See packages/core/src/types.ts:OwnershipData.
Return null when ownership data is unavailable; callers treat that as
a soft-fail.
A minimal ownership provider that reads from a CODEOWNERS file:
import type { OwnershipProvider } from "deepsec/config";
import fs from "node:fs";
export function codeownersProvider(rootPath: string): OwnershipProvider {
return {
name: "codeowners",
async fetchOwnership({ filePath }) {
// Parse CODEOWNERS, match filePath against globs, return the
// first matching team/email.
// Return null if no match or file doesn't exist.
// ...
},
};
}An external organization plugin can wrap an internal directory or ownership oracle the same way.
people looks up a person by email or name and returns their metadata
(manager, slack handle, github username). Used by ownership and by
notifiers for @-mentions and escalation.
interface PeopleProvider {
name: string;
lookup(query: string): Promise<Person | null>;
lookupManager?(person: Person): Promise<Person | null>;
}Person has a generic core (name, email, title, managerKey) plus
an extra map for provider-specific fields (e.g. slackId, slackHandle).
An external organization plugin can wrap an internal people directory the same way.
notifiers are where findings get reported. Slack, GitHub Issues,
webhooks, an internal incident system; whatever fits.
interface NotifierPlugin {
name: string;
notify(params: NotifyParams): Promise<FindingNotification>;
}NotifyParams carries the finding, the FileRecord, and the projectId.
FindingNotification carries an externalId and externalUrl for
correlation back to the source.
deepsec doesn't ship a notifier in core. The original Slack notifier was removed during open-sourcing because Slack belongs in a plugin. A GitHub Issues notifier would be a good first plugin to write.
executor runs deepsec commands on remote infrastructure. The in-tree
@vercel/sandbox executor is the canonical example. Docker, Kubernetes,
and AWS-Batch executors all fit here.
interface ExecutorProvider {
name: string;
launch(req: ExecutorLaunchRequest, onLog: (m: string) => void): Promise<string>; // runId
collect(runId: string): Promise<void>;
status?(runId: string): Promise<ExecutorStatus>;
}The Vercel-Sandbox path lives in
packages/deepsec/src/sandbox/; it's
not yet routed through ExecutorProvider. That refactor is on the
roadmap. For now, this is the most experimental slot of the five.
Drop-in pattern:
// my-plugin/src/__tests__/plugin.test.ts
import { describe, expect, it } from "vitest";
import { createDefaultRegistry } from "deepsec/config";
import myPlugin from "../index.js";
describe("@my-org/plugin-internal-services", () => {
it("contributes the expected matchers", () => {
const plugin = myPlugin();
const slugs = plugin.matchers!.map(m => m.slug);
expect(slugs).toContain("internal-rpc-no-auth");
});
it("does not collide with built-ins", () => {
const built = new Set(createDefaultRegistry().slugs());
const plugin = myPlugin();
for (const m of plugin.matchers ?? []) {
// Either the slug is unique, or you're intentionally overriding.
// Document the overrides loudly.
}
});
});ownership, people, and executor are single-slot. The last
plugin to declare each wins. So a generic codeowners ownership plugin
can load first, and an org-specific oracle later in the plugins: [...]
array overrides it.
matchers, notifiers, and agents are additive. All plugin
contributions stack.