Skip to content

Corvalon/lichen

Repository files navigation

lichen

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.

Why lichen

Knowing what you actually depend on, and when it is safe to move, is harder than it should be:

  • go list -m all shows 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.
  • govulncheck does 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.

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

Install

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.

Quick start

node dist/cli.js                  # scan the current dir, print the text tree
node dist/cli.js ~/code/myapp     # scan another Go module

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

Subcommands

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

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

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

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

Policy / CI gating

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

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

Makefile

LICHEN ?= node dist/cli.js

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

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

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 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:

  1. 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 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:

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

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: `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).

Visualize

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

cd vscode-extension && npm install && npm run build   # build the engine first

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

How it works

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.

Roadmap

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 the go get fix command); distinguishing fixable vs no-fix criticals.
  • GitHub-renderable report + health score (Markdown + Mermaid; per-category grade where overall = worst category).
  • lichen --html auto-open, a VS Code "Open Report" Webview, and lichen apply (dry-run by default) to perform the recommended upgrades.
  • A published lichen binary, and Maven / NuGet adapters.

Contributing

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.

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 dependency-tree + policy tool: pinned version, parent constraint, license (deps.dev), available versions, CVEs, upgrade impact, and an air-gapped CI gate. Free stack, offline-capable.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors