$ cat gitlab-ci-cd/part-01.md
GitLab CI/CD : Container-Native Pipelines with Built-in SAST

Introduction: Why I Switched from Jenkins to GitLab
After building Jenkins pipelines, I was comfortable with Jenkins. Then I discovered GitLab CI/CD and everything changed.
The fundamental difference? GitLab is container-native from day one.
Jenkins approach:
- Install Jenkins on server
- Configure agents with Java, Maven, Node.js, Python
- Install plugins for Docker, SonarQube, JUnit
- Write Groovy pipeline code
GitLab approach:
- Install GitLab Runner
- Install Docker
- Write YAML
- Everything else runs in containers
This article shows my complete migration with deep technical explanations of how GitLab’s container-native architecture works internally.
GitLab CI/CD Architecture: How It Actually Works
The Component Stack
┌─────────────────────────────────────┐
│ GitLab Server │
│ (Manages jobs, stores artifacts) │
└──────────────┬──────────────────────┘
│
│ HTTPS/gRPC
│
┌──────────────▼──────────────────────┐
│ GitLab Runner │
│ (Executes jobs, pulls containers) │
└──────────────┬──────────────────────┘
│
┌──────┴──────┐
│ │
┌───────▼─────┐ ┌─────▼──────┐
│ Docker │ │ Shell │
│ Executor │ │ Executor │
└─────────────┘ └────────────┘
Communication flow:
- GitLab Server (gitlab.com or self-hosted):
- GitLab Runner (your machine/VM):
- Executors:
My First GitLab Pipeline: Deep Technical Analysis
Repository: GitLab/java-project/java-project.yml
stages:
- maven-test-stage
- maven-deploy-stage
- sonarqube-stage
- docker-build-stage
- docker-push-stage
- docker-run-stage
- aws-configuration-stage
- kubernetes-stage
maven-test-job:
stage: maven-test-stage
tags:
- macos
- personal
- shell
before_script:
- mvn -v
script:
- mvn clean package
- mvn test
artifacts:
name: war
paths:
- "target/my-webapp.war"
reports:
junit:
- target/surefire-reports/TEST.*.xml
expire_in: 30 days
Deep Dive: Tags and Runner Selection
tags:
- macos
- personal
- shell
How runner selection works:
Step 1: Job enters queue
When pipeline starts, GitLab creates job with these requirements:
{
"job_id": 12345,
"tags_required": ["macos", "personal", "shell"],
"image": null,
"services": []
}
Step 2: Runners check eligibility
Every 3 seconds, each runner asks GitLab: “Do you have jobs for me?”
Runner A (Linux, tags: [linux, docker]):
- Has tag "macos"? NO
- Skip this job
Runner B (macOS, tags: [macos, personal, shell]):
- Has tag "macos"? YES
- Has tag "personal"? YES
- Has tag "shell"? YES
- Accept this job! ✅
Step 3: Runner claims job
POST https://gitlab.com/api/v4/jobs/12345/claim
Headers:
Authorization: Bearer runner-token-xyz
Response:
{
"status": "claimed",
"runner_id": 789
}
Step 4: Runner executes job
On macOS runner:
# GitLab Runner executes these commands directly on macOS
cd /Users/runner/builds/project
mvn -v
mvn clean package
mvn test
Why this matters:
# Jenkins approach
pipeline {
agent { label 'macos-agent' }
}
Single label, finds first matching agent.
# GitLab approach
tags:
- macos
- personal
- shell
Must match ALL tags. More specific control.
Deep Dive: Artifacts System
artifacts:
name: war
paths:
- "target/my-webapp.war"
reports:
junit:
- target/surefire-reports/TEST.*.xml
expire_in: 30 days
What happens during artifact upload:
Step 1: Job completes successfully
GitLab Runner scans for artifacts:
# Runner finds these files
/builds/project/target/my-webapp.war
/builds/project/target/surefire-reports/TEST.UserServiceTest.xml
/builds/project/target/surefire-reports/TEST.AppTest.xml
Step 2: Create ZIP archive
# Runner creates temporary ZIP
cd /builds/project
zip -r /tmp/artifacts-12345.zip target/my-webapp.war target/surefire-reports/TEST.*.xml
# ZIP structure:
artifacts-12345.zip
├── target/
│ ├── my-webapp.war
│ └── surefire-reports/
│ ├── TEST.UserServiceTest.xml
│ └── TEST.AppTest.xml
Step 3: Upload to GitLab
POST https://gitlab.com/api/v4/jobs/12345/artifacts
Headers:
Content-Type: multipart/form-data
JOB-TOKEN: secret-job-token
Body:
file: /tmp/artifacts-12345.zip
artifact_type: archive
expire_in: 2592000 (30 days in seconds)
Response:
{
"artifacts_file": {
"filename": "artifacts.zip",
"size": 5242880 (5 MB)
},
"artifacts_expire_at": "2026-01-19T10:30:00Z"
}
Step 4: Parse JUnit reports
GitLab extracts and parses JUnit XML:
<!-- TEST.UserServiceTest.xml -->
<testsuite tests="3" failures="0" errors="0" time="0.532">
<testcase name="testUserCreation" time="0.123"/>
<testcase name="testUserUpdate" time="0.204"/>
<testcase name="testUserDeletion" time="0.205"/>
</testsuite>
Stores in database:
INSERT INTO ci_job_artifacts (
job_id,
file_type,
size,
expire_at,
file_store
) VALUES (
12345,
'junit',
5242880,
'2026-01-19 10:30:00',
1 # local storage
);
Step 5: Display in UI
GitLab UI shows:
Tests: 3 passed, 0 failed
Duration: 0.532s
Coverage: N/A
Artifact retrieval by other jobs:
deploy-job:
stage: deploy
dependencies:
- maven-test-job
script:
- ls target/my-webapp.war # File is here!
What happens:
- deploy-job starts
- GitLab downloads artifacts-12345.zip
- Extracts to job workspace
- Job can access target/my-webapp.war
Artifact expiration:
After 30 days, GitLab cron job runs:
# GitLab background job (simplified)
ArtifactExpireWorker.perform_async
def perform
expired = CiJobArtifact.where('expire_at < ?', Time.now)
expired.find_each do |artifact|
artifact.file.remove! # Delete from storage
artifact.destroy # Delete database record
end
end
Deep Dive: SonarQube Integration
sonarqube-check:
stage: sonarqube-stage
image: maven:3.6.3-jdk-11
variables:
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
GIT_DEPTH: "0"
cache:
key: "${CI_JOB_NAME}"
paths:
- .sonar/cache
script:
- mvn verify sonar:sonar -Dsonar.projectKey=root_test_AZOKVHhuL2aw2rzHZn9m
allow_failure: true
only:
- main
Technical breakdown:
1. Variable SONAR_USER_HOME:
SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
What this does:
Without this:
SonarQube stores cache in: /root/.sonar/
Next job runs: Fresh container, /root/.sonar/ is empty
Result: Re-downloads all analysis plugins (300MB)
With this:
SonarQube stores cache in: /builds/project/.sonar/
GitLab caches this directory
Next job runs: Cache restored to /builds/project/.sonar/
Result: Uses cached plugins (0MB download)
Time savings:
- First run: 45 seconds to download SonarQube plugins
- Cached run: 2 seconds to extract cache
- 43 seconds saved per run!
2. Variable GIT_DEPTH:
GIT_DEPTH: "0"
Default GitLab behavior (GIT_DEPTH=50):
git clone --depth=50 https://gitlab.com/user/project.git
Downloads only last 50 commits:
Commit history:
[Latest] abc123 (downloaded)
[Latest-1] def456 (downloaded)
...
[Latest-49] xyz789 (downloaded)
[Latest-50] old999 (downloaded)
[Latest-51] ancient (NOT downloaded)
Why this breaks SonarQube:
SonarQube analyzes:
- Code evolution over time
- When bugs were introduced
- Technical debt trends
Needs full Git history!
With GIT_DEPTH=0:
git clone --depth=0 https://gitlab.com/user/project.git
# Same as: git clone https://gitlab.com/user/project.git
Downloads ENTIRE history:
All commits from first to latest
SonarQube can analyze properly
Trade-off:
- GIT_DEPTH=50: Fast clone (5 seconds), incomplete analysis
- GIT_DEPTH=0: Slow clone (30 seconds), complete analysis
3. Cache mechanism deep dive:
cache:
key: "${CI_JOB_NAME}"
paths:
- .sonar/cache
How caching works:
First run (cache miss):
Step 1: Job starts, no cache found
Step 2: SonarQube downloads plugins to .sonar/cache/
Step 3: Analysis completes
Step 4: GitLab Runner creates cache archive:
tar -czf cache-sonarqube-check.tar.gz .sonar/cache/
Step 5: Upload cache to GitLab server:
PUT /api/v4/projects/123/cache?key=sonarqube-check
Body: cache-sonarqube-check.tar.gz (300MB)
Second run (cache hit):
Step 1: Job starts
Step 2: Runner checks GitLab server:
GET /api/v4/projects/123/cache?key=sonarqube-check
Response: cache-sonarqube-check.tar.gz exists
Step 3: Download and extract:
curl -o cache.tar.gz https://gitlab.com/.../cache
tar -xzf cache.tar.gz
Step 4: .sonar/cache/ directory restored
Step 5: SonarQube uses cached plugins
Cache key strategies:
# Per-job caching
cache:
key: "${CI_JOB_NAME}" # Key: "sonarqube-check"
# Per-branch caching
cache:
key: "${CI_COMMIT_REF_SLUG}" # Key: "main" or "feature-123"
# Per-branch-per-job caching
cache:
key: "${CI_COMMIT_REF_SLUG}-${CI_JOB_NAME}" # Key: "main-sonarqube-check"
My choice: ${CI_JOB_NAME} because SonarQube cache is same across all branches.
4. allow_failure behavior:
allow_failure: true
What happens when job fails:
Without allow_failure:
Job fails → Pipeline status: FAILED ❌
Blocks deployment
Manual intervention required
With allow_failure:
Job fails → Pipeline status: SUCCESS (with warnings) ⚠️
Deployment continues
Quality issues logged but don't block
My use case:
SonarQube sometimes fails on:
- Minor code smells
- Slight decrease in coverage
- Acceptable technical debt
I want to see the results but not block deployment.
Job status combinations:
Job Result allow_failure Pipeline Status Success false Success ✅ Success true Success ✅ Failure false Failed ❌ Failure true Success (with warnings) ⚠️
Container Registry: Technical Deep Dive
docker-job:
stage: docker-build-stage
image: docker:20.10.16
services:
- docker:20.10.16-dind
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.gitlab.com
script:
- docker build -t registry.gitlab.com/lowyiiii/java-project .
- docker push registry.gitlab.com/lowyiiii/java-project
Docker-in-Docker (DinD) Architecture
What actually happens when job runs:
Step 1: Runner starts job container
# GitLab Runner executes:
docker run \
--name job-container \
--network gitlab-runner \
docker:20.10.16 \
sh -c "docker login ... && docker build ..."
Step 2: Runner starts DinD service container
# Simultaneously, Runner executes:
docker run \
--name dind-service \
--privileged \
--network gitlab-runner \
docker:20.10.16-dind
The –privileged flag is critical:
Without –privileged:
DinD container cannot access host kernel features
Cannot start Docker daemon
ERROR: Cannot connect to Docker daemon ❌
With –privileged:
DinD container gets full host access
Can start Docker daemon
Docker commands work ✅
Step 3: Containers communicate
┌─────────────────────────┐
│ job-container │
│ (docker:20.10.16) │
│ │
│ $ docker build ... │
└───────────┬─────────────┘
│
│ TCP connection to
│ docker:2376 (TLS)
│
┌───────────▼─────────────┐
│ dind-service │
│ (docker:20.10.16-dind)│
│ │
│ Docker Daemon │
│ listening on :2376 │
└─────────────────────────┘
Environment variables automatically set:
# Inside job container:
echo $DOCKER_HOST
# Output: tcp://docker:2376
echo $DOCKER_TLS_VERIFY
# Output: 1
echo $DOCKER_CERT_PATH
# Output: /certs/client
This tells Docker client to connect to DinD daemon!
Step 4: Docker build process
# In job container:
docker build -t registry.gitlab.com/lowyiiii/java-project .
# What happens:
1. Docker client sends build context to DinD daemon
2. DinD daemon builds image inside its own container
3. Image stored in DinD daemon's storage
4. Image layers never touch job container
Storage architecture:
Host machine:
/var/lib/docker/
├── containers/
│ ├── dind-service/
│ └── job-container/
└── volumes/
└── dind-storage/
└── overlay2/
└── [image layers stored here]
Step 5: Docker push
docker push registry.gitlab.com/lowyiiii/java-project
# Process:
1. DinD daemon reads image layers from its storage
2. Chunks layers into 10MB blobs
3. Compresses with gzip
4. Uploads to registry.gitlab.com via HTTPS
5. Sends manifest listing all layers
Network flow:
job-container → dind-service → internet → registry.gitlab.com
(client) (daemon) (registry)
GitLab Container Registry Authentication
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.gitlab.com
GitLab provides these variables automatically:
# Job environment:
CI_REGISTRY_USER="gitlab-ci-token"
CI_REGISTRY_PASSWORD="glcbt-Xyj4k9m..." # Job token (changes per job!)
CI_REGISTRY="registry.gitlab.com"
CI_REGISTRY_IMAGE="registry.gitlab.com/lowyiiii/java-project"
How job token authentication works:
Step 1: Job starts, GitLab generates token
# GitLab server (simplified)
def create_job_token(job)
token = SecureRandom.base64(32)
JobToken.create!(
job_id: job.id,
token: token,
expires_at: job.finished_at + 1.hour
)
token
end
Step 2: Docker login request
POST https://registry.gitlab.com/jwt/auth
Headers:
Authorization: Basic Z2l0bGFiLWNpLXRva2VuOmdsY2J0LVh5ajRrOW0uLi4=
# Base64("gitlab-ci-token:glcbt-Xyj4k9m...")
Response:
{
"token": "eyJhbGciOiJSUzI1NiIs...", # JWT
"expires_in": 300,
"issued_at": "2024-12-19T10:00:00Z"
}
Step 3: Docker saves token
# Docker creates ~/.docker/config.json
{
"auths": {
"registry.gitlab.com": {
"auth": "Z2l0bGFiLWNpLXRva2VuOmdsY2J0LVh5ajRrOW0uLi4="
}
}
}
Step 4: Docker push with JWT
PUT https://registry.gitlab.com/v2/lowyiiii/java-project/blobs/uploads/
Headers:
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Body:
[layer blob data]
GitLab verifies JWT:
1. Decodes JWT
2. Checks signature
3. Validates expiration
4. Checks job permissions
5. Allows upload ✅
Why job tokens are secure:
Traditional password:
- Same password for all jobs
- If leaked, all projects compromised
- Requires manual rotation
Job token:
- Unique per job
- Expires 1 hour after job completion
- Automatically rotated
- Limited to specific project
- Job-specific permissions
AWS EKS Deployment: kubectl Installation Deep Dive
deploy-job:
stage: deploy
image:
name: amazon/aws-cli
entrypoint: [ "" ]
before_script:
- curl -O https://s3.us-west-2.amazonaws.com/amazon-eks/1.30.2/2024-07-12/bin/linux/amd64/kubectl
- curl -O https://s3.us-west-2.amazonaws.com/amazon-eks/1.30.2/2024-07-12/bin/linux/amd64/kubectl.sha256
- sha256sum -c kubectl.sha256
- chmod +x ./kubectl
- mkdir -p $HOME/bin && cp ./kubectl $HOME/bin/kubectl && export PATH=$HOME/bin:$PATH
script:
- aws configure set aws_access_key $AWS_ACCESS_KEY_ID
- kubectl config use-context "$KUBE_CONTEXT"
- kubectl apply -f Application.yaml
Why Entrypoint Override is Necessary
Default amazon/aws-cli Dockerfile:
FROM python:3.11-slim
RUN pip install awscli
ENTRYPOINT ["aws"]
CMD ["--help"]
Without entrypoint override:
deploy-job:
image: amazon/aws-cli
script:
- echo "Starting deployment"
What GitLab Runner actually executes:
docker run amazon/aws-cli sh -c "echo 'Starting deployment'"
# Docker combines:
# ENTRYPOINT ["aws"] + COMMAND ["sh", "-c", "echo 'Starting deployment'"]
# Result: aws sh -c "echo 'Starting deployment'"
# ERROR: Invalid AWS command! ❌
With entrypoint override:
deploy-job:
image:
name: amazon/aws-cli
entrypoint: [ "" ]
script:
- echo "Starting deployment"
What GitLab Runner executes:
docker run --entrypoint="" amazon/aws-cli sh -c "echo 'Starting deployment'"
# Docker uses:
# ENTRYPOINT [] (empty)
# COMMAND ["sh", "-c", "echo 'Starting deployment'"]
# Result: sh -c "echo 'Starting deployment'"
# SUCCESS: Starting deployment ✅
kubectl Binary Verification Process
curl -O https://s3.us-west-2.amazonaws.com/amazon-eks/1.30.2/2024-07-12/bin/linux/amd64/kubectl
curl -O https://s3.us-west-2.amazonaws.com/amazon-eks/1.30.2/2024-07-12/bin/linux/amd64/kubectl.sha256
sha256sum -c kubectl.sha256
What happens step-by-step:
Step 1: Download kubectl binary
curl -O https://s3.../kubectl
# Downloads:
-rwxr-xr-x 1 root root 48234567 Dec 19 10:00 kubectl
Step 2: Download checksum file
curl -O https://s3.../kubectl.sha256
# Downloads:
cat kubectl.sha256
# Output:
7d9e8c1f4e5a6b3c2d1a0f9e8d7c6b5a4e3d2c1b0a9f8e7d6c5b4a3e2d1c0f9e kubectl
Step 3: Verify checksum
sha256sum -c kubectl.sha256
# sha256sum process:
1. Reads kubectl.sha256 file
2. Extracts expected hash: 7d9e8c1f4e5a6b3c...
3. Calculates actual hash of kubectl binary:
sha256sum kubectl
# Calculated: 7d9e8c1f4e5a6b3c...
4. Compares:
if expected == calculated:
print "kubectl: OK"
exit 0
else:
print "kubectl: FAILED"
exit 1
Why this matters:
Scenario 1: Compromised download
# Attacker replaces kubectl with malicious version
# Malicious kubectl hash: abc123...
# AWS official hash: 7d9e8c...
sha256sum -c kubectl.sha256
# kubectl: FAILED
# sha256sum: WARNING: 1 computed checksum did NOT match
# Pipeline fails ✅ Attack prevented!
Scenario 2: Corrupted download
# Network error causes partial download
# Partial kubectl hash: def456...
# Expected hash: 7d9e8c...
sha256sum -c kubectl.sha256
# kubectl: FAILED
# Pipeline fails ✅ Corruption detected!
Step 4: Make kubectl executable and add to PATH
chmod +x ./kubectl
# Changes permissions:
# Before: -rw-r--r-- kubectl
# After: -rwxr-xr-x kubectl
mkdir -p $HOME/bin
# Creates: /root/bin/
cp ./kubectl $HOME/bin/kubectl
# Copies kubectl to: /root/bin/kubectl
export PATH=$HOME/bin:$PATH
# Updates PATH:
# Before: /usr/local/bin:/usr/bin:/bin
# After: /root/bin:/usr/local/bin:/usr/bin:/bin
# Now kubectl is available:
which kubectl
# Output: /root/bin/kubectl ✅
Kubernetes Context Management
kubectl config use-context "$KUBE_CONTEXT"
What is KUBE_CONTEXT?
GitLab provides this variable from project settings:
Settings → CI/CD → Variables:
KUBE_CONTEXT = "lowyiiii/java-project:my-agent"
Kubeconfig structure:
# ~/.kube/config
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUd...
server: https://abc123.gr7.us-east-1.eks.amazonaws.com
name: my-eks-cluster
contexts:
- context:
cluster: my-eks-cluster
namespace: production
user: gitlab-deploy
name: lowyiiii/java-project:my-agent
users:
- name: gitlab-deploy
user:
token: k8s-aws-v1.aHR0cHM6...
What kubectl config use-context does:
kubectl config use-context "lowyiiii/java-project:my-agent"
# Modifies ~/.kube/config:
current-context: lowyiiii/java-project:my-agent
# Now kubectl uses:
# - Cluster: my-eks-cluster
# - Namespace: production
# - User: gitlab-deploy
Verification:
kubectl config current-context
# Output: lowyiiii/java-project:my-agent
kubectl cluster-info
# Output:
# Kubernetes control plane is running at https://abc123.gr7.us-east-1.eks.amazonaws.com
# CoreDNS is running at https://abc123.gr7.us-east-1.eks.amazonaws.com/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
Built-in SAST: How It Works Internally
sast:
stage: test
include:
- template: Jobs/SAST.gitlab-ci.yml
This ONE line enables security scanning!
What Actually Happens
Step 1: Template inclusion
include:
- template: Jobs/SAST.gitlab-ci.yml
GitLab fetches from internal repository:
https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/SAST.gitlab-ci.yml
Template contents (simplified):
# GitLab's SAST template
variables:
SAST_EXCLUDED_PATHS: "spec,test,tests,tmp"
SAST_EXCLUDED_ANALYZERS: ""
.sast-analyzer:
stage: test
allow_failure: true
artifacts:
reports:
sast: gl-sast-report.json
spotbugs-sast:
extends: .sast-analyzer
image: "$SECURE_ANALYZERS_PREFIX/spotbugs:$SAST_ANALYZER_IMAGE_TAG"
rules:
- if: $SAST_DISABLED
when: never
- if: $CI_COMMIT_BRANCH
exists:
- "**/*.java"
- "**/pom.xml"
semgrep-sast:
extends: .sast-analyzer
image: "$SECURE_ANALYZERS_PREFIX/semgrep:$SAST_ANALYZER_IMAGE_TAG"
rules:
- if: $SAST_DISABLED
when: never
- if: $CI_COMMIT_BRANCH
Step 2: GitLab detects language
# GitLab scans repository:
find . -name "*.java"
# Found: src/main/java/com/example/App.java
find . -name "pom.xml"
# Found: pom.xml
# Conclusion: Java project detected!
# Runs: spotbugs-sast job
Step 3: Spotbugs analyzer runs
# GitLab starts container:
docker run \
-v /builds/project:/code \
registry.gitlab.com/gitlab-org/security-products/analyzers/spotbugs:latest \
/analyzer run
# Inside container:
cd /code
mvn compile # Compile Java code
spotbugs -textui -xml -output gl-sast-report.json target/classes/
# Spotbugs scans for:
# - SQL injection
# - XXE (XML External Entity)
# - Path traversal
# - Insecure randomness
# - Hard-coded credentials
Step 4: Generate SAST report
// gl-sast-report.json
{
"version": "14.0.0",
"vulnerabilities": [
{
"id": "SQL_INJECTION_HIBERNATE",
"category": "sast",
"name": "SQL Injection",
"message": "Potential SQL injection",
"description": "User input is used in SQL query without sanitization",
"severity": "High",
"scanner": {
"id": "spotbugs",
"name": "SpotBugs"
},
"location": {
"file": "src/main/java/com/example/UserService.java",
"start_line": 45,
"end_line": 47
},
"identifiers": [
{
"type": "spotbugs_type",
"name": "SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE",
"value": "SQL_NONCONSTANT_STRING_PASSED_TO_EXECUTE"
}
]
}
]
}
Step 5: GitLab parses and displays
-- GitLab database
INSERT INTO vulnerability_findings (
project_id,
scanner_id,
severity,
confidence,
report_type,
primary_identifier_id,
location_fingerprint,
raw_metadata
) VALUES (
123,
456,
'high',
'medium',
'sast',
789,
'abc123...',
'{"description": "Potential SQL injection", ...}'
);
Step 6: UI display
Security Dashboard → SAST
┌────────────────────────────────────────────┐
│ 1 High Severity Vulnerability Found │
├────────────────────────────────────────────┤
│ SQL Injection │
│ File: UserService.java:45 │
│ Status: Detected │
│ [View Details] [Create Issue] [Dismiss] │
└────────────────────────────────────────────┘
Merge request integration:
Merge Request !123
┌────────────────────────────────────────────┐
│ ⚠️ Security Report │
├────────────────────────────────────────────┤
│ SAST detected 1 new vulnerability: │
│ • SQL Injection (High) - UserService.java │
│ │
│ [Block Merge] [Approve Override] │
└────────────────────────────────────────────┘