diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml new file mode 100644 index 000000000..961114bc9 --- /dev/null +++ b/.github/workflows/ghsa-linear-sync.yml @@ -0,0 +1,97 @@ +name: Sync GHSA advisories to Linear + +on: + schedule: + - cron: '0 4 * * *' # daily at 09:30 IST + workflow_dispatch: {} + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + security-events: read + steps: + - name: Fetch triage advisories + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \ + | jq -cs 'add | [.[] | { + ghsa_id, cve_id, summary, severity, state, html_url, + description, created_at, + cvss_score: .cvss.score, + reporter: ([.credits[]?.user.login] | first // "unknown") + }]' > advisories.json + echo "Fetched $(jq 'length' advisories.json) triage advisories" + + - name: Create Linear issues for new advisories + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} + LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} + run: | + created_count=0 + skipped_count=0 + failed_count=0 + while read -r advisory; do + ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id') + summary=$(printf '%s' "$advisory" | jq -r '.summary') + severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"') + cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"') + cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"') + reporter=$(printf '%s' "$advisory" | jq -r '.reporter') + html_url=$(printf '%s' "$advisory" | jq -r '.html_url') + created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1) + description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."') + + existing=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \ + | jq '.data.issues.nodes | length') + + if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then + skipped_count=$((skipped_count+1)) + continue + fi + + priority=3 + case "$severity" in + critical) priority=1 ;; + high) priority=2 ;; + medium) priority=3 ;; + low) priority=4 ;; + esac + + title="[$ghsa_id] $summary" + body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \ + "$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description") + + success=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg title "$title" \ + --arg body "$body" \ + --arg teamId "$LINEAR_TEAM_ID" \ + --arg projectId "$LINEAR_PROJECT_ID" \ + --arg labelId "$LINEAR_LABEL_ID" \ + --argjson priority "$priority" \ + '{ + query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }", + variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}} + }')" | jq -r '.data.issueCreate.success // false') + + if [ "$success" = "true" ]; then + created_count=$((created_count+1)) + else + failed_count=$((failed_count+1)) + fi + done < <(jq -c '.[]' advisories.json) + echo "Created $created_count, skipped $skipped_count, failed $failed_count" + if [ "$failed_count" -gt 0 ]; then + exit 1 + fi diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb index b3f920644..42fe918cc 100644 --- a/app/controllers/concerns/meta_token_verify_concern.rb +++ b/app/controllers/concerns/meta_token_verify_concern.rb @@ -2,6 +2,10 @@ # This concern handles the token verification step. module MetaTokenVerifyConcern + CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze + META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze + META_SIGNATURE_PREFIX = 'sha256='.freeze + def verify service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' if valid_token?(params['hub.verify_token']) @@ -14,6 +18,53 @@ module MetaTokenVerifyConcern private + def verify_meta_signature! + return unless meta_signature_verification_required? + return if valid_meta_signature? + + head :unauthorized + end + + def valid_meta_signature? + signature = request.headers[META_SIGNATURE_HEADER] + return false unless signature&.start_with?(META_SIGNATURE_PREFIX) + + meta_app_secrets.any? do |secret| + next false if secret.blank? + + expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}" + ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature) + end + end + + def meta_request_body + @meta_request_body ||= request.raw_post + end + + def meta_app_secrets + raise 'Overwrite this method in your controller' + end + + def meta_signature_verification_required? + true + end + + def channel_meta_app_secrets(channel) + return [] if channel.blank? + + secrets = [] + secrets << channel.app_secret if channel.respond_to?(:app_secret) + secrets.concat(provider_config_meta_app_secrets(channel)) + secrets.compact_blank.uniq + end + + def provider_config_meta_app_secrets(channel) + return [] unless channel.respond_to?(:provider_config) + + provider_config = channel.provider_config.to_h.with_indifferent_access + CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence } + end + def valid_token?(_token) raise 'Overwrite this method your controller' end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index 569c2524b..6e5168634 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -1,6 +1,8 @@ class Webhooks::InstagramController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :events + def events Rails.logger.info('Instagram webhook received events') if params['object'].casecmp('instagram').zero? @@ -39,4 +41,38 @@ class Webhooks::InstagramController < ActionController::API token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end + + def meta_app_secrets + [ + *instagram_channel_meta_app_secrets, + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil), + GlobalConfigService.load('FB_APP_SECRET', nil) + ] + end + + def instagram_channel_meta_app_secrets + instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) } + end + + def instagram_channels_from_payload + Array(params.to_unsafe_hash[:entry]).flat_map do |entry| + instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id| + [ + Channel::Instagram.find_by(instagram_id: instagram_id), + Channel::FacebookPage.find_by(instagram_id: instagram_id) + ] + end + end.compact.uniq + end + + def instagram_ids_from_entry(entry) + messages = entry[:messaging].presence || entry[:standby] || [] + messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) } + end + + def instagram_id_from_messaging(messaging) + return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present? + + messaging.dig(:recipient, :id) + end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index c4c376e5c..ee71f3c92 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -1,6 +1,8 @@ class Webhooks::WhatsappController < ActionController::API include MetaTokenVerifyConcern + before_action :verify_meta_signature!, only: :process_payload + def process_payload if inactive_whatsapp_number? Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}") @@ -20,6 +22,45 @@ class Webhooks::WhatsappController < ActionController::API token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? end + def meta_app_secrets + [ + *channel_meta_app_secrets(whatsapp_channel), + GlobalConfigService.load('WHATSAPP_APP_SECRET', nil) + ] + end + + def whatsapp_channel + @whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + end + + def meta_signature_verification_required? + return true if whatsapp_channel.blank? + return false unless whatsapp_channel.provider == 'whatsapp_cloud' + return true if channel_meta_app_secrets(whatsapp_channel).present? + + whatsapp_channel.provider_config['source'] == 'embedded_signup' + end + + def whatsapp_business_payload_channel + return unless params[:object] == 'whatsapp_business_account' + + metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) + return if metadata.blank? + + phone_number = normalized_phone_number(metadata[:display_phone_number]) + phone_number_id = metadata[:phone_number_id] + channel = Channel::Whatsapp.find_by(phone_number: phone_number) + + return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id + end + + def normalized_phone_number(phone_number) + return if phone_number.blank? + + phone_number = phone_number.to_s + phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + end + def inactive_whatsapp_number? phone_number = params[:phone_number] return false if phone_number.blank? diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index bae5623a7..dc79365e3 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -47,9 +47,10 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } - initiateCall(contactId, inboxId) { + initiateCall(contactId, inboxId, conversationId = null) { return axios.post(`${this.url}/${contactId}/call`, { inbox_id: inboxId, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 5d13b1ee1..d338d2108 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -20,6 +20,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; const props = defineProps({ phone: { type: String, default: '' }, contactId: { type: [String, Number], required: true }, + // When set, the WhatsApp call continues in this conversation (matching the + // header button) instead of looking up the contact's most recent one. + conversationId: { type: [String, Number], default: null }, label: { type: String, default: '' }, icon: { type: [String, Object, Function], default: '' }, size: { type: String, default: 'sm' }, @@ -76,8 +79,12 @@ const findWhatsappConversationId = async inboxId => { return match?.id || null; }; -const startWhatsappCall = async inboxId => { - const conversationId = await findWhatsappConversationId(inboxId); +const startWhatsappCall = async (inboxId, conversationIdHint) => { + // WhatsApp /initiate is conversation-scoped, so we must hand it a + // conversation. Use the caller's hint when given (in-conversation flow); + // otherwise pick the most recent one in the inbox. + const conversationId = + conversationIdHint || (await findWhatsappConversationId(inboxId)); if (!conversationId) { useAlert(t('CONTACT_PANEL.CALL_FAILED')); return; @@ -108,13 +115,13 @@ const startWhatsappCall = async inboxId => { navigateToConversation(conversationId); }; -const startCall = async inboxId => { +const startCall = async (inboxId, conversationIdHint = null) => { if (isInitiatingCall.value) return; const inbox = (inboxesList.value || []).find(i => i.id === inboxId); if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) { try { - await startWhatsappCall(inboxId); + await startWhatsappCall(inboxId, conversationIdHint); } catch (error) { useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED')); } @@ -125,6 +132,7 @@ const startCall = async inboxId => { const response = await store.dispatch('contacts/initiateCall', { contactId: props.contactId, inboxId, + conversationId: conversationIdHint, }); const { call_sid: callSid, conversation_id: conversationId } = response; @@ -145,6 +153,22 @@ const startCall = async inboxId => { }; const onClick = async () => { + // In conversation context, only stay in this conversation if its inbox is + // itself voice-capable (works the same for Twilio and WhatsApp). For + // non-voice channels (email, web, …) fall back to the picker so the call + // goes out via a voice inbox. + if (props.conversationId) { + const conversation = store.getters.getConversationById( + props.conversationId + ); + const conversationInbox = (inboxesList.value || []).find( + i => i.id === conversation?.inbox_id + ); + if (conversationInbox && isVoiceCallEnabled(conversationInbox)) { + await startCall(conversationInbox.id, props.conversationId); + return; + } + } if (voiceInboxes.value.length > 1) { dialogRef.value?.open(); return; diff --git a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue index 7f0379b9a..913e3e381 100644 --- a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue +++ b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue @@ -49,7 +49,11 @@ const rules = computed(() => ({ props.attribute.regexPattern && { regexValidation: value => { if (!value) return true; - return getRegexp(props.attribute.regexPattern).test(value); + try { + return getRegexp(props.attribute.regexPattern).test(value); + } catch { + return false; + } }, }), }, diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue index 8f70a9fa2..232cc9aac 100644 --- a/app/javascript/dashboard/components/CustomAttribute.vue +++ b/app/javascript/dashboard/components/CustomAttribute.vue @@ -102,13 +102,14 @@ export default { return this.v$.editedValue.$error; }, errorMessage() { - if (this.v$.editedValue.url) { + if (this.v$.editedValue.url?.$invalid) { return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL'); } - if (!this.v$.editedValue.regexValidation) { - return this.regexCue - ? this.regexCue - : this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT'); + if (this.v$.editedValue.regexValidation?.$invalid) { + return ( + this.regexCue || + this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT') + ); } return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); }, @@ -134,9 +135,12 @@ export default { editedValue: { required, regexValidation: value => { - return !( - this.attributeRegex && !getRegexp(this.attributeRegex).test(value) - ); + if (!this.attributeRegex || !value) return true; + try { + return getRegexp(this.attributeRegex).test(value); + } catch { + return false; + } }, }, }; diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index d711c7fb4..473087f85 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -1,14 +1,16 @@