diff --git a/docs/supported_inventory_types.md b/docs/supported_inventory_types.md index c4c500873..d12c865e4 100644 --- a/docs/supported_inventory_types.md +++ b/docs/supported_inventory_types.md @@ -216,10 +216,11 @@ See the docs on [how to add a new Extractor](/docs/new_extractor.md). | HTTP CSRF Token | `secrets/csrftoken` | ### Sensitive information -| Type | Extractor Plugin | -| ------------------------------------------- | ------------------------------------ | -| International Bank Account Number | `sensitiveinformation/iban` | -| US Social Security Number | `sensitiveinformation/ssn` | +| Type | Extractor Plugin | +| ------------------------------------------- | ---------------------------------------- | +| International Bank Account Number | `sensitiveinformation/iban` | +| US Passport Numbers | `sensitiveinformation/uspassportnumber` | +| US Social Security Number | `sensitiveinformation/ssn` | ### Container inventory diff --git a/extractor/filesystem/list/list.go b/extractor/filesystem/list/list.go index f24529dcd..a43276552 100644 --- a/extractor/filesystem/list/list.go +++ b/extractor/filesystem/list/list.go @@ -186,6 +186,7 @@ import ( "github.com/google/osv-scalibr/veles/secrets/vapid" "github.com/google/osv-scalibr/veles/sensitiveinformation/iban" "github.com/google/osv-scalibr/veles/sensitiveinformation/ssn" + "github.com/google/osv-scalibr/veles/sensitiveinformation/uspassportnumber" cpb "github.com/google/osv-scalibr/binary/proto/config_go_proto" ) @@ -459,6 +460,7 @@ var ( SensitiveInformationDetectors = initMapFromVelesPlugins([]velesPlugin{ {iban.NewDetector(), "sensitiveinformation/iban", 0}, {ssn.NewDetector(), "sensitiveinformation/ssn", 0}, + {uspassportnumber.NewDetector(), "sensitiveinformation/uspassportnumber", 0}, }) // Secrets contains both secret extractors and detectors. diff --git a/veles/sensitiveinformation/uspassportnumber/detector.go b/veles/sensitiveinformation/uspassportnumber/detector.go new file mode 100644 index 000000000..ed2eca5a3 --- /dev/null +++ b/veles/sensitiveinformation/uspassportnumber/detector.go @@ -0,0 +1,68 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package uspassportnumber implements logic for detecting US passport numbers +package uspassportnumber + +import ( + "bytes" + "regexp" + + "github.com/google/osv-scalibr/veles" + "github.com/google/osv-scalibr/veles/sensitiveinformation" + "github.com/google/osv-scalibr/veles/sensitiveinformation/common/simpleregex" +) + +const ( + maxPassportNumberLen = 9 + maxKeywordLen = 20 + contextWindowSize = 32 +) + +var ( + keywordsRe = simpleregex.KeywordsRe([]string{ + `pass`, + `passport`, + `travel`, + `doc`, + `document`, + }) + passportNumberRe = regexp.MustCompile(`\b[A-Za-z][0-9]{8}\b`) +) + +// NewDetector returns a Detector, that finds US Passport Numbers +func NewDetector() veles.Detector { + return simpleregex.Detector{ + MaxLen: max(maxKeywordLen, maxPassportNumberLen), + Re: passportNumberRe, + KeywordsRe: keywordsRe, + ContextWindowBefore: contextWindowSize, + ContextWindowAfter: contextWindowSize, + FromMatch: func(blob []byte, keywordMatch bool) (sensitiveinformation.SensitiveInformation, bool) { + likelihood := sensitiveinformation.LikelihoodUnlikely + if keywordMatch { + likelihood = sensitiveinformation.LikelihoodLikely + } + + return sensitiveinformation.SensitiveInformation{ + InfoType: sensitiveinformation.InfoType{ + Name: "US_PASSPORT_NUMBER", + Sensitivity: sensitiveinformation.SensitivityLevelHigh, + }, + Likelihood: likelihood, + Raw: bytes.Clone(blob), + }, true + }, + } +} diff --git a/veles/sensitiveinformation/uspassportnumber/detector_test.go b/veles/sensitiveinformation/uspassportnumber/detector_test.go new file mode 100644 index 000000000..790595b50 --- /dev/null +++ b/veles/sensitiveinformation/uspassportnumber/detector_test.go @@ -0,0 +1,388 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package uspassportnumber + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/osv-scalibr/veles" + "github.com/google/osv-scalibr/veles/sensitiveinformation" + "github.com/google/osv-scalibr/veles/velestest" +) + +const ( + validUSPassportNumber = "A12345678" +) + +func TestDetectorAcceptance(t *testing.T) { + velestest.AcceptDetector( + t, + NewDetector(), + validUSPassportNumber, + buildExpectedResult([]byte(validUSPassportNumber), sensitiveinformation.LikelihoodUnlikely), + ) +} + +func TestDetector(t *testing.T) { + engine, err := veles.NewDetectionEngine([]veles.Detector{NewDetector()}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + input string + want []veles.Secret + }{ + { + name: "number_with_keyword", + input: `passport_number: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "number_with_keyword_lowercase", + input: `passport_number: a12345678`, + want: []veles.Secret{buildExpectedResult([]byte("a12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "log_json_pretty", + input: `{ + "level": "INFO", + "user_id": "1234", + "passport_number": "A12345678" + } + `, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "log_json_minified", + input: `{"level":"INFO","user_id":"1234","passport_number":"A12345678"}`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "log_xml_pretty", + input: ` + 1234 + A12345678 + + `, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "log_xml_minified", + input: `1234A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "log_yaml", + input: `user_id: "1234" + passport_number: A12345678 + `, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "log_csv", + input: `user_id,passport_number + 000,000 + 123,A12345678 + `, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "keyword_after", + input: `A12345678 passport number`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "us_passport", + input: `us passport: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "usa_passport", + input: `usa passport: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "united_states_passport", + input: `united states passport: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "american_passport", + input: `american passport: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "passport_number_spaced", + input: `passport number: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "passport_no", + input: `passport no: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "passport_num", + input: `passport num: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "passport_hash", + input: `passport #: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "us_passport_number", + input: `us passport number: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "us_passport_no", + input: `us passport no: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "us_pass_no", + input: `us pass no: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "us_passport_hash", + input: `us passport #: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "us_travel_document", + input: `us travel document: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "us_travel_doc", + input: `us travel doc: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "united_states_travel_document", + input: `united states travel document: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "travel_document_number", + input: `travel document number: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "travel_doc_no", + input: `travel doc no: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "passport_book_number", + input: `passport book number: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + { + name: "case_insensitive", + input: `US PASSPORT: A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodLikely)}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := engine.Detect(t.Context(), strings.NewReader(tc.input)) + if err != nil { + t.Errorf("Detect() error: %v, want nil", err) + } + if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Detect() diff (-want +got):\n%s", diff) + } + }) + } +} + +func TestDetector_LowLikelihood(t *testing.T) { + engine, err := veles.NewDetectionEngine([]veles.Detector{NewDetector()}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + input string + want []veles.Secret + }{ + { + name: "valid_number_no_context_keyword", + input: `A12345678`, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodUnlikely)}, + }, + { + name: "multiple_matches", + input: `A12345678 123456789 Z98765432`, + want: []veles.Secret{ + buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodUnlikely), + buildExpectedResult([]byte("Z98765432"), sensitiveinformation.LikelihoodUnlikely), + }, + }, + { + name: "multiple_matches_long_gap", + input: "A12345678" + strings.Repeat(" ", 50000) + "Z98765432", + want: []veles.Secret{ + buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodUnlikely), + buildExpectedResult([]byte("Z98765432"), sensitiveinformation.LikelihoodUnlikely), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := engine.Detect(t.Context(), strings.NewReader(tc.input)) + if err != nil { + t.Errorf("Detect() error: %v, want nil", err) + } + if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Detect() diff (-want +got):\n%s", diff) + } + }) + } +} + +func TestDetector_NoMatch(t *testing.T) { + engine, err := veles.NewDetectionEngine([]veles.Detector{NewDetector()}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + input string + }{ + { + name: "invalid_number_missing_letter", + input: `12345678`, + }, + { + name: "invalid_number_missing_number", + input: `A1234567`, + }, + { + name: "invalid_number_dash_separator", + input: `A_12345678`, + }, + { + name: "invalid_number_space_separator", + input: `A 12345678`, + }, + { + name: "numeric_first_character", + input: `123456789`, + }, + { + name: "text_without_number", + input: `not a passport number`, + }, + { + name: "invalid_number_too_long", + input: `A123456789`, + }, + { + name: "invalid_second_character_alpha", + input: `AB2345678`, + }, + { + name: "invalid_number_dash_middle", + input: `A1234-678`, + }, + { + name: "invalid_number_space_middle", + input: `A1234 678`, + }, + { + name: "number_within_longer_string", + input: `asdfA12345678asdf`, + }, + { + name: "number_within_underscores", + input: `asdf_A12345678_asdf`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := engine.Detect(t.Context(), strings.NewReader(tc.input)) + if err != nil { + t.Errorf("Detect() error: %v, want nil", err) + } + if len(got) != 0 { + t.Errorf("Detect() got %v secrets, want 0", len(got)) + } + }) + } +} + +func TestDetector_OutsideSearchWindow(t *testing.T) { + engine, err := veles.NewDetectionEngine([]veles.Detector{NewDetector()}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + input string + want []veles.Secret + }{ + { + name: "long_log_csv", + input: `user_id,passport_number + 000,000 + 000,000 + 000,000 + 000,000 + 123,A12345678 + `, + want: []veles.Secret{buildExpectedResult([]byte("A12345678"), sensitiveinformation.LikelihoodUnlikely)}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := engine.Detect(t.Context(), strings.NewReader(tc.input)) + if err != nil { + t.Errorf("Detect() error: %v, want nil", err) + } + + if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Detect() diff (-want +got):\n%s", diff) + } + }) + } +} + +func buildExpectedResult(blob []byte, likelihood sensitiveinformation.Likelihood) sensitiveinformation.SensitiveInformation { + return sensitiveinformation.SensitiveInformation{ + InfoType: sensitiveinformation.InfoType{ + Name: "US_PASSPORT_NUMBER", + Sensitivity: sensitiveinformation.SensitivityLevelHigh, + }, + Likelihood: likelihood, + Raw: blob, + } +}