diff --git a/README.md b/README.md index 7fbc187..77d80fe 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # pncli — The Paperwork Nightmare CLI +[![npm](https://img.shields.io/npm/v/%40kolatts%2Fpncli?style=flat-square&color=cb3837&logo=npm)](https://www.npmjs.com/package/@kolatts/pncli) + > One command does what three meetings couldn't. -pncli gives AI coding agents (and humans) structured CLI access to Jira, Bitbucket, Confluence, and SonarQube. No MCP servers required. No meetings to schedule. No forms to fill out. +pncli gives AI coding agents (and humans) structured CLI access to Jira, Bitbucket, Confluence, SonarQube, and SDElements. No MCP servers required. No meetings to schedule. No forms to fill out. ## Why? @@ -47,6 +49,8 @@ pncli uses a three-layer config system (highest priority wins): | `PNCLI_BITBUCKET_PAT` | Bitbucket personal access token | | `PNCLI_SONAR_BASE_URL` | SonarQube Server base URL | | `PNCLI_SONAR_TOKEN` | SonarQube personal access token | +| `PNCLI_SDE_BASE_URL` | SDElements base URL | +| `PNCLI_SDE_TOKEN` | SDElements API token | | `PNCLI_CONFIG_PATH` | Override global config file path | ## For AI Agents @@ -82,6 +86,7 @@ This project uses Conventional Commits for automatic versioning: | Bitbucket | ✅ Active | Server REST v1.0 | | Confluence | ✅ Active | Server REST v1 | | SonarQube | ✅ Active | Server Web API | +| SDElements | ✅ Active | REST API v2 (cloud + on-prem) | | Artifactory | 🔜 Coming | The nightmare never ends | ## License diff --git a/copilot-instructions.md b/copilot-instructions.md index 368742d..4f49a8d 100644 --- a/copilot-instructions.md +++ b/copilot-instructions.md @@ -4,7 +4,7 @@ ## What is pncli? -pncli is a CLI tool that provides structured JSON access to Jira, Bitbucket, Confluence, SonarQube, and local git state. Use it for all interactions with these services. It exists because MCP servers aren't available in this environment — pncli is your agent-friendly shim layer. +pncli is a CLI tool that provides structured JSON access to Jira, Bitbucket, Confluence, SonarQube, SDElements, and local git state. Use it for all interactions with these services. It exists because MCP servers aren't available in this environment — pncli is your agent-friendly shim layer. ## Important @@ -368,6 +368,84 @@ pncli sonar hotspots --all Fetch all pages ``` +### Sde + +``` +pncli sde server-info + +pncli sde whoami + +pncli sde users + --email Filter by email address + --first-name Filter by first name + --last-name Filter by last name + --active Filter by active status: true or false + --page Page number (1-based) (default: "1") + --page-size Results per page (default: "100") + --all Fetch all pages + +pncli sde projects + --name Filter by project name + --search Text search on name and profile + --active Filter by active status: true, false, or all + --ordering Sort by: name, created, updated (prefix with - for + descending) + --expand Expand nested fields (comma-separated): + application,business_unit,creator + --include Include extra fields (comma-separated): + task_counts,permissions + --page Page number (1-based) (default: "1") + --page-size Results per page (default: "100") + --all Fetch all pages + +pncli sde project + --id Project ID (or set defaults.sde.project in config) + --expand Expand nested fields (comma-separated): + application,business_unit,creator + --include Include extra fields (comma-separated): + task_counts,permissions + +pncli sde tasks + --project Project ID (or set defaults.sde.project in config) + --phase Filter by phase slug (e.g. development, + architecture-design) + --priority Filter by priority (1-10) + --status Filter by status ID (e.g. TS1, TS2) + --assigned-to Filter by assignee email + --source Filter by source: default, custom, manual, project + --verification Filter by verification: pass, fail, partial, none + --tag Filter by tag name + --accepted Filter by accepted status: true or false + --relevant Filter by relevant status: true or false + --expand Expand nested fields (comma-separated): + status,phase,problem,text + --include Include extra fields (comma-separated): + how_tos,last_note,references,regulation_sections + --page Page number (1-based) (default: "1") + --page-size Results per page (default: "100") + --all Fetch all pages + +pncli sde task + --project Project ID (or set defaults.sde.project in config) + --task Task ID (e.g. T21) + --expand Expand nested fields (comma-separated): + status,phase,problem,text + --include Include extra fields (comma-separated): + how_tos,last_note,references + +pncli sde threats + --project Project ID (or set defaults.sde.project in config) + --severity Filter by severity (1-10) + --search Full-text search on title and threat ID + --ordering Sort by: threat__severity, threat_id, status (prefix - + for descending) + --capec-id Filter by CAPEC attack pattern ID + --component-id Filter by component ID + --page Page number (1-based) (default: "1") + --page-size Results per page (default: "100") + --all Fetch all pages +``` + ### Deps ``` diff --git a/src/cli.ts b/src/cli.ts index bd5aea6..a28909e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { registerJiraCommands } from './services/jira/commands.js'; import { registerBitbucketCommands } from './services/bitbucket/commands.js'; import { registerConfluenceCommands } from './services/confluence/commands.js'; import { registerSonarCommands } from './services/sonar/commands.js'; +import { registerSdeCommands } from './services/sde/commands.js'; import { registerDepsCommands } from './services/deps/commands.js'; import { registerConfigCommands } from './services/config/commands.js'; @@ -55,6 +56,7 @@ registerJiraCommands(program); registerBitbucketCommands(program); registerConfluenceCommands(program); registerSonarCommands(program); +registerSdeCommands(program); registerDepsCommands(program); registerConfigCommands(program); @@ -66,6 +68,7 @@ Services: bitbucket Bitbucket Server confluence Confluence sonar SonarQube Server (quality gates, issues, metrics, hotspots) + sde SDElements (threat modeling, countermeasures, compliance) config Manage pncli configuration `); diff --git a/src/lib/config.ts b/src/lib/config.ts index 950c0c0..f42c819 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { execSync } from 'child_process'; -import type { GlobalConfig, RepoConfig, ResolvedConfig, JiraDefaults, BitbucketDefaults, SonarDefaults } from '../types/config.js'; +import type { GlobalConfig, RepoConfig, ResolvedConfig, JiraDefaults, BitbucketDefaults, SonarDefaults, SdeDefaults } from '../types/config.js'; import type { CustomFieldDefinition } from '../types/jira.js'; const ENV_KEYS = { @@ -21,6 +21,8 @@ const ENV_KEYS = { ARTIFACTORY_REPO_MAVEN: 'PNCLI_ARTIFACTORY_REPO_MAVEN', SONAR_BASE_URL: 'PNCLI_SONAR_BASE_URL', SONAR_TOKEN: 'PNCLI_SONAR_TOKEN', + SDE_BASE_URL: 'PNCLI_SDE_BASE_URL', + SDE_TOKEN: 'PNCLI_SDE_TOKEN', CONFIG_PATH: 'PNCLI_CONFIG_PATH' } as const; @@ -61,11 +63,12 @@ function mergeCustomFields( function mergeDefaults( global: GlobalConfig['defaults'], repo: RepoConfig['defaults'] -): { jira: JiraDefaults; bitbucket: BitbucketDefaults; sonar: SonarDefaults } { +): { jira: JiraDefaults; bitbucket: BitbucketDefaults; sonar: SonarDefaults; sde: SdeDefaults } { return { jira: { ...global?.jira, ...repo?.jira }, bitbucket: { ...global?.bitbucket, ...repo?.bitbucket }, - sonar: { ...global?.sonar, ...repo?.sonar } + sonar: { ...global?.sonar, ...repo?.sonar }, + sde: { ...global?.sde, ...repo?.sde } }; } @@ -115,6 +118,10 @@ export function loadConfig(opts: LoadConfigOptions = {}): ResolvedConfig { baseUrl: process.env[ENV_KEYS.SONAR_BASE_URL] ?? globalConfig.sonar?.baseUrl, token: process.env[ENV_KEYS.SONAR_TOKEN] ?? globalConfig.sonar?.token }, + sde: { + baseUrl: process.env[ENV_KEYS.SDE_BASE_URL] ?? globalConfig.sde?.baseUrl, + token: process.env[ENV_KEYS.SDE_TOKEN] ?? globalConfig.sde?.token + }, defaults: mergedDefaults }; } @@ -176,6 +183,10 @@ export function maskConfig(config: ResolvedConfig): unknown { sonar: { ...config.sonar, token: config.sonar.token ? '***' : undefined + }, + sde: { + ...config.sde, + token: config.sde.token ? '***' : undefined } }; } diff --git a/src/lib/http.ts b/src/lib/http.ts index ce38508..9f48130 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -239,6 +239,61 @@ export class HttpClient { return request(url, init, opts.timeoutMs ?? 30000); } + private sdeHeaders(): Record { + const { token } = this.config.sde; + if (!token) throw new PncliError('SDElements credentials not configured. Run: pncli config init'); + return { + 'Authorization': `Token ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Connection': 'close' + }; + } + + async sde( + path: string, + opts: HttpRequestOptions = {} + ): Promise { + const baseUrl = this.config.sde.baseUrl; + if (!baseUrl) throw new PncliError('SDElements baseUrl not configured. Run: pncli config init'); + + const url = buildUrl(baseUrl, path, opts.params); + const headers = this.sdeHeaders(); + const init: RequestInit = { + method: opts.method ?? 'GET', + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined + }; + + if (this.dryRun) { + const safeHeaders = { ...headers, Authorization: '[REDACTED]' }; + const msg = `DRY RUN: ${init.method} ${url}\nHeaders: ${JSON.stringify(safeHeaders, null, 2)}\n` + + (opts.body ? `Body: ${JSON.stringify(opts.body, null, 2)}\n` : ''); + fs.writeSync(process.stderr.fd, msg); + process.exitCode = ExitCode.SUCCESS; + throw new PncliError('dry-run', 0); + } + + return request(url, init, opts.timeoutMs ?? 30000); + } + + async sdePaginate( + fetchPage: (page: number, pageSize: number) => Promise<{ count: number; results: T[] }> + ): Promise { + const results: T[] = []; + let page = 1; + const pageSize = 100; + + while (true) { + const response = await fetchPage(page, pageSize); + results.push(...response.results); + if (results.length >= response.count || response.results.length === 0) break; + page++; + } + + return results; + } + private sonarHeaders(): Record { const { token } = this.config.sonar; if (!token) throw new PncliError('SonarQube credentials not configured. Run: pncli config init'); diff --git a/src/services/config/commands.ts b/src/services/config/commands.ts index 463c24f..c3ea02c 100644 --- a/src/services/config/commands.ts +++ b/src/services/config/commands.ts @@ -125,6 +125,17 @@ export function registerConfigCommands(program: Command): void { results.sonar = { ok: null, message: 'not configured' }; } + if (cfg.sde.baseUrl) { + try { + await http.sde('/api/v2/users/me/'); + results.sde = { ok: true, message: 'connected' }; + } catch (err) { + results.sde = { ok: false, message: err instanceof Error ? err.message : String(err) }; + } + } else { + results.sde = { ok: null, message: 'not configured' }; + } + success(results, 'config', 'test', start); } catch (err) { fail(err, 'config', 'test', start); @@ -236,6 +247,26 @@ async function initGlobalConfig(start: number): Promise { }); } + process.stderr.write('\n── SDElements ────────────────────────────────────\n'); + const useSde = await confirm({ + message: 'Configure SDElements for threat modeling and countermeasure queries?', + default: false + }); + + let sdeBaseUrl = ''; + let sdeToken = ''; + + if (useSde) { + sdeBaseUrl = await input({ + message: 'SDElements base URL\n Cloud-hosted: https://your-org.sdelements.com\n On-premise: https://sde.your-company.com\n URL: ', + default: '' + }); + + sdeToken = await password({ + message: 'SDElements API token:' + }); + } + process.stderr.write('\n── Defaults ──────────────────────────────────────\n'); const jiraProject = await input({ message: 'Default Jira project key (optional):', @@ -247,6 +278,11 @@ async function initGlobalConfig(start: number): Promise { default: '' }) : ''; + const sdeProject = useSde ? await input({ + message: 'Default SDElements project ID (optional, numeric):', + default: '' + }) : ''; + process.stderr.write('\n'); const confirmed = await confirm({ message: 'Write config to ~/.pncli/config.json?', @@ -293,6 +329,12 @@ async function initGlobalConfig(start: number): Promise { token: sonarToken || undefined } } : {}), + ...(useSde ? { + sde: { + baseUrl: sdeBaseUrl || undefined, + token: sdeToken || undefined + } + } : {}), defaults: { jira: { project: jiraProject || undefined @@ -301,6 +343,11 @@ async function initGlobalConfig(start: number): Promise { sonar: { project: sonarProject || undefined } + } : {}), + ...(useSde && sdeProject ? { + sde: { + project: sdeProject || undefined + } } : {}) } }); diff --git a/src/services/sde/client.ts b/src/services/sde/client.ts new file mode 100644 index 0000000..39a8413 --- /dev/null +++ b/src/services/sde/client.ts @@ -0,0 +1,203 @@ +import type { HttpClient } from '../../lib/http.js'; +import type { + SdeServerInfo, + SdeUser, + SdeProject, + SdeTask, + SdeThreat, + SdePaginatedResponse +} from '../../types/sde.js'; + +const API = '/api/v2'; + +export interface ListProjectsOpts { + name?: string; + slug?: string; + search?: string; + active?: string; + ordering?: string; + expand?: string; + include?: string; + page?: number; + pageSize?: number; +} + +export interface ListTasksOpts { + projectId: number; + phase?: string; + priority?: string; + status?: string; + assignedTo?: string; + source?: string; + verification?: string; + tag?: string; + accepted?: string; + relevant?: string; + expand?: string; + include?: string; + page?: number; + pageSize?: number; +} + +export interface ListThreatsOpts { + projectId: number; + severity?: string; + title?: string; + threatId?: string; + capecId?: string; + componentId?: string; + search?: string; + ordering?: string; + page?: number; + pageSize?: number; +} + +export interface ListUsersOpts { + email?: string; + firstName?: string; + lastName?: string; + isActive?: string; + page?: number; + pageSize?: number; +} + +function projectParams(opts: Omit, page: number, pageSize: number) { + return { + name: opts.name, + slug: opts.slug, + search: opts.search, + active: opts.active, + ordering: opts.ordering, + expand: opts.expand, + include: opts.include, + page, + page_size: pageSize + }; +} + +function taskParams(opts: Omit, page: number, pageSize: number) { + return { + phase: opts.phase, + priority: opts.priority, + status: opts.status, + assigned_to: opts.assignedTo, + source: opts.source, + verification: opts.verification, + tag: opts.tag, + accepted: opts.accepted, + relevant: opts.relevant, + expand: opts.expand, + include: opts.include, + page, + page_size: pageSize + }; +} + +function threatParams(opts: Omit, page: number, pageSize: number) { + return { + severity: opts.severity, + title: opts.title, + threat_id: opts.threatId, + capec_id: opts.capecId, + component_id: opts.componentId, + search: opts.search, + ordering: opts.ordering, + page, + page_size: pageSize + }; +} + +function userParams(opts: Omit, page: number, pageSize: number) { + return { + email: opts.email, + first_name: opts.firstName, + last_name: opts.lastName, + is_active: opts.isActive, + page, + page_size: pageSize + }; +} + +export class SdeClient { + constructor(private http: HttpClient) {} + + async getServerInfo(): Promise { + return this.http.sde(`${API}/server-info/`); + } + + async getMe(): Promise { + return this.http.sde(`${API}/users/me/`); + } + + async getProject(projectId: number, expand?: string, include?: string): Promise { + return this.http.sde(`${API}/projects/${projectId}/`, { + params: { expand, include } + }); + } + + async getTask(projectId: number, taskId: string, expand?: string, include?: string): Promise { + return this.http.sde(`${API}/projects/${projectId}/tasks/${taskId}/`, { + params: { expand, include } + }); + } + + async listProjects(opts: ListProjectsOpts = {}): Promise> { + return this.http.sde>(`${API}/projects/`, { + params: projectParams(opts, opts.page ?? 1, opts.pageSize ?? 100) + }); + } + + async listAllProjects(opts: Omit = {}): Promise { + return this.http.sdePaginate(async (page, pageSize) => { + const resp = await this.http.sde>(`${API}/projects/`, { + params: projectParams(opts, page, pageSize) + }); + return { count: resp.count, results: resp.results }; + }); + } + + async listTasks(opts: ListTasksOpts): Promise> { + return this.http.sde>(`${API}/projects/${opts.projectId}/tasks/`, { + params: taskParams(opts, opts.page ?? 1, opts.pageSize ?? 100) + }); + } + + async listAllTasks(opts: Omit): Promise { + return this.http.sdePaginate(async (page, pageSize) => { + const resp = await this.http.sde>(`${API}/projects/${opts.projectId}/tasks/`, { + params: taskParams(opts, page, pageSize) + }); + return { count: resp.count, results: resp.results }; + }); + } + + async listThreats(opts: ListThreatsOpts): Promise> { + return this.http.sde>(`${API}/projects/${opts.projectId}/threats/`, { + params: threatParams(opts, opts.page ?? 1, opts.pageSize ?? 100) + }); + } + + async listAllThreats(opts: Omit): Promise { + return this.http.sdePaginate(async (page, pageSize) => { + const resp = await this.http.sde>(`${API}/projects/${opts.projectId}/threats/`, { + params: threatParams(opts, page, pageSize) + }); + return { count: resp.count, results: resp.results }; + }); + } + + async listUsers(opts: ListUsersOpts = {}): Promise> { + return this.http.sde>(`${API}/users/`, { + params: userParams(opts, opts.page ?? 1, opts.pageSize ?? 100) + }); + } + + async listAllUsers(opts: Omit = {}): Promise { + return this.http.sdePaginate(async (page, pageSize) => { + const resp = await this.http.sde>(`${API}/users/`, { + params: userParams(opts, page, pageSize) + }); + return { count: resp.count, results: resp.results }; + }); + } +} diff --git a/src/services/sde/commands.ts b/src/services/sde/commands.ts new file mode 100644 index 0000000..baee484 --- /dev/null +++ b/src/services/sde/commands.ts @@ -0,0 +1,268 @@ +import { Command } from 'commander'; +import { SdeClient } from './client.js'; +import { createHttpClient } from '../../lib/http.js'; +import { loadConfig } from '../../lib/config.js'; +import { success, fail } from '../../lib/output.js'; +import { PncliError } from '../../lib/errors.js'; + +function getClient(program: Command): { client: SdeClient; config: ReturnType } { + const opts = program.optsWithGlobals(); + const config = loadConfig({ configPath: opts.config }); + if (!config.sde.baseUrl) throw new PncliError('SDElements not configured. Run: pncli config init'); + const http = createHttpClient(config, Boolean(opts.dryRun)); + return { client: new SdeClient(http), config }; +} + +function resolveProject(config: ReturnType, cliProject?: string): number { + const project = cliProject ?? config.defaults.sde.project; + if (!project) throw new PncliError('No project specified. Use --project or set defaults.sde.project in config.'); + if (!/^\d+$/.test(project)) throw new PncliError(`Invalid project ID: "${project}". SDElements project IDs must be positive integers.`); + const id = parseInt(project, 10); + if (id <= 0) throw new PncliError(`Invalid project ID: "${project}". SDElements project IDs must be positive integers.`); + return id; +} + +function parsePage(val: string, flag: string): number { + const n = parseInt(val, 10); + if (isNaN(n) || n < 1) throw new PncliError(`Invalid ${flag}: "${val}". Must be a positive integer.`); + return n; +} + +function validateActive(val: string | undefined, allowed: string[]): string | undefined { + if (val === undefined) return undefined; + if (!allowed.includes(val)) { + throw new PncliError(`Invalid --active value: "${val}". Allowed: ${allowed.join(', ')}.`); + } + return val; +} + +export function registerSdeCommands(program: Command): void { + const sde = program.command('sde').description('SDElements operations (threat modeling, countermeasures, compliance)'); + + // ── Server Info ─────────────────────────────────────────────────────────── + + sde.command('server-info') + .description('Get server version and platform info (requires super-user)') + .action(async () => { + const start = Date.now(); + try { + const { client } = getClient(program); + const data = await client.getServerInfo(); + success(data, 'sde', 'server-info', start); + } catch (err) { fail(err, 'sde', 'server-info', start); } + }); + + // ── Whoami ──────────────────────────────────────────────────────────────── + + sde.command('whoami') + .description('Get current authenticated user') + .action(async () => { + const start = Date.now(); + try { + const { client } = getClient(program); + const data = await client.getMe(); + success(data, 'sde', 'whoami', start); + } catch (err) { fail(err, 'sde', 'whoami', start); } + }); + + // ── Users ───────────────────────────────────────────────────────────────── + + sde.command('users') + .description('List users') + .option('--email ', 'Filter by email address') + .option('--first-name ', 'Filter by first name') + .option('--last-name ', 'Filter by last name') + .option('--active ', 'Filter by active status: true or false') + .option('--page ', 'Page number (1-based)', '1') + .option('--page-size ', 'Results per page', '100') + .option('--all', 'Fetch all pages') + .action(async (opts: { email?: string; firstName?: string; lastName?: string; active?: string; page: string; pageSize: string; all?: boolean }) => { + const start = Date.now(); + try { + const { client } = getClient(program); + const baseOpts = { + email: opts.email, + firstName: opts.firstName, + lastName: opts.lastName, + isActive: validateActive(opts.active, ['true', 'false']) + }; + if (opts.all) { + const data = await client.listAllUsers(baseOpts); + success(data, 'sde', 'users', start); + } else { + const data = await client.listUsers({ + ...baseOpts, + page: parsePage(opts.page, '--page'), + pageSize: parsePage(opts.pageSize, '--page-size') + }); + success(data, 'sde', 'users', start); + } + } catch (err) { fail(err, 'sde', 'users', start); } + }); + + // ── Projects ────────────────────────────────────────────────────────────── + + sde.command('projects') + .description('List projects') + .option('--name ', 'Filter by project name') + .option('--search ', 'Text search on name and profile') + .option('--active ', 'Filter by active status: true, false, or all') + .option('--ordering ', 'Sort by: name, created, updated (prefix with - for descending)') + .option('--expand ', 'Expand nested fields (comma-separated): application,business_unit,creator') + .option('--include ', 'Include extra fields (comma-separated): task_counts,permissions') + .option('--page ', 'Page number (1-based)', '1') + .option('--page-size ', 'Results per page', '100') + .option('--all', 'Fetch all pages') + .action(async (opts: { name?: string; search?: string; active?: string; ordering?: string; expand?: string; include?: string; page: string; pageSize: string; all?: boolean }) => { + const start = Date.now(); + try { + const { client } = getClient(program); + const baseOpts = { + name: opts.name, + search: opts.search, + active: validateActive(opts.active, ['true', 'false', 'all']), + ordering: opts.ordering, + expand: opts.expand, + include: opts.include + }; + if (opts.all) { + const data = await client.listAllProjects(baseOpts); + success(data, 'sde', 'projects', start); + } else { + const data = await client.listProjects({ + ...baseOpts, + page: parsePage(opts.page, '--page'), + pageSize: parsePage(opts.pageSize, '--page-size') + }); + success(data, 'sde', 'projects', start); + } + } catch (err) { fail(err, 'sde', 'projects', start); } + }); + + // ── Project ─────────────────────────────────────────────────────────────── + + sde.command('project') + .description('Get a single project by ID') + .option('--id ', 'Project ID (or set defaults.sde.project in config)') + .option('--expand ', 'Expand nested fields (comma-separated): application,business_unit,creator') + .option('--include ', 'Include extra fields (comma-separated): task_counts,permissions') + .action(async (opts: { id?: string; expand?: string; include?: string }) => { + const start = Date.now(); + try { + const { client, config } = getClient(program); + const projectId = resolveProject(config, opts.id); + const data = await client.getProject(projectId, opts.expand, opts.include); + success(data, 'sde', 'project', start); + } catch (err) { fail(err, 'sde', 'project', start); } + }); + + // ── Tasks ───────────────────────────────────────────────────────────────── + + sde.command('tasks') + .description('List countermeasures for a project') + .option('--project ', 'Project ID (or set defaults.sde.project in config)') + .option('--phase ', 'Filter by phase slug (e.g. development, architecture-design)') + .option('--priority ', 'Filter by priority (1-10)') + .option('--status ', 'Filter by status ID (e.g. TS1, TS2)') + .option('--assigned-to ', 'Filter by assignee email') + .option('--source ', 'Filter by source: default, custom, manual, project') + .option('--verification ', 'Filter by verification: pass, fail, partial, none') + .option('--tag ', 'Filter by tag name') + .option('--accepted ', 'Filter by accepted status: true or false') + .option('--relevant ', 'Filter by relevant status: true or false') + .option('--expand ', 'Expand nested fields (comma-separated): status,phase,problem,text') + .option('--include ', 'Include extra fields (comma-separated): how_tos,last_note,references,regulation_sections') + .option('--page ', 'Page number (1-based)', '1') + .option('--page-size ', 'Results per page', '100') + .option('--all', 'Fetch all pages') + .action(async (opts: { project?: string; phase?: string; priority?: string; status?: string; assignedTo?: string; source?: string; verification?: string; tag?: string; accepted?: string; relevant?: string; expand?: string; include?: string; page: string; pageSize: string; all?: boolean }) => { + const start = Date.now(); + try { + const { client, config } = getClient(program); + const projectId = resolveProject(config, opts.project); + const baseOpts = { + projectId, + phase: opts.phase, + priority: opts.priority, + status: opts.status, + assignedTo: opts.assignedTo, + source: opts.source, + verification: opts.verification, + tag: opts.tag, + accepted: opts.accepted, + relevant: opts.relevant, + expand: opts.expand, + include: opts.include + }; + if (opts.all) { + const data = await client.listAllTasks(baseOpts); + success(data, 'sde', 'tasks', start); + } else { + const data = await client.listTasks({ + ...baseOpts, + page: parsePage(opts.page, '--page'), + pageSize: parsePage(opts.pageSize, '--page-size') + }); + success(data, 'sde', 'tasks', start); + } + } catch (err) { fail(err, 'sde', 'tasks', start); } + }); + + // ── Task ────────────────────────────────────────────────────────────────── + + sde.command('task') + .description('Get a single countermeasure') + .option('--project ', 'Project ID (or set defaults.sde.project in config)') + .requiredOption('--task ', 'Task ID (e.g. T21)') + .option('--expand ', 'Expand nested fields (comma-separated): status,phase,problem,text') + .option('--include ', 'Include extra fields (comma-separated): how_tos,last_note,references') + .action(async (opts: { project?: string; task: string; expand?: string; include?: string }) => { + const start = Date.now(); + try { + const { client, config } = getClient(program); + const projectId = resolveProject(config, opts.project); + const data = await client.getTask(projectId, opts.task, opts.expand, opts.include); + success(data, 'sde', 'task', start); + } catch (err) { fail(err, 'sde', 'task', start); } + }); + + // ── Threats ─────────────────────────────────────────────────────────────── + + sde.command('threats') + .description('List threats for a project') + .option('--project ', 'Project ID (or set defaults.sde.project in config)') + .option('--severity ', 'Filter by severity (1-10)') + .option('--search ', 'Full-text search on title and threat ID') + .option('--ordering ', 'Sort by: threat__severity, threat_id, status (prefix - for descending)') + .option('--capec-id ', 'Filter by CAPEC attack pattern ID') + .option('--component-id ', 'Filter by component ID') + .option('--page ', 'Page number (1-based)', '1') + .option('--page-size ', 'Results per page', '100') + .option('--all', 'Fetch all pages') + .action(async (opts: { project?: string; severity?: string; search?: string; ordering?: string; capecId?: string; componentId?: string; page: string; pageSize: string; all?: boolean }) => { + const start = Date.now(); + try { + const { client, config } = getClient(program); + const projectId = resolveProject(config, opts.project); + const baseOpts = { + projectId, + severity: opts.severity, + search: opts.search, + ordering: opts.ordering, + capecId: opts.capecId, + componentId: opts.componentId + }; + if (opts.all) { + const data = await client.listAllThreats(baseOpts); + success(data, 'sde', 'threats', start); + } else { + const data = await client.listThreats({ + ...baseOpts, + page: parsePage(opts.page, '--page'), + pageSize: parsePage(opts.pageSize, '--page-size') + }); + success(data, 'sde', 'threats', start); + } + } catch (err) { fail(err, 'sde', 'threats', start); } + }); +} diff --git a/src/types/config.ts b/src/types/config.ts index 69c7287..31ff052 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -15,6 +15,15 @@ export interface SonarDefaults { project?: string; } +export interface SdeConfig { + baseUrl?: string; + token?: string; +} + +export interface SdeDefaults { + project?: string; +} + export interface JiraConfig { baseUrl?: string; apiToken?: string; @@ -47,6 +56,7 @@ export interface Defaults { jira?: JiraDefaults; bitbucket?: BitbucketDefaults; sonar?: SonarDefaults; + sde?: SdeDefaults; } export interface UserConfig { @@ -61,6 +71,7 @@ export interface GlobalConfig { confluence?: ConfluenceConfig; artifactory?: ArtifactoryConfig; sonar?: SonarConfig; + sde?: SdeConfig; defaults?: Defaults; } @@ -92,9 +103,14 @@ export interface ResolvedConfig { baseUrl: string | undefined; token: string | undefined; }; + sde: { + baseUrl: string | undefined; + token: string | undefined; + }; defaults: { jira: JiraDefaults; bitbucket: BitbucketDefaults; sonar: SonarDefaults; + sde: SdeDefaults; }; } diff --git a/src/types/sde.ts b/src/types/sde.ts new file mode 100644 index 0000000..c8cebef --- /dev/null +++ b/src/types/sde.ts @@ -0,0 +1,119 @@ +export interface SdeRelease { + name: string; + date: string; +} + +export interface SdePlatform { + python_implementation: string; + python_version: string; + os: string; + pip_packages: Record; +} + +export interface SdeServerInfo { + domain: string; + release: SdeRelease; + platform: SdePlatform; + plugins: Record; + jobs_queue_length: number; + sso_settings: unknown; +} + +export interface SdeUserGroup { + id: number; + name: string; + role: string; +} + +export interface SdeUser { + id: number; + email: string; + first_name: string; + last_name: string; + role: string; + groups: SdeUserGroup[]; + last_login: string | null; + date_joined: string; + is_active: boolean; + is_superuser: boolean; + external_id: string | null; +} + +export interface SdeApplication { + id: number; + name: string; + slug: string; +} + +export interface SdeProject { + id: number; + slug: string; + name: string; + url: string; + application: number | SdeApplication; + archived: boolean; + description: string; + created: string; + updated: string; + survey_complete: boolean; + survey_dirty: boolean; + locked: boolean; + risk_policy_compliant: boolean; + tags: string[]; + users: unknown[]; + groups: unknown[]; +} + +export interface SdeAssignedUser { + id: number; + email: string; + first_name: string; + last_name: string; + role: string; +} + +export interface SdeTask { + id: string; + task_id: string; + title: string; + text: string; + url: string; + phase: string | { id: number; name: string; slug: string }; + priority: number; + status: string | { id: string; name: string; slug: string; meaning: string }; + assigned_to: SdeAssignedUser[]; + accepted: boolean; + relevant: boolean; + relevant_via_survey: boolean; + project_specific: boolean; + manually_added_from_library: boolean; + verification_status: string; + note_count: number; + became_relevant: string | null; + updated: string; + status_updated: string; + tags: string[]; + problem: string | null; +} + +export interface SdeThreat { + id: string; + threat_id: string; + title: string; + severity: number; + description: string; + status: string; + created_at: string; + updated_at: string; + became_relevant: string | null; + problems: unknown[]; + capecs: unknown[]; + related_components: unknown[]; +} + +export interface SdePaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +}