Add LicenseExpressionChecker: feature flag + denylist AND operator

What and why

With the ExpressionParser in place (Issue 1), this issue introduces Security::MergeRequestApprovalPolicies::LicenseExpressionChecker and wires it behind a wip feature flag. The checker handles the denylist path for AND expressions and plain single-identifier licenses (:id node type).

AND is the first operator because it is the most intuitive: a dependency carries all component licenses simultaneously, so denying any single component is sufficient to flag a violation. The :id branch (plain identifiers like MIT) must ship alongside AND because real reports always contain single-identifier licenses alongside compound ones.

The SPDX ID vs. display name mismatch

This is a correctness requirement that must ship in this issue, not a follow-up.

The problem: policies store license display names as entered by the user in the YAML (e.g. MIT License, Apache License 2.0). The existing DeniedLicensesChecker compares license.name (display name) against policy names — this works because PackageLicenses already translates SPDX IDs to display names via Gitlab::SPDX::Catalogue before building the report.

However, when a CycloneDX SBOM contains a license expression (e.g. MIT AND Apache-2.0), the expression string is stored as-is in license.name and license.id is nil. The ExpressionParser correctly decomposes this into :id nodes with values "MIT" and "Apache-2.0" — but these are SPDX IDs, not display names. Comparing "MIT" against a policy denying "MIT License" produces no match.

The fix: after parsing, walk the node tree and replace :id node values that are known SPDX IDs with their display names using the same Gitlab::SPDX::Catalogue that PackageLicenses already uses. If a node value is a known SPDX ID, replace it with the display name. If it is not in the catalogue (custom LicenseRef- identifiers, unknown strings), keep it as-is — this is the correct fallback for non-standard identifiers.

def parse_expression(name)
  result = ::Gitlab::SPDX::ExpressionParser.new(name).parse
  resolve_node_ids(result.node)
  result
end

def resolve_node_ids(node)
  case node.type
  when :id
    node.value = spdx_catalogue_map[node.value] || node.value
  when :and, :or, :with
    node.children.each { |child| resolve_node_ids(child) }
  end
end

def spdx_catalogue_map
  Gitlab::SPDX::Catalogue.latest_active_licenses.to_h { |l| [l.id, l.name] }
end
strong_memoize_attr :spdx_catalogue_map

The catalogue is already cached for 7 days in Rails cache (see Gitlab::SPDX::Catalogue::LATEST_ACTIVE_LICENSES_CACHE_KEY), so there is no performance concern. LicenseRef- and DocumentRef- identifiers are not in the SPDX catalogue by definition — the || node.value fallback passes them through unchanged.

Node tree walking

ExpressionParser (from Issue 1) produces a left-associative binary tree for multi-term AND expressions. For example, MIT AND Apache-2.0 AND GPL-3.0 produces:

:and
├── :and
│   ├── :id "MIT"
│   └── :id "Apache-2.0"
└── :id "GPL-3.0"

The checker recursively walks the full tree so that all leaf nodes are evaluated, regardless of how many terms the AND expression contains:

def violates_policy?(node, denied_names)
  case node.type
  when :and
    node.children.any? { |child| violates_policy?(child, denied_names) }
  when :id
    denied_names.include?(node.value)
  when :literal
    denied_names.include?(node.value)
  else
    # :or, :with, :plus — not yet handled; fall back to literal match
    denied_names.include?(node.value.to_s)
  end
end

Scope

  • Add ee/config/feature_flags/wip/license_expression_checker.yml.
  • Update ee/app/services/security/scan_result_policies/update_license_approvals_service.rb to branch between DeniedLicensesChecker and LicenseExpressionChecker based on the flag.
  • Add ee/lib/security/merge_request_approval_policies/license_expression_checker.rb with:
    • denied_licenses_with_dependencies (denylist path only)
    • check_denied_licenses
    • violates_policy? with recursive :and tree walking and :id / :literal branches
    • parse_expression with SPDX ID → display name resolution via resolve_node_ids
    • resolve_node_ids and spdx_catalogue_map helpers
    • licenses_to_check, license_states, license_dependencies_map shared helpers
  • Add ee/spec/lib/security/merge_request_approval_policies/license_expression_checker_spec.rb.

Implementation notes

AND semantics: a dependency carries all component licenses simultaneously. Denying any single component is a violation. The checker recurses into both children of each :and node, so three-term (and longer) AND expressions are fully evaluated.

:id semantics: literal match of the resolved display name against the policy denylist. After ID resolution, a plain MIT expression node becomes MIT License, matching the policy directly.

:literal semantics (parse-error fallback): the raw expression string is matched literally against the policy denylist. This also serves as the fallback for :or, :with, and :plus node types, which are not handled in this iteration.

The licenses_to_check helper handles the three license_states variants:

  • only_newly_detected — diff between target branch report and pipeline report (added only)
  • include_newly_detected — union of both reports
  • default — target branch report only

The feature flag is wip and default_enabled: false. The checker is safe to merge because the flag is off by default. DeniedLicensesChecker continues to handle all traffic until the flag is enabled.

Note on duplication: LicenseExpressionChecker intentionally duplicates some logic from DeniedLicensesChecker. The long-term plan is to replace DeniedLicensesChecker once LicenseExpressionChecker reaches feature parity. Keeping them separate avoids coupling the refactor to the feature delivery.

Acceptance criteria

  • Feature flag license_expression_checker exists as wip, default_enabled: false.
  • When the flag is disabled, UpdateLicenseApprovalsService uses DeniedLicensesChecker (no behaviour change).
  • When the flag is enabled, LicenseExpressionChecker is used instead.
  • A report expression MIT AND Apache-2.0 with policy denying MIT License (display name) is flagged as a violation — SPDX ID MIT is resolved to MIT License before comparison.
  • A report expression MIT AND Apache-2.0 with policy denying Apache License 2.0 (display name) is flagged as a violation.
  • A report expression MIT AND Apache-2.0 with policy denying only GPL-3.0-only is not a violation.
  • A three-term expression MIT AND Apache-2.0 AND GPL-3.0-only with policy denying any one of the three components is flagged as a violation (recursive tree walk).
  • A report license MIT (plain identifier) with policy denying MIT License is flagged as a violation (:id path, ID resolved to display name).
  • A license identifier not in the SPDX catalogue is kept as-is and matched literally.
  • license_states: ['newly_detected'] checks only newly added licenses.
  • license_states: ['detected'] checks the target branch report.
  • Spec uses feature_category: :security_policy_management.

References

  • Parser issue: #600026 (closed)
  • POC MR: !235265 (closed)
  • Gitlab::SPDX::Catalogue — ee/lib/gitlab/spdx/catalogue.rb
  • PackageLicenses#license_name_for — same translation pattern, ee/lib/gitlab/license_scanning/package_licenses.rb
Edited by 🤖 GitLab Bot 🤖