Skip to content

Latest commit

 

History

History
248 lines (195 loc) · 7.29 KB

File metadata and controls

248 lines (195 loc) · 7.29 KB

Plugins

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 */ })],
});

Where to put your plugin

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.

Slot 1: matchers

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.

Slot 2: ownership

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.

Slot 3: people

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.

Slot 4: notifiers

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.

Slot 5: executor

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.

Testing your plugin

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.
    }
  });
});

Resolution order

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.