Skip to content

Commit a36e7e7

Browse files
micahleeGitHub Enterprise
authored andcommitted
Merge pull request #18 from Conjur-Enterprise/cnjr-11978-container-command-history
CNJR-11978: Add container command history to report
2 parents 1efa28e + 8407fd3 commit a36e7e7

File tree

5 files changed

+315
-0
lines changed

5 files changed

+315
-0
lines changed

.devops/gitleaks.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ config:
22
exclusions:
33
# These tests include mock secrets to test the command history sanitizing
44
- "pkg/checks/command_history_test.go"
5+
- "pkg/checks/container_command_history_test.go"
56
- "pkg/checks/sanitize/sanitizer_test.go"

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1717
report. CNJR-11977
1818
- Command history check that records the recent command history from the host
1919
machine to the inspect report. CNJR-11976
20+
- Container command history check that records the recent command history from
21+
inside a container to the inspect report. CNJR-11978
2022

2123
## [0.4.2] - 2025-03-25
2224

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Package checks defines all of the possible Conjur Inspect checks that can
2+
// be run.
3+
package checks
4+
5+
import (
6+
"fmt"
7+
"io"
8+
"strings"
9+
10+
"github.com/cyberark/conjur-inspect/pkg/check"
11+
"github.com/cyberark/conjur-inspect/pkg/checks/sanitize"
12+
"github.com/cyberark/conjur-inspect/pkg/container"
13+
"github.com/cyberark/conjur-inspect/pkg/log"
14+
)
15+
16+
// ContainerCommandHistory collects recent command history from inside a container
17+
type ContainerCommandHistory struct {
18+
Provider container.ContainerProvider
19+
}
20+
21+
// Describe provides a textual description of what this check gathers info on
22+
func (cch *ContainerCommandHistory) Describe() string {
23+
return fmt.Sprintf("%s command history", cch.Provider.Name())
24+
}
25+
26+
// Run performs the container command history collection
27+
func (cch *ContainerCommandHistory) Run(runContext *check.RunContext) []check.Result {
28+
// If there is no container ID, return
29+
if strings.TrimSpace(runContext.ContainerID) == "" {
30+
return []check.Result{}
31+
}
32+
33+
containerInstance := cch.Provider.Container(runContext.ContainerID)
34+
35+
// Execute tail command to get last 100 lines of bash history
36+
// Use a shell command that won't fail if the file doesn't exist
37+
stdout, stderr, err := containerInstance.Exec(
38+
"sh", "-c", "tail -n 100 /root/.bash_history 2>/dev/null || true",
39+
)
40+
if err != nil {
41+
return check.ErrorResult(
42+
cch,
43+
fmt.Errorf("failed to retrieve command history from container: %w", err),
44+
)
45+
}
46+
47+
// Read stdout from the command execution
48+
historyBytes, err := io.ReadAll(stdout)
49+
if err != nil {
50+
return check.ErrorResult(
51+
cch,
52+
fmt.Errorf("failed to read command history output: %w", err),
53+
)
54+
}
55+
56+
// Read any stderr for logging
57+
stderrBytes, _ := io.ReadAll(stderr)
58+
if len(stderrBytes) > 0 {
59+
log.Warn("stderr while reading container command history: %s", string(stderrBytes))
60+
}
61+
62+
historyContent := string(historyBytes)
63+
64+
// Sanitize the history to redact sensitive values
65+
redactor := sanitize.NewRedactor()
66+
sanitizedContent := redactor.RedactLines(historyContent)
67+
68+
// Save history to output store
69+
outputFileName := fmt.Sprintf(
70+
"%s-command-history.txt",
71+
strings.ToLower(cch.Provider.Name()),
72+
)
73+
_, err = runContext.OutputStore.Save(
74+
outputFileName,
75+
strings.NewReader(sanitizedContent),
76+
)
77+
if err != nil {
78+
log.Warn("failed to save container command history output: %s", err)
79+
}
80+
81+
// Return empty results on success (output is saved)
82+
return []check.Result{}
83+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package checks
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
"testing"
8+
9+
"github.com/cyberark/conjur-inspect/pkg/test"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestContainerCommandHistoryDescribe(t *testing.T) {
15+
provider := &test.ContainerProvider{}
16+
cch := &ContainerCommandHistory{Provider: provider}
17+
assert.Equal(t, "Test Container Provider command history", cch.Describe())
18+
}
19+
20+
func TestContainerCommandHistoryRunSuccessful(t *testing.T) {
21+
// Create a mock container provider with bash history
22+
bashHistory := strings.Join([]string{
23+
"ls -la",
24+
"cd /tmp",
25+
"echo 'hello world'",
26+
}, "\n")
27+
28+
provider := &test.ContainerProvider{
29+
ExecResponses: map[string]test.ExecResponse{
30+
"sh -c tail -n 100 /root/.bash_history 2>/dev/null || true": {
31+
Stdout: strings.NewReader(bashHistory),
32+
Stderr: strings.NewReader(""),
33+
Error: nil,
34+
},
35+
},
36+
}
37+
38+
cch := &ContainerCommandHistory{Provider: provider}
39+
runContext := test.NewRunContext("container123")
40+
results := cch.Run(&runContext)
41+
42+
// Should return empty results on success
43+
assert.Empty(t, results)
44+
45+
// Verify the file was saved to the output store
46+
items, err := runContext.OutputStore.Items()
47+
require.NoError(t, err)
48+
require.Len(t, items, 1)
49+
info, err := items[0].Info()
50+
require.NoError(t, err)
51+
// Provider name is "Test Container Provider" and gets ToLower() -> "test container provider"
52+
assert.Equal(t, "test container provider-command-history.txt", info.Name())
53+
}
54+
55+
func TestContainerCommandHistorySanitization(t *testing.T) {
56+
// Create history with sensitive data that should be redacted
57+
bashHistory := strings.Join([]string{
58+
"mysql -u root -pMyPassword123",
59+
"api_key=sk_live_abc123xyz",
60+
"token=secret_token_value",
61+
"echo 'normal command'",
62+
}, "\n")
63+
64+
provider := &test.ContainerProvider{
65+
ExecResponses: map[string]test.ExecResponse{
66+
"sh -c tail -n 100 /root/.bash_history 2>/dev/null || true": {
67+
Stdout: strings.NewReader(bashHistory),
68+
Stderr: strings.NewReader(""),
69+
Error: nil,
70+
},
71+
},
72+
}
73+
74+
cch := &ContainerCommandHistory{Provider: provider}
75+
runContext := test.NewRunContext("container123")
76+
results := cch.Run(&runContext)
77+
78+
assert.Empty(t, results)
79+
80+
// Verify sanitization occurred
81+
items, err := runContext.OutputStore.Items()
82+
require.NoError(t, err)
83+
require.Len(t, items, 1)
84+
85+
outputStoreItemReader, cleanup, err := items[0].Open()
86+
defer cleanup()
87+
require.NoError(t, err)
88+
89+
savedContent, err := io.ReadAll(outputStoreItemReader)
90+
require.NoError(t, err)
91+
92+
// Verify sensitive data was redacted
93+
assert.NotContains(t, string(savedContent), "sk_live_abc123xyz")
94+
assert.NotContains(t, string(savedContent), "secret_token_value")
95+
// Normal command should still be present
96+
assert.Contains(t, string(savedContent), "echo 'normal command'")
97+
// The api_key and token patterns should have been redacted with [REDACTED]
98+
assert.Contains(t, string(savedContent), "[REDACTED]")
99+
}
100+
101+
func TestContainerCommandHistoryEmptyContainerID(t *testing.T) {
102+
provider := &test.ContainerProvider{}
103+
cch := &ContainerCommandHistory{Provider: provider}
104+
runContext := test.NewRunContext("")
105+
results := cch.Run(&runContext)
106+
107+
// Should return empty results when container ID is empty
108+
assert.Empty(t, results)
109+
110+
// Verify nothing was saved
111+
items, err := runContext.OutputStore.Items()
112+
require.NoError(t, err)
113+
assert.Len(t, items, 0)
114+
}
115+
116+
func TestContainerCommandHistoryExecError(t *testing.T) {
117+
provider := &test.ContainerProvider{
118+
ExecResponses: map[string]test.ExecResponse{
119+
"sh -c tail -n 100 /root/.bash_history 2>/dev/null || true": {
120+
Stdout: nil,
121+
Stderr: nil,
122+
Error: fmt.Errorf("file not found"),
123+
},
124+
},
125+
}
126+
127+
cch := &ContainerCommandHistory{Provider: provider}
128+
runContext := test.NewRunContext("container123")
129+
results := cch.Run(&runContext)
130+
131+
// Should return error result
132+
require.Len(t, results, 1)
133+
assert.Equal(t, "Test Container Provider command history", results[0].Title)
134+
assert.Contains(t, results[0].Message, "failed to retrieve command history from container")
135+
}
136+
137+
func TestContainerCommandHistoryNoExecResponse(t *testing.T) {
138+
// Provider with empty ExecResponses - should error on unknown command
139+
provider := &test.ContainerProvider{
140+
ExecResponses: map[string]test.ExecResponse{},
141+
}
142+
143+
cch := &ContainerCommandHistory{Provider: provider}
144+
runContext := test.NewRunContext("container123")
145+
results := cch.Run(&runContext)
146+
147+
// Should return error result
148+
require.Len(t, results, 1)
149+
assert.Equal(t, "Test Container Provider command history", results[0].Title)
150+
assert.Contains(t, results[0].Message, "failed to retrieve command history from container")
151+
}
152+
153+
func TestContainerCommandHistoryEmptyHistory(t *testing.T) {
154+
// Test with empty history file
155+
provider := &test.ContainerProvider{
156+
ExecResponses: map[string]test.ExecResponse{
157+
"sh -c tail -n 100 /root/.bash_history 2>/dev/null || true": {
158+
Stdout: strings.NewReader(""),
159+
Stderr: strings.NewReader(""),
160+
Error: nil,
161+
},
162+
},
163+
}
164+
165+
cch := &ContainerCommandHistory{Provider: provider}
166+
runContext := test.NewRunContext("container123")
167+
results := cch.Run(&runContext)
168+
169+
// Should return empty results (empty history is valid)
170+
assert.Empty(t, results)
171+
172+
// Verify empty file was saved
173+
items, err := runContext.OutputStore.Items()
174+
require.NoError(t, err)
175+
require.Len(t, items, 1)
176+
}
177+
178+
func TestContainerCommandHistoryStderr(t *testing.T) {
179+
// Test that stderr is logged but doesn't cause failure
180+
bashHistory := strings.Join([]string{
181+
"ls -la",
182+
"pwd",
183+
}, "\n")
184+
185+
provider := &test.ContainerProvider{
186+
ExecResponses: map[string]test.ExecResponse{
187+
"sh -c tail -n 100 /root/.bash_history 2>/dev/null || true": {
188+
Stdout: strings.NewReader(bashHistory),
189+
Stderr: strings.NewReader("some warning message"),
190+
Error: nil,
191+
},
192+
},
193+
}
194+
195+
cch := &ContainerCommandHistory{Provider: provider}
196+
runContext := test.NewRunContext("container123")
197+
results := cch.Run(&runContext)
198+
199+
// Should still succeed despite stderr
200+
assert.Empty(t, results)
201+
202+
// Verify the file was saved
203+
items, err := runContext.OutputStore.Items()
204+
require.NoError(t, err)
205+
require.Len(t, items, 1)
206+
}
207+
208+
func TestContainerCommandHistoryWhitespaceContainerID(t *testing.T) {
209+
provider := &test.ContainerProvider{}
210+
cch := &ContainerCommandHistory{Provider: provider}
211+
runContext := test.NewRunContext(" ")
212+
results := cch.Run(&runContext)
213+
214+
// Should return empty results when container ID is only whitespace
215+
assert.Empty(t, results)
216+
217+
// Verify nothing was saved
218+
items, err := runContext.OutputStore.Items()
219+
require.NoError(t, err)
220+
assert.Len(t, items, 0)
221+
}

pkg/cmd/default_report.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ func defaultReportSections() []report.Section {
108108
Provider: &container.PodmanProvider{},
109109
},
110110

111+
// Container command history
112+
&checks.ContainerCommandHistory{
113+
Provider: &container.DockerProvider{},
114+
},
115+
&checks.ContainerCommandHistory{
116+
Provider: &container.PodmanProvider{},
117+
},
118+
111119
// Container config
112120
&checks.ConjurConfig{
113121
Provider: &container.DockerProvider{},

0 commit comments

Comments
 (0)