# frozen_string_literal: true require 'fast_spec_helper' require 'webmock/rspec' require 'oj' require_relative '../../scripts/semgrep_result_processor' RSpec.describe SemgrepResultProcessor, feature_category: :tooling do let(:report_file) { 'spec/fixtures/scripts/gl-sast-report.json' } let(:processor) { described_class.new(report_file) } before do stub_env('CI_PROJECT_DIR', '/tmp/project_dir') stub_env('CI_API_V4_URL', 'https://gitlab.com/api/v4') stub_env('CI_MERGE_REQUEST_PROJECT_ID', '1234') stub_env('CI_MERGE_REQUEST_IID', '1234') stub_env('CI_MERGE_REQUEST_LABELS', '') stub_env('CUSTOM_SAST_RULES_BOT_PAT', 'gl-pat-123') stub_env('BOT_USER_ID', '21564538') stub_request(:any, /gitlab.com/).to_return(status: 400) end describe '#execute' do around do |example| example.run rescue SystemExit end let(:sample_results) do { "some_fingerprint" => { path: "example_path.rb", line: 1, message: "Example message" } } end let(:unique_results) { sample_results } # Assume filter_duplicate_findings returns the same data for simplicity before do allow(processor).to receive(:get_sast_results).and_return(sample_results) allow(processor).to receive(:filter_duplicate_findings).with(sample_results).and_return(unique_results) allow(processor).to receive(:create_inline_comments).with(unique_results) end it 'calls the methods in the correct sequence with expected arguments' do expect(processor).to receive(:perform_allowlist_check) expect(processor).to receive(:get_sast_results) expect(processor).to receive(:filter_duplicate_findings).with(sample_results) expect(processor).to receive(:create_inline_comments).with(unique_results) processor.execute end it 'raises an error and prints the error message' do allow(processor).to receive(:perform_allowlist_check).and_raise(StandardError, 'Error message here') expect { processor.execute }.to raise_error(SystemExit) end shared_examples 'does not comment on MR with stop labels' do it "returns early and does not comment" do expect(processor).to receive(:perform_allowlist_check) expect(processor).to receive(:get_sast_results) expect(processor).to receive(:filter_duplicate_findings).with(sample_results) expect do processor.execute end.to output(%r{Not adding comments for this MR as it has the appsec-sast::stop / pipeline::tier-3 label}) .to_stdout expect(processor).not_to receive(:create_inline_comments) end end context 'when CI_MERGE_REQUEST_LABELS includes appsec-sast::stop' do before do stub_env('CI_MERGE_REQUEST_LABELS', 'appsec-sast::stop') end it_behaves_like 'does not comment on MR with stop labels' end context 'when CI_MERGE_REQUEST_LABELS includes pipeline::tier-3' do before do stub_env('CI_MERGE_REQUEST_LABELS', 'pipeline::tier-3') end it_behaves_like 'does not comment on MR with stop labels' end end describe '#sast_stop_label_present?' do context 'when CI_MERGE_REQUEST_LABELS includes appsec-sast::stop' do it 'returns true' do stub_env('CI_MERGE_REQUEST_LABELS', 'appsec-sast::stop, other-label') expect(processor.send(:sast_stop_label_present?)).to be true end end context 'when CI_MERGE_REQUEST_LABELS does not include appsec-sast::stop' do it 'returns false' do stub_env('CI_MERGE_REQUEST_LABELS', 'another-label, different-label') expect(processor.send(:sast_stop_label_present?)).to be false end end context 'when CI_MERGE_REQUEST_LABELS is empty' do it 'returns false' do stub_env('CI_MERGE_REQUEST_LABELS', '') expect(processor.send(:sast_stop_label_present?)).to be false end end context 'when CI_MERGE_REQUEST_LABELS is nil' do it 'returns false' do stub_env('CI_MERGE_REQUEST_LABELS', nil) expect(processor.send(:sast_stop_label_present?)).to be false end end end describe '#pipeline_tier_three_label_present?' do context 'when CI_MERGE_REQUEST_LABELS includes pipeline::tier-3' do it 'returns true' do stub_env('CI_MERGE_REQUEST_LABELS', 'pipeline::tier-3, other-label') expect(processor.send(:pipeline_tier_three_label_present?)).to be true end end context 'when CI_MERGE_REQUEST_LABELS does not include pipeline::tier-3' do it 'returns false' do stub_env('CI_MERGE_REQUEST_LABELS', 'another-label, different-label') expect(processor.send(:pipeline_tier_three_label_present?)).to be false end end context 'when CI_MERGE_REQUEST_LABELS is empty' do it 'returns false' do stub_env('CI_MERGE_REQUEST_LABELS', '') expect(processor.send(:pipeline_tier_three_label_present?)).to be false end end context 'when CI_MERGE_REQUEST_LABELS is nil' do it 'returns false' do stub_env('CI_MERGE_REQUEST_LABELS', nil) expect(processor.send(:pipeline_tier_three_label_present?)).to be false end end end describe '#apply_label' do it 'returns' do successful_response = instance_double(Net::HTTPOK, code: '200', body: 'Successful response') http = instance_double(Net::HTTP) allow(Net::HTTP).to receive(:start).and_yield(http) allow(http).to receive(:request).and_return(successful_response) expect(processor.send(:apply_label)).to be_nil end it 'handles error response' do failed_response = instance_double(Net::HTTPBadRequest, code: '400', body: 'Bad Request') http = instance_double(Net::HTTP) allow(Net::HTTP).to receive(:start).and_yield(http) allow(http).to receive(:request).and_return(failed_response) allow(processor).to receive(:post_comment) expect do processor.send(:apply_label) end.to output(%r{Failed to apply labels with status code 400: Bad Request}) .to_stdout end end describe SemgrepResultProcessor do describe '#perform_allowlist_check' do let(:processor) { described_class.new } context 'when CI_PROJECT_DIR is not allowlisted' do before do stub_env('CI_PROJECT_DIR', '/tmp/not_allowlisted') # Set a valid API URL to isolate the test to just the project dir check stub_env('CI_API_V4_URL', 'https://gitlab.com/api/v4') end it 'exits with status code 1' do expect { processor.perform_allowlist_check }.to raise_error(SystemExit) do |error| expect(error.status).to eq(1) end end it 'outputs an error message' do expect do processor.perform_allowlist_check rescue SystemExit # Catch the exit to continue the test end.to output(%r{Error: CI_PROJECT_DIR '/tmp/not_allowlisted' is not allowed.}).to_stdout end end context 'when CI_PROJECT_DIR is allowlisted but CI_API_V4_URL is not' do before do # Set an allowed project dir stub_env('CI_PROJECT_DIR', '/builds/gitlab-org/gitlab') # Set a non-allowed API URL stub_env('CI_API_V4_URL', 'https://not-allowed-api.com') end it 'exits with status code 1' do expect { processor.perform_allowlist_check }.to raise_error(SystemExit) do |error| expect(error.status).to eq(1) end end it 'outputs an error message about the API URL' do expect do processor.perform_allowlist_check rescue SystemExit # Catch the exit to continue the test end.to output(%r{Error: CI_API_V4_URL 'https://not-allowed-api.com' is not allowed.}).to_stdout end end context 'when both CI_PROJECT_DIR and CI_API_V4_URL are allowlisted' do before do stub_env('CI_PROJECT_DIR', '/builds/gitlab-org/gitlab') stub_env('CI_API_V4_URL', 'https://gitlab.com/api/v4') end it 'completes successfully without exiting' do expect { processor.perform_allowlist_check }.not_to raise_error end end end end describe '#filter_duplicate_findings' do before do allow(processor).to receive(:get_existing_comments).and_return(existing_comments) end let(:existing_comments) do [ { "body" => " Some comment ", "author" => { "id" => 123 } }, { "body" => " Another comment ", "author" => { "id" => 123 } }, { "body" => " Yet another comment ", "author" => { "id" => 123 } } ] end let(:first_unique_rule_id) { described_class::UNIQUE_COMMENT_RULES_IDS.first } let(:new_run_findings) do { "abc123" => { path: "path/to/file1.rb", line: 10, message: "Duplicate finding 1", check_id: "RULE1" }, "def456" => { path: "path/to/file2.rb", line: 20, message: "Duplicate finding 2", check_id: "RULE2" }, "new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", check_id: "RULE3" }, "new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: "RULE4" } } end it 'filters out findings with fingerprints that are already in comments from the bot' do result = processor.filter_duplicate_findings(new_run_findings) expect(result).to eq({ "new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", check_id: "RULE3" }, "new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: "RULE4" } }) end it 'returns all findings if no comments from the bot exist' do allow(processor).to receive(:get_existing_comments).and_return([]) result = processor.filter_duplicate_findings(new_run_findings) expect(result).to eq(new_run_findings) end it 'returns an empty hash if all fingerprints are already in bot comments' do allow(processor).to receive(:get_existing_comments).and_return([ { "body" => " Some comment", "author" => { "id" => 123 } }, { "body" => " Another comment", "author" => { "id" => 123 } }, { "body" => " Yet another comment", "author" => { "id" => 123 } }, { "body" => " Another existing comment", "author" => { "id" => 123 } } ]) result = processor.filter_duplicate_findings(new_run_findings) expect(result).to eq({}) end it 'filters out findings with check_ids that are in the UNIQUE_COMMENT_RULES_IDS list' do new_run_findings["new789"] = { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: first_unique_rule_id } new_run_findings["new890"] = { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: first_unique_rule_id } result = processor.filter_duplicate_findings(new_run_findings) expect(result).to eq({ "new123" => { path: "path/to/file3.rb", line: 30, message: "New finding 1", check_id: "RULE3" }, "new456" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: "RULE4" }, "new789" => { path: "path/to/file4.rb", line: 40, message: "New finding 2", check_id: first_unique_rule_id } }) end end describe '#get_sast_results' do let(:sample_non_versioned_fingerprint) { "a5adf24a2512f31141f460e0bc18f39c8388105e564f" } let(:sample_message) { "This is a sample SAST finding message" } let(:scanned_path) { "ee/lib/ai/context/dependencies/config_files/python_pip.rb" } let(:check_id) { "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex" } let(:sample_data) do { "errors" => [], "interfile_languages_used" => [], "paths" => { "scanned" => [scanned_path], "skipped" => [ { "path" => "ee/spec/lib/ai/context/dependencies/config_files/python_pip_spec.rb", "reason" => "cli_exclude_flags_match" }, { "path" => "ee/spec/services/ai/repository_xray/scan_dependencies_service_spec.rb", "reason" => "cli_include_flags_do_not_match" } ] }, "results" => [ { "check_id" => check_id, "path" => scanned_path, "start" => { "line" => 9, "col" => 11, "offset" => 178 }, "end" => { "line" => 9, "col" => 93, "offset" => 260 }, "extra" => { "fingerprint" => "#{sample_non_versioned_fingerprint}_0", "message" => sample_message } }, { "check_id" => check_id, "path" => scanned_path, "start" => { "line" => 9, "col" => 32, "offset" => 199 }, "end" => { "line" => 9, "col" => 93, "offset" => 260 }, "extra" => { "fingerprint" => "#{sample_non_versioned_fingerprint}_1", "message" => sample_message } } ], "skipped_rules" => [], "version" => "1.93.0" } end before do allow(File).to receive(:read).and_call_original allow(File).to receive(:read).with(report_file).and_return(JSON.generate(sample_data)) # rubocop:disable Gitlab/Json -- Used only in CI scripts end it 'parses the SAST report and prints findings correctly' do expected_output = { sample_non_versioned_fingerprint => { check_id: "builds.sast-custom-rules.secure-coding-guidelines.ruby.glappsec_insecure-regex", path: "ee/lib/ai/context/dependencies/config_files/python_pip.rb", line: 9, message: sample_message } } result = processor.get_sast_results expect(result).to eq(expected_output) end it 'prints findings to the console' do expect do processor.get_sast_results end.to output( %r{Finding \(Fingerprint: #{sample_non_versioned_fingerprint}\) in #{scanned_path} at line 9: #{sample_message}} ).to_stdout end it 'exits when no findings are present' do empty_data = sample_data.merge("results" => []) allow(File).to receive(:read).with(report_file).and_return(JSON.generate(empty_data)) # rubocop:disable Gitlab/Json -- Used only in CI scripts expect { processor.get_sast_results }.to raise_error(SystemExit).and output(/No findings./).to_stdout end context 'when check_id is absent from SAST results' do let(:check_id) { nil } it 'returns an empty check_id' do expected_output = { sample_non_versioned_fingerprint => { check_id: check_id, path: "ee/lib/ai/context/dependencies/config_files/python_pip.rb", line: 9, message: sample_message } } result = processor.get_sast_results expect(result).to eq(expected_output) end end end describe '#create_inline_comments' do let(:processor) { described_class.new } let(:base_sha) { 'base_sha' } let(:head_sha) { 'head_sha' } let(:start_sha) { 'start_sha' } before do stub_env('CI_API_V4_URL', 'https://gitlab.example.com/api/v4') stub_env('CI_MERGE_REQUEST_PROJECT_ID', '123') stub_env('CI_MERGE_REQUEST_IID', '1') stub_env('CUSTOM_SAST_RULES_BOT_PAT', 'fake-token') allow(processor).to receive(:populate_commits_from_versions).and_return([base_sha, head_sha, start_sha]) # More robust mocking of HTTP operations allow(processor).to receive(:post_comment) end context 'with secure coding guidelines finding' do let(:scg_finding) do { 'fingerprint1' => { path: 'file1.rb', line: 10, message: 'Error message 1', check_id: 'builds.sast-custom-rules.secure-coding-guidelines.ruby' } } end it 'posts comment with SCG suffix and applies label' do # Create a successful HTTP response mock successful_response = instance_double(Net::HTTPCreated) allow(successful_response).to receive(:instance_of?).with(Net::HTTPCreated).and_return(true) allow(successful_response).to receive_messages(code: '201', body: 'Success') # Mock Net::HTTP to capture and validate what happens with our mocked services http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(successful_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) # Expect apply_label to be called expect(processor).to receive(:apply_label).once # Expect post_comment not to be called expect(processor).not_to receive(:post_comment) # Run the method processor.create_inline_comments(scg_finding) end end context 'with s1 finding' do let(:s1_finding) do { 'fingerprint2' => { path: 'file2.rb', line: 20, message: 'Error message 2', check_id: 'builds.sast-custom-rules.s1.rule' } } end it 'posts comment with S1 suffix and applies label' do # Create a successful HTTP response mock successful_response = instance_double(Net::HTTPCreated) allow(successful_response).to receive(:instance_of?).with(Net::HTTPCreated).and_return(true) allow(successful_response).to receive_messages(code: '201', body: 'Success') # Track the form data being sent form_data_captured = nil # Create a request double that can capture form data request_double = instance_double(Net::HTTP::Post) allow(request_double).to receive(:[]=) allow(request_double).to receive(:set_form_data) do |data| form_data_captured = data end # Mock Net::HTTP::Post.new to return our request double allow(Net::HTTP::Post).to receive(:new).and_return(request_double) # Mock HTTP to return successful response http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(successful_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) # Expect apply_label to be called expect(processor).to receive(:apply_label).once # Run the method processor.create_inline_comments(s1_finding) # Verify the message includes the S1 ping expect(form_data_captured["body"]).to include(described_class::MESSAGE_S1_PING_APPSEC) end end context 'with other finding type' do let(:other_finding) do { 'fingerprint3' => { path: 'file3.rb', line: 30, message: 'Error message 3', check_id: 'builds.sast-custom-rules.other' } } end it 'posts comment with default suffix and applies label' do # Create a successful HTTP response mock successful_response = instance_double(Net::HTTPCreated) allow(successful_response).to receive(:instance_of?).with(Net::HTTPCreated).and_return(true) allow(successful_response).to receive_messages(code: '201', body: 'Success') # Track the form data being sent form_data_captured = nil # Create a request double that can capture form data request_double = instance_double(Net::HTTP::Post) allow(request_double).to receive(:[]=) allow(request_double).to receive(:set_form_data) do |data| form_data_captured = data end # Mock Net::HTTP::Post.new to return our request double allow(Net::HTTP::Post).to receive(:new).and_return(request_double) # Mock HTTP to return successful response http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(successful_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) # Expect apply_label to be called expect(processor).to receive(:apply_label).once # Run the method processor.create_inline_comments(other_finding) # Manually verify the message - there's an issue with the code that needs fixing # This test will fail until that's addressed - the "" in the else clause is causing issues message_without_newlines = form_data_captured["body"].delete("\n") expect(message_without_newlines).to include(described_class::MESSAGE_PING_APPSEC.delete("\n")) end end context 'with failed HTTP response' do let(:finding) do { 'fingerprint4' => { path: 'file4.rb', line: 40, message: 'Error message 4', check_id: 'some.rule' } } end it 'falls back to post_comment when inline comment fails' do # Create a failed HTTP response mock failed_response = instance_double(Net::HTTPBadRequest) allow(failed_response).to receive(:instance_of?).with(Net::HTTPCreated).and_return(false) allow(failed_response).to receive_messages(code: '400', body: 'Bad Request') # Mock request request_double = instance_double(Net::HTTP::Post) allow(request_double).to receive(:[]=) allow(request_double).to receive(:set_form_data) allow(Net::HTTP::Post).to receive(:new).and_return(request_double) # Mock HTTP to return failed response http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(failed_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) # Expect post_comment to be called expect(processor).to receive(:post_comment).once # Output should include error message expect do processor.create_inline_comments(finding) end.to output(/Failed to post inline comment with status code 400/).to_stdout end end context 'with multiple findings' do let(:mixed_findings) do { 'fingerprint1' => { path: 'file1.rb', line: 10, message: 'Error message 1', check_id: 'builds.sast-custom-rules.secure-coding-guidelines.ruby' }, 'fingerprint2' => { path: 'file2.rb', line: 20, message: 'Error message 2', check_id: 'builds.sast-custom-rules.s1.rule' } } end it 'processes all findings correctly' do # Create response doubles successful_response = instance_double(Net::HTTPCreated) allow(successful_response).to receive(:instance_of?).with(Net::HTTPCreated).and_return(true) allow(successful_response).to receive_messages(code: '201', body: 'Success') failed_response = instance_double(Net::HTTPBadRequest) allow(failed_response).to receive(:instance_of?).with(Net::HTTPCreated).and_return(false) allow(failed_response).to receive_messages(code: '400', body: 'Bad Request') # Mock request request_double = instance_double(Net::HTTP::Post) allow(request_double).to receive(:[]=) allow(request_double).to receive(:set_form_data) allow(Net::HTTP::Post).to receive(:new).and_return(request_double) # Track call count to return different responses call_count = 0 http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request) do call_count += 1 call_count == 1 ? successful_response : failed_response end allow(Net::HTTP).to receive(:start).and_yield(http_double) # Apply label should be called once expect(processor).to receive(:apply_label).once # Post_comment should be called once expect(processor).to receive(:post_comment).once # Run the method and check output expect do processor.create_inline_comments(mixed_findings) end.to output(/Failed to post inline comment with status code 400/).to_stdout end end end describe '#get_existing_comments' do before do stub_env('CI_API_V4_URL', 'https://gitlab.example.com/api/v4') stub_env('CI_MERGE_REQUEST_PROJECT_ID', '123') stub_env('CI_MERGE_REQUEST_IID', '1') stub_env('CUSTOM_SAST_RULES_BOT_PAT', 'fake-token') end around do |example| example.run rescue SystemExit # Catch SystemExit end context 'when API request is successful' do it 'returns parsed JSON response' do http_response = instance_double(Net::HTTPOK) allow(http_response).to receive(:instance_of?).with(Net::HTTPOK).and_return(true) allow(http_response).to receive(:body).and_return('[{"id": 1, "body": "Comment"}]') http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(http_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) result = processor.send(:get_existing_comments) expect(result).to eq([{ "id" => 1, "body" => "Comment" }]) end end context 'when API request fails' do it 'outputs error message, posts comment, and exits' do failed_response = instance_double(Net::HTTPBadRequest) allow(failed_response).to receive(:instance_of?).with(Net::HTTPOK).and_return(false) allow(failed_response).to receive_messages(code: '400', body: 'Bad Request') http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(failed_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) # Expect post_comment to be called with specific message expect(processor).to receive(:post_comment).with(/Failed to fetch comments: Bad Request/) # Check for console output expect do # This will raise SystemExit which is caught by the around block processor.send(:get_existing_comments) end.to output(/Failed to fetch comments with status code 400/).to_stdout # Verify the exit code by capturing the raised exception begin processor.send(:get_existing_comments) rescue SystemExit => e expect(e.status).to eq(0) end end end end describe '#populate_commits_from_versions' do before do stub_env('CI_API_V4_URL', 'https://gitlab.example.com/api/v4') stub_env('CI_MERGE_REQUEST_PROJECT_ID', '123') stub_env('CI_MERGE_REQUEST_IID', '1') stub_env('CUSTOM_SAST_RULES_BOT_PAT', 'fake-token') end around do |example| example.run rescue SystemExit # Catch SystemExit end context 'when API request is successful' do it 'returns the three SHA values' do # Create mock response with sample data sample_response = [ { 'base_commit_sha' => 'abc123base', 'head_commit_sha' => 'def456head', 'start_commit_sha' => 'ghi789start' } ].to_json # Mock the HTTP response http_response = instance_double(Net::HTTPOK) allow(http_response).to receive(:instance_of?).with(Net::HTTPOK).and_return(true) allow(http_response).to receive(:body).and_return(sample_response) # Mock the HTTP request cycle http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(http_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) # Call the method and verify the result result = processor.send(:populate_commits_from_versions) # Verify each SHA is correctly extracted and returned expect(result).to be_an(Array) expect(result.size).to eq(3) expect(result[0]).to eq('abc123base') # base_sha expect(result[1]).to eq('def456head') # head_sha expect(result[2]).to eq('ghi789start') # start_sha end end context 'when API request fails' do it 'outputs error message, posts comment, and exits' do # Mock a failed HTTP response failed_response = instance_double(Net::HTTPBadRequest) allow(failed_response).to receive(:instance_of?).with(Net::HTTPOK).and_return(false) allow(failed_response).to receive_messages(code: '400', body: 'Bad Request') # Mock the HTTP request cycle http_double = instance_double(Net::HTTP) allow(http_double).to receive(:request).and_return(failed_response) allow(Net::HTTP).to receive(:start).and_yield(http_double) # Expect post_comment to be called with specific message expect(processor).to receive(:post_comment).with(/Failed to fetch versions: Bad Request/) # Check for console output expect do # This will raise SystemExit which is caught by the around block processor.send(:populate_commits_from_versions) end.to output(/Failed to fetch versions with status code 400/).to_stdout # Verify the exit code by capturing the raised exception begin processor.send(:populate_commits_from_versions) rescue SystemExit => e expect(e.status).to eq(0) end end end end describe '#post_comment' do around do |example| example.run rescue SystemExit end it 'handles error response' do allow(Net::HTTP).to receive(:post).and_return(Net::HTTPBadRequest.new(nil, 400, 'Bad Request')) expect { processor.send(:post_comment, 'Example comment') }.to raise_error(SystemExit) end end end