📎 Unreleased docs. This is the next version (from main), features here may change or not be in a release yet.See the latest release →

Guides

Scan a monorepo

On this page

A scanner's usual model is one repository, one application, one owner, one verdict. A monorepo breaks all four at once, and the breakages compound: one repository is many applications, so a single verdict describes nothing anybody owns; one application is many subtrees, so a scan scoped to one cannot see the rest; one flaw is many findings, because a shared library is imported by everything; and one commit produces many scans, which the platforms receiving them mostly assume it does not.

Draugr's descriptor is built around the first of those. The rest of this page is what to do about the other three.

Decide first: one project, or several

This is the decision everything else follows from, and it is not about the repository's shape. It is about who gates.

One pipeline over the whole tree is one project. One draugr.saga.yaml, components inside it, one verdict, one team answering for it. A platform team that owns CI while feature teams own code is this shape.

Each team gating its own slice is several projects. A Saga per team, each with its own project:, each with its own pipeline. They can live in the same repository and read the same files. Splitting a Saga across files shows the mechanics, written there for several products and identical for several teams.

The second shape gets a few things for nothing: each team's gate blocks only its own merges, the control plane files each under its own project, and the code-scanning categories are already distinct. Take it where teams genuinely gate independently.

The rest of this page is the first shape, which is the harder one.

Carve the tree into components

A component is a part of the application, and paths: is what makes it a part of one repository rather than all of it:

project: acme
release: { version: "4.2.0" }

components:
  - name: storefront
    exposure: public
    criticality: important
    labels: { team: web }
    repositories:
      - url: .
        paths: [services/storefront]

  - name: ledger
    exposure: internal
    criticality: critical
    labels: { team: payments }
    repositories:
      - url: .
        paths: [services/ledger]

paths: is a sparse checkout, so a scanner pointed at storefront sees that subtree and nothing else. Three things follow.

A finding says which component it belongs to, so a report over one repository is still a report about parts of it. The band is the component's, because exposure and criticality are declared per component, so the same CVE is P1 in the public storefront and P3 in something restricted. And a flaw in one component is not attributed to another, including where two components ship the same vulnerable package, which is two findings with two owners rather than one.

What it costs is analysis across the boundary. A taint flow that starts in services/storefront and ends in libs/shared is invisible to a scanner that was handed only the first, and nothing in the output says so. That is the price of carving by ownership, and it is worth naming because no tool that carves this way avoids it. A component that needs the whole tree analyzed together is one component with a wider paths:.

draugr doctor draugr.saga.yaml looks up every paths: entry in the commit the scan would check out, and fails on an entry that is not committed there, such as a directory that has since moved. See targets.

Root files

A file at the repository root belongs to the component whose paths: names it. A root lockfile, go.mod or Dockerfile is scanned once, under its owner, and a credential committed at the root is reported once rather than once per component. A component built from the root module or workspace names the files it builds from:

  - name: storefront
    repositories:
      - url: .
        paths: [services/storefront, package.json, package-lock.json]

Two components that both name package-lock.json both scan it, which is right when both ship what it pins. A member whose lockfile sits at the root and is not named appears under Caveats as unread-files with no lockfile, so the missing entry shows in the report.

The scanners' configuration at the root, .trivyignore, .semgrepignore, .gitleaks.toml and the rest listed in scoping a repository, is kept for every component, so a suppression written once applies to each.

An entry the repository does not hold, services/storfront for services/storefront, is refused with the entry named.

Give each team a pipeline that covers its own code

--labels selects components by what they are:

draugr scan draugr.saga.yaml --labels team=web
DRAUGR  FAIL  draugr-demo 1.0  (scope: 1 of 3 components)  6.061s

 4 P1 4 P2 1 P3 0 P4

CONTROLS
  iac       pass   no findings
  licenses  pass   no findings
  sast      pass   no findings
  sca       FAIL   4 P1 4 P2 1 P3
  secrets   pass   no findings

COMPONENTS
  storefront  FAIL   4 P1 4 P2 1 P3
  api         not scanned
  platform    not scanned

Every control still runs; what narrowed is which components they ran against.

The team's pipeline is about the team's code, so its pull-request comment is too. Use a label rather than --components: a team knows the label it files under, and a list of component names in a pipeline file goes stale the first time somebody adds a component.

--exposure and --criticality select the same way over Draugr's own vocabulary, for the run that is about a property rather than an owner:

draugr scan draugr.saga.yaml --exposure public --criticality critical

A selector that matches nothing is an error. A typo, or a label somebody moved, would otherwise scan nothing and pass. See the CLI reference for what the messages say.

Two scans of one commit do not erase each other

This is the failure worth knowing about before it happens, because nothing about it looks wrong.

GitHub code scanning replaces whatever it last received under the same tool and category. Two pipelines uploading against one commit therefore keep only whichever finished last, and both runs report success. A reader cannot tell a product with no findings from one whose findings were deleted by the next job.

Draugr gives each run a category from the descriptor's project: and from what the run was narrowed to:

The runThe category
project: acme, all of itacme
--labels team=web, which resolves to one componentacme/components:storefront
--components api,web --controls scaacme/components:api,web/controls:sca

So each team's pipeline, and each leg of a matrix, keeps its own alerts. Publishing to code scanning has the mechanics.

The same property makes a partial scan safe, which is the other half. A tool that uploads part of a repository under one category resolves everything it did not look at as fixed, and a resolved alert is a positive claim that somebody fixed something.

Scanning the same tree repeatedly stays cheap

A job scoped with paths: is cached against the content its checkout holds rather than the repository's commit, so a commit touching one component leaves every other component's result valid. That is what keeps a monorepo affordable, because a monorepo takes a commit every few minutes and a key naming the whole repository would be invalidated by every one of them.

Caching and performance has the measurement, and the two cases that keep the commit: a job that reads the repository's history, and a repository Draugr clones from a URL.

Gate a pull request on what it introduced

A monorepo has a backlog. Gating on all of it blocks every merge, so the gate gets turned off within a week and stops being a gate.

Gating on new findings is the answer and needs no monorepo-specific setup: the diff is between two scans of the same descriptor, so a team scoped to its own components diffs its own components. Scope the base scan the same way as the head scan, or the diff reports everything the base covered and the head did not as fixed.

Answer "whose is this" where there are many of them

Every finding carries its component's labels into results.sarif, so a platform holding many components can narrow a list to the ones somebody is answerable for.

They are deliberately absent from the console, the Markdown report and pull-request comments. Those answer what to fix, for a reader who already knows the work is theirs, and a column repeating the same team on every row of their own pull request is noise. Filtering is a question asked where there is a fleet.

What this page does not solve

  • Analysis across a component boundary, as above. Carving by ownership and analyzing the whole tree together are different requests.
  • A component nobody has classified. An undeclared component reads as public and critical, which is the safe direction and not a useful one at scale. draugr classify asks the two questions per component.
  • Knowing which components exist. Draugr scans what the descriptor declares. A subtree nobody wrote a component for is a subtree nobody scans, and draugr doctor reports surfaces the descriptor declares that no control examines rather than surfaces nobody declared.