diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e89d17f..6c33d91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,18 @@ jobs: echo "$out" echo "$out" | grep -q "0 finding(s)" + - name: Context scoring is switchable (2 findings on, 1 off) + run: | + printf 'phone: (415) 555-2671\ncard 4532015112830366 charged\n' > /tmp/ctx.txt + on=$(/tmp/pii-scan -mode files -level warning -threshold 0.8 \ + -context=true /tmp/ctx.txt) + echo "$on" + echo "$on" | grep -q "2 finding(s)" + off=$(/tmp/pii-scan -mode files -level warning -threshold 0.8 \ + -context=false /tmp/ctx.txt) + echo "$off" + echo "$off" | grep -q "1 finding(s)" + self-scan: name: Scan this PR with the action itself if: github.event_name == 'pull_request' diff --git a/.pii-allowlist b/.pii-allowlist index 23682a4..cb2ea5e 100644 --- a/.pii-allowlist +++ b/.pii-allowlist @@ -3,3 +3,9 @@ 4532015112830366 jane@example.com 536-90-4399 + +# Context-scoring fixtures: reserved-range values used to show that a labelled +# match outscores a bare one. 555-01xx numbers and RFC 1918 addresses. +(415) 555-2671 +415-555-2671 +192.168.1.44 diff --git a/README.md b/README.md index cd2108e..cef3924 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ its own report comments, so it never scans itself in a loop. | `threshold` | `0.8` | Minimum confidence in `[0,1]`. Raise for precision, lower for recall | | `entities` | all 45 | Comma-separated entity types to restrict to, e.g. `CREDIT_CARD,EMAIL_ADDRESS,US_SSN` | | `ignore-entities` | `DATE_TIME,URL` | Entity types dropped as noise | +| `context-scoring` | `true` | Score a match higher when a word naming its entity type precedes it. `false` scores on pattern strength alone | | `allowlist-file` | – | Repo path with allowed values, one per line (`#` comments ok) | | `exclude` | `go.sum,*.lock,*.svg,*.min.js,vendor/**,node_modules/**` | Glob patterns of diff paths to skip (`mode: diff`) | | `comment` | `true` | Post/update the sticky report comment | @@ -148,8 +149,14 @@ and point the action at it with `allowlist-file: .pii-allowlist`. To narrow what's detected instead, set `entities` to the types you care about, or tune `threshold`: the default `0.8` keeps checksum-verified identifiers (score `1.0`: credit cards, national IDs, IBANs) and drops shape-only matches. -Lower it (e.g. `0.4`) to also catch emails and phone numbers, which score -`0.5`. +Nearby context words lift a match's score, so a labelled `phone: ...` or +`ip=...` clears `0.8` where the bare value would not. Lower it (e.g. `0.4`) +to also catch unlabelled emails and phone numbers, which score `0.5`. + +If you'd rather `threshold` mean pattern strength alone — `0.8` as "checksum +validated, no exceptions" — set `context-scoring: false`. That restores the +scores a pattern earns on its own, at the cost of the real PII the +surrounding words would otherwise reveal. ## What gets posted diff --git a/action.yml b/action.yml index c05c3da..78e98aa 100644 --- a/action.yml +++ b/action.yml @@ -27,6 +27,13 @@ inputs: ignore-entities: description: "Comma-separated entity types dropped as noise." default: "DATE_TIME,URL" + context-scoring: + description: >- + Score a match higher when a word naming its entity type precedes it + (e.g. "phone: 415-555-2671" scores 0.85, the bare number 0.5). Set to + "false" to score on pattern strength alone, so `threshold` means only + how strong the pattern is. + default: "true" allowlist-file: description: "Repo path of a file with allowed values, one per line (# comments ok)." default: "" @@ -74,6 +81,7 @@ runs: THRESHOLD: ${{ inputs.threshold }} ENTITIES: ${{ inputs.entities }} IGNORE: ${{ inputs.ignore-entities }} + CONTEXT: ${{ inputs.context-scoring }} ALLOWLIST: ${{ inputs.allowlist-file }} EXCLUDE: ${{ inputs.exclude }} FAIL: ${{ inputs.fail-on-findings }} @@ -90,7 +98,7 @@ runs: echo "marker=$MARKER" >> "$GITHUB_OUTPUT" args=(-threshold "$THRESHOLD" -ignore "$IGNORE" -level "$LEVEL" \ - -report "$REPORT" -marker "$MARKER") + -context="$CONTEXT" -report "$REPORT" -marker "$MARKER") [ -n "$ENTITIES" ] && args+=(-entities "$ENTITIES") [ -n "$ALLOWLIST" ] && args+=(-allowlist-file "$ALLOWLIST") diff --git a/cmd/pii-scan/main.go b/cmd/pii-scan/main.go index ee34322..0a6f3b1 100644 --- a/cmd/pii-scan/main.go +++ b/cmd/pii-scan/main.go @@ -38,6 +38,7 @@ func run() error { source := flag.String("source", "", "label for what was scanned, used in the report") marker := flag.String("marker", defaultMarker, "sticky-comment marker id embedded in the report") level := flag.String("level", "error", "annotation level: error, warning, or notice") + useContext := flag.Bool("context", true, "score a match higher when a word naming its entity type precedes it (-context=false for pattern-only scores)") flag.Parse() allowList, err := readAllowlist(*allowlistFile) @@ -46,6 +47,9 @@ func run() error { } s := newScanner(*threshold, splitList(*entities), splitList(*ignore), allowList) s.exclude = splitList(*exclude) + if !*useContext { + s.disableContext() + } var findings []Finding switch *mode { diff --git a/cmd/pii-scan/scan.go b/cmd/pii-scan/scan.go index 64ed8c3..4322339 100644 --- a/cmd/pii-scan/scan.go +++ b/cmd/pii-scan/scan.go @@ -50,6 +50,16 @@ func newScanner(threshold float64, entities, ignore, allowList []string) *scanne } } +// disableContext turns off context-aware scoring, restoring the scores a +// pattern gets on its own. +// +// This exists for callers who read a threshold as a statement about pattern +// strength alone — "0.8 means checksum-validated" — since context can lift a +// labelled email or phone from 0.5 to 0.85 and over that line. +func (s *scanner) disableContext() { + s.engine.SetContextEnhancer(nil) +} + // excluded reports whether a diff path matches any exclude pattern. Patterns // match the full path or its basename; "dir/**" matches everything under dir. func (s *scanner) excluded(file string) bool { diff --git a/cmd/pii-scan/scan_test.go b/cmd/pii-scan/scan_test.go index 2d935f7..e23edde 100644 --- a/cmd/pii-scan/scan_test.go +++ b/cmd/pii-scan/scan_test.go @@ -131,6 +131,49 @@ func TestScanFile(t *testing.T) { } } +// TestContextScoring pins the behavior alcatraz v0.14.0 introduced: a word +// naming the entity type in front of a match lifts its score. The threshold +// here is 0.8, the action's default, so each of these values is the +// difference between a clean scan and a failing one. +func TestContextScoring(t *testing.T) { + tests := []struct { + name string + text string + want int // findings at threshold 0.8 with context scoring on + }{ + {"labelled email", "email: jane@example.com", 1}, + {"bare email", "ping jane@example.com now", 0}, + {"labelled ip", "ip 192.168.1.44 hit the api", 1}, + {"bare ip", "saw 192.168.1.44 today", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newScanner(0.8, nil, []string{"DATE_TIME", "URL"}, nil) + findings, err := s.scanText(strings.NewReader(tt.text)) + if err != nil { + t.Fatal(err) + } + if len(findings) != tt.want { + t.Errorf("context on: got %d findings, want %d: %+v", + len(findings), tt.want, findings) + } + + // Pattern-only scoring never rates these above 0.8, so + // disableContext must drop them all regardless of labelling. + s = newScanner(0.8, nil, []string{"DATE_TIME", "URL"}, nil) + s.disableContext() + findings, err = s.scanText(strings.NewReader(tt.text)) + if err != nil { + t.Fatal(err) + } + if len(findings) != 0 { + t.Errorf("context off: got %d findings, want 0: %+v", + len(findings), findings) + } + }) + } +} + func TestThresholdDropsLowConfidence(t *testing.T) { // A bare 8-digit run only triggers low-confidence recognizers // (e.g. US_BANK_NUMBER at 0.05); threshold 0.4 must drop it. diff --git a/go.mod b/go.mod index 1ae4a51..ebb1472 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/hoophq/alcatraz-action go 1.24 -require github.com/hoophq/alcatraz v0.4.0 +require github.com/hoophq/alcatraz v0.14.0 diff --git a/go.sum b/go.sum index ae30f09..28da78e 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/hoophq/alcatraz v0.4.0 h1:ZGTvP/eouv7yBatlQx6RIdUQb9gM9gz7ej3RUgPSTsk= -github.com/hoophq/alcatraz v0.4.0/go.mod h1:B5ZNcwna5qCRZpVN6MMjmUbzEDpk5Q04jnKxqfZucdM= +github.com/hoophq/alcatraz v0.14.0 h1:rlmiLv5ko2fG0og71UgPSZ7/77qEF5+bwPYQooRTzSY= +github.com/hoophq/alcatraz v0.14.0/go.mod h1:B5ZNcwna5qCRZpVN6MMjmUbzEDpk5Q04jnKxqfZucdM=