Skip to content
Merged
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
7 changes: 5 additions & 2 deletions .github/workflows/reusable-security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,11 @@ jobs:

- name: Check for Failures
if: always()
env:
SCAN_OUTCOME: ${{ steps.armis_scan.outcome }}
FAIL_ON_THRESHOLD: ${{ inputs.fail-on }}
run: |
if [ "${{ steps.armis_scan.outcome }}" = "failure" ]; then
echo "::error::Security vulnerabilities detected by Armis (threshold: ${{ inputs.fail-on }})"
if [ "$SCAN_OUTCOME" = "failure" ]; then
echo "::error::Security vulnerabilities detected by Armis (threshold: $FAIL_ON_THRESHOLD)"
exit 1
fi
8 changes: 6 additions & 2 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ func WithHTTPClient(client *httpclient.Client) ClientOption {

// NewClient creates a new API client with the given configuration.
func NewClient(baseURL, token string, debug bool, uploadTimeout time.Duration, opts ...ClientOption) *Client {
// Warn about non-HTTPS URLs (except localhost) as credentials may be exposed
if parsedURL, err := url.Parse(baseURL); err == nil && parsedURL.Scheme != "https" {
// Validate and warn about URL issues
parsedURL, err := url.Parse(baseURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: invalid base URL %q: %v - requests may fail\n", baseURL, err)
} else if parsedURL.Scheme != "https" {
// Warn about non-HTTPS URLs (except localhost) as credentials may be exposed
host := parsedURL.Hostname()
if host != "localhost" && host != "127.0.0.1" {
fmt.Fprintf(os.Stderr, "Warning: using non-HTTPS URL %q - credentials may be transmitted insecurely\n", baseURL)
Expand Down
14 changes: 12 additions & 2 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import (
"github.com/ArmisSecurity/armis-cli/internal/model"
)

// Package-level variables for testability
var (
stdoutSyncer = func() error { return os.Stdout.Sync() }
stderrWriter io.Writer = os.Stderr
osExit = os.Exit
)

// FormatOptions contains options for formatting scan results.
type FormatOptions struct {
GroupBy string
Expand Down Expand Up @@ -61,7 +68,10 @@ func ExitIfNeeded(result *model.ScanResult, failOnSeverities []string, exitCode
exitCode = 1
}
// Flush stdout to ensure all output is written before exit
_ = os.Stdout.Sync()
os.Exit(exitCode)
if err := stdoutSyncer(); err != nil {
// Log flush failure to stderr (stdout may be broken)
_, _ = fmt.Fprintf(stderrWriter, "Warning: failed to flush stdout before exit: %v\n", err)
}
osExit(exitCode)
}
}
136 changes: 136 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package output

import (
"bytes"
"errors"
"testing"

"github.com/ArmisSecurity/armis-cli/internal/model"
Expand Down Expand Up @@ -155,3 +157,137 @@ func TestShouldFail_CaseSensitive(t *testing.T) {
t.Error("ShouldFail should match 'HIGH' with 'HIGH'")
}
}

func TestExitIfNeeded_ExitsOnMatchingSeverity(t *testing.T) {
// Save original and restore after test
originalOsExit := osExit
defer func() { osExit = originalOsExit }()

var exitCode int
exitCalled := false
osExit = func(code int) {
exitCode = code
exitCalled = true
}

result := &model.ScanResult{
Findings: []model.Finding{
{Severity: model.SeverityCritical},
},
}

ExitIfNeeded(result, []string{"CRITICAL"}, 2)

if !exitCalled {
t.Error("ExitIfNeeded should call osExit when severity matches")
}
if exitCode != 2 {
t.Errorf("ExitIfNeeded called osExit with code %d, want 2", exitCode)
}
}

func TestExitIfNeeded_NoExitWhenNoMatch(t *testing.T) {
// Save original and restore after test
originalOsExit := osExit
defer func() { osExit = originalOsExit }()

exitCalled := false
osExit = func(code int) {
exitCalled = true
}

result := &model.ScanResult{
Findings: []model.Finding{
{Severity: model.SeverityLow},
},
}

ExitIfNeeded(result, []string{"CRITICAL", "HIGH"}, 1)

if exitCalled {
t.Error("ExitIfNeeded should not call osExit when severity does not match")
}
}

func TestExitIfNeeded_NormalizesExitCode(t *testing.T) {
// Save original and restore after test
originalOsExit := osExit
defer func() { osExit = originalOsExit }()

tests := []struct {
name string
inputCode int
expectedCode int
}{
{"negative code normalizes to 1", -1, 1},
{"code above 255 normalizes to 1", 256, 1},
{"code 0 stays 0", 0, 0},
{"code 255 stays 255", 255, 255},
{"code 100 stays 100", 100, 100},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var exitCode int
osExit = func(code int) {
exitCode = code
}

result := &model.ScanResult{
Findings: []model.Finding{
{Severity: model.SeverityCritical},
},
}

ExitIfNeeded(result, []string{"CRITICAL"}, tt.inputCode)

if exitCode != tt.expectedCode {
t.Errorf("ExitIfNeeded with code %d called osExit with %d, want %d",
tt.inputCode, exitCode, tt.expectedCode)
}
})
}
}

func TestExitIfNeeded_StdoutSyncError(t *testing.T) {
// Save originals and restore after test
originalStdoutSyncer := stdoutSyncer
originalStderrWriter := stderrWriter
originalOsExit := osExit
defer func() {
stdoutSyncer = originalStdoutSyncer
stderrWriter = originalStderrWriter
osExit = originalOsExit
}()

// Mock stdoutSyncer to return an error
stdoutSyncer = func() error {
return errors.New("sync failed")
}

// Capture stderr output
var stderrBuf bytes.Buffer
stderrWriter = &stderrBuf

// Mock osExit to not actually exit
osExit = func(code int) {}

result := &model.ScanResult{
Findings: []model.Finding{
{Severity: model.SeverityCritical},
},
}

ExitIfNeeded(result, []string{"CRITICAL"}, 1)

stderrOutput := stderrBuf.String()
if stderrOutput == "" {
t.Error("ExitIfNeeded should write warning to stderr when stdout sync fails")
}
if !bytes.Contains(stderrBuf.Bytes(), []byte("Warning")) {
t.Errorf("stderr output should contain 'Warning', got: %s", stderrOutput)
}
if !bytes.Contains(stderrBuf.Bytes(), []byte("sync failed")) {
t.Errorf("stderr output should contain error message, got: %s", stderrOutput)
}
}
32 changes: 16 additions & 16 deletions internal/scan/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import (
"strings"
"time"

"github.com/ArmisSecurity/armis-cli/internal/api"
"github.com/ArmisSecurity/armis-cli/internal/model"
"github.com/ArmisSecurity/armis-cli/internal/progress"
"github.com/ArmisSecurity/armis-cli/internal/scan"
"github.com/ArmisSecurity/armis-cli/internal/util"
"github.com/ArmisSecurity/armis-cli/internal/api"
"github.com/ArmisSecurity/armis-cli/internal/model"
"github.com/ArmisSecurity/armis-cli/internal/progress"
"github.com/ArmisSecurity/armis-cli/internal/scan"
"github.com/ArmisSecurity/armis-cli/internal/util"
)

const (
Expand Down Expand Up @@ -294,19 +294,19 @@ func convertNormalizedFindings(normalizedFindings []model.NormalizedFinding, deb
finding.CodeSnippet = *loc.Snippet
}

if loc.SnippetStartLine != nil {
finding.SnippetStartLine = *loc.SnippetStartLine
}
if loc.SnippetStartLine != nil {
finding.SnippetStartLine = *loc.SnippetStartLine
}

finding.Type = scan.DeriveFindingType(
len(nf.NormalizedRemediation.VulnerabilityTypeMetadata.CVEs) > 0,
loc.HasSecret,
finding.FindingCategory,
)
finding.Type = scan.DeriveFindingType(
len(nf.NormalizedRemediation.VulnerabilityTypeMetadata.CVEs) > 0,
loc.HasSecret,
finding.FindingCategory,
)

if loc.HasSecret && finding.CodeSnippet != "" {
finding.CodeSnippet = util.MaskSecretInLine(finding.CodeSnippet)
}
if loc.HasSecret && finding.CodeSnippet != "" {
finding.CodeSnippet = util.MaskSecretInLine(finding.CodeSnippet)
}

finding.Title = finding.Description

Expand Down
32 changes: 16 additions & 16 deletions internal/scan/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import (
"strings"
"time"

"github.com/ArmisSecurity/armis-cli/internal/api"
"github.com/ArmisSecurity/armis-cli/internal/model"
"github.com/ArmisSecurity/armis-cli/internal/progress"
"github.com/ArmisSecurity/armis-cli/internal/scan"
"github.com/ArmisSecurity/armis-cli/internal/util"
"github.com/ArmisSecurity/armis-cli/internal/api"
"github.com/ArmisSecurity/armis-cli/internal/model"
"github.com/ArmisSecurity/armis-cli/internal/progress"
"github.com/ArmisSecurity/armis-cli/internal/scan"
"github.com/ArmisSecurity/armis-cli/internal/util"
)

// MaxRepoSize is the maximum allowed size for repositories.
Expand Down Expand Up @@ -432,19 +432,19 @@ func convertNormalizedFindings(normalizedFindings []model.NormalizedFinding, deb
finding.CodeSnippet = *loc.Snippet
}

if loc.SnippetStartLine != nil {
finding.SnippetStartLine = *loc.SnippetStartLine
}
if loc.SnippetStartLine != nil {
finding.SnippetStartLine = *loc.SnippetStartLine
}

finding.Type = scan.DeriveFindingType(
len(nf.NormalizedRemediation.VulnerabilityTypeMetadata.CVEs) > 0,
loc.HasSecret,
finding.FindingCategory,
)
finding.Type = scan.DeriveFindingType(
len(nf.NormalizedRemediation.VulnerabilityTypeMetadata.CVEs) > 0,
loc.HasSecret,
finding.FindingCategory,
)

if loc.HasSecret && finding.CodeSnippet != "" {
finding.CodeSnippet = util.MaskSecretInLine(finding.CodeSnippet)
}
if loc.HasSecret && finding.CodeSnippet != "" {
finding.CodeSnippet = util.MaskSecretInLine(finding.CodeSnippet)
}

finding.Title = finding.Description

Expand Down
37 changes: 26 additions & 11 deletions internal/util/mask.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package util

import (
"fmt"
"regexp"
"strings"
)
Expand Down Expand Up @@ -74,20 +75,34 @@ func MaskSecretInLine(line string) string {
return result
}

// maskValue masks a secret value, preserving some structure hints.
// Shows first 2 chars and last 2 chars if long enough, otherwise all asterisks.
// maskValue masks a secret value completely for security.
// Only reveals a length range of the original value, not any actual characters.
// This prevents leaking prefixes that identify secret types (e.g., "eyJ" for JWT,
// "ghp_" for GitHub tokens, "AKIA" for AWS keys, "sk_live_" for Stripe).
// Length ranges are used instead of exact lengths to prevent token type identification.
func maskValue(value string) string {
if len(value) <= 4 {
return strings.Repeat("*", len(value))
length := len(value)
if length == 0 {
return ""
}

// For longer values, show partial hints
if len(value) <= 8 {
return value[:1] + strings.Repeat("*", len(value)-2) + value[len(value)-1:]
if length < 10 {
// For short values (up to 9 chars), show asterisks matching length
return strings.Repeat("*", length)
}

// For very long values, show first 2 and last 2
return value[:2] + strings.Repeat("*", len(value)-4) + value[len(value)-2:]
// For longer values, show length range to prevent token type identification
// (e.g., GitHub PATs ~40 chars, AWS keys 40 chars could be fingerprinted)
var rangeStr string
switch {
case length <= 20:
rangeStr = "10-20"
case length <= 40:
rangeStr = "20-40"
case length <= 80:
rangeStr = "40-80"
default:
rangeStr = "80+"
}
return fmt.Sprintf("********[%s]", rangeStr)
}

// MaskSecretInLines masks secrets in multiple lines.
Expand Down
Loading
Loading