Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Enterprise-grade CLI for static application security scanning with Armis Cloud.
- [Verification](#verification)
- [Quick Start](#quick-start)
- [Usage](#usage)
- [Supply Chain Protection](#supply-chain-protection)
- [Output Formats](#output-formats)
- [CI/CD Integration](#cicd-integration)
- [Environment Variables](#environment-variables)
Expand All @@ -47,6 +48,7 @@ Enterprise-grade CLI for static application security scanning with Armis Cloud.
- Multiple output formats: human, JSON, SARIF, JUnit XML
- **SBOM generation**: Generate CycloneDX Software Bill of Materials
- **VEX generation**: Generate Vulnerability Exploitability eXchange documents
- **Supply chain protection**: Block packages published too recently (typosquatting, compromised maintainers, dependency confusion) across npm, Python, and Java — no Armis Cloud auth required
- CI/CD ready: GitHub Actions, Jenkins, GitLab, Azure, Bitbucket, CircleCI
- Configurable exit codes and fail-on severity
- Secure authentication, size limits, and best practices
Expand Down Expand Up @@ -456,6 +458,69 @@ armis-cli scan image nginx:latest --pull=never

---

## Supply Chain Protection

The `supply-chain` command enforces a minimum **release age** on your dependencies. Packages published more recently than the threshold (default 72h) are flagged or blocked — a cheap, effective defense against typosquatting, compromised maintainer accounts, and dependency-confusion attacks, which almost always rely on a freshly published malicious version.

No Armis Cloud authentication is required: `supply-chain` queries public registries (npm, PyPI, Maven Central) directly.

**Supported ecosystems:** npm, pnpm, bun, yarn (Node); pip, uv, poetry, pipenv, pdm (Python); Maven, Gradle (Java).

### Audit a lockfile (CI)

```bash
# Audit the lockfile in the current directory (auto-detected)
armis-cli supply-chain check

# Custom threshold, exclude your own scoped packages, fail the build on findings
armis-cli supply-chain check --min-age 7d --exclude "@myorg/*" --fail-on medium

# Machine-readable output for CI
armis-cli supply-chain check --format sarif --fail-on high
```

By default `check` only reports packages that are **new** versus the base branch lockfile (auto-detected from `origin/main`). Use `--all` to audit every package, and `--fail-open` to pass when the registry is unreachable.

> **Fail the build:** `check` reports findings as MEDIUM/HIGH severity. To gate CI, pass `--fail-on medium` (or `high`) — the default `--fail-on` is CRITICAL, which supply-chain findings never reach.

### Enforce locally during installs

```bash
# Wrap your package managers in ~/.bashrc / ~/.zshrc (interactive)
armis-cli supply-chain init

# Preview changes without writing
armis-cli supply-chain init --dry-run

# Generate a committable policy file (.armis-supply-chain.yaml)
armis-cli supply-chain init --mode config

# Show the active policy, detected ecosystems, and shell status
armis-cli supply-chain status

# Remove the shell wrappers
armis-cli supply-chain uninit
```

### Configuration

Commit a `.armis-supply-chain.yaml` to share policy with your team:

```yaml
version: 1
min-age: 72h
exclusions:
- "@myorg/*"
# ecosystems: # optional: restrict to specific ecosystems (default: all detected)
# - npm
# - pip
fail-open: false
```

Bypass for a single command with `ARMIS_SUPPLY_CHAIN_SKIP=<pkg>`; disable enforcement entirely with `ARMIS_SUPPLY_CHAIN=off`.

---

## Output Formats

### Human-Readable (Default)
Expand Down
17 changes: 17 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `supply-chain` command for enforcing package release-age policies, defending against supply-chain attacks (typosquatting, compromised maintainers, dependency confusion) by flagging or blocking packages published more recently than a configurable threshold (default 72h). No Armis Cloud authentication required — queries public registries directly. (#206, #210, #211)
- Supports 11 package managers across three ecosystems: npm, pnpm, bun, yarn (Node); pip, uv, poetry, pipenv, pdm (Python); Maven, Gradle (Java).
- Node package managers and pip/uv use a transparent registry proxy that filters out too-young versions during install; poetry, pipenv, pdm, Maven, and Gradle use a pre-install lockfile audit that blocks the build before execution.
- `supply-chain check` audits lockfiles in CI; `supply-chain init`/`uninit` set up local shell enforcement; `supply-chain status` reports the active policy and detected ecosystems.
- Configurable via `.armis-supply-chain.yaml` (`min-age`, `exclusions`, `ecosystems`, `fail-open`); per-invocation bypass via `ARMIS_SUPPLY_CHAIN_SKIP`; master kill switch via `ARMIS_SUPPLY_CHAIN=off`.
- Gradle lockfile staleness detection (warns when `build.gradle` is newer than `gradle.lockfile`), Maven `pom.xml` partial-coverage notice (direct dependencies only), and a warning for unrecognized ecosystem names in the config.
- The `ecosystems` config field accepts both `pipenv` (the tool name shown in `--help`) and `pipfile` (the internal name) so either spelling works.
- The install summary reports each filtered package on one line showing the too-new version, its age, and the older version installed in its place (e.g. `axios 1.17.0 (1 day old) → 1.16.1 installed`). When every package resolves to a safe version it reads as a success; packages with no older safe version are called out individually. If the package manager itself does not complete (for example a dependency pins a version that only the filtered release satisfies), the summary reports the safe version as "available" rather than claiming it was installed, and explains how to relax or exclude the constraint. A one-time explanation of why fresh releases are withheld is shown on the first filtered install in an interactive terminal (suppressed thereafter and in CI).

### Changed

### Deprecated
Expand All @@ -17,8 +26,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `supply-chain check`: `--fail-on` now accepts lowercase severities (e.g. `--fail-on medium`) and validates the value, matching `scan repo`/`scan image`. Previously a lowercase or invalid value was silently ignored, so the CI gate never fired and a real violation exited 0.
- `supply-chain`: an unknown subcommand (e.g. a typo like `chekc`) now exits non-zero with a "Did you mean" suggestion instead of printing help and exiting 0.
- `supply-chain check`: `--min-age` parse errors no longer print the duration twice; the message now suggests valid formats (`72h`, `3d`, `1w`). Output reads "1 package" (not "1 packages"), and the empty "Scan ID:" line is omitted for the local audit.
- `supply-chain check`: base-lockfile auto-detection now bounds its `git` subprocesses with a timeout (and honors cancellation), so a wedged or misconfigured `git` invocation can no longer hang the command indefinitely.
- `supply-chain`: the config `ecosystems` field now actually scopes enforcement. Previously it was parsed and typo-checked but ignored, so `ecosystems: [npm]` still enforced every ecosystem. `check` now skips an out-of-scope lockfile, `wrap` passes an out-of-scope package manager straight through, and `init` only wraps in-scope package managers. The gate fails safe: an empty list (or a list of only unrecognized names) enforces everything, so a typo cannot silently disable the control.

### Security

- `supply-chain wrap` (pip/uv): age enforcement now actually filters. The local-enforcement proxy previously only understood the npm registry format, so pip and uv installs were pointed at the proxy but their PyPI Simple API requests passed through unfiltered — young packages installed silently. The proxy now speaks the PyPI Simple API (PEP 691/700 JSON), removing distribution files published more recently than the policy threshold; a file with no upload timestamp is removed (fail-closed) rather than allowed.

---

## [1.10.2] - 2026-05-28
Expand Down
49 changes: 49 additions & 0 deletions docs/ci-examples/github-actions-supply-chain.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
# GitHub Actions - Supply Chain Release-Age Check
#
# Audits your lockfile for packages published too recently — a cheap, effective
# defense against typosquatting, compromised maintainer accounts, and
# dependency-confusion attacks, which almost always ship a freshly published
# malicious version.
#
# No Armis Cloud authentication is required: supply-chain queries public
# registries (npm, PyPI, Maven Central) directly, so this workflow needs no
# secrets.
#
# Notes:
# - By default `check` reports only packages that are NEW versus the base
# branch lockfile. On PRs this focuses the result on what the PR introduces.
# - Findings are MEDIUM/HIGH severity, so --fail-on medium is required to gate
# the build (the default --fail-on is CRITICAL, which these never reach).
# - Add --fail-open if you would rather pass than block when the registry is
# temporarily unreachable.

name: Supply Chain Check

on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
supply-chain:
name: Package Release-Age Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Fetch the base branch so `check` can diff against origin/main and
# report only newly introduced packages.
fetch-depth: 0

- name: Install armis-cli
run: curl -sSL https://raw.githubusercontent.com/ArmisSecurity/armis-cli/main/scripts/install.sh | bash

- name: Audit dependencies for recently-published packages
run: |
armis-cli supply-chain check \
--min-age 72h \
--fail-on medium
34 changes: 34 additions & 0 deletions internal/cmd/supply_chain.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package cmd

import (
"fmt"
"os"
"strings"

"github.com/ArmisSecurity/armis-cli/internal/supplychain"
"github.com/spf13/cobra"
)
Expand All @@ -19,6 +23,15 @@ func loadConfigUpward(dir string) (*supplychain.Config, string, error) {
if err != nil {
return nil, configDir, err
}
if cfg != nil {
// Surface typos in the config's "ecosystems" list once, here, so every
// command that loads the config (check/status/wrap) reports them
// consistently rather than silently ignoring an unrecognized name.
if unknown := cfg.UnknownEcosystems(); len(unknown) > 0 {
fmt.Fprintf(os.Stderr, "Warning: unknown ecosystem(s) %s in %s — supported: %s\n",
strings.Join(unknown, ", "), supplychain.ConfigFileName, supplychain.KnownEcosystemsHint())
}
}
return cfg, configDir, nil
}

Expand Down Expand Up @@ -50,6 +63,27 @@ No Armis Cloud authentication is required — supply-chain queries public regist

# Check what supply-chain init would do
armis-cli supply-chain init --dry-run`,
// A parent command with no RunE prints help and exits 0 on an unknown
// subcommand, so `supply-chain chekc` silently "succeeds" in CI. Reject
// unknown args with a non-zero exit and a "did you mean" suggestion; with no
// args, fall back to the usual help text.
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
// SuggestionsMinimumDistance is 0 here because cobra only sets its default
// (2) deeper in its own execute path, which we bypass by calling
// SuggestionsFor directly. Set it so close typos like "chekc"→"check" are
// offered.
if cmd.SuggestionsMinimumDistance <= 0 {
cmd.SuggestionsMinimumDistance = 2
}
err := fmt.Errorf("unknown subcommand %q for %q", args[0], cmd.CommandPath())
if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 {
err = fmt.Errorf("%w\n\nDid you mean this?\n\t%s", err, strings.Join(suggestions, "\n\t"))
}
return err
},
}

func init() {
Expand Down
67 changes: 59 additions & 8 deletions internal/cmd/supply_chain_check.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package cmd

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/ArmisSecurity/armis-cli/internal/cli"
"github.com/ArmisSecurity/armis-cli/internal/model"
Expand All @@ -15,6 +17,13 @@ import (
"github.com/spf13/cobra"
)

// baseDetectGitTimeout bounds the git subprocesses used to fetch the base
// lockfile. git base detection only reads local objects, but a misconfigured
// remote or filesystem can wedge a git invocation indefinitely; this ceiling
// keeps `supply-chain check` from hanging on it. The parent command context is
// still honored, so SIGINT cancels sooner than this.
const baseDetectGitTimeout = 15 * time.Second

var (
scMinAge string
scExclude []string
Expand Down Expand Up @@ -93,13 +102,32 @@ func runSupplyChainCheck(cmd *cobra.Command, args []string) error {
return fmt.Errorf("lockfile not found: %s", lockfilePath)
}

// Respect the config's "ecosystems" scope: if it restricts enforcement and
// this lockfile's ecosystem is excluded, skip the audit and report a clean
// pass rather than checking an out-of-scope ecosystem. loadConfigUpward
// returns nil (enforce-all) when no config is present, and EnforcesEcosystem
// fails safe on an all-typo list.
cfg, _, err := loadConfigUpward(dir)
if err != nil {
return err
}
eco := check.DetectEcosystemFromPath(lockfilePath)
// armis:ignore cwe:476 reason:EnforcesEcosystem has an explicit nil-receiver guard (returns true when c==nil), so calling it on a nil cfg is safe by design
if !cfg.EnforcesEcosystem(eco) {
s := output.GetStyles()
fmt.Fprintf(os.Stderr, "%s %s\n",
s.MutedText.Render("[armis]"),
s.MutedText.Render(fmt.Sprintf("supply-chain: %s not in configured ecosystems, skipping", eco)))
return nil
}

var baseLockfile string
var autoDetectedBase bool
if !scAll {
if scBaseLockfile != "" {
baseLockfile = scBaseLockfile
} else {
baseLockfile = detectBaseLockfile(lockfilePath)
baseLockfile = detectBaseLockfile(cmd.Context(), lockfilePath)
autoDetectedBase = baseLockfile != ""
}
}
Expand Down Expand Up @@ -129,8 +157,9 @@ func runSupplyChainCheck(cmd *cobra.Command, args []string) error {
s := output.GetStyles()
fmt.Fprintf(os.Stderr, "%s %s\n",
s.MutedText.Render("[armis]"),
s.MutedText.Render(fmt.Sprintf("supply-chain: checked %d packages, %d skipped, %d violations (%s policy)",
result.Checked, result.Skipped, len(result.Violations), policy.MinReleaseAge)))
s.MutedText.Render(fmt.Sprintf("supply-chain: checked %s, %d skipped, %s (%s policy)",
countNoun(result.Checked, "package"), result.Skipped,
countNoun(len(result.Violations), "violation"), policy.MinReleaseAge)))

findings := make([]model.Finding, 0, len(result.Violations))
for _, v := range result.Violations {
Expand Down Expand Up @@ -161,7 +190,24 @@ func runSupplyChainCheck(cmd *cobra.Command, args []string) error {
return fmt.Errorf("formatting output: %w", err)
}

return output.CheckExit(scanResult, failOn, exitCode)
// Use getFailOn() (not the raw failOn global) so --fail-on is validated and
// case-normalized to uppercase. ShouldFail matches severities exactly, so a
// lowercase "medium" would otherwise never match a "MEDIUM" finding and the
// CI gate would silently pass. The scan commands already route through here.
failOnSeverities, err := getFailOn()
if err != nil {
return err
}
return output.CheckExit(scanResult, failOnSeverities, exitCode)
}

// countNoun formats a count with its noun, pluralizing with a trailing "s" when
// the count is not exactly 1 (e.g. "1 package", "2 packages", "0 violations").
func countNoun(n int, noun string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, noun)
}
return fmt.Sprintf("%d %ss", n, noun)
}

func buildSummary(findings []model.Finding) model.Summary {
Expand All @@ -181,11 +227,16 @@ func buildSummary(findings []model.Finding) model.Summary {
return summary
}

func detectBaseLockfile(lockfilePath string) string {
func detectBaseLockfile(ctx context.Context, lockfilePath string) string {
if _, err := exec.LookPath("git"); err != nil {
return ""
}

// Bound every git subprocess so a wedged invocation cannot hang the check.
// Derived from the command context, so SIGINT still cancels earlier.
ctx, cancel := context.WithTimeout(ctx, baseDetectGitTimeout)
defer cancel()

// Anchor every git invocation to the directory that contains the lockfile,
// not the process's cwd. Otherwise `armis-cli supply-chain check
// /other/repo` (or a --lockfile outside cwd) resolves base detection
Expand All @@ -197,13 +248,13 @@ func detectBaseLockfile(lockfilePath string) string {
}
gitWorkDir := filepath.Dir(absLockfile)

gitDir := exec.Command("git", "rev-parse", "--git-dir") //nolint:gosec // detecting git repo
gitDir := exec.CommandContext(ctx, "git", "rev-parse", "--git-dir") //nolint:gosec // detecting git repo
gitDir.Dir = gitWorkDir
if err := gitDir.Run(); err != nil {
return ""
}

showTopLevel := exec.Command("git", "rev-parse", "--show-toplevel") //nolint:gosec
showTopLevel := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel") //nolint:gosec
showTopLevel.Dir = gitWorkDir
topLevel, err := showTopLevel.Output()
if err != nil {
Expand Down Expand Up @@ -233,7 +284,7 @@ func detectBaseLockfile(lockfilePath string) string {

for _, base := range []string{"origin/main", "origin/master"} {
// armis:ignore cwe:22 reason:relPath is confined to the repo tree by the traversal guard above and git resolves the pathspec within the repo; base is one of two hardcoded refs
showBase := exec.Command("git", "show", base+":"+relPath) //nolint:gosec // user's git repo
showBase := exec.CommandContext(ctx, "git", "show", base+":"+relPath) //nolint:gosec // user's git repo
showBase.Dir = gitWorkDir
content, err := showBase.Output()
if err != nil {
Expand Down
Loading
Loading