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 .github/workflows/reusable-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,5 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.10.1
version: v2.12.2
args: --timeout=5m
11 changes: 11 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,18 @@ linters:
- gosec
- goconst
- misspell
exclusions:
rules:
# The test/ directory holds standalone integration-test scaffolding
# (e.g. mock-server.go, package main). goconst's ignore-tests only
# matches *_test.go files, so exempt the whole test/ tree explicitly —
# constant-extraction adds no value to throwaway test harnesses.
- path: ^test/
linters:
- goconst
settings:
goconst:
ignore-tests: true
gosec:
excludes:
- G705 # XSS via taint analysis - not applicable to CLI stderr output
Expand Down
4 changes: 2 additions & 2 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ func TestClient_StartIngest(t *testing.T) {
server := testutil.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
// Attempt to parse the multipart form - this reads the body
// The reader error will cause this to fail, but we still send a response
_ = r.ParseMultipartForm(32 << 20)
_ = r.ParseMultipartForm(32 << 20) //nolint:gosec // G120: test server with bounded 32MB limit
testutil.JSONResponse(t, w, http.StatusOK, model.IngestUploadResponse{ScanID: testScanID})
})

Expand Down Expand Up @@ -302,7 +302,7 @@ func TestClient_StartIngest(t *testing.T) {

t.Run("sends SBOM and VEX flags when set", func(t *testing.T) {
server := testutil.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
if err := r.ParseMultipartForm(32 << 20); err != nil { //nolint:gosec // G120: test server with bounded 32MB limit
t.Fatalf("Failed to parse multipart form: %v", err)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ func (c *AuthClient) Authenticate(ctx context.Context, clientID, clientSecret st
Region: regionHint,
}

// armis:ignore cwe:770 reason:credentials are bounded by caller input (CLI flags/env); request is a single fixed-structure JSON with context timeout
jsonBody, err := json.Marshal(reqBody)
// armis:ignore cwe:522 cwe:770 reason:marshaling credentials is intentional for the auth token endpoint; sent over HTTPS; bounded by caller input
jsonBody, err := json.Marshal(reqBody) //nolint:gosec // G117: ClientSecret is a credential field; marshaling is intentional for the auth token request
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/install/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func (ci *ClaudeInstaller) registerMarketplace(pluginDir string) error {

func (ci *ClaudeInstaller) registerPlugin(pluginDir string) error {
instFile := filepath.Join(ci.claudeDir, "plugins", "installed_plugins.json")
data := map[string]interface{}{"version": 2, "plugins": map[string]interface{}{}}
data := map[string]interface{}{jsonKeyVersion: 2, "plugins": map[string]interface{}{}}
// armis:ignore cwe:770 reason:reads bounded JSON config file from user's ~/.claude dir; not unbounded input
if b, err := os.ReadFile(filepath.Clean(instFile)); err == nil {
_ = json.Unmarshal(b, &data)
Expand Down
47 changes: 35 additions & 12 deletions internal/install/editors.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,23 @@ import (
"runtime"
)

const mcpServerName = "armis-appsec"
const (
mcpServerName = "armis-appsec"
maxEditorConfigSize = 10 << 20 // 10 MB — matches the settings file limit in hooks.go
)
Comment on lines +12 to +15

// JSON key constants shared across MCP/hook config builders.
const (
jsonKeyType = "type"
jsonKeyCommand = "command"
jsonKeyArgs = "args"
jsonKeyMatcher = "matcher"
jsonKeyHooks = "hooks"
jsonKeyTimeout = "timeout"
jsonKeyVersion = "version"

jsonTypeCommand = "command"
)

// EditorID identifies a supported editor.
type EditorID string
Expand Down Expand Up @@ -302,10 +318,10 @@ func registerVSCodeFormat(pluginDir, configFile string) error {
servers = make(map[string]interface{})
}
servers[mcpServerName] = map[string]interface{}{
"type": "stdio",
"command": venvPython(pluginDir),
"args": []string{filepath.Join(pluginDir, "server.py")},
"envFile": filepath.Join(pluginDir, ".env"),
jsonKeyType: "stdio",
jsonKeyCommand: venvPython(pluginDir),
jsonKeyArgs: []string{filepath.Join(pluginDir, "server.py")},
"envFile": filepath.Join(pluginDir, ".env"),
}
data["servers"] = servers

Expand All @@ -321,9 +337,9 @@ func registerZedFormat(pluginDir, configFile string) error {
servers = make(map[string]interface{})
}
servers[mcpServerName] = map[string]interface{}{
"command": map[string]interface{}{
"path": venvPython(pluginDir),
"args": []string{filepath.Join(pluginDir, "server.py")},
jsonKeyCommand: map[string]interface{}{
"path": venvPython(pluginDir),
jsonKeyArgs: []string{filepath.Join(pluginDir, "server.py")},
},
"settings": map[string]interface{}{},
}
Expand All @@ -334,16 +350,23 @@ func registerZedFormat(pluginDir, configFile string) error {

func stdServerEntry(pluginDir string) map[string]interface{} {
return map[string]interface{}{
"command": venvPython(pluginDir),
"args": []string{filepath.Join(pluginDir, "server.py")},
jsonKeyCommand: venvPython(pluginDir),
jsonKeyArgs: []string{filepath.Join(pluginDir, "server.py")},
}
}

func readJSONFileAsMap(path string) map[string]interface{} {
data := make(map[string]interface{})
clean := filepath.Clean(path)
// armis:ignore cwe:22 reason:path from filepath.Join with known base dirs; filepath.Clean applied
// armis:ignore cwe:253 reason:ReadFile error handled by err == nil guard; non-critical config read
if b, err := os.ReadFile(filepath.Clean(path)); err == nil {
// Reject non-regular files (devices, FIFOs): they can report Size()==0 yet
// stream unbounded data into os.ReadFile, defeating the size cap (CWE-770).
if info, err := os.Stat(clean); err != nil || !info.Mode().IsRegular() || info.Size() > maxEditorConfigSize {
return data
}
Comment on lines +360 to +366
// armis:ignore cwe:22 cwe:253 reason:path from filepath.Join with known base dirs; filepath.Clean applied; ReadFile error handled by err == nil guard
if b, err := os.ReadFile(clean); err == nil { //nolint:gosec
// armis:ignore cwe:502 cwe:770 reason:Go encoding/json into map[string]interface{} has no gadget/polymorphic deserialization; input is the user's own local editor config, size-bounded by the maxEditorConfigSize guard above
_ = json.Unmarshal(b, &data)
}
return data
Expand Down
51 changes: 51 additions & 0 deletions internal/install/editors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

Expand Down Expand Up @@ -287,6 +288,56 @@ func TestRegisterPreservesExistingConfig(t *testing.T) {
}
}

func TestRegisterSkipsOversizedConfig(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, "mcp.json")
pluginDir := filepath.Join(dir, "plugin")

// Write a valid-but-oversized config: a real mcpServers entry plus padding
// that pushes the file past maxEditorConfigSize. readJSONFileAsMap must skip
// reading it, so registration starts fresh and the padded entry is dropped.
padding := strings.Repeat("a", maxEditorConfigSize+1)
existing := map[string]interface{}{
"mcpServers": map[string]interface{}{
"other-server": map[string]interface{}{
"command": "node",
"args": []string{"server.js"},
},
},
"_pad": padding,
}
b, _ := json.Marshal(existing)
if err := os.WriteFile(configFile, b, 0o600); err != nil {
t.Fatalf("seeding config: %v", err)
}

configPathOverrides = map[EditorID]string{EditorCursor: configFile}
defer func() { configPathOverrides = nil }()

e, _ := EditorByID(EditorCursor)
if err := e.Register(pluginDir); err != nil {
t.Fatalf("Register() error: %v", err)
}

// The rewritten config must be valid JSON with armis-appsec registered.
var data map[string]interface{}
out, _ := os.ReadFile(filepath.Clean(configFile))
if err := json.Unmarshal(out, &data); err != nil {
t.Fatalf("rewritten config is not valid JSON: %v", err)
}
servers, ok := data["mcpServers"].(map[string]interface{})
if !ok {
t.Fatal("mcpServers key missing after register")
}
if _, ok := servers[mcpServerName]; !ok {
t.Error("armis-appsec not registered")
}
// The oversized file was skipped, so its prior contents were not merged in.
if _, ok := servers["other-server"]; ok {
t.Error("oversized config was read instead of skipped (other-server survived)")
}
}

func TestRegisterJetBrains(t *testing.T) {
dir := t.TempDir()
configFile := filepath.Join(dir, ".jb-mcp.json")
Expand Down
43 changes: 25 additions & 18 deletions internal/install/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ func InstallHooks() error {
func installHooksToFile(settingsPath string) error {
settings := make(map[string]interface{})

if info, err := os.Stat(settingsPath); err == nil && info.Size() > maxSettingsSize {
return fmt.Errorf("settings file too large (%d bytes): %s", info.Size(), settingsPath)
if info, err := os.Stat(settingsPath); err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("settings file is not a regular file: %s", settingsPath)
}
if info.Size() > maxSettingsSize {
return fmt.Errorf("settings file too large (%d bytes): %s", info.Size(), settingsPath)
}
}
// armis:ignore cwe:59 reason:settingsPath from filepath.Join(UserHomeDir, hardcoded ".claude/settings.json"); no user input
// armis:ignore cwe:59 cwe:770 reason:settingsPath from filepath.Join(UserHomeDir, hardcoded ".claude/settings.json"); regular-file + size bounded by guards above
data, err := os.ReadFile(settingsPath) //nolint:gosec // G304: path constructed from UserHomeDir + hardcoded segments
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading settings: %w", err)
Expand All @@ -47,7 +52,7 @@ func installHooksToFile(settingsPath string) error {
}
}

hooks, _ := settings["hooks"].(map[string]interface{})
hooks, _ := settings[jsonKeyHooks].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
}
Expand All @@ -57,11 +62,11 @@ func installHooksToFile(settingsPath string) error {
// Check if Armis hook already exists
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if matcher, _ := m["matcher"].(string); matcher == armisHookMatcher {
if innerHooks, _ := m["hooks"].([]interface{}); len(innerHooks) > 0 {
if matcher, _ := m[jsonKeyMatcher].(string); matcher == armisHookMatcher {
if innerHooks, _ := m[jsonKeyHooks].([]interface{}); len(innerHooks) > 0 {
for _, h := range innerHooks {
if hm, ok := h.(map[string]interface{}); ok {
if cmd, _ := hm["command"].(string); cmd != "" && isArmisHookCommand(cmd) {
if cmd, _ := hm[jsonKeyCommand].(string); cmd != "" && isArmisHookCommand(cmd) {
return nil // already installed
}
}
Expand All @@ -72,18 +77,18 @@ func installHooksToFile(settingsPath string) error {
}

armisHook := map[string]interface{}{
"matcher": armisHookMatcher,
"hooks": []map[string]interface{}{
jsonKeyMatcher: armisHookMatcher,
jsonKeyHooks: []map[string]interface{}{
{
"type": "command",
"command": "armis-cli scan repo --format json --no-progress --fail-on CRITICAL . >/dev/null 2>&1",
jsonKeyType: jsonTypeCommand,
jsonKeyCommand: "armis-cli scan repo --format json --no-progress --fail-on CRITICAL . >/dev/null 2>&1",
},
},
}

preToolUse = append(preToolUse, armisHook)
hooks["PreToolUse"] = preToolUse
settings["hooks"] = hooks
settings[jsonKeyHooks] = hooks

if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil {
return fmt.Errorf("creating settings directory: %w", err)
Expand All @@ -109,10 +114,12 @@ func removeHooksFromFile(settingsPath string) error {
return nil
}
return fmt.Errorf("reading settings: %w", err)
} else if !info.Mode().IsRegular() {
return fmt.Errorf("settings file is not a regular file: %s", settingsPath)
} else if info.Size() > maxSettingsSize {
return fmt.Errorf("settings file too large (%d bytes): %s", info.Size(), settingsPath)
}
// armis:ignore cwe:59 reason:settingsPath from filepath.Join(UserHomeDir, hardcoded ".claude/settings.json"); no user input
// armis:ignore cwe:59 cwe:770 reason:settingsPath from filepath.Join(UserHomeDir, hardcoded ".claude/settings.json"); regular-file + size bounded by guards above
data, err := os.ReadFile(settingsPath) //nolint:gosec // G304: path constructed from UserHomeDir + hardcoded segments
Comment on lines 119 to 123
if err != nil {
return fmt.Errorf("reading settings: %w", err)
Expand All @@ -123,7 +130,7 @@ func removeHooksFromFile(settingsPath string) error {
return fmt.Errorf("parsing settings: %w", err)
}

hooks, _ := settings["hooks"].(map[string]interface{})
hooks, _ := settings[jsonKeyHooks].(map[string]interface{})
if hooks == nil {
return nil
}
Expand Down Expand Up @@ -152,9 +159,9 @@ func removeHooksFromFile(settingsPath string) error {
}

if len(hooks) == 0 {
delete(settings, "hooks")
delete(settings, jsonKeyHooks)
} else {
settings["hooks"] = hooks
settings[jsonKeyHooks] = hooks
}

if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil {
Expand All @@ -164,10 +171,10 @@ func removeHooksFromFile(settingsPath string) error {
}

func isArmisHookEntry(m map[string]interface{}) bool {
innerHooks, _ := m["hooks"].([]interface{})
innerHooks, _ := m[jsonKeyHooks].([]interface{})
for _, h := range innerHooks {
if hm, ok := h.(map[string]interface{}); ok {
if cmd, _ := hm["command"].(string); isArmisHookCommand(cmd) {
if cmd, _ := hm[jsonKeyCommand].(string); isArmisHookCommand(cmd) {
return true
}
}
Expand Down
Loading
Loading