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
2 changes: 1 addition & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
type AuthConfig struct {
// JWT auth credentials
ClientID string
ClientSecret string
ClientSecret string //nolint:gosec // G117: This is a config field name, not a secret value
AuthEndpoint string // Full URL to the authentication service

// Legacy Basic auth
Expand Down
6 changes: 3 additions & 3 deletions internal/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func NewAuthClient(endpoint string, debug bool) (*AuthClient, error) {
// authRequest is the request body for the authenticate endpoint.
type authRequest struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
ClientSecret string `json:"client_secret"` //nolint:gosec // G117: This is a JSON field name for auth request, not a secret value
}

// authResponse is the response from the authenticate endpoint.
Expand Down Expand Up @@ -88,7 +88,7 @@ func (c *AuthClient) Authenticate(ctx context.Context, clientID, clientSecret st

req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
resp, err := c.httpClient.Do(req) //nolint:gosec // G704: authEndpoint is constructed from validated config, not user input
if err != nil {
return "", fmt.Errorf("authentication request failed: %w", err)
}
Expand All @@ -107,7 +107,7 @@ func (c *AuthClient) Authenticate(ctx context.Context, clientID, clientSecret st
if resp.StatusCode != http.StatusOK {
// Log detailed error info when debug mode is enabled
if c.debug {
fmt.Fprintf(os.Stderr, "DEBUG: Auth failed with status %d, body: %s\n", resp.StatusCode, string(body))
fmt.Fprintf(os.Stderr, "DEBUG: Auth failed with status %d, body: %s\n", resp.StatusCode, string(body)) //nolint:gosec // G705: Debug output to stderr only, not rendered in HTML
}
// Don't include raw response body in error to prevent potential info leakage
return "", fmt.Errorf("authentication failed (status %d)", resp.StatusCode)
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/color.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func InitColors(mode ColorMode) {
disableColors()
return
}
if !term.IsTerminal(int(os.Stderr.Fd())) {
if !term.IsTerminal(int(os.Stderr.Fd())) { //nolint:gosec // G115: Fd() returns uintptr which fits in int on all supported platforms
disableColors()
return
}
Expand Down
2 changes: 1 addition & 1 deletion internal/httpclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
req.Body = newBody
}

resp, err = c.httpClient.Do(req)
resp, err = c.httpClient.Do(req) //nolint:gosec // G704: URL is from API client, validated before use

Check failure

Code scanning / Armis Security Scanner

Server-Side Request Forgery (CWE-918: Server-Side Request Forgery (SSRF)) High

Server-Side Request Forgery (CWE-918: Server-Side Request Forgery (SSRF)): The function receives an *http.Request* from its caller and forwards it directly to c.httpClient.Do(req) without any validation or restriction of the request URL. If an attacker can influence the URL field of the request (e.g., by supplying it through user‑controlled input to the caller), the attacker can cause the client to issue arbitrary HTTP requests to internal or external services. No sanitization or whitelist checks are performed before the request is sent, so the tainted URL reaches the network request sink, making the SSRF vulnerability exploitable. The code resides in a client library, not an exposed HTTP endpoint, resulting in internal‑service exposure. This confirms a true positive for CWE‑918.
if err != nil {
// Close response body if present to prevent resource leaks
// (some HTTP errors may return both a response and an error)
Expand Down
80 changes: 70 additions & 10 deletions internal/output/human.go
Original file line number Diff line number Diff line change
Expand Up @@ -728,11 +728,11 @@ func renderSummaryDashboard(w io.Writer, result *model.ScanResult) error {
content.WriteString("SCAN COMPLETE\n")

// Total findings - simple and prominent
content.WriteString(fmt.Sprintf("%d findings", result.Summary.Total))
fmt.Fprintf(&content, "%d findings", result.Summary.Total)

// Duration if available (inline)
if duration := scanDuration(result); duration != "" {
content.WriteString(fmt.Sprintf(" • %s", duration))
fmt.Fprintf(&content, " • %s", duration)
}
content.WriteString("\n")

Expand Down Expand Up @@ -789,7 +789,7 @@ func renderSummaryDashboard(w io.Writer, result *model.ScanResult) error {
catParts = append(catParts, fmt.Sprintf("%s (%d)", util.FormatCategory(cc.category), cc.count))
}
catLabel := s.MutedText.Render("Categories:")
content.WriteString(fmt.Sprintf("%s %s\n", catLabel, strings.Join(catParts, ", ")))
fmt.Fprintf(&content, "%s %s\n", catLabel, strings.Join(catParts, ", "))
}

// Render the summary box using predefined style
Expand Down Expand Up @@ -889,10 +889,20 @@ func renderFinding(w io.Writer, finding model.Finding, opts FormatOptions) {
}
}

// Defense-in-depth: always mask secrets in code snippets before display,
// even if upstream already masked. Already-masked content (e.g., "********[20-40]")
// remains safely masked, though the exact format may change on re-processing.
// Create a local copy of the finding to avoid modifying the caller's struct,
// since formatCodeSnippetWithFrame reads from finding.CodeSnippet directly.
maskedFinding := finding
if maskedFinding.CodeSnippet != "" {
maskedFinding.CodeSnippet = util.MaskSecretInMultiLineString(maskedFinding.CodeSnippet)
}

// Code snippet with framed box
if finding.CodeSnippet != "" {
if maskedFinding.CodeSnippet != "" {
_, _ = fmt.Fprintf(w, "\n")
_, _ = fmt.Fprintf(w, "%s\n", formatCodeSnippetWithFrame(finding))
_, _ = fmt.Fprintf(w, "%s\n", formatCodeSnippetWithFrame(maskedFinding))
}

// Display proposed fix if available
Expand Down Expand Up @@ -1180,12 +1190,51 @@ func wrapTitle(title string, maxWidth, indent int) string {
return result.String()
}

// maskFixForDisplay creates a copy of Fix with secrets masked in code fields.
// This provides defense-in-depth against secret leakage through proposed fixes and patches.
func maskFixForDisplay(fix *model.Fix) *model.Fix {
fixCopy := *fix

// Mask Patch (unified diff, multi-line)
if fixCopy.Patch != nil && *fixCopy.Patch != "" {
masked := util.MaskSecretInMultiLineString(*fixCopy.Patch)
fixCopy.Patch = &masked
}

// Mask ProposedFixes content
if len(fixCopy.ProposedFixes) > 0 {
maskedFixes := make([]model.CodeSnippetFix, len(fixCopy.ProposedFixes))
for i, pf := range fixCopy.ProposedFixes {
maskedFixes[i] = pf
maskedFixes[i].Content = util.MaskSecretInMultiLineString(pf.Content)
}
fixCopy.ProposedFixes = maskedFixes
}

// Mask VulnerableCode content (defense-in-depth for consistency with JSON formatter)
if fixCopy.VulnerableCode != nil && fixCopy.VulnerableCode.Content != "" {
maskedVuln := *fixCopy.VulnerableCode
maskedVuln.Content = util.MaskSecretInMultiLineString(maskedVuln.Content)
fixCopy.VulnerableCode = &maskedVuln
}

// Mask PatchFiles content (map of filename -> patch content)
if len(fixCopy.PatchFiles) > 0 {
fixCopy.PatchFiles = util.MaskSecretsInStringMap(fixCopy.PatchFiles)
}

return &fixCopy
}

// formatFixSection formats the proposed fix section for display.
func formatFixSection(fix *model.Fix) string {
if fix == nil {
return ""
}

// Defense-in-depth: mask secrets in code-containing fields before display
fix = maskFixForDisplay(fix)

s := GetStyles()
var sb strings.Builder

Expand Down Expand Up @@ -1247,11 +1296,11 @@ func formatProposedSnippet(snippet model.CodeSnippetFix) string {
s := GetStyles()
var sb strings.Builder

sb.WriteString(fmt.Sprintf(" File: %s", snippet.FilePath))
fmt.Fprintf(&sb, " File: %s", snippet.FilePath)
if snippet.StartLine != nil && snippet.EndLine != nil {
sb.WriteString(fmt.Sprintf(" (lines %d-%d)", *snippet.StartLine, *snippet.EndLine))
fmt.Fprintf(&sb, " (lines %d-%d)", *snippet.StartLine, *snippet.EndLine)
} else if snippet.StartLine != nil {
sb.WriteString(fmt.Sprintf(" (line %d)", *snippet.StartLine))
fmt.Fprintf(&sb, " (line %d)", *snippet.StartLine)
}
sb.WriteString("\n")

Expand All @@ -1264,7 +1313,7 @@ func formatProposedSnippet(snippet model.CodeSnippetFix) string {
}
for i, line := range highlightedLines {
lineNum := s.ProposedLineNumber.Render(fmt.Sprintf("%4d", startLine+i))
sb.WriteString(fmt.Sprintf(" %s %s\n", lineNum, line))
fmt.Fprintf(&sb, " %s %s\n", lineNum, line)
}

return sb.String()
Expand Down Expand Up @@ -1649,7 +1698,18 @@ func buildTokenPositions(tokens []string) []int {
}

// tokenizeLine splits a line into word-like tokens preserving positions
// maxTokenizeLength is the maximum line length that will be tokenized.
// Lines exceeding this limit return a single-element slice to prevent
// unbounded memory allocation (CWE-770) from attacker-controlled input.
const maxTokenizeLength = 10 * 1024

func tokenizeLine(s string) []string {
// Defense against unbounded memory allocation (CWE-770):
// return early for extremely long lines to prevent slice growth
if len(s) > maxTokenizeLength {
return []string{s}
}

var tokens []string
var current strings.Builder

Expand Down Expand Up @@ -1716,7 +1776,7 @@ func formatDiffWithColorsStyled(patch string) string {
case "context":
// Handle ellipsis marker (omitted lines indicator)
if op.Line.OldNum == -1 && op.Line.NewNum == -1 {
sb.WriteString(fmt.Sprintf(" %s\n", s.MutedText.Render(op.Line.Content)))
fmt.Fprintf(&sb, " %s\n", s.MutedText.Render(op.Line.Content))
afterHunk = false
emptyCount = 0
continue
Expand Down
129 changes: 129 additions & 0 deletions internal/output/human_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -881,3 +881,132 @@ func TestFormatCodeSnippetWithFrame_RedactedSnippet(t *testing.T) {
})
}
}

func TestRenderFinding_MasksSecrets(t *testing.T) {
cli.InitColors(cli.ColorModeNever)
SyncColors()

tests := []struct {
name string
codeSnippet string
wantContains string
wantNotContain string
}{
{
name: "masks AWS key in snippet",
codeSnippet: `aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"`,
wantContains: "********",
wantNotContain: "wJalrXUtnFEMI",
},
{
name: "masks password in snippet",
codeSnippet: `password = "SuperSecretPassword123!"`,
wantContains: "********",
wantNotContain: "SuperSecretPassword123!",
},
{
name: "already masked content preserved",
codeSnippet: `password = ********[20-40]`,
wantContains: "********",
},
{
name: "normal code without secrets unchanged",
codeSnippet: `fmt.Println("hello world")`,
wantContains: `fmt.Println("hello world")`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
finding := model.Finding{
ID: "test-1",
Severity: model.SeverityHigh,
Title: "Test Finding",
CodeSnippet: tt.codeSnippet,
SnippetStartLine: 1,
StartLine: 1,
EndLine: 1,
}
renderFinding(&buf, finding, FormatOptions{})
output := buf.String()

if tt.wantContains != "" && !strings.Contains(output, tt.wantContains) {
t.Errorf("expected output to contain %q, got:\n%s", tt.wantContains, output)
}
if tt.wantNotContain != "" && strings.Contains(output, tt.wantNotContain) {
t.Errorf("expected output NOT to contain %q, got:\n%s", tt.wantNotContain, output)
}
})
}
}

func TestMaskFixForDisplay(t *testing.T) {
tests := []struct {
name string
fix *model.Fix
wantNotContain string
wantContains string
}{
{
name: "masks patch secrets",
fix: &model.Fix{
Patch: ptrString(`- password = "OldSecret123"`),
Explanation: "Remove hardcoded password",
},
wantNotContain: "OldSecret123",
wantContains: "********",
},
{
name: "masks proposed fix content",
fix: &model.Fix{
ProposedFixes: []model.CodeSnippetFix{
{
FilePath: "config.go",
Content: `api_key = "sk-1234567890abcdefghij"`,
},
},
},
wantNotContain: "sk-1234567890abcdefghij",
wantContains: "********",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
masked := maskFixForDisplay(tt.fix)

// Check patch
if masked.Patch != nil {
if tt.wantNotContain != "" && strings.Contains(*masked.Patch, tt.wantNotContain) {
t.Errorf("expected masked patch NOT to contain %q", tt.wantNotContain)
}
if tt.wantContains != "" && !strings.Contains(*masked.Patch, tt.wantContains) {
t.Errorf("expected masked patch to contain %q", tt.wantContains)
}
}

// Check proposed fixes
for _, pf := range masked.ProposedFixes {
if tt.wantNotContain != "" && strings.Contains(pf.Content, tt.wantNotContain) {
t.Errorf("expected masked proposed fix NOT to contain %q", tt.wantNotContain)
}
if tt.wantContains != "" && !strings.Contains(pf.Content, tt.wantContains) {
t.Errorf("expected masked proposed fix to contain %q", tt.wantContains)
}
}

// Verify original is not modified
if tt.fix.Patch != nil && strings.Contains(*tt.fix.Patch, "********") {
// This would indicate the original was modified
if tt.wantNotContain != "" && !strings.Contains(*tt.fix.Patch, tt.wantNotContain) {
t.Error("original fix should not be modified")
}
}
})
}
}

func ptrString(s string) *string {
return &s
}
Loading
Loading