NeuraLegion/sectester-js

View on GitHub
packages/scan/src/Scan.ts

Summary

Maintainability
A
25 mins
Test Coverage
A
100%
import { Scans } from './Scans';
import {
  Issue,
  IssueGroup,
  ScanState,
  ScanStatus,
  Severity,
  severityRanges
} from './models';
import { ScanAborted, ScanTimedOut } from './exceptions';
import { delay, Logger } from '@sectester/core';

export interface ScanOptions {
  id: string;
  scans: Scans;
  logger?: Logger;
  pollingInterval?: number;
  timeout?: number;
}

export class Scan {
  public readonly id: string;
  private readonly ACTIVE_STATUSES: ReadonlySet<ScanStatus> = new Set([
    ScanStatus.PENDING,
    ScanStatus.RUNNING,
    ScanStatus.QUEUED
  ]);
  private readonly DONE_STATUSES: ReadonlySet<ScanStatus> = new Set([
    ScanStatus.DISRUPTED,
    ScanStatus.DONE,
    ScanStatus.FAILED,
    ScanStatus.STOPPED
  ]);
  private readonly scans: Scans;
  private readonly pollingInterval: number;
  private readonly logger: Logger | undefined;
  private readonly timeout: number | undefined;
  private state: ScanState = { status: ScanStatus.PENDING };

  constructor({
    id,
    scans,
    logger,
    timeout,
    pollingInterval = 5 * 1000
  }: ScanOptions) {
    this.scans = scans;
    this.logger = logger;
    this.id = id;
    this.pollingInterval = pollingInterval;
    this.timeout = timeout;
  }

  get active(): boolean {
    return this.ACTIVE_STATUSES.has(this.state.status);
  }

  get done(): boolean {
    return this.DONE_STATUSES.has(this.state.status);
  }

  public async issues(): Promise<Issue[]> {
    await this.refreshState();

    return this.scans.listIssues(this.id);
  }

  public async *status(): AsyncIterableIterator<ScanState> {
    while (this.active) {
      await delay(this.pollingInterval);

      yield this.refreshState();
    }

    return this.state;
  }

  public async expect(
    expectation: Severity | ((scan: Scan) => unknown)
  ): Promise<void> {
    let timeoutPassed = false;

    const timer: NodeJS.Timeout | undefined = this.timeout
      ? setTimeout(() => (timeoutPassed = true), this.timeout)
      : undefined;

    const predicate = this.createPredicate(expectation);

    // eslint-disable-next-line @typescript-eslint/naming-convention
    for await (const _ of this.status()) {
      const preventFurtherPolling =
        (await predicate()) || this.done || timeoutPassed;

      if (preventFurtherPolling) {
        break;
      }
    }

    if (timer) {
      clearTimeout(timer);
    }

    this.assert(timeoutPassed);
  }

  public async dispose(): Promise<void> {
    try {
      await this.refreshState();

      if (!this.active) {
        await this.scans.deleteScan(this.id);
      }
    } catch {
      // noop
    }
  }

  public async stop(): Promise<void> {
    try {
      await this.refreshState();

      if (this.active) {
        await this.scans.stopScan(this.id);
      }
    } catch {
      // noop
    }
  }

  private assert(timeoutPassed?: boolean) {
    const { status } = this.state;

    if (this.done && status !== ScanStatus.DONE) {
      throw new ScanAborted(status);
    }

    if (timeoutPassed) {
      throw new ScanTimedOut(this.timeout ?? 0);
    }
  }

  private async refreshState(): Promise<ScanState> {
    if (!this.done) {
      const lastState = this.state;

      this.state = await this.scans.getScan(this.id);

      this.changingStatus(lastState.status, this.state.status);
    }

    return this.state;
  }

  private changingStatus(from: ScanStatus, to: ScanStatus): void {
    if (from !== ScanStatus.QUEUED && to === ScanStatus.QUEUED) {
      this.logger?.warn(
        'The maximum amount of concurrent scans has been reached for the organization, ' +
          'the execution will resume once a free engine will be available. ' +
          'If you want to increase the execution concurrency, ' +
          'please upgrade your subscription or contact your system administrator'
      );
    }

    if (from === ScanStatus.QUEUED && to !== ScanStatus.QUEUED) {
      this.logger?.log('Connected to engine, resuming execution');
    }
  }

  private createPredicate(
    expectation: Severity | ((scan: Scan) => unknown)
  ): () => unknown {
    return () => {
      try {
        return typeof expectation === 'function'
          ? expectation(this)
          : this.satisfyExpectation(expectation);
      } catch {
        // noop
      }
    };
  }

  private satisfyExpectation(severity: Severity): boolean {
    const issueGroups = this.state.issuesBySeverity ?? [];

    return issueGroups.some(
      (x: IssueGroup) =>
        severityRanges.get(severity)?.includes(x.type) && x.number > 0
    );
  }
}