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
24 changes: 24 additions & 0 deletions cfcli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ 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
}

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

// runWithDependencies performs one cfcli invocation against the given dependencies and returns the
// process exit code:
//
// - 0: the scan found nothing, or the exclude subcommand succeeded
// - 1: any other failure — bad arguments, the dictionary, decryption, scanning, an exclusion
// update, or a write
// - 2: the scan reported at least one finding that no allow rule or exclusion covered
// - 3: a dictionary expression does not compile under Go's RE2 engine
//
// 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,
Expand Down Expand Up @@ -173,6 +191,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 +206,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
26 changes: 26 additions & 0 deletions cfcli/app_pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ func TestRunWithDependenciesRequiresPassword(t *testing.T) {
}
}

// 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 @@ -40,6 +43,9 @@ func TestRunWithDependenciesMapsInvalidRegexpToExitCodeThree(t *testing.T) {
}
}

// 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 Down Expand Up @@ -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 @@ -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 @@ -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 Down Expand Up @@ -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 @@ -182,6 +197,9 @@ func TestRunWithDependenciesAcceptsRelativeFolderPath(t *testing.T) {
}
}

// 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 Down Expand Up @@ -224,6 +242,11 @@ func TestRunWithDependenciesEnablesVerboseListOutput(t *testing.T) {
}
}

// 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
15 changes: 15 additions & 0 deletions cfcli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
"testing"
)

// A scan invocation with no positional argument, or with more than two, exits 1 with the usage
// text on stderr and nothing on stdout. The zero-value dependencies are part of the rule: the
// argument count has to be rejected before anything calls the nil homeDir behind the refresher.
func TestRunRejectsInvalidArgumentCounts(t *testing.T) {
tests := [][]string{{}, {"root", "list", "extra"}}
for _, args := range tests {
Expand All @@ -32,6 +35,9 @@ func TestRunRejectsInvalidArgumentCounts(t *testing.T) {
}
}

// A wrong argument count for either command form prints the whole usage block, scan form and
// exclude form together, so a user who mistyped one still sees the other. Add an entry to the
// expected list here when writeHelp gains a line.
func TestRunPrintsExpandedHelpForInsufficientArguments(t *testing.T) {
tests := [][]string{
nil,
Expand Down Expand Up @@ -62,6 +68,9 @@ func TestRunPrintsExpandedHelpForInsufficientArguments(t *testing.T) {
}
}

// A clean scan exits 0 and prints the dictionary status, path, and version, then the progress
// line, the file count, and the elapsed time, with nothing on stderr. The check compares the whole
// stdout transcript, so a line added anywhere in the run has to be added to want as well.
func TestRunReportsTotalFilesScanned(t *testing.T) {
root := initRepository(t)
writeTestFile(t, root, "a.txt", "a")
Expand Down Expand Up @@ -93,6 +102,10 @@ func TestRunReportsTotalFilesScanned(t *testing.T) {
}
}

// Every invocation whose file set cannot be established exits 1: a missing directory, a file in
// place of a directory, a directory outside any Git repository, and a file list that cannot be
// read. The stdout transcript stops after the progress line, and stderr names the operation that
// failed.
func TestRunReportsRuntimeErrors(t *testing.T) {
nonGitDirectory := t.TempDir()
rootFile := filepath.Join(t.TempDir(), "root.txt")
Expand Down Expand Up @@ -167,6 +180,8 @@ func TestRunReportsUnavailableGit(t *testing.T) {
}
}

// Every cacheState maps to its own status line. Add a case here when a state joins the cacheState
// constants, or the run prints a bare "TEXT: " line for it.
func TestDictionaryStatusMessage(t *testing.T) {
tests := []struct {
state cacheState
Expand Down
3 changes: 3 additions & 0 deletions cfcli/atomic_replace_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ package main

import "os"

// replaceFile renames source onto destination, replacing an existing destination in one step.
// Both paths must live on the same filesystem, because [os.Rename] has no copy fallback. Windows
// builds get the MoveFileExW implementation instead.
func replaceFile(source, destination string) error {
return os.Rename(source, destination)
}
4 changes: 4 additions & 0 deletions cfcli/atomic_replace_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import (
"unsafe"
)

// Values of the Win32 MOVEFILE_* flags that MoveFileExW takes in dwFlags.
const (
moveFileReplaceExisting = 0x1
moveFileWriteThrough = 0x8
)

var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW")

// replaceFile moves source onto destination with MoveFileExW, replacing an existing destination
// and returning once the change has reached the disk rather than the cache. Both paths must live
// on the same volume, because the call omits MOVEFILE_COPY_ALLOWED.
func replaceFile(source, destination string) error {
sourcePointer, err := syscall.UTF16PtrFromString(source)
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions cfcli/decrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ import (
"unicode/utf8"
)

// dictionaryIV is the fixed AES-CBC initialization vector the published dictionary is encrypted
// with. It, the salt and iteration count in [decryptDictionary], and the padding scheme all mirror
// the constants the Java class PasswordBasedEncryption decrypts the same dictionaries with; a change
// on either side makes every already published dictionary undecryptable.
var dictionaryIV = []byte{0, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 0}

// decryptDictionary decodes ciphertext as Base64 text, ignoring surrounding whitespace, and
// decrypts it under a key derived from password. The plaintext has to be valid UTF-8; anything else
// is an error, as is an empty password. The returned slice is a fresh copy.
func decryptDictionary(ciphertext []byte, password string) ([]byte, error) {
if password == "" {
return nil, fmt.Errorf("Dictionary password environment variable CYBER_FERRET_PASSWORD is not set")
Expand Down Expand Up @@ -48,6 +55,7 @@ func deriveKey(password, salt []byte, iterations, keyLength int) []byte {
return pbkdf2(password, salt, iterations, keyLength, sha256.New)
}

// pbkdf2 derives keyLength bytes, not bits, from password and salt using newHash as the HMAC hash.
func pbkdf2(password, salt []byte, iterations, keyLength int, newHash func() hash.Hash) []byte {
hashLength := newHash().Size()
blocks := (keyLength + hashLength - 1) / hashLength
Expand All @@ -74,6 +82,9 @@ func pbkdf2(password, salt []byte, iterations, keyLength int, newHash func() has
return derived[:keyLength]
}

// removePKCS7Padding strips the trailing padding and returns a prefix of plaintext that shares its
// backing array. It reports an error when the final byte is not a padding length within blockSize,
// or when the bytes it covers do not all repeat it.
func removePKCS7Padding(plaintext []byte, blockSize int) ([]byte, error) {
if len(plaintext) == 0 {
return nil, fmt.Errorf("invalid PKCS padding: plaintext is empty")
Expand Down
4 changes: 4 additions & 0 deletions cfcli/decrypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"testing"
)

// The fixed ciphertext decrypts under the same parameters the Java class PasswordBasedEncryption
// decrypts published dictionaries with. A failure here means a crypto parameter drifted from that
// class, so published dictionaries no longer decrypt; compare the IV, salt, iteration count, and key
// length against the Java constants rather than adjusting the vector.
func TestDecryptDictionaryMatchesJavaVector(t *testing.T) {
const ciphertext = "nzn8A/OYyg6yMGSNTqYRzsRQ4G/x0BkR2x9W7Wqq8Kw="

Expand Down
Loading
Loading