A local, free dependency intelligence tool for Go. For every module in your build's dependency graph, lichen shows the pinned version, the version each parent actually asked for, the license, known CVEs, the language / runtime version, release dates, the per-version upgrade path, and a security-aware recommended upgrade -- from your machine, with no account and no commercial backend, and it works offline.
The name: a lichen is a symbiosis of distinct organisms (a fungus and an alga) living as one. A dependency graph is the same idea -- many independent projects bound into a single build.
Knowing what you actually depend on, and when it is safe to move, is harder than it should be:
go list -m allshows pinned versions but not licenses, CVEs, or who pulled a dep in.- Commercial SCA suites answer these, but the full feature set is paid and analysis typically runs through a vendor's backend -- your dependency tree leaves the building.
govulncheckdoes excellent reachability analysis for Go, but it answers one question (is a vulnerable symbol called) -- not "what's my license exposure, what's outdated, which upgrade fixes this CVE, and is that upgrade safe."
And underneath all of it sits a timing problem: the most dangerous version of a package is the one published an hour ago. Hijacked-maintainer and poisoned releases are usually caught and yanked within days, yet auto-bump tooling races you onto day-zero releases, and almost nothing in the free stack gates on release age or steers an upgrade to the newest vetted fix instead of the newest release. lichen treats release freshness as policy: a configurable soak window that both the CI gate and the upgrade recommendations respect (see "Why a soak window?" under Policy / CI gating).
lichen fills these gaps with a 100% free stack: the language's own build tools
locally (go mod graph, go list), the public deps.dev
API (licenses, versions, advisories; no auth), and the language's release feed
(go.dev for the Go toolchain). One local view, plus one CI
gate behind a single exit code.
lichen gates; it does not do reachability. For Go, run govulncheck for
call-graph reachability and keep lichen as the offline-capable, cross-ecosystem
policy gate (license + staleness + advisory + freshness) and upgrade
advisor. They complement each other.
The interactive HTML report (lichen . --json | node tools/dist/viz.js),
rendered here from the bundled offline fixture (npm run viz:demo). One row per
dependency: pinned version, the version the parent asked for, license, advisory
pills linking to OSV, and the expanded upgrade path with per-version release
dates, licenses, and the security-aware recommendation. Severity filters and a
name filter collapse the tree to just the paths that matter; the page is a
single self-contained file that follows your light/dark preference.
| Pinned version | what MVS/resolution actually selected into your build |
| Parent constraint | the version the parent declared, shown separately so you can see when resolution bumped a transitive dep above what a parent asked for |
| License | SPDX, from deps.dev; flags a relicense on upgrade (e.g. MIT -> BUSL) |
| Advisories | OSV/CVE id, CVE alias, CVSS score, title, link |
| Language version | Go's go / toolchain requirement, flagged when a newer stable release exists |
| Available versions | full published list; per-version release dates |
| Recommended upgrade | security > license > highest version that qualifies, with the trade-off stated |
| Scope | production / test / not-imported, from go list -deps vs go list -test -deps. not-imported deps (in go mod graph but imported by neither your build nor its tests) are informational and never gate, so a CVE in a dep nobody imports doesn't redline the build |
lichen is a Node CLI (Node 18+). Build from source:
git clone <repo> lichen && cd lichen
npm install && npm run build # builds the engine/CLI -> dist/, and tools/That produces dist/cli.js, run directly as node dist/cli.js.
To get a real lichen command (instead of node dist/cli.js), link it globally:
npm link # symlinks `lichen` onto your PATH
lichen --offline # now runnable as `lichen`Once published to npm it will also be available as npm i -g lichen and
npx lichen (the package is publish-ready: bin, files, and a
prepublishOnly build are wired; the maintainer publishes with a single
npm publish under the chosen registry name). The examples below use
node dist/cli.js; substitute lichen if you linked it.
node dist/cli.js # scan the current dir, print the text tree
node dist/cli.js ~/code/myapp # scan another Go modulenode dist/cli.js [path] [flags] (path defaults to .). Flags, grouped by how
often you reach for them:
Everyday
| Flag | Effect |
|---|---|
(positional) path |
directory containing the go.mod to scan (default .) |
--json |
print the enriched tree as JSON (pipe to the visualizer or CI). Carries a top-level schemaVersion a consumer can rely on (bumped only on a breaking change) |
--format <fmt> |
output format: tree (default), json, or cyclonedx (a CycloneDX 1.6 SBOM with components + vulnerabilities, for ingestion by Dependency-Track and similar). --json is shorthand for --format json |
--offline |
skip deps.dev: tree shape + pinned + parent constraint only |
--upgrades |
also fetch each newer version's license + advisories (powers the per-version upgrade list and the security-aware recommendation) |
--critical <n> |
CVSS above n counts as "critical" when choosing the recommended upgrade (default 7.0; 0 treats any scored advisory as critical). Mirrors the visualizer's --critical |
--config <file> |
org base policy; the scanned repo's lichen.config.json is layered on top as a local overlay that can only tighten the base (used alone if no repo config is present) |
Testing / air-gapped (feed saved command/API output instead of running go / calling deps.dev)
| Flag | Effect |
|---|---|
--graph-file <file> |
canned go mod graph output (use with --list-file) |
--list-file <file> |
canned go list -m all output |
--deps-file <file> |
canned go list -deps output (production scope set); omit and scope stays unknown |
--test-deps-file <file> |
canned go list -test -deps output (production + test scope set) |
--facts-file <file> |
canned per-package facts (offline license/advisory/version enrichment). Accepts a lichen facts snapshot (versioned {schemaVersion, facts} envelope) or a legacy bare map |
Exit codes: 0 clean, 1 hard error, 2 policy violation.
Alongside the default scan, lichen has four verbs. apply mutates (behind
--yes); impact, why, and facts are read-only.
| Command | What it does |
|---|---|
lichen apply [path] |
upgrade dependencies to the recommended fix (see Apply upgrades) |
lichen impact <module>[@<version>] [path] |
forward blast radius: what a bump forces upward via Go MVS, with the relicense / CVE delta per forced node. With a policy present (lichen.config.json / --config), it also flags upgrades that introduce a gate violation (a newly-denied license or an over-threshold CVE) via !! GATE lines. Omit @<version> to bump to the latest known. --json for machine output; --requirements-file for a fully offline run |
lichen why <module> [path] |
reverse query: the parent requirement(s) pinning a module below its latest, plus the command that lifts the cap |
lichen facts [path] |
emit a versioned {schemaVersion, facts} snapshot of this build's deps.dev data, to feed an air-gapped gate via --facts-file (see Air-gapped CI) |
# What does bumping shared to v1.7.0 drag along?
node dist/cli.js impact github.com/acme/shared@v1.7.0
# Target: shared v1.5.0 -> v1.7.0 [relicense MIT -> BUSL-1.1] (clears CVE-2024-9999)
# Forced: leaf v0.4.0 -> v0.5.0 (clears CVE-2024-1111)
# Why is shared stuck at v1.5.0?
node dist/cli.js why github.com/acme/shared
# Held at v1.5.0 by: github.com/acme/parent-b requires v1.5.0
# To lift it: go get github.com/acme/shared@v1.7.0Impact analysis is Go-only for now: the forward walk relies on MVS's
single-selected-version property. Range ecosystems (Maven/NuGet/npm) need the
version-range model first, so impact errs clearly on a non-Go tree.
# Human tree of the module in the current directory
node dist/cli.js
# Full upgrade detail (per-version license + CVEs + recommendation)
node dist/cli.js . --upgrades
# JSON for CI, or to feed the visualizer
node dist/cli.js . --json > tree.json
node dist/cli.js . --json | node tools/dist/viz.js > report.html
# Gate a build: fail (exit 2) on a denied license or a CVSS over the threshold
node dist/cli.js . --config lichen.config.json ; echo "exit=$?"
# Fast structural view with no network
node dist/cli.js . --offlineGating is opt-in per repo: drop a lichen.config.json at the scanned repo root
(or point at one with --config). When present, lichen evaluates it and exits
2 on any violation, which fails a CI step. No file means gating is off.
{
"licenseDenylist": ["GPL-3.0", "AGPL-3.0"],
"maxOutdated": 5,
"maxCvssSeverity": 7.0,
"minReleaseAgeDays": 14,
"ignoreAdvisories": ["GHSA-xxxx-yyyy-zzzz", "CVE-2024-1234"],
"exceptions": [
{ "id": "CVE-2024-5678", "reason": "patch tracked in JIRA-123", "expires": "2026-09-30" }
]
}| Key | Effect |
|---|---|
licenseDenylist |
SPDX expressions that fail the gate (case-insensitive) |
maxOutdated |
max distinct outdated packages allowed |
maxCvssSeverity |
any advisory with CVSS strictly above this fails (0 = block on any scored CVE) |
minReleaseAgeDays |
soak window: a pinned version younger than this warns (fails under --strict); also keeps recommendations from pointing at versions younger than the window |
ignoreAdvisories |
permanent, ungoverned mute, matched on advisory id or CVE alias |
exceptions |
governed, time-boxed waivers: { id, reason, expires }. While unexpired the advisory is an "accepted risk" (reported, never failed); once expires passes it re-blocks |
Why a soak window? The riskiest moment to adopt a release is right after it
ships. Hijacked-maintainer and poisoned releases are usually detected and
yanked within days of publication, so a version that has survived two weeks in
public has had the ecosystem's scanners, researchers, and other people's CI
runs vet it for you; a version published an hour ago has had none of that.
The pattern is not hypothetical: the
chalk/debug npm hijack
(September 2025; foundational packages pulling 2-3 billion downloads a week,
malicious versions live for only hours), the self-replicating
Shai-Hulud worm
(100+ npm packages), and the
xz-utils backdoor
(CVE-2024-3094, caught before it reached mainstream stable distributions) all
played out inside exactly this window. (Dependency-update bots and package
managers have begun shipping the same control as a "cooldown" or minimum
release age, several in direct response to Shai-Hulud.) minReleaseAgeDays applies it in both directions:
too-fresh pins warn (fail under --strict), and lichen's own upgrade
recommendations skip versions younger than the window instead of steering
you into the risk zone. One deliberate exception: when the only fix for a
known CVE is younger than the window, lichen still recommends it but flags its
age, because a known vulnerability usually outweighs an unknown one.
Advisories without a CVSS score are reported but never gate by default (so an
unscored advisory neither silently fails nor silently passes unseen);
--strict blocks them. Each distinct name@version contributes at most one
advisory violation. Dependencies scoped not-imported (in go mod graph but
imported by neither your build nor its tests) are informational and never gate.
An advisory violation is actionable: it marks whether the vulnerable module is a
direct or transitive dependency and gives the upgrade command. For a
transitive module it also names the direct dependency that introduced it (so you
can bump that instead of pinning the module). For Go:
[advisory] github.com/acme/shared@v1.5.0: ... CVSS 9.1 exceeds maxCvssSeverity=7 -- transitive (via github.com/acme/parent-a); go get github.com/acme/shared@v1.7.0
[advisory] github.com/acme/direct-dep@v1.0.0: ... CVSS 8.2 exceeds maxCvssSeverity=7 -- direct; go get github.com/acme/direct-dep@v1.4.0
A transitive fix command targets the vulnerable module itself; Go's MVS adds the
indirect pin to force it. When no acceptable newer version exists the hint reads
-- direct; no fix available (or -- transitive (via ...); no fix available).
exceptions vs ignoreAdvisories. ignoreAdvisories is a permanent,
reasonless mute. exceptions is the audit-trail-friendly waiver: it carries a
reason and an expiry, surfaces as an "accepted risk" in the output while valid,
and automatically re-blocks once it expires (so a forgotten waiver can't hide a
CVE forever).
Central-config layering (org base + local overlay). Point --config at a
shared org policy and keep a repo-local lichen.config.json, and lichen layers
them: the local file can only tighten the org base, never weaken it
(maxCvssSeverity/maxOutdated take the lower value, licenseDenylist is
unioned, ignoreAdvisories is intersected; an omitted local key inherits the
org value). A team can be stricter than org policy but can't opt out of it. With
only one file present, that file is used as-is.
lichen is exit-code-driven: 0 clean, 1 hard error (bad input,
toolchain missing), 2 policy violation. In CI, run it with --quiet (prints
only the gate result and lets the exit code fail the step) and, if you want the
strictest posture, --strict (block unscored advisories and too-fresh pins).
Makefile
LICHEN ?= node dist/cli.js
.PHONY: deps-gate
deps-gate: ## fail the build on a dependency-policy violation
$(LICHEN) . --quiet --config lichen.config.jsonGitHub Actions
name: deps-gate
on: [push, pull_request]
jobs:
lichen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5 # lichen shells out to `go` for the graph
with: { go-version: stable }
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build # build the lichen CLI
# exit 2 (a policy violation) fails the job; --strict for the strictest gate.
- run: node dist/cli.js . --quiet --strict --config lichen.config.jsonLayer an org-wide policy over a repo-local one by committing a lichen.config.json
in the repo and pointing --config at the vendored/shared org policy: the repo
file can only tighten the org base (see central-config layering above).
Machine-readable gate report (--gate-json). For a central ingester, a
waiver dashboard, or a PR bot, add --gate-json to emit the gate result as JSON
on stdout (and nothing else, so it pipes cleanly). The exit code is unchanged.
node dist/cli.js . --quiet --gate-json --config lichen.config.json | jq .{
"ok": false,
"violations": [
{ "kind": "advisory", "message": "github.com/acme/shared@v1.5.0: ... -- transitive (via github.com/acme/parent-a); go get github.com/acme/shared@v1.7.0" }
],
"warnings": [],
"acceptedRisks": [
{ "node": "github.com/acme/other@v2.0.0", "id": "GHSA-...", "reason": "patch tracked in JIRA-123", "expires": "2026-12-31" }
]
}ok is true exactly when there are no hard violations (exit 0).
acceptedRisks is the granted-exception (waiver) roll-up -- every active
governed exceptions entry, with its reason and expiry, for the audit trail.
Regulated and air-gapped Go shops cannot send their dependency tree to a SaaS
scanner. lichen runs the whole gate offline and toolchain-free on the
locked-down box: it needs no network and no go install, only the graph you
capture and a facts snapshot. The org policy is enforced with tighten-only
layering, so a local overlay can never weaken the base (see central-config
layering above).
The flow is two steps across the air gap:
-
On a connected build box, snapshot the dependency graph and the deps.dev facts (versions, licenses, advisories, upgrade paths):
go mod graph > graph.txt # the module requirement graph go list -m all > list.txt # the resolved/pinned versions node dist/cli.js facts . > facts.json # versioned deps.dev snapshot
Copy
graph.txt,list.txt,facts.json, and yourorg-policy.jsonacross the gap. -
On the air-gapped box, gate with zero network and zero toolchain. The graph and facts are read from files; nothing phones home:
node dist/cli.js . --quiet --strict \ --graph-file graph.txt --list-file list.txt \ --facts-file facts.json --offline \ --config org-policy.json ; echo "exit=$?"
The offline run reproduces the same gate verdict and exit code as an online
run on the same tree: the snapshot is the exact data the gate reasons about. The
snapshot is a versioned {schemaVersion, facts} envelope so a snapshot captured
by one lichen build is safe to consume by another; the --facts-file reader
also accepts a legacy bare map for back-compat.
Reference GitHub Action (the connected box publishes the snapshot as an
artifact; the gate job consumes it with no setup-go and no network):
name: airgapped-deps-gate
on: [push, pull_request]
jobs:
snapshot: # runs where deps.dev + the Go toolchain are reachable
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: stable }
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- run: |
go mod graph > graph.txt
go list -m all > list.txt
node dist/cli.js facts . > facts.json
- uses: actions/upload-artifact@v4
with:
name: deps-snapshot
path: |
graph.txt
list.txt
facts.json
gate: # runs offline: no setup-go, no deps.dev
needs: snapshot
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- uses: actions/download-artifact@v4
with: { name: deps-snapshot }
# exit 2 fails the job. No network is touched here.
- run: |
node dist/cli.js . --quiet --strict \
--graph-file graph.txt --list-file list.txt \
--facts-file facts.json --offline \
--config org-policy.jsonlichen apply upgrades dependencies to the engine's recommended fix (the same
security > license > highest recommendation the tree shows). It is a dry-run
by default: it prints the plan and the exact commands, and changes nothing
until you add --yes.
node dist/cli.js apply # dry-run: every dep with a newer recommendation
node dist/cli.js apply --criticals # only deps with a critical advisory (needs a policy)
node dist/cli.js apply --cve CVE-2024-1234 # only deps carrying that advisory (GHSA or CVE id)
node dist/cli.js apply --recommended --yes # execute: `go get <module>@<fix>` then `go mod tidy`Example dry-run:
Planned upgrades (2):
github.com/acme/shared v1.5.0 -> v1.7.0 [transitive (adds an indirect require)] -- recommended (relicenses to BUSL-1.1)
github.com/acme/leaf v0.4.0 -> v0.5.0 [transitive (adds an indirect require)] -- recommended upgrade
Commands:
go get github.com/acme/shared@v1.7.0
go get github.com/acme/leaf@v0.5.0
go mod tidy
Dry run (default). Re-run with --yes to execute these commands.
Each target is marked direct or transitive; a transitive upgrade is
added as an indirect require (Go's MVS), shown explicitly. When requirement
data is reachable (online, or via --requirements-file), the dry-run also prints
the MVS forced set each upgrade drags in (the same analysis as lichen impact), so you see the true blast radius before running the commands.
--criticals needs
a policy with maxCvssSeverity (from lichen.config.json or --config) so the
critical advisories can be identified. Deps scoped not-imported are never
targeted. Self-execution runs only behind --yes (the static HTML report's
buttons merely copy the commands; true one-click apply is the deferred
lichen serve localhost mode).
tools/viz.ts turns --json into a graph. It is a pure consumer of the JSON
contract (it never imports the engine) and needs nothing installed beyond
npm install -- no system Graphviz, no CDN. Output is self-contained and works
offline.
node dist/cli.js . --json | node tools/dist/viz.js > report.html # default: interactive HTML
node dist/cli.js . --json | node tools/dist/viz.js --format svg > graph.svg
node dist/cli.js . --json | node tools/dist/viz.js --format dot > graph.dot
npm run viz:demo > demo.html # render the bundled fixtureThe default HTML report is an interactive collapsible tree (pure inline
HTML/CSS/JS): lazy-expanded so you can navigate the full depth of large graphs,
follows the browser/OS light/dark theme, with name + severity filters
(Critical / Advisories / Outdated / License-change). Each node shows version,
parent-constraint, license, advisories, language tag, and a ✓ recommend
badge; the "N newer" pill expands a stacked per-version list (release date,
license, and the CVEs each version carries). CVEs link to OSV; package names
link to deps.dev. Colors are always paired with text labels (ADA). --format svg/dot render a static Graphviz image via @viz-js/viz (Graphviz compiled to
WebAssembly).
cd vscode-extension && npm install && npm run build # build the engine firstPress F5 to launch an Extension Development Host; the "Lichen" view appears in the
Explorer. The tree mirrors the report: each node's description shows version,
license, advisory count, and the recommended upgrade; the hover tooltip adds the
release date, the recommendation with its trade-off, CVE links to OSV, a deps.dev
link, and (with lichen.fetchUpgrades on) the per-version upgrade path. Settings:
lichen.offline, lichen.fetchUpgrades.
A standalone TypeScript engine is the single source of truth, reused by every front-end (CLI, VS Code extension, the visualizer):
ecosystem adapter ──RawGraph──▶ engine ──DepTree──▶ front-ends (CLI / extension / viz)
(go.ts) │
└── enrich ──▶ deps.dev (license/versions/advisories)
└─▶ go.dev (language version)
Adding an ecosystem means writing one adapter under src/ecosystems/ that
returns a common RawGraph; the engine and front-ends don't change. deps.dev
already covers GO, MAVEN, and NUGET systems.
Active roadmap and open design decisions live in TODO.md. Highlights:
- Dependency scope classification (production / test-only / not-imported) so a CVE in a dep that never ships (or only a dependency's own tests use) doesn't raise a false alarm.
- CI gate hardening:
--quiet, governed exceptions (reason + expiry), minimum-release-age (soak) rule, and actionable block messages (direct vs transitive, the introducing direct dep, and thego getfix command); distinguishing fixable vs no-fix criticals. - GitHub-renderable report + health score (Markdown + Mermaid; per-category grade where overall = worst category).
lichen --htmlauto-open, a VS Code "Open Report" Webview, andlichen apply(dry-run by default) to perform the recommended upgrades.- A published
lichenbinary, and Maven / NuGet adapters.
npm install && npm run build && npm test # node:test, hermetic (no toolchain/network)The engine has injectable seams (CommandRunner, runtimeResolver,
FactsProvider) so tests run with zero go toolchain and zero network. Keep
front-ends presentation-only; put new logic in the engine. See
CLAUDE.md for architecture conventions.
MIT (see LICENSE). The engine ships with no third-party
runtime dependencies, and all build/test tooling (TypeScript, @types/*,
@viz-js/viz) is permissive, so nothing constrains the choice. The permissive
core is intended to stay permissive; any future commercial/enterprise features
(a central control plane) would be separate add-ons, not a relicense of the core.