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
--recursivefinds: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/weband@acme/uiare modules — the npm detector expands them fromfrontend/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.
NoteDetectors 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 0for 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.