├── app ├── helpers │ └── contrast_helper.rb ├── views │ ├── contrast │ │ └── vote.html.erb │ └── settings │ │ └── _contrast_settings.html.erb └── controllers │ └── contrast_controller.rb ├── test ├── test_helper.rb └── functional │ └── contrast_controller_test.rb ├── config ├── routes.rb └── locales │ ├── ja.yml │ └── en.yml ├── LICENSE ├── init.rb ├── lib ├── journals_controller_patch.rb ├── journals_helper_patch.rb ├── settings_controller_patch.rb ├── contrast_payload_parser.rb ├── issues_controller_patch.rb ├── contrast_util.rb └── issue_hooks.rb ├── README.md └── db └── migrate └── 001_plugin_settings.rb /app/helpers/contrast_helper.rb: -------------------------------------------------------------------------------- 1 | module ContrastHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/contrast/vote.html.erb: -------------------------------------------------------------------------------- 1 |

ContrastController#vote

2 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the Redmine helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') 3 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # Plugin's routes 2 | # See: http://guides.rubyonrails.org/routing.html 3 | post 'contrast/vote', :to => 'contrast#vote' 4 | -------------------------------------------------------------------------------- /test/functional/contrast_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class ContrastControllerTest < ActionController::TestCase 4 | # Replace this with your real tests. 5 | def test_truth 6 | assert true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Contrast Security OSS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | Redmine::Plugin.register :contrastsecurity do 23 | name 'Contrast Redmine Plugin' 24 | author 'Taka Shiozaki' 25 | description 'This is a Contrast plugin for Redmine' 26 | version '1.2.1' 27 | url 'https://github.com/Contrast-Security-OSS/contrast-redmine-plugin' 28 | author_url 'https://github.com/Contrast-Security-OSS' 29 | settings :default => { 30 | 'vul_issues' => true, 31 | 'lib_issues' => true, 32 | 'vul_seen_dt_format' => '%Y/%m/%d %H:%M', 33 | 'sts_reported' => '報告済', 34 | 'sts_suspicious' => '疑わしい', 35 | 'sts_confirmed' => '確認済', 36 | 'sts_notaproblem' => '問題無し', 37 | 'sts_remediated' => '修復済', 38 | 'sts_fixed' => '修正完了', 39 | 'pri_critical' => '最高', 40 | 'pri_high' => '高', 41 | 'pri_medium' => '中', 42 | 'pri_low' => '低', 43 | 'pri_note' => '最低', 44 | 'pri_cvelib' => '高' 45 | }, :partial => 'settings/contrast_settings' 46 | require 'issue_hooks' 47 | end 48 | 49 | Rails.configuration.to_prepare do 50 | unless IssuesController.included_modules.include?(IssuesControllerPatch) 51 | IssuesController.send :include, IssuesControllerPatch 52 | end 53 | unless JournalsController.included_modules.include?(JournalsControllerPatch) 54 | JournalsController.send :include, JournalsControllerPatch 55 | end 56 | unless SettingsController.included_modules.include?(SettingsControllerPatch) 57 | SettingsController.send :include, SettingsControllerPatch 58 | end 59 | unless JournalsHelper.included_modules.include?(JournalsHelperPatch) 60 | JournalsHelper.send :include, JournalsHelperPatch 61 | end 62 | end 63 | 64 | -------------------------------------------------------------------------------- /lib/journals_controller_patch.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | module JournalsControllerPatch 23 | def self.included(base) 24 | base.send(:include, InstanceMethods) 25 | base.class_eval do 26 | unloadable 27 | alias_method_chain :update, :sync 28 | end 29 | end 30 | 31 | module InstanceMethods 32 | def update_with_sync 33 | notes = @journal.notes 34 | @journal.safe_attributes = params[:journal] 35 | if @journal.notes.blank? # コメントが削除された場合 36 | issue = @journal.journalized 37 | cv_org = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.org_id')}).first 38 | cv_app = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.app_id')}).first 39 | cv_vul = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.vul_id')}).first 40 | org_id = cv_org.try(:value) 41 | app_id = cv_app.try(:value) 42 | vul_id = cv_vul.try(:value) 43 | if org_id.nil? || org_id.empty? || app_id.nil? || app_id.empty? || vul_id.nil? || vul_id.empty? 44 | update = update_without_sync 45 | return update 46 | end 47 | note_id = nil 48 | @journal.details.each do |detail| 49 | if detail.prop_key == "contrast_note_id" 50 | note_id = detail.value 51 | end 52 | end 53 | if note_id.blank? 54 | update = update_without_sync 55 | return update 56 | end 57 | teamserver_url = Setting.plugin_contrastsecurity['teamserver_url'] 58 | url = sprintf('%s/api/ng/%s/applications/%s/traces/%s/notes/%s?expand=skip_links', teamserver_url, org_id, app_id, vul_id, note_id) 59 | res, msg = ContrastUtil.callAPI(url: url, method: "DELETE") 60 | if res.present? && res.code == "200" 61 | @journal.details = [] 62 | end 63 | end 64 | update = update_without_sync 65 | return update 66 | end 67 | end 68 | end 69 | 70 | -------------------------------------------------------------------------------- /lib/journals_helper_patch.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | module JournalsHelperPatch 23 | def self.included(base) 24 | base.send(:include, InstanceMethods) 25 | base.class_eval do 26 | unloadable 27 | alias_method_chain :render_notes, :modify 28 | end 29 | end 30 | 31 | module InstanceMethods 32 | def render_notes_with_modify(issue, journal, options={}) 33 | render_notes = render_notes_without_modify(issue, journal, options) 34 | last_updater_uid = nil 35 | last_updater = nil 36 | journal.details.each do |detail| 37 | if detail.prop_key == "contrast_last_updater_uid" 38 | last_updater_uid = detail.value 39 | elsif detail.prop_key == "contrast_last_updater" 40 | last_updater = detail.value 41 | end 42 | end 43 | if last_updater_uid.blank? 44 | # おそらくContrastと無関係なチケット 45 | return render_notes 46 | end 47 | username = Setting.plugin_contrastsecurity['username'] 48 | if last_updater_uid != username 49 | upd_tag = link_to(l(:button_edit), 50 | edit_journal_path(journal), 51 | :remote => true, 52 | :method => 'get', 53 | :title => l(:button_edit), 54 | :class => 'icon-only icon-edit' 55 | ).html_safe 56 | del_tag = link_to(l(:button_delete), 57 | journal_path(journal, :journal => {:notes => ""}), 58 | :remote => true, 59 | :method => 'put', :data => {:confirm => l(:text_are_you_sure)}, 60 | :title => l(:button_delete), 61 | :class => 'icon-only icon-del' 62 | ).html_safe 63 | return render_notes.gsub(upd_tag, "").gsub(del_tag, "").gsub("

", "

by " + last_updater + "").html_safe 64 | else 65 | exist_creator_pattern = /(\(by .+\))<\/p>/ 66 | is_exist_creator = render_notes.match(exist_creator_pattern) 67 | if is_exist_creator 68 | by_creator = is_exist_creator[1].gsub(/\(|\)/, "") 69 | return render_notes.gsub(is_exist_creator[1], "").gsub("

", "

" + by_creator + "").html_safe 70 | end 71 | end 72 | return render_notes 73 | end 74 | end 75 | end 76 | 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Redmine ContrastSecurity Plugin 2 | 3 | ### 概要 4 | - Contrast TeamServerからのWebhookを受信して、Redmineにチケットを作成することができます。 5 | 脆弱性、CVEを含むライブラリの情報がRedmineに共有されます。 6 | - Redmineのチケットのステータスを変更すると、Contrast TeamServerに連携することができます。 7 | - Contrast TeamServerで脆弱性のステータスが変更されると、これもWebhookでRedmineに通知され 8 | Redmineのチケットのステータスも合わせて更新されます。 9 | - コメントについても相互に共有されます。ステータス変更時のコメントも同様です。 10 | - Redmineのチケットの詳細を表示する際に、Contrast TeamServerの以下の情報が自動で同期されます。 11 | - 最終検出日時 12 | - 深刻度(深刻度に応じて、Redmineのチケットの優先度が同期されます) 13 | 14 | ### 前提条件 15 | - TeamServerは3.8.3, 3.8.4, 3.8.5で動作確認済みです。 16 | - Redmineは3.4.6、4.2.2で動作確認済みです。 17 | 18 | ### 導入手順 19 | 1. [Release](https://github.com/Contrast-Security-OSS/contrast-redmine-plugin/releases) から最新リリースの**Source code**のzipまたはtar.gzファイルをダウンロードします。 20 | Redmine3系と4系でダウンロードするバイナリが異なります。適したリリースバージョンからダウンロードしてください。 21 | 2. ContrastSecurityプラグインを配置 22 | Redmineのドキュメントルートが```/var/www/redmine```と想定します。 23 | ダウンローしたファイルは、*contrast-redmine-plugin-x.x.x*のディレクトリ名で解凍されます。 24 | 解凍されたディレクトリをRedmineのプラグインディレクトリに配置します。 25 | 配置する際は```contrastsecurity```というディレクトリ名としてください。 26 | ```bash 27 | mv contrast-redmine-plugin-x.x.x /var/www/redmine/plugins/contrastsecurity 28 | ``` 29 | 3. マイグレート 30 | ```bash 31 | # インストール時 32 | bundle exec rake redmine:plugins:migrate 33 | # アンインストール時は必要に応じて以下のコマンドを実行してください。 34 | bundle exec rake redmine:plugins:migrate VERSION=0 NAME=contrastsecurity 35 | ``` 36 | Contrastプラグインのモデルを新たに作るわけではなく、デフォルトの選択肢(ステータス、優先度)、トラッカー、カスタムフィールドを作成します。 37 | 38 | 4. ContrastSecurityプラグインを有効にします。 39 | Redmineを再起動してください。 40 | 41 | 5. RedmineのRestAPIを有効にする 42 | 管理 -> 設定 -> APIで、「RESTによるWebサービスを有効にする」にチェックを入れる。 43 | 44 | 6. TeamServerからwebhookを受けるユーザーのAPITokenを確認 45 | 個人設定 -> APIアクセスキーを表示で、APIトークンが表示されます。 46 | 47 | 7. TeamServerのGeneric Webhookを設定 48 | - Name: ```Redmine(好きな名前)``` 49 | - URL: ```http://[REDMINE_HOST]:[REDMINE_PORT]/redmine/contrast/vote?key=[API_TOKEN]``` 50 | サブディレクトリを使っていない場合は 51 | ```http://[REDMINE_HOST]:[REDMINE_PORT]/contrast/vote?key=[API_TOKEN]``` 52 | - Applications: ```任意``` 53 | - Payload: 54 | ```json 55 | { 56 | "summary":"$Title", 57 | "description":"$Message", 58 | "project": "petclinic", 59 | "tracker": "脆弱性", 60 | "application_name": "$ApplicationName", 61 | "application_code": "$ApplicationCode", 62 | "vulnerability_tags": "$VulnerabilityTags", 63 | "application_id": "$ApplicationId", 64 | "server_name": "$ServerName", 65 | "server_id": "$ServerId", 66 | "organization_id": "$OrganizationId", 67 | "severity": "$Severity", 68 | "status": "$Status", 69 | "vulnerability_id": "$TraceId", 70 | "vulnerability_rule": "$VulnerabilityRule", 71 | "environment": "$Environment", 72 | "event_type": "$EventType" 73 | } 74 | ``` 75 | project, trackerは連携するRedmine側の内容と合わせてください。 76 | - プロジェクトには名称ではなく識別子を設定してください。 77 | - 存在しないプロジェクト名が指定されていると、チケット作成時にエラーとなり、Webhook自体が無効となります。 78 | その場合は、Payloadを修正のうえ接続テストからWebhookの保存をやりなおす必要があります。 79 | - トラッカーも適切に設定してください。 80 | - Send as HTML: ```チェックは外してください``` 81 | 82 | 設定後、**テスト接続**を行って、保存してください。 83 | 84 | 8. Generic Webhookの設定後、Notfication(ベルマーク)を行ってください。 85 | Libraryも対象にすることでCVEを含むライブラリの情報もRedmineに連携されます。 86 | 「ステータス変更時に通知を受信」にチェックを入れることで、TeamServerのステータス変更についてもRedmine側に通知されます。(双方向同期) 87 | 88 | 9. プラグインの設定(確認)を行います。 89 | - TeamServer 接続設定 90 | Contrast TeamServerのアカウントメニュー「あなたのアカウント」に必要な情報が揃ってるので、そこからコピーしてください。 91 | - チケット作成対象 92 | チケットを作成する対象にチェックを入れてください。 93 | - ステータスマッピング 94 | Contrast TeamServer側のステータスとRedmine側のステータスの紐付けを設定します。 95 | - 優先度マッピング 96 | Contrast TeamServer側の脆弱性の**深刻度**とRedmine側の**優先度**の紐付けを設定します。 97 | -------------------------------------------------------------------------------- /config/locales/ja.yml: -------------------------------------------------------------------------------- 1 | # Japanese strings go here for Rails i18n 2 | ja: 3 | contrast_custom_fields: 4 | org_id: "【Contrast】組織ID" 5 | app_id: "【Contrast】アプリID" 6 | vul_id: "【Contrast】脆弱性ID" 7 | lib_id: "【Contrast】ライブラリID" 8 | lib_lang: "【Contrast】ライブラリ言語" 9 | first_seen: "【Contrast】最初の検出" 10 | last_seen: "【Contrast】最後の検出" 11 | severity: "【Contrast】深刻度" 12 | confidence: "【Contrast】信頼性" 13 | module: "【Contrast】モジュール" 14 | server: "【Contrast】サーバ" 15 | category: "【Contrast】カテゴリ" 16 | rule: "【Contrast】ルール名" 17 | test_connect_fail: "テスト接続に失敗しました。TeamServer 接続設定を再度ご確認ください。" 18 | status_settings_fail: "ステータス設定が正しくありません。" 19 | priority_settings_fail: "優先度設定が正しくありません。" 20 | config_section_connect: "TeamServer 接続設定(情報はTeamServerのYour Accountで確認できます)" 21 | config_section_import: "チケット作成対象" 22 | config_section_status: "ステータスマッピング" 23 | teamserver_url: "Contrast URL" 24 | org_id: "組織ID" 25 | api_key: "APIキー" 26 | username: "ユーザー名" 27 | username_hint: "TeamServerのUIから操作を行わないAPI専用のユーザーを指定してください。" 28 | service_key: "サービスキー" 29 | auth_header: "認証ヘッダー" 30 | config_section_proxy: "プロキシ設定" 31 | proxy_host: "ホスト" 32 | proxy_port: "ポート" 33 | proxy_auth: "認証あり" 34 | proxy_userpass: "ユーザー/パスワード" 35 | vul_issues: "脆弱性" 36 | lib_issues: "ライブラリ" 37 | teamserver_side: "TeamServer側" 38 | redmine_side: "Redmine側" 39 | sts_reported: "報告済" 40 | sts_reported_ph: "例)報告" 41 | sts_suspicious: "疑わしい" 42 | sts_suspicious_ph: "例)保留" 43 | sts_confirmed: "確認済" 44 | sts_confirmed_ph: "例)確認済" 45 | sts_notaproblem: "問題無し" 46 | sts_notaproblem_ph: "例)無視" 47 | sts_remediated: "修復済" 48 | sts_remediated_ph: "例)修正済" 49 | sts_fixed: "修正完了" 50 | sts_fixed_ph: "例)完了" 51 | report_vul_happened: "何が起こったか?" 52 | report_vul_overview: "どんなリスクであるか?" 53 | report_vul_howtofix: "修正方法" 54 | report_vul_url: "脆弱性URL" 55 | report_lib_curver: "現在バージョン" 56 | report_lib_newver: "最新バージョン" 57 | report_lib_class: "クラス(使用/全体)" 58 | report_lib_cves: "脆弱性" 59 | report_lib_url: "ライブラリURL" 60 | config_section_priority: "優先度マッピング" 61 | pri_critical: "重大" 62 | pri_high: "高" 63 | pri_medium: "中" 64 | pri_low: "低" 65 | pri_note: "注意" 66 | pri_cvelib: "脆弱なライブラリ" 67 | pri_for_cvelib_ph: "脆弱なライブラリはこの優先度が設定されます。" 68 | vuln_does_not_exist: "この脆弱性はTeamServerに存在しません。" 69 | lib_does_not_exist: "このライブラリはTeamServerに存在しません。" 70 | sync_comment_failure: "コメントの同期に失敗しました。" 71 | event_new_vulnerability: "[Contrast plugin] 新しい脆弱性を受信" 72 | event_dup_vulnerability: "[Contrast plugin] 既知の脆弱性を受信" 73 | event_vulnerability_changestatus: "[Contrast plugin] 脆弱性のステータス変更を受信" 74 | event_new_vulnerable_library: "[Contrast plugin] 新しい脆弱性ライブラリを受信" 75 | event_new_vulnerability_comment: "[Contrast plugin] 新しいコメントを受信" 76 | problem_with_hook_description: "[Contrast plugin] TeamServerからのペイロードの内容に問題があります。" 77 | problem_with_priority: "[Contrast plugin] プラグインの優先度マッピング設定に問題があるようです。" 78 | problem_with_status: "[Contrast plugin] プラグインのステータスマッピング設定に問題があるようです。" 79 | problem_with_customfield: "[Contrast plugin] カスタムフィールドの値に問題があるようです。" 80 | issue_create_success: "[Contrast plugin] チケットを作成しました。" 81 | issue_create_failure: "[Contrast plugin] チケットの作成に失敗しました。" 82 | issue_update_success: "[Contrast plugin] チケットを更新しました。" 83 | issue_update_failure: "[Contrast plugin] チケットの更新に失敗しました。" 84 | issue_status_change_success: "[Contrast plugin] チケットのステータスを変更しました。" 85 | issue_status_change_failure: "[Contrast plugin] チケットのステータスの変更に失敗しました。" 86 | journal_create_success: "[Contrast plugin] 履歴を追加しました。" 87 | journal_create_failure: "[Contrast plugin] 履歴の追加に失敗しました。" 88 | config_section_other: "その他" 89 | vul_seen_datetime_format: "検出日時フォーマット" 90 | problem_with_vul_seen_dt_format: "[Contrast plugin] 脆弱性の検出日時のフォーマット指定に問題があるようです。" 91 | status_changed_comment: "ステータス を %{old} から %{new} に変更" 92 | notaproblem_reason: "問題無しへの変更理由: %{reason}" 93 | 94 | -------------------------------------------------------------------------------- /db/migrate/001_plugin_settings.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | class PluginSettings < ActiveRecord::Migration 23 | 24 | CUSTOM_FIELD_NAMES = [ 25 | '【Contrast】ルール名', 26 | '【Contrast】カテゴリ', 27 | '【Contrast】サーバ', 28 | '【Contrast】モジュール', 29 | '【Contrast】信頼性', 30 | '【Contrast】深刻度', 31 | '【Contrast】最後の検出', 32 | '【Contrast】最初の検出', 33 | '【Contrast】ライブラリ言語', 34 | '【Contrast】ライブラリID', 35 | '【Contrast】脆弱性ID', 36 | '【Contrast】アプリID', 37 | '【Contrast】組織ID' 38 | ] 39 | STATUS_NAMES = ['報告済', '疑わしい', '確認済', '問題無し', '修復済', '修正完了'] 40 | PRIORITY_NAMES = ['最低', '低', '中', '高', '最高'] 41 | 42 | def self.up 43 | puts "Status create..." 44 | STATUS_NAMES.each do |name| 45 | if not IssueStatus.exists?(name: name) 46 | IssueStatus.new(name: name, is_closed: false).save 47 | end 48 | end 49 | 50 | puts "Priority create..." 51 | PRIORITY_NAMES.each do |name| 52 | if not IssuePriority.exists?(name: name) 53 | IssuePriority.new(name: name, is_default: false, active: true).save 54 | end 55 | end 56 | 57 | puts "Tracker create..." 58 | if not Tracker.exists?(name: '脆弱性') 59 | tracker = Tracker.new(name: '脆弱性') 60 | tracker.default_status = IssueStatus.find_by_name('報告済') 61 | tracker.save 62 | end 63 | 64 | puts "CustomField create..." 65 | tracker = Tracker.find_by_name('脆弱性') 66 | CUSTOM_FIELD_NAMES.each do |name| 67 | if not IssueCustomField.exists?(name: name) 68 | custom_field = IssueCustomField.new(name: name) 69 | custom_field.position = 1 70 | custom_field.visible = true 71 | custom_field.is_required = false 72 | custom_field.is_filter = false 73 | custom_field.searchable = false 74 | custom_field.field_format = 'string' 75 | custom_field.trackers << tracker 76 | custom_field.save 77 | end 78 | end 79 | end 80 | 81 | def self.down 82 | puts "CustomField destroy..." 83 | CUSTOM_FIELD_NAMES.each do |name| 84 | if IssueCustomField.exists?(name: name) 85 | IssueCustomField.find_by_name(name).destroy 86 | end 87 | end 88 | 89 | puts "Tracker destroy..." 90 | if Tracker.exists?(name: '脆弱性') 91 | Tracker.find_by_name('脆弱性').destroy 92 | end 93 | 94 | puts "Priority destroy..." 95 | PRIORITY_NAMES.each do |name| 96 | if IssuePriority.exists?(name: name) 97 | IssuePriority.find_by_name(name).destroy 98 | end 99 | end 100 | 101 | puts "Status destroy..." 102 | STATUS_NAMES.each do |name| 103 | if IssueStatus.exists?(name: name) 104 | IssueStatus.find_by_name(name).destroy 105 | end 106 | end 107 | end 108 | end 109 | 110 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | contrast_custom_fields: 4 | org_id: "【Contrast】組織ID" 5 | app_id: "【Contrast】アプリID" 6 | vul_id: "【Contrast】脆弱性ID" 7 | lib_id: "【Contrast】ライブラリID" 8 | lib_lang: "【Contrast】ライブラリ言語" 9 | first_seen: "【Contrast】最初の検出" 10 | last_seen: "【Contrast】最後の検出" 11 | severity: "【Contrast】深刻度" 12 | confidence: "【Contrast】信頼性" 13 | module: "【Contrast】モジュール" 14 | server: "【Contrast】サーバ" 15 | category: "【Contrast】カテゴリ" 16 | rule: "【Contrast】ルール名" 17 | test_connect_fail: "Test connection failed. Please confirm the TeamServer Connect settings." 18 | status_settings_fail: "Status settings are incorrect." 19 | priority_settings_fail: "Priority settings are incorrect." 20 | config_section_connect: "TeamServer Connect(You can get this information with Your Account in TeamServer.)" 21 | config_section_import: "Issue Target" 22 | config_section_status: "Status Mapping" 23 | teamserver_url: "Contrast URL" 24 | org_id: "Organization ID" 25 | api_key: "API Key" 26 | username: "Username" 27 | username_hint: "Please specify a user who only uses the API." 28 | service_key: "Service Key" 29 | auth_header: "Authorization Header" 30 | config_section_proxy: "Proxy Settings" 31 | proxy_host: "Host" 32 | proxy_port: "Port" 33 | proxy_auth: "Authentication" 34 | proxy_userpass: "User/Password" 35 | vul_issues: "Vulnerabilities" 36 | lib_issues: "Libraries" 37 | teamserver_side: "TeamServer" 38 | redmine_side: "Redmine" 39 | sts_reported: "Reported" 40 | sts_reported_ph: "e.g. New" 41 | sts_suspicious: "Suspicious" 42 | sts_suspicious_ph: "e.g. Hold" 43 | sts_confirmed: "Confirmed" 44 | sts_confirmed_ph: "e.g. Confirm" 45 | sts_notaproblem: "Not a Problem" 46 | sts_notaproblem_ph: "e.g. NoProblem" 47 | sts_remediated: "Remediated" 48 | sts_remediated_ph: "e.g. Fixed" 49 | sts_fixed: "Fixed" 50 | sts_fixed_ph: "e.g. Done" 51 | report_vul_happened: "What happened?" 52 | report_vul_overview: "What's the risk?" 53 | report_vul_howtofix: "How to fix" 54 | report_vul_url: "Vul URL" 55 | report_lib_curver: "Version" 56 | report_lib_newver: "Latest" 57 | report_lib_class: "Used/Total Classes" 58 | report_lib_cves: "CVEs" 59 | report_lib_url: "Lib URL" 60 | config_section_priority: "Priority Mapping" 61 | pri_critical: "Critical" 62 | pri_high: "High" 63 | pri_medium: "Medium" 64 | pri_low: "Low" 65 | pri_note: "Note" 66 | pri_cvelib: "Vulnerable library" 67 | pri_for_cvelib_ph: "Vulnerable libraries will use this priority." 68 | vuln_does_not_exist: "This vulnerability does not exist on TeamServer." 69 | lib_does_not_exist: "This library does not exist on TeamServer." 70 | sync_comment_failure: "Comment synchronization failed." 71 | event_new_vulnerability: "[Contrast plugin] NEW VULNERABILITY" 72 | event_dup_vulnerability: "[Contrast plugin] DUPLICATE VULNERABILITY" 73 | event_vulnerability_changestatus: "[Contrast plugin] VULNERABILITY CHANGESTATUS" 74 | event_new_vulnerable_library: "[Contrast plugin] NEW VULNERABLE LIBRARY" 75 | event_new_vulnerability_comment: "[Contrast plugin] NEW VULNERABILITY COMMENT" 76 | problem_with_hook_description: "[Contrast plugin] There is a problem with the payload from TeamServer." 77 | problem_with_priority: "[Contrast plugin] There seems to be a problem with the plugin Priority Mapping settings." 78 | problem_with_status: "[Contrast plugin] There seems to be a problem with the plugin Status Mapping settings." 79 | problem_with_customfield: "[Contrast plugin] There seems to be a problem with the Custom Field." 80 | issue_create_success: "[Contrast plugin] Issue has been reported." 81 | issue_create_failure: "[Contrast plugin] Issue creation failed." 82 | issue_update_success: "[Contrast plugin] Issue has been updated." 83 | issue_update_failure: "[Contrast plugin] Issue update failed." 84 | issue_status_change_success: "[Contrast plugin] Issue status has been updated." 85 | issue_status_change_failure: "[Contrast plugin] Issue status updating failed." 86 | journal_create_success: "[Contrast plugin] Journal has been added." 87 | journal_create_failure: "[Contrast plugin] Journal adding failed." 88 | config_section_other: "Other" 89 | vul_seen_datetime_format: "Vul detection datetime format" 90 | problem_with_vul_seen_dt_format: "[Contrast plugin] There seems to be a problem with the Vul detection datetime format settings." 91 | status_changed_comment: "Status changed from %{old} to %{new}" 92 | notaproblem_reason: "Reason for not a problems: %{reason}" 93 | 94 | -------------------------------------------------------------------------------- /lib/settings_controller_patch.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | module SettingsControllerPatch 23 | def self.included(base) 24 | base.send(:include, InstanceMethods) 25 | base.class_eval do 26 | unloadable 27 | alias_method_chain :plugin, :validate 28 | end 29 | end 30 | 31 | module InstanceMethods 32 | def plugin_with_validate 33 | @plugin = Redmine::Plugin.find(params[:id]) 34 | if @plugin.name != "Contrast Redmine Plugin" 35 | plugin = plugin_without_validate 36 | return plugin 37 | end 38 | 39 | if request.post? 40 | setting = params[:settings] ? params[:settings].permit!.to_h : {} 41 | #Setting.send "plugin_#{@plugin.id}=", setting 42 | teamserver_url = setting['teamserver_url'] 43 | org_id = setting['org_id'] 44 | api_key = setting['api_key'] 45 | username = setting['username'] 46 | service_key = setting['service_key'] 47 | proxy_host = setting['proxy_host'] 48 | proxy_port = setting['proxy_port'] 49 | proxy_user = setting['proxy_user'] 50 | proxy_pass = setting['proxy_pass'] 51 | if teamserver_url.empty? || org_id.empty? || api_key.empty? || username.empty? || service_key.empty? 52 | flash[:error] = l(:test_connect_fail) 53 | redirect_to plugin_settings_path(@plugin) and return 54 | end 55 | url = sprintf('%s/api/ng/%s/applications/', teamserver_url, org_id) 56 | res, msg = ContrastUtil.callAPI( 57 | url: url, api_key: api_key, username: username, service_key: service_key, 58 | proxy_host: proxy_host, proxy_port: proxy_port, proxy_user: proxy_user, proxy_pass: proxy_pass 59 | ) 60 | if res.nil? 61 | flash[:error] = msg 62 | redirect_to plugin_settings_path(@plugin) and return 63 | else 64 | if res.code != "200" 65 | flash[:error] = l(:test_connect_fail) 66 | redirect_to plugin_settings_path(@plugin) and return 67 | end 68 | end 69 | # ステータスマッピングチェック 70 | statuses = [] 71 | statuses << setting['sts_reported'] 72 | statuses << setting['sts_suspicious'] 73 | statuses << setting['sts_confirmed'] 74 | statuses << setting['sts_notaproblem'] 75 | statuses << setting['sts_remediated'] 76 | statuses << setting['sts_fixed'] 77 | statuses.each do |status| 78 | status_obj = IssueStatus.find_by_name(status) 79 | if status_obj.nil? 80 | flash[:error] = l(:status_settings_fail) 81 | redirect_to plugin_settings_path(@plugin) and return 82 | end 83 | end 84 | # 優先度マッピングチェック 85 | priorities = [] 86 | priorities << setting['pri_critical'] 87 | priorities << setting['pri_high'] 88 | priorities << setting['pri_medium'] 89 | priorities << setting['pri_low'] 90 | priorities << setting['pri_note'] 91 | priorities << setting['pri_cvelib'] 92 | priorities.each do |priority| 93 | priority_obj = IssuePriority.find_by_name(priority) 94 | if priority_obj.nil? 95 | flash[:error] = l(:priority_settings_fail) 96 | redirect_to plugin_settings_path(@plugin) and return 97 | end 98 | end 99 | end 100 | plugin = plugin_without_validate 101 | return plugin 102 | end 103 | end 104 | end 105 | 106 | -------------------------------------------------------------------------------- /lib/contrast_payload_parser.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | # Contrast webhook's payload parser class 23 | class ContrastPayloadParser 24 | UUID_V4_PATTERN = /[a-z0-9]{8}-[a-z0-9]{4}-4[a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{12}/.freeze 25 | VUL_ID_PATTERN = /[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}/.freeze 26 | VUL_ID_PATTERN2 = /[A-Z0-9\-]{19}$/.freeze 27 | VUL_URL_PATTERN = %r{.+\((.+/vulns/[A-Z0-9\-]{19})\)}.freeze 28 | 29 | attr_reader :event_type, 30 | :app_name, 31 | :app_id, 32 | :org_id, 33 | :vul_id, 34 | :lib_id, 35 | :description, 36 | :status, 37 | :vulnerability_tags, 38 | :project_id, # Redmine's project id 39 | :tracker # name of Redmine's tracker 40 | 41 | def initialize(payload) 42 | @event_type = payload['event_type'] 43 | @app_name = payload['application_name'] 44 | @org_id = ContrastPayloadParser.parse_org_id(payload['organization_id'], 45 | payload['description']) 46 | @app_id = ContrastPayloadParser.parse_app_id(payload['application_id'], 47 | payload['description']) 48 | @vul_id = ContrastPayloadParser.parse_vul_id(payload['vulnerability_id'], 49 | payload['description']) 50 | @description = payload['description'] 51 | @status = payload['status'] 52 | @lib_id = '' 53 | @vulnerability_tags = payload['vulnerability_tags'] 54 | @project_id = payload['project'] 55 | @tracker = payload['tracker'] 56 | end 57 | 58 | def self.parse_org_id(org_id, description) 59 | if org_id.empty? 60 | description.scan(UUID_V4_PATTERN)[0] 61 | else 62 | org_id 63 | end 64 | end 65 | 66 | def self.parse_app_id(app_id, description) 67 | if app_id.empty? 68 | description.scan(UUID_V4_PATTERN)[1] 69 | else 70 | app_id 71 | end 72 | end 73 | 74 | def self.parse_vul_id(vul_id, description) 75 | if vul_id.empty? 76 | description.scan(VUL_ID_PATTERN)[0] 77 | else 78 | vul_id 79 | end 80 | end 81 | 82 | def get_self_url 83 | is_vul_url = @description.match(VUL_URL_PATTERN) 84 | return is_vul_url[1].gsub(VUL_ID_PATTERN, @vul_id) if is_vul_url 85 | end 86 | 87 | def get_lib_info 88 | #lib_pattern = %r{index.html#/#{@org_id}/libraries/(.+)/([a-z0-9]+)\)} 89 | lib_pattern = %r{index.html#/#{@org_id}/libraries.+&activeRow=([a-z0-9]+)%7C([^&]+)&view} 90 | matched = @description.match(lib_pattern) 91 | 92 | if matched 93 | @lib_id = matched[1] 94 | return { 'lang' => matched[2], 'id' => matched[1] } 95 | else 96 | return { 'lang' => '', 'id' => '' } 97 | end 98 | end 99 | 100 | def get_lib_url 101 | lib_info = get_lib_info 102 | lib_url_pattern = /.+\((.+#{lib_info['id']}.+).+\)/ 103 | matched = @description.match(lib_url_pattern) 104 | if matched 105 | matched[1] 106 | else 107 | "" 108 | end 109 | end 110 | 111 | def set_vul_info_from_comment 112 | vul_id_pattern = %r{.+ commented on a .+[^(]+ \(.+index.html#/(.+)/applications/(.+)/vulns/([^)]+)\)} 113 | matched = @description.match(vul_id_pattern) 114 | if matched 115 | ContrastPayloadParser.parse_org_id(matched[1], @description) 116 | ContrastPayloadParser.parse_app_id(matched[2], @description) 117 | ContrastPayloadParser.parse_vul_id(matched[3], @description) 118 | else 119 | false 120 | end 121 | end 122 | 123 | end 124 | -------------------------------------------------------------------------------- /lib/issues_controller_patch.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | module IssuesControllerPatch 23 | def self.included(base) 24 | base.send(:include, InstanceMethods) 25 | base.class_eval do 26 | unloadable 27 | alias_method_chain :show, :update 28 | end 29 | end 30 | 31 | module InstanceMethods 32 | def show_with_update 33 | cv_org = CustomValue.where(customized_type: 'Issue').where(customized_id: @issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.org_id')}).first 34 | cv_app = CustomValue.where(customized_type: 'Issue').where(customized_id: @issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.app_id')}).first 35 | cv_vul = CustomValue.where(customized_type: 'Issue').where(customized_id: @issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.vul_id')}).first 36 | cv_lib = CustomValue.where(customized_type: 'Issue').where(customized_id: @issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.lib_id')}).first 37 | cv_lib_lang = CustomValue.where(customized_type: 'Issue').where(customized_id: @issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.lib_lang')}).first 38 | org_id = cv_org.try(:value) 39 | app_id = cv_app.try(:value) 40 | vul_id = cv_vul.try(:value) 41 | lib_id = cv_lib.try(:value) 42 | lib_lang = cv_lib_lang.try(:value) 43 | type = nil 44 | if vul_id.present? 45 | if org_id.blank? || app_id.blank? 46 | show = show_without_update 47 | return show 48 | end 49 | type = "VUL" 50 | elsif lib_id.present? 51 | if org_id.blank? || lib_lang.blank? 52 | show = show_without_update 53 | return show 54 | end 55 | type = "LIB" 56 | else 57 | show = show_without_update 58 | return show 59 | end 60 | 61 | if type == "VUL" 62 | teamserver_url = Setting.plugin_contrastsecurity['teamserver_url'] 63 | url = sprintf('%s/api/ng/%s/traces/%s/trace/%s', teamserver_url, org_id, app_id, vul_id) 64 | res, msg = ContrastUtil.callAPI(url: url) 65 | # puts res.code 66 | if res.present? && res.code != "200" 67 | flash.now[:warning] = l(:vuln_does_not_exist) 68 | show = show_without_update 69 | return show 70 | end 71 | vuln_json = JSON.parse(res.body) 72 | last_time_seen = vuln_json['trace']['last_time_seen'] 73 | severity = vuln_json['trace']['severity'] 74 | priority = ContrastUtil.get_priority_by_severity(severity) 75 | unless priority.nil? 76 | @issue.priority = priority 77 | end 78 | dt_format = Setting.plugin_contrastsecurity['vul_seen_dt_format'] 79 | if dt_format.blank? 80 | dt_format = "%Y/%m/%d %H:%M" 81 | end 82 | @issue.custom_field_values.each do |cfv| 83 | if cfv.custom_field.name == l('contrast_custom_fields.last_seen') then 84 | cfv.value = Time.at(last_time_seen/1000.0).strftime(dt_format) 85 | elsif cfv.custom_field.name == l('contrast_custom_fields.severity') then 86 | cfv.value = severity 87 | end 88 | end 89 | @issue.save 90 | unless ContrastUtil.syncComment(org_id, app_id, vul_id, @issue) 91 | flash.now[:warning] = l(:sync_comment_failure) 92 | end 93 | else 94 | teamserver_url = Setting.plugin_contrastsecurity['teamserver_url'] 95 | url = sprintf('%s/api/ng/%s/libraries/%s/%s?expand=vulns', teamserver_url, org_id, lib_lang, lib_id) 96 | res, msg = ContrastUtil.callAPI(url: url) 97 | # puts res.code 98 | if res.present? && res.code != "200" 99 | flash.now[:warning] = l(:lib_does_not_exist) 100 | show = show_without_update 101 | return show 102 | end 103 | end 104 | show = show_without_update 105 | return show 106 | end 107 | end 108 | end 109 | 110 | -------------------------------------------------------------------------------- /app/views/settings/_contrast_settings.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:config_section_connect) %>

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
<%= l(:teamserver_url) %><%= text_field_tag('settings[teamserver_url]', @settings['teamserver_url'], {size:"65", placeholder:"https://teamserver-host/Contrast"})%>
<%= l(:org_id) %><%= text_field_tag('settings[org_id]', @settings['org_id'], {size:"65"})%>
<%= l(:api_key) %><%= text_field_tag('settings[api_key]', @settings['api_key'], {size:"65"})%>
<%= l(:username) %><%= text_field_tag('settings[username]', @settings['username'], {size:"65", placeholder:"Login ID(mail address)"})%>
<%= l(:username_hint) %>
<%= l(:service_key) %><%= text_field_tag('settings[service_key]', @settings['service_key'], {size:"65"})%>
30 |
31 |

<%= l(:config_section_proxy) %>

32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
<%= l(:proxy_host) %><%= text_field_tag('settings[proxy_host]', @settings['proxy_host'], {size:"30"})%><%= l(:proxy_port) %><%= text_field_tag('settings[proxy_port]', @settings['proxy_port'], {size:"10"})%>
42 |
43 | <%= l(:proxy_auth) %> 44 | 45 | 46 | 47 | 48 | 49 | 51 | 52 | 53 |
<%= l(:proxy_userpass) %><%= text_field_tag('settings[proxy_user]', @settings['proxy_user'], {size:"20"})%>/ 50 | <%= password_field_tag('settings[proxy_pass]', @settings['proxy_pass'], {size:"20"})%>
54 |
55 |
56 |

<%= l(:config_section_import) %>

57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
<%= l(:vul_issues) %><%= check_box_tag 'settings[vul_issues]', true, @settings['vul_issues'] %>
<%= l(:lib_issues) %><%= check_box_tag 'settings[lib_issues]', true, @settings['lib_issues'] %>
69 |
70 |

<%= l(:config_section_status) %>

71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 |
<%= l(:teamserver_side) %><%= l(:redmine_side) %>
<%= l(:sts_reported) %><%= text_field_tag('settings[sts_reported]', @settings['sts_reported'], {size:"25"})%><%= l(:sts_reported_ph) %>
<%= l(:sts_suspicious) %><%= text_field_tag('settings[sts_suspicious]', @settings['sts_suspicious'], {size:"25"})%><%= l(:sts_suspicious_ph) %>
<%= l(:sts_confirmed) %><%= text_field_tag('settings[sts_confirmed]', @settings['sts_confirmed'], {size:"25"})%><%= l(:sts_confirmed_ph) %>
<%= l(:sts_notaproblem) %><%= text_field_tag('settings[sts_notaproblem]', @settings['sts_notaproblem'], {size:"25"})%><%= l(:sts_notaproblem_ph) %>
<%= l(:sts_remediated) %><%= text_field_tag('settings[sts_remediated]', @settings['sts_remediated'], {size:"25"})%><%= l(:sts_remediated_ph) %>
<%= l(:sts_fixed) %><%= text_field_tag('settings[sts_fixed]', @settings['sts_fixed'], {size:"25"})%><%= l(:sts_fixed_ph) %>
112 |

<%= l(:config_section_priority) %>

113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 |
<%= l(:teamserver_side) %><%= l(:redmine_side) %>
<%= l(:pri_critical) %><%= text_field_tag('settings[pri_critical]', @settings['pri_critical'], {size:"25"})%>
<%= l(:pri_high) %><%= text_field_tag('settings[pri_high]', @settings['pri_high'], {size:"25"})%>
<%= l(:pri_medium) %><%= text_field_tag('settings[pri_medium]', @settings['pri_medium'], {size:"25"})%>
<%= l(:pri_low) %><%= text_field_tag('settings[pri_low]', @settings['pri_low'], {size:"25"})%>
<%= l(:pri_note) %><%= text_field_tag('settings[pri_note]', @settings['pri_note'], {size:"25"})%>
<%= l(:pri_cvelib) %><%= text_field_tag('settings[pri_cvelib]', @settings['pri_cvelib'], {size:"25"})%><%= l(:pri_for_cvelib_ph) %>
154 |
155 |

<%= l(:config_section_other) %>

156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 |
<%= l(:vul_seen_datetime_format) %><%= text_field_tag('settings[vul_seen_dt_format]', @settings['vul_seen_dt_format'], {size:"30", placeholder:"%Y/%m/%d %H:%M"})%>
164 | -------------------------------------------------------------------------------- /lib/contrast_util.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | module ContrastUtil 23 | include Redmine::I18n 24 | def self.get_priority_by_severity(severity) 25 | case severity 26 | when "Critical" 27 | priority_str = Setting.plugin_contrastsecurity['pri_critical'] 28 | when "High" 29 | priority_str = Setting.plugin_contrastsecurity['pri_high'] 30 | when "Medium" 31 | priority_str = Setting.plugin_contrastsecurity['pri_medium'] 32 | when "Low" 33 | priority_str = Setting.plugin_contrastsecurity['pri_low'] 34 | when "Note" 35 | priority_str = Setting.plugin_contrastsecurity['pri_note'] 36 | end 37 | priority = IssuePriority.find_by_name(priority_str) 38 | return priority 39 | end 40 | 41 | def self.get_redmine_status(contrast_status) 42 | case contrast_status 43 | when "Reported", "報告済" 44 | rm_status = Setting.plugin_contrastsecurity['sts_reported'] 45 | when "Suspicious", "疑わしい" 46 | rm_status = Setting.plugin_contrastsecurity['sts_suspicious'] 47 | when "Confirmed", "確認済" 48 | rm_status = Setting.plugin_contrastsecurity['sts_confirmed'] 49 | when "NotAProblem", "Not a Problem", "問題無し" 50 | rm_status = Setting.plugin_contrastsecurity['sts_notaproblem'] 51 | when "Remediated", "修復済" 52 | rm_status = Setting.plugin_contrastsecurity['sts_remediated'] 53 | when "Fixed", "修正完了" 54 | rm_status = Setting.plugin_contrastsecurity['sts_fixed'] 55 | end 56 | status = IssueStatus.find_by_name(rm_status) 57 | return status 58 | end 59 | 60 | def self.get_contrast_status(redmine_status) 61 | sts_reported_array = [Setting.plugin_contrastsecurity['sts_reported']] 62 | sts_suspicious_array = [Setting.plugin_contrastsecurity['sts_suspicious']] 63 | sts_confirmed_array = [Setting.plugin_contrastsecurity['sts_confirmed']] 64 | sts_notaproblem_array = [Setting.plugin_contrastsecurity['sts_notaproblem']] 65 | sts_remediated_array = [Setting.plugin_contrastsecurity['sts_remediated']] 66 | sts_fixed_array = [Setting.plugin_contrastsecurity['sts_fixed']] 67 | status = nil 68 | if sts_reported_array.include?(redmine_status) 69 | status = "Reported" 70 | elsif sts_suspicious_array.include?(redmine_status) 71 | status = "Suspicious" 72 | elsif sts_confirmed_array.include?(redmine_status) 73 | status = "Confirmed" 74 | elsif sts_notaproblem_array.include?(redmine_status) 75 | status = "NotAProblem" 76 | elsif sts_remediated_array.include?(redmine_status) 77 | status = "Remediated" 78 | elsif sts_fixed_array.include?(redmine_status) 79 | status = "Fixed" 80 | end 81 | return status 82 | end 83 | 84 | def callAPI(url: , method: "GET", data: nil, api_key: nil, username: nil, service_key: nil, proxy_host: nil, proxy_port: nil, proxy_user: nil, proxy_pass: nil) 85 | uri = URI.parse(url) 86 | http = nil 87 | proxy_host ||= Setting.plugin_contrastsecurity['proxy_host'] 88 | proxy_port ||= Setting.plugin_contrastsecurity['proxy_port'] 89 | proxy_user ||= Setting.plugin_contrastsecurity['proxy_user'] 90 | proxy_pass ||= Setting.plugin_contrastsecurity['proxy_pass'] 91 | proxy_uri = "" 92 | if proxy_host.present? && proxy_port.present? 93 | proxy_uri = URI.parse(sprintf('http://%s:%d', proxy_host, proxy_port)) 94 | if proxy_user.present? && proxy_pass.present? 95 | http = Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port, proxy_user, proxy_pass) 96 | else 97 | http = Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port) 98 | end 99 | else 100 | http = Net::HTTP.new(uri.host, uri.port) 101 | end 102 | http.use_ssl = false 103 | if uri.scheme === "https" 104 | http.use_ssl = true 105 | http.verify_mode = OpenSSL::SSL::VERIFY_NONE 106 | end 107 | case method 108 | when "GET" 109 | req = Net::HTTP::Get.new(uri.request_uri) 110 | when "POST" 111 | req = Net::HTTP::Post.new(uri.request_uri) 112 | req.body = data 113 | when "PUT" 114 | req = Net::HTTP::Put.new(uri.request_uri) 115 | req.body = data 116 | when "DELETE" 117 | req = Net::HTTP::Delete.new(uri.request_uri) 118 | else 119 | return 120 | end 121 | # TeamServer接続設定のみparamから渡されたものを使う。なければ設定から取得 122 | api_key ||= Setting.plugin_contrastsecurity['api_key'] 123 | username ||= Setting.plugin_contrastsecurity['username'] 124 | service_key ||= Setting.plugin_contrastsecurity['service_key'] 125 | 126 | auth_header = Base64.strict_encode64(username + ":" + service_key) 127 | req["Authorization"] = auth_header 128 | req["API-Key"] = api_key 129 | req['Content-Type'] = req['Accept'] = 'application/json' 130 | begin 131 | res = http.request(req) 132 | return res, nil 133 | rescue => e 134 | puts [uri.to_s, e.class, e].join(" : ") 135 | return nil, e.to_s 136 | end 137 | end 138 | module_function :callAPI 139 | 140 | def syncComment(org_id, app_id, vul_id, issue) 141 | teamserver_url = Setting.plugin_contrastsecurity['teamserver_url'] 142 | url = sprintf('%s/api/ng/%s/applications/%s/traces/%s/notes?expand=skip_links', teamserver_url, org_id, app_id, vul_id) 143 | res, msg = callAPI(url: url) 144 | if res.present? && res.code != "200" 145 | return false 146 | end 147 | notes_json = JSON.parse(res.body) 148 | issue.journals.each do |c_journal| 149 | if not c_journal.private_notes 150 | c_journal.destroy 151 | end 152 | end 153 | notes_json['notes'].reverse.each do |c_note| 154 | old_status_str = "" 155 | new_status_str = "" 156 | status_change_reason_str = "" 157 | if c_note.has_key?("properties") 158 | c_note['properties'].each do |c_prop| 159 | if c_prop['name'] == "status.change.previous.status" 160 | status_obj = ContrastUtil.get_redmine_status(c_prop['value']) 161 | unless status_obj.nil? 162 | old_status_str = status_obj.name 163 | end 164 | elsif c_prop['name'] == "status.change.status" 165 | status_obj = ContrastUtil.get_redmine_status(c_prop['value']) 166 | unless status_obj.nil? 167 | new_status_str = status_obj.name 168 | end 169 | elsif c_prop['name'] == "status.change.substatus" && c_prop['value'].present? 170 | status_change_reason_str = l(:notaproblem_reason, :reason => c_prop['value']) + "\n" 171 | end 172 | end 173 | end 174 | note_str = CGI.unescapeHTML(status_change_reason_str + c_note['note']) 175 | if old_status_str.present? && new_status_str.present? 176 | cmt_chg_msg = l(:status_changed_comment, :old => old_status_str, :new => new_status_str) 177 | note_str = "(" + cmt_chg_msg + ")\n" + CGI.unescapeHTML(status_change_reason_str + c_note['note']) 178 | end 179 | journal = Journal.new 180 | journal.journalized = issue 181 | journal.user = User.current 182 | journal.notes = note_str 183 | journal.created_on = Time.at(c_note['last_modification']/1000.0) 184 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_note_id", value: c_note['id']) 185 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_last_updater_uid", value: c_note['last_updater_uid']) 186 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_last_updater", value: c_note['last_updater']) 187 | journal.save() 188 | end 189 | return true 190 | end 191 | module_function :syncComment 192 | end 193 | 194 | -------------------------------------------------------------------------------- /lib/issue_hooks.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | class IssueHook < Redmine::Hook::Listener 23 | def controller_issues_bulk_edit_before_save(context) 24 | issue = context[:issue] 25 | cv_org = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.org_id')}).first 26 | cv_app = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.app_id')}).first 27 | cv_vul = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.vul_id')}).first 28 | org_id = cv_org.try(:value) 29 | app_id = cv_app.try(:value) 30 | vul_id = cv_vul.try(:value) 31 | if org_id.blank? || app_id.blank? || vul_id.blank? 32 | return 33 | end 34 | status = ContrastUtil.get_contrast_status(issue.status.name) 35 | if status.nil? 36 | return 37 | end 38 | teamserver_url = Setting.plugin_contrastsecurity['teamserver_url'] 39 | # Get Status from TeamServer 40 | url = sprintf('%s/api/ng/%s/traces/%s/filter/%s?expand=skip_links', teamserver_url, org_id, app_id, vul_id) 41 | res, msg = ContrastUtil.callAPI(url: url) 42 | vuln_json = JSON.parse(res.body) 43 | sts_chg_ptn = "\\(" + l(:text_journal_changed, :label => ".+", :old => ".+", :new => ".+") + "\\)\\R" 44 | sts_chg_pattern = /#{sts_chg_ptn}/ 45 | reason_ptn = l(:notaproblem_reason, :reason => ".+") + "\\R" 46 | reason_pattern = /#{reason_ptn}/ 47 | if vuln_json['trace']['status'] != status 48 | # Put Status(and Comment) from TeamServer 49 | url = sprintf('%s/api/ng/%s/orgtraces/mark', teamserver_url, org_id) 50 | t_data_dict = {"traces" => [vul_id], "status" => status} 51 | t_data_dict["note"] = "status changed (by " + User.current.name + ")" 52 | ContrastUtil.callAPI(url: url, method: "PUT", data: t_data_dict.to_json) 53 | end 54 | end 55 | 56 | def controller_journals_edit_post(context) 57 | journal = context[:journal] 58 | private_note = journal.private_notes 59 | issue = journal.journalized 60 | cv_org = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.org_id')}).first 61 | cv_app = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.app_id')}).first 62 | cv_vul = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.vul_id')}).first 63 | org_id = cv_org.try(:value) 64 | app_id = cv_app.try(:value) 65 | vul_id = cv_vul.try(:value) 66 | if org_id.blank? || app_id.blank? || vul_id.blank? 67 | return 68 | end 69 | note_id = nil 70 | journal.details.each do |detail| 71 | if detail.prop_key == "contrast_note_id" 72 | note_id = detail.value 73 | end 74 | end 75 | teamserver_url = Setting.plugin_contrastsecurity['teamserver_url'] 76 | url = sprintf('%s/api/ng/%s/applications/%s/traces/%s/notes/%s?expand=skip_links', teamserver_url, org_id, app_id, vul_id, note_id) 77 | if note_id.present? 78 | if private_note 79 | # プライベート注記に変更された場合 80 | details = journal.details.to_a.delete_if{|detail| detail.prop_key == "contrast_note_id"} 81 | journal.details = details 82 | journal.save 83 | ContrastUtil.callAPI(url: url, method: "DELETE") 84 | end 85 | end 86 | 87 | note = journal.notes 88 | sts_chg_ptn = "\\(" + l(:text_journal_changed, :label => ".+", :old => ".+", :new => ".+") + "\\)\\R" 89 | sts_chg_pattern = /#{sts_chg_ptn}/ 90 | reason_ptn = l(:notaproblem_reason, :reason => ".+") + "\\R" 91 | reason_pattern = /#{reason_ptn}/ 92 | note = note.sub(/#{sts_chg_ptn}/, "") 93 | note = note.sub(/#{reason_ptn}/, "") 94 | t_data = {"note" => note}.to_json 95 | if note_id.blank? && !private_note # note idがなく、でもプライベート注記じゃない(またプライベート注記じゃなくなった)場合 96 | res, msg = ContrastUtil.callAPI(url: url, method: "POST", data: t_data) 97 | # note idを取得してredmine側のコメントに反映する。 98 | note_json = JSON.parse(res.body) 99 | if note_json['success'] 100 | journal.notes = CGI.unescapeHTML(note_json['note']['note']) 101 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_note_id", value: note_json['note']['id']) 102 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_last_updater_uid", value: note_json['note']['last_updater_uid']) 103 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_last_updater", value: note_json['note']['last_updater']) 104 | journal.save() 105 | end 106 | else 107 | ContrastUtil.callAPI(url: url, method: "PUT", data: t_data) 108 | end 109 | end 110 | 111 | def controller_issues_edit_after_save(context) 112 | params = context[:params] 113 | issue = context[:issue] 114 | journal = context[:journal] 115 | cv_org = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.org_id')}).first 116 | cv_app = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.app_id')}).first 117 | cv_vul = CustomValue.where(customized_type: 'Issue').where(customized_id: issue.id).joins(:custom_field).where(custom_fields: {name: l('contrast_custom_fields.vul_id')}).first 118 | org_id = cv_org.try(:value) 119 | app_id = cv_app.try(:value) 120 | vul_id = cv_vul.try(:value) 121 | if org_id.blank? || app_id.blank? || vul_id.blank? 122 | return 123 | end 124 | status = ContrastUtil.get_contrast_status(issue.status.name) 125 | if status.nil? 126 | return 127 | end 128 | teamserver_url = Setting.plugin_contrastsecurity['teamserver_url'] 129 | # Get Status from TeamServer 130 | url = sprintf('%s/api/ng/%s/traces/%s/filter/%s?expand=skip_links', teamserver_url, org_id, app_id, vul_id) 131 | res, msg = ContrastUtil.callAPI(url: url) 132 | vuln_json = JSON.parse(res.body) 133 | note = params['issue']['notes'] 134 | private_note = params['issue']['private_notes'] 135 | sts_chg_ptn = "\\(" + l(:text_journal_changed, :label => ".+", :old => ".+", :new => ".+") + "\\)\\R" 136 | sts_chg_pattern = /#{sts_chg_ptn}/ 137 | reason_ptn = l(:notaproblem_reason, :reason => ".+") + "\\R" 138 | reason_pattern = /#{reason_ptn}/ 139 | note = note.sub(/#{sts_chg_ptn}/, "") 140 | note = note.sub(/#{reason_ptn}/, "") 141 | if vuln_json['trace']['status'] != status 142 | # Put Status(and Comment) from TeamServer 143 | url = sprintf('%s/api/ng/%s/orgtraces/mark', teamserver_url, org_id) 144 | t_data_dict = {"traces" => [vul_id], "status" => status} 145 | if note.present? && private_note == "0" 146 | t_data_dict["note"] = note + " (by " + issue.last_updated_by.name + ")" 147 | else 148 | t_data_dict["note"] = "status changed (by " + issue.last_updated_by.name + ")" 149 | end 150 | ContrastUtil.callAPI(url: url, method: "PUT", data: t_data_dict.to_json) 151 | else 152 | if note.present? && private_note == "0" 153 | url = sprintf('%s/api/ng/%s/applications/%s/traces/%s/notes?expand=skip_links', teamserver_url, org_id, app_id, vul_id) 154 | t_data = {"note" => note + " (by " + issue.last_updated_by.name + ")"}.to_json 155 | res, msg = ContrastUtil.callAPI(url: url, method: "POST", data: t_data) 156 | # note idを取得してredmine側のコメントに反映する。 157 | note_json = JSON.parse(res.body) 158 | if note_json['success'] 159 | journal.notes = CGI.unescapeHTML(note_json['note']['note']) 160 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_note_id", value: note_json['note']['id']) 161 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_last_updater_uid", value: note_json['note']['last_updater_uid']) 162 | journal.details << JournalDetail.new(property: "cf", prop_key: "contrast_last_updater", value: note_json['note']['last_updater']) 163 | journal.save() 164 | end 165 | end 166 | end 167 | end 168 | end 169 | 170 | -------------------------------------------------------------------------------- /app/controllers/contrast_controller.rb: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # Copyright (c) 2020 Contrast Security Japan G.K. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | 22 | class ContrastController < ApplicationController 23 | before_action :require_login 24 | skip_before_filter :verify_authenticity_token 25 | accept_api_auth :vote 26 | 27 | @@mutex = Thread::Mutex.new 28 | 29 | CUSTOM_FIELDS = [ 30 | l('contrast_custom_fields.rule'), 31 | l('contrast_custom_fields.category'), 32 | l('contrast_custom_fields.server'), 33 | l('contrast_custom_fields.module'), 34 | l('contrast_custom_fields.confidence'), 35 | l('contrast_custom_fields.severity'), 36 | l('contrast_custom_fields.last_seen'), 37 | l('contrast_custom_fields.first_seen'), 38 | l('contrast_custom_fields.lib_lang'), 39 | l('contrast_custom_fields.lib_id'), 40 | l('contrast_custom_fields.vul_id'), 41 | l('contrast_custom_fields.app_id'), 42 | l('contrast_custom_fields.org_id') 43 | ].freeze 44 | # /Contrast/api/ng/[ORG_ID]/traces/[APP_ID]/trace/[VUL_ID] 45 | TRACE_API_ENDPOINT = '%s/api/ng/%s/traces/%s/trace/%s?expand=servers,application'.freeze 46 | LIBRARY_DETAIL_API_ENDPOINT = '%s/api/ng/%s/libraries/%s/%s?expand=vulns'.freeze 47 | TEAM_SERVER_URL = Setting.plugin_contrastsecurity['teamserver_url'] 48 | APP_INFO_API_ENDPOINT = '%s/api/ng/%s/applications/%s?expand=skip_links'.freeze 49 | 50 | def vote 51 | # logger.info(request.body.read) 52 | parsed_payload = ContrastPayloadParser.new(JSON.parse(request.body.read)) 53 | 54 | # logger.info(event_type) 55 | 56 | project = Project.find_by_identifier(parsed_payload.project_id) 57 | tracker = Tracker.find_by_name(parsed_payload.tracker) 58 | 59 | if project.nil? || tracker.nil? 60 | return head :not_found 61 | end 62 | 63 | unless tracker.projects.include? project 64 | tracker.projects << project 65 | tracker.save 66 | end 67 | 68 | add_custom_fields = [] 69 | if parsed_payload.event_type == 'NEW_VULNERABILITY' || 70 | parsed_payload.event_type == 'VULNERABILITY_DUPLICATE' 71 | if parsed_payload.event_type == 'NEW_VULNERABILITY' 72 | logger.info(l(:event_new_vulnerability)) 73 | else 74 | logger.info(l(:event_dup_vulnerability)) 75 | end 76 | 77 | unless Setting.plugin_contrastsecurity['vul_issues'] 78 | return render plain: 'Vul Skip' 79 | end 80 | 81 | url = format(TRACE_API_ENDPOINT, 82 | TEAM_SERVER_URL, 83 | parsed_payload.org_id, 84 | parsed_payload.app_id, 85 | parsed_payload.vul_id) 86 | 87 | res, msg = ContrastUtil.callAPI(url: url) 88 | vuln_json = JSON.parse(res.body) 89 | # logger.info(vuln_json) 90 | url = format( 91 | APP_INFO_API_ENDPOINT, 92 | TEAM_SERVER_URL, 93 | parsed_payload.org_id, 94 | parsed_payload.app_id 95 | ) 96 | res, msg = ContrastUtil.callAPI(url: url) 97 | app_info_json = JSON.parse(res.body) 98 | 99 | summary = '[' + app_info_json['application']['name'] + '] ' + vuln_json['trace']['title'] 100 | 101 | first_time_seen = vuln_json['trace']['first_time_seen'] 102 | last_time_seen = vuln_json['trace']['last_time_seen'] 103 | category = vuln_json['trace']['category'] 104 | confidence = vuln_json['trace']['confidence'] 105 | rule_title = vuln_json['trace']['rule_title'] 106 | severity = vuln_json['trace']['severity'] 107 | priority = ContrastUtil.get_priority_by_severity(severity) 108 | status = vuln_json['trace']['status'] 109 | status_obj = ContrastUtil.get_redmine_status(status) 110 | # logger.info(priority) 111 | if priority.nil? 112 | logger.error(l(:problem_with_priority)) 113 | return head :not_found 114 | end 115 | module_str = parsed_payload.app_name + ' (' + vuln_json['trace']['application']['context_path'] + ') - ' + vuln_json['trace']['application']['language'] 116 | server_list = Array.new 117 | vuln_json['trace']['servers'].each do |c_server| 118 | server_list.push(c_server['name']) 119 | end 120 | dt_format = Setting.plugin_contrastsecurity['vul_seen_dt_format'] 121 | if dt_format.blank? 122 | dt_format = '%Y/%m/%d %H:%M' 123 | end 124 | add_custom_fields << { 'id_str': l('contrast_custom_fields.first_seen'), 125 | 'value': Time.at(first_time_seen / 1000.0) 126 | .strftime(dt_format) } 127 | add_custom_fields << { 'id_str': l('contrast_custom_fields.last_seen'), 128 | 'value': Time.at(last_time_seen / 1000.0) 129 | .strftime(dt_format) } 130 | add_custom_fields << { 'id_str': l('contrast_custom_fields.severity'), 131 | 'value': severity } 132 | add_custom_fields << { 'id_str': l('contrast_custom_fields.confidence'), 133 | 'value': confidence } 134 | add_custom_fields << { 'id_str': l('contrast_custom_fields.module'), 135 | 'value': module_str } 136 | add_custom_fields << { 'id_str': l('contrast_custom_fields.server'), 137 | 'value': server_list.join(', ') } 138 | add_custom_fields << { 'id_str': l('contrast_custom_fields.category'), 139 | 'value': category } 140 | add_custom_fields << { 'id_str': l('contrast_custom_fields.rule'), 141 | 'value': rule_title } 142 | # logger.info(add_custom_fields) 143 | story_url = '' 144 | howtofix_url = '' 145 | vuln_json['trace']['links'].each do |c_link| 146 | if c_link['rel'] == 'story' 147 | story_url = c_link['href'] 148 | if story_url.include?('{traceUuid}') 149 | story_url = story_url.sub(/{traceUuid}/, vul_id) 150 | end 151 | end 152 | if c_link['rel'] == 'recommendation' 153 | howtofix_url = c_link['href'] 154 | if howtofix_url.include?('{traceUuid}') 155 | howtofix_url = howtofix_url.sub(/{traceUuid}/, vul_id) 156 | end 157 | end 158 | end 159 | # logger.info(summary) 160 | # logger.info(story_url) 161 | # logger.info(howtofix_url) 162 | # logger.info(self_url) 163 | # Story 164 | chapters = '' 165 | story = '' 166 | if story_url.present? 167 | get_story_res, msg = ContrastUtil.callAPI(url: story_url) 168 | story_json = JSON.parse(get_story_res.body) 169 | story_json['story']['chapters'].each do |chapter| 170 | chapters << chapter['introText'] + "\n" 171 | if chapter['type'] == 'properties' 172 | chapter['properties'].each do |key, value| 173 | chapters << "\n" + key + "\n" 174 | if value['value'].start_with?('{{#table}}') 175 | chapters << "\n" + value['value'] + "\n" 176 | else 177 | chapters << '{{#xxxxBlock}}' + value['value'] + "{{/xxxxBlock}}\n" 178 | end 179 | end 180 | elsif ['configuration', 'location', 'recreation', 'dataflow', 'source'].include? chapter['type'] 181 | chapters << '{{#xxxxBlock}}' + chapter['body'] + "{{/xxxxBlock}}\n" 182 | end 183 | end 184 | story = story_json['story']['risk']['formattedText'] 185 | end 186 | # How to fix 187 | howtofix = '' 188 | if howtofix_url.present? 189 | get_howtofix_res, msg = ContrastUtil.callAPI(url: howtofix_url) 190 | howtofix_json = JSON.parse(get_howtofix_res.body) 191 | howtofix = howtofix_json['recommendation']['formattedText'] 192 | end 193 | # description 194 | deco_mae = '' 195 | deco_ato = '' 196 | if Setting.text_formatting == 'textile' 197 | deco_mae = 'h2. ' 198 | deco_ato = "\n" 199 | elsif Setting.text_formatting == 'markdown' 200 | deco_mae = '## ' 201 | end 202 | description = '' 203 | description << deco_mae + l(:report_vul_happened) + deco_ato + "\n" 204 | description << convertMustache(chapters) + "\n\n" 205 | description << deco_mae + l(:report_vul_overview) + deco_ato + "\n" 206 | description << convertMustache(story) + "\n\n" 207 | description << deco_mae + l(:report_vul_howtofix) + deco_ato + "\n" 208 | description << convertMustache(howtofix) + "\n\n" 209 | description << deco_mae + l(:report_vul_url) + deco_ato + "\n" 210 | description << parsed_payload.get_self_url 211 | elsif parsed_payload.event_type == 'VULNERABILITY_CHANGESTATUS_OPEN' || 212 | parsed_payload.event_type == 'VULNERABILITY_CHANGESTATUS_CLOSED' 213 | logger.info(l(:event_vulnerability_changestatus)) 214 | 215 | # logger.info(status) 216 | if parsed_payload.vul_id.blank? 217 | logger.error(l(:problem_with_customfield)) 218 | return head :ok 219 | end 220 | cvs = CustomValue.where( 221 | customized_type: 'Issue', value: parsed_payload.vul_id 222 | ).joins(:custom_field).where( 223 | custom_fields: { 224 | name: l('contrast_custom_fields.vul_id') 225 | } 226 | ) 227 | cvs.each do |cv| 228 | issue = cv.customized 229 | if parsed_payload.project_id != issue.project.identifier 230 | next 231 | end 232 | 233 | status_obj = ContrastUtil.get_redmine_status(parsed_payload.status) 234 | if status_obj.nil? 235 | logger.error(l(:problem_with_status)) 236 | return head :ok 237 | end 238 | 239 | issue.status = status_obj 240 | if issue.save 241 | logger.info(l(:issue_status_change_success)) 242 | return head :ok 243 | else 244 | logger.error(l(:issue_status_change_failure)) 245 | return head :internal_server_error 246 | end 247 | end 248 | return head :ok 249 | elsif parsed_payload.event_type == 'NEW_VULNERABLE_LIBRARY' 250 | logger.info(l(:event_new_vulnerable_library)) 251 | unless Setting.plugin_contrastsecurity['lib_issues'] 252 | return render plain: 'Lib Skip' 253 | end 254 | 255 | lib_info = parsed_payload.get_lib_info 256 | # logger.info("[+]lib_info: #{lib_info}, #{lib_info['lang']}, #{lib_info['id'] }") 257 | url = format(LIBRARY_DETAIL_API_ENDPOINT, 258 | TEAM_SERVER_URL, parsed_payload.org_id, 259 | lib_info['lang'], lib_info['id']) 260 | # logger.info("LIBRARY_URL: #{url}") 261 | res, msg = ContrastUtil.callAPI(url: url) 262 | # logger.info(JSON.parse(res.body)) 263 | lib_json = JSON.parse(res.body) 264 | lib_name = lib_json['library']['file_name'] 265 | file_version = lib_json['library']['file_version'] 266 | latest_version = lib_json['library']['latest_version'] 267 | classes_used = lib_json['library']['classes_used'] 268 | class_count = lib_json['library']['class_count'] 269 | cve_list = Array.new 270 | lib_json['library']['vulns'].each do |c_link| 271 | cve_list.push(c_link['name']) 272 | end 273 | priority_str = Setting.plugin_contrastsecurity['pri_cvelib'] 274 | priority = IssuePriority.find_by_name(priority_str) 275 | # logger.info(priority) 276 | if priority.nil? 277 | logger.error(l(:problem_with_priority)) 278 | return head :not_found 279 | end 280 | 281 | self_url = parsed_payload.get_lib_url 282 | summary = lib_name 283 | # description 284 | deco_mae = '' 285 | deco_ato = '' 286 | if Setting.text_formatting == 'textile' 287 | deco_mae = '*' 288 | deco_ato = '*' 289 | elsif Setting.text_formatting == 'markdown' 290 | deco_mae = '**' 291 | deco_ato = '**' 292 | end 293 | description = '' 294 | description << deco_mae + l(:report_lib_curver) + deco_ato + "\n" 295 | description << file_version + "\n\n" 296 | description << deco_mae + l(:report_lib_newver) + deco_ato + "\n" 297 | description << latest_version + "\n\n" 298 | description << deco_mae + l(:report_lib_class) + deco_ato + "\n" 299 | description << classes_used.to_s + '/' + class_count.to_s + "\n\n" 300 | description << deco_mae + l(:report_lib_cves) + deco_ato + "\n" 301 | description << cve_list.join("\n") + "\n\n" 302 | description << deco_mae + l(:report_lib_url) + deco_ato + "\n" 303 | description << self_url 304 | elsif parsed_payload.event_type == 'NEW_VULNERABILITY_COMMENT_FROM_SCRIPT' 305 | logger.info(l(:event_new_vulnerability_comment)) 306 | 307 | if parsed_payload.set_vul_info_from_comment 308 | cvs = CustomValue.where( 309 | customized_type: 'Issue', value: parsed_payload.vul_id 310 | ).joins(:custom_field).where( 311 | custom_fields: { name: l('contrast_custom_fields.vul_id') } 312 | ) 313 | cvs.each do |cv| 314 | issue = cv.customized 315 | if parsed_payload.project_id == issue.project.identifier 316 | ContrastUtil.syncComment(parsed_payload.org_id, 317 | parsed_payload.app_id, 318 | parsed_payload.vul_id, 319 | issue) 320 | end 321 | end 322 | end 323 | return head :ok 324 | else 325 | if parsed_payload.vulnerability_tags == 'VulnerabilityTestTag' 326 | return render plain: 'Test URL Success' 327 | end 328 | return head :ok 329 | end 330 | # ここに来るのは NEW_VULNERABILITY か VULNERABILITY_DUPLICATE か NEW_VULNERABLE_LIBRARYの通知のみです。 331 | custom_field_hash = {} 332 | CUSTOM_FIELDS.each do |custom_field_name| 333 | custom_field = IssueCustomField.find_by_name(custom_field_name) 334 | if custom_field.nil? 335 | custom_field = IssueCustomField.new(name: custom_field_name) 336 | custom_field.position = 1 337 | custom_field.visible = true 338 | custom_field.is_required = false 339 | custom_field.is_filter = false 340 | custom_field.searchable = false 341 | custom_field.field_format = 'string' 342 | custom_field.projects << project 343 | custom_field.trackers << tracker 344 | custom_field.save 345 | else 346 | unless custom_field.projects.include? project 347 | custom_field.projects << project 348 | custom_field.save 349 | end 350 | unless custom_field.trackers.include? tracker 351 | custom_field.trackers << tracker 352 | custom_field.save 353 | end 354 | end 355 | custom_field_hash[custom_field_name] = custom_field.id 356 | end 357 | # DUPLICATEの場合は脆弱性IDが違うもので飛んでくる場合があるので、取得し直す 358 | # LIBRARYの場合はvuln_idを取得し直さない 359 | vul_id = '' 360 | if parsed_payload.event_type != 'NEW_VULNERABLE_LIBRARY' 361 | url = "#{TEAM_SERVER_URL}/api/ng/#{parsed_payload.org_id}/traces/#{parsed_payload.app_id}/filter/#{parsed_payload.vul_id}" 362 | res, msg = ContrastUtil.callAPI(url: url) 363 | parsed_response = JSON.parse(res.body) 364 | vul_id = parsed_response['trace']['uuid'] 365 | end 366 | lib_info = parsed_payload.get_lib_info 367 | custom_fields = [ 368 | { 'id': custom_field_hash[l('contrast_custom_fields.org_id')], 369 | 'value': parsed_payload.org_id }, 370 | { 'id': custom_field_hash[l('contrast_custom_fields.app_id')], 371 | 'value': parsed_payload.app_id }, 372 | { 'id': custom_field_hash[l('contrast_custom_fields.vul_id')], 373 | 'value': vul_id }, 374 | { 'id': custom_field_hash[l('contrast_custom_fields.lib_id')], 375 | 'value': lib_info['id'] }, 376 | { 'id': custom_field_hash[l('contrast_custom_fields.lib_lang')], 377 | 'value': lib_info['lang'] } 378 | ] 379 | add_custom_fields.each do |add_custom_field| 380 | custom_fields << { 'id': custom_field_hash[add_custom_field[:id_str]], 'value': add_custom_field[:value] } 381 | end 382 | # logger.info(custom_fields) 383 | issue = nil 384 | @@mutex.lock 385 | begin 386 | # 脆弱性ライブラリはDUPLICATE通知はない前提 387 | if parsed_payload.event_type != 'NEW_VULNERABLE_LIBRARY' 388 | # logger.info("[+]webhook vul_id: #{parsed_payload.vul_id}, api vul_id: #{vul_id}") 389 | cvs = CustomValue.where( 390 | customized_type: 'Issue', value: vul_id 391 | ).joins(:custom_field).where( 392 | custom_fields: { name: l('contrast_custom_fields.vul_id') } 393 | ) 394 | # logger.info("[+]Custome Values: #{cvs}") 395 | cvs.each do |cv| 396 | logger.info(cv) 397 | issue = cv.customized 398 | end 399 | end 400 | if issue.nil? 401 | # logger.info("[+]event_type: #{parsed_payload.event_type}") 402 | # logger.info("[+] issue: #{issue}") 403 | issue = Issue.new( 404 | project: project, 405 | subject: summary, 406 | tracker: tracker, 407 | priority: priority, 408 | description: description, 409 | custom_fields: custom_fields, 410 | author: User.current 411 | ) 412 | elsif parsed_payload.event_type == 'VULNERABILITY_DUPLICATE' 413 | logger.info('[+]update issue') 414 | issue.description = description 415 | issue.custom_fields = custom_fields 416 | end 417 | ensure 418 | @@mutex.unlock 419 | end 420 | unless status_obj.nil? 421 | issue.status = status_obj 422 | end 423 | if issue.save 424 | if parsed_payload.event_type == 'VULNERABILITY_DUPLICATE' 425 | logger.info(l(:issue_update_success)) 426 | else 427 | logger.info(l(:issue_create_success)) 428 | end 429 | return head :ok 430 | else 431 | if parsed_payload.event_type == 'VULNERABILITY_DUPLICATE' 432 | logger.error(l(:issue_update_failure)) 433 | else 434 | logger.error(l(:issue_create_failure)) 435 | end 436 | return head :internal_server_error 437 | end 438 | end 439 | 440 | def convertMustache(str) 441 | if Setting.text_formatting == 'textile' 442 | # Link 443 | new_str = str.gsub(/({{#link}}[^\[]+?)\[\](.+?\$\$LINK_DELIM\$\$)/, '\1%5B%5D\2') 444 | new_str = new_str.gsub(%r{{{#link}}(.+?)\$\$LINK_DELIM\$\$(.+?){{/link}}}, '"\2":\1 ') 445 | # CodeBlock 446 | new_str = new_str.gsub(/{{#[A-Za-z]+Block}}/, '
').gsub(%r{{{/[A-Za-z]+Block}}}, '
') 447 | # Header 448 | new_str = new_str.gsub(/{{#header}}/, 'h3. ').gsub(%r{{{/header}}}, "\n") 449 | # List 450 | new_str = new_str.gsub(/[ \t]*{{#listElement}}/, '* ').gsub(%r{{{/listElement}}}, '') 451 | # Table 452 | while true do 453 | tbl_bgn_idx = new_str.index('{{#table}}') 454 | tbl_end_idx = new_str.index('{{/table}}') 455 | if tbl_bgn_idx.nil? || tbl_end_idx.nil? 456 | break 457 | else 458 | # logger.info(sprintf('%s - %s', tbl_bgn_idx, tbl_end_idx)) 459 | tbl_str = new_str.slice(tbl_bgn_idx, tbl_end_idx - tbl_bgn_idx + 10) # 10は{{/table}}の文字数 460 | tbl_str = tbl_str.gsub(/[ \t]*{{#tableRow}}[\s]*{{#tableHeaderRow}}/, '|').gsub(%r{{{/tableHeaderRow}}[\s]*}, '|') 461 | tbl_str = tbl_str.gsub(/[ \t]*{{#tableRow}}[\s]*{{#tableCell}}/, '|').gsub(%r{{{/tableCell}}[\s]*}, '|') 462 | tbl_str = tbl_str.gsub(/[ \t]*{{#badTableRow}}[\s]*{{#tableCell}}/, "\n|").gsub(%r{{{/tableCell}}[\s]*}, '|') 463 | tbl_str = tbl_str.gsub(/{{{nl}}}/, '
') 464 | tbl_str = tbl_str.gsub(%r{{{(#|/)[A-Za-z]+}}}, '') # ここで残ったmustacheを全削除 465 | new_str[tbl_bgn_idx, tbl_end_idx - tbl_bgn_idx + 10] = tbl_str # 10は{{/table}}の文字数 466 | end 467 | end 468 | elsif Setting.text_formatting == 'markdown' 469 | # Link 470 | new_str = str.gsub(/({{#link}}[^\[]+?)\[\](.+?\$\$LINK_DELIM\$\$)/, '\1%5B%5D\2') 471 | new_str = new_str.gsub(%r{{{#link}}(.+?)\$\$LINK_DELIM\$\$(.+?){{/link}}}, '[\2](\1)') 472 | # CodeBlock 473 | new_str = new_str.gsub(/{{#[A-Za-z]+Block}}/, "\n~~~\n").gsub(%r{{{/[A-Za-z]+Block}}}, "\n~~~\n") 474 | # Header 475 | new_str = new_str.gsub(/{{#header}}/, '### ').gsub(%r{{{/header}}}, '') 476 | # List 477 | new_str = new_str.gsub(/[ \t]*{{#listElement}}/, '* ').gsub(%r{{{/listElement}}}, '') 478 | # Table 479 | while true do 480 | tbl_bgn_idx = new_str.index('{{#table}}') 481 | tbl_end_idx = new_str.index('{{/table}}') 482 | if tbl_bgn_idx.nil? || tbl_end_idx.nil? 483 | break 484 | else 485 | # logger.info(sprintf('%s - %s', tbl_bgn_idx, tbl_end_idx)) 486 | tbl_str = new_str.slice(tbl_bgn_idx, tbl_end_idx - tbl_bgn_idx + 10) # 10は{{/table}}の文字数 487 | tbl_str = tbl_str.gsub(%r{({{#tableRow}}[\s]*({{#tableHeaderRow}}.+{{/tableHeaderRow}})[\s]*{{/tableRow}})}, '\1' + "\n{{#tableRowX}}" + '\2' + '{{/tableRowX}}') 488 | if mo = tbl_str.match(%r{({{#tableRowX}}[\s]*.+[\s]*{{/tableRowX}})}) 489 | replace_str = mo[1].gsub(/tableHeaderRow/, 'tableHeaderRowX') 490 | tbl_str = tbl_str.gsub(%r{{{#tableRowX}}[\s]*.+[\s]*{{/tableRowX}}}, replace_str) 491 | tbl_str = tbl_str.gsub(%r{({{#tableHeaderRowX}})(.+?)({{/tableHeaderRowX}})}, '\1---\3') 492 | end 493 | tbl_str = tbl_str.gsub(/[ \t]*{{#tableRow}}[\s]*{{#tableHeaderRow}}/, '|').gsub(%r{{{/tableHeaderRow}}[\s]*}, '|') 494 | tbl_str = tbl_str.gsub(/[ \t]*{{#tableRowX}}[\s]*{{#tableHeaderRowX}}/, '|').gsub(%r{{{/tableHeaderRowX}}[\s]*}, '|') 495 | tbl_str = tbl_str.gsub(/[ \t]*{{#tableRow}}[\s]*{{#tableCell}}/, '|').gsub(%r{{{/tableCell}}[\s]*}, '|') 496 | tbl_str = tbl_str.gsub(/[ \t]*{{#badTableRow}}[\s]*{{#tableCell}}/, "\n|").gsub(%r{{{/tableCell}}[\s]*}, '|') 497 | tbl_str = tbl_str.gsub(/{{{nl}}}/, '
') 498 | tbl_str = tbl_str.gsub(%r{{{(#|/)[A-Za-z]+}}}, '') # ここで残ったmustacheを全削除 499 | new_str[tbl_bgn_idx, tbl_end_idx - tbl_bgn_idx + 10] = tbl_str # 10は{{/table}}の文字数 500 | end 501 | end 502 | else 503 | # Link 504 | new_str = str.gsub(/\$\$LINK_DELIM\$\$/, ' ') 505 | end 506 | # New line 507 | new_str = new_str.gsub(/{{{nl}}}/, "\n") 508 | # Other 509 | new_str = new_str.gsub(%r{{{(#|/)[A-Za-z]+}}}, '') 510 | # Comment 511 | new_str = new_str.gsub(/{{!.+}}/, '') 512 | # <, >, nbsp 513 | new_str = new_str.gsub(/</, '<').gsub(/>/, '>').gsub(/ /, ' ') 514 | # Quot 515 | new_str = new_str.gsub(/"/, '"') 516 | # Tab 517 | new_str = new_str.gsub(/\t/, ' ') 518 | # Character Reference 519 | new_str = new_str.gsub(/&#[^;]+;/, '') 520 | return new_str 521 | end 522 | end 523 | 524 | --------------------------------------------------------------------------------