@ 261105.4
current
import {
  checkoutOnSecurityReleaseBranch,
  checkRemote,
  commitAndPushVulnerabilitiesJSON,
  validateDate,
  pickReport,
  getAffectedVersionLines,
  getReportSeverity,
  getSummary,
  confirmSecurityStep,
  writeSecurityFile,
  SecurityRelease
} from './security-release/security-release.js';
import auth from './auth.js';
import Request from './request.js';
import nv from '@pkgjs/nv';
import semver from 'semver';
import { incrementVersion } from './release/utils.js';
import { getMergedConfig } from './config.js';

export const CVE_SOURCES = ['hackerone', 'openjs-cna'];
const DEFAULT_CNA_TIMEOUT_SECONDS = 600;

// `cve_source` picks who issues CVE ids: HackerOne (the default, unchanged
// behaviour) or the OpenJS Foundation CNA. HackerOne stays the bug-bounty
// source either way and is updated with the resulting id.
export function resolveCveSource(config = {}) {
  const { cve_source: cveSource } = config;
  if (cveSource === undefined || cveSource === null || cveSource === '') {
    return 'hackerone';
  }
  if (!CVE_SOURCES.includes(cveSource)) {
    throw new Error(
      `Unknown cve_source "${cveSource}"; expected one of: ${CVE_SOURCES.join(', ')}`
    );
  }
  return cveSource;
}

export default class UpdateSecurityRelease extends SecurityRelease {
  getCveSource() {
    let config;
    try {
      config = getMergedConfig();
    } catch {
      config = {};
    }
    return resolveCveSource(config);
  }

  // Seconds to wait for an OpenJS CNA run before saving the correlation id
  // and giving control back. Supervised buckets wait for a coordinator, so
  // this is a pause, not a failure.
  getCnaTimeoutMs() {
    let seconds;
    try {
      ({ openjs_cna_timeout: seconds } = getMergedConfig());
    } catch {
      // fall through to the default
    }
    const parsed = Number(seconds);
    return (Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CNA_TIMEOUT_SECONDS) * 1000;
  }

  async sync() {
    await checkRemote(this.cli, this.repository);

    const content = this.readVulnerabilitiesJSON();
    const credentials = await auth({
      github: true,
      h1: true
    });
    const req = new Request(credentials);
    for (let i = 0; i < content.reports.length; ++i) {
      const report = content.reports[i];
      const { data } = await req.getReport(report.id);
      const reportSeverity = getReportSeverity(data);
      const summaryContent = getSummary(data);
      const link = `https://hackerone.com/reports/${report.id}`;
      let prURL = report.prURL;
      if (data.relationships.custom_field_values.data.length) {
        prURL = data.relationships.custom_field_values.data[0].attributes.value;
      }

      content.reports[i] = {
        ...report,
        title: data.attributes.title,
        cveIds: data.attributes.cve_ids,
        severity: reportSeverity,
        summary: summaryContent ?? report.summary,
        link,
        prURL
      };
    }
    const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
    await writeSecurityFile(
      this.cli,
      vulnerabilitiesJSONPath,
      JSON.stringify(content, null, 2),
      'This writes synchronized HackerOne report data to vulnerabilities.json.'
    );

    const commitMessage = 'chore: git node security --sync';
    await commitAndPushVulnerabilitiesJSON([vulnerabilitiesJSONPath],
      commitMessage, { cli: this.cli, repository: this.repository });
    this.cli.ok('Synced vulnerabilities.json with HackerOne');
  }

  async updateReleaseDate(releaseDate) {
    const { cli } = this;

    try {
      validateDate(releaseDate);
    } catch {
      cli.error('Invalid date format. Please use the format yyyy/mm/dd.');
      process.exit(1);
    }

    // checkout on the next-security-release branch
    await checkoutOnSecurityReleaseBranch(cli, this.repository);

    // update the release date in the vulnerabilities.json file
    const updatedVulnerabilitiesFiles = await this.updateJSONReleaseDate(releaseDate, { cli });

    const commitMessage = `chore: update the release date to ${releaseDate}`;
    await commitAndPushVulnerabilitiesJSON(updatedVulnerabilitiesFiles,
      commitMessage, { cli, repository: this.repository });
    cli.ok('Done!');
  }

  async updateJSONReleaseDate(releaseDate) {
    const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
    const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath);
    content.releaseDate = releaseDate;

    await writeSecurityFile(
      this.cli,
      vulnerabilitiesJSONPath,
      JSON.stringify(content, null, 2),
      `This sets releaseDate to ${releaseDate} in vulnerabilities.json.`
    );

    this.cli.ok(`Updated the release date in vulnerabilities.json: ${releaseDate}`);
    return [vulnerabilitiesJSONPath];
  }

  async addReport(reportId) {
    const credentials = await auth({
      github: true,
      h1: true
    });

    const req = new Request(credentials);
    // checkout on the next-security-release branch
    await checkoutOnSecurityReleaseBranch(this.cli, this.repository);

    // get h1 report
    const { data: report } = await req.getReport(reportId);
    const entry = await pickReport(report, { cli: this.cli, req });

    const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
    const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath);
    content.reports.push(entry);
    await writeSecurityFile(
      this.cli,
      vulnerabilitiesJSONPath,
      JSON.stringify(content, null, 2),
      `This appends HackerOne report ${entry.id} to vulnerabilities.json.`
    );
    this.cli.ok(`Updated vulnerabilities.json with the report: ${entry.id}`);
    const commitMessage = `chore: added report ${entry.id} to vulnerabilities.json`;
    await commitAndPushVulnerabilitiesJSON(vulnerabilitiesJSONPath,
      commitMessage, { cli: this.cli, repository: this.repository });
    this.cli.ok('Done!');
  }

  async removeReport(reportId) {
    const { cli } = this;
    // checkout on the next-security-release branch
    await checkoutOnSecurityReleaseBranch(cli, this.repository);
    const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
    const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath);
    const found = content.reports.some((report) => report.id === reportId);
    if (!found) {
      cli.error(`Report with id ${reportId} not found in vulnerabilities.json`);
      process.exit(1);
    }
    content.reports = content.reports.filter((report) => report.id !== reportId);
    await writeSecurityFile(
      this.cli,
      vulnerabilitiesJSONPath,
      JSON.stringify(content, null, 2),
      `This removes HackerOne report ${reportId} from vulnerabilities.json.`
    );
    this.cli.ok(`Updated vulnerabilities.json with the report: ${reportId}`);

    const commitMessage = `chore: remove report ${reportId} from vulnerabilities.json`;
    await commitAndPushVulnerabilitiesJSON(vulnerabilitiesJSONPath,
      commitMessage, { cli, repository: this.repository });
    cli.ok('Done!');
  }

  async requestCVEs() {
    const cveSource = this.getCveSource();
    const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
    const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath);
    const { reports } = content;
    this.validateReportsForCVE(reports);

    // A pending OpenJS reservation must be resumed through the CNA even if
    // the operator flipped `cve_source` back to HackerOne in the meantime.
    const hasPending = reports.some((report) => report.pendingCve?.correlationId);
    const credentials = await auth({
      github: true,
      h1: true,
      cna: cveSource === 'openjs-cna' || hasPending
    });
    const req = new Request(credentials);
    if (cveSource === 'openjs-cna') {
      this.cli.info(
        `Requesting CVEs through the OpenJS Foundation CNA (${credentials.cna.worker_url})`
      );
    }
    const programId = cveSource === 'hackerone'
      ? await this.getNodeProgramId(req)
      : undefined;
    await this.promptCVECreation(req, reports, programId, content, cveSource);
  }

  validateReportsForCVE(reports) {
    const invalid = [];
    for (const report of reports) {
      if (report.cveIds?.length) continue;
      const missing = [];
      if (!report.summary) missing.push('description');
      if (!report.severity?.weakness_id) missing.push('weakness_id');
      if (!report.severity?.cvss_vector_string) missing.push('cvss_vector_string');
      if (missing.length) {
        invalid.push({ id: report.id, missing });
      }
    }
    if (invalid.length) {
      for (const { id, missing } of invalid) {
        this.cli.error(`Report ${id} is missing: ${missing.join(', ')}`);
      }
      throw new Error('Some reports are missing required fields for CVE request. ' +
        'Run `git node security --sync` to update them.');
    }
  }

  async updateHackonerReportCve(req, report) {
    const { id, cveIds } = report;
    await confirmSecurityStep(
      this.cli,
      `update HackerOne report \`${id}\` with CVEs ${cveIds}`,
      'This writes the assigned CVE IDs back to the HackerOne report.'
    );
    return this.updateHackonerReportCveWithoutConfirmation(req, report);
  }

  async updateHackonerReportCveWithoutConfirmation(req, report) {
    const { id, cveIds } = report;
    this.cli.startSpinner(`Updating report ${id} with CVEs ${cveIds}..`);
    const body = {
      data: {
        type: 'report-cves',
        attributes: {
          cve_ids: cveIds
        }
      }
    };
    try {
      const response = await req.updateReportCVE(id, body);
      if (response.errors) {
        this.cli.stopSpinner(
          `Error updating report ${id}`,
          this.cli.SPINNER_STATUS.FAILED
        );
        this.cli.error(`Error updating report ${id}`);
        this.cli.error(JSON.stringify(response.errors, null, 2));
        return false;
      }
      this.cli.stopSpinner(`Done updating report ${id} with CVEs ${cveIds}..`);
      return true;
    } catch (error) {
      this.cli.stopSpinner(
        `Error updating report ${id}`,
        this.cli.SPINNER_STATUS.FAILED
      );
      this.cli.error(error);
      return false;
    }
  }

  async promptCVECreation(req, reports, programId, content, cveSource = 'hackerone') {
    const reportsWithoutCVE = reports.filter((report) => !report.cveIds?.length);
    const requestAll = await this.promptRequestAllCVEs(reportsWithoutCVE);

    if (requestAll) {
      await confirmSecurityStep(
        this.cli,
        `request CVEs for ${reportsWithoutCVE.length} HackerOne reports`,
        cveSource === 'openjs-cna'
          ? 'This reserves a CVE through the OpenJS Foundation CNA for every report without a CVE.'
          : 'This submits CVE requests to HackerOne for every report without a CVE.'
      );
    }

    const supportedVersions = (await nv('supported'));
    const eolVersions = (await nv('eol'));
    const versionCache = new Map();
    const successfulReports = await this.collectSuccessfulCVERequests({
      req,
      reports,
      programId,
      supportedVersions,
      eolVersions,
      versionCache,
      requestAll,
      content,
      cveSource
    });

    if (!successfulReports.length) {
      this.cli.warn('No CVE requests succeeded.');
      return;
    }

    await this.applySuccessfulCVEUpdates(req, content, successfulReports);
  }

  showCVERequestSummary(reports) {
    this.cli.info(`Reports selected for CVE requests (${reports.length}):`);
    for (const { id, severity, title } of reports) {
      const rating = severity?.rating || 'unknown';
      this.cli.info(`- ${id} [${rating}] ${title}`);
    }
  }

  promptRequestAllCVEs(reportsWithoutCVE) {
    if (reportsWithoutCVE.length <= 1) return false;
    this.showCVERequestSummary(reportsWithoutCVE);
    return this.cli.prompt(
      'Request CVEs for all reports without prompting for each report?',
      { defaultAnswer: false }
    );
  }

  async collectSuccessfulCVERequests({
    req,
    reports,
    programId,
    supportedVersions,
    eolVersions,
    versionCache,
    requestAll,
    content,
    cveSource = 'hackerone'
  }) {
    const successfulReports = [];
    try {
      for (const report of reports) {
        const requested = await this.requestReportCVE({
          req,
          report,
          programId,
          supportedVersions,
          eolVersions,
          versionCache,
          requestAll,
          content,
          cveSource
        });
        if (requested) successfulReports.push(report);
      }
    } catch (error) {
      if (!this.isPromptInterrupted(error)) throw error;
      this.cli.warn('CVE request interrupted. Finalizing successful requests.');
    }
    return successfulReports;
  }

  isPromptInterrupted(error) {
    return error?.name === 'ExitPromptError' ||
      error?.name === 'AbortPromptError' ||
      error?.message?.includes('User force closed');
  }

  async requestReportCVE({
    req,
    report,
    programId,
    supportedVersions,
    eolVersions,
    versionCache,
    requestAll,
    content,
    cveSource = 'hackerone'
  }) {
    const { id, summary, title, affectedVersions, cveIds, link } = report;
    const affectedVersionLines = getAffectedVersionLines(affectedVersions);
    // skip if already has a CVE
    // risky because the CVE associated might be
    // mentioned in the report and not requested by Node
    if (cveIds?.length) return false;

    // A previous run dispatched the reservation but did not see it complete
    // (typically a supervised bucket waiting for a coordinator). Pick it up
    // instead of reserving a second id.
    if (report.pendingCve?.correlationId) {
      this.cli.info(
        `Resuming pending OpenJS CNA reservation for report ${id} ` +
        `(correlation id ${report.pendingCve.correlationId})`
      );
      return this.waitForReservedCve({ req, report, content });
    }

    let severity = report.severity;

    if (!severity.cvss_vector_string || !severity.weakness_id) {
      try {
        const h1Report = await req.getReport(id);
        if (!h1Report.data.relationships.severity?.data.attributes.cvss_vector_string) {
          throw new Error('No severity found');
        }
        severity = {
          weakness_id: h1Report.data.relationships.weakness?.data.id,
          cvss_vector_string:
            h1Report.data.relationships.severity?.data.attributes.cvss_vector_string,
          rating: h1Report.data.relationships.severity?.data.attributes.rating
        };
      } catch {
        this.cli.error(`Couldnt not retrieve severity from report ${id}, skipping...`);
        return false;
      }
    }

    const { cvss_vector_string, weakness_id } = severity;

    const issuer = cveSource === 'openjs-cna' ? 'the OpenJS Foundation CNA' : 'HackerOne';
    const create = requestAll || await this.cli.prompt(
      `Request a CVE via ${issuer} for: \n
Title: ${title}\n
Link: ${link}\n
Affected versions: ${affectedVersionLines.join(', ')}\n
Vector: ${cvss_vector_string}\n
Summary: ${summary}\n`,
      { defaultAnswer: true });

    if (!create) return false;

    const { h1AffectedVersions, patchedVersions } =
      await this.calculateVersions(
        affectedVersionLines,
        supportedVersions,
        eolVersions,
        versionCache
      );

    if (cveSource === 'openjs-cna') {
      if (!requestAll) {
        await confirmSecurityStep(
          this.cli,
          `reserve a CVE through the OpenJS Foundation CNA for HackerOne report \`${id}\``,
          'This reserves a CVE id at MITRE through the OpenJS CNA for the selected report.'
        );
      }
      return this.reserveCveViaOpenJsCna({ req, report, content, patchedVersions });
    }

    const body = {
      data: {
        type: 'cve-request',
        attributes: {
          team_handle: 'nodejs-team',
          versions: h1AffectedVersions,
          metrics: [
            {
              vectorString: cvss_vector_string
            }
          ],
          auto_submit_on_publicly_disclosing_report: true,
          references: ['https://nodejs.org/en/blog/vulnerability'],
          report_id: report.id,
          weakness_id: Number(weakness_id),
          description: report.summary,
          vulnerability_discovered_at: new Date().toISOString()
        }
      }
    };
    if (!requestAll) {
      await confirmSecurityStep(
        this.cli,
        `request CVE for HackerOne report \`${report.id}\``,
        'This submits a CVE request to HackerOne for the selected report.'
      );
    }
    this.cli.startSpinner(`Requesting CVE for report ${id}...`);
    let response;
    try {
      response = await req.requestCVE(programId, body);
    } catch (error) {
      this.cli.stopSpinner(
        `Error requesting CVE for report ${id}`,
        this.cli.SPINNER_STATUS.FAILED
      );
      this.cli.error(error);
      return false;
    }
    if (response.errors) {
      this.cli.stopSpinner(
        `Error requesting CVE for report ${id}`,
        this.cli.SPINNER_STATUS.FAILED
      );
      this.cli.error(`Error requesting CVE for report ${id}`);
      this.cli.error(JSON.stringify(response.errors, null, 2));
      return false;
    }
    this.cli.stopSpinner(`Requested CVE for report ${id}`);
    const { cve_identifier } = response.data.attributes;
    report.cveIds = [cve_identifier];
    report.patchedVersions = patchedVersions;
    return true;
  }

  // Dispatches `reserve-cve` and saves the correlation id to
  // vulnerabilities.json BEFORE waiting, so a timeout (or Ctrl-C) never
  // orphans a reserved id: re-running --request-cve resumes from the file.
  async reserveCveViaOpenJsCna({ req, report, content, patchedVersions }) {
    const { id } = report;
    this.cli.startSpinner(`Reserving a CVE for report ${id} through the OpenJS CNA...`);
    let dispatch;
    try {
      dispatch = await req.cnaDispatch('reserve-cve');
    } catch (error) {
      this.cli.stopSpinner(
        `Error reserving a CVE for report ${id}`,
        this.cli.SPINNER_STATUS.FAILED
      );
      this.cli.error(error);
      return false;
    }
    report.pendingCve = {
      correlationId: dispatch.correlation_id,
      dispatchedAt: new Date().toISOString(),
      patchedVersions
    };
    this.cli.stopSpinner(
      `Dispatched reserve-cve for report ${id} (correlation id ${dispatch.correlation_id})`
    );
    await this.persistPendingCve(content, report);
    return this.waitForReservedCve({ req, report, content });
  }

  // Writes vulnerabilities.json locally (no commit) so the `pendingCve`
  // marker survives an interrupted run. The commit happens with the regular
  // post-request update.
  async persistPendingCve(content, report) {
    const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
    const action = report.pendingCve
      ? `This saves the pending OpenJS CNA correlation id for report ${report.id} ` +
        'so the reservation can be resumed if this command is interrupted.'
      : `This clears the failed OpenJS CNA reservation marker for report ${report.id} ` +
        'so the next attempt dispatches a fresh reservation.';
    await writeSecurityFile(
      this.cli,
      vulnerabilitiesJSONPath,
      JSON.stringify(content, null, 2),
      action
    );
  }

  async waitForReservedCve({ req, report, content }) {
    const { id, pendingCve } = report;
    const { correlationId } = pendingCve;
    this.cli.startSpinner(
      `Waiting for the OpenJS CNA to reserve a CVE for report ${id} (${correlationId})...`
    );
    let run;
    try {
      run = await req.cnaWaitForCompletion(correlationId, {
        timeoutMs: this.getCnaTimeoutMs()
      });
    } catch (error) {
      this.cli.stopSpinner(
        `CVE reservation for report ${id} did not complete`,
        this.cli.SPINNER_STATUS.FAILED
      );
      if (error.name === 'CnaTimeoutError') {
        // Keep pendingCve: the run is still going on the server side.
        this.cli.warn(
          `Still waiting on ${correlationId} (supervised buckets wait for a ` +
          'coordinator approval). The correlation id is saved in ' +
          'vulnerabilities.json; re-run `git node security --request-cve` to resume.'
        );
        return false;
      }
      // The run concluded without a CVE: drop the marker so the next attempt
      // dispatches a fresh reservation.
      this.cli.error(error);
      delete report.pendingCve;
      await this.persistPendingCve(content, report);
      return false;
    }

    const cveId = run.result?.cve_id;
    if (!cveId) {
      this.cli.stopSpinner(
        `Reservation for report ${id} completed without a CVE id`,
        this.cli.SPINNER_STATUS.FAILED
      );
      this.cli.error(
        `Run ${correlationId} succeeded but returned no cve_id. ` +
        `Inspect ${run.url ?? 'the run'} with an OpenJS CNA coordinator before retrying.`
      );
      delete report.pendingCve;
      await this.persistPendingCve(content, report);
      return false;
    }

    report.cveIds = [cveId];
    report.patchedVersions = pendingCve.patchedVersions;
    delete report.pendingCve;
    this.cli.stopSpinner(`Reserved ${cveId} for report ${id}`);
    return true;
  }

  async applySuccessfulCVEUpdates(req, content, successfulReports) {
    this.cli.info('CVE requests succeeded:');
    for (const { id, cveIds } of successfulReports) {
      this.cli.info(`- ${id}: ${cveIds.join(', ')}`);
    }
    const updateVulnerabilitiesJSON = await this.cli.prompt(
      'Update vulnerabilities.json with the successful CVE requests?',
      { defaultAnswer: true }
    );
    if (!updateVulnerabilitiesJSON) {
      this.cli.warn(
        'Skipping HackerOne updates because vulnerabilities.json was not updated.'
      );
      return;
    }

    const vulnerabilitiesUpdated = await this.updateVulnerabilitiesJSON(content);
    if (!vulnerabilitiesUpdated) {
      this.cli.warn('Skipping HackerOne updates because vulnerabilities.json update failed.');
      return;
    }

    const updateHackerOne = await this.cli.prompt(
      'Update HackerOne reports with the successful CVE IDs?',
      { defaultAnswer: true }
    );
    if (updateHackerOne) {
      await confirmSecurityStep(
        this.cli,
        `update ${successfulReports.length} HackerOne reports with CVE IDs`,
        'This writes the assigned CVE IDs back to all successful HackerOne reports.'
      );
      const h1Succeeded = [];
      const h1Failed = [];
      for (const report of successfulReports) {
        try {
          const updated = await this.updateHackonerReportCveWithoutConfirmation(req, report);
          if (updated) {
            h1Succeeded.push(report);
          } else {
            h1Failed.push(report);
          }
        } catch (error) {
          this.cli.error(error);
          h1Failed.push(report);
        }
      }

      if (h1Succeeded.length) {
        this.cli.info('HackerOne reports updated:');
        for (const { id, cveIds } of h1Succeeded) {
          this.cli.info(`- ${id}: ${cveIds.join(', ')}`);
        }
      }
      if (h1Failed.length) {
        this.cli.warn('HackerOne reports not updated:');
        for (const { id, cveIds } of h1Failed) {
          this.cli.warn(`- ${id}: ${cveIds.join(', ')}`);
        }
      }
    }
  }

  async getNodeProgramId(req) {
    const programs = await req.getPrograms();
    const { data } = programs;
    for (const program of data) {
      const { attributes } = program;
      if (attributes.handle === 'nodejs') {
        return program.id;
      }
    }
  }

  async calculateVersions(
    affectedVersions,
    supportedVersions,
    eolVersions,
    versionCache = new Map()
  ) {
    affectedVersions = getAffectedVersionLines(affectedVersions);
    const h1AffectedVersions = [];
    const patchedVersions = [];
    let isPatchRelease = true;
    for (const affectedVersion of affectedVersions) {
      if (!versionCache.has(affectedVersion)) {
        const affectedMajor = affectedVersion.split('.')[0];
        const latest =
          supportedVersions.find((v) => v.major === Number(affectedMajor))?.version ?? '';
        const version = await this.cli.prompt(
          `What is the affected version (<=) for release line ${affectedVersion}?`,
          { questionType: 'input', defaultAnswer: latest });

        const nextPatchVersion = incrementVersion(version, 'patch');
        const nextMinorVersion = incrementVersion(version, 'minor');
        const patchedVersion = await this.cli.promptRadio(
          `What is the patched version (>=) for release line ${affectedVersion}?`,
          [nextPatchVersion, nextMinorVersion],
          {
            defaultAnswer: isPatchRelease ? nextPatchVersion : nextMinorVersion
          });

        if (patchedVersion !== nextPatchVersion) {
          isPatchRelease = false; // is a minor release
        }

        versionCache.set(affectedVersion, {
          patchedVersion,
          h1AffectedVersion: {
            vendor: 'nodejs',
            product: 'node',
            func: '<=',
            version,
            versionType: 'semver',
            affected: true
          }
        });
      }

      const { h1AffectedVersion, patchedVersion } = versionCache.get(affectedVersion);
      patchedVersions.push(patchedVersion);
      h1AffectedVersions.push(h1AffectedVersion);
    }

    // All EOL versions are affected since they no longer receive security patches
    for (const eolVersion of eolVersions) {
      const version = semver.valid(eolVersion.version);
      if (version) {
        h1AffectedVersions.push({
          vendor: 'nodejs',
          product: 'node',
          func: '<=',
          version,
          versionType: 'semver',
          affected: true
        });
      }
    }

    return { h1AffectedVersions, patchedVersions };
  }
}