///|
fn push_finding(
out : Array[Finding],
kind : SensitiveKind,
start : Int,
end : Int,
confidence : Confidence,
) -> Unit {
if start >= 0 && end > start {
out.push({ kind, start, end, confidence, })
}
}
///|
fn sort_findings(items : Array[Finding]) -> Unit {
for i = 1; i < items.length(); i = i + 1 {
let value = items[i]
let mut j = i
while j > 0 &&
(
items[j - 1].start > value.start ||
(items[j - 1].start == value.start && items[j - 1].end < value.end)
) {
items[j] = items[j - 1]
j -= 1
}
items[j] = value
}
}
///|
fn non_overlapping(items : Array[Finding]) -> Array[Finding] {
sort_findings(items)
let out : Array[Finding] = []
for item in items {
if out.length() == 0 || item.start >= out[out.length() - 1].end {
out.push(item)
}
}
out
}
///|
/// Remove email findings whose domain is an IANA-reserved documentation
/// domain: example.com/net/org plus the test/example/invalid TLDs.
fn filter_example_domains(text : String, out : Array[Finding]) -> Unit {
let keep : Array[Finding] = []
for finding in out {
if finding.kind == Email && is_example_domain(text, finding) {
continue
}
keep.push(finding)
}
out.clear()
for finding in keep {
out.push(finding)
}
}
///|
fn is_example_domain(text : String, finding : Finding) -> Bool {
// Walk back from the '@' recorded inside the finding range.
let mut at = finding.end - 1
while at > finding.start && text[at].to_int() != 64 {
at -= 1
}
if text[at].to_int() != 64 {
return false
}
let domain = ascii_lower(text[at + 1:finding.end].to_owned())
domain == "example.com" ||
domain == "example.net" ||
domain == "example.org" ||
domain == "test" ||
domain == "example" ||
domain == "invalid"
}
///|
fn run_detectors(
text : String,
config : ScanConfig,
out : Array[Finding],
) -> Unit {
let needs_lower = config.detect_bearer ||
config.detect_assignment ||
config.detect_webhook
let lower = if needs_lower { ascii_lower(text) } else { "" }
if config.detect_access_token {
scan_prefixed_tokens(text, config.extra_prefixes, out)
}
if config.detect_bearer {
scan_bearer(text, lower, out)
}
if config.detect_jwt {
scan_jwt(text, out)
}
if config.detect_private_key {
scan_private_keys(text, out)
}
if config.detect_url_credential {
scan_url_credentials(text, out)
}
if config.detect_assignment {
scan_assignments(text, lower, out)
}
if config.detect_email {
scan_emails(text, out)
if config.suppress_example_domains {
filter_example_domains(text, out)
}
}
if config.detect_ipv4 {
scan_ipv4(text, config.suppress_version_ipv4, out)
}
if config.detect_ipv6 {
scan_ipv6(text, out)
}
if config.detect_phone {
scan_phones(text, out)
}
if config.detect_public_key {
scan_public_keys(text, out)
}
if config.detect_wallet {
scan_wallet_addresses(text, out)
}
if config.detect_webhook {
scan_webhook_urls(text, lower, out)
}
if config.detect_assignment {
scan_basic_auth(text, lower, out)
}
// Resident IDs outrank payment cards: an eighteen-digit checksummed
// identity number is stronger evidence than a Luhn pass, and Mastercard
// BINs (51-55) overlap common area codes.
if config.detect_resident_id {
scan_resident_ids(text, out)
}
if config.detect_mac {
scan_mac_addresses(text, out)
}
if config.detect_uuid {
scan_uuids(text, out)
}
if config.detect_payment_card {
scan_cards(text, config.require_known_card_prefix, out)
}
}
///|
/// Scan text without retaining sensitive values in the returned findings.
pub fn scan(
text : String,
policy? : ScanPolicy = ScanPolicy::standard(),
) -> Array[Finding] {
scan_with_config(text, policy.to_config())
}
///|
/// Scan with full control over detector families, suppressors and extra
/// prefixed-token rules.
pub fn scan_with_config(text : String, config : ScanConfig) -> Array[Finding] {
let out : Array[Finding] = []
run_detectors(text, config, out)
non_overlapping(out)
}
///|
/// Add caller-known exact secrets to the built-in detector set. The returned
/// findings do not carry rule labels or values. Empty and very short values are
/// deliberately ignored.
pub fn scan_with_rules(
text : String,
rules : Array[CustomRule],
policy? : ScanPolicy = ScanPolicy::standard(),
) -> Array[Finding] {
scan_full(text, rules, policy.to_config())
}
///|
/// Unified entry point combining exact-value rules with a full configuration.
///|
/// Count findings by kind without producing redacted text. Ordered by kind
/// name for deterministic output; counts only, never values.
pub fn findings_summary(
text : String,
config? : ScanConfig = ScanConfig::standard(),
) -> Array[KindCount] {
let findings = scan_with_config(text, config)
let rows : Array[KindCount] = []
for finding in findings {
let mut placed = false
for row in rows {
if row.kind == finding.kind {
row.count += 1
placed = true
break
}
}
if !placed {
rows.push({ kind: finding.kind, count: 1, })
}
}
for i = 1; i < rows.length(); i = i + 1 {
let value = rows[i]
let mut j = i
while j > 0 &&
lexicographic_before(
value.kind.to_string(),
rows[j - 1].kind.to_string(),
) {
rows[j] = rows[j - 1]
j -= 1
}
rows[j] = value
}
rows
}
///|
///|
/// Scan while keeping caller-certified literals in place: any finding that
/// overlaps a kept literal is dropped. Keep values shorter than four code
/// units are ignored so a tiny string cannot neuter whole families.
pub fn scan_except(
text : String,
keep : Array[String],
config? : ScanConfig = ScanConfig::standard(),
) -> Array[Finding] {
let findings = scan_with_config(text, config)
let kept : Array[(Int, Int)] = []
for literal in keep {
if literal.length() >= 4 {
let mut i = 0
while i + literal.length() <= text.length() {
if starts_at(text, i, literal) {
kept.push((i, i + literal.length()))
i += literal.length()
} else {
i += 1
}
}
}
}
let out : Array[Finding] = []
for finding in findings {
let mut overlap = false
for span in kept {
if finding.start < span.1 && finding.end > span.0 {
overlap = true
break
}
}
if !overlap {
out.push(finding)
}
}
out
}
///|
///|
/// Human-readable, value-free finding report: one line per finding in the
/// form `KIND start-end CONFIDENCE`. Safe to print because it never
/// contains matched text.
pub fn explain(
text : String,
config? : ScanConfig = ScanConfig::standard(),
) -> Array[String] {
let lines : Array[String] = []
for finding in scan_with_config(text, config) {
lines.push(
finding.kind.to_string() +
" " +
finding.start.to_string() +
"-" +
finding.end.to_string() +
" " +
finding.confidence.to_string(),
)
}
lines
}
///|
pub fn scan_full(
text : String,
rules : Array[CustomRule],
config : ScanConfig,
) -> Array[Finding] {
let out = scan_with_config(text, config)
for rule in rules {
if rule.value.length() >= 4 {
let mut i = 0
while i + rule.value.length() <= text.length() {
if starts_at(text, i, rule.value) {
push_finding(out, CustomSecret, i, i + rule.value.length(), High)
i += rule.value.length()
} else {
i += 1
}
}
}
}
non_overlapping(out)
}