diff --git a/src/Commands/RerunDiscovery.ts b/src/Commands/RerunDiscovery.ts new file mode 100644 index 00000000..43d394a8 --- /dev/null +++ b/src/Commands/RerunDiscovery.ts @@ -0,0 +1,69 @@ +import { Discoveries, RestDiscoveryOptions } from 'src/Discovery'; +import { ErrorMessageFactory, logger } from 'src/Utils'; +import { container } from 'tsyringe'; +import { Arguments, Argv, CommandModule } from 'yargs'; + +export class RerunDiscovery implements CommandModule { + public readonly command = 'discovery:rerun [options] '; + public readonly describe = + 'Request to start a new discovery using the same configuration as an existing discovery, by discovery ID.'; + + public builder(argv: Argv): Argv { + return argv + .option('token', { + alias: 't', + describe: 'Bright API-key', + string: true, + requiresArg: true, + demandOption: true + }) + .positional('discoveryId', { + describe: 'ID of an existing discovery which you want to re-run.', + requiresArg: true, + demandOption: true, + type: 'string' + }) + .option('project', { + alias: 'p', + describe: 'ID of the project', + string: true, + requiresArg: true, + demandOption: true + }) + .middleware((args: Arguments) => + container.register(RestDiscoveryOptions, { + useValue: { + insecure: args.insecure as boolean, + baseURL: args.api as string, + apiKey: args.token as string, + proxyURL: (args.proxyBright ?? args.proxy) as string, + timeout: args.timeout as number + } + }) + ); + } + + public async handler(args: any): Promise { + try { + const discoveryManager: Discoveries = container.resolve(Discoveries); + const projectId = args.project as string; + const discoveryId = args.discoveryId as string; + const newDiscoveryId = await discoveryManager.rerun( + projectId, + discoveryId + ); + + // eslint-disable-next-line no-console + console.log(newDiscoveryId); + process.exit(0); + } catch (error) { + logger.error( + ErrorMessageFactory.genericCommandError({ + error, + command: 'discovery:rerun' + }) + ); + process.exit(1); + } + } +} diff --git a/src/Commands/RunDiscovery.ts b/src/Commands/RunDiscovery.ts new file mode 100644 index 00000000..b0536db4 --- /dev/null +++ b/src/Commands/RunDiscovery.ts @@ -0,0 +1,156 @@ +import { Discoveries, DiscoveryConfig } from '../Discovery'; +import { ErrorMessageFactory, logger } from '../Utils'; +import { RestDiscoveryOptions } from 'src/Discovery/RestDiscoveries'; +import { container } from 'tsyringe'; +import { Arguments, Argv, CommandModule } from 'yargs'; + +export class RunDiscovery implements CommandModule { + public readonly command = 'discovery:run [options]'; + public readonly describe = + 'Start a new discovery for the received configuration.'; + + public builder(argv: Argv): Argv { + return argv + .option('token', { + alias: 't', + describe: 'Bright API-key', + string: true, + requiresArg: true, + demandOption: true + }) + .option('project', { + alias: 'p', + describe: 'ID of the project', + string: true, + requiresArg: true, + demandOption: true + }) + .option('name', { + alias: 'n', + describe: 'Name of the discovery.', + string: true, + requiresArg: true, + demandOption: true + }) + .option('auth', { + alias: 'o', + describe: 'Auth object ID.', + string: true, + requiresArg: true + }) + .option('repeater', { + alias: 'agent', + requiresArg: true, + array: true, + describe: 'ID of any repeaters connected with the discovery.' + }) + .option('archive', { + alias: 'a', + normalize: true, + requiresArg: true, + describe: + "A collection of your app's http/websockets logs into HAR file. " + + 'Usually you can use browser dev tools or our browser web extension' + }) + .option('crawler', { + alias: 'c', + requiresArg: true, + array: true, + describe: + 'A list of specific urls that should be included into crawler.', + demandOption: true + }) + .option('host-filter', { + alias: 'F', + requiresArg: true, + array: true, + describe: 'A list of specific hosts that should be included into scan.' + }) + .option('header', { + alias: 'H', + requiresArg: true, + array: true, + describe: + 'A list of specific headers that should be included into request.' + }) + .option('smart', { + boolean: true, + describe: + 'Use automatic smart decisions such as: parameter skipping, detection phases, etc. to minimize scan time.' + }) + .option('crawl-parent-subdomains', { + boolean: true, + describe: 'Crawl parent path folders and subdomains', + default: false + }) + .option('concurrency', { + number: true, + default: 10, + describe: + 'Number of maximum concurrent requests allowed to be sent to the target, can range between 1 to 50 (default: 10).', + requiresArg: true + }) + .option('interactions-depth', { + number: true, + default: 3, + describe: + 'Number of maximum interactions with nested objects, can range between 1 to 5 (default: 3).', + requiresArg: true + }) + .middleware((args: Arguments) => + container.register(RestDiscoveryOptions, { + useValue: { + insecure: args.insecure as boolean, + baseURL: args.api as string, + apiKey: args.token as string, + proxyURL: (args.proxyBright ?? args.proxy) as string, + timeout: args.timeout as number + } + }) + ); + } + + public async handler(args: Arguments): Promise { + try { + const discoveryManager: Discoveries = container.resolve(Discoveries); + + const projectId = args.project as string; + + const { id: discoveryId, warnings } = await discoveryManager.create( + projectId, + { + name: args.name, + authObjectId: args.auth, + hostsFilter: args.hostFilter, + crawlerUrls: args.crawler, + fileId: args.archive, + repeaters: args.repeater, + optimizedCrawler: args.smart, + poolSize: args.concurrency, + maxInteractionsChainLength: args.interactionsDepth, + subdomainsCrawl: args.crawlParentSubdomains, + headers: args.header + } as DiscoveryConfig + ); + + // eslint-disable-next-line no-console + console.log(discoveryId); + + if (warnings?.length) { + logger.warn( + `${warnings.map((warning) => warning.message).join('\n')}\n` + ); + } + + process.exit(0); + } catch (error) { + logger.error( + ErrorMessageFactory.genericCommandError({ + error, + command: 'discovery:run' + }) + ); + process.exit(1); + } + } +} diff --git a/src/Commands/StopDiscovery.ts b/src/Commands/StopDiscovery.ts new file mode 100644 index 00000000..95d7a604 --- /dev/null +++ b/src/Commands/StopDiscovery.ts @@ -0,0 +1,64 @@ +import { Discoveries, RestDiscoveryOptions } from '../Discovery'; +import { ErrorMessageFactory, logger } from '../Utils'; +import { container } from 'tsyringe'; +import { Arguments, Argv, CommandModule } from 'yargs'; + +export class StopDiscovery implements CommandModule { + public readonly command = 'discovery:stop [options] '; + public readonly describe = 'Stop discovery by id.'; + + public builder(argv: Argv): Argv { + return argv + .option('token', { + alias: 't', + describe: 'Bright API-key', + string: true, + requiresArg: true, + demandOption: true + }) + .option('project', { + alias: 'p', + requiresArg: true, + string: true, + describe: 'ID of the project', + demandOption: true + }) + .positional('discoveryId', { + describe: 'ID of an existing discovery which you want to stop.', + requiresArg: true, + demandOption: true, + type: 'string' + }) + .middleware((args: Arguments) => + container.register(RestDiscoveryOptions, { + useValue: { + insecure: args.insecure as boolean, + baseURL: args.api as string, + apiKey: args.token as string, + proxyURL: (args.proxyBright ?? args.proxy) as string, + timeout: args.timeout as number + } + }) + ); + } + + public async handler(args: Arguments): Promise { + try { + const discoveryManager: Discoveries = container.resolve(Discoveries); + + await discoveryManager.stop( + args.project as string, + args.discoveryId as string + ); + process.exit(0); + } catch (error) { + logger.error( + ErrorMessageFactory.genericCommandError({ + error, + command: 'discovery:stop' + }) + ); + process.exit(1); + } + } +} diff --git a/src/Discovery/Discoveries.ts b/src/Discovery/Discoveries.ts new file mode 100644 index 00000000..b491570b --- /dev/null +++ b/src/Discovery/Discoveries.ts @@ -0,0 +1,77 @@ +export interface DiscoveryConfig { + name: string; + authObjectId?: string; + poolSize?: number; + crawlerUrls?: string[]; + extraHosts?: Record; + headers?: Record | Header[]; + fileId?: string; + targetId?: string; + hostsFilter?: string[]; + optimizedCrawler?: boolean; + maxInteractionsChainLength: number; + subdomainsCrawl: boolean; + exclusions?: Exclusions; + repeaters?: string[]; + discoveryTypes?: DiscoveryType[]; + targetTimeout: number; +} + +export interface Header { + name: string; + value: string; + mergeStrategy: 'replace'; +} + +export interface Discoveries { + create( + projectId: string, + config: DiscoveryConfig + ): Promise; + + rerun(projectId: string, discoveryId: string): Promise; + + stop(projectId: string, discoveryId: string): Promise; + + delete(projectId: string, discoveryId: string): Promise; +} + +export const Discoveries: unique symbol = Symbol('Discoveries'); + +export interface DiscoveryWarning { + code: string; + message: string; +} + +export interface DiscoveryCreateResponse { + id: string; + warnings?: DiscoveryWarning[]; +} + +export enum DiscoveryType { + CRAWLER = 'crawler', + ARCHIVE = 'archive', + OAS = 'oas' +} + +export interface RequestExclusion { + patterns: string[]; + methods: string[]; +} + +export interface Exclusions { + params: string[]; + requests: RequestExclusion[]; +} + +export interface StorageFile { + id: string; + type: SourceType; +} + +export enum SourceType { + OPEN_API = 'openapi', + RAML = 'raml', + POSTMAN = 'postman', + HAR = 'har' +} diff --git a/src/Discovery/RestDiscoveries.ts b/src/Discovery/RestDiscoveries.ts new file mode 100644 index 00000000..5ee1efb0 --- /dev/null +++ b/src/Discovery/RestDiscoveries.ts @@ -0,0 +1,176 @@ +import { + Discoveries, + DiscoveryConfig, + DiscoveryCreateResponse, + DiscoveryType, + Header, + SourceType, + StorageFile +} from './Discoveries'; +import { ProxyFactory } from '../Utils'; +import { CliInfo } from '../Config'; +import { delay, inject, injectable } from 'tsyringe'; +import axios, { Axios } from 'axios'; +import http from 'node:http'; +import https from 'node:https'; + +export interface RestDiscoveryOptions { + baseURL: string; + apiKey: string; + timeout?: number; + insecure?: boolean; + proxyURL?: string; + proxyDomains?: string[]; +} + +export const RestDiscoveryOptions: unique symbol = Symbol( + 'RestDiscoveryOptions' +); + +@injectable() +export class RestDiscoveries implements Discoveries { + private readonly client: Axios; + + constructor( + @inject(delay(() => CliInfo)) private readonly info: CliInfo, + @inject(ProxyFactory) private readonly proxyFactory: ProxyFactory, + @inject(RestDiscoveryOptions) + { baseURL, apiKey, timeout, insecure, proxyURL }: RestDiscoveryOptions + ) { + const { + httpAgent = new http.Agent(), + httpsAgent = new https.Agent({ rejectUnauthorized: !insecure }) + } = proxyURL + ? this.proxyFactory.createProxy({ + proxyUrl: proxyURL, + rejectUnauthorized: !insecure + }) + : {}; + + this.client = axios.create({ + baseURL, + timeout, + httpAgent, + httpsAgent, + responseType: 'json', + headers: { authorization: `Api-Key ${apiKey}` } + }); + } + + public async create( + projectId: string, + config: DiscoveryConfig + ): Promise { + const preparedConfig = await this.prepareConfig({ ...config }); + const res = await this.client.post( + `/api/v2/projects/${projectId}/discoveries`, + preparedConfig + ); + + return res.data; + } + + public async rerun(projectId: string, discoveryId: string): Promise { + const res = await this.client.post<{ id: string }>( + `/api/v2/projects/${projectId}/discoveries/${discoveryId}/rerun` + ); + + return res.data.id; + } + + public async stop(projectId: string, discoveryId: string): Promise { + await this.client.put( + `/api/v2/projects/${projectId}/discoveries/${discoveryId}/lifecycle`, + { + action: 'stop' + } + ); + } + + public async delete(projectId: string, discoveryId: string): Promise { + await this.client.delete( + `/api/v2/projects/${projectId}/discoveries/${discoveryId}` + ); + } + + private async prepareConfig({ headers, ...rest }: DiscoveryConfig): Promise< + Omit & { + headers: Header[]; + info: { + source: string; + client?: { name: string; version: string }; + }; + } + > { + const config = await this.applyDefaultSettings(rest); + + return { + ...config, + info: { + source: 'cli', + client: { + name: 'bright-cli', + version: this.info.version + } + }, + headers: headers + ? Object.entries(headers).map(([name, value]: [string, string]) => ({ + name, + value, + mergeStrategy: 'replace' + })) + : undefined + }; + } + + private async applyDefaultSettings( + discoveryConfig: Omit + ): Promise> { + const exclusions = + discoveryConfig.exclusions?.params || discoveryConfig.exclusions?.requests + ? discoveryConfig.exclusions + : undefined; + + let discoveryTypes: DiscoveryType[] = await this.exploreDiscovery( + discoveryConfig + ); + discoveryTypes = discoveryTypes?.length ? discoveryTypes : undefined; + + return { + ...discoveryConfig, + discoveryTypes, + exclusions + }; + } + + private async exploreDiscovery( + body: DiscoveryConfig + ): Promise { + const discoveryTypes: DiscoveryType[] = []; + const { fileId, crawlerUrls } = body; + + if (Array.isArray(crawlerUrls)) { + discoveryTypes.push(DiscoveryType.CRAWLER); + } + + if (fileId) { + try { + const { data } = await this.client.get( + `/api/v2/files/${fileId}` + ); + + discoveryTypes.push( + data.type === SourceType.HAR + ? DiscoveryType.ARCHIVE + : DiscoveryType.OAS + ); + } catch (error) { + throw new Error( + `Error loading file with id "${fileId}": No such file or you do not have permissions.` + ); + } + } + + return discoveryTypes; + } +} diff --git a/src/Discovery/index.ts b/src/Discovery/index.ts new file mode 100644 index 00000000..35cbb1f5 --- /dev/null +++ b/src/Discovery/index.ts @@ -0,0 +1,2 @@ +export * from './Discoveries'; +export * from './RestDiscoveries'; diff --git a/src/container.ts b/src/container.ts index 95e06186..b75a761a 100644 --- a/src/container.ts +++ b/src/container.ts @@ -56,6 +56,7 @@ import { ServerRepeaterLauncher } from './Repeater'; import { ProxyFactory, DefaultProxyFactory } from './Utils'; +import { Discoveries, RestDiscoveries } from './Discovery'; import { container, Lifecycle } from 'tsyringe'; container @@ -167,6 +168,11 @@ container { lifecycle: Lifecycle.Singleton } ) .register(Scans, { useClass: RestScans }, { lifecycle: Lifecycle.Singleton }) + .register( + Discoveries, + { useClass: RestDiscoveries }, + { lifecycle: Lifecycle.Singleton } + ) .register( EntryPoints, { useClass: RestEntryPoints }, diff --git a/src/index.ts b/src/index.ts index fb214979..63b2da73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,9 @@ import { } from './Commands'; import { CliBuilder } from './Config'; import container from './container'; +import { RunDiscovery } from './Commands/RunDiscovery'; +import { StopDiscovery } from './Commands/StopDiscovery'; +import { RerunDiscovery } from './Commands/RerunDiscovery'; container.resolve(CliBuilder).build({ commands: [ @@ -25,6 +28,9 @@ container.resolve(CliBuilder).build({ new RunScan(), new RetestScan(), new StopScan(), + new RunDiscovery(), + new StopDiscovery(), + new RerunDiscovery(), new UploadArchive(), new Configure(), new GetEntryPoints()