A local, free dependency intelligence tool for Go, npm, pnpm, Cargo, Python, Ruby, Maven, Gradle, and NuGet. For every package in your build's dependency graph, lichen shows the pinned version, the version each parent actually asked for, the license, known CVEs, 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.
It reads your repo, or the container image you actually ship
(lichen image myapp:1.4). Out of the box, with no configuration, it warns on
available updates and fails on a CVE affecting a pinned version.
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:
- Your package manager's own listing (
go list -m all,npm ls,cargo tree,mvn dependency:tree,bundle list) shows pinned versions but not licenses, CVEs, or who pulled a dep in. - Every ecosystem answers the question differently, so a polyglot repo needs a different tool, a different output format, and a different CI step per language.
- 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.
- Language-specific reachability tools (
govulncheckfor Go, for example) do that job well, but they answer 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." - What you audit is usually the repo; what runs is the image. They drift -- a base image carries its own dependencies, a multi-stage build copies some in and deletes others -- so a clean repo scan is not evidence about the artifact you shipped.
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
artifacts locally (see the ecosystem table below) and the public
deps.dev
API (licenses, versions, advisories; no auth). One tool, one output format,
one CI gate behind a single exit code, across all eight ecosystems (nine
adapters: pnpm workspaces are npm-registry packages read from pnpm-lock.yaml,
wherever in the workspace you point the scan). The
ecosystem is auto-detected from the lockfile present -- and a directory with NO
recognized manifest is a hard error, never a guess: a gate that silently
scanned the wrong project would exit 0 exactly when you most need it not to
(override with --ecosystem). Multiple-copies resolution (npm, pnpm, Cargo,
uv) is modeled natively,
with each parent's declared range carried per edge and upgrade
recommendations capped at the highest version every parent accepts.
| Ecosystem | Detected via | Source | Runtime toolchain |
|---|---|---|---|
| Go | go.mod |
go mod graph + go list |
go |
| npm | package-lock.json |
committed lockfile | none |
| pnpm | pnpm-lock.yaml (found at the workspace ROOT, even when you scan a member like apps/web) |
committed lockfile, per-importer | none |
| Cargo (Rust) | Cargo.lock |
committed lockfile | none |
| Python | uv.lock / poetry.lock |
committed lockfile | none |
| Ruby | Gemfile.lock |
committed lockfile | none |
| NuGet | packages.lock.json |
committed lockfile | none |
| Maven | pom.xml |
mvn dependency:tree -Dverbose |
mvn |
| Gradle | gradle.lockfile |
committed lockfile (Maven coords) | none |
Only Go and Maven shell out to their build tool; every other adapter parses a committed lockfile, so an air-gapped or CI box needs no toolchain installed. Each ecosystem has a dedicated version comparator (semver for Go/npm/Cargo, PEP 440 for Python, Gem::Version for Ruby, Maven's ComparableVersion, NuGet's SemVer-2-plus-revision) so "outdated" and "latest" are never a naive string sort.
lichen gates; it does not do reachability. Keep your ecosystem's
call-graph tool (govulncheck for Go, and equivalents elsewhere) for symbol-level
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 |
| 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, where the ecosystem's metadata supports it (see below). not-imported deps are informational and never gate, so a CVE in a dep nobody imports doesn't redline the build |
| Language version | the build's language/toolchain requirement, flagged when a newer stable release exists (Go only today, from the go / toolchain directives via go.dev) |
Scope resolution by ecosystem. Go is the most precise: go list -deps ./...
vs go list -test -deps ./... splits the graph three ways, and anything in go mod graph but in neither import set is not-imported (a dependency's own test
dependency, compiled into nothing you ship). npm, pnpm, Maven, and poetry
classify production vs test from their dev/test group metadata. The remaining
adapters (Cargo, uv, Ruby, NuGet, Gradle) leave scope unknown, and unknown
scope falls back to the global maxCvssSeverity -- lichen never guesses a
classification that would silently suppress a finding, and never grants
scope-based leniency to a scope it could not determine.
lichen is a Node CLI (Node 18+). Install from npm:
npx @corvalon/lichen # one-shot run, no install
npm i -g @corvalon/lichen # or install the `lichen` command globallyThe VS Code extension installs from the packaged .vsix on the
Releases page. Download
the latest lichen-vscode-*.vsix, then run:
code --install-extension lichen-vscode-<version>.vsix # the file you downloadedOr, in the editor, open the Extensions view, then the ... menu, then
Install from VSIX and pick the downloaded file. This works in any
VS Code-compatible editor (VS Code, VSCodium, Cursor, Windsurf).
Or 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`The examples below use node dist/cli.js; substitute lichen if you
installed or linked it (the package name is scoped, @corvalon/lichen, but
the installed command is plain lichen).
node dist/cli.js # scan the current dir, print the text tree
node dist/cli.js ~/code/myapp # scan another project (ecosystem auto-detected)
node dist/cli.js . --ecosystem NPM # force one, if a repo has several manifests
node dist/cli.js image myapp:1.4 # scan the container image instead of the repoThe ecosystem comes from whichever build file is present (go.mod,
package-lock.json, Cargo.lock, uv.lock / poetry.lock, Gemfile.lock,
packages.lock.json, pom.xml, gradle.lockfile). The output shape, the
--json schema, the SBOM, the policy file, and the exit codes are identical
whichever one it picks.
Expect a non-zero exit if it finds a CVE. With no config file lichen applies
a safe default: available updates warn, a CVE affecting a
pinned version fails (exit 2), and releases younger than 14 days are held back.
Add --no-default-policy for a report-only run.
Upgrading from 0.4.x? That gate is new in 0.5.0 and is a breaking change for a config-less run. See CHANGELOG.md.
node dist/cli.js [path] [flags] (path defaults to .). Flags, grouped by how
often you reach for them:
Everyday
| Flag | Effect |
|---|---|
(positional) path |
directory to scan; the ecosystem is auto-detected from the lockfile present (default .) |
--ecosystem <name> |
force an adapter instead of auto-detecting, for a repo carrying more than one manifest. Accepts go, npm/node, pnpm, cargo/rust, python/pypi/py/uv/poetry, ruby/rubygems/gem/bundler, maven/mvn/gradle, nuget/dotnet. --ecosystem pnpm forces the pnpm reader in a repo carrying both package-lock.json and pnpm-lock.yaml |
--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) |
--no-default-policy |
turn OFF the built-in safe defaults applied when no config is present. A run with no config and no policy can then never fail (exit 0 always), which is how lichen behaved before 0.5.0 |
--version / -V |
print the version and exit |
--help / -h |
print the usage summary and exit; works after a subcommand too (lichen apply --help) |
An unrecognized --flag is a hard error (exit 1), never silently ignored,
so a typo like --offlien fails loudly instead of scanning with defaults and
looking like it worked.
Testing / air-gapped (feed saved API output instead of calling deps.dev, and saved command output instead of running a build tool)
| Flag | Effect | Ecosystem |
|---|---|---|
--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 |
all |
--graph-file <file> |
canned go mod graph output (use with --list-file) |
Go |
--list-file <file> |
canned go list -m all output |
Go |
--deps-file <file> |
canned go list -deps output (production scope set); omit and scope stays unknown |
Go |
--test-deps-file <file> |
canned go list -test -deps output (production + test scope set) |
Go |
The graph-capture flags exist because Go and Maven are the only adapters that
shell out to a build tool. The other six read a lockfile that is already
committed to your repo, so on an offline box the graph needs no capture step at
all: check the repo out and add --facts-file. See
Air-gapped CI.
Exit codes: 0 clean, 1 hard error, 2 policy violation. Note that 2 is
reachable without any config file: see Safe by default.
The exit code reflects the gate regardless of output format, so --json and
--format cyclonedx also exit 2 when the policy is violated. The document is
still written in full. If you are generating an SBOM as an artifact rather than
gating on it, and the step runs under set -e, add --no-default-policy (or
handle the exit code) so a CVE does not abort the export.
Alongside the default scan, lichen has six verbs. apply mutates (behind
--yes); image, impact, why, facts, and report are read-only.
| Command | What it does |
|---|---|
lichen apply [path] |
upgrade dependencies to the recommended fix (see Apply upgrades) |
lichen image <ref|archive|dir> |
scan the dependency manifests inside a container image (see Scan a container image) |
lichen report [path] [-o out.html] |
write the interactive HTML report (see Visualize) |
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 is Go-only; image covers eight of the nine adapters plus OS
packages (Maven-via-pom is the exception: see
Scan a container image); every other command works on
all eight ecosystems. The
forward walk is a restricted re-resolution that relies on MVS's
single-selected-version property (one pinned version per module, build-wide, and
versions only ever move up), which is a Go guarantee. Ecosystems that resolve by
range or allow multiple copies of a package need a different algorithm, so
impact errs clearly on a non-Go tree rather than reporting a blast radius it
cannot compute. why has no such limit: it reads the parent constraints already
on the 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
# Interactive HTML report, no pipe needed (same renderer as the pipe above)
lichen report . -o report.html
# Gate a build with NO config: safe defaults fail (exit 2) on a CVE
node dist/cli.js . --quiet ; echo "exit=$?"
# Gate on your own policy instead of the defaults
node dist/cli.js . --config lichen.config.json ; echo "exit=$?"
# Report only, never fail (the pre-0.5.0 config-less behavior)
node dist/cli.js . --no-default-policy ; echo "exit=$?"
# Scan the image you ship, not the repo it came from
node dist/cli.js image myapp:1.4
node dist/cli.js image myapp:1.4 --format cyclonedx > image-sbom.json
# Fast structural view with no network
node dist/cli.js . --offlineYou do not have to configure anything to be protected. With no
lichen.config.json and no --config, lichen applies a built-in default
policy:
| Finding | Default behavior |
|---|---|
| a CVE affecting a pinned version | fails the gate (exit 2), scored or not |
| a newer version is available | warns (never fails) |
| a pinned version younger than 14 days | warns (fails under --strict) |
This is deliberately a behavior change: a config-less run previously could
not fail no matter what it found, which made "gating disabled" and "gate passed"
indistinguishable at the exit code. If you relied on the old behavior, pass
--no-default-policy (or pin a config that says what you actually want).
A lichen.config.json fills against the defaults (since 0.8.0): a key you
do not write inherits its safe default, so a config that sets one thing -- say a
longer soak window -- cannot silently turn every other gate off. (0.5.0's
replace-the-defaults contract did exactly that in its first day of field use: a
soak-only config disabled advisory gating repo-wide with no observable.) To
deliberately disable a defaulted gate, set the key to null -- the opt-out
is then visible in the file and lichen prints a notice on every run. A written
value always wins, even when weaker than the default: the defaults are a
starting point for repos that have not made a decision, not a floor under repos
that have. --no-default-policy uses the config exactly as written.
Drop a lichen.config.json at the scanned repo root (or point at one with
--config). lichen evaluates it and exits 2 on any violation, which fails
a CI step.
{
"licenseDenylist": ["GPL-3.0", "AGPL-3.0"],
"maxOutdated": 5,
"maxCvssSeverity": 7.0,
"minReleaseAgeDays": 14,
"ignoreAdvisories": ["GHSA-xxxx-yyyy-zzzz", "CVE-2024-1234"],
"scopeThresholds": { "test": 7.0 },
"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 |
warnOnOutdated |
emit a warning per dependency with a newer version available. Never fails the gate (that is maxOutdated's job). On in the default policy |
failUnscoredAdvisories |
treat an advisory with no CVSS score as a violation. A missing score is metadata about the vulnerability, not evidence there isn't one. On in the default policy |
ignoreAdvisories |
permanent, ungoverned mute, matched on advisory id or CVE alias |
scopeThresholds |
per-scope CVSS thresholds overriding maxCvssSeverity for that scope only, e.g. {"test": 7.0}. An absent scope inherits maxCvssSeverity, so omitting the key changes nothing. Advisory gate only -- it never relaxes the soak window (see below) |
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 per-scope thresholds? lichen already classifies every dependency
production / test / not-imported, and a CVE in a lint or build tool used to
redline a build exactly like one in shipped runtime code. That does not make
anyone safer; it manufactures waiver debt, and waivers rot into blindness.
{"test": 7.0} says "I accept dev-tooling advisories below 7.0" once, instead of
muting individual advisory ids forever.
The key is deliberately narrow. It modulates the advisory gate and nothing
else -- in particular not the soak window. Scope tells you something real
about whether a known defect is reachable. It tells you nothing about an
unknown compromise, because a malicious publish executes at install time
(postinstall and its equivalents) whether or not the package is dev-only.
Relaxing the soak for dev dependencies would disable the control exactly where
it does most of its work. For the same reason a relaxed threshold does not
suppress unscored advisories: a missing CVSS score is not evidence of low
severity.
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 are informational and
never gate.
An advisory violation is actionable: it marks whether the vulnerable package is a direct or transitive dependency and gives the upgrade command in that ecosystem's own syntax. For a transitive package it also names the direct dependency that introduced it, so you can bump that instead of pinning the package.
[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] debug@2.6.9: ... CVSS 8.2 exceeds maxCvssSeverity=7 -- transitive (via express); npm install debug@4.4.3
[advisory] serde@1.0.100: ... CVSS 7.5 exceeds maxCvssSeverity=7 -- direct; cargo update -p serde --precise 1.0.219
The fix hint per ecosystem:
| Ecosystem | Upgrade command emitted |
|---|---|
| Go | go get <module>@<fix> |
| npm | npm install <pkg>@<fix> |
| pnpm | npm install <pkg>@<fix> shown today (known limitation) -- run pnpm update <pkg>@<fix> in a pnpm workspace |
| Cargo | cargo update -p <pkg> --precise <fix> |
| Python | pip install <pkg>==<fix> |
| Ruby | bundle update <gem> |
| Maven / Gradle | mvn versions:use-dep-version -Dincludes=<coord> -DdepVersion=<fix> |
| NuGet | dotnet add package <pkg> --version <fix> |
In Go, a transitive fix command targets the vulnerable module itself and 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).
The zero-config gate is a real gate. You do not need a lichen.config.json
to fail a build on a CVE; the safe defaults do that already.
Add a config when you want to say something the defaults do not: deny licenses,
cap staleness, waive a specific advisory, or lengthen the soak window.
lichen . --quiet # gate on the safe defaults
lichen . --quiet --config lichen.config.json # gate on your own policyMakefile
LICHEN ?= node dist/cli.js
.PHONY: deps-gate
deps-gate: ## fail the build on a dependency-policy violation
$(LICHEN) . --quietGitHub Actions -- for the six lockfile ecosystems (npm, Cargo, Python, Ruby, NuGet, Gradle) the whole job is Node plus your committed lockfile. No language toolchain is needed, because lichen reads the lockfile rather than invoking the package manager:
name: deps-gate
on: [push, pull_request]
jobs:
lichen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm i -g @corvalon/lichen
# exit 2 (a policy violation) fails the job; --strict for the strictest gate.
# No --config needed: the safe defaults already fail on a CVE. Add
# `--config lichen.config.json` once you have a policy of your own.
- run: lichen . --quiet --strict
# Gate the IMAGE you ship, not just the repo it was built from.
# Needs no language toolchain -- lichen reads the lockfiles inside it.
- run: lichen image myapp:${{ github.sha }} --quietGo and Maven are the two adapters that shell out, so those jobs add the toolchain step:
- uses: actions/setup-go@v5 # Go only: lichen runs `go mod graph`
with: { go-version: stable }
# or, for Maven: lichen runs `mvn dependency:tree -Dverbose`
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21' }Layer 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 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 package manager installed, only a
facts snapshot (and, for Go and Maven, a captured graph). The org policy is
enforced with tighten-only layering, so a local overlay can never weaken the
base (see central-config layering above).
For the six lockfile ecosystems (npm, Cargo, Python, Ruby, NuGet, Gradle) there is only one thing to carry across the gap, because the dependency graph is already committed to your repo:
-
On a connected box, snapshot the deps.dev facts:
lichen facts . > facts.json # versioned deps.dev snapshot
-
On the air-gapped box, gate with zero network and zero toolchain:
lichen . --quiet --strict --offline \ --facts-file facts.json \ --config org-policy.json ; echo "exit=$?"
For Go and Maven, whose graphs come from a build tool rather than a lockfile, capture the graph on the connected box too:
-
On a connected build box:
go mod graph > graph.txt # the module requirement graph go list -m all > list.txt # the resolved/pinned versions lichen 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:
lichen . --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 (Go, the case that needs a captured graph: the
connected box publishes the snapshot as an artifact; the gate job consumes it
with no setup-go and no network). For a lockfile ecosystem, drop setup-go
and the two capture lines, and carry only facts.json:
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 # Go only; drop for a lockfile ecosystem
with: { go-version: stable }
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm i -g @corvalon/lichen
- run: |
go mod graph > graph.txt # Go only; drop for a lockfile ecosystem
go list -m all > list.txt # Go only; drop for a lockfile ecosystem
lichen facts . > facts.json
- uses: actions/upload-artifact@v4
with:
name: deps-snapshot
path: |
graph.txt
list.txt
facts.json
gate: # runs offline: no toolchain, 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 i -g @corvalon/lichen
- uses: actions/download-artifact@v4
with: { name: deps-snapshot }
# exit 2 fails the job. No network is touched here.
- run: |
lichen . --quiet --strict \
--graph-file graph.txt --list-file list.txt \
--facts-file facts.json --offline \
--config org-policy.jsonWhat you build is not always what you ship. lichen image reads the
dependencies inside a container image -- language lockfiles, Go binaries'
embedded module lists, installed node_modules trees, and the distro's own
package database -- so the answer comes from the artifact that actually runs
rather than from the repo it was supposed to be built from.
lichen image myapp:1.4 # a local image (uses `docker save`)
lichen image image.tar # a `docker save` archive -- no daemon needed
lichen image ./oci-layout/ # an OCI layout directory -- no daemon neededIt flattens the image's layers (honoring whiteouts, so a file deleted in a later stage is not reported), finds every project inside, and runs the same adapters, enrichment, recommendation, and policy gate a repo scan uses. One image usually holds several projects, so output is grouped by the path inside the image:
lichen image myapp:1.4 -- 7 layer(s), 2 project(s) [linux/amd64]
/srv/api/package-lock.json (NPM)
...
Policy violations (1):
[advisory] /srv/api/package-lock.json: express@4.18.2: CVE-2024-29041 ...
Only the two flags that need a working tree are absent (--ecosystem is
inferred per directory, and there is no repo lichen.config.json to read, so
policy comes from --config). Everything else carries over: --offline,
--upgrades, --facts-file, --quiet, --strict, --gate-json, and
--format cyclonedx.
Two of the three sources need no Docker at all. An archive or OCI layout is
just a file, so an image exported on a connected machine can be scanned on an
air-gapped one -- pair it with --facts-file for a fully offline gate.
--format cyclonedx emits one CycloneDX 1.6 BOM for the whole image:
metadata.component is the image (type container), each project inside it is
an application component tagged with its path, and the libraries are the
de-duped set across all of them, so a package vendored into two projects counts
once.
| Lockfiles | npm, Cargo, Python (uv + poetry), RubyGems, Gradle, NuGet -- every ecosystem whose resolved graph lives in a committed lockfile |
| Go binaries | every Go >= 1.18 binary embeds its complete resolved module list; lichen reads it straight out of the executable, no toolchain needed. That set is exactly what was linked into the shipped artifact, so it has no not-imported noise -- and the binary's own Go version is checked for staleness too |
Installed node_modules |
a production Node image normally ships without a lockfile (standalone builds and multi-stage copies strip it); lichen falls back to the installed tree's own manifests. This is a flat inventory, honestly labeled (installed set, flat) -- the dependency graph is genuinely not in the image |
| OS packages | Alpine (apk) and Debian/Ubuntu (dpkg, including distroless status.d) installed-package databases. deps.dev serves no OS system, so these enrich via OSV.dev per distro release, with the license straight from the package database where it records one (apk does). No version feed exists for them, so staleness and soak windows never apply -- a distro security patch is never held back |
| Not covered yet | rpm databases (a binary BerkeleyDB/sqlite format; detected and reported loudly rather than skipped silently) and Maven-via-pom (mvn dependency:tree needs the toolchain; a bare pom.xml is a declaration, not a resolution) |
docker save only exports images that are already local; docker pull the tag
first if it is remote. lichen never pulls on your behalf.
lichen 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 the planThe plan is always printed in the scanned ecosystem's own upgrade syntax (the
table under Policy / CI gating). --yes executes for
Go (go get then go mod tidy) and npm (npm install); for the other six
ecosystems the dry-run plan is the deliverable and you run the printed commands
yourself.
Example dry-run (a Go module; an npm or Cargo project prints the same shape with that ecosystem's commands):
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. In Go a transitive upgrade
is added as an indirect require (MVS), shown explicitly; when requirement data
is reachable (online, or via --requirements-file), the Go 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).
The interactive HTML renderer ships in the package (src/report.ts), so the
report is one command; tools/viz.ts remains a pure consumer of the --json
contract for the static Graphviz formats and re-imports the same renderer for
HTML, so every path emits the identical document. Nothing installed beyond
npm install -- no system Graphviz, no CDN. Output is self-contained and works
offline.
lichen report . -o report.html # interactive HTML, direct
node dist/cli.js . --json | node tools/dist/viz.js > report.html # same document, via the pipe
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).
Install it by downloading the latest lichen-vscode-*.vsix from the
Releases page and running
code --install-extension lichen-vscode-<version>.vsix (or Extensions view,
... menu, Install from VSIX). To develop it instead:
cd vscode-extension && npm install && npm run build # build the engine first…then press F5 to launch an Extension Development Host. The "Lichen" view gets
its own Activity Bar icon and covers the same ecosystems as the CLI,
auto-detected from the workspace's build files. 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. The scan starts
in the background as the project loads, re-runs when any ecosystem's
manifest/lockfile changes, and warns on configurable finding dimensions
(lichen.warnOn: critical / advisory / outdated / license-change /
recommended, plus a badge on the icon). From the view you can open the full
HTML report in a browser, or apply recommended upgrades: Go and npm execute
(go get + go mod tidy / npm install, with confirmation and a relicense
consent gate); other ecosystems surface the exact commands with one-click
copy. Settings: lichen.offline, lichen.fetchUpgrades, lichen.prefetch,
lichen.warnOn, lichen.critical, lichen.minReleaseAgeDays.
A standalone TypeScript engine is the single source of truth, reused by every front-end (CLI, VS Code extension, the visualizer):
a repo on disk ─┐
├─▶ ecosystem adapter ──RawGraph──▶ engine ──DepTree──▶ front-ends
a container ────┘ (src/ecosystems/*) │ (CLI / extension / viz)
image (src/image/*) 9 adapters, one shape │
└── enrich ──▶ deps.dev
└─▶ go.dev (Go only)
Adding an ecosystem means writing one adapter under src/ecosystems/ that
returns a common RawGraph, plus (for a non-semver ecosystem) a comparator in
src/version-schemes.ts; the engine and front-ends don't change. Nine ship
today: Go, npm (package-lock.json), pnpm (pnpm-lock.yaml,
workspace-aware), Cargo (Cargo.lock), Python (uv.lock /
poetry.lock), Ruby (Gemfile.lock), NuGet (packages.lock.json),
Maven (mvn dependency:tree -Dverbose, whose verbose output exposes the
conflict-mediated parent constraints), and Gradle (gradle.lockfile) --
eight ecosystems, since pnpm resolves npm-registry packages. The
Ecosystem union values map directly to deps.dev system names, so enrichment is
zero-config per ecosystem.
A container image is a second SOURCE for those same adapters, not a ninth
adapter. Every lockfile adapter takes (dir, FileReader), so src/image/
flattens an image's layers into a virtual filesystem and calls the existing
adapters against paths inside it, which is why a new ecosystem becomes
image-scannable for free. See
Scan a container image.
Presentation rules that more than one front-end needs live in the engine too
(src/tree-stats.ts: criticality, per-tree counts, row tone). The VS Code
extension imports vscode, so anything defined inside it cannot be unit-tested;
keeping the shared rules in the engine is what stops the editor and the CLI from
quietly disagreeing about the same dependency.
Active work and open design decisions are tracked in Jira project
LICHEN (see TODO.md
for the pointer). Shipped already: scope classification,
the hardened CI gate (--quiet, governed exceptions with expiry, the soak
rule, actionable block messages), lichen apply (dry-run by default),
lichen report (the same renderer drives the VS Code extension's "Open HTML
Report"), lichen image (container scanning: lockfiles, Go binaries'
embedded module lists, installed node_modules, and Alpine/Debian/Ubuntu OS
packages via OSV.dev), the
safe-by-default policy (a config-less run gates instead of reporting),
per-scope advisory thresholds (scopeThresholds, advisory gate only -- it
never relaxes the soak window), and
nine ecosystem adapters, each with a real verification corpus
(Gradle's is authored to the lockfile format; the rest are generated by their
own toolchains -- pnpm's by pnpm itself, with workspace: links and
peer-suffixed resolutions). Open highlights:
- GitHub-renderable report + health score (Markdown + Mermaid; per-category grade where overall = worst category).
- Executing applies for the non-Go/npm ecosystems in the extension (their upgrade commands are currently surfaced with one-click copy rather than run).
- rpm package databases in image scanning (RHEL/UBI, Fedora, Amazon Linux). The apk/dpkg text databases are covered; the rpm database is a binary BerkeleyDB/sqlite format that needs its own reader, so an rpm image is detected and reported as unsupported rather than parsed.
- A published
lichenbinary; further ecosystems are additive.
npm install && npm run build && npm test # node:test, hermetic (no toolchain/network)The engine has injectable seams (CommandRunner, FileReader,
runtimeResolver, FactsProvider) so the whole suite, all nine adapters
included, runs with zero toolchains 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.