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 cfcli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ the stop.

Exit codes are:

- `0`: The scan completes without findings.
- `0`: The scan completes without findings, or the `exclude` command succeeds.
- `1`: Arguments, cache access, password lookup, decryption, or another runtime operation fails.
- `2`: At least one non-allowed match is found.
- `3`: A dictionary expression is incompatible with Go RE2.
Expand Down
57 changes: 38 additions & 19 deletions cfcli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ import (
"time"
)

// appDependencies holds the process-level services runWithDependencies would otherwise reach for
// directly, so a test can supply a temporary home, a fixed clock, and a known password. A nil now
// falls back to [time.Now]. A nil getenv leaves the dictionary password empty, which decryption
// then rejects.
type appDependencies struct {
refresher cacheRefresher
getenv func(string) string
now func() time.Time
}

func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
// run performs one cfcli invocation with the live dictionary endpoint, the process environment,
// and the wall clock. It returns the exit status documented on [runWithDependencies].
func run(ctx context.Context, args []string, stdout, stderr io.Writer) exitStatus {
return runWithDependencies(ctx, args, stdout, stderr, appDependencies{
refresher: cacheRefresher{
client: &http.Client{},
Expand All @@ -31,12 +37,19 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
})
}

// runWithDependencies performs one cfcli invocation against the given dependencies and returns
// [exitClean], [exitFailure], [exitFindings], or [exitBadExpression], each of which documents the
// condition it reports.
//
// Scan output goes to stdout. The usage help, every fatal message, and the dictionary warnings go
// to stderr; the --print=details warning is the one warning written to stdout. An "exclude" first
// argument is dispatched to [runExcludeCommand] before any option is parsed.
func runWithDependencies(
ctx context.Context,
args []string,
stdout, stderr io.Writer,
dependencies appDependencies,
) int {
) exitStatus {
if isExcludeCommand(args) {
return runExcludeCommand(args, stdout, stderr)
}
Expand All @@ -50,35 +63,35 @@ func runWithDependencies(
writeFatal(errorOutput, "%v", err)
}
_ = writeHelp(errorOutput)
return 1
return exitFailure
}
if parsed.printDetails && parsed.mode != modeQuick {
if err := output.text(
"Warning: --print=details applies only to --mode=quick and will be ignored.",
); err != nil {
writeFatal(errorOutput, "Cannot write print option warning: %v", err)
return 1
return exitFailure
}
}

cache, err := dependencies.refresher.refresh(ctx, errorOutput)
if err != nil {
writeFatal(errorOutput, "Cannot prepare dictionary cache: %v", err)
return 1
return exitFailure
}
cachePath := cache.path
if err := output.text("%s", dictionaryStatusMessage(cache.state)); err != nil {
writeFatal(errorOutput, "Cannot write dictionary status: %v", err)
return 1
return exitFailure
}
if err := output.text("Dictionary path: %s", dictionaryDisplayPath(cache.path, cache.home)); err != nil {
writeFatal(errorOutput, "Cannot write dictionary path: %v", err)
return 1
return exitFailure
}
encrypted, err := os.ReadFile(cachePath)
if err != nil {
writeFatal(errorOutput, "Cannot read dictionary cache %q: %v", cachePath, err)
return 1
return exitFailure
}
password := ""
if dependencies.getenv != nil {
Expand All @@ -87,7 +100,7 @@ func runWithDependencies(
plaintext, err := decryptDictionary(encrypted, password)
if err != nil {
writeFatal(errorOutput, "%v", err)
return 1
return exitFailure
}

loaded, err := loadDictionary(plaintext, errorOutput)
Expand All @@ -99,14 +112,14 @@ func runWithDependencies(
dictionaryPath = absolutePath
}
writeFatal(errorOutput, "Cannot compile dictionary regexp from \"%s\": %v", dictionaryPath, compileError)
return 3
return exitBadExpression
}
writeFatal(errorOutput, "Cannot load dictionary: %v", err)
return 1
return exitFailure
}
if err := output.text("Dictionary version: %s", loaded.version); err != nil {
writeFatal(errorOutput, "Cannot write dictionary version: %v", err)
return 1
return exitFailure
}

now := dependencies.now
Expand All @@ -116,14 +129,14 @@ func runWithDependencies(
startedAt := now()
if err := output.text("Scanning is in progress. Please wait."); err != nil {
writeFatal(errorOutput, "Cannot write scanning progress: %v", err)
return 1
return exitFailure
}

exclusions := loadExclusions(parsed.root, errorOutput)
files, err := selectFiles(ctx, parsed.root, parsed.listPath)
if err != nil {
writeFatal(errorOutput, "%v", err)
return 1
return exitFailure
}
result, err := scanFilesConfigured(
parsed.root,
Expand All @@ -138,21 +151,21 @@ func runWithDependencies(
)
if err != nil {
writeFatal(errorOutput, "Cannot scan files: %v", err)
return 1
return exitFailure
}
if err := output.text("Total files scanned %d", result.scannedCount); err != nil {
writeFatal(errorOutput, "Cannot write scanned file count: %v", err)
return 1
return exitFailure
}
elapsed := now().Sub(startedAt).Seconds()
if err := output.text("Scanning is finished in %.3f seconds.", elapsed); err != nil {
writeFatal(errorOutput, "Cannot write scanning duration: %v", err)
return 1
return exitFailure
}
if result.found {
return 2
return exitFindings
}
return 0
return exitClean
}

func writeHelp(output *lineOutput) error {
Expand All @@ -173,6 +186,9 @@ func writeHelp(output *lineOutput) error {
return nil
}

// writeFatal writes a message for a failure the caller exits on. It discards the write error:
// every caller already returns a nonzero exit code, and the only stream left to report a failed
// write on is the one that just failed.
func writeFatal(output *lineOutput, format string, args ...any) {
_ = output.text(format, args...)
}
Expand All @@ -185,6 +201,9 @@ func dictionaryStatusMessage(state cacheState) string {
}[state]
}

// dictionaryDisplayPath renders path for a human reader: a path inside home becomes "~/" followed
// by the remainder, and anything else is cleaned and left as it is. The result always uses "/"
// separators, including on Windows.
func dictionaryDisplayPath(path, home string) string {
relative, err := filepath.Rel(home, path)
outsideHome := relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator))
Expand Down
44 changes: 35 additions & 9 deletions cfcli/app_pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ func TestRunWithDependenciesRequiresPassword(t *testing.T) {

exitCode := runWithDependencies(context.Background(), []string{root}, &stdout, &stderr, dependencies)

if exitCode != 1 || !strings.Contains(stderr.String(), "TEXT: Dictionary password") {
if exitCode != exitFailure || !strings.Contains(stderr.String(), "TEXT: Dictionary password") {
t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String())
}
}

// A dictionary pattern that Go's RE2 engine rejects ends the run with exit code 3 rather than the
// generic 1, so a caller can tell a broken dictionary from a broken environment. The case here is
// a lookbehind, which RE2 has no equivalent for.
func TestRunWithDependenciesMapsInvalidRegexpToExitCodeThree(t *testing.T) {
root := initRepository(t)
dependencies := testAppDependencies(t, "BAD(regexp)=(?<=token)value\n", "test-password")
Expand All @@ -35,11 +38,14 @@ func TestRunWithDependenciesMapsInvalidRegexpToExitCodeThree(t *testing.T) {

exitCode := runWithDependencies(context.Background(), []string{root}, &stdout, &stderr, dependencies)

if exitCode != 3 || !strings.Contains(stderr.String(), "TEXT: Cannot compile dictionary regexp") {
if exitCode != exitBadExpression || !strings.Contains(stderr.String(), "TEXT: Cannot compile dictionary regexp") {
t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String())
}
}

// Quick mode stops at the first finding: it emits that one found event, exits 2, and prints the
// hint that names the flag combination for exclusion commands rather than the commands themselves.
// It still prints the scan summary the other modes print.
func TestRunWithDependenciesQuickReturnsTwoOnFirstFinding(t *testing.T) {
root := initRepository(t)
writeTestFile(t, root, "secret.txt", "contains SECRET")
Expand All @@ -49,7 +55,7 @@ func TestRunWithDependenciesQuickReturnsTwoOnFirstFinding(t *testing.T) {

exitCode := runWithDependencies(context.Background(), []string{"--mode=quick", root}, &stdout, &stderr, dependencies)

if exitCode != 2 || !strings.Contains(
if exitCode != exitFindings || !strings.Contains(
stdout.String(),
`JSON: {"type":"found","key":"SECRET","found":"SECRET","line":1,"file":"secret.txt"}`,
) || !strings.Contains(
Expand All @@ -70,6 +76,8 @@ func TestRunWithDependenciesQuickReturnsTwoOnFirstFinding(t *testing.T) {
}
}

// Quick mode with --print=details prints a ready-to-run exclude command for each supported shell
// in place of the hint, never both.
func TestRunWithDependenciesQuickPrintDetailsPrintsExclusionCommands(t *testing.T) {
root := initRepository(t)
writeTestFile(t, root, "secret.txt", "contains SECRET")
Expand All @@ -85,7 +93,7 @@ func TestRunWithDependenciesQuickPrintDetailsPrintsExclusionCommands(t *testing.
dependencies,
)

if exitCode != 2 ||
if exitCode != exitFindings ||
!strings.Contains(stdout.String(), "TEXT: POSIX: cfcli exclude ") ||
!strings.Contains(stdout.String(), "TEXT: PowerShell: cfcli exclude ") ||
!strings.Contains(stdout.String(), "TEXT: cmd.exe: cfcli exclude ") ||
Expand All @@ -94,6 +102,9 @@ func TestRunWithDependenciesQuickPrintDetailsPrintsExclusionCommands(t *testing.
}
}

// The default JSON mode reports a finding and keeps going, so the scan summary and the dictionary
// status lines are all present alongside the found event, and exit code 2 arrives at the end
// rather than in place of the rest of the output.
func TestRunWithDependenciesJSONCompletesAndReturnsTwo(t *testing.T) {
root := initRepository(t)
writeTestFile(t, root, "secret.txt", "SECRET")
Expand All @@ -103,7 +114,7 @@ func TestRunWithDependenciesJSONCompletesAndReturnsTwo(t *testing.T) {

exitCode := runWithDependencies(context.Background(), []string{root}, &stdout, &stderr, dependencies)

if exitCode != 2 || !strings.Contains(stdout.String(), `JSON: {"type":"found","key":"SECRET"`) ||
if exitCode != exitFindings || !strings.Contains(stdout.String(), `JSON: {"type":"found","key":"SECRET"`) ||
strings.Contains(stdout.String(), "cfcli exclude ") ||
!strings.HasSuffix(
stdout.String(),
Expand All @@ -114,6 +125,8 @@ func TestRunWithDependenciesJSONCompletesAndReturnsTwo(t *testing.T) {
assertCurrentDictionaryOutput(t, stdout.String())
}

// Outside quick mode --print=details is ignored with a warning on stdout, and no exclude command
// is printed. The scan itself runs to completion and still reports the finding through exit 2.
func TestRunWithDependenciesPrintDetailsWarnsOutsideQuickMode(t *testing.T) {
root := initRepository(t)
writeTestFile(t, root, "secret.txt", "SECRET")
Expand All @@ -129,7 +142,7 @@ func TestRunWithDependenciesPrintDetailsWarnsOutsideQuickMode(t *testing.T) {
dependencies,
)

if exitCode != 2 ||
if exitCode != exitFindings ||
!strings.Contains(
stdout.String(),
"TEXT: Warning: --print=details applies only to --mode=quick and will be ignored.\n",
Expand All @@ -147,6 +160,8 @@ func assertCurrentDictionaryOutput(t *testing.T, output string) {
}
}

// FOLDER_PATH may be given relative to the working directory. The test changes the process working
// directory to reach that case, so it cannot run in parallel with anything else in the package.
func TestRunWithDependenciesAcceptsRelativeFolderPath(t *testing.T) {
root := initRepository(t)
writeTestFile(t, root, "safe.txt", "safe")
Expand Down Expand Up @@ -174,14 +189,17 @@ func TestRunWithDependenciesAcceptsRelativeFolderPath(t *testing.T) {
dependencies,
)

if exitCode != 0 || !strings.HasSuffix(
if exitCode != exitClean || !strings.HasSuffix(
stdout.String(),
"TEXT: Total files scanned 1\nTEXT: Scanning is finished in 1.234 seconds.\n",
) {
t.Fatalf("exit code = %d, stdout = %q, stderr = %q", exitCode, stdout.String(), stderr.String())
}
}

// A match covered by a grand-report exclusion is reported as an excluded event instead of a found
// one, so the run still exits 0. The report file itself stays eligible for scanning, which is why
// the count is two for one written file.
func TestRunWithDependenciesAppliesGrandReportExclusions(t *testing.T) {
root := initRepository(t)
writeTestFile(t, root, "secret.txt", "SECRET")
Expand All @@ -193,7 +211,7 @@ func TestRunWithDependenciesAppliesGrandReportExclusions(t *testing.T) {

exitCode := runWithDependencies(context.Background(), []string{root}, &stdout, &stderr, dependencies)

if exitCode != 0 || !strings.Contains(
if exitCode != exitClean || !strings.Contains(
stdout.String(),
`JSON: {"type":"excluded","key":"SECRET","found":"SECRET","line":1,"file":"secret.txt"}`,
) {
Expand All @@ -219,11 +237,16 @@ func TestRunWithDependenciesEnablesVerboseListOutput(t *testing.T) {
dependencies,
)

if exitCode != 0 || !strings.Contains(stdout.String(), `JSON: {"type":"list","file":"safe.txt"}`) {
if exitCode != exitClean || !strings.Contains(stdout.String(), `JSON: {"type":"list","file":"safe.txt"}`) {
t.Fatalf("exit code = %d, stdout = %q, stderr = %q", exitCode, stdout.String(), stderr.String())
}
}

// testAppDependencies builds dependencies for an offline run. The dictionary cache it writes under
// a temporary home is fresh, so the refresher serves it without touching the nil HTTP client and
// the run reports the dictionary as up to date. The clock returns a fixed instant, then one
// 1234 ms later for every call after the first, which is the "1.234 seconds" the expected
// transcripts contain.
func testAppDependencies(t *testing.T, plaintext, password string) appDependencies {
t.Helper()
home := t.TempDir()
Expand Down Expand Up @@ -253,6 +276,9 @@ func testAppDependencies(t *testing.T, plaintext, password string) appDependenci
}
}

// encryptDictionaryForTest produces what decryptDictionary expects: PKCS#7-padded AES-CBC under a
// key derived from password with the same salt, iteration count, and IV, then Base64. Change it in
// step with decryptDictionary, or every test that loads a dictionary fails while decrypting.
func encryptDictionaryForTest(t *testing.T, plaintext []byte, password string) string {
t.Helper()
key := deriveKey([]byte(password), []byte("bsd87918hediu"), 65536, 32)
Expand Down
Loading
Loading