Skip to content
Open
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: 2 additions & 0 deletions dummy_config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Mock database configuration
const DUMMY_AWS_KEY = "AKIAIOSFODNN7EXAMPLE";
53 changes: 42 additions & 11 deletions internal/appcore/review_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1301,56 +1301,87 @@ func runReviewWithOptions(opts reviewopts.Options) error {
return nil
}

var standardTokenExclusions = []string{
`:(exclude)package-lock.json`,
`:(exclude)yarn.lock`,
`:(exclude)pnpm-lock.yaml`,
`:(exclude)go.sum`,
`:(exclude)Cargo.lock`,
`:(exclude)poetry.lock`,
`:(exclude)Gemfile.lock`,
}

// collectDiffWithOptions securely intercepts diff collection by filtering out lockfiles globally and running the local Offline Security scanner synchronously before any payload bubbles backwards gracefully.
func collectDiffWithOptions(opts reviewopts.Options) ([]byte, error) {
diffContent, err := collectDiffWithOptionsRaw(opts)
if err != nil {
return nil, err
}

// [Offline PII/Secret Pre-Flight Scanner]
// Run offline secret scanning right as the subsystem collects bytes, protecting BOTH standard --review AND --vouch.
if err := ScanDiffForSecrets(diffContent); err != nil {
fmt.Fprintf(os.Stderr, "\n[FATAL] %v\n", err)
return nil, cli.Exit(err.Error(), 1)
}

return diffContent, nil
}

func collectDiffWithOptionsRaw(opts reviewopts.Options) ([]byte, error) {
diffSource := opts.DiffSource
verbose := opts.Verbose

switch diffSource {
case "staged":
if verbose {
log.Println("Collecting staged changes...")
log.Println("Collecting staged changes (excluding standard lockfiles)...")
}
return reviewapi.RunGitCommand("diff", "--staged")
args := append([]string{"diff", "--staged", "--", "."}, standardTokenExclusions...)
return reviewapi.RunGitCommand(args...)

case "working":
if verbose {
log.Println("Collecting working tree changes...")
log.Println("Collecting working tree changes (excluding standard lockfiles)...")
}
return reviewapi.RunGitCommand("diff")
args := append([]string{"diff", "--", "."}, standardTokenExclusions...)
return reviewapi.RunGitCommand(args...)

case "commit":
commitVal := opts.CommitVal
if commitVal == "" {
return nil, fmt.Errorf("--commit is required when diff-source=commit")
}
if verbose {
log.Printf("Collecting diff for commit: %s", commitVal)
log.Printf("Collecting diff for commit: %s (excluding standard lockfiles)", commitVal)
}
// Check if it's a range (contains .. or ...)
if strings.Contains(commitVal, "..") {
// It's a commit range, use git diff
return reviewapi.RunGitCommand("diff", commitVal)
args := append([]string{"diff", commitVal, "--"}, standardTokenExclusions...)
return reviewapi.RunGitCommand(args...)
}
// Single commit, use git show to get the commit's changes
return reviewapi.RunGitCommand("show", "--format=", commitVal)
args := append([]string{"show", "--format=", commitVal, "--"}, standardTokenExclusions...)
return reviewapi.RunGitCommand(args...)

case "range":
rangeVal := opts.RangeVal
if rangeVal == "" {
return nil, fmt.Errorf("--range is required when diff-source=range")
}
if verbose {
log.Printf("Collecting diff for range: %s", rangeVal)
log.Printf("Collecting diff for range: %s (excluding standard lockfiles)", rangeVal)
}
return reviewapi.RunGitCommand("diff", rangeVal)
args := append([]string{"diff", rangeVal, "--"}, standardTokenExclusions...)
return reviewapi.RunGitCommand(args...)

case "file":
filePath := opts.DiffFile
if filePath == "" {
return nil, fmt.Errorf("--diff-file is required when diff-source=file")
}
if verbose {
log.Printf("Reading diff from file: %s", filePath)
log.Printf("Reading diff from file (no automatic exclusions applied): %s", filePath)
}
return storage.ReadDiffFile(filePath)

Expand Down
74 changes: 74 additions & 0 deletions internal/appcore/secscan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package appcore

import (
"fmt"
"regexp"
"strings"
)

// SecretPattern represents a regex rule to match known sensitive patterns
type SecretPattern struct {
Name string
Pattern *regexp.Regexp
}

// Pre-compiled high-confidence secret patterns
var secretPatterns = []SecretPattern{
{
Name: "AWS Access Key ID",
Pattern: regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
},
{
Name: "GitHub Personal Access Token",
Pattern: regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`),
},
{
Name: "Slack Token",
Pattern: regexp.MustCompile(`xox[baprs]-[0-9]{10,}-[0-9]{10,}-[a-zA-Z0-9]{24}`),
},
{
Name: "RSA / OpenSSH Private Key",
Pattern: regexp.MustCompile(`-----BEGIN (?:RSA|OPENSSH) PRIVATE KEY-----`),
},
{
Name: "Generic High Entropy Secret",
Pattern: regexp.MustCompile(`(?i)(?:sk|api_key|token|secret)[-_]?(?:key|token)?(?:[\s:=]+)['"]?([a-zA-Z0-9_\-\.]{20,})['"]?`),
},
}

// ScanDiffForSecrets scans the provided git diff content for high-confidence secrets
// Returns an error detailing the found secrets, or nil if safe.
func ScanDiffForSecrets(diffContent []byte) error {
if len(diffContent) == 0 {
return nil
}

contentStr := string(diffContent)
var foundSecrets []string

for _, sp := range secretPatterns {
if sp.Pattern.MatchString(contentStr) {
// Find all matches for reporting
matches := sp.Pattern.FindAllString(contentStr, -1)
for _, match := range matches {
redacted := redactSecretMatch(match)
foundSecrets = append(foundSecrets, fmt.Sprintf("%s (%s)", sp.Name, redacted))
}
}
}

if len(foundSecrets) > 0 {
return fmt.Errorf("local security check failed. Found %d potentially sensitive credential(s) in the staged diff:\n - %s\n\nAborting review. If you must commit this, please bypass using the `--skip` flag.",
len(foundSecrets), strings.Join(foundSecrets, "\n - "))
}

return nil
}

// redactSecretMatch masks all but the first 4 and last 4 characters of the matched secret
func redactSecretMatch(secret string) string {
if len(secret) <= 8 {
return strings.Repeat("*", len(secret))
}
return secret[:4] + "...." + secret[len(secret)-4:]
}
131 changes: 81 additions & 50 deletions network/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"time"
)
Expand Down Expand Up @@ -40,80 +41,110 @@ func NewClient(timeout time.Duration) *Client {
}
}

func (c *Client) DoJSON(method, url string, payload any, bearerToken, orgContext string, headers map[string]string) (*Response, error) {
var bodyReader io.Reader
if payload != nil {
bodyJSON, err := json.Marshal(payload)
const maxRetries = 3

// doWithRetry encapsulates the core HTTP execution with exponential backoff and jitter.
func (c *Client) doWithRetry(reqBody []byte, reqBuilder func(io.Reader) (*http.Request, error)) (*Response, error) {
var resp *http.Response
var err error

for attempt := 0; attempt <= maxRetries; attempt++ {
var bodyReader io.Reader
if reqBody != nil {
bodyReader = bytes.NewReader(reqBody)
}

req, reqErr := reqBuilder(bodyReader)
if reqErr != nil {
return nil, reqErr
}

resp, err = c.httpClient.Do(req)

// Determine if the failure is transient
shouldRetry := false
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
shouldRetry = true
} else if resp.StatusCode >= 500 || resp.StatusCode == 429 {
shouldRetry = true
}
bodyReader = bytes.NewReader(bodyJSON)
}

req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, err
}
if !shouldRetry || attempt == maxRetries {
break
}

if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
if orgContext != "" {
req.Header.Set("X-Org-Context", orgContext)
}
for key, value := range headers {
req.Header.Set(key, value)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}

// Calculate backoff: wait = base * 2^attempt + jitter
baseWait := time.Duration(500*(1<<attempt)) * time.Millisecond
jitter := time.Duration(rand.Intn(200)) * time.Millisecond
time.Sleep(baseWait + jitter)
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("failed to read response body: %w", readErr)
}

return &Response{
StatusCode: resp.StatusCode,
Body: body,
Body: bodyBytes,
Header: resp.Header,
}, nil
}

func (c *Client) Do(method, url string, body []byte, headers map[string]string) (*Response, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
func (c *Client) DoJSON(method, url string, payload any, bearerToken, orgContext string, headers map[string]string) (*Response, error) {
var bodyJSON []byte
var err error
if payload != nil {
bodyJSON, err = json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
}

req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
builder := func(bodyReader io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, err
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
if orgContext != "" {
req.Header.Set("X-Org-Context", orgContext)
}
for key, value := range headers {
req.Header.Set(key, value)
}
return req, nil
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
return c.doWithRetry(bodyJSON, builder)
}

func (c *Client) Do(method, url string, body []byte, headers map[string]string) (*Response, error) {
builder := func(bodyReader io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
return req, nil
}

return &Response{
StatusCode: resp.StatusCode,
Body: respBody,
Header: resp.Header,
}, nil
return c.doWithRetry(body, builder)
}
Binary file added test.txt
Binary file not shown.