← All articles

The monorepo your scanner thinks is one app

September 13, 2026 · Wilson Santos · updated October 1, 2026
On this page

Point a scanner at a monorepo and it answers the question you asked. One number, one repository, one verdict.

Now try to use it. Which of those findings are the payments team's? Which are in the public checkout service and which in an internal batch job nobody outside can reach? If the build goes red, whose merges stop? The report cannot say, because the tool was never told the repository holds several applications. It was told it is one directory.

That is the monorepo problem, and it has nothing to do with repository size.

One checkout, two readings On the left, one box labelled acme with a single failing verdict over thirty-seven findings, and no owner. On the right, the same tree as three components: api and storefront, both public and failing with their own counts, and platform, internal and passing. Each names the team that answers for it. WHAT THE TOOL IS TOLD acme/ FAIL · 37 findings one verdict, no owner WHAT THE ORGANIZATION HAS api public · team platform FAIL · 14 P1 storefront public · team web FAIL · 4 P1 platform internal · team platform pass
The same checkout. The number on the left is true and answers nothing; the counts on the right each have somebody who can act on them.

One repository, one application, one owner, one verdict

That is the model almost every security tool is built on, and a monorepo breaks all four at once.

What the tool assumesWhat a monorepo doesWhat it costs
A repository is an applicationMany applications share a checkoutOne verdict describes nothing anybody owns
An application is a whole treeAn application is a subtreeA scan scoped to one cannot see the rest
A flaw is a findingA shared library is imported everywhereOne flaw arrives as many findings
A commit produces one analysisA commit produces one per teamThe last result replaces the others

The first three are annoying. The fourth is dangerous, and we will come back to it.

None of this is fixed by a smarter scanner. A scanner reads code and reports what it finds. Which part of the tree that code belongs to, who owns it, and whether it faces the internet are facts that are not in the code, so no amount of analysis recovers them. They have to be declared.

Describe the application, not the directory

Draugr takes a descriptor that names the parts:

project: acme
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

Nothing in that file is about scanners. It is a description of an application: what the parts are, how exposed each one is, how much depends on it, and who answers for it. The scanners are inferred from it.

Three things a monorepo needs follow immediately.

A finding belongs to a part, so the report is about parts.

DRAUGR  FAIL  draugr-demo 1.0  (scope: sca)  4.696s

 18 P1 17 P2 2 P3 0 P4

CONTROLS
  sca  FAIL   18 P1 17 P2 2 P3

COMPONENTS
  api         FAIL   14 P1 13 P2 1 P3
  storefront  FAIL   4 P1 4 P2 1 P3
  platform    pass   no findings

Thirty-seven findings, and the question "whose" already has an answer. Two components ship the same vulnerable package and that is two findings with two owners, not one finding somebody has to route.

The band is the part's, not the repository's. exposure and criticality are declared per component, so the same CVE is P1 in the public storefront and a backlog item in something internal. A tool that sees one repository has to give every finding the same context, which means giving most of them the wrong one.

A team can scan what it owns. A label on the component, and a pipeline that asks for it:

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

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

A label rather than a list of component names, because a team knows the label it files under and a list in a pipeline file goes stale the first time somebody adds a service. The run says what it covered and what it did not, so a narrowed run cannot be mistaken for a whole one.

The fourth breakage, which is the dangerous one

Splitting scans across teams is the obvious move once the parts are named, and it is where tools that were not told about the parts do real damage.

On GitHub, code scanning decides what an upload replaces by category:

If you upload a second SARIF file for a commit with the same category and from the same tool, the earlier results are overwritten.

A tool that sets no category files everything under the empty one, so the second pipeline against a commit erases the first team's alerts. Both runs report success. GitHub publishes an action specifically for monorepos whose job is to assign a category per project.

It is not a GitHub quirk. Semgrep documents the same hazard against its own backend, and its version is worse:

if --include and --exclude are used in a semgrep ci scan without setting SEMGREP_REPO_DISPLAY_NAME, that scan might close findings that aren't detected because that part of the repo was not scanned.

The verb is close, not miss.

A missed finding

The scan did not look there. Bad, and the report says so: nothing claims that subtree was clean.

A closed finding

The scan did not look there, and something recorded that the problem is gone. Indistinguishable from a fix, made on your behalf.

Anything that receives results and keys them on "the repository" has this shape of bug available to it. A descriptor that names the parts is what closes it: Draugr derives each run's analysis category from the project and what the run covered, so two teams against one commit land in different categories and keep both sets of alerts. On GitLab and Azure, where the results land as pipeline artifacts rather than a merged store, the same declaration is what keeps a team's report about that team.

The part that stays hard

Carving a repository by ownership costs analysis across the boundary. A taint flow that starts in one team's service and ends in a shared library is invisible to a scan handed only the first. For Go, Draugr runs gosec and govulncheck over the whole module the parts share, and gives each finding to the part whose paths hold its file, so a flow into a shared package is still seen and is ranked by the part that owns it, while a scanner that reads files sees only the part it was handed.

Worth saying because the alternative is not a better tool, it is a different question. Scanning a tree as one application and scanning it as six owned parts are two things to want, and a part that genuinely needs the whole tree analyzed together is one component with a wider path. What nobody should accept is a tool that quietly does the second while reporting as though it did the first.

If you have a monorepo

  1. Make the parts explicit somewhere a tool can read. Everything else depends on it, and it has to live beside the code so it moves when the code does.
  2. Give each part its exposure and criticality. Without them every finding is ranked against the same assumption, and on a repository holding a public gateway and an internal cron job that assumption is wrong most of the time.
  3. Check what happens when two pipelines touch one commit. If the platform keeps only the last, you will not find out from either run.
  4. Gate on what a change introduced. A monorepo has a backlog by definition, and a gate on all of it blocks every merge until somebody turns it off.

Draugr does the first three from one file and the fourth with draugr diff. The monorepo guide covers the setup, including the decision that comes first: whether the repository is one project with many components or several projects sharing a checkout, which depends on who gates rather than on how the directories are arranged. The output above came from the demo sandbox, which you can re-run.