Skip to content

Commit 9ab04af

Browse files
committed
new file: internal/analysis/active/taint/scanner.go
new file: internal/analysis/active/taint/scanner_test.go modified: internal/analysis/active/taint/taint_analyzer.go modified: internal/analysis/active/taint/taint_analyzer_test.go modified: internal/analysis/active/taint/taint_shim.js modified: internal/browser/session/session.go
1 parent 7ab8ec1 commit 9ab04af

6 files changed

Lines changed: 573 additions & 35 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Filename: internal/analysis/active/taint/scanner.go
2+
package taint
3+
4+
import (
5+
"regexp"
6+
"strconv"
7+
"strings"
8+
"unicode"
9+
)
10+
11+
// PANScanner handles the verification of credit card numbers (Primary Account Numbers).
12+
type PANScanner struct {
13+
// matcher uses a robust regex to identify potential PAN candidates.
14+
// It is unexported but accessible within the 'taint' package (e.g., by analyzer.go).
15+
matcher *regexp.Regexp
16+
}
17+
18+
// The modernized and comprehensive regex pattern for PAN detection.
19+
// Covers Visa, MasterCard (including 2-series), Amex, Discover, and Diners Club.
20+
// It handles common separators (spaces, dashes).
21+
// This must be kept in sync with the CC_REGEX in taint_shim.js.
22+
const panPattern = `\b(?:4[0-9]{3}(?:[- ]?[0-9]{4}){2}(?:[- ]?[0-9]{1,4})?|(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)(?:[- ]?[0-9]{4}){3}|3[47][0-9]{2}(?:[- ]?[0-9]{6})[- ]?[0-9]{5}|6(?:011|5[0-9]{2}|4[4-9][0-9])(?:[- ]?[0-9]{4}){3}|3(?:0[0-5]|[68][0-9])[0-9](?:[- ]?[0-9]{6})[- ]?[0-9]{4})\b`
23+
24+
// NewPANScanner initializes a PANScanner by compiling the detection regex once for efficient reuse.
25+
func NewPANScanner() *PANScanner {
26+
return &PANScanner{
27+
matcher: regexp.MustCompile(panPattern),
28+
}
29+
}
30+
31+
// HasValidPAN checks if an input string contains at least one sequence that matches
32+
// the PAN pattern and subsequently passes the Luhn algorithm validation.
33+
func (ps *PANScanner) HasValidPAN(input string) bool {
34+
// Find all sequences matching the regex pattern.
35+
matches := ps.matcher.FindAllString(input, -1)
36+
37+
for _, match := range matches {
38+
// Clean the match (remove separators) before validation.
39+
clean := stripSeparators(match)
40+
if luhnCheck(clean) {
41+
// If any match is valid, return true immediately.
42+
return true
43+
}
44+
}
45+
// No valid PANs found in the input.
46+
return false
47+
}
48+
49+
// stripSeparators removes non-digit characters (like dashes and spaces) from a potential PAN string.
50+
func stripSeparators(pan string) string {
51+
// Use strings.Map to iterate over runes and keep only digits.
52+
return strings.Map(func(r rune) rune {
53+
if unicode.IsDigit(r) {
54+
return r
55+
}
56+
// Return -1 to drop the rune from the result.
57+
return -1
58+
}, pan)
59+
}
60+
61+
// luhnCheck implements the Luhn algorithm (Mod 10 checksum) to validate a cleaned credit card number.
62+
func luhnCheck(ccNumber string) bool {
63+
// Basic length validation (PANs are typically 13-19 digits).
64+
if len(ccNumber) < 13 || len(ccNumber) > 19 {
65+
return false
66+
}
67+
68+
var sum int
69+
// 'alt' determines whether the current digit should be doubled (every second digit from the right).
70+
alt := false
71+
72+
// Iterate backwards through the number string.
73+
for i := len(ccNumber) - 1; i >= 0; i-- {
74+
// Convert the character digit to an integer.
75+
digit, err := strconv.Atoi(string(ccNumber[i]))
76+
if err != nil {
77+
// Should not happen if stripSeparators and the regex are correct, but handle defensively.
78+
return false
79+
}
80+
81+
if alt {
82+
// Double the digit.
83+
digit *= 2
84+
// If the result is greater than 9, subtract 9 (equivalent to summing the digits of the result).
85+
if digit > 9 {
86+
digit -= 9
87+
}
88+
}
89+
90+
// Add the digit (or the processed doubled digit) to the total sum.
91+
sum += digit
92+
// Toggle the alternate flag for the next iteration.
93+
alt = !alt
94+
}
95+
96+
// The number is valid if the total sum is divisible by 10.
97+
return sum%10 == 0
98+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Filename: internal/analysis/active/taint/scanner_test.go
2+
package taint
3+
4+
import (
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/mock"
9+
"github.com/xkilldash9x/scalpel-cli/api/schemas"
10+
)
11+
12+
// --- Unit Tests for PANScanner Logic ---
13+
14+
func TestStripSeparators(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
input string
18+
expected string
19+
}{
20+
{"No separators", "1234567890", "1234567890"},
21+
{"Spaces", "1234 5678 90", "1234567890"},
22+
{"Dashes", "1234-5678-90", "1234567890"},
23+
{"Mixed", "1234- 5678 - 90", "1234567890"},
24+
{"Non-digits", "12a34b", "1234"}, // Should remove letters
25+
{"Symbols", "12$34#", "1234"}, // Should remove symbols
26+
{"Empty", "", ""},
27+
}
28+
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
result := stripSeparators(tt.input)
32+
assert.Equal(t, tt.expected, result)
33+
})
34+
}
35+
}
36+
37+
func TestLuhnCheck(t *testing.T) {
38+
tests := []struct {
39+
name string
40+
input string // Input should be cleaned (digits only)
41+
isValid bool
42+
}{
43+
// Valid Numbers (Mathematically verified)
44+
{"Valid Visa", "453201511283034", true}, // Corrected: Ends in 34 (Sum 50)
45+
{"Valid MasterCard", "5555555555554444", true},
46+
{"Valid Amex", "371449635398431", true},
47+
48+
// Invalid Numbers
49+
{"Invalid Checksum (Single Digit Off)", "453201511283035", false}, // Corrected: Ends in 35 (Sum 51)
50+
{"Invalid Checksum (Transposition)", "453201511283036", false},
51+
{"Too Short", "123456", false},
52+
{"Too Long", "123456789012345678901", false}, // > 19 digits
53+
{"Empty", "", false},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
result := luhnCheck(tt.input)
59+
assert.Equal(t, tt.isValid, result, "Input: %s", tt.input)
60+
})
61+
}
62+
}
63+
64+
func TestPANScanner_HasValidPAN(t *testing.T) {
65+
scanner := NewPANScanner()
66+
67+
tests := []struct {
68+
name string
69+
input string
70+
expectMatch bool
71+
}{
72+
// 1. Positive Matches (Valid Regex + Valid Luhn)
73+
{
74+
name: "Simple Visa",
75+
input: "Here is a card: 453201511283034 thanks", // Corrected Check Digit
76+
expectMatch: true,
77+
},
78+
{
79+
name: "Formatted MasterCard",
80+
input: "Payment: 5555-5555-5555-4444",
81+
expectMatch: true,
82+
},
83+
{
84+
name: "Amex with spaces",
85+
input: "AMEX 3714 496353 98431",
86+
expectMatch: true,
87+
},
88+
{
89+
name: "Mixed text",
90+
input: "user_id: 1234, cc: 4532-0151-1128-3034, exp: 12/25", // Corrected Check Digit
91+
expectMatch: true,
92+
},
93+
94+
// 2. Negative Matches (Valid Regex + Invalid Luhn) -> False Positive Reduction
95+
{
96+
name: "Regex Match but Invalid Luhn (Visa-like)",
97+
input: "4111111111111112", // Ends in 2 (Sum 31) -> Invalid. (All 1s is Valid Sum 30)
98+
expectMatch: false,
99+
},
100+
{
101+
name: "Regex Match but Invalid Luhn (MC-like)",
102+
input: "5100000000000001", // Ends in 1 -> Sum ends in 1 -> Invalid
103+
expectMatch: false,
104+
},
105+
106+
// 3. Negative Matches (No Regex Match)
107+
{
108+
name: "Random Numbers",
109+
input: "Call 18005550199 for help",
110+
expectMatch: false,
111+
},
112+
{
113+
name: "Short Number",
114+
input: "4123",
115+
expectMatch: false,
116+
},
117+
{
118+
name: "IIN not in list",
119+
input: "9000000000000000", // 9 is not a major card scheme start
120+
expectMatch: false,
121+
},
122+
}
123+
124+
for _, tt := range tests {
125+
t.Run(tt.name, func(t *testing.T) {
126+
match := scanner.HasValidPAN(tt.input)
127+
assert.Equal(t, tt.expectMatch, match, "Input: %s", tt.input)
128+
})
129+
}
130+
}
131+
132+
// --- Integration Tests (Analyzer + Scanner) ---
133+
134+
func TestProcessSensitiveDataEvent_ConfirmedLeak(t *testing.T) {
135+
// Setup Analyzer
136+
analyzer, reporter, _ := setupAnalyzer(t, nil, false)
137+
analyzer.wg.Add(1)
138+
go analyzer.correlateWorker(0)
139+
140+
// Valid Visa that passes Luhn
141+
validCC := "4532-0151-1128-3034" // Corrected Check Digit
142+
143+
// Create a Sensitive Data Event
144+
event := SinkEvent{
145+
Type: schemas.TaintSink("SENSITIVE_STORAGE_WRITE"),
146+
Value: validCC,
147+
Detail: "Sensitive pattern detected in storage value",
148+
PageURL: "http://example.com/checkout",
149+
PageTitle: "Checkout",
150+
}
151+
152+
// Expect a Report
153+
reporter.On("Report", mock.MatchedBy(func(f CorrelatedFinding) bool {
154+
return f.IsConfirmed == true &&
155+
f.Origin == schemas.TaintSource("HEURISTIC_CLIENT_DATA") &&
156+
f.Value == validCC &&
157+
f.Sink == schemas.TaintSink("SENSITIVE_STORAGE_WRITE")
158+
})).Return().Once()
159+
160+
analyzer.eventsChan <- event
161+
finalizeCorrelationTest(t, analyzer)
162+
163+
reporter.AssertExpectations(t)
164+
}
165+
166+
func TestProcessSensitiveDataEvent_Suppressed_FalsePositive(t *testing.T) {
167+
analyzer, reporter, _ := setupAnalyzer(t, nil, false)
168+
analyzer.wg.Add(1)
169+
go analyzer.correlateWorker(0)
170+
171+
// Looks like a CC (Regex match) but INVALID Luhn
172+
invalidLuhnCC := "4111-1111-1111-1112" // Sum 31
173+
174+
event := SinkEvent{
175+
Type: schemas.TaintSink("SENSITIVE_STORAGE_WRITE"),
176+
Value: invalidLuhnCC,
177+
Detail: "Sensitive pattern detected (shim guess)",
178+
}
179+
180+
// We expect NO report because the backend verification (Luhn) should fail
181+
analyzer.eventsChan <- event
182+
finalizeCorrelationTest(t, analyzer)
183+
184+
assert.Empty(t, reporter.GetFindings(), "Should suppress false positive CC numbers")
185+
}
186+
187+
func TestProcessSensitiveDataEvent_OtherSecrets(t *testing.T) {
188+
analyzer, reporter, _ := setupAnalyzer(t, nil, false)
189+
analyzer.wg.Add(1)
190+
go analyzer.correlateWorker(0)
191+
192+
// A value that does NOT look like a credit card, but was flagged by the shim
193+
apiKey := "AKIAIOSFODNN7EXAMPLE"
194+
195+
event := SinkEvent{
196+
Type: schemas.TaintSink("SENSITIVE_STORAGE_WRITE"),
197+
Value: apiKey,
198+
Detail: "Sensitive pattern detected: AWS Key",
199+
}
200+
201+
// Expect Report (Unconfirmed/Heuristic)
202+
reporter.On("Report", mock.MatchedBy(func(f CorrelatedFinding) bool {
203+
return f.IsConfirmed == false &&
204+
f.Value == apiKey &&
205+
f.Detail == "Potential Secret/Key Leak: Sensitive pattern detected: AWS Key"
206+
})).Return().Once()
207+
208+
analyzer.eventsChan <- event
209+
finalizeCorrelationTest(t, analyzer)
210+
211+
reporter.AssertExpectations(t)
212+
}

0 commit comments

Comments
 (0)