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
23 changes: 6 additions & 17 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ test-cli-regressions: build
NYLAS_TEST_RATE_LIMIT_BURST=$(NYLAS_TEST_RATE_LIMIT_BURST) \
NYLAS_TEST_BINARY=$(CURDIR)/bin/nylas \
go test ./internal/cli/integration/... -tags=integration -v -timeout 10m -p 1 \
-run 'TestCLI_(InboundRemoved|InboxAliasRemoved|HelpOmitsInbound|AuthLoginRejectsInboxProvider|ConnectorSurfaces_HideInboxProvider|AdminConnectorsCreate_RejectsInboxProvider|AdminConnectorsShow_HidesInboxProvider)$$'
-run 'TestCLI_(InboundRemoved|InboxAliasRemoved|HelpOmitsInbound|AuthLoginRejectsInboxProvider|ConnectorSurfaces_HideInboxProvider|AdminConnectorsCreate_RejectsInboxProvider|AdminConnectorsShow_HidesInboxProvider|EmailSendValidationShowsFormattedSuggestion)$$'
@echo "✓ CLI regression checks passed"

# Agent integration checks require explicit credentials plus an agent domain so the lifecycle suites do not self-skip.
Expand Down Expand Up @@ -268,9 +268,10 @@ test-playwright:
@echo ""
@echo "Installing Playwright dependencies..."
@cd tests && npm install
@cd tests && npm run test:config
@echo ""
@echo "Running E2E tests..."
@cd tests && npx playwright test
@cd tests && UI_E2E_DEMO=true npx playwright test
@echo ""
@echo "✓ Playwright E2E tests complete!"
@echo " Report: tests/playwright-report/index.html"
Expand All @@ -294,7 +295,8 @@ test-playwright-ui:
}
@$(MAKE) --no-print-directory build
@cd tests && npm install
@cd tests && npx playwright test --project=ui-chromium
@cd tests && npm run test:config
@cd tests && UI_E2E_DEMO=true npx playwright test --project=ui-chromium
@echo "✓ UI E2E tests complete!"

test-playwright-interactive:
Expand All @@ -313,20 +315,7 @@ test-playwright-headed:
# Security Targets
# ============================================================================
security:
@echo "=== Security Scan ==="
@echo "Checking for hardcoded API keys..."
@grep -rE "nyk_v0[a-zA-Z0-9_]{20,}" --include="*.go" . | grep -v "_test.go" && echo "WARNING: Possible API key found!" || echo "✓ No API keys found"
@echo ""
@echo "Checking for credential patterns..."
@grep -rE "(api_key|password|secret)\s*=\s*\"[^\"]+\"" --include="*.go" . | grep -v "_test.go" | grep -v "mock.go" && echo "WARNING: Possible credentials found!" || echo "✓ No hardcoded credentials"
@echo ""
@echo "Checking for full credential logging..."
@grep -rE "fmt\.(Print|Fprint|Sprint).*[Aa]pi[Kk]ey[^:\[]" --include="*.go" . | grep -v "token.go" | grep -v "doctor.go" && echo "WARNING: Possible credential logging!" || echo "✓ No credential logging"
@echo ""
@echo "Checking staged files..."
@git diff --cached --name-only | grep -E "\.(env|key|pem|json)$$" && echo "WARNING: Sensitive file staged!" || echo "✓ No sensitive files staged"
@echo ""
@echo "=== Security scan complete ==="
@sh scripts/security-scan.sh .

# ============================================================================
# CI Targets
Expand Down
3 changes: 2 additions & 1 deletion cmd/nylas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/nylas/cli/internal/cli/audit"
"github.com/nylas/cli/internal/cli/auth"
"github.com/nylas/cli/internal/cli/calendar"
"github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/cli/config"
"github.com/nylas/cli/internal/cli/contacts"
"github.com/nylas/cli/internal/cli/dashboard"
Expand Down Expand Up @@ -68,7 +69,7 @@ func main() {

if err := cli.Execute(); err != nil {
cli.LogAuditError(err)
fmt.Fprintln(os.Stderr, "Error:", err)
fmt.Fprint(os.Stderr, common.FormatError(err))
os.Exit(1)
}
}
33 changes: 31 additions & 2 deletions internal/cli/integration/local_regressions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ func TestCLI_AuthRemove_UpdatesDefaultGrantAndConfig(t *testing.T) {
t.Fatalf("failed to set default grant: %v", err)
}

stdout, stderr, err := runCLIWithOverrides(30*time.Second, map[string]string{
stdout, stderr, err := runCLIWithOverrides(5*time.Second, map[string]string{
"XDG_CONFIG_HOME": configHome,
"XDG_CACHE_HOME": cacheHome,
"HOME": tempDir,
Expand Down Expand Up @@ -255,7 +255,7 @@ func TestCLI_AuthList_DoesNotRequireFileStorePassphrase(t *testing.T) {
configHome := filepath.Join(tempDir, "xdg")
cacheHome := filepath.Join(tempDir, "cache")

stdout, stderr, err := runCLIWithOverrides(30*time.Second, map[string]string{
stdout, stderr, err := runCLIWithOverrides(5*time.Second, map[string]string{
"XDG_CONFIG_HOME": configHome,
"XDG_CACHE_HOME": cacheHome,
"HOME": tempDir,
Expand All @@ -275,6 +275,35 @@ func TestCLI_AuthList_DoesNotRequireFileStorePassphrase(t *testing.T) {
}
}

func TestCLI_EmailSendValidationShowsFormattedSuggestion(t *testing.T) {
if testBinary == "" {
t.Skip("CLI binary not found")
}

tempDir := t.TempDir()
stdout, stderr, err := runCLIWithOverrides(5*time.Second, map[string]string{
"XDG_CONFIG_HOME": filepath.Join(tempDir, "xdg"),
"XDG_CACHE_HOME": filepath.Join(tempDir, "cache"),
"HOME": tempDir,
"NYLAS_API_KEY": "",
"NYLAS_GRANT_ID": "",
"NYLAS_DISABLE_KEYRING": "true",
"NYLAS_FILE_STORE_PASSPHRASE": "integration-test-file-store-passphrase",
}, "email", "send", "--to", "user@example.com", "--body", "hi", "--yes")
if err == nil {
t.Fatalf("expected email send validation to fail\nstdout: %s\nstderr: %s", stdout, stderr)
}
for _, want := range []string{
"subject is required",
"Suggestion:",
"Use --subject to specify the email subject",
} {
if !strings.Contains(stderr, want) {
t.Fatalf("stderr %q does not contain %q", stderr, want)
}
}
}

func TestCLI_AuthProviders_RequiresFileStorePassphrase(t *testing.T) {
if testBinary == "" {
t.Skip("CLI binary not found")
Expand Down
22 changes: 22 additions & 0 deletions internal/testutil/security_scan_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//go:build integration

package testutil

import (
"strings"
"testing"
)

func TestSecurityScanIntegrationFailsClosedForSensitiveJSON(t *testing.T) {
repoDir := initSecurityScanRepo(t)
writeFile(t, repoDir, "credentials.json", "{}\n")
runGit(t, repoDir, "add", "credentials.json")

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if !strings.Contains(output, "Sensitive tracked files found") {
t.Fatalf("security scan output %q does not mention tracked sensitive files", output)
}
}
228 changes: 228 additions & 0 deletions internal/testutil/security_scan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package testutil

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestSecurityScanFailsForHardcodedCredentials(t *testing.T) {
repoDir := initSecurityScanRepo(t)
writeFile(t, repoDir, "leak.go", `package main

var api_key = "not-a-real-secret"
`)

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if !strings.Contains(output, "Possible credentials found") {
t.Fatalf("security scan output %q does not mention credentials", output)
}
}

func TestSecurityScanRedactsMatchedSecretValues(t *testing.T) {
repoDir := initSecurityScanRepo(t)
secret := "nyk_v0abcdefghijklmnopqrstuvwx"
writeFile(t, repoDir, "leak.go", `package main

var apiKey = "`+secret+`"
`)

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if strings.Contains(output, secret) {
t.Fatalf("security scan leaked matched secret value in output:\n%s", output)
}
if !strings.Contains(output, "leak.go:3") {
t.Fatalf("security scan output %q does not include sanitized file location", output)
}
}

func TestSecurityScanScansNonGoCredentialFiles(t *testing.T) {
repoDir := initSecurityScanRepo(t)
secret := "demo-client-secret-12345"
writeFile(t, repoDir, "settings.yaml", "client_secret: "+secret+"\n")

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if !strings.Contains(output, "Possible credentials found") {
t.Fatalf("security scan output %q does not mention credentials", output)
}
if strings.Contains(output, secret) {
t.Fatalf("security scan leaked matched secret value in output:\n%s", output)
}
if !strings.Contains(output, "settings.yaml:1") {
t.Fatalf("security scan output %q does not include sanitized file location", output)
}
}

func TestSecurityScanScansTokenCredentialNames(t *testing.T) {
repoDir := initSecurityScanRepo(t)
secret := "tokenvalue1234567890"
writeFile(t, repoDir, "app.go", `package main

var access_token = "`+secret+`"
`)

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if !strings.Contains(output, "Possible credentials found") {
t.Fatalf("security scan output %q does not mention credentials", output)
}
if strings.Contains(output, secret) {
t.Fatalf("security scan leaked matched secret value in output:\n%s", output)
}
if !strings.Contains(output, "app.go:3") {
t.Fatalf("security scan output %q does not include sanitized file location", output)
}
}

func TestSecurityScanDetectsFormattedAPIKeyLogging(t *testing.T) {
repoDir := initSecurityScanRepo(t)
writeFile(t, repoDir, "logging.go", `package main

import "fmt"

func main() {
apiKey := loadAPIKey()
_ = fmt.Sprintf("loaded key %s", apiKey)
}

func loadAPIKey() string {
return "from-keyring"
}
`)

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if !strings.Contains(output, "Possible credential logging") {
t.Fatalf("security scan output %q does not mention credential logging", output)
}
if !strings.Contains(output, "logging.go:7") {
t.Fatalf("security scan output %q does not include sanitized file location", output)
}
if strings.Contains(output, "loaded key") || strings.Contains(output, "apiKey") {
t.Fatalf("security scan leaked matched logging line in output:\n%s", output)
}
}

func TestSecurityScanFailsForTrackedSensitiveFiles(t *testing.T) {
repoDir := initSecurityScanRepo(t)
writeFile(t, repoDir, ".env.production", "NYLAS_API_KEY=not-a-real-key\n")
runGit(t, repoDir, "add", ".env.production")

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if !strings.Contains(output, "Sensitive tracked files found") {
t.Fatalf("security scan output %q does not mention tracked sensitive files", output)
}
}

func TestSecurityScanFailsForTrackedSensitiveJSONFiles(t *testing.T) {
repoDir := initSecurityScanRepo(t)
writeFile(t, repoDir, "service-account.json", "{}\n")
runGit(t, repoDir, "add", "service-account.json")

output, err := runSecurityScan(t, repoDir)
if err == nil {
t.Fatalf("security scan passed, want failure. output:\n%s", output)
}
if !strings.Contains(output, "Sensitive tracked files found") {
t.Fatalf("security scan output %q does not mention tracked sensitive files", output)
}
}

func TestSecurityScanAllowsTestFixtures(t *testing.T) {
repoDir := initSecurityScanRepo(t)
writeFile(t, repoDir, "fixture_test.go", `package main

var api_key = "not-a-real-secret"
`)
writeFile(t, repoDir, "testdata/settings.yaml", "client_secret: fixture-secret-value\n")

output, err := runSecurityScan(t, repoDir)
if err != nil {
t.Fatalf("security scan failed, want success. output:\n%s", output)
}
}

func initSecurityScanRepo(t *testing.T) string {
t.Helper()

repoDir := t.TempDir()
runGit(t, repoDir, "init")
writeFile(t, repoDir, "main.go", "package main\n")
runGit(t, repoDir, "add", "main.go")
return repoDir
}

func runSecurityScan(t *testing.T, repoDir string) (string, error) {
t.Helper()

cmd := exec.Command("sh", securityScanScriptPath(t), repoDir)
output, err := cmd.CombinedOutput()
return string(output), err
}

func securityScanScriptPath(t *testing.T) string {
t.Helper()

dir, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd returned error: %v", err)
}

for {
candidate := filepath.Join(dir, "scripts", "security-scan.sh")
if _, err := os.Stat(candidate); err == nil {
return candidate
}

parent := filepath.Dir(dir)
if parent == dir {
t.Fatalf("could not find scripts/security-scan.sh from %s", dir)
}
dir = parent
}
}

func writeFile(t *testing.T, repoDir, name, contents string) {
t.Helper()

path := filepath.Join(repoDir, name)
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
t.Fatalf("os.MkdirAll returned error: %v", err)
}
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatalf("os.WriteFile returned error: %v", err)
}
}

func runGit(t *testing.T, repoDir string, args ...string) {
t.Helper()

cmd := exec.Command("git", args...)
cmd.Dir = repoDir
cmd.Env = append(os.Environ(),
"HOME="+t.TempDir(),
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output)
}
}
Loading
Loading