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: 7 additions & 16 deletions aho.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ package portcullis
// kwMask is a fixed-size bitset over keyword indices, used to record
// which patterns occurred in a scanned input and which keywords each
// rule subscribes to. Storing it as a small array keeps every test
// branch-free; the cap of 320 indices accommodates today's catalogue
// of ~285 unique keywords with limited remaining headroom for future
// rules (an overflow trips a deterministic panic in [buildAhoCorasick]).
// branch-free; the cap of len(kwMask)*64 indices (currently 320)
// accommodates today's catalogue of ~285 unique keywords with limited
// remaining headroom for future rules (an overflow trips a
// deterministic panic in [buildAhoCorasick]).
type kwMask [5]uint64

func (m *kwMask) empty() bool { return m[0]|m[1]|m[2]|m[3]|m[4] == 0 }
Expand Down Expand Up @@ -73,7 +74,7 @@ type acAutomaton struct {
// buildAhoCorasick compiles patterns into an automaton. Patterns
// must be lower-cased ASCII.
func buildAhoCorasick(patterns []string) *acAutomaton {
if len(patterns) > 320 {
if len(patterns) > len(kwMask{})*64 {
panic("portcullis: too many AC patterns for kwMask")
}

Expand Down Expand Up @@ -227,24 +228,14 @@ func (a *acAutomaton) scanSerial(text string) (mask kwMask) {
off := raw &^ acceptBit
i++
if raw&acceptBit != 0 {
ap := &accept[off>>stateShift]
mask[0] |= ap[0]
mask[1] |= ap[1]
mask[2] |= ap[2]
mask[3] |= ap[3]
mask[4] |= ap[4]
mask.orIn(&accept[off>>stateShift])
}
for i < n && off != 0 {
raw = next[off+uint32(text[i])]
off = raw &^ acceptBit
i++
if raw&acceptBit != 0 {
ap := &accept[off>>stateShift]
mask[0] |= ap[0]
mask[1] |= ap[1]
mask[2] |= ap[2]
mask[3] |= ap[3]
mask[4] |= ap[4]
mask.orIn(&accept[off>>stateShift])
}
}
}
Expand Down
5 changes: 2 additions & 3 deletions aho_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,11 @@ func TestAhoCorasickPropagatesAllKwMaskWords(t *testing.T) {
}

// TestAhoCorasickPanicOnTooManyPatterns verifies that buildAhoCorasick
// panics when given more than 320 patterns, which would overflow the
// kwMask bitset.
// panics when given more patterns than the kwMask bitset can index.
func TestAhoCorasickPanicOnTooManyPatterns(t *testing.T) {
t.Parallel()

patterns := make([]string, 321)
patterns := make([]string, len(kwMask{})*64+1)
for i := range patterns {
// Each pattern must be unique to avoid trie conflicts; encode
// the index as a 3-letter base-26 string.
Expand Down
30 changes: 11 additions & 19 deletions cmd/portcullis-scan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,18 @@ func scanFileBytes(path, root string, maxSize int64, scanBinary bool) ([]byte, e
display = rel
}
var b strings.Builder
// Matches arrive in ascending Start order, so line/column are
// tracked with one cursor instead of rescanning data from the
// start for every match.
line, lastNL, pos := 1, -1, 0
for _, m := range matches {
line, col := lineCol(data, m.Start)
fmt.Fprintf(&b, "%s:%d:%d: %s\n", display, line, col, sanitizeValue(m.Value))
for ; pos < m.Start; pos++ {
if data[pos] == '\n' {
line++
lastNL = pos
}
}
fmt.Fprintf(&b, "%s:%d:%d: %s\n", display, line, pos-lastNL, sanitizeValue(m.Value))
}
return []byte(b.String()), nil
}
Expand Down Expand Up @@ -386,23 +395,6 @@ func readIfScannable(path string, maxSize int64, scanBinary bool) (data []byte,
return append(sniff, rest...), true, nil
}

// lineCol returns the 1-based line and column for offset within data.
// Column counts bytes since the last newline.
func lineCol(data []byte, offset int) (line, col int) {
if offset > len(data) {
offset = len(data)
}
line = 1
last := -1
for i := range offset {
if data[i] == '\n' {
line++
last = i
}
}
return line, offset - last
}

// sanitizeValue collapses CR / LF in v so a multi-line match (e.g. a
// PEM block) stays on a single output line.
func sanitizeValue(v string) string {
Expand Down
58 changes: 58 additions & 0 deletions cmd/portcullis-scan/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,64 @@ func TestRunPrintsEachSecretFound(t *testing.T) {
assert.Equal(t, 3, strings.Count(out, "\n"))
}

// TestScanFileBytesLineColTracking locks down the incremental
// line/column cursor: matches at offset zero, several matches on one
// line, CRLF line endings, and empty files.
func TestScanFileBytesLineColTracking(t *testing.T) {
t.Parallel()

tests := []struct {
name string
content string
want []string
}{
{
name: "match at offset zero",
content: githubPAT + "\n",
want: []string{"f:1:1: " + githubPAT},
},
{
name: "two matches on the same line",
content: githubPAT + " " + githubPAT + "\n",
want: []string{
"f:1:1: " + githubPAT,
"f:1:42: " + githubPAT,
},
},
{
name: "crlf line endings",
content: "clean line\r\n" + githubPAT + "\r\nkey: " + githubPAT + "\r\n",
want: []string{
"f:2:1: " + githubPAT,
"f:3:6: " + githubPAT,
},
},
{
name: "empty file",
content: "",
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

root := t.TempDir()
path := filepath.Join(root, "f")
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0o644))

buf, err := scanFileBytes(path, root, defaultMaxSize, false)

require.NoError(t, err)
var wantOut string
if len(tc.want) > 0 {
wantOut = strings.Join(tc.want, "\n") + "\n"
}
assert.Equal(t, wantOut, string(buf))
})
}
}

func TestRunReturnsZeroWhenNoSecrets(t *testing.T) {
t.Parallel()

Expand Down
56 changes: 34 additions & 22 deletions portcullis.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,26 @@ func Find(text string) []Match {
return dedupOverlapping(findMatches(text))
}

func findMatches(text string) []Match {
// scanPrelude runs the shared pre-filter behind [Find], [Redact],
// and [Contains]: the Aho–Corasick pass over text. ok is false when
// no rule could possibly match — empty input, or no keyword hit and
// no always-run rules — letting callers return without touching any
// regex.
func scanPrelude(text string) (rs *ruleSet, found kwMask, ok bool) {
if text == "" {
return nil
return nil, kwMask{}, false
}
rs := compiledRuleSet()
found := rs.ac.scan(text)
rs = compiledRuleSet()
found = rs.ac.scan(text)
if found.empty() && !rs.hasAlwaysRun {
return nil, kwMask{}, false
}
return rs, found, true
}

func findMatches(text string) []Match {
rs, found, ok := scanPrelude(text)
if !ok {
return nil
}
var matches []Match
Expand All @@ -67,6 +80,19 @@ func findMatches(text string) []Match {
return matches
}

// sortMatches orders matches by Start ascending, breaking ties by
// End descending so that at equal starts the longest span comes
// first — the order both [dedupOverlapping] and [mergeOverlapping]
// rely on for their single greedy pass.
func sortMatches(matches []Match) {
slices.SortFunc(matches, func(a, b Match) int {
if a.Start != b.Start {
return a.Start - b.Start
}
return b.End - a.End
})
}

// dedupOverlapping collapses overlapping matches to one per underlying
// span, keeping the longest. After sorting by Start asc, End desc, a
// greedy walk drops anything contained in the last kept match, and a
Expand All @@ -80,12 +106,7 @@ func dedupOverlapping(matches []Match) []Match {
if len(matches) < 2 {
return matches
}
slices.SortFunc(matches, func(a, b Match) int {
if a.Start != b.Start {
return a.Start - b.Start
}
return b.End - a.End
})
sortMatches(matches)
out := matches[:0]
for _, m := range matches {
if len(out) == 0 || m.Start >= out[len(out)-1].End {
Expand All @@ -104,12 +125,8 @@ func dedupOverlapping(matches []Match) []Match {
// Contains reports whether text matches any built-in secret rule.
// It is safe for concurrent use.
func Contains(text string) bool {
if text == "" {
return false
}
rs := compiledRuleSet()
found := rs.ac.scan(text)
if found.empty() && !rs.hasAlwaysRun {
rs, found, ok := scanPrelude(text)
if !ok {
return false
}
for i := range rs.rules {
Expand Down Expand Up @@ -186,12 +203,7 @@ func mergeOverlapping(matches []Match) []Match {
if len(matches) < 2 {
return matches
}
slices.SortFunc(matches, func(a, b Match) int {
if a.Start != b.Start {
return a.Start - b.Start
}
return b.End - a.End
})
sortMatches(matches)
out := matches[:0]
for _, m := range matches {
if len(out) == 0 || m.Start >= out[len(out)-1].End {
Expand Down
4 changes: 2 additions & 2 deletions rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func contextual(vendor, body string) string {
// the regex-compiled form actually used at scan time.
//
//nolint:funlen // single-source-of-truth for the ruleset
var rules = sync.OnceValue(func() []rule {
func rules() []rule {
return []rule{
{
// aws-access-key-id. Prefix list mirrors the gitleaks
Expand Down Expand Up @@ -1991,7 +1991,7 @@ var rules = sync.OnceValue(func() []rule {
keywords: []string{"secret-live-", "secret-test-"},
},
}
})
}

// compiledRule is the runtime form of a [rule]: its keywords are
// folded into a [kwMask] over the catalogue's shared keyword index,
Expand Down