Enable BLSA through a security scan profile

What does this MR do and why?

Problem: BLSA can't be turned on per project today. Once the flag and the flow are on for a group, a triggered run has no per-project check, and a retried job can start a second scan of the same pipeline.

  • Turns on the Business Logic Security Scan for a project by attaching a Business Logic scan profile. This is the same model triage_and_remediation uses. There is no per-project toggle.
  • Adds a generic run_eligibility_resolver hook to foundational flows. This flow uses it to skip a triggered run when the project has no profile, or the pipeline is stale, a merge train, or already scanned.
  • Adds the "Business Logic (default)" preset. Its only allowed trigger is merge request pipelines.
  • While the flag is off, hides Business Logic profiles from the list, single-read and project fields, and blocks create, update and attach. Delete and detach stay ungated, so existing profiles can be cleaned up. Always keeps Business Logic profiles out of CI job injection.
  • Reports Business Logic scanning as enabled on the Security Configuration page when a profile is attached (the scan runs as a Duo flow, so no pipeline report can show it), and pushes the flag to the Security Inventory and group Security configuration pages.
  • Attaching or detaching the Business Logic profile now updates the Security Inventory (closes #631786).
  • Both profile checks (run eligibility and the presenter) use security_scan_profile_for(...).present?: on master that method now returns a profile or nil, not a relation.
  • Backend, plus the docs page below (no frontend). Everything is behind bl_security_analyzer, checked on the root namespace.
  • Also includes the business logic scanning docs page, reviewed and approved by Technical Writing in !258562 (merged), which merged into this branch.

Part of the BLSA split of !246889. Tracker: https://gitlab.com/gitlab-org/gitlab/-/work_items/630266

Merge order: MR-3c (!257465 (merged), merged) adds the changed-files scoping the automatic MR scan uses. MR-4 (!257196 (merged)) shows the results; merge it before this MR.

Without the UI MR, the API returns the Business Logic preset but the profile pages don't list it yet: the frontend skips scan types it has no category for.

How business logic security scans start

Path What it scans How it's enabled Goes through this MR's check?
1. Automatic MR scan (this MR) Files the MR adds or modifies, on the MR head pipeline (an MR pipeline, or a branch pipeline of an open MR), after it succeeds. Once per pipeline. Attach the Business Logic scan profile. Yes
2. Duo Chat /flow: (recommended for full scans) The whole default branch. Select Business Logic Security Scan after /flow:. No profile needed. No
3. API-started full scan The whole default branch. POST /api/v4/ai/duo_workflows/workflows with workflow_definition=bl_security/experimental. No profile needed. No
4. Scheduled scan execution policy (!257271) The branches the policy lists. Unblocked: !253659 (merged) merged on 2026-09-25. Not in this MR. No

Only the automatic path goes through Ai::FlowTriggers::RunService, so only it is checked.

Before / after

Rows apply while bl_security_analyzer is on for the top-level group, except the flag-off row. Business Logic profiles are always kept out of CI job injection.

Input Before After
MR head pipeline succeeds, BL profile attached Can start a scan (no per-project check) Scan of the changed files
MR head pipeline succeeds, no BL profile Can start a scan (no per-project check) No scan (Skipping BL security flow: no Business Logic scan profile attached for merge requests)
Same pipeline reports success again (retried job) Can start a second scan No second scan (pipeline already scanned)
Merge train pipeline, or a stale MR pipeline Can start a scan No scan
availableSecurityScanProfiles No Business Logic preset "Business Logic (default)" preset
Create a BL profile with MERGE_REQUEST_PIPELINE Fails trigger validation Created
Create a BL profile with any other trigger Fails trigger validation Fails trigger validation
Flag off: read, update or attach a saved BL profile Not gated Not available / hidden
Security Configuration card, BL profile attached Not enabled Enabled
Security Inventory and group Security configuration pages Flag not pushed blSecurityAnalyzer pushed for the root group

How to review

  • Heads-up: !256963 (merged) adds an attribute at the same spot in foundational_flow/attributes.rb. Whichever merges second needs a trivial rebase.
  • BlSecurity::RunEligibility: the skip order, and the exclusive lease taken last.
  • Where RunService#validation_error calls the hook: after the flag and resource checks, before the autonomous and human-user checks.
  • PipelineEligibilityService: the not_flow_backed merge keeps business_logic out of CI job injection.
  • Flag checks on the scan profile read, create, update and attach paths (resolvers, ProjectType#security_scan_profiles, FindOrCreateService, update mutation). Delete and detach are not gated, so profiles can be cleaned up.

Backward compatibility / impact on existing flows

Change Risk Mitigation
RunService eligibility hook Runs for every triggered foundational flow. Returns true when a flow sets no resolver. Only bl_security/experimental sets one. Specs cover both.
PipelineEligibilityService excludes flow-backed types Could drop CI scan-profile injection for other types. Excludes only FLOW_BACKED_SCAN_TYPES (business_logic). The trigger query already joins scan_profile.
Presenter scanner_enabled? override Could change other cards' status. Calls super for every type except :business_logic.
Trigger allowlist: business_logic accepts merge_request_pipeline A widening. At the base, no trigger was valid for business_logic. Nothing that validated before stops validating. Profiles exist only behind the experiment flag.

Database queries

Security::ScanProfile.not_flow_backed is merged into the trigger query in Security::ScanProfiles::PipelineEligibilityService#build_applicable_triggers. It skips Business Logic profiles, which run as a Duo flow instead of injected CI jobs.

scan_type is a smallint enum (Enums::Security::SCAN_PROFILES_TYPES); business_logic is 5. With one flow-backed type, Rails emits != 5 rather than NOT IN (...).

SQL from to_sql in a GDK on code identical to this MR (project_id = 1 stands in for the pipeline's project). eligible? runs it with .exists?; the CI config processor loads it with SELECT "security_scan_profile_triggers".* and no LIMIT. The default_branch_pipeline variant is the same with trigger_type = 0.

Before this MR

SELECT 1 AS one FROM "security_scan_profile_triggers"
INNER JOIN "security_scan_profiles" ON "security_scan_profile_triggers"."security_scan_profile_id" = "security_scan_profiles"."id"
INNER JOIN "security_scan_profiles_projects" ON "security_scan_profiles"."id" = "security_scan_profiles_projects"."security_scan_profile_id"
INNER JOIN "security_scan_profiles" "scan_profiles_security_scan_profile_triggers" ON "scan_profiles_security_scan_profile_triggers"."id" = "security_scan_profile_triggers"."security_scan_profile_id"
WHERE "security_scan_profiles_projects"."project_id" = 1
  AND "security_scan_profile_triggers"."trigger_type" = 1
  AND "security_scan_profiles"."deleted_at" IS NULL
LIMIT 1

After this MR (one added predicate)

SELECT 1 AS one FROM "security_scan_profile_triggers"
INNER JOIN "security_scan_profiles" ON "security_scan_profile_triggers"."security_scan_profile_id" = "security_scan_profiles"."id"
INNER JOIN "security_scan_profiles_projects" ON "security_scan_profiles"."id" = "security_scan_profiles_projects"."security_scan_profile_id"
INNER JOIN "security_scan_profiles" "scan_profiles_security_scan_profile_triggers" ON "scan_profiles_security_scan_profile_triggers"."id" = "security_scan_profile_triggers"."security_scan_profile_id"
WHERE "security_scan_profiles_projects"."project_id" = 1
  AND "security_scan_profile_triggers"."trigger_type" = 1
  AND "security_scan_profiles"."deleted_at" IS NULL
  AND "security_scan_profiles"."scan_type" != 5
LIMIT 1

The second, aliased join on security_scan_profiles comes from with_not_deleted_profile joining :scan_profile on top of the has_many :through. It was already there before this MR, and this MR does not change it.

Indexes the query can use

  • index_security_scan_profiles_projects_on_unique_project_profile (project_id, security_scan_profile_id): the project_id filter.
  • security_scan_profiles_pkey (id): both joins to security_scan_profiles.
  • index_security_scan_profile_triggers_on_profile_trigger_unique (security_scan_profile_id, trigger_type): the trigger join and the trigger_type filter.

The new scan_type != 5 predicate does not change which indexes are used. It filters the few profile rows already fetched by primary key for one project, as deleted_at IS NULL already does. No index covers scan_type alone, and a != on a small enum would not use one anyway.

Postgres removes the duplicate self-join on security_scan_profiles, so the plan shows three relations and applies both profile filters to the remaining scan. Re-checked on the exact query above (project 278964) on 2026-09-29: 2.3 ms execution, same plan.

Query plan (Database Lab, gitlab-production-sec clone, project 278964, 2026-09-25, postgresai joe explain): 7.7 ms total (planning 5.6 ms, execution 2.1 ms), index scans only:

Limit
  ->  Nested Loop
        ->  Nested Loop
              ->  Index Only Scan using index_security_scan_profiles_projects_on_unique_project_profile on security_scan_profiles_projects
                    Index Cond: (project_id = 278964)
              ->  Index Only Scan using index_security_scan_profile_triggers_on_profile_trigger_unique on security_scan_profile_triggers
                    Index Cond: ((security_scan_profile_id = security_scan_profiles_projects.security_scan_profile_id) AND (trigger_type = 1))
        ->  Index Scan using security_scan_profiles_pkey on security_scan_profiles scan_profiles_security_scan_profile_triggers
              Index Cond: (id = security_scan_profile_triggers.security_scan_profile_id)
              Filter: ((deleted_at IS NULL) AND (scan_type <> 5))

Design decisions

  • A separate run_eligibility_resolver, not goal_validator_resolver. The goal validator also runs on API and Duo Chat starts. A profile check there would wrongly require a profile for on-demand full scans. The likely counter-proposal is a flow-specific skip in EventTriggerService, like fix_pipeline/v1's stale check. The generic hook was kept so each flow declares its own rule in its definition, and RunService stays flow-agnostic.
  • Only successful MR head pipelines trigger a scan. This is a deliberate cost choice. Full default-branch scans start from Duo Chat or the API instead.
  • Use the flag, not a revert, as the kill switch. Reverting removes the per-project filter.
  • The preset stays readable by its virtual ID when the flag is off. That lookup has no namespace to check. Triage behaves the same. Creating, attaching, and running are gated. With the flag off, saved and attached Business Logic profiles are hidden too.
  • The once-per-pipeline lease is taken before RunService's later checks. It stops duplicate runs from retried jobs. The cost: if a later check or the start fails, that pipeline is not retried for a day.

Not in this MR

How to set up and validate locally

  1. Turn on bl_security_analyzer for a top-level group. Turn on foundational flows and the Business Logic Security Scan flow in its Duo settings.
  2. Attach the preset to a project with the securityScanProfileAttach GraphQL mutation, securityScanProfileId: "gid://gitlab/Security::ScanProfile/business_logic". (With the UI MR, use Secure > Security configuration instead.)
  3. Open an MR and let its pipeline succeed. The flow starts and scans the changed files.
  4. Retry a job in that pipeline so it succeeds again. No second scan starts. application_json.log shows Skipping BL security flow: pipeline already scanned, and RunService logs Flow run skipped at info level.
  5. Detach the profile and push again. The flow is skipped, and application_json.log shows the skip reason.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Meir Benayoun

Merge request reports

Loading
Loading