Scan a monorepo
On this page
- Decide first: one project, or several
- Carve the tree into components
- Root files
- Give each team a pipeline that covers its own code
- Two scans of one commit do not erase each other
- Scanning the same tree repeatedly stays cheap
- Gate a pull request on what it introduced
- Answer "whose is this" where there are many of them
- What this page does not solve
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 scannedEvery 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 run | The category |
|---|---|
project: acme, all of it | acme |
--labels team=web, which resolves to one component | acme/components:storefront |
--components api,web --controls sca | acme/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 classifyasks 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 doctorreports surfaces the descriptor declares that no control examines rather than surfaces nobody declared.