diff --git a/config/routes/project.rb b/config/routes/project.rb
index cf607772f34060853cd65a2018ecd96b0e3ec02a..d0cea915d496e8400dca3df73cf7dd753509d806 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -362,6 +362,12 @@
         end
       end
 
+      # EE-specific start
+      namespace :security do
+        resource :dashboard, only: [:show], controller: :dashboard
+      end
+      # EE-specific end
+
       resources :milestones, constraints: { id: /\d+/ } do
         member do
           post :promote
diff --git a/doc/user/project/img/project_security_dashboard.png b/doc/user/project/img/project_security_dashboard.png
new file mode 100644
index 0000000000000000000000000000000000000000..3294e59e9432a8c707c989ad25a102faa57cde6c
Binary files /dev/null and b/doc/user/project/img/project_security_dashboard.png differ
diff --git a/doc/user/project/index.md b/doc/user/project/index.md
index 43db5f6afa0ba5b2ea69e19cf8edb3fac767f46e..5c2bcd30dcbef42e231a7621b8878835fc08d9dd 100644
--- a/doc/user/project/index.md
+++ b/doc/user/project/index.md
@@ -82,6 +82,7 @@ website with GitLab Pages
 - [Wiki](wiki/index.md): Document your GitLab project in an integrated Wiki
 - [Snippets](../snippets.md): Store, share and collaborate on code snippets
 - [Cycle Analytics](cycle_analytics.md): Review your development lifecycle
+- [Security Dashboard](security_dashboard.md): Security Dashboard
 - [Syntax highlighting](highlighting.md): An alternative to customize
 your code blocks, overriding GitLab's default choice of language
 - [Badges](badges.md): Badges for the project overview
diff --git a/doc/user/project/security_dashboard.md b/doc/user/project/security_dashboard.md
new file mode 100644
index 0000000000000000000000000000000000000000..c597f4bdd8256668cd50eaa0cb9f7ad8a31d92ab
--- /dev/null
+++ b/doc/user/project/security_dashboard.md
@@ -0,0 +1,18 @@
+# Project Security Dashboard
+
+> [Introduced][ee-6165] in [GitLab Ultimate][ee] 11.1.
+
+The Security Dashboard displays the latest security reports for your project.
+Use it to find and fix vulnerabilities affecting the [default branch](./repository/branches/index.md#default-branch).
+
+![Project Security Dashboard](img/project_security_dashboard.png)
+
+## How it works?
+
+To benefit from the Security Dashboard you must first configure the [Security Reports](./merge_requests/index.md#security-reports).
+
+The Security Dashboard will then list security vulnerabilities from the latest pipeline run on the default branch (e.g., `master`).
+You will also be able to interact with the reports [the same way you can do on a merge request](./merge_requests/index.md#interacting-with-security-reports).
+
+[ee-6165]: https://gitlab.com/gitlab-org/gitlab-ee/issues/6165
+[ee]: https://about.gitlab.com/pricing
diff --git a/ee/app/assets/javascripts/pages/projects/security/dashboard/show/index.js b/ee/app/assets/javascripts/pages/projects/security/dashboard/show/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..88f1aa479d712e04f305bb41a6379a361e0a696d
--- /dev/null
+++ b/ee/app/assets/javascripts/pages/projects/security/dashboard/show/index.js
@@ -0,0 +1,66 @@
+import Vue from 'vue';
+import createStore from 'ee/vue_shared/security_reports/store';
+import SecurityReportApp from 'ee/vue_shared/security_reports/card_security_reports_app.vue';
+
+document.addEventListener('DOMContentLoaded', () => {
+  const securityTab = document.getElementById('js-security-report-app');
+
+  const {
+    hasPipelineData,
+    userPath,
+    userAvatarPath,
+    pipelineCreated,
+    pipelinePath,
+    userName,
+    commitId,
+    commitPath,
+    refId,
+    refPath,
+    pipelineId,
+    canCreateFeedback,
+    canCreateIssue,
+    ...rest
+  } = securityTab.dataset;
+
+  const parsedPipelineId = parseInt(pipelineId, 10);
+
+  const store = createStore();
+
+  return new Vue({
+    el: securityTab,
+    store,
+    components: {
+      SecurityReportApp,
+    },
+    methods: {},
+    render(createElement) {
+      return createElement('security-report-app', {
+        props: {
+          pipelineId: parsedPipelineId,
+          hasPipelineData: hasPipelineData === 'true',
+          canCreateIssue: canCreateIssue === 'true',
+          canCreateFeedback: canCreateFeedback === 'true',
+          triggeredBy: {
+            avatarPath: userAvatarPath,
+            name: userName,
+            path: userPath,
+          },
+          pipeline: {
+            id: parsedPipelineId,
+            created: pipelineCreated,
+            path: pipelinePath,
+          },
+          commit: {
+            id: commitId,
+            path: commitPath,
+          },
+          branch: {
+            id: refId,
+            path: refPath,
+          },
+          ...rest,
+        },
+      });
+    },
+  });
+});
diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/card_security_reports_app.vue b/ee/app/assets/javascripts/vue_shared/security_reports/card_security_reports_app.vue
new file mode 100644
index 0000000000000000000000000000000000000000..064cf08e20e830007ad20da61ff8a2da914e32ec
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/security_reports/card_security_reports_app.vue
@@ -0,0 +1,198 @@
+<script>
+import { s__, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import EmptySecurityDashboard from './components/empty_security_dashboard.vue';
+import SplitSecurityReport from './split_security_reports_app.vue';
+
+export default {
+  components: {
+    EmptySecurityDashboard,
+    UserAvatarLink,
+    Icon,
+    SplitSecurityReport,
+    TimeagoTooltip,
+  },
+  props: {
+    hasPipelineData: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    emptyStateIllustrationPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    securityDashboardHelpPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    alwaysOpen: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    headBlobPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    sastHeadPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    dastHeadPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    sastContainerHeadPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    dependencyScanningHeadPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    sastHelpPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    sastContainerHelpPath: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    dastHelpPath: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    dependencyScanningHelpPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+    vulnerabilityFeedbackPath: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    vulnerabilityFeedbackHelpPath: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    pipelineId: {
+      type: Number,
+      required: false,
+      default: null,
+    },
+    commit: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    triggeredBy: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    branch: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    pipeline: {
+      type: Object,
+      required: false,
+      default: () => ({}),
+    },
+    canCreateFeedback: {
+      type: Boolean,
+      required: true,
+    },
+    canCreateIssue: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  computed: {
+    headline() {
+      return sprintf(
+        s__('SecurityDashboard|Pipeline %{pipelineLink} triggered'),
+        {
+          pipelineLink: `<a href="${this.pipeline.path}">#${this.pipeline.id}</a>`,
+        },
+        false,
+      );
+    },
+  },
+};
+</script>
+<template>
+  <div>
+    <div
+      v-if="hasPipelineData"
+      class="card security-dashboard prepend-top-default"
+    >
+      <div class="card-header">
+        <span class="js-security-dashboard-left">
+          <span v-html="headline"></span>
+          <timeago-tooltip :time="pipeline.created"/>
+          {{ __('by') }}
+          <user-avatar-link
+            :link-href="triggeredBy.path"
+            :img-src="triggeredBy.avatarPath"
+            :img-alt="triggeredBy.name"
+            :img-size="24"
+            :username="triggeredBy.name"
+            class="avatar-image-container"
+          />
+        </span>
+        <span class="js-security-dashboard-right pull-right">
+          <icon name="branch"/>
+          <a
+            :href="branch.path"
+            class="monospace"
+          >{{ branch.id }}</a>
+          <span class="text-muted prepend-left-5 append-right-5">&middot;</span>
+          <icon name="commit"/>
+          <a
+            :href="commit.path"
+            class="monospace"
+          >{{ commit.id }}</a>
+        </span>
+      </div>
+      <split-security-report
+        :pipeline-id="pipelineId"
+        :head-blob-path="headBlobPath"
+        :sast-head-path="sastHeadPath"
+        :dast-head-path="dastHeadPath"
+        :sast-container-head-path="sastContainerHeadPath"
+        :dependency-scanning-head-path="dependencyScanningHeadPath"
+        :sast-help-path="sastHelpPath"
+        :sast-container-help-path="sastContainerHelpPath"
+        :dast-help-path="dastHelpPath"
+        :dependency-scanning-help-path="dependencyScanningHelpPath"
+        :vulnerability-feedback-path="vulnerabilityFeedbackPath"
+        :vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
+        :can-create-feedback="canCreateFeedback"
+        :can-create-issue="canCreateIssue"
+        always-open
+      />
+    </div>
+    <empty-security-dashboard
+      v-else
+      :help-path="securityDashboardHelpPath"
+      :illustration-path="emptyStateIllustrationPath"
+    />
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/components/empty_security_dashboard.vue b/ee/app/assets/javascripts/vue_shared/security_reports/components/empty_security_dashboard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0defc046471251ff3f93ad960d2f3c45336b0f71
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_shared/security_reports/components/empty_security_dashboard.vue
@@ -0,0 +1,52 @@
+<script>
+import { s__ } from '~/locale';
+
+export default {
+  props: {
+    illustrationPath: {
+      type: String,
+      required: true,
+    },
+    helpPath: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    paragraphText: () =>
+      s__(
+        `SecurityDashboard|
+         The security dashboard displays the latest security report.
+         Use it to find and fix vulnerabilities.`,
+      ),
+  },
+};
+</script>
+<template>
+  <div class="row empty-state">
+    <div class="col-12">
+      <div class="svg-content">
+        <img
+          :src="illustrationPath"
+        />
+      </div>
+    </div>
+    <div class="col-12">
+      <div class="text-content text-center">
+        <h4>
+          {{ s__('SecurityDashboard|Monitor vulnerabilities in your code') }}
+        </h4>
+        <p>
+          {{ paragraphText }}
+        </p>
+        <a
+          :href="helpPath"
+          class="btn btn-new"
+          rel="nofollow"
+        >
+          {{ __('Learn more') }}
+        </a>
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue b/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue
index 290c563b3588aa9dd9b08b0530b191ea3e41f7df..29ae78adeab83cf2e9056e80af96dccf997c2ab2 100644
--- a/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue
+++ b/ee/app/assets/javascripts/vue_shared/security_reports/components/report_section.vue
@@ -1,7 +1,6 @@
 <script>
 import { __ } from '~/locale';
 import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
-import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
 import IssuesList from './issues_list.vue';
 import Popover from './help_popover.vue';
 import { LOADING, ERROR, SUCCESS } from '../store/constants';
@@ -10,11 +9,15 @@ export default {
   name: 'ReportSection',
   components: {
     IssuesList,
-    LoadingIcon,
     StatusIcon,
     Popover,
   },
   props: {
+    alwaysOpen: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     type: {
       type: String,
       required: false,
@@ -76,12 +79,14 @@ export default {
 
   data() {
     return {
-      collapseText: __('Expand'),
       isCollapsed: true,
     };
   },
 
   computed: {
+    collapseText() {
+      return this.isCollapsed ? __('Expand') : __('Collapse');
+    },
     isLoading() {
       return this.status === LOADING;
     },
@@ -91,7 +96,16 @@ export default {
     isSuccess() {
       return this.status === SUCCESS;
     },
+    isCollapsible() {
+      return !this.alwaysOpen && this.hasIssues;
+    },
+    isExpanded() {
+      return this.alwaysOpen || !this.isCollapsed;
+    },
     statusIconName() {
+      if (this.isLoading) {
+        return 'loading';
+      }
       if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) {
         return 'warning';
       }
@@ -116,13 +130,9 @@ export default {
       return Object.keys(this.popoverOptions).length > 0;
     },
   },
-
   methods: {
     toggleCollapsed() {
       this.isCollapsed = !this.isCollapsed;
-
-      const text = this.isCollapsed ? __('Expand') : __('Collapse');
-      this.collapseText = text;
     },
   },
 };
@@ -130,15 +140,9 @@ export default {
 <template>
   <section>
     <div
-      class="media prepend-top-default prepend-left-default
- append-right-default append-bottom-default"
+      class="media"
     >
-      <loading-icon
-        v-if="isLoading"
-        class="mr-widget-icon"
-      />
       <status-icon
-        v-else
         :status="statusIconName"
       />
       <div
@@ -157,7 +161,7 @@ export default {
         </span>
 
         <button
-          v-if="hasIssues"
+          v-if="isCollapsible"
           type="button"
           class="js-collapse-btn btn bt-default float-right btn-sm"
           @click="toggleCollapsed"
@@ -169,7 +173,7 @@ export default {
 
     <div
       v-if="hasIssues"
-      v-show="!isCollapsed"
+      v-show="isExpanded"
       class="js-report-section-container"
     >
       <slot name="body">
diff --git a/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue b/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue
index af95dc71494b7dda7214d14ce176288522aa171c..4b29f15172db64f21d9f1c3fa9df8b0eaf8f161b 100644
--- a/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue
+++ b/ee/app/assets/javascripts/vue_shared/security_reports/split_security_reports_app.vue
@@ -15,6 +15,11 @@ export default {
   },
   mixins: [mixin, reportsMixin],
   props: {
+    alwaysOpen: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     headBlobPath: {
       type: String,
       required: true,
@@ -210,6 +215,7 @@ export default {
   <div>
     <report-section
       v-if="sastHeadPath"
+      :always-open="alwaysOpen"
       :type="$options.sast"
       :status="checkReportStatus(sast.isLoading, sast.hasError)"
       :loading-text="translateText('SAST').loading"
@@ -223,6 +229,7 @@ export default {
 
     <report-section
       v-if="dependencyScanningHeadPath"
+      :always-open="alwaysOpen"
       :type="$options.sast"
       :status="checkReportStatus(dependencyScanning.isLoading, dependencyScanning.hasError)"
       :loading-text="translateText('Dependency scanning').loading"
@@ -236,6 +243,7 @@ export default {
 
     <report-section
       v-if="sastContainerHeadPath"
+      :always-open="alwaysOpen"
       :type="$options.sastContainer"
       :status="checkReportStatus(sastContainer.isLoading, sastContainer.hasError)"
       :loading-text="translateText('Container scanning').loading"
@@ -249,6 +257,7 @@ export default {
 
     <report-section
       v-if="dastHeadPath"
+      :always-open="alwaysOpen"
       :type="$options.dast"
       :status="checkReportStatus(dast.isLoading, dast.hasError)"
       :loading-text="translateText('DAST').loading"
diff --git a/ee/app/assets/stylesheets/pages/projects.scss b/ee/app/assets/stylesheets/pages/projects.scss
index 1f9a24e7ab020f0396acde07c7201ea19f339d21..d48ee36d4fe167a2d758cb01286686dbc2f4cf8a 100644
--- a/ee/app/assets/stylesheets/pages/projects.scss
+++ b/ee/app/assets/stylesheets/pages/projects.scss
@@ -112,3 +112,31 @@
     border-left: none;
   }
 }
+
+.security-dashboard {
+  .card-header {
+    padding: $gl-padding;
+    background-color: $gray-light;
+
+    .user-avatar-link {
+      color: $gl-text-color;
+      font-weight: $gl-font-weight-bold;
+
+      .avatar {
+        margin-right: $gl-padding-4;
+      }
+    }
+
+    svg {
+      vertical-align: sub;
+    }
+
+    .avatar {
+      float: none;
+    }
+  }
+
+  .split-report-section:last-of-type {
+    border-bottom: none;
+  }
+}
diff --git a/ee/app/assets/stylesheets/pages/security_reports.scss b/ee/app/assets/stylesheets/pages/security_reports.scss
index 20264a5f150519aeec5b974e974f883ad56908d3..b644c1372d6d03fc098c45f4c61c7ac157787b03 100644
--- a/ee/app/assets/stylesheets/pages/security_reports.scss
+++ b/ee/app/assets/stylesheets/pages/security_reports.scss
@@ -14,6 +14,22 @@
 
   .media {
     align-items: center;
+    padding: 10px;
+    line-height: 20px;
+
+    /*
+    This fixes the wrapping div of the icon in the report header.
+    Apparently the borderless status icons are half the size of the status icons with border.
+    This means we have to double the size of the wrapping div for borderless icons.
+    */
+    .space-children:first-child {
+      width: 32px;
+      height: 32px;
+      align-items: center;
+      justify-content: center;
+      margin-right: 5px;
+      margin-left: 1px;
+    }
   }
 
   .code-text {
@@ -108,7 +124,7 @@
 }
 
 .report-block-list-issue-description-text::after {
-  content: "\00a0";
+  content: '\00a0';
 }
 
 .report-block-list-issue-description {
diff --git a/ee/app/controllers/projects/security/dashboard_controller.rb b/ee/app/controllers/projects/security/dashboard_controller.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4be839987dad953649bc65a76c8fb47ce42df417
--- /dev/null
+++ b/ee/app/controllers/projects/security/dashboard_controller.rb
@@ -0,0 +1,18 @@
+module Projects
+  module Security
+    class DashboardController < Projects::ApplicationController
+      before_action :ensure_security_features_enabled
+      before_action :authorize_read_project_security_dashboard!
+
+      def show
+        @pipeline = @project.latest_pipeline_with_security_reports
+      end
+
+      private
+
+      def ensure_security_features_enabled
+        render_404 unless @project.security_reports_feature_available?
+      end
+    end
+  end
+end
diff --git a/ee/app/helpers/ee/projects_helper.rb b/ee/app/helpers/ee/projects_helper.rb
index 64eca85c936860103f90a3fa55a2d3e2d13f10bb..f50e69eae9cbd420ca871e248786be40d5aecff4 100644
--- a/ee/app/helpers/ee/projects_helper.rb
+++ b/ee/app/helpers/ee/projects_helper.rb
@@ -2,6 +2,11 @@ module EE
   module ProjectsHelper
     extend ::Gitlab::Utils::Override
 
+    override :sidebar_projects_paths
+    def sidebar_projects_paths
+      super + %w(projects/security/dashboard#show)
+    end
+
     override :sidebar_settings_paths
     def sidebar_settings_paths
       super + %w(audit_events#index)
@@ -95,5 +100,53 @@ def share_project_description
 
       description.to_s.html_safe
     end
+
+    def project_security_dashboard_config(project, pipeline)
+      if pipeline.nil?
+        {
+          empty_state_illustration_path: image_path('illustrations/security-dashboard_empty.svg'),
+          security_dashboard_help_path: help_page_path("user/project/security_dashboard"),
+          has_pipeline_data: "false",
+          can_create_feedback: "false",
+          can_create_issue: "false"
+        }
+      else
+        # Handle old job and artifact names for container scanning
+        sast_container_head_path = if pipeline.expose_sast_container_data?
+                                     sast_container_artifact_url(pipeline)
+                                   elsif pipeline.expose_container_scanning_data?
+                                     container_scanning_artifact_url(pipeline)
+                                   else
+                                     nil
+                                   end
+
+        {
+          head_blob_path: project_blob_path(project, pipeline.sha),
+          sast_head_path: pipeline.expose_sast_data? ? sast_artifact_url(pipeline) : nil,
+          dependency_scanning_head_path: pipeline.expose_dependency_scanning_data? ? dependency_scanning_artifact_url(pipeline) : nil,
+          dast_head_path: pipeline.expose_dast_data? ? dast_artifact_url(pipeline) : nil,
+          sast_container_head_path: sast_container_head_path,
+          vulnerability_feedback_path: project_vulnerability_feedback_index_path(project),
+          pipeline_id: pipeline.id,
+          vulnerability_feedback_help_path: help_page_path("user/project/merge_requests/index", anchor: "interacting-with-security-reports-ultimate"),
+          sast_help_path: help_page_path('user/project/merge_requests/sast'),
+          dependency_scanning_help_path: help_page_path('user/project/merge_requests/dependency_scanning'),
+          dast_help_path: help_page_path('user/project/merge_requests/dast'),
+          sast_container_help_path: help_page_path('user/project/merge_requests/sast_container'),
+          user_path: user_url(pipeline.user),
+          user_avatar_path: pipeline.user.avatar_url,
+          user_name: pipeline.user.name,
+          commit_id: pipeline.commit.short_id,
+          commit_path: project_commit_url(project, pipeline.commit),
+          ref_id: pipeline.ref,
+          ref_path: project_commits_url(project, pipeline.ref),
+          pipeline_path: pipeline_url(pipeline),
+          pipeline_created: pipeline.created_at.to_s,
+          has_pipeline_data: "true",
+          can_create_feedback: can?(current_user, :admin_vulnerability_feedback, project).to_s,
+          can_create_issue: can?(current_user, :create_issue, project).to_s
+        }
+      end
+    end
   end
 end
diff --git a/ee/app/models/ee/ci/pipeline.rb b/ee/app/models/ee/ci/pipeline.rb
index 1f24f206e58c688dba665aeb560f196c5587efbe..b5ee461230895e0c8d9dbb661be316cc2212469f 100644
--- a/ee/app/models/ee/ci/pipeline.rb
+++ b/ee/app/models/ee/ci/pipeline.rb
@@ -10,6 +10,10 @@ module Pipeline
 
       included do
         has_one :chat_data, class_name: 'Ci::PipelineChatData'
+
+        scope :with_security_reports, -> {
+          joins(:artifacts).where(ci_builds: { name: %w[sast dependency_scanning sast:container container_scanning dast] })
+        }
       end
 
       # codeclimate_artifact is deprecated and replaced with code_quality_artifact (#5779)
diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb
index 0cb9e626b1a265063f535700d8bca7f20925a3e4..858e4bf6a99b926b2cb5e7fdcf0860eea121f0a0 100644
--- a/ee/app/models/ee/project.rb
+++ b/ee/app/models/ee/project.rb
@@ -88,6 +88,17 @@ def with_slack_application_disabled
       end
     end
 
+    def security_reports_feature_available?
+      feature_available?(:sast) ||
+        feature_available?(:dependency_scanning) ||
+        feature_available?(:sast_container) ||
+        feature_available?(:dast)
+    end
+
+    def latest_pipeline_with_security_reports
+      pipelines.newest_first(default_branch).with_security_reports.first
+    end
+
     def ensure_external_webhook_token
       return if external_webhook_token.present?
 
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index a354788082164a862ebbfaf98e7edd71fbed0783..b74d1313497a5eb32e32a89e2bf35c2ce4c598ec 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -81,6 +81,7 @@ module ProjectPolicy
       rule { can?(:developer_access) }.policy do
         enable :admin_board
         enable :admin_vulnerability_feedback
+        enable :read_project_security_dashboard
       end
 
       rule { can?(:read_project) }.enable :read_vulnerability_feedback
diff --git a/ee/app/views/projects/security/dashboard/show.html.haml b/ee/app/views/projects/security/dashboard/show.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..3154cbfb060e0460f74aa167481c3dd6182bda91
--- /dev/null
+++ b/ee/app/views/projects/security/dashboard/show.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _("Security Dashboard")
+- page_title _("Security Dashboard")
+
+#js-security-report-app{ data: project_security_dashboard_config(@project, @pipeline) }
diff --git a/ee/app/views/projects/sidebar/_security_dashboard.html.haml b/ee/app/views/projects/sidebar/_security_dashboard.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..c1de616fc2bf25eaa36848ffc10fec792f8134d6
--- /dev/null
+++ b/ee/app/views/projects/sidebar/_security_dashboard.html.haml
@@ -0,0 +1,5 @@
+- return unless @project.security_reports_feature_available? && can?(current_user, :read_project_security_dashboard, @project)
+
+= nav_link(path: 'projects/security/dashboard#show') do
+  = link_to project_security_dashboard_path(@project), title: _('Security Dashboard'), class: 'shortcuts-project-security-dashboard' do
+    %span= _('Security Dashboard')
diff --git a/ee/changelogs/unreleased/6165_project_security_dashboard.yml b/ee/changelogs/unreleased/6165_project_security_dashboard.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b7ec3ca37df33edac75f66d9aaf02a03eb17d23c
--- /dev/null
+++ b/ee/changelogs/unreleased/6165_project_security_dashboard.yml
@@ -0,0 +1,5 @@
+---
+title: Add project Security Dashboard
+merge_request: 6197
+author:
+type: added
diff --git a/ee/spec/controllers/projects/security/dashboard_controller_spec.rb b/ee/spec/controllers/projects/security/dashboard_controller_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cdc28a1edbc8a7a49200bcd7590f13c8f8bd0245
--- /dev/null
+++ b/ee/spec/controllers/projects/security/dashboard_controller_spec.rb
@@ -0,0 +1,74 @@
+require 'spec_helper'
+
+describe Projects::Security::DashboardController do
+  let(:group)   { create(:group) }
+  let(:project) { create(:project, :public, namespace: group) }
+  let(:user)    { create(:user) }
+
+  before do
+    group.add_developer(user)
+  end
+
+  describe 'GET #show' do
+    let(:pipeline_1) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_2) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_3) { create(:ci_pipeline_without_jobs, project: project) }
+
+    before do
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'sast',
+        pipeline: pipeline_1,
+        options: {
+          artifacts: {
+            paths: [Ci::Build::SAST_FILE]
+          }
+        }
+      )
+    end
+
+    def show_security_dashboard(current_user = user)
+      sign_in(current_user)
+      get :show, namespace_id: project.namespace, project_id: project
+    end
+
+    context 'when security reports features are enabled' do
+      it 'returns the latest pipeline with security reports for project' do
+        stub_licensed_features(sast: true)
+
+        show_security_dashboard
+
+        expect(response).to have_gitlab_http_status(200)
+        expect(response).to render_template(:show)
+      end
+    end
+
+    context 'when security reports features are disabled' do
+      it 'returns the latest pipeline with security reports for project' do
+        stub_licensed_features(sast: false, dependency_scanning: false, sast_container: false, dast: false)
+
+        show_security_dashboard
+
+        expect(response).to have_gitlab_http_status(404)
+        expect(response).to render_template('errors/not_found')
+      end
+    end
+
+    context 'with unauthorized user for security dashboard' do
+      let(:guest) { create(:user) }
+
+      it 'returns a not found 404 response' do
+        stub_licensed_features(sast: true)
+
+        group.add_guest(guest)
+
+        show_security_dashboard guest
+
+        expect(response).to have_gitlab_http_status(404)
+        expect(response).to render_template('errors/access_denied')
+      end
+    end
+  end
+end
diff --git a/ee/spec/models/ci/pipeline_spec.rb b/ee/spec/models/ci/pipeline_spec.rb
index 9adb152a9925b808415d7b8446cb3aac1ca17178..f9cd9e2bcbe734f93298faddcebd912d6882fe99 100644
--- a/ee/spec/models/ci/pipeline_spec.rb
+++ b/ee/spec/models/ci/pipeline_spec.rb
@@ -96,4 +96,79 @@
       it { expect(pipeline.send(method.to_sym)).to be_truthy }
     end
   end
+
+  describe '#with_security_reports scope' do
+    let(:pipeline_1) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_2) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_3) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_4) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_5) { create(:ci_pipeline_without_jobs, project: project) }
+
+    before do
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'sast',
+        pipeline: pipeline_1,
+        options: {
+          artifacts: {
+            paths: [Ci::Build::SAST_FILE]
+          }
+        }
+      )
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'dependency_scanning',
+        pipeline: pipeline_2,
+        options: {
+          artifacts: {
+            paths: [Ci::Build::DEPENDENCY_SCANNING_FILE]
+          }
+        }
+      )
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'container_scanning',
+        pipeline: pipeline_3,
+        options: {
+          artifacts: {
+            paths: [Ci::Build::CONTAINER_SCANNING_FILE]
+          }
+        }
+      )
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'dast',
+        pipeline: pipeline_4,
+        options: {
+          artifacts: {
+            paths: [Ci::Build::DAST_FILE]
+          }
+        }
+      )
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'foobar',
+        pipeline: pipeline_5,
+        options: {
+          artifacts: {
+            paths: ['foobar-report.json']
+          }
+        }
+      )
+    end
+
+    it "returns pipeline with security reports" do
+      expect(described_class.with_security_reports).to eq([pipeline_1, pipeline_2, pipeline_3, pipeline_4])
+    end
+  end
 end
diff --git a/ee/spec/models/project_spec.rb b/ee/spec/models/project_spec.rb
index 28ec4bad9ba63d5f5fa4167a53282a25b04a180b..fc698e6fe6b9610e6751fa0cf00077a7c0c86dc9 100644
--- a/ee/spec/models/project_spec.rb
+++ b/ee/spec/models/project_spec.rb
@@ -1420,4 +1420,65 @@
       2.times { expect(project.any_path_locks?).to be_truthy }
     end
   end
+
+  describe '#security_reports_feature_available?' do
+    security_features = %i[sast dependency_scanning sast_container dast]
+
+    let(:project) { create(:project) }
+
+    security_features.each do |feature|
+      it "returns true when at least #{feature} is enabled" do
+        allow(project).to receive(:feature_available?) { false }
+        allow(project).to receive(:feature_available?).with(feature) { true }
+
+        expect(project.security_reports_feature_available?).to eq(true)
+      end
+    end
+
+    it "returns false when all security features are disabled" do
+      security_features.each do |feature|
+        allow(project).to receive(:feature_available?).with(feature) { false }
+      end
+
+      expect(project.security_reports_feature_available?).to eq(false)
+    end
+  end
+
+  describe '#latest_pipeline_with_security_reports' do
+    let(:project) { create(:project) }
+    let(:pipeline_1) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_2) { create(:ci_pipeline_without_jobs, project: project) }
+    let(:pipeline_3) { create(:ci_pipeline_without_jobs, project: project) }
+
+    before do
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'sast',
+        pipeline: pipeline_1,
+        options: {
+          artifacts: {
+            paths: [Ci::Build::SAST_FILE]
+          }
+        }
+      )
+      create(
+        :ci_build,
+        :success,
+        :artifacts,
+        name: 'sast',
+        pipeline: pipeline_2,
+        options: {
+          artifacts: {
+            paths: [Ci::Build::SAST_FILE]
+          }
+        }
+      )
+    end
+
+    it "returns the latest pipeline with security reports" do
+      expect(project.latest_pipeline_with_security_reports).to eq(pipeline_2)
+    end
+  end
 end
diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb
index d64f5a83219c45c23fbe829e3e687304782dc76d..ed0f55ea45719c56083d40db8acf239a4cd73c6a 100644
--- a/ee/spec/policies/project_policy_spec.rb
+++ b/ee/spec/policies/project_policy_spec.rb
@@ -303,4 +303,56 @@
       it { is_expected.to be_disallowed(:admin_vulnerability_feedback) }
     end
   end
+
+  describe 'read_project_security_dashboard' do
+    subject { described_class.new(current_user, project) }
+
+    context 'with admin' do
+      let(:current_user) { admin }
+
+      it { is_expected.to be_allowed(:read_project_security_dashboard) }
+    end
+
+    context 'with owner' do
+      let(:current_user) { owner }
+
+      it { is_expected.to be_allowed(:read_project_security_dashboard) }
+    end
+
+    context 'with master' do
+      let(:current_user) { master }
+
+      it { is_expected.to be_allowed(:read_project_security_dashboard) }
+    end
+
+    context 'with developer' do
+      let(:current_user) { developer }
+
+      it { is_expected.to be_allowed(:read_project_security_dashboard) }
+    end
+
+    context 'with reporter' do
+      let(:current_user) { reporter }
+
+      it { is_expected.to be_disallowed(:read_project_security_dashboard) }
+    end
+
+    context 'with guest' do
+      let(:current_user) { guest }
+
+      it { is_expected.to be_disallowed(:read_project_security_dashboard) }
+    end
+
+    context 'with non member' do
+      let(:current_user) { create(:user) }
+
+      it { is_expected.to be_disallowed(:read_project_security_dashboard) }
+    end
+
+    context 'with anonymous' do
+      let(:current_user) { nil }
+
+      it { is_expected.to be_disallowed(:read_project_security_dashboard) }
+    end
+  end
 end
diff --git a/spec/javascripts/vue_shared/security_reports/card_security_reports_app_spec.js b/spec/javascripts/vue_shared/security_reports/card_security_reports_app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef355b79f35e8de32de9db30d18d44d1f68ca61e
--- /dev/null
+++ b/spec/javascripts/vue_shared/security_reports/card_security_reports_app_spec.js
@@ -0,0 +1,259 @@
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import { TEST_HOST } from 'spec/test_constants';
+
+import component from 'ee/vue_shared/security_reports/card_security_reports_app.vue';
+import createStore from 'ee/vue_shared/security_reports/store';
+import state from 'ee/vue_shared/security_reports/store/state';
+import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import { trimText } from 'spec/helpers/vue_component_helper';
+
+import { sastIssues, dast, dockerReport } from './mock_data';
+
+describe('Card security reports app', () => {
+  const Component = Vue.extend(component);
+
+  let vm;
+  let mock;
+
+  const runDate = new Date();
+  runDate.setDate(runDate.getDate() - 7);
+
+  beforeEach(() => {
+    mock = new MockAdapter(axios);
+
+    vm = mountComponentWithStore(Component, {
+      store: createStore(),
+      props: {
+        hasPipelineData: true,
+        emptyStateIllustrationPath: `${TEST_HOST}/img`,
+        securityDashboardHelpPath: `${TEST_HOST}/help_dashboard`,
+        commit: {
+          id: '1234adf',
+          path: `${TEST_HOST}/commit`,
+        },
+        branch: {
+          id: 'master',
+          path: `${TEST_HOST}/branch`,
+        },
+        pipeline: {
+          id: '55',
+          created: runDate.toISOString(),
+          path: `${TEST_HOST}/pipeline`,
+        },
+        triggeredBy: {
+          path: `${TEST_HOST}/user`,
+          avatarPath: `${TEST_HOST}/img`,
+          name: 'TestUser',
+        },
+        headBlobPath: 'path',
+        baseBlobPath: 'path',
+        sastHeadPath: `${TEST_HOST}/sast_head`,
+        dependencyScanningHeadPath: `${TEST_HOST}/dss_head`,
+        dastHeadPath: `${TEST_HOST}/dast_head`,
+        sastContainerHeadPath: `${TEST_HOST}/sast_container_head`,
+        sastHelpPath: 'path',
+        dependencyScanningHelpPath: 'path',
+        vulnerabilityFeedbackPath: `${TEST_HOST}/vulnerability_feedback_path`,
+        vulnerabilityFeedbackHelpPath: 'path',
+        dastHelpPath: 'path',
+        sastContainerHelpPath: 'path',
+        pipelineId: 123,
+        canCreateFeedback: true,
+        canCreateIssue: true,
+      },
+    });
+  });
+
+  afterEach(() => {
+    vm.$store.replaceState(state());
+    vm.$destroy();
+    mock.restore();
+  });
+
+  describe('computed properties', () => {
+    describe('headline', () => {
+      it('renders `Pipeline <link> triggered`', () => {
+        expect(vm.headline).toBe(`Pipeline <a href="${TEST_HOST}/pipeline">#55</a> triggered`);
+      });
+    });
+  });
+
+  describe('Headline renders', () => {
+    it('pipeline metadata information', () => {
+      const element = vm.$el.querySelector('.card-header .js-security-dashboard-left');
+
+      expect(trimText(element.textContent)).toBe('Pipeline #55 triggered 1 week ago by TestUser');
+
+      const pipelineLink = element.querySelector(`a[href="${TEST_HOST}/pipeline"]`);
+
+      expect(pipelineLink).not.toBeNull();
+      expect(pipelineLink.textContent).toBe('#55');
+
+      const userAvatarLink = element.querySelector('a.user-avatar-link');
+
+      expect(userAvatarLink).not.toBeNull();
+      expect(userAvatarLink.getAttribute('href')).toBe(`${TEST_HOST}/user`);
+      expect(userAvatarLink.querySelector('img').getAttribute('src')).toBe(`${TEST_HOST}/img`);
+      expect(userAvatarLink.textContent).toBe('TestUser');
+    });
+
+    it('branch and commit information', () => {
+      const branchIcon = vm.$el.querySelector(
+        '.card-header .js-security-dashboard-right .ic-branch',
+      );
+
+      expect(branchIcon).not.toBeNull();
+
+      const branchLink = branchIcon.nextElementSibling;
+
+      expect(branchLink).not.toBeNull();
+      expect(branchLink.textContent).toBe('master');
+      expect(branchLink.getAttribute('href')).toBe(`${TEST_HOST}/branch`);
+
+      const middot = branchLink.nextElementSibling;
+
+      expect(middot).not.toBeNull();
+      expect(middot.textContent).toBe('·');
+
+      const commitIcon = middot.nextElementSibling;
+
+      expect(commitIcon).not.toBeNull();
+      expect(commitIcon.classList).toContain('ic-commit');
+
+      const commitLink = commitIcon.nextElementSibling;
+
+      expect(commitLink).not.toBeNull();
+      expect(commitLink.textContent).toContain('1234adf');
+      expect(commitLink.getAttribute('href')).toBe(`${TEST_HOST}/commit`);
+    });
+  });
+
+  describe('Empty State renders correctly', () => {
+    beforeEach(done => {
+      vm.hasPipelineData = false;
+      Vue.nextTick(done);
+    });
+
+    it('image illustration is set to defined path', () => {
+      const imgEl = vm.$el.querySelector('img');
+
+      expect(imgEl.getAttribute('src')).toBe(`${TEST_HOST}/img`);
+    });
+
+    it('headline text is to `Monitor vulnerabilities in your code`', () => {
+      const headingEl = vm.$el.querySelector('h4');
+
+      expect(headingEl.textContent.trim()).toBe('Monitor vulnerabilities in your code');
+    });
+
+    it('paragraph text is to `The security dashboard...`', () => {
+      const paragraphEl = vm.$el.querySelector('p');
+
+      expect(trimText(paragraphEl.textContent)).toBe(
+        'The security dashboard displays the latest security report. Use it to find and fix vulnerabilities.',
+      );
+    });
+
+    it('learn more link has correct path and text', () => {
+      const linkEl = vm.$el.querySelector('a');
+
+      expect(linkEl.textContent.trim()).toBe('Learn more');
+      expect(linkEl.getAttribute('href')).toBe(`${TEST_HOST}/help_dashboard`);
+    });
+  });
+
+  describe('Report renders correctly', () => {
+    describe('while loading', () => {
+      beforeEach(() => {
+        mock.onGet(`${TEST_HOST}/sast_head`).reply(200, sastIssues);
+        mock.onGet(`${TEST_HOST}/dss_head`).reply(200, sastIssues);
+        mock.onGet(`${TEST_HOST}/dast_head`).reply(200, dast);
+        mock.onGet(`${TEST_HOST}/sast_container_head`).reply(200, dockerReport);
+        mock.onGet(`${TEST_HOST}/vulnerability_feedback_path`).reply(200, []);
+      });
+
+      it('renders loading summary text + spinner', done => {
+        expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull();
+
+        expect(vm.$el.textContent).toContain('SAST is loading');
+        expect(vm.$el.textContent).toContain('Dependency scanning is loading');
+        expect(vm.$el.textContent).toContain('Container scanning is loading');
+        expect(vm.$el.textContent).toContain('DAST is loading');
+
+        setTimeout(() => {
+          done();
+        }, 0);
+      });
+    });
+
+    describe('with all reports', () => {
+      beforeEach(() => {
+        mock.onGet(`${TEST_HOST}/sast_head`).reply(200, sastIssues);
+        mock.onGet(`${TEST_HOST}/dss_head`).reply(200, sastIssues);
+        mock.onGet(`${TEST_HOST}/dast_head`).reply(200, dast);
+        mock.onGet(`${TEST_HOST}/sast_container_head`).reply(200, dockerReport);
+        mock.onGet(`${TEST_HOST}/vulnerability_feedback_path`).reply(200, []);
+      });
+
+      it('renders reports', done => {
+        setTimeout(() => {
+          expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
+
+          expect(vm.$el.textContent).toContain('SAST detected 3 vulnerabilities');
+          expect(vm.$el.textContent).toContain('Dependency scanning detected 3 vulnerabilities');
+
+          // Renders container scanning result
+          expect(vm.$el.textContent).toContain('Container scanning detected 2 vulnerabilities');
+
+          // Renders DAST result
+          expect(vm.$el.textContent).toContain('DAST detected 2 vulnerabilities');
+
+          done();
+        }, 0);
+      });
+
+      it('renders all reports expanded and with no way to collapse', done => {
+        setTimeout(() => {
+          expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
+          expect(vm.$el.querySelector('.js-collapse-btn')).toBeNull();
+
+          const reports = vm.$el.querySelectorAll('.js-report-section-container');
+
+          reports.forEach(report => {
+            expect(report).not.toHaveCss({ display: 'none' });
+          });
+
+          done();
+        }, 0);
+      });
+    });
+
+    describe('with error', () => {
+      beforeEach(() => {
+        mock.onGet(`${TEST_HOST}/sast_head`).reply(500);
+        mock.onGet(`${TEST_HOST}/dss_head`).reply(500);
+        mock.onGet(`${TEST_HOST}/dast_head`).reply(500);
+        mock.onGet(`${TEST_HOST}/sast_container_head`).reply(500);
+        mock.onGet(`${TEST_HOST}/vulnerability_feedback_path`).reply(500, []);
+      });
+
+      it('renders error state', done => {
+        setTimeout(() => {
+          expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
+
+          expect(vm.$el.textContent).toContain('SAST resulted in error while loading results');
+          expect(vm.$el.textContent).toContain(
+            'Dependency scanning resulted in error while loading results',
+          );
+          expect(vm.$el.textContent).toContain(
+            'Container scanning resulted in error while loading results',
+          );
+          expect(vm.$el.textContent).toContain('DAST resulted in error while loading results');
+          done();
+        }, 0);
+      });
+    });
+  });
+});
diff --git a/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js b/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js
index f2c1e33aa43af605566ef6155c20d601c0d8efde..095ee338ee886440506402045681166903cd85ad 100644
--- a/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js
+++ b/spec/javascripts/vue_shared/security_reports/components/report_section_spec.js
@@ -5,16 +5,78 @@ import { codequalityParsedIssues } from 'spec/vue_mr_widget/mock_data';
 
 describe('Report section', () => {
   let vm;
-  let ReportSection;
-
-  beforeEach(() => {
-    ReportSection = Vue.extend(reportSection);
-  });
+  const ReportSection = Vue.extend(reportSection);
 
   afterEach(() => {
     vm.$destroy();
   });
 
+  describe('computed', () => {
+    beforeEach(() => {
+      vm = mountComponent(ReportSection, {
+        type: 'codequality',
+        status: 'SUCCESS',
+        loadingText: 'Loading codeclimate report',
+        errorText: 'foo',
+        successText: 'Code quality improved on 1 point and degraded on 1 point',
+        resolvedIssues: codequalityParsedIssues,
+        hasIssues: false,
+        alwaysOpen: false,
+      });
+    });
+
+    describe('isCollapsible', () => {
+      const testMatrix = [
+        { hasIssues: false, alwaysOpen: false, isCollapsible: false },
+        { hasIssues: false, alwaysOpen: true, isCollapsible: false },
+        { hasIssues: true, alwaysOpen: false, isCollapsible: true },
+        { hasIssues: true, alwaysOpen: true, isCollapsible: false },
+      ];
+
+      testMatrix.forEach(({ hasIssues, alwaysOpen, isCollapsible }) => {
+        const issues = hasIssues ? 'has issues' : 'has no issues';
+        const open = alwaysOpen ? 'is always open' : 'is not always open';
+
+        it(`is ${isCollapsible}, if the report ${issues} and ${open}`, done => {
+          vm.hasIssues = hasIssues;
+          vm.alwaysOpen = alwaysOpen;
+
+          Vue.nextTick()
+            .then(() => {
+              expect(vm.isCollapsible).toBe(isCollapsible);
+            })
+            .then(done)
+            .catch(done.fail);
+        });
+      });
+    });
+
+    describe('isExpanded', () => {
+      const testMatrix = [
+        { isCollapsed: false, alwaysOpen: false, isExpanded: true },
+        { isCollapsed: false, alwaysOpen: true, isExpanded: true },
+        { isCollapsed: true, alwaysOpen: false, isExpanded: false },
+        { isCollapsed: true, alwaysOpen: true, isExpanded: true },
+      ];
+
+      testMatrix.forEach(({ isCollapsed, alwaysOpen, isExpanded }) => {
+        const issues = isCollapsed ? 'is collapsed' : 'is not collapsed';
+        const open = alwaysOpen ? 'is always open' : 'is not always open';
+
+        it(`is ${isExpanded}, if the report ${issues} and ${open}`, done => {
+          vm.isCollapsed = isCollapsed;
+          vm.alwaysOpen = alwaysOpen;
+
+          Vue.nextTick()
+            .then(() => {
+              expect(vm.isExpanded).toBe(isExpanded);
+            })
+            .then(done)
+            .catch(done.fail);
+        });
+      });
+    });
+  });
   describe('when it is loading', () => {
     it('should render loading indicator', () => {
       vm = mountComponent(ReportSection, {
@@ -30,7 +92,7 @@ describe('Report section', () => {
   });
 
   describe('with success status', () => {
-    it('should render provided data', () => {
+    beforeEach(() => {
       vm = mountComponent(ReportSection, {
         type: 'codequality',
         status: 'SUCCESS',
@@ -40,51 +102,49 @@ describe('Report section', () => {
         resolvedIssues: codequalityParsedIssues,
         hasIssues: true,
       });
+    });
 
-      expect(
-        vm.$el.querySelector('.js-code-text').textContent.trim(),
-      ).toEqual('Code quality improved on 1 point and degraded on 1 point');
+    it('should render provided data', () => {
+      expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
+        'Code quality improved on 1 point and degraded on 1 point',
+      );
 
-      expect(
-        vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length,
-      ).toEqual(codequalityParsedIssues.length);
+      expect(vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length).toEqual(
+        codequalityParsedIssues.length,
+      );
     });
 
     describe('toggleCollapsed', () => {
-      it('toggles issues', (done) => {
-        vm = mountComponent(ReportSection, {
-          type: 'codequality',
-          status: 'SUCCESS',
-          loadingText: 'Loading codeclimate report',
-          errorText: 'foo',
-          successText: 'Code quality improved on 1 point and degraded on 1 point',
-          resolvedIssues: codequalityParsedIssues,
-          hasIssues: true,
-        });
+      const hiddenCss = { display: 'none' };
 
+      it('toggles issues', done => {
         vm.$el.querySelector('button').click();
 
-        Vue.nextTick(() => {
-          expect(
-            vm.$el.querySelector('.js-report-section-container').getAttribute('style'),
-          ).toEqual('');
-          expect(
-            vm.$el.querySelector('button').textContent.trim(),
-          ).toEqual('Collapse');
-
-          vm.$el.querySelector('button').click();
-
-          Vue.nextTick(() => {
-            expect(
-              vm.$el.querySelector('.js-report-section-container').getAttribute('style'),
-            ).toEqual('display: none;');
-            expect(
-              vm.$el.querySelector('button').textContent.trim(),
-            ).toEqual('Expand');
-
-            done();
-          });
-        });
+        Vue.nextTick()
+          .then(() => {
+            expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss);
+            expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Collapse');
+
+            vm.$el.querySelector('button').click();
+          })
+          .then(Vue.nextTick)
+          .then(() => {
+            expect(vm.$el.querySelector('.js-report-section-container')).toHaveCss(hiddenCss);
+            expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Expand');
+          })
+          .then(done)
+          .catch(done.fail);
+      });
+
+      it('is always expanded, if always-open is set to true', done => {
+        vm.alwaysOpen = true;
+        Vue.nextTick()
+          .then(() => {
+            expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss);
+            expect(vm.$el.querySelector('button')).toBeNull();
+          })
+          .then(done)
+          .catch(done.fail);
       });
     });
   });
@@ -107,69 +167,78 @@ describe('Report section', () => {
     beforeEach(() => {
       vm = mountComponent(ReportSection, {
         status: 'SUCCESS',
-        successText: 'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability',
+        successText:
+          'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability',
         type: 'SAST',
         errorText: 'Failed to load security report',
         hasIssues: true,
         loadingText: 'Loading security report',
-        resolvedIssues: [{
-          cve: 'CVE-2016-9999',
-          file: 'Gemfile.lock',
-          message: 'Test Information Leak Vulnerability in Action View',
-          title: 'Test Information Leak Vulnerability in Action View',
-          path: 'Gemfile.lock',
-          solution: 'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
-          tool: 'bundler_audit',
-          url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
-          urlPath: '/Gemfile.lock',
-        }],
-        unresolvedIssues: [{
-          cve: 'CVE-2014-7829',
-          file: 'Gemfile.lock',
-          message: 'Arbitrary file existence disclosure in Action Pack',
-          title: 'Arbitrary file existence disclosure in Action Pack',
-          path: 'Gemfile.lock',
-          solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
-          tool: 'bundler_audit',
-          url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
-          urlPath: '/Gemfile.lock',
-        }],
-        allIssues: [{
-          cve: 'CVE-2016-0752',
-          file: 'Gemfile.lock',
-          message: 'Possible Information Leak Vulnerability in Action View',
-          title: 'Possible Information Leak Vulnerability in Action View',
-          path: 'Gemfile.lock',
-          solution: 'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
-          tool: 'bundler_audit',
-          url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
-          urlPath: '/Gemfile.lock',
-        }],
+        resolvedIssues: [
+          {
+            cve: 'CVE-2016-9999',
+            file: 'Gemfile.lock',
+            message: 'Test Information Leak Vulnerability in Action View',
+            title: 'Test Information Leak Vulnerability in Action View',
+            path: 'Gemfile.lock',
+            solution:
+              'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
+            tool: 'bundler_audit',
+            url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
+            urlPath: '/Gemfile.lock',
+          },
+        ],
+        unresolvedIssues: [
+          {
+            cve: 'CVE-2014-7829',
+            file: 'Gemfile.lock',
+            message: 'Arbitrary file existence disclosure in Action Pack',
+            title: 'Arbitrary file existence disclosure in Action Pack',
+            path: 'Gemfile.lock',
+            solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
+            tool: 'bundler_audit',
+            url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
+            urlPath: '/Gemfile.lock',
+          },
+        ],
+        allIssues: [
+          {
+            cve: 'CVE-2016-0752',
+            file: 'Gemfile.lock',
+            message: 'Possible Information Leak Vulnerability in Action View',
+            title: 'Possible Information Leak Vulnerability in Action View',
+            path: 'Gemfile.lock',
+            solution:
+              'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
+            tool: 'bundler_audit',
+            url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
+            urlPath: '/Gemfile.lock',
+          },
+        ],
       });
     });
 
-    it('should render full report section', (done) => {
+    it('should render full report section', done => {
       vm.$el.querySelector('button').click();
 
       Vue.nextTick(() => {
-        expect(
-          vm.$el.querySelector('.js-expand-full-list').textContent.trim(),
-        ).toEqual('Show complete code vulnerabilities report');
+        expect(vm.$el.querySelector('.js-expand-full-list').textContent.trim()).toEqual(
+          'Show complete code vulnerabilities report',
+        );
 
         done();
       });
     });
 
-    it('should expand full list when clicked and hide the show all button', (done) => {
+    it('should expand full list when clicked and hide the show all button', done => {
       vm.$el.querySelector('button').click();
 
       Vue.nextTick(() => {
         vm.$el.querySelector('.js-expand-full-list').click();
 
         Vue.nextTick(() => {
-          expect(
-            vm.$el.querySelector('.js-mr-code-all-issues').textContent.trim(),
-          ).toContain('Possible Information Leak Vulnerability in Action View');
+          expect(vm.$el.querySelector('.js-mr-code-all-issues').textContent.trim()).toContain(
+            'Possible Information Leak Vulnerability in Action View',
+          );
 
           done();
         });
diff --git a/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js b/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js
index 1fc7a5ed29f64b03437fe788f67c7d734e5f2fbb..a6a3c07defd584ae0becf1a249d6d4fbaecb430e 100644
--- a/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js
+++ b/spec/javascripts/vue_shared/security_reports/grouped_security_reports_app_spec.js
@@ -3,7 +3,8 @@ import MockAdapter from 'axios-mock-adapter';
 import axios from '~/lib/utils/axios_utils';
 import component from 'ee/vue_shared/security_reports/grouped_security_reports_app.vue';
 import state from 'ee/vue_shared/security_reports/store/state';
-import mountComponent from '../../helpers/vue_mount_component_helper';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { trimText } from 'spec/helpers/vue_component_helper';
 import {
   sastIssues,
   sastIssuesBase,
@@ -20,13 +21,6 @@ describe('Grouped security reports app', () => {
   let mock;
   const Component = Vue.extend(component);
 
-  function removeBreakLine(data) {
-    return data
-      .replace(/\r?\n|\r/g, '')
-      .replace(/\s\s+/g, ' ')
-      .trim();
-  }
-
   beforeEach(() => {
     mock = new MockAdapter(axios);
   });
@@ -80,10 +74,10 @@ describe('Grouped security reports app', () => {
         );
         expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
 
-        expect(removeBreakLine(vm.$el.textContent)).toContain(
+        expect(trimText(vm.$el.textContent)).toContain(
           'SAST resulted in error while loading results',
         );
-        expect(removeBreakLine(vm.$el.textContent)).toContain(
+        expect(trimText(vm.$el.textContent)).toContain(
           'Dependency scanning resulted in error while loading results',
         );
         expect(vm.$el.textContent).toContain(
@@ -130,7 +124,7 @@ describe('Grouped security reports app', () => {
       });
     });
 
-    it('renders loading summary text + spinner', (done) => {
+    it('renders loading summary text + spinner', done => {
       expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull();
       expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual(
         'Security scanning is loading',
@@ -197,12 +191,12 @@ describe('Grouped security reports app', () => {
         expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
 
         // Renders Sast result
-        expect(removeBreakLine(vm.$el.textContent)).toContain(
+        expect(trimText(vm.$el.textContent)).toContain(
           'SAST detected 2 new vulnerabilities and 1 fixed vulnerability',
         );
 
         // Renders DSS result
-        expect(removeBreakLine(vm.$el.textContent)).toContain(
+        expect(trimText(vm.$el.textContent)).toContain(
           'Dependency scanning detected 2 new vulnerabilities and 1 fixed vulnerability',
         );
         // Renders container scanning result
@@ -214,12 +208,14 @@ describe('Grouped security reports app', () => {
       }, 0);
     });
 
-    it('opens modal with more information', (done) => {
+    it('opens modal with more information', done => {
       setTimeout(() => {
         vm.$el.querySelector('.break-link').click();
 
         Vue.nextTick(() => {
-          expect(vm.$el.querySelector('.modal-title').textContent.trim()).toEqual(sastIssues[0].message);
+          expect(vm.$el.querySelector('.modal-title').textContent.trim()).toEqual(
+            sastIssues[0].message,
+          );
           expect(vm.$el.querySelector('.modal-body').textContent).toContain(sastIssues[0].solution);
 
           done();
diff --git a/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js b/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js
index 4e8f7e7494dbe0063ae8bbbd8e557da229c45344..8141cd64a3ba9e0f83ad7ad9c2746ee38d68401f 100644
--- a/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js
+++ b/spec/javascripts/vue_shared/security_reports/split_security_reports_app_spec.js
@@ -7,19 +7,12 @@ import state from 'ee/vue_shared/security_reports/store/state';
 import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
 import { sastIssues, dast, dockerReport } from './mock_data';
 
-describe('Slipt security reports app', () => {
+describe('Split security reports app', () => {
   const Component = Vue.extend(component);
 
   let vm;
   let mock;
 
-  function removeBreakLine(data) {
-    return data
-      .replace(/\r?\n|\r/g, '')
-      .replace(/\s\s+/g, ' ')
-      .trim();
-  }
-
   beforeEach(() => {
     mock = new MockAdapter(axios);
   });
@@ -107,18 +100,48 @@ describe('Slipt security reports app', () => {
     it('renders reports', done => {
       setTimeout(() => {
         expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
-        expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
 
-        expect(removeBreakLine(vm.$el.textContent)).toContain('SAST detected 3 vulnerabilities');
-        expect(removeBreakLine(vm.$el.textContent)).toContain(
-          'Dependency scanning detected 3 vulnerabilities',
-        );
+        expect(vm.$el.textContent).toContain('SAST detected 3 vulnerabilities');
+        expect(vm.$el.textContent).toContain('Dependency scanning detected 3 vulnerabilities');
 
         // Renders container scanning result
         expect(vm.$el.textContent).toContain('Container scanning detected 2 vulnerabilities');
 
         // Renders DAST result
         expect(vm.$el.textContent).toContain('DAST detected 2 vulnerabilities');
+
+        done();
+      }, 0);
+    });
+
+    it('renders all reports collapsed by default', done => {
+      setTimeout(() => {
+        expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
+        expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
+
+        const reports = vm.$el.querySelectorAll('.js-report-section-container');
+
+        reports.forEach(report => {
+          expect(report).toHaveCss({ display: 'none' });
+        });
+
+        done();
+      }, 0);
+    });
+
+    it('renders all reports expanded with the option always-open', done => {
+      vm.alwaysOpen = true;
+
+      setTimeout(() => {
+        expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
+        expect(vm.$el.querySelector('.js-collapse-btn')).toBeNull();
+
+        const reports = vm.$el.querySelectorAll('.js-report-section-container');
+
+        reports.forEach(report => {
+          expect(report).not.toHaveCss({ display: 'none' });
+        });
+
         done();
       }, 0);
     });
@@ -158,8 +181,8 @@ describe('Slipt security reports app', () => {
       setTimeout(() => {
         expect(vm.$el.querySelector('.fa-spinner')).toBeNull();
 
-        expect(removeBreakLine(vm.$el.textContent)).toContain('SAST resulted in error while loading results');
-        expect(removeBreakLine(vm.$el.textContent)).toContain(
+        expect(vm.$el.textContent).toContain('SAST resulted in error while loading results');
+        expect(vm.$el.textContent).toContain(
           'Dependency scanning resulted in error while loading results',
         );
         expect(vm.$el.textContent).toContain(