diff --git a/lib/github_advisory_sync.rb b/lib/github_advisory_sync.rb index 4aaf290551..59f7550937 100644 --- a/lib/github_advisory_sync.rb +++ b/lib/github_advisory_sync.rb @@ -250,6 +250,73 @@ def initialize(advisory) @vulnerabilities = [] end + def self.formatted_yaml(data) + yaml = data.to_yaml + formatted_yaml = indent_mapping_sequences(yaml) + + return formatted_yaml if yaml_round_trips?(formatted_yaml, data) + + yaml + end + + def self.indent_mapping_sequences(yaml) + active_sequence_indents = [] + pending_sequence_indent = nil + block_scalar_indent = nil + + yaml.lines.map do |line| + if line.strip.empty? || line.start_with?("---") + next line + end + + indent = line[/\A */].length + + if block_scalar_indent + if indent > block_scalar_indent + next "#{' ' * active_sequence_indents.length}#{line}" + end + + block_scalar_indent = nil + end + + sequence_item = line.match?(/\A\s*-\s/) + + active_sequence_indents.pop while active_sequence_indents.any? && + indent <= active_sequence_indents.last && + !(sequence_item && indent == active_sequence_indents.last) + + if sequence_item && pending_sequence_indent == indent + active_sequence_indents << indent + end + + pending_sequence_indent = sequence_indent_after_mapping_key(line, indent) + block_scalar_indent = indent if block_scalar_header?(line) + + "#{' ' * active_sequence_indents.length}#{line}" + end.join + end + + def self.sequence_indent_after_mapping_key(line, indent) + if line.match?(/\A\s*-\s+.*:\s*\z/) + indent + 2 + elsif line.match?(/\A\s*[^#].*:\s*\z/) + indent + end + end + + def self.block_scalar_header?(line) + line.match?(/\A\s*(?:-\s+)?[^#]+:\s*[>|](?:[+-]?[1-9]|[1-9][+-]?)?\s*\z/) + end + + def self.yaml_round_trips?(yaml, data) + YAML.safe_load(yaml, permitted_classes: [Date]) == data + rescue Psych::Exception + false + end + + private_class_method :indent_mapping_sequences, :sequence_indent_after_mapping_key, + :block_scalar_header?, :yaml_round_trips? + def identifier_list advisory["identifiers"] end @@ -338,7 +405,7 @@ def update(package) return if saved_data == new_data File.open(package.filename, 'w') do |file| - file.write YAML.dump(new_data) + file.write self.class.formatted_yaml(new_data) end puts "Updated: #{package.filename}" @@ -428,7 +495,7 @@ def create(package) FileUtils.mkdir_p(File.dirname(filename_to_write)) File.open(filename_to_write, "w") do |file| # create an automatically generated advisory yaml file - file.write new_data.to_yaml + file.write self.class.formatted_yaml(new_data) # The data we just wrote is incomplete, # and therefore should not be committed as is @@ -448,7 +515,7 @@ def create(package) # Still it should be removed before the data goes into rubysec file.write "# GitHub advisory data below - **Remove this data before committing**\n" file.write "# Use this data to write patched_versions (and potentially unaffected_versions) above\n" - file.write advisory.merge("vulnerabilities" => vulnerabilities).to_yaml + file.write self.class.formatted_yaml(advisory.merge("vulnerabilities" => vulnerabilities)) end puts "Wrote: #{filename_to_write}" filename_to_write diff --git a/spec/github_advisory_sync_spec.rb b/spec/github_advisory_sync_spec.rb new file mode 100644 index 0000000000..019832ffb6 --- /dev/null +++ b/spec/github_advisory_sync_spec.rb @@ -0,0 +1,74 @@ +require "spec_helper" +require "github_advisory_sync" + +RSpec.describe GitHub::GitHubAdvisory do + describe ".formatted_yaml" do + it "indents generated sequence values under their keys" do + data = { + "patched_versions" => [">= 3.0.1"], + "related" => { + "url" => [ + "https://github.com/autolab/Autolab/security/advisories/GHSA-v46j-h43h-rwrm" + ] + } + } + + yaml = described_class.formatted_yaml(data) + + expect(yaml).to include(%(patched_versions:\n - ">= 3.0.1"\n)) + expect(yaml).to include( + "related:\n" \ + " url:\n" \ + " - https://github.com/autolab/Autolab/security/advisories/GHSA-v46j-h43h-rwrm\n" + ) + expect(YAML.safe_load(yaml)).to eq(data) + end + + it "keeps nested array payloads valid" do + data = { + "description" => "Impact:\n- user-provided bullet\n", + "notes" => " heading:\n - keep literal bullet\n", + "vulnerabilities" => [ + { + "package" => { + "name" => "autolab" + }, + "identifiers" => [ + { + "type" => "CVE", + "value" => "CVE-2026-1234" + } + ] + } + ] + } + + yaml = described_class.formatted_yaml(data) + + expect(yaml).to include( + "vulnerabilities:\n" \ + " - package:\n" \ + " name: autolab\n" \ + " identifiers:\n" \ + " - type: CVE\n" \ + " value: CVE-2026-1234\n" + ) + expect(YAML.safe_load(yaml)).to eq(data) + end + + it "does not corrupt multiline quoted scalar payloads" do + data = { + "vulnerabilities" => [ + { + "desc" => "x\n", + "fixed" => true + } + ] + } + + yaml = described_class.formatted_yaml(data) + + expect(YAML.safe_load(yaml)).to eq(data) + end + end +end