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_mapThe 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
endScope
- Add
ee/config/feature_flags/wip/license_expression_checker.yml. - Update
ee/app/services/security/scan_result_policies/update_license_approvals_service.rbto branch betweenDeniedLicensesCheckerandLicenseExpressionCheckerbased on the flag. - Add
ee/lib/security/merge_request_approval_policies/license_expression_checker.rbwith:denied_licenses_with_dependencies(denylist path only)check_denied_licensesviolates_policy?with recursive:andtree walking and:id/:literalbranchesparse_expressionwith SPDX ID → display name resolution viaresolve_node_idsresolve_node_idsandspdx_catalogue_maphelperslicenses_to_check,license_states,license_dependencies_mapshared 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_checkerexists aswip,default_enabled: false. - When the flag is disabled,
UpdateLicenseApprovalsServiceusesDeniedLicensesChecker(no behaviour change). - When the flag is enabled,
LicenseExpressionCheckeris used instead. - A report expression
MIT AND Apache-2.0with policy denyingMIT License(display name) is flagged as a violation — SPDX IDMITis resolved toMIT Licensebefore comparison. - A report expression
MIT AND Apache-2.0with policy denyingApache License 2.0(display name) is flagged as a violation. - A report expression
MIT AND Apache-2.0with policy denying onlyGPL-3.0-onlyis not a violation. - A three-term expression
MIT AND Apache-2.0 AND GPL-3.0-onlywith policy denying any one of the three components is flagged as a violation (recursive tree walk). - A report license
MIT(plain identifier) with policy denyingMIT Licenseis flagged as a violation (:idpath, 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.rbPackageLicenses#license_name_for— same translation pattern,ee/lib/gitlab/license_scanning/package_licenses.rb