Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ nullify auth switch
|---------|-------------|
| `nullify pentest` | Run DAST pentest scans (cloud or local via Docker) |
| `nullify bughunt` | Cloud-based automated bug hunting |
| `nullify secrets scan-file` | Local, in-process secret scan of a single file or stdin buffer — no API call, no network. Powers the IDE extension's on-save scanning |

### CI/CD

Expand Down
4 changes: 4 additions & 0 deletions cmd/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ func init() {
// scpm's /scpm/dependencies/analyze. Wired at top level (not under
// apiCmd) so `nullify deps analyze` reads naturally in CI scripts.
commands.RegisterDepsAnalyzeCommand(rootCmd, getAPIClient)

// Local, in-process scanning: no API client, no network call, so it
// hangs off the top-level 'secrets' command rather than 'api secrets'.
commands.RegisterScanFileCommand(secretsCmd)
}

func setupLogger(ctx context.Context) (context.Context, error) {
Expand Down
19 changes: 19 additions & 0 deletions cmd/cli/cmd/secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package cmd

import (
"github.com/spf13/cobra"
)

// secretsCmd is the top-level home for local, in-process secret scanning
// (scan-file). The generated, API-backed secrets commands (allowlists,
// findings, ...) stay under 'api secrets' via RegisterSecretsCommands --
// this tree is for scanning that never calls the platform API.
var secretsCmd = &cobra.Command{
Use: "secrets",
Short: "Local secrets scanning",
Long: "Local, in-process secret scanning. Scans run entirely on this machine; no file content is sent to the Nullify API.",
}

func init() {
rootCmd.AddCommand(secretsCmd)
}
92 changes: 92 additions & 0 deletions internal/commands/scanfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Hand-written, not generated: scan-file has no OpenAPI operation to
// generate a client from (it never calls the Nullify API — see
// internal/scanfile's package doc for why, and for what engine it wraps).
// It follows the same RegisterXxxCommand(parent, ...) shape as this
// package's other hand-written files (context_push.go, pentest_bridge.go).
package commands

import (
"encoding/json"
"fmt"
"io"
"os"

"github.com/spf13/cobra"

"github.com/nullify-platform/cli/internal/scanfile"
)

// RegisterScanFileCommand adds the 'scan-file' subcommand to parent (the
// hand-written top-level 'secrets' command; see cmd/cli/cmd/secrets.go).
func RegisterScanFileCommand(parent *cobra.Command) {
var (
flagStdin bool
flagPath string
flagRepository string
)

cmd := &cobra.Command{
Use: "scan-file [path]",
Short: "Scan a single file (or stdin buffer) for secrets and print normalized findings",
Long: `Scan a single file for hardcoded secrets and print normalized findings as a
JSON array on stdout. All matching runs in-process: no network call is made
and file content never leaves the machine.

This is the fast-local scan the IDE extension runs on save. It scans only
the given file (or the buffer piped on stdin via --stdin), never a whole
repo, and emits engine-agnostic findings.

Examples:
# Scan a file on disk
nullify secrets scan-file ./config.go

# Scan an unsaved editor buffer (the editor pipes the buffer, --path labels it)
cat buffer.go | nullify secrets scan-file --stdin --path ./config.go`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var (
content []byte
path string
err error
)

if flagStdin {
if flagPath == "" {
return fmt.Errorf("--path is required when using --stdin")
}
if len(args) > 0 {
return fmt.Errorf("cannot combine a positional path with --stdin")
}
content, err = io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("failed to read stdin: %w", err)
}
path = flagPath
} else {
if len(args) != 1 {
return fmt.Errorf("exactly one file path is required (or use --stdin)")
}
path = args[0]
content, err = os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read %s: %w", path, err)
}
}

findings := scanfile.ScanContent(content, path, flagRepository)

enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
if err := enc.Encode(findings); err != nil {
return fmt.Errorf("failed to encode findings: %w", err)
}
return nil
},
}

cmd.Flags().BoolVar(&flagStdin, "stdin", false, "read the file content from stdin instead of disk")
cmd.Flags().StringVar(&flagPath, "path", "", "path to label the scanned content with (required with --stdin)")
cmd.Flags().StringVar(&flagRepository, "repository", "", "repository identifier folded into the correlation fingerprint")

parent.AddCommand(cmd)
}
105 changes: 105 additions & 0 deletions internal/commands/scanfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package commands

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"

"github.com/nullify-platform/cli/internal/scanfile"
)

func newTestRoot() *cobra.Command {
root := &cobra.Command{Use: "root"}
RegisterScanFileCommand(root)
return root
}

// fakeAWSKey builds an AWS-access-key-ID-shaped fixture via concatenation so
// the 20-character string never appears contiguous in this file's own
// source text and can't trip a secret scanner on this repo's own commits.
func fakeAWSKey() string {
return "AKIA" + "ABCDEFGHIJKLMNOP"
}

func TestRegisterScanFileCommandAddsScanFile(t *testing.T) {
root := newTestRoot()
cmd, _, err := root.Find([]string{"scan-file"})
require.NoError(t, err)
require.Equal(t, "scan-file", cmd.Name())
}

func TestScanFileCommandScansDiskFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.go")
content := "awsKey := \"" + fakeAWSKey() + "\"\n"
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))

root := newTestRoot()
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs([]string{"scan-file", path})

require.NoError(t, root.Execute())

var findings []scanfile.NormalizedFinding
require.NoError(t, json.Unmarshal(stdout.Bytes(), &findings))
require.NotEmpty(t, findings)
require.Equal(t, path, findings[0].File)
}

func TestScanFileCommandScansStdin(t *testing.T) {
content := "awsKey := \"" + fakeAWSKey() + "\"\n"

root := newTestRoot()
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetIn(strings.NewReader(content))
root.SetArgs([]string{"scan-file", "--stdin", "--path", "config.go", "--repository", "owner/repo"})

require.NoError(t, root.Execute())

var findings []scanfile.NormalizedFinding
require.NoError(t, json.Unmarshal(stdout.Bytes(), &findings))
require.NotEmpty(t, findings)
require.Equal(t, "config.go", findings[0].File)
}

func TestScanFileCommandRequiresPathWithStdin(t *testing.T) {
root := newTestRoot()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetIn(strings.NewReader("content"))
root.SetArgs([]string{"scan-file", "--stdin"})

require.Error(t, root.Execute())
}

func TestScanFileCommandRejectsPathAndStdinTogether(t *testing.T) {
root := newTestRoot()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetIn(strings.NewReader("content"))
root.SetArgs([]string{"scan-file", "--stdin", "--path", "a.go", "b.go"})

require.Error(t, root.Execute())
}

func TestScanFileCommandCleanFileProducesEmptyArray(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "main.go")
require.NoError(t, os.WriteFile(path, []byte("package main\n\nfunc main() {}\n"), 0o600))

root := newTestRoot()
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs([]string{"scan-file", path})

require.NoError(t, root.Execute())
require.Equal(t, "[]\n", stdout.String())
}
107 changes: 107 additions & 0 deletions internal/scanfile/baseline_rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package scanfile

import "regexp"

// baselineRules covers well-known, publicly documented credential formats
// that nullifyRules doesn't already handle (nullifyRules is Nullify's own
// additions layered on top of gitleaks' several-hundred-rule default set;
// this package doesn't vendor that default set -- see scanfile.go's package
// doc). It is not a claim of parity with gitleaks' breadth: it is a
// deliberately small, high-confidence set for the formats most likely to
// turn up in a file someone is actively editing.
func baselineRules() []Rule {
return []Rule{
{
ID: "aws-access-key-id",
Regex: regexp.MustCompile(`\b((?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b`),
Keywords: []string{"akia", "asia", "abia", "acca"},
},
{
ID: "aws-secret-access-key",
Regex: regexp.MustCompile(`(?i)aws[a-z0-9_.-]{0,20}?(?:secret|access)[a-z0-9_.-]{0,20}?key[a-z0-9_.-]{0,20}?\s*[:=]\s*['"]?([A-Za-z0-9/+=]{40})['"]?`),
Keywords: []string{"aws"},
MinEntropy: 4.0,
},
{
ID: "github-pat",
Regex: regexp.MustCompile(`\bgh[oprsu]_[A-Za-z0-9]{36,251}\b`),
Keywords: []string{"ghp_", "gho_", "ghr_", "ghs_", "ghu_"},
},
{
ID: "gitlab-pat",
Regex: regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{20}\b`),
Keywords: []string{"glpat-"},
},
{
ID: "slack-token",
Regex: regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,72}\b`),
Keywords: []string{"xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-"},
},
{
ID: "slack-webhook-url",
Regex: regexp.MustCompile(`https://hooks\.slack\.com/services/T[A-Za-z0-9]{6,}/B[A-Za-z0-9]{6,}/[A-Za-z0-9]{16,}`),
Keywords: []string{"hooks.slack.com"},
},
{
ID: "stripe-secret-key",
Regex: regexp.MustCompile(`\bsk_live_[A-Za-z0-9]{24,}\b`),
Keywords: []string{"sk_live_"},
},
{
ID: "stripe-restricted-key",
Regex: regexp.MustCompile(`\brk_live_[A-Za-z0-9]{24,}\b`),
Keywords: []string{"rk_live_"},
},
{
ID: "google-api-key",
Regex: regexp.MustCompile(`\bAIza[A-Za-z0-9_-]{35}\b`),
Keywords: []string{"AIza"},
},
{
ID: "pem-private-key",
Regex: regexp.MustCompile(`-----BEGIN\s?((?:RSA|EC|DSA|OPENSSH|PGP)\s)?PRIVATE KEY(\sBLOCK)?-----`),
Keywords: []string{"private key"},
},
{
ID: "npm-access-token",
Regex: regexp.MustCompile(`\bnpm_[A-Za-z0-9]{36}\b`),
Keywords: []string{"npm_"},
},
{
ID: "twilio-api-key",
Regex: regexp.MustCompile(`\bSK[0-9a-fA-F]{32}\b`),
Keywords: []string{"twilio"},
},
{
ID: "sendgrid-api-key",
Regex: regexp.MustCompile(`\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b`),
Keywords: []string{"sg."},
},
{
ID: "mailgun-api-key",
Regex: regexp.MustCompile(`\bkey-[a-f0-9]{32}\b`),
Keywords: []string{"mailgun", "key-"},
},
{
ID: "discord-bot-token",
Regex: regexp.MustCompile(`\b[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}\b`),
Keywords: []string{"discord"},
},
{
ID: "azure-storage-connection-string",
Regex: regexp.MustCompile(`(?i)DefaultEndpointsProtocol=https?;AccountName=[A-Za-z0-9]+;AccountKey=[A-Za-z0-9+/=]{20,};`),
Keywords: []string{"accountkey="},
},
{
ID: "heroku-api-key",
Regex: regexp.MustCompile(`(?i)heroku[a-z0-9_.-]{0,20}?(?:api)?[a-z0-9_.-]{0,20}?key\s*[:=]\s*['"]?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})['"]?`),
Keywords: []string{"heroku"},
},
{
ID: "generic-api-key",
Regex: regexp.MustCompile(`(?i)(?:secret|token|api[_-]?key|apikey|password|passwd|credential)\s*[:=]\s*['"]([A-Za-z0-9+/=_.-]{16,64})['"]`),
Keywords: []string{"secret", "token", "key", "password", "passwd", "credential"},
MinEntropy: 4.0,
},
}
}
Loading