Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ The `supply-chain` command enforces a minimum **release age** on your dependenci

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).
**Supported ecosystems:** npm, npx, pnpm, bun, yarn (Node); pip, uv, poetry, pipenv, pdm (Python); Maven, Gradle (Java).

### Audit a lockfile (CI)

Expand Down
5 changes: 4 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ 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).
- Supports 12 package managers across three ecosystems: npm, npx, 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.
- `npx` is wrapped alongside `npm` (it ships with npm and resolves from the same registry), so ad-hoc `npx <pkg>` runs are filtered through the same proxy. Enforcement applies to packages npx fetches from the registry; a package already in the npx cache or a binary already in `node_modules/.bin` runs without a registry round-trip and is not re-checked. The sibling runners `pnpm dlx` and `yarn dlx` are already covered as subcommands of the existing pnpm/yarn wrappers; `bunx` (a separate binary) is not yet wrapped.
- `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.
Expand All @@ -20,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- `supply-chain init`: now wraps every supported package manager found on your `PATH` instead of only the ones with a lockfile in the current directory. The injected shell functions are global (they apply in every directory), so detecting from the current project's lockfiles left gaps — e.g. running `init` in a Go repo wrapped only `npm`/`npx`, so a later `pip install` in a Python project ran unenforced. Detection is now machine-wide; per-project enforcement is still decided dynamically at install time from the nearest `.armis-supply-chain.yaml` (the `ecosystems` scope and policy are re-read on each install), so wrapping a package manager never forces enforcement where the project hasn't opted in. When no supported package manager is on `PATH`, `init` still falls back to wrapping `npm`/`npx`.

### Deprecated

### Removed
Expand Down
42 changes: 42 additions & 0 deletions internal/cmd/cmdutil/failon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmdutil

import (
"fmt"
"strings"
)

// ValidSeverities contains the valid severity level strings for the --fail-on flag.
var ValidSeverities = []string{"INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"}

// ValidateFailOn checks that every entry of severities is a recognized severity
// level and normalizes it to uppercase in place. ShouldFail matches severities
// exactly, so normalization here is what lets a lowercase "medium" trip the gate
// on a "MEDIUM" finding.
func ValidateFailOn(severities []string) error {
validSet := make(map[string]bool)
for _, s := range ValidSeverities {
validSet[s] = true
}

for i, sev := range severities {
// Normalize to uppercase for case-insensitive matching
upper := strings.ToUpper(sev)
if !validSet[upper] {
return fmt.Errorf("invalid severity level %q: must be one of %v", sev, ValidSeverities)
}
// Update the slice with normalized value
severities[i] = upper
}
return nil
}

// GetFailOn validates and normalizes the given --fail-on severities, returning
// the normalized slice. It is pure: callers pass the flag value (read from the
// cobra command or a package global) rather than relying on a shared global, so
// both the scan commands and the supplychain subpackage can use it.
Comment on lines +33 to +36
func GetFailOn(failOn []string) ([]string, error) {
if err := ValidateFailOn(failOn); err != nil {
return nil, err
}
return failOn, nil
}
77 changes: 77 additions & 0 deletions internal/cmd/cmdutil/failon_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cmdutil

import "testing"

func TestValidateFailOn(t *testing.T) {
tests := []struct {
name string
severities []string
wantErr bool
}{
{
name: "valid single severity",
severities: []string{"CRITICAL"},
wantErr: false,
},
{
name: "valid multiple severities",
severities: []string{"HIGH", "CRITICAL"},
wantErr: false,
},
{
name: "valid all severities",
severities: []string{"INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"},
wantErr: false,
},
{
name: "valid severity lowercase",
severities: []string{"high"},
wantErr: false,
},
{
name: "invalid severity unknown",
severities: []string{"INVALID"},
wantErr: true,
},
{
name: "invalid mixed valid and invalid",
severities: []string{"HIGH", "invalid"},
wantErr: true,
},
{
name: "empty slice is valid",
severities: []string{},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFailOn(tt.severities)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateFailOn(%v) error = %v, wantErr %v", tt.severities, err, tt.wantErr)
}
})
}
}

func TestGetFailOn(t *testing.T) {
t.Run("returns normalized severities", func(t *testing.T) {
// GetFailOn is pure: it validates and uppercase-normalizes the slice it is
// given. A lowercase entry must come back uppercased so ShouldFail (exact
// match) can trip the CI gate.
result, err := GetFailOn([]string{"high", "CRITICAL"})
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result) != 2 || result[0] != "HIGH" || result[1] != "CRITICAL" {
t.Errorf("Expected [HIGH CRITICAL], got %v", result)
}
})

t.Run("returns error for invalid severity", func(t *testing.T) {
if _, err := GetFailOn([]string{"invalid"}); err == nil {
t.Error("Expected error for invalid severity")
}
})
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
package cmd
// Package cmdutil holds command plumbing shared across the cmd package and its
// subpackages (e.g. supplychain). It exists to break what would otherwise be an
// import cycle: cmd imports its command subpackages to wire them into the root
// command, so those subpackages cannot import cmd back. Helpers both sides need
// live here, the one package both can import.
package cmdutil

import (
"io"
Expand Down Expand Up @@ -44,7 +49,7 @@ func (c *OutputConfig) Cleanup() {
//
// Example usage:
//
// cfg, err := ResolveOutput(cmd, outputFile, format, colorFlag)
// cfg, err := cmdutil.ResolveOutput(cmd, outputFile, format, colorFlag)
// if err != nil {
// return err
// }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package cmd
package cmdutil

import (
"os"
Expand Down
45 changes: 25 additions & 20 deletions internal/cmd/install_theme.go → internal/cmd/cmdutil/theme.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
package cmd
package cmdutil

import (
"github.com/ArmisSecurity/armis-cli/internal/cli"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)

// Brand colors matching internal/output/styles.go
// Brand colors matching internal/output/styles.go. The exported colors are
// referenced by interactive flows outside this package (install, uninstall);
// the unexported ones are used only by armisTheme below.
var (
brandAccent = lipgloss.AdaptiveColor{Light: "#7c3aed", Dark: "#7c3aed"} // purple-600 (Armis brand)
brandSuccess = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"} // green-600/500 (completion ✓)
BrandAccent = lipgloss.AdaptiveColor{Light: "#7c3aed", Dark: "#7c3aed"} // purple-600 (Armis brand)
BrandSuccess = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"} // green-600/500 (completion ✓)
BrandError = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"} // red-600/500
BrandMuted = lipgloss.AdaptiveColor{Light: "#4B5563", Dark: "#6B7280"} // gray-600/500
BrandSeparator = lipgloss.AdaptiveColor{Light: "#C4B5FD", Dark: "#4C1D95"} // purple-300/900 (title underline)
BrandWarn = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"} // amber-600/500

brandSelected = lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"} // emerald-600/400 (multi-select [+])
brandError = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"} // red-600/500
brandMuted = lipgloss.AdaptiveColor{Light: "#4B5563", Dark: "#6B7280"} // gray-600/500
brandBright = lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#FFFFFF"} // gray-800/white
brandBorder = lipgloss.AdaptiveColor{Light: "#D1D5DB", Dark: "#374151"} // gray-300/700 (buttons)
brandSeparator = lipgloss.AdaptiveColor{Light: "#C4B5FD", Dark: "#4C1D95"} // purple-300/900 (title underline)
brandPanelBorder = lipgloss.AdaptiveColor{Light: "#6366F1", Dark: "#818CF8"} // indigo-500/400 (interactive panels)
brandDim = lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#4B5563"} // gray-400/600
brandWarn = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"} // amber-600/500
)

func armisTheme() *huh.Theme {
Expand All @@ -27,25 +30,25 @@ func armisTheme() *huh.Theme {
t.Focused.Base = t.Focused.Base.BorderForeground(brandPanelBorder)
t.Focused.Card = t.Focused.Base
t.Focused.Title = t.Focused.Title.Foreground(brandBright).Bold(true)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(brandAccent).Bold(true).MarginBottom(1)
t.Focused.Description = t.Focused.Description.Foreground(brandMuted)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(brandError)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(brandError)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(brandAccent)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(brandAccent)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(brandAccent)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(BrandAccent).Bold(true).MarginBottom(1)
t.Focused.Description = t.Focused.Description.Foreground(BrandMuted)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(BrandError)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(BrandError)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(BrandAccent)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(BrandAccent)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(BrandAccent)
t.Focused.Option = t.Focused.Option.Foreground(brandBright)
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(brandAccent)
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(BrandAccent)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(brandSelected)
t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(brandSelected).SetString("[+] ")
t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(brandDim).SetString("[ ] ")
t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(brandBright)
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(brandAccent).Bold(true)
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(BrandAccent).Bold(true)
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(brandBright).Background(brandBorder)

t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(brandAccent)
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(BrandAccent)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(brandDim)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(brandAccent)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(BrandAccent)

t.Blurred = t.Focused
t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
Expand All @@ -59,7 +62,9 @@ func armisTheme() *huh.Theme {
return t
}

func getInstallTheme() *huh.Theme {
// GetInstallTheme returns the Armis-branded huh theme, or the plain base theme
// when colors are disabled (NO_COLOR, non-TTY, --color=never).
func GetInstallTheme() *huh.Theme {
if !cli.ColorsEnabled() {
return huh.ThemeBase()
}
Expand Down
21 changes: 11 additions & 10 deletions internal/cmd/install_interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,24 @@ import (
"strings"

"github.com/ArmisSecurity/armis-cli/internal/cli"
"github.com/ArmisSecurity/armis-cli/internal/cmd/cmdutil"
"github.com/ArmisSecurity/armis-cli/internal/install"
"github.com/ArmisSecurity/armis-cli/internal/progress"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)

func runInteractiveInstall(force bool) error {
theme := getInstallTheme()
theme := cmdutil.GetInstallTheme()
accessible := !cli.ColorsEnabled()

fmt.Fprintln(os.Stderr, "")
if accessible {
fmt.Fprintln(os.Stderr, " Armis AppSec MCP Server Setup")
fmt.Fprintln(os.Stderr, " ─────────────────────────────")
} else {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(brandAccent)
borderStyle := lipgloss.NewStyle().Foreground(brandSeparator)
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(cmdutil.BrandAccent)
borderStyle := lipgloss.NewStyle().Foreground(cmdutil.BrandSeparator)
fmt.Fprintf(os.Stderr, " %s\n", titleStyle.Render("Armis AppSec MCP Server Setup"))
fmt.Fprintf(os.Stderr, " %s\n", borderStyle.Render("─────────────────────────────"))
}
Expand Down Expand Up @@ -52,9 +53,9 @@ func runInteractiveInstall(force bool) error {
if accessible {
successMark, failMark, warnMark = "[OK]", "[FAIL]", "[WARN]"
} else {
successMark = lipgloss.NewStyle().Foreground(brandSuccess).Render("✓")
failMark = lipgloss.NewStyle().Foreground(brandError).Render("✗")
warnMark = lipgloss.NewStyle().Foreground(brandWarn).Render("⚠")
successMark = lipgloss.NewStyle().Foreground(cmdutil.BrandSuccess).Render("✓")
failMark = lipgloss.NewStyle().Foreground(cmdutil.BrandError).Render("✗")
warnMark = lipgloss.NewStyle().Foreground(cmdutil.BrandWarn).Render("⚠")
}

fmt.Fprintln(os.Stderr, "")
Expand Down Expand Up @@ -219,7 +220,7 @@ func runInteractiveInstall(force bool) error {
fmt.Fprintln(os.Stderr, " Run 'armis-cli hook init' in other repos for pre-commit coverage.")
}
} else {
dimStyle := lipgloss.NewStyle().Foreground(brandMuted)
dimStyle := lipgloss.NewStyle().Foreground(cmdutil.BrandMuted)
fmt.Fprintln(os.Stderr, dimStyle.Render(" Next steps:"))
fmt.Fprintln(os.Stderr, dimStyle.Render(" Restart your editors to activate the MCP server."))
if installPreCommit {
Expand Down Expand Up @@ -347,7 +348,7 @@ func validateAndReport(clientID, clientSecret string, accessible bool) error {
if accessible {
fmt.Fprintln(os.Stderr, "[FAIL]")
} else {
fmt.Fprintln(os.Stderr, lipgloss.NewStyle().Foreground(brandError).Render("✗"))
fmt.Fprintln(os.Stderr, lipgloss.NewStyle().Foreground(cmdutil.BrandError).Render("✗"))
}
for _, line := range strings.Split(err.Error(), "\n") {
fmt.Fprintf(os.Stderr, " %s\n", line)
Expand All @@ -358,7 +359,7 @@ func validateAndReport(clientID, clientSecret string, accessible bool) error {
if accessible {
fmt.Fprintln(os.Stderr, "[OK]")
} else {
fmt.Fprintln(os.Stderr, lipgloss.NewStyle().Foreground(brandSuccess).Render("✓"))
fmt.Fprintln(os.Stderr, lipgloss.NewStyle().Foreground(cmdutil.BrandSuccess).Render("✓"))
}
fmt.Fprintln(os.Stderr, "")
return nil
Expand Down Expand Up @@ -477,7 +478,7 @@ func offerHookSetup(theme *huh.Theme, accessible bool, hasClaude bool) ([]instal
if accessible {
fmt.Fprintln(os.Stderr, " No hook-capable AI clients detected. Skipping hook setup.")
} else {
dimStyle := lipgloss.NewStyle().Foreground(brandMuted)
dimStyle := lipgloss.NewStyle().Foreground(cmdutil.BrandMuted)
fmt.Fprintln(os.Stderr, dimStyle.Render(" No hook-capable AI clients detected. Skipping hook setup."))
}
}
Expand Down
29 changes: 0 additions & 29 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -319,31 +318,3 @@ func validatePageLimit(limit int) error {
}
return nil
}

// validSeverities contains the valid severity level strings for the --fail-on flag.
var validSeverities = []string{"INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"}

func validateFailOn(severities []string) error {
validSet := make(map[string]bool)
for _, s := range validSeverities {
validSet[s] = true
}

for i, sev := range severities {
// Normalize to uppercase for case-insensitive matching
upper := strings.ToUpper(sev)
if !validSet[upper] {
return fmt.Errorf("invalid severity level %q: must be one of %v", sev, validSeverities)
}
// Update the slice with normalized value
severities[i] = upper
}
return nil
}

func getFailOn() ([]string, error) {
if err := validateFailOn(failOn); err != nil {
return nil, err
}
return failOn, nil
}
Loading
Loading