Managing GitLab License Policies With No Dependencies

GitLab's license approval policies provide a powerful and flexible means of managing dependency license approvals. Using these policies requires performing GitLab's Dependency Scanning to generate and report a Software Bill of Materials (SBOM). For the cases where there are no dependencies, Dependency Scanning is not run, no SBOM is created, and the license policies are considered failed and require approval. For projects without dependencies this is an added hassle, and this post proposes a solution.

The solution for no dependencies #

In GitLab 16.4, license approval policies were added as an enhancement to the previous License Scanning template. The documentation detailing the implementation indicates that Dependency Scanning should be enabled, then add the appropriate license approval policies, and the policies are enforced. Unfortunately, the Dependency Scanning job rules are optimized to only run when there are dependency lockfiles present, so when they are not it does not run, no SBOM is created, and the license approval policies fail and require approval even though there are no dependencies. Not the end of the world, but a hassle.

The solution for these cases is simple, and is actually captured in the GitLab documentation - ensure that a CycloneDX SBOM is created in some job and provided to GitLab as reports:cyclonedx. This then triggers license scanning using the contents of the SBOM.

This post covers non container-based projects, otherwise Container Scanning should be run to provide an SBOM.

Always run dependency scanning? #

One option investigated is simply overriding the rules for the Dependency Scanning template to run on all pipelines. Theoretically this should produce an SBOM with no dependencies, which is valid. Unfortunately, attempts with multiple projects showed inconsistent results. In some cases the SBOM was created with no dependencies, as expected, but in others no SBOM was created and no errors were identified by Gemnasium (GitLab's Dependency Scanning tool). There was no obvious commonality for these two cases.

Syft to the rescue #

So, another tool must be used to generate the SBOM. GitLab simply expects a CycloneDX formatted SBOM, a well established standard. The chosen solution here is the Syft application from Anchore. Syft is designed to create SBOMs from a host of different ecosystems, scanning either container images or file systems. It includes support for C/C++, Go, Java, JavaScript, Python, Ruby, Rust, Swift, and others. See the documentation on supported ecosystems for complete details. There are many other tools available, but Syft seemed to provide the most comprehensive single solution, and in tests consistently produced a valid CycloneDX SBOM with or without dependencies.

There is a Syft container images on Docker Hub, but this image is built from scratch and has no shell available (see the Dockerfile). Using an image with no shell in GitLab CI results in a log like the following because GitLab requires a shell to execute the script (see supported shells):

Executing "step_script" stage of the job script
Using docker image sha256:c9b97dbf39ec0138c627c8bd3b56e5d879bc0b8304f05db9dde6db9daff6d129
  for anchore/syft:v0.97.1 with digest
  anchore/syft@sha256:abc8d4310c54b56dd1e789d5f60b8ebc43f472652b34971d4b0d0dbed7f4ebda
Cleaning up project directory and file based variables
ERROR: Job failed (system failure): Error response from daemon: OCI runtime create failed:
  container_linux.go:380: starting container process caused: exec: "sh":
  executable file not found in $PATH: unknown (exec.go:78:0s)

So, a new Syft image was created that includes Syft in an Alpine Linux image so a shell is available. The details of this image are not addressed here, but feel free to post questions in the comments (or the project issues).

The GitLab CI job #

First, setup a job to simply run Syft and provide an SBOM:

syft_sbom:
  image:
    name: registry.gitlab.com/gitlab-ci-utils/container-images/syft:latest
    entrypoint: ['']
  script:
    - /syft/syft $CI_PROJECT_DIR -o cyclonedx-json=syft.cdx.json
  artifacts:
    paths:
      - syft.cdx.json
    reports:
      cyclonedx:
        - syft.cdx.json

This uses the new Syft image noted previously, with the entrypoint overridden to nothing. Syft is executed in the script with options to scan the current directory and produce a CycloneDX formatted file. This is saved as an artifact and uploaded as reports:cyclonedx to satisfy the license approval policy needs. This job could be used to always generate an SBOM, but in this example Dependency Scanning is be allowed to do its job where it can, and just use Syft to fill in the cases with no dependencies.

To finalize the rules for when Syft should be run, first examine the Dependency Scanning template rules. A subset is included below for the primary Gemnasium job.

.gemnasium-shared-rule:
  exists:
    - '**/Gemfile.lock'
    - '**/composer.lock'
    - '**/gems.locked'
    - '**/go.sum'
    - '**/npm-shrinkwrap.json'
    - '**/package-lock.json'
    - '**/yarn.lock'
    - '**/pnpm-lock.yaml'
    - '**/packages.lock.json'
    - '**/conan.lock'

gemnasium-dependency_scanning:
  rules:
    - if: $DEPENDENCY_SCANNING_DISABLED == 'true' || $DEPENDENCY_SCANNING_DISABLED == '1'
      when: never
    - if: $DS_EXCLUDED_ANALYZERS =~ /gemnasium([^-]|$)/
      when: never
    - if: $CI_COMMIT_BRANCH &&
          $GITLAB_FEATURES =~ /\bdependency_scanning\b/ &&
          $CI_GITLAB_FIPS_MODE == 'true'
      exists: !reference [.gemnasium-shared-rule, exists]
      variables:
        DS_IMAGE_SUFFIX: '-fips'
        DS_REMEDIATE: 'false'
    - if: $CI_COMMIT_BRANCH &&
          $GITLAB_FEATURES =~ /\bdependency_scanning\b/
      exists: !reference [.gemnasium-shared-rule, exists]

There's a rule template identifying the dependency lockfiles that triggers the job (this is important later), then a standard set of rules used in GitLab security scans:

  • An option for a variable DEPENDENCY_SCANNING_DISABLED to disable the Dependency Scanning job
  • An option to disable Gemnasium via the DS_EXCLUDED_ANALYZERS variable
  • Otherwise, if dependency_scanning is an available feature (the GITLAB_FEATURES variable is set by GitLab based on the license), execute the job if one of the available lockfiles exists (with both FIPS and non-FIPS variants)

There are similar jobs for the Maven and Python Gemnasium scanners that follow the same pattern.

Pulling this all together is a CI file that includes both the Dependency Scanning template and the Syft job defined previously, with overridden rules to only run in the appropriate cases.

include:
  - template: Dependency-Scanning.gitlab-ci.yml
  - local: /jobs/Syft.gitlab-ci.yml

# Included from Syft.gitlab-ci.yml - the job defined above
syft_sbom:
  rules:
    # If any of of these files exist, Gemnasium will be run and create an SBOM
    - exists: !reference [.gemnasium-shared-rule, exists]
      when: never
    # If any of of these files exist, Gemnasium-maven will be run and create an SBOM
    - exists: !reference [.gemnasium-maven-shared-rule, exists]
      when: never
    # If any of of these files exist, Gemnasium-python will be run and create an SBOM
    - exists: !reference [.gemnasium-python-shared-rule, exists]
      when: never
    # Otherwise, run Syft to create an SBOM, even if empty
    - if: $CI_COMMIT_BRANCH

In this case use the .gemnasium-shared-rule* templates defined in the Dependency Scanning template and setup rules to never run the Syft job if any of them are present, then finally a rule to run on any other branch pipelines (to match the GitLab template, although this could include tags, MRs, etc).