Lesson 2: Setting up Security Scanners and Policies#

In this lesson you will configure all of GitLab’s built-in security scanners, enable Secret Push Protection, integrate a third-party scanner via SARIF, enable security training, and create the three types of security policies that prevent insecure code from being merged.

This lesson covers:

  1. What security scans GitLab offers
  2. Adding security scans to the pipeline
  3. Configuring scanners with security configuration profiles
  4. Configuring security scanners
  5. Using DAST scan and site profiles
  6. Enabling Secret Push Protection
  7. Integrating a third-party scanner using SARIF
  8. Enabling security training
  9. Understanding security policies
  10. Creating a merge request approval policy (vulnerabilities)
  11. Creating a scan execution policy (IaC scanning)
  12. Injecting custom jobs using pipeline execution policies

What security scans GitLab offers#

GitLab provides a comprehensive set of built-in security scanners so you can detect vulnerabilities across your entire application stack — from source code, to dependencies, to running services — without integrating a collection of third-party tools. Detection rules are maintained by GitLab’s Security Research team and are updated continuously.

Software dependency advisories live in the GitLab Advisory Database, which combines the public advisory database with GitLab-curated entries.

GitLab provides the following security scanners:

ScannerWhat it does
Static Application Security Testing (SAST)Analyzes source code for known vulnerability patterns. GitLab Advanced SAST extends this with cross-file, cross-function dataflow (“taint”) analysis and produces a code flow trace from source to sink.
Dynamic Application Security Testing (DAST)Scans your running application by sending requests and analyzing responses for vulnerabilities (requires a deployed application).
API Security TestingPerforms dynamic scans of your APIs using an OpenAPI definition, HAR file, or Postman collection.
Container ScanningScans container images for known vulnerabilities in OS packages and language libraries. Also runs as Continuous Container Scanning against the registry so newly published CVEs surface without a code change.
Dependency ScanningScans project dependencies for known vulnerabilities and detects each dependency’s software license. Supports Static Reachability to prioritize vulnerabilities that are actually called from your code.
Secret DetectionScans the repository for accidentally committed secrets (API keys, tokens, passwords). Secret Push Protection blocks secrets before they land on the server.
Infrastructure as Code (IaC) ScanningScans Terraform, Ansible, CloudFormation, Kubernetes, and Helm files for misconfigurations and known vulnerabilities.

When a scanner detects a vulnerability, it provides the following information:

  • Description of the vulnerability
  • When it was detected
  • Current status (Detected, Confirmed, Dismissed, Resolved)
  • Severity with risk assessment data (CVSS score, EPSS probability, KEV status, reachability)
  • Location including the file path and line number
  • Scanner type that found it
  • Evidence supporting the finding
  • Relevant links including educational resources, training, and solutions
  • Identifiers such as CVE and CWE references
  • Request/Response data (for dynamic scans only)

Adding security scans to the pipeline#

Security scanners can be added to your pipeline in three different ways. The most common method is including GitLab-provided CI templates in your .gitlab-ci.yml, which gives you full control over scanner configuration using the GitLab CI/CD job syntax.

This project already has security scanners configured using CI templates, as seen in the .gitlab-ci.yml:

include:
  - template: Jobs/Container-Scanning.gitlab-ci.yml
  - template: Jobs/Dependency-Scanning.v2.gitlab-ci.yml
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml
  - template: Security/DAST.gitlab-ci.yml

The Dependency-Scanning.v2 template is the latest version and includes both vulnerability detection and license scanning in a single scanner.

The three methods for adding scanners are:

  1. CI Templates (recommended) — Add GitLab-maintained CI templates to your .gitlab-ci.yml. This is the most flexible approach and is what this project uses. See the documentation for details.

  2. Security configuration profiles — From a group’s Secure > Security inventory, apply reusable, centrally-managed profiles that turn scanners on across many projects from the UI — no per-project .gitlab-ci.yml edits. See the documentation for details.

  3. Scan Execution Policies — Enforce a scanner from a security policy project so developers cannot disable it. We will set one of these up later in this lesson.

Configuring scanners with security configuration profiles#

Editing .gitlab-ci.yml works well for one project, but it does not scale to dozens or hundreds of repositories. Security configuration profiles solve this: they are centralized, reusable settings that define how and when scanners run, applied in bulk from the UI without editing CI/CD configuration in each project. This is the recommended way to roll out scanners consistently across a group.

Security configuration profiles require GitLab Ultimate and are managed from a group’s Security inventory. To view the inventory you need the Security Manager, Developer, Maintainer, or Owner role on the group. We cover the Security inventory itself in more depth in Lesson 4.

GitLab currently ships three default profiles, each with its own scan triggers:

ProfileScan triggers
Secret detectionPush protection (blocks pushes containing detected secrets), merge request pipelines, and default-branch pipelines
SASTMerge request pipelines and default-branch pipelines
Dependency scanningMerge request pipelines and default-branch pipelines

Review test coverage#

  1. On the top bar, select Search or go to and find your group

  2. In the left sidebar, select Secure > Security inventory

  3. Review the Test Coverage column for each project. Each scanner shows one of four statuses:

  • Enabled — configured and completed successfully
  • Not enabled — not configured
  • Failed — ran but did not complete successfully
  • Stale — previously enabled but has not run in the last three consecutive pipelines

Apply a profile to projects#

To enable a scanner on a single project:

  1. From Secure > Security inventory, locate the project

  2. Select the vertical ellipsis next to it and choose Manage tool coverage

  3. Turn individual scanners on or off

To apply a default profile across many projects at once:

  1. From Secure > Security inventory, select multiple projects or an entire subgroup

  2. Select the Bulk action dropdown and choose Manage security scanners

  3. Choose Apply default profile to all

The scanners run on the next pipeline for each selected project — no .gitlab-ci.yml edits required.

Profiles are applied to the projects in a group or subgroup, but they are not attached to the group itself, and there is no inheritance between subgroups. If you need a scanner enforced so developers cannot remove it — regardless of the project’s .gitlab-ci.yml — use a Scan Execution Policy instead, which we cover later in this lesson.

Configuring security scanners#

Each scanner can be configured using CI/CD environment variables. Depending on the scanner, different configuration options are available. You can also use any GitLab CI/CD job keyword to further customize scanner behavior (e.g., changing which stage a scanner runs in, setting rules for when it runs, or adding dependencies).

See the application security documentation for the full list of configuration variables for each scanner.

Example: Configuring GitLab Advanced SAST and Static Reachability#

GitLab Advanced SAST adds cross-file, cross-function dataflow (“taint”) analysis on top of the default SAST analyzers, so you get a code flow view that traces tainted data from the source to the vulnerable sink. Static Reachability does something similar for dependencies — it marks a dependency vulnerability as reachable only if your code actually calls into the vulnerable function.

Both are enabled in this project via a few variables at the top of .gitlab-ci.yml:

variables:
  GITLAB_ADVANCED_SAST_ENABLED: true     # enable cross-file taint analysis
  DS_STATIC_REACHABILITY_ENABLED: true   # flag dependency findings as reachable
  DS_MAX_DEPTH: -1                       # follow the entire call graph

When triaging, prioritize findings that are reachable — those are the ones an attacker can actually exercise.

Example: Configuring DAST#

Below is an example showing how to customize the DAST scanner. This configuration runs a passive (non-intrusive) scan on the default branch and a full active scan on all other branches:

include:
  - template: Security/DAST.gitlab-ci.yml

dast:
  stage: dast
  rules:
    - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
      variables:
        DAST_FULL_SCAN: "false"
    - if: $CI_COMMIT_REF_NAME != $CI_DEFAULT_BRANCH
      variables:
        DAST_FULL_SCAN: "true"
  needs:
    - deploy-kubernetes

Using DAST scan and site profiles#

DAST is the one scanner whose configuration commonly changes between runs — you want to test the same staging URL with different scan intensities, or you want to scan a different environment with the same scan settings. GitLab models this with two reusable profiles:

  • Site profiles — define what to scan: target URL, authentication credentials, allowed/excluded paths, request headers
  • Scan profiles — define how to scan: passive vs active, crawl timeout, max scan duration, AJAX spidering, browser-based vs proxy-based

A DAST job picks one of each. Swap the site profile to point at a different environment; swap the scan profile to go from a 5-minute passive scan to a full active scan.

Create a site profile#

  1. Navigate to Secure > Security configuration

  2. Find the DAST row and click Manage profiles

  3. Click New site profile

  4. Fill in:

  • Profile name: tanuki-shop-staging
  • Target URL: the URL of your deployed Tanuki Shop (from Operate > Environments) — or any HTTPS URL you control if you skipped deployment
  • Site type: Website
  • Excluded URLs: any paths you don’t want scanned (admin panels, destructive endpoints)
  1. Click Save profile

Create a scan profile#

  1. Back on the DAST profiles page, click the Scan profiles tab

  2. Click New scan profile

  3. Fill in:

  • Profile name: passive-quick
  • Spider timeout: 5 minutes
  • Target timeout: 60 seconds
  • Scan mode: Passive (Active is intrusive — only run it on test environments)
  • AJAX spider: enabled (Tanuki Shop is a single-page Angular app, so the AJAX spider catches routes the standard spider misses)
  1. Click Save profile

Use the profiles from a DAST job#

You can run a DAST scan directly from the profiles page, or reference the profiles from a CI job:

dast:
  dast_configuration:
    site_profile: "tanuki-shop-staging"
    scan_profile: "passive-quick"

Now any developer who needs to run DAST against staging can do so without learning the underlying DAST variables — the security team owns the profile, the developer just consumes it. This is the same separation-of-duties pattern we’ll see later with security policies.

Authenticating the DAST scan#

Most of Tanuki Shop lives behind a login, so an unauthenticated scan only reaches a handful of public pages. To scan the authenticated surface, DAST drives the login form in a real browser once at the start of the scan and reuses the resulting session for every subsequent request.

Tanuki Shop ships with seeded accounts, so you don’t need to create one. Use the built-in jim customer account:

SettingValue
DAST_AUTH_USERNAMEjim@tanuki.local
DAST_AUTH_PASSWORDncc-1701

Seeded user emails are <name>@<application.domain>. This deployment runs with NODE_ENV=gitlab (see chart/values.yaml), which loads config/gitlab.yml and sets the domain to tanuki.local — hence jim@tanuki.local. If you deploy with a different config (the default is juice-sh.op), match the username to that domain. The password ncc-1701 is a literal in data/static/users.yml and never changes. jim is a low-privilege customer, which is the right level for an authenticated scan; avoid using an admin account. These are training credentials for the intentionally insecure demo app — never reuse this pattern for real accounts.

Store both values as masked CI/CD variables (Settings > CI/CD > Variables) rather than committing them, then declare only the non-secret login-form selectors in the dast job:

dast:
  variables:
    DAST_AUTH_URL: "$DAST_WEBSITE#/login"      # login page (built from the deployed target URL)
    DAST_AUTH_USERNAME_FIELD: "css:#email"      # username/email input
    DAST_AUTH_PASSWORD_FIELD: "css:#password"   # password input
    DAST_AUTH_SUBMIT_FIELD: "css:#loginButton"  # submit button
    DAST_AUTH_SUCCESS_IF_NO_LOGIN_FORM: "true"  # success = the login form is gone
    DAST_AUTH_REPORT: "true"                     # emit gl-dast-debug-auth-report.html to debug logins

DAST reads DAST_AUTH_USERNAME / DAST_AUTH_PASSWORD automatically and types them into the fields identified by DAST_AUTH_USERNAME_FIELD / DAST_AUTH_PASSWORD_FIELD. Because DAST_AUTH_REPORT is enabled, the job publishes gl-dast-debug-auth-report.html as an artifact — open it to confirm the session was established, or to see where a failed login broke.

When configuring a site profile in the UI instead, attach the same credentials under its Authentication section (Username, Password, and the field selectors above) — the site profile is where authentication belongs, since it describes what to scan.

Enabling Secret Push Protection#

Secret Push Protection catches accidentally committed secrets at git push time — before they reach the remote and become part of repository history. It is the cheapest possible place to catch a leak: nothing to revoke, nothing to scrub from history.

  1. Navigate to Secure > Security configuration in the left sidebar

  2. Locate the Secret Push Protection row

  3. Toggle it on

Now try it locally — create a branch, commit a “secret,” and push:

git checkout -b test-secret-push-protection
echo 'AWS_KEY="AKIAIOSFODNN7EXAMPLE"' >> .env.example
git add .env.example
git commit -m "test secret push protection"
git push origin test-secret-push-protection

The push is rejected and GitLab prints the offending file, line number, and detector that fired. Clean up:

git reset --hard HEAD~1
git checkout gitlab
git branch -D test-secret-push-protection

If a real secret needs to ship (a placeholder, a test fixture, a known-public token), users with the right role can skip the check by adding [skip secret push protection] to the commit message or by configuring an exclusion.

Integrating a third-party scanner using SARIF#

GitLab’s built-in scanners cover most languages and frameworks, but you may already use a specialized scanner (Semgrep, Snyk Open Source, Bandit, ESLint security plugins, Trivy, Checkov, etc.) and want its findings to show up in the same vulnerability report. The easiest way is SARIF — the industry-standard output format for static analysis. GitLab consumes SARIF 2.1.0 directly via the artifacts:reports:sast artifact.

We will use Semgrep as the example because it is free, has a strong rule library, and emits SARIF natively.

Add the Semgrep job to your pipeline#

  1. In your project, open .gitlab-ci.yml

  2. Add the following job at the bottom of the file:

semgrep-sast:
  stage: test
  image: returntocorp/semgrep:latest
  variables:
    # Use the bundled "auto" config; swap for "p/owasp-top-ten", "p/ci",
    # or a path to your own rules to customize coverage
    SEMGREP_RULES: "p/default"
  script:
    - semgrep ci --config "$SEMGREP_RULES" --sarif --output gl-sast-semgrep.sarif || true
  artifacts:
    when: always
    reports:
      sast: gl-sast-semgrep.sarif
    paths:
      - gl-sast-semgrep.sarif
    expire_in: 1 week
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  allow_failure: true
  1. Commit the change and push it

A few details that make this work end-to-end:

  • artifacts:reports:sast — this is the key. GitLab inspects every SAST-class artifact and accepts SARIF 2.1.0 in addition to its native JSON format. No conversion step is needed.
  • || true + allow_failure: true — Semgrep exits non-zero when it finds something. We want findings to surface in GitLab, not fail the build. Your security policies (next section) decide whether the merge request is blocked.
  • paths: artifact — keeps the raw SARIF available for download and for downstream tools (SIEM, dashboards).
  • rules: — runs on merge requests and the default branch, matching how GitLab’s own scanners are scheduled.

Verify the findings appear in GitLab#

  1. Open the merge request (or pipeline) created by your commit

  2. Wait for semgrep-sast to complete

  3. In the merge request, expand Security scanning. Semgrep findings appear in the same list as the built-in SAST findings, grouped under their own scanner name.

  4. After the pipeline finishes on the default branch, the same findings appear in Secure > Vulnerability report.

  5. Policies you set up later in this lesson treat Semgrep findings exactly like findings from any other scanner — no additional configuration required.

The same pattern works for any tool that emits SARIF — point artifacts:reports:sast at the SARIF file. For tools that emit GitLab’s native JSON schema directly (Trivy with --format gitlab is a common one), use the same artifacts:reports: field that matches the scan type (container_scanning, dependency_scanning, etc.).

SARIF ingestion is currently supported under artifacts:reports:sast. Findings from other categories (DAST, dependency scanning, container scanning) should be emitted in GitLab’s native JSON schema or converted using a small wrapper script. See Security report schemas for the schemas.

Enabling security training#

GitLab can link detected vulnerabilities to remediation training from third-party providers, giving developers a fast path from “what is this finding” to “how do I fix it for good.”

  1. Navigate to Secure > Security configuration in the left sidebar

  2. Click the Vulnerability Management tab

  3. Enable all of the Security training providers:

  • Kontra — Interactive developer security education built around real-world examples
  • Secure Code Warrior — Bite-sized secure coding lessons that map to specific vulnerabilities
  • SecureFlag — Hands-on labs in a virtualized environment

Once enabled, every vulnerability page shows a Training link tailored to the specific CWE.

Understanding security policies#

Security policies let you enforce security requirements as code. Policies live in a dedicated security policy project — a separate repository that GitLab creates automatically when you create your first policy. This separation means security teams can manage policies independently of developers, which is the foundation for separation of duties.

There are four main types of policies:

  1. Merge Request Approval Policies — require extra approvals when conditions are met (e.g., new high-severity vulnerabilities)
  2. Scan Execution Policies — enforce specific scans on designated branches/schedules so they cannot be removed by a .gitlab-ci.yml change
  3. Pipeline Execution Policies — inject custom CI/CD jobs (compliance checks, custom audits) that developers cannot remove
  4. Vulnerability Management Policies — automate status transitions on the vulnerability report (e.g., auto-resolve vulnerabilities older than 90 days when no longer detected)

In the following sections we will set up the first three.

Creating a merge request approval policy (vulnerabilities)#

Merge request approvals are the gate before code merges. GitLab can require additional approvals when scanners detect new vulnerabilities, so insecure code cannot ship without a security review.

  1. Navigate to Secure > Policies in the left sidebar

  2. Click the New policy button

  3. Click the Select policy button under Merge request approval policy

  4. Fill out the following:

  • Name: require_maintainer_approval_for_detected_vulnerabilities
  • Description (optional): Requires approval from maintainers before vulnerable code can be merged
  1. Ensure the Enabled radio button is selected under Policy status

  2. Note that the Policy scope is set to the current project by default

Policies can also be created at the group level, scoped to multiple projects at once. This is the usual approach for enforcing consistent security requirements across an entire organization.

  1. Under the Rules section, create a rule with the following specifications:

When Security Scan from All scanners runs against the default branch with No exceptions and finds Any vulnerability matching all of the following criteria:

Severity is: All severity levels

Status is: New, All vulnerability states

  1. Under the Actions section, create an action with the following criteria:

Require 1 approval from Roles: Maintainer

  1. Under Override project approval settings, unselect all default options so the policy does not override existing project settings.

  2. Under Fallback behavior and edge case settings, select Fail closed. This blocks the merge request when the scanner cannot run for any reason, instead of silently letting it through.

  3. Click Configure with a merge request. You will be taken to a merge request in the security policy project that GitLab created to store your policies.

  4. Click Merge to activate the policy.

Creating a scan execution policy (IaC scanning)#

A scan execution policy enforces that a scanner runs — even if the project’s .gitlab-ci.yml does not include it. This is how security teams guarantee coverage across many projects without depending on each project’s pipeline configuration.

We will enforce IaC scanning, which is not yet in this project’s pipeline.

  1. Navigate to Secure > Policies in the left sidebar

  2. Click the New policy button

  3. Click Select policy under Scan execution policy

  4. Fill out the following:

  • Name: enforce_iac_scanning_on_all_branches
  • Description (optional): Ensures IaC scanning runs on every pipeline, regardless of project CI configuration
  1. Ensure the Enabled radio button is selected

  2. Under the Actions section, configure:

Run IaC Scanning scan with Default settings

  1. Under the Conditions section, configure:

Triggers: Triggered by pipeline event

For branches: All branches

  1. Click Configure with a merge request and merge the resulting MR

The IaC scanner now runs in every pipeline. Developers cannot disable it by editing .gitlab-ci.yml — it is injected from the security policy project.

Injecting custom jobs using pipeline execution policies#

Now let’s create a Pipeline Execution Policy that injects a custom CI/CD job into the pipeline. This is the most common way to enforce compliance checks, audit logging, or additional testing that developers cannot bypass.

Pipeline execution policies can inject any custom CI/CD configuration (including security scans). If you only need to enforce that specific GitLab security scans run, prefer a Scan Execution Policy — they are simpler to author.

Importing the custom job project#

The custom job lives in a separate project. Let’s import it first.

  1. Navigate to https://gitlab.com/projects/new (or click Projects in the left sidebar)

  2. Select Import project

  3. Click the Repository by URL button

  4. Under Git repository URL, enter:

https://gitlab.com/gitlab-da/tutorials/security-and-governance/tanuki-shop-custom-pipelines.git
  1. Under Project URL, select the same group where you imported the Tanuki Shop project

  2. Under Visibility Level, select Public

  3. Click the Create project button

  4. Wait for the import to complete.

Creating the pipeline execution policy#

  1. Navigate back to your Tanuki Shop project

  2. Go to Secure > Policies in the left sidebar

  3. Click New policy

  4. Click Select policy under Pipeline execution policy

  5. Fill out the following:

  • Name: inject_custom_soc2_echo_job
  • Description (optional): Injects a custom compliance job to echo ‘SOC2 Compliance Check’ into the pipeline
  1. Ensure Enabled is selected

  2. Under Actions, create an action with the following criteria:

Inject into the .gitlab-ci.yml with the following pipeline execution file from Tanuki Shop - Custom Pipelines

File path: soc2.yml

File reference (Optional): default branch

Add job name suffix: On conflict

  1. Leave the Additional configuration options at their defaults

  2. Click Configure with a merge request and merge the resulting MR


You now have:

  • Built-in scanners running on every pipeline (SAST + Advanced SAST, Dependency Scanning + Static Reachability, Container Scanning, Secret Detection, DAST)
  • Secret Push Protection blocking secrets before they hit the server
  • A third-party scanner (Semgrep) feeding findings into the same vulnerability report via SARIF
  • Three security policies: a vulnerability-approval gate, an enforced IaC scan, and an injected compliance job

Next we’ll look at what all of this feels like from the developer side, in a merge request.

Previous Lesson Next Lesson