@ 261105.4
current
import fs from 'node:fs';
import path from 'node:path';
import nv from '@pkgjs/nv';
import {
  PLACEHOLDERS,
  checkoutOnSecurityReleaseBranch,
  validateDate,
  SecurityRelease,
  commitAndPushVulnerabilitiesJSON,
  getAffectedVersionLines,
  getDependencyUpdates,
  getHighestSeverityAnnouncement,
  writeSecurityFile,
} from './security-release/security-release.js';
import auth from './auth.js';
import Request from './request.js';

const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];

export default class SecurityBlog extends SecurityRelease {
  req;

  async createPreRelease(nodejsOrgFolder) {
    const { cli } = this;

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

    // read vulnerabilities JSON file
    const content = this.readVulnerabilitiesJSON();
    // validate the release date read from vulnerabilities JSON
    if (!content.releaseDate) {
      throw new Error('Release date is not set in vulnerabilities.json,' +
        ' run `git node security --update-date=YYYY/MM/DD` to set the release date.');
    }

    validateDate(content.releaseDate);
    const releaseDate = new Date(content.releaseDate);

    const template = this.getSecurityPreReleaseTemplate();
    const data = {
      annoucementDate: await this.getAnnouncementDate(cli),
      releaseDate: this.formatReleaseDate(releaseDate),
      affectedVersions: this.getAffectedVersions(content),
      vulnerabilities: this.getPreReleaseVulnerabilities(content),
      slug: this.getSlug(releaseDate),
      impact: this.getImpact(content)
    };
    const month = releaseDate.toLocaleString('en-US', { month: 'long' }).toLowerCase();
    const year = releaseDate.getFullYear();
    const fileName = `${month}-${year}-security-releases`;
    const fileNameExt = fileName + '.md';
    const preRelease = this.buildPreRelease(template, data);

    const pathToBlogPosts = 'apps/site/pages/en/blog/vulnerability';
    const pathToBannerJson = 'apps/site/site.json';

    const file = path.resolve(process.cwd(), nodejsOrgFolder, pathToBlogPosts, fileNameExt);
    const site = path.resolve(process.cwd(), nodejsOrgFolder, pathToBannerJson);

    const endDate = new Date(data.annoucementDate);
    endDate.setDate(endDate.getDate() + 7);

    const link = `https://nodejs.org/en/blog/vulnerability/${fileName}`;
    await this.updateWebsiteBanner(site, {
      startDate: data.annoucementDate,
      endDate: endDate.toISOString(),
      text: `New security releases to be made available ${data.releaseDate}`,
      link,
      type: 'warning'
    });
    await writeSecurityFile(
      cli,
      file,
      preRelease,
      'This writes the pre-release vulnerability announcement post.'
    );

    cli.ok(`Announcement file created and banner has been updated. Folder: ${nodejsOrgFolder}`);
    await this.updateAnnouncementLink(link);
  }

  async updateAnnouncementLink(link) {
    const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
    const content = this.readVulnerabilitiesJSON(vulnerabilitiesJSONPath);

    if (content.announcement === link) {
      return [vulnerabilitiesJSONPath];
    }

    content.announcement = link;
    await writeSecurityFile(
      this.cli,
      vulnerabilitiesJSONPath,
      JSON.stringify(content, null, 2),
      'This writes the announcement link to vulnerabilities.json.'
    );
    const commitMessage = 'chore: add announcement link';
    await commitAndPushVulnerabilitiesJSON([vulnerabilitiesJSONPath],
      commitMessage, { cli: this.cli, repository: this.repository });

    this.cli.ok('Updated the announcement link in vulnerabilities.json');
    return [vulnerabilitiesJSONPath];
  }

  async createPostRelease(nodejsOrgFolder) {
    const { cli } = this;
    const credentials = await auth({
      github: true,
      h1: true
    });

    this.req = new Request(credentials);

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

    // read vulnerabilities JSON file
    const content = this.readVulnerabilitiesJSON();
    if (!content.releaseDate) {
      throw new Error('Release date is not set in vulnerabilities.json,' +
        ' run `git node security --update-date=YYYY/MM/DD` to set the release date.');
    }

    validateDate(content.releaseDate);
    const releaseDate = new Date(content.releaseDate);
    const template = this.getSecurityPostReleaseTemplate();
    const data = {
      annoucementDate: releaseDate.toISOString(),
      releaseDate: this.formatReleaseDate(releaseDate),
      affectedVersions: this.getAffectedVersions(content),
      vulnerabilities: this.getVulnerabilities(content),
      slug: this.getSlug(releaseDate),
      author: 'The Node.js Project',
      dependencyUpdates: content.dependencies
    };

    const pathToBlogPosts = path.resolve(nodejsOrgFolder, 'apps/site/pages/en/blog/vulnerability');
    const pathToBannerJson = path.resolve(nodejsOrgFolder, 'apps/site/site.json');

    const preReleasePath = path.resolve(pathToBlogPosts, data.slug + '.md');
    let preReleaseContent = this.findExistingPreRelease(preReleasePath);
    if (!preReleaseContent) {
      throw new Error(`Existing pre-release not found! Path: ${preReleasePath} `);
    }

    const postReleaseContent = await this.buildPostRelease(template, data, content);
    // cut the part before summary
    const preSummary = preReleaseContent.indexOf('# Summary');
    if (preSummary !== -1) {
      preReleaseContent = preReleaseContent.substring(preSummary);
    }
    const updatedContent = postReleaseContent + preReleaseContent;

    const endDate = new Date(data.annoucementDate);
    endDate.setDate(endDate.getDate() + 7);
    const month = releaseDate.toLocaleString('en-US', { month: 'long' });
    const capitalizedMonth = month[0].toUpperCase() + month.slice(1);

    await this.updateWebsiteBanner(pathToBannerJson, {
      startDate: releaseDate,
      endDate,
      text: `${capitalizedMonth} Security Release is available`
    });

    await writeSecurityFile(
      cli,
      preReleasePath,
      updatedContent,
      'This writes the post-release vulnerability announcement post.'
    );
    cli.ok(`Announcement file and banner has been updated. Folder: ${nodejsOrgFolder}`);
  }

  findExistingPreRelease(filepath) {
    if (!fs.existsSync(filepath)) {
      return null;
    }

    return fs.readFileSync(filepath, 'utf-8');
  }

  promptAuthor(cli) {
    return cli.prompt('Who is the author of this security release? If multiple' +
      ' use & as separator', {
      questionType: 'input',
      defaultAnswer: PLACEHOLDERS.author
    });
  }

  async updateWebsiteBanner(siteJsonPath, content) {
    const siteJson = JSON.parse(fs.readFileSync(siteJsonPath));

    const currentValue = siteJson.websiteBanners.index;
    siteJson.websiteBanners.index = {
      startDate: content.startDate ?? currentValue.startDate,
      endDate: content.endDate ?? currentValue.endDate,
      text: content.text ?? currentValue.text,
      link: content.link ?? currentValue.link,
      type: content.type ?? currentValue.type
    };
    await writeSecurityFile(
      this.cli,
      siteJsonPath,
      JSON.stringify(siteJson, null, 2) + '\n',
      'This writes the updated security release website banner.'
    );
  }

  formatReleaseDate(releaseDate) {
    const options = {
      weekday: 'long',
      month: 'long',
      day: 'numeric',
      year: 'numeric'
    };
    return releaseDate.toLocaleDateString('en-US', options);
  }

  buildPreRelease(template, data) {
    const {
      annoucementDate,
      releaseDate,
      affectedVersions,
      vulnerabilities,
      slug,
      impact
    } = data;
    return template.replaceAll(PLACEHOLDERS.annoucementDate, annoucementDate)
      .replaceAll(PLACEHOLDERS.slug, slug)
      .replaceAll(PLACEHOLDERS.affectedVersions, affectedVersions)
      .replaceAll(PLACEHOLDERS.vulnerabilities, vulnerabilities)
      .replaceAll(PLACEHOLDERS.releaseDate, releaseDate)
      .replaceAll(PLACEHOLDERS.impact, impact);
  }

  async buildPostRelease(template, data, content) {
    const {
      annoucementDate,
      releaseDate,
      affectedVersions,
      vulnerabilities,
      slug,
      impact,
      author,
      dependencyUpdates
    } = data;
    return template.replaceAll(PLACEHOLDERS.annoucementDate, annoucementDate)
      .replaceAll(PLACEHOLDERS.slug, slug)
      .replaceAll(PLACEHOLDERS.affectedVersions, affectedVersions)
      .replaceAll(PLACEHOLDERS.vulnerabilities, vulnerabilities)
      .replaceAll(PLACEHOLDERS.releaseDate, releaseDate)
      .replaceAll(PLACEHOLDERS.impact, impact)
      .replaceAll(PLACEHOLDERS.author, author)
      .replaceAll(PLACEHOLDERS.reports, await this.getReportsTemplate(content))
      .replaceAll(PLACEHOLDERS.dependencyUpdates,
        this.getDependencyUpdatesTemplate(dependencyUpdates))
      .replaceAll(PLACEHOLDERS.downloads, await this.getDownloadsTemplate(affectedVersions));
  }

  async getReportsTemplate(content) {
    const reports = [...content.reports].sort((a, b) =>
      this.getSeverityOrder(a) - this.getSeverityOrder(b));
    let template = '';
    for (const report of reports) {
      const cveId = report.cveIds?.join(', ');
      if (!cveId) {
        throw new Error(`CVE ID for vulnerability ${report.link} ${report.title} not found`);
      }
      template += `## ${report.title} (${cveId}) - (${report.severity.rating})\n\n`;
      if (!report.summary) {
        throw new Error(`Summary missing for vulnerability ${report.link} ` +
        `${report.title}. Please create it before continuing.`);
      }

      template += `${report.summary}\n\n`;
      const releaseLines = getAffectedVersionLines(report.affectedVersions).join(', ');
      template += `Impact:\n\n- This vulnerability affects all users\
 in active release lines: ${releaseLines}\n\n`;
      if (!report.patchAuthors) {
        throw new Error(`Missing patch author for vulnerability ${report.link} ${report.title}`);
      }
      template += `Thank you, to ${report.reporter} for reporting this vulnerability\
 and thank you ${report.patchAuthors.join(' and ')} for fixing it.\n\n`;
    }
    return template;
  }

  getSeverityOrder(report) {
    const rating = report.severity?.rating?.toLowerCase();
    const index = SEVERITY_ORDER.indexOf(rating);
    if (index === -1) return SEVERITY_ORDER.length;
    return index;
  }

  getDependencyUpdatesTemplate(dependencyUpdates) {
    const updates = getDependencyUpdates(dependencyUpdates);
    if (updates.length === 0) return '';
    let template = '\nThis security release includes the following dependency' +
      ' updates to address public vulnerabilities:\n';
    for (const update of updates) {
      const title = this.formatDependencyUpdateTitle(update) || update.name;
      const releaseLines = getAffectedVersionLines(update.affectedVersions);
      const formattedVersions = this.formatDependencyVersions(update.versions);
      const versionText = formattedVersions ? ` (${formattedVersions})` : '';
      template += `- ${title}${versionText} on ${releaseLines.join(', ')}\n`;
    }
    return template;
  }

  formatDependencyUpdateVersion(version) {
    if (typeof version === 'string' || typeof version === 'number') {
      return String(version);
    }

    if (!version || typeof version !== 'object') return '';

    return version.version || version.to || version.patched || version.name ||
      this.formatDependencyUpdateTitle(version) || version.url || JSON.stringify(version);
  }

  formatDependencyVersions(versions) {
    if (!Array.isArray(versions)) return '';
    // The current schema carries one entry per release line, so the same
    // version repeats when every line gets the same update.
    return [...new Set(versions
      .map((version) => this.formatDependencyUpdateVersion(version))
      .filter(Boolean))]
      .join(', ');
  }

  formatDependencyUpdateTitle(dependency) {
    if (!dependency || typeof dependency !== 'object') return '';
    if (!dependency.title) return dependency.name ?? '';

    const titleSeparator = dependency.title.indexOf(':');
    if (titleSeparator === -1) return dependency.title;
    return dependency.title.substring(titleSeparator + ':'.length).trim();
  }

  async getDownloadsTemplate(affectedVersions) {
    let template = '';
    const versionsToBeReleased = (await nv('supported')).filter(
      (v) => affectedVersions.split(', ').includes(`${v.major}.x`)
    );
    for (const version of versionsToBeReleased) {
      const v = `v${version.major}.${version.minor}.${Number(version.patch) + 1}`;
      template += `- [Node.js ${v}](/blog/release/${v}/)\n`;
    }

    return template;
  }

  getSlug(releaseDate) {
    const month = releaseDate.toLocaleString('en-US', { month: 'long' });
    const year = releaseDate.getFullYear();
    return `${month.toLocaleLowerCase()}-${year}-security-releases`;
  }

  async getAnnouncementDate(cli) {
    try {
      const date = await this.promptAnnouncementDate(cli);
      validateDate(date);
      return new Date(date).toISOString();
    } catch {
      return PLACEHOLDERS.annoucementDate;
    }
  }

  promptAnnouncementDate(cli) {
    const today = new Date().toISOString().substring(0, 10).replace(/-/g, '/');
    return cli.prompt('When is the security release going to be announced? ' +
      'Enter in YYYY/MM/DD format:', {
      questionType: 'input',
      defaultAnswer: today
    });
  }

  getImpact(content) {
    const impact = new Map();
    for (const report of content.reports) {
      if (!report.severity?.rating) {
        throw new Error(`severity.rating not found for report ${report.id}.`);
      }

      for (const version of getAffectedVersionLines(report.affectedVersions)) {
        if (!impact.has(version)) impact.set(version, []);
        impact.get(version).push(report);
      }
    }

    const result = Array.from(impact.entries())
      .sort(([a], [b]) => b.localeCompare(a)) // DESC
      .map(([version, reports]) =>
        getHighestSeverityAnnouncement(reports, `the ${version} release line`))
      .join('\n');

    return result;
  }

  getVulnerabilities(content) {
    const severityCount = new Map();

    for (const report of content.reports) {
      if (!report.severity?.rating) {
        throw new Error(`severity.rating not found for report ${report.id}.`);
      }

      const rating = report.severity.rating;
      severityCount.set(rating, (severityCount.get(rating) || 0) + 1);
    }

    const text = [];
    for (const [rating, count] of severityCount) {
      text.push(`- ${count} ${rating} severity issues.`);
    }

    return text.join('\n');
  }

  getPreReleaseVulnerabilities(content) {
    for (const report of content.reports) {
      if (!report.severity?.rating) {
        throw new Error(`severity.rating not found for report ${report.id}.`);
      }
    }

    return getHighestSeverityAnnouncement(content.reports);
  }

  getSecurityPreReleaseTemplate() {
    return fs.readFileSync(
      new URL(
        './github/templates/security-pre-release.md',
        import.meta.url
      ),
      'utf-8'
    );
  }

  getSecurityPostReleaseTemplate() {
    return fs.readFileSync(
      new URL(
        './github/templates/security-post-release.md',
        import.meta.url
      ),
      'utf-8'
    );
  }
}