///|
// 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: "lin_api_", min_length: 24, },
{ prefix: "glc_", min_length: 20, },
{ prefix: "cko_", min_length: 20, },
{ prefix: "key-", min_length: 32, },
{ prefix: "nfp_", min_length: 40, },
{ prefix: "pypi-AgEIcHlwaS5vcmc", min_length: 40, },
{ prefix: "glrt-", min_length: 25, },
{ prefix: "xoxe.x-xcs-", min_length: 30, },
{ prefix: "ATATTx", min_length: 40, },
{ prefix: "1//", min_length: 40, },
{ prefix: "LTAI", min_length: 20, },
{ prefix: "sntrys_", min_length: 40, },
{ 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 char_class_match(chars : CharClass, c : Int) -> Bool {
match chars {
AlnumUndash => is_ascii_alnum(c) || c == 45 || c == 95
HexLower => (c >= 48 && c <= 57) || (c >= 97 && c <= 102)
HexAny => is_ascii_hexdigit(c)
Base64Url => is_base64url(c)
Digits => is_ascii_digit(c)
}
}
///|
/// Scan caller-supplied shape rules: prefix, then a run in the rule's
/// character class whose total span lands inside the length window.
fn scan_pattern_rules(
text : String,
rules : Array[PatternRule],
out : Array[Finding],
) -> Unit {
for rule in rules {
if rule.prefix.length() < 2 ||
rule.min_length < rule.prefix.length() ||
rule.max_length < rule.min_length {
continue
}
let mut i = 0
while i + rule.prefix.length() <= text.length() {
if starts_at(text, i, rule.prefix) {
let mut end = i + rule.prefix.length()
while end < text.length() &&
end - i < rule.max_length &&
char_class_match(rule.chars, text[end].to_int()) {
end += 1
}
// Do not cut a match short in the middle of a class run: a longer
// neighbour would mean this shape does not own the token.
let tail_ok = end >= text.length() ||
!char_class_match(rule.chars, text[end].to_int())
if tail_ok && end - i >= rule.min_length {
push_finding(out, AccessToken, i, end, High)
i = end
} else {
i += 1
}
} else {
i += 1
}
}
}
}
///|
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
}
}
}
}