Skip to content

Repository files navigation

lichen

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.

Why lichen

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 (govulncheck for 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.

What it looks like

lichen interactive HTML report showing the bundled example fixture: a dependency tree with pinned versions, the version each parent asked for, licenses, a CVSS 9.1 advisory pill, and the expanded per-version upgrade path with release dates and a recommended upgrade that flags a relicense

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.

What you get, per dependency

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.

Install

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 globally

The 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 downloaded

Or, 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).

Quick start

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 repo

The 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.

CLI reference

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.

Subcommands

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.0

impact 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.

Examples

# 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 . --offline

Policy / CI gating

Safe by default

You 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.

Configuring it

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.

Use in CI

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 policy

Makefile

LICHEN ?= node dist/cli.js

.PHONY: deps-gate
deps-gate:                        ## fail the build on a dependency-policy violation
	$(LICHEN) . --quiet

GitHub 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 }} --quiet

Go 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.

Air-gapped CI

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:

  1. On a connected box, snapshot the deps.dev facts:

    lichen facts . > facts.json          # versioned deps.dev snapshot
  2. 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:

  1. 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 your org-policy.json across the gap.

  2. 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.json

Scan a container image

What 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 needed

It 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.

What it does and does not cover

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.

Apply upgrades

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 plan

The 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).

Visualize

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 fixture

The 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).

VS Code extension

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.

How it works

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.

Roadmap

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 lichen binary; further ecosystems are additive.

Contributing

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.

License

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.

About

Local, free dependency intelligence for Go, npm, pnpm, Cargo, Python, Ruby, Maven, Gradle, and NuGet -- in your repo or inside the container image you ship. Pinned version, parent constraint, license, CVEs, upgrade impact, SBOM, and an air-gapped CI policy gate that is safe by default. No account, no backend, works offline.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages