///|
// Detector family module, split out of scan.mbt for navigability.
///|
/// Recognized service token prefixes with per-family minimum total lengths.
/// All samples in tests are synthetic; real credentials never appear here.
fn builtin_token_specs() -> Array[TokenPrefixRule] {
[
{ prefix: "github_pat_", min_length: 22, },
{ prefix: "sk-ant-", min_length: 15, },
{ prefix: "sk_live_", min_length: 16, },
{ prefix: "rk_live_", min_length: 16, },
{ prefix: "pk_live_", min_length: 16, },
{ prefix: "dop_v1_", min_length: 15, },
{ prefix: "dop_v2_", min_length: 15, },
{ prefix: "shpat_", min_length: 14, },
{ prefix: "shpca_", min_length: 14, },
{ prefix: "shppa_", min_length: 14, },
{ prefix: "ghp_", min_length: 12, },
{ prefix: "gho_", min_length: 12, },
{ prefix: "ghu_", min_length: 12, },
{ prefix: "ghs_", min_length: 12, },
{ prefix: "ghr_", min_length: 12, },
{ prefix: "gha_", min_length: 12, },
{ prefix: "xoxb-", min_length: 13, },
{ prefix: "xoxp-", min_length: 13, },
{ prefix: "xoxc-", min_length: 13, },
{ prefix: "xoxa-", min_length: 13, },
{ prefix: "xoxr-", min_length: 13, },
{ prefix: "glpat-", min_length: 14, },
{ prefix: "npm_", min_length: 12, },
{ prefix: "AIza", min_length: 20, },
{ prefix: "AKIA", min_length: 20, },
{ prefix: "ASIA", min_length: 20, },
{ prefix: "SG.", min_length: 20, },
{ prefix: "sk-", min_length: 11, },
]
}
///|
fn rule_first(rule : TokenPrefixRule) -> Int {
rule.prefix[0].to_int()
}
///|
/// Bucket the rules by first character so the scan loop only compares a
/// position against candidates that can match at all.
fn bucket_specs(
specs : Array[TokenPrefixRule],
) -> Array[Array[TokenPrefixRule]] {
let buckets : Array[Array[TokenPrefixRule]] = []
for _ in 0..<256 {
buckets.push([])
}
for spec in specs {
if spec.prefix.length() >= 2 && spec.min_length >= spec.prefix.length() {
buckets[rule_first(spec)].push(spec)
}
}
for bucket in buckets {
sort_specs(bucket)
}
buckets
}
///|
/// Longest prefix first so `sk-ant-` wins over `sk-` at the same position.
fn sort_specs(items : Array[TokenPrefixRule]) -> Unit {
for i = 1; i < items.length(); i = i + 1 {
let value = items[i]
let mut j = i
while j > 0 && items[j - 1].prefix.length() < value.prefix.length() {
items[j] = items[j - 1]
j -= 1
}
items[j] = value
}
}
///|
fn scan_prefixed_tokens(
text : String,
extra : Array[TokenPrefixRule],
out : Array[Finding],
) -> Unit {
let specs = builtin_token_specs()
for rule in extra {
specs.push(rule)
}
let buckets = bucket_specs(specs)
for i = 0; i < text.length(); i = i + 1 {
let code = text[i].to_int()
// Every built-in prefix starts with an ASCII character; skip non-ASCII
// code units instead of indexing past the bucket table.
if code >= 256 {
continue
}
let bucket = buckets[code]
for spec in bucket {
if starts_at(text, i, spec.prefix) {
let mut end = i + spec.prefix.length()
while end < text.length() && is_token_char(text[end].to_int()) {
end += 1
}
if end - i >= spec.min_length {
push_finding(out, AccessToken, i, end, High)
}
break
}
}
}
}