Skip to content
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ go test -v ./internal/output/... -run TestHumanFormatter

### Core Packages

- `internal/auth/` - Authentication provider supporting two modes. JWT (priority): client credentials exchange at `/api/v1/authenticate`, auto-refresh 5min before expiry, tenant ID extracted from `customer_id` JWT claim. Basic (fallback): static token + explicit tenant ID. Implements `AuthHeaderProvider` interface used by the API client.
- `internal/auth/` - Authentication provider supporting two modes. JWT (priority): client credentials exchange at `/api/v1/auth/token`, auto-refresh 5min before expiry, tenant ID extracted from `customer_id` JWT claim. Basic (fallback): static token + explicit tenant ID. Implements `AuthHeaderProvider` interface used by the API client.
- `internal/api/` - API client for Armis Cloud. Two HTTP clients: one for general calls (60s timeout), one for uploads (streaming, no timeout, no retry). Functional options pattern (`WithHTTPClient()`, `WithUploadHTTPClient()`, `WithAllowLocalURLs()`). Upload uses `io.Pipe` streaming to avoid OOM on large files. Enforces HTTPS, validates presigned S3 URLs against SSRF.
- `internal/model/` - Data structures: `Finding` (23 fields), `ScanResult`, `Summary`, `Fix`, `FindingValidation` (with taint/reachability analysis), API response types (`NormalizedFinding`, pagination).
- `internal/output/` - Output formatters (human, json, sarif, junit) implementing the `Formatter` interface. `styles.go` defines ~50 lipgloss styles using Tailwind CSS color palette. `icons.go` defines Unicode constants (severity dots, box-drawing chars). `SyncColors()` switches between full-color and plain styles based on `cli.ColorsEnabled()`.
Expand Down Expand Up @@ -83,9 +83,10 @@ go test -v ./internal/output/... -run TestHumanFormatter

- `ARMIS_CLIENT_ID` - Client ID for JWT authentication (recommended)
- `ARMIS_CLIENT_SECRET` - Client secret for JWT authentication
- `ARMIS_AUTH_ENDPOINT` - JWT authentication service endpoint URL
- `ARMIS_API_TOKEN` - API token for Basic authentication (fallback)
- `ARMIS_TENANT_ID` - Tenant identifier (required only with Basic auth; JWT extracts it from token)
- `ARMIS_API_URL` - Override base URL for Armis API (advanced; defaults based on --dev flag)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The env var list removes ARMIS_AUTH_ENDPOINT and adds ARMIS_API_URL, but it does not mention the newly introduced region override (--region / ARMIS_REGION). Please document ARMIS_REGION here as well to keep the CLI configuration summary accurate.

Suggested change
- `ARMIS_API_URL` - Override base URL for Armis API (advanced; defaults based on --dev flag)
- `ARMIS_API_URL` - Override base URL for Armis API (advanced; defaults based on --dev flag)
- `ARMIS_REGION` - Override Armis cloud region (equivalent to `--region`; affects default API URL selection)

Copilot uses AI. Check for mistakes.
- `ARMIS_REGION` - Override Armis cloud region (equivalent to `--region`; used for region-aware authentication)
- `ARMIS_FORMAT` - Default output format
- `ARMIS_PAGE_LIMIT` - Results pagination size
- `ARMIS_THEME` - Terminal background theme: auto, dark, light (default: auto)
Expand Down
3 changes: 2 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,10 @@ armis-cli scan repo . \
|----------|-------------|
| `ARMIS_CLIENT_ID` | Client ID for JWT authentication |
| `ARMIS_CLIENT_SECRET` | Client secret for JWT authentication |
| `ARMIS_AUTH_ENDPOINT` | Authentication service endpoint URL |
| `ARMIS_API_TOKEN` | API token for Basic authentication |
| `ARMIS_TENANT_ID` | Tenant identifier (required for Basic auth only) |
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This env var table documents auth-related configuration, but it omits ARMIS_API_URL, which the CLI uses to override the API (and now JWT auth) base URL. Since ARMIS_AUTH_ENDPOINT is being removed here, consider adding ARMIS_API_URL to keep the docs aligned with the actual configuration surface.

Suggested change
| `ARMIS_TENANT_ID` | Tenant identifier (required for Basic auth only) |
| `ARMIS_TENANT_ID` | Tenant identifier (required for Basic auth only) |
| `ARMIS_API_URL` | Override base URL for Armis API and authentication (advanced) |

Copilot uses AI. Check for mistakes.
| `ARMIS_API_URL` | Override base URL for Armis API and authentication (advanced) |
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The environment variable list was updated to add ARMIS_API_URL, but the new --region flag / ARMIS_REGION env var (added in root command) is not documented here. Please add an entry describing ARMIS_REGION (authentication region override) so users can discover it.

Suggested change
| `ARMIS_API_URL` | Override base URL for Armis API and authentication (advanced) |
| `ARMIS_API_URL` | Override base URL for Armis API and authentication (advanced) |
| `ARMIS_REGION` | Authentication region override (advanced; corresponds to `--region` flag) |

Copilot uses AI. Check for mistakes.
| `ARMIS_REGION` | Authentication region override (advanced; corresponds to `--region` flag) |

**General:**

Expand Down
5 changes: 1 addition & 4 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,9 @@ func (c *Client) IsDebug() bool {
// request URL uses HTTPS (or localhost for testing). This prevents credential
// exposure over insecure channels.
//
// For JWT auth: sends raw JWT token (no "Bearer" prefix)
// For JWT auth: sends "Bearer <token>" per RFC 6750
// For Basic auth: sends "Basic <token>" per RFC 7617
//
// NOTE: The backend expects raw JWT tokens without the "Bearer" prefix.
// This is unconventional but matches the backend API contract.
//
// SECURITY NOTE: The localhost/127.0.0.1 exception is intentional for local
// development and testing environments where HTTPS certificates are not available.
// Production deployments must always use HTTPS.
Expand Down
111 changes: 92 additions & 19 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ type AuthConfig struct {
// JWT auth credentials
ClientID string
ClientSecret string //nolint:gosec // G117: This is a config field name, not a secret value
AuthEndpoint string // Full URL to the authentication service
BaseURL string // Moose API base URL (dev or prod)
Region string // Optional region override - bypasses auto-discovery if set

// Legacy Basic auth
Token string
Expand All @@ -33,21 +34,24 @@ type JWTCredentials struct {
Token string
TenantID string // Extracted from customer_id claim
ExpiresAt time.Time
Region string // Deployment region (e.g., "us1", "eu1", "au1")
}

// AuthProvider manages authentication tokens with automatic refresh.
// It supports both JWT authentication and legacy Basic authentication.
// For JWT auth, tokens are automatically refreshed when within 5 minutes of expiry.
type AuthProvider struct {
config AuthConfig
credentials *JWTCredentials
authClient *AuthClient
mu sync.RWMutex
isLegacy bool // true if using Basic auth (--token)
config AuthConfig
credentials *JWTCredentials
authClient *AuthClient
mu sync.RWMutex
isLegacy bool // true if using Basic auth (--token)
cachedRegion string // memoized region from disk cache (loaded once)
regionLoaded bool // true if cachedRegion has been loaded from disk
}

// NewAuthProvider creates an AuthProvider from configuration.
// If ClientID and ClientSecret are set, uses JWT auth with the specified endpoint.
// If ClientID and ClientSecret are set, uses JWT auth with the specified base URL.
// Otherwise falls back to legacy Basic auth with Token.
func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {
p := &AuthProvider{
Expand All @@ -64,12 +68,12 @@ func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {

// Determine auth mode: JWT credentials take priority
if config.ClientID != "" && config.ClientSecret != "" {
// JWT auth
// JWT auth via moose
p.isLegacy = false
if config.AuthEndpoint == "" {
return nil, fmt.Errorf("--auth-endpoint is required when using client credentials")
if config.BaseURL == "" {
return nil, fmt.Errorf("base URL is required for JWT authentication")
}
Comment on lines 70 to 75
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments around NewAuthProvider/AuthConfig still refer to using a specific "endpoint" for JWT auth, but the config field has been renamed to BaseURL and is now described as the Moose API base URL. Please update the nearby doc/comment text to consistently use “base URL” (or clarify the distinction if there still is one) so the public contract of this package remains accurate.

Copilot uses AI. Check for mistakes.
authClient, err := NewAuthClient(config.AuthEndpoint, config.Debug)
authClient, err := NewAuthClient(config.BaseURL, config.Debug)
if err != nil {
return nil, fmt.Errorf("failed to create auth client: %w", err)
}
Expand All @@ -93,7 +97,7 @@ func NewAuthProvider(config AuthConfig) (*AuthProvider, error) {
}

// GetAuthorizationHeader returns the Authorization header value.
// For JWT auth: the raw JWT token (no "Bearer" prefix - backend expects raw JWT)
// For JWT auth: "Bearer <token>" per RFC 6750
// For Basic auth: "Basic <token>" per RFC 7617
func (p *AuthProvider) GetAuthorizationHeader(ctx context.Context) (string, error) {
if p.isLegacy {
Expand All @@ -108,8 +112,8 @@ func (p *AuthProvider) GetAuthorizationHeader(ctx context.Context) (string, erro

p.mu.RLock()
defer p.mu.RUnlock()
// Raw JWT token (no Bearer prefix) - backend expects raw JWT per API contract
return p.credentials.Token, nil
// Bearer token per RFC 6750
return "Bearer " + p.credentials.Token, nil
}

// GetTenantID returns the tenant ID for API requests.
Expand All @@ -129,6 +133,23 @@ func (p *AuthProvider) GetTenantID(ctx context.Context) (string, error) {
return p.credentials.TenantID, nil
}

// GetRegion returns the deployment region from the JWT token.
// For JWT auth: extracted from region claim (may be empty for older tokens)
// For Basic auth: returns empty string (no region available)
func (p *AuthProvider) GetRegion(ctx context.Context) (string, error) {
if p.isLegacy {
return "", nil // Legacy auth doesn't have region
}

if err := p.refreshIfNeeded(ctx); err != nil {
return "", fmt.Errorf("failed to refresh token: %w", err)
}

p.mu.RLock()
defer p.mu.RUnlock()
return p.credentials.Region, nil
}

// IsLegacy returns true if using legacy Basic auth.
func (p *AuthProvider) IsLegacy() bool {
return p.isLegacy
Expand Down Expand Up @@ -160,6 +181,16 @@ func (p *AuthProvider) GetRawToken(ctx context.Context) (string, error) {

// exchangeCredentials exchanges client credentials for a JWT token.
// Uses double-checked locking to prevent thundering herd of concurrent refreshes.
// Leverages region caching to avoid auto-discovery overhead on subsequent requests.
//
// Region selection priority:
// 1. --region flag (config.Region) - explicit override, bypasses cache and discovery
// 2. Cached region - from previous successful auth for this client_id
// 3. Auto-discovery - server tries regions until one succeeds
//
// Retry behavior: If auth fails with a cached region hint (not explicit --region),
// the cache is cleared and auth is retried without the hint. This handles stale
// cache gracefully without requiring user to re-run the command.
func (p *AuthProvider) exchangeCredentials(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
Expand All @@ -169,21 +200,60 @@ func (p *AuthProvider) exchangeCredentials(ctx context.Context) error {
return nil
}

token, err := p.authClient.Authenticate(ctx, p.config.ClientID, p.config.ClientSecret)
// Load cached region once per process (memoize to avoid repeated disk I/O)
if !p.regionLoaded {
if region, ok := loadCachedRegion(p.config.ClientID); ok {
p.cachedRegion = region
}
p.regionLoaded = true
}

// Determine region hint - explicit flag takes priority over cache
var regionHint *string
var usingCachedHint bool
if p.config.Region != "" {
// Explicit --region flag - don't retry on failure (user error)
regionHint = &p.config.Region
} else if p.cachedRegion != "" {
// Cached region - will retry without hint on failure
regionHint = &p.cachedRegion
usingCachedHint = true
}

result, err := p.authClient.Authenticate(ctx, p.config.ClientID, p.config.ClientSecret, regionHint)
if err != nil {
return err
// If auth failed with a cached region hint, clear cache and retry without hint
// This handles stale cache (region changed) without requiring user to re-run
if usingCachedHint {
clearCachedRegion()
p.cachedRegion = ""
// Retry without region hint - let server auto-discover
result, err = p.authClient.Authenticate(ctx, p.config.ClientID, p.config.ClientSecret, nil)
if err != nil {
return err
}
} else {
return err
}
Comment on lines +203 to +237
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new region-caching/region-hint retry logic (clearing the cache and retrying without the hint on failure) isn’t covered by tests in this package. Adding an auth provider test that simulates a failing request when a cached region is provided and a succeeding request without the hint would help prevent regressions in this behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +223 to +237
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using a cached region hint, any Authenticate() error triggers cache clear + an immediate retry without the hint. This means transient failures (network/5xx), or genuinely invalid credentials, will always cause a second request and will also wipe a potentially-correct cache entry. Consider only clearing/retrying on a specific “region hint rejected” condition (e.g., a typed error that carries HTTP status like 401/403), and avoid retrying on transport errors.

Copilot uses AI. Check for mistakes.
}

// Cache the discovered region for future requests (skip if unchanged)
if result.Region != "" && result.Region != p.cachedRegion {
saveCachedRegion(p.config.ClientID, result.Region)
p.cachedRegion = result.Region
}

// Parse JWT to extract claims
claims, err := parseJWTClaims(token)
claims, err := parseJWTClaims(result.Token)
if err != nil {
return fmt.Errorf("failed to parse JWT: %w", err)
}

p.credentials = &JWTCredentials{
Token: token,
Token: result.Token,
TenantID: claims.CustomerID,
ExpiresAt: claims.ExpiresAt,
Region: claims.Region,
}

return nil
Expand All @@ -207,6 +277,7 @@ func (p *AuthProvider) refreshIfNeeded(ctx context.Context) error {
type jwtClaims struct {
CustomerID string // maps to tenant_id
ExpiresAt time.Time
Region string // deployment region (optional)
}

// parseJWTClaims extracts claims from a JWT without signature verification.
Expand All @@ -233,7 +304,8 @@ func parseJWTClaims(token string) (*jwtClaims, error) {

var data struct {
CustomerID string `json:"customer_id"`
Exp float64 `json:"exp"` // float64 to handle servers that return fractional timestamps
Exp float64 `json:"exp"` // float64 to handle servers that return fractional timestamps
Region string `json:"region"` // optional deployment region
}
if err := json.Unmarshal(payload, &data); err != nil {
return nil, fmt.Errorf("failed to parse JWT payload: %w", err)
Expand All @@ -252,5 +324,6 @@ func parseJWTClaims(token string) (*jwtClaims, error) {
return &jwtClaims{
CustomerID: data.CustomerID,
ExpiresAt: time.Unix(expSec, 0),
Region: data.Region, // may be empty for backward compatibility
}, nil
}
Loading
Loading