Case studyBomly CLIby Ahmed ElMallah4 min read

Scanning a polyglot monorepo with Bomly

Scan npm workspaces, Go modules, and Python services in one pass: Bomly CLI's recursive discovery, per-module manifests, and CI artifacts on a real monorepo.

Scanning a polyglot monorepo with Bomly

A monorepo with services in several languages is awkward to audit: each ecosystem has its own tooling, and you end up stitching reports together by hand. Since v0.18.0, the Bomly CLI does this in one pass: bomly scan --recursive finds every nested project, and workspace ecosystems (npm, pnpm, Cargo, Maven, Gradle) are shown module by module.

Every command and output block below ran against bomly 0.18.0, exactly as shown, on the fixture described next — in our setup, on the day of writing. Advisory counts and package versions will differ when you run it.

The repository

The fixture is small but realistic: an npm workspace, a Go service, a Python service, and an old prototype nobody deleted.

acme-shop/
├── examples/
│   └── legacy/          # abandoned npm prototype (express)
├── frontend/            # npm workspace root: apps/* + packages/*
│   ├── package.json
│   ├── package-lock.json
│   ├── apps/web/        # @acme/web — react, react-dom, @acme/ui
│   └── packages/ui/     # @acme/ui — clsx
└── services/
    ├── api/             # Go module — chi, google/uuid
    └── billing/         # Python service — flask, requests

There is no manifest at the root. That is common in monorepos, and it is exactly what a single-directory scan cannot see.

Recreate the fixture yourself

All six manifests. Lockfiles are generated, not hand-written: run npm install --package-lock-only in frontend/ and examples/legacy/, and go mod tidy in services/api/ (it needs one .go file importing the two modules).

frontend/package.json:

{
  "name": "@acme/frontend",
  "version": "1.0.0",
  "private": true,
  "workspaces": ["apps/*", "packages/*"]
}

frontend/apps/web/package.json:

{
  "name": "@acme/web",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "@acme/ui": "1.0.0",
    "react": "19.1.0",
    "react-dom": "19.1.0"
  }
}

frontend/packages/ui/package.json:

{
  "name": "@acme/ui",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "clsx": "2.1.1"
  }
}

services/api/go.mod:

module acme.example/api

go 1.26.4

require (
	github.com/go-chi/chi/v5 v5.3.1
	github.com/google/uuid v1.6.0
)

services/billing/requirements.txt:

flask==3.1.1
requests==2.32.4

examples/legacy/package.json:

{
  "name": "legacy-prototype",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "express": "4.21.2"
  }
}

What the default scan does

By default, bomly scan only inspects the directory you point it at. From the root of this tree there is nothing to find, and the CLI says so:

bomly scan
Nothing to evaluate: no subprojects discovered for execution target with the applied filters; discovery probe: found package-lock.json at examples/legacy (npm); found package-lock.json at frontend (npm); found package.json at frontend/apps/web (npm); found package.json at frontend/packages/ui (npm); found go.mod at services/api (gomod); found requirements.txt at services/billing (pip); hint: manifests exist in subdirectories (e.g. examples/legacy); retry with --recursive

The scan exits with code 5, and the discovery probe lists every manifest it saw on the way out.

One scan from the root

bomly scan --recursive
✓ 87 packages in 6 manifests   (18 direct, 69 transitive · runtime 87, dev 0)
  ├─ examples/legacy (subproject, npm)
  │  └─ legacy-prototype — 69 packages [package-lock.json]
  ├─ frontend (subproject, npm)
  │  └─ @acme/frontend — 0 packages, 2 modules [package-lock.json]
  │     ├─ @acme/web (module, npm) — 4 packages [frontend/apps/web/package.json]
  │     └─ @acme/ui (module, npm) — 1 package [frontend/packages/ui/package.json]
  ├─ services/api (subproject, gomod)
  │  └─ acme.example/api — 2 packages [go.mod]
  └─ services/billing (subproject, pip)
     └─ root — 12 packages [requirements.txt]

(The report also prints a top-level dependency table; it appears in the enrichment section below.)

One command, one graph: the npm workspace, the Go module, the Python service, and the forgotten prototype, each resolved by its own native detector.

Subprojects and modules

The tree shows two kinds of nesting:

  • A subproject is an independent nested directory with its own detector run. That is what --recursive finds: frontend, services/api, services/billing, examples/legacy.
  • A module is a member the package manager itself resolves under one root manifest: an npm/pnpm workspace member, a Cargo workspace member, a Maven reactor module. @acme/web and @acme/ui are modules — the npm detector expands them from frontend/package.json.

Modules do not need --recursive: a scan of frontend/ alone would still show both. They are also not double-counted: the walk prunes nested npm manifests below frontend, because the workspace detector already resolves them.

The npm, pnpm, Cargo, and Maven detectors emit one manifest entry per module since v0.18.0, and Gradle since v0.18.1 — each with its own dependency subtree. That is why @acme/web has its own row above. The same grouping appears in the interactive TUI, the markdown report, and the MCP scan summary. JSON output is structurally unchanged: the hierarchy is derived from each manifest's existing subproject and path fields, so your own tooling can group rows the same way.

Note

Detectors without per-module output (sbt, Mix, classic Yarn, pub) keep one merged root manifest, as before.

Trimming the walk

The walk skips dot-directories, node_modules, vendor, build outputs (target, build, dist), and Python virtualenvs on its own. Depth is capped at 3 levels by default; --max-depth changes the cap (0 removes it).

--exclude adds your own glob patterns. The legacy prototype adds 69 packages we never ship, so it should not be in the report or the SBOM:

bomly scan --recursive --exclude examples
✓ 18 packages in 5 manifests   (17 direct, 1 transitive · runtime 18, dev 0)
  ├─ frontend (subproject, npm)
  │  └─ @acme/frontend — 0 packages, 2 modules [package-lock.json]
  │     ├─ @acme/web (module, npm) — 4 packages [frontend/apps/web/package.json]
  │     └─ @acme/ui (module, npm) — 1 package [frontend/packages/ui/package.json]
  ├─ services/api (subproject, gomod)
  │  └─ acme.example/api — 2 packages [go.mod]
  └─ services/billing (subproject, pip)
     └─ root — 12 packages [requirements.txt]

A pattern without a slash matches directory names at any depth (--exclude dist skips every dist/); a pattern with a slash matches relative to the scan root (--exclude "apps/*"). The flag is repeatable and takes comma-separated values.

Advisories and licenses

Everything so far is dependency resolution only. Advisory and license data is a separate step — network enrichment is opt-in via --enrich:

bomly scan --recursive --enrich
✓ 87 packages in 6 manifests   (18 direct, 69 transitive · runtime 87, dev 0)
  [manifest tree as above]

✓ Enriched via Grype, deps.dev License Matcher

Top-level dependencies
  NAME                       VERSION     LICENSE        SCOPE     VULNS
  blinker                    1.9.0       MIT            runtime   -
  certifi                    2026.6.17   MPL-2.0        runtime   -
  charset-normalizer         3.4.9       MIT            runtime   -
  click                      8.4.2       BSD-3-Clause   runtime   -
  clsx                       2.1.1       MIT            runtime   -
  express                    4.21.2      MIT            runtime   -
  flask                      3.1.1       BSD-3-Clause   runtime   1L
  github.com/go-chi/chi/v5   v5.3.1      MIT            runtime   -
  github.com/google/uuid     v1.6.0      BSD-3-Clause   runtime   -
  idna                       3.18        BSD-3-Clause   runtime   -
  itsdangerous               2.2.0       non-standard   runtime   -
  jinja2                     3.1.6       non-standard   runtime   -
  markupsafe                 3.0.3       BSD-3-Clause   runtime   -
  react                      19.1.0      MIT            runtime   -
  react-dom                  19.1.0      MIT            runtime   -
  requests                   2.32.4      Apache-2.0     runtime   1M
  urllib3                    2.7.0       MIT            runtime   -
  werkzeug                   3.1.8       BSD-3-Clause   runtime   -

1L and 1M are advisory counts by severity: on the day of the scan, one low on flask@3.1.1 and one medium on requests@2.32.4. Every package sits under a subproject or module, so the tree answers "which service does this belong to" directly.

Artifacts for CI

The same recursive scan renders as an SBOM or as findings:

# CycloneDX 1.6 SBOM of the whole monorepo
bomly scan --recursive --exclude examples --format cyclonedx > sbom.cdx.json

# SARIF 2.1.0 findings for code-scanning dashboards
bomly scan --recursive --enrich --audit --format sarif > bomly.sarif

--format sarif requires --audit: SARIF is a findings format, and without an auditor pass there are no findings to report. Each SARIF finding points at the manifest that owns it — services/billing/requirements.txt, not the repo root.

Upload recipes are in the CI integration docs; the format matrix is in output formats.

Notes and limitations

  • Recursive discovery walks 3 levels deep by default. Projects nested deeper need --max-depth (or --max-depth 0 for no limit).
  • Per-module manifest entries exist for npm, pnpm, Cargo, Maven, and Gradle today; other multi-module ecosystems keep a single merged root manifest.
  • Symlinked directories are not followed during the walk.

For a single-language repo, plain bomly scan from that directory is already enough. For everything else, add --recursive.

FAQ

Does --recursive descend into node_modules or vendored code? No. Dot-directories, node_modules, vendor, build outputs, and Python virtualenvs are skipped by built-in ignore rules; --exclude adds your own.

Do npm workspaces need --recursive? No. Workspace members are modules, expanded natively from the root manifest. --recursive is for independent projects in nested directories.

Does any of this require network access? Advisory and license enrichment is opt-in via --enrich. Resolution itself can also reach out, depending on the ecosystem: in this fixture, services/billing has a requirements.txt but no lockfile, so the pip detector resolves it by running pip install into an isolated environment — which may contact PyPI — and build-tool detectors (Go, Maven, Gradle) may download uncached artifacts, exactly as the tools themselves would. Lockfile parsers (npm, pnpm, Cargo, and friends) work from the file alone.


Read the scan targets docs