///|
// Detector family module, split out of scan.mbt for navigability.
///|
fn scan_bearer(text : String, lower : String, out : Array[Finding]) -> Unit {
for i = 0; i + 7 < text.length(); i = i + 1 {
if starts_at(lower, i, "bearer ") {
let start = i + 7
let end = trim_value_end(text, start)
// Skip previously produced markers so repeated redaction stays idempotent
// under every style, including PreserveLast4.
if end - start >= 8 && !starts_at(text, start, "[REDACTED") {
push_finding(out, BearerToken, start, end, High)
}
}
}
}
///|
fn scan_jwt(text : String, out : Array[Finding]) -> Unit {
for i = 0; i + 12 < text.length(); i = i + 1 {
if starts_at(text, i, "eyJ") {
let mut end = i
let mut dots = 0
while end < text.length() {
let c = text[end].to_int()
if c == 46 {
dots += 1
end += 1
} else if is_base64url(c) {
end += 1
} else {
break
}
}
if dots == 2 && end - i >= 16 {
push_finding(out, Jwt, i, end, High)
}
}
}
}
///|
/// A PEM footer runs to the closing `-----` dash run, not to end of line:
/// trailing prose on the same line must stay outside the finding. Falls back
/// to line end when the footer is truncated.
fn pem_footer_end(text : String, from : Int) -> Int {
let mut k = from
while k + 5 <= text.length() && !starts_at(text, k, "-----") {
k += 1
}
if k + 5 <= text.length() {
return k + 5
}
let mut e = from
while e < text.length() && text[e].to_int() != 10 && text[e].to_int() != 13 {
e += 1
}
e
}
///|
fn scan_private_keys(text : String, out : Array[Finding]) -> Unit {
let begins = [
"-----BEGIN PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----",
"-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----",
"-----BEGIN PGP PRIVATE KEY BLOCK-----", "-----BEGIN DSA PRIVATE KEY-----",
]
for i = 0; i < text.length(); i = i + 1 {
for begin in begins {
if starts_at(text, i, begin) {
let mut end = i + begin.length()
let marker = "-----END"
while end < text.length() && !starts_at(text, end, marker) {
end += 1
}
if end < text.length() {
end = pem_footer_end(text, end + 7)
} else {
end = text.length()
}
push_finding(out, PrivateKey, i, end, High)
}
}
}
}
///|
/// PEM certificate and CSR blocks: everything from a BEGIN marker through
/// the end of its END line is public material worth flagging for hygiene.
fn scan_certificates(text : String, out : Array[Finding]) -> Unit {
let begins = [
"-----BEGIN CERTIFICATE-----", "-----BEGIN TRUSTED CERTIFICATE-----", "-----BEGIN CERTIFICATE REQUEST-----",
]
for i = 0; i < text.length(); i = i + 1 {
for begin in begins {
if starts_at(text, i, begin) {
let mut end = i + begin.length()
while end < text.length() && !starts_at(text, end, "-----END") {
end += 1
}
if end < text.length() {
end = pem_footer_end(text, end + 7)
} else {
end = text.length()
}
push_finding(out, PublicKey, i, end, High)
}
}
}
}
///|
/// Public key material: `ssh-rsa`, `ssh-ed25519`, and `ecdsa-sha2-*` key
/// lines whose base64 body is at least fifty characters. Public keys are not
/// secrets, but they identify infrastructure and are flagged for hygiene.
fn scan_public_keys(text : String, out : Array[Finding]) -> Unit {
let heads = ["ssh-rsa ", "ssh-ed25519 ", "ecdsa-sha2-"]
for i = 0; i < text.length(); i = i + 1 {
for head in heads {
if starts_at(text, i, head) {
let body_start = i + head.length()
let mut end = body_start
while end < text.length() && is_base64url(text[end].to_int()) {
end += 1
}
if end - body_start >= 50 {
push_finding(out, PublicKey, i, end, High)
}
}
}
}
scan_certificates(text, out)
}
///|
fn scan_url_credentials(text : String, out : Array[Finding]) -> Unit {
for i = 0; i + 3 < text.length(); i = i + 1 {
if starts_at(text, i, "://") {
let authority_start = i + 3
let mut at = authority_start
let mut colon = -1
while at < text.length() &&
text[at].to_int() != 47 &&
text[at].to_int() != 32 &&
text[at].to_int() != 9 {
if text[at].to_int() == 58 && colon < 0 {
colon = at
}
if text[at].to_int() == 64 {
break
}
at += 1
}
if at < text.length() &&
text[at].to_int() == 64 &&
colon > authority_start &&
colon + 1 < at {
push_finding(out, UrlCredential, authority_start, at, High)
}
}
}
}
///|
fn sensitive_key(key : String) -> Bool {
let k = ascii_lower(key)
k == "password" ||
k == "passwd" ||
k == "pwd" ||
k == "secret" ||
k == "token" ||
k == "api_key" ||
k == "apikey" ||
k == "private_key" ||
k == "authorization" ||
k == "client_secret" ||
k == "account_key" ||
k == "shared_access_key" ||
k == "sharedaccesskey" ||
k == "secret_key" ||
k == "private_token" ||
k == "passphrase" ||
k == "credential" ||
k == "auth_token" ||
k == "refresh_token" ||
k == "session_key" ||
k == "aws_secret_access_key"
}
///|
fn scan_assignments(
text : String,
lower : String,
out : Array[Finding],
) -> Unit {
let mut i = 0
while i < text.length() {
if is_ascii_letter(text[i].to_int()) || text[i].to_int() == 95 {
let key_start = i
while i < text.length() &&
(
is_ascii_alnum(text[i].to_int()) ||
text[i].to_int() == 95 ||
text[i].to_int() == 45
) {
i += 1
}
let key = text[key_start:i].to_owned()
let mut sep = i
// A closing quote right after the key is JSON/object spelling
// ("password": value); step over it before looking for the separator.
if sep < text.length() &&
(text[sep].to_int() == 34 || text[sep].to_int() == 39) {
sep += 1
}
while sep < text.length() &&
(text[sep].to_int() == 32 || text[sep].to_int() == 9) {
sep += 1
}
if sensitive_key(key) &&
sep < text.length() &&
(text[sep].to_int() == 61 || text[sep].to_int() == 58) {
let mut start = sep + 1
while start < text.length() &&
(text[start].to_int() == 32 || text[start].to_int() == 9) {
start += 1
}
if ascii_lower(key) == "authorization" &&
starts_at(lower, start, "bearer ") {
i = start + 7
} else if ascii_lower(key) == "authorization" &&
starts_at(lower, start, "basic ") {
// The Basic-auth detector owns the base64 blob; do not double-report.
i = start + 6
} else if starts_at(text, start, "[REDACTED") {
i = start + 9
} else if start < text.length() &&
(text[start].to_int() == 34 || text[start].to_int() == 39) {
let quote = text[start].to_int()
start += 1
// A quoted marker is an already-redacted value; skip it so
// re-scanning redacted JSON stays idempotent.
if !starts_at(text, start, "[REDACTED") {
let mut end = start
while end < text.length() && text[end].to_int() != quote {
end += 1
}
push_finding(out, CredentialAssignment, start, end, High)
}
} else {
push_finding(
out,
CredentialAssignment,
start,
trim_value_end(text, start),
High,
)
}
}
} else {
i += 1
}
}
}
///|
/// HTTP Basic credentials in `Authorization: Basic ` headers. The
/// base64 blob decodes to user:password, so the whole value is secret even
/// though no single word looks like one.
fn scan_basic_auth(text : String, lower : String, out : Array[Finding]) -> Unit {
for i = 0; i + 20 < text.length(); i = i + 1 {
if starts_at(lower, i, "authorization: basic ") ||
starts_at(lower, i, "authorization basic ") {
let start = i + 21
let mut end = start
while end < text.length() && is_base64url(text[end].to_int()) {
end += 1
}
if end - start >= 16 && !starts_at(text, start, "[REDACTED") {
push_finding(out, CredentialAssignment, start, end, High)
}
}
}
}
///|
/// Incoming-webhook URLs: Slack `hooks.slack.com/services/T.../B.../token`
/// and Discord `discord.com/api/webhooks//`. The path structure
/// and segment shapes are validated, not just the host name.
fn scan_webhook_urls(
text : String,
lower : String,
out : Array[Finding],
) -> Unit {
let anchors = ["hooks.slack.com/services/", "discord.com/api/webhooks/"]
for i = 0; i < text.length(); i = i + 1 {
for anchor in anchors {
if starts_at(lower, i, anchor) {
let start = i
let mut end = i + anchor.length()
let mut segments = 1
let mut seg_start = end
let mut shapes_ok = true
let seg_lengths : Array[Int] = []
while end < text.length() {
let c = text[end].to_int()
if is_ascii_alnum(c) || c == 45 || c == 95 {
end += 1
} else if c == 47 && segments < 4 {
seg_lengths.push(end - seg_start)
segments += 1
end += 1
seg_start = end
} else {
break
}
}
seg_lengths.push(end - seg_start)
if anchor == "hooks.slack.com/services/" {
shapes_ok = segments == 3 &&
seg_lengths[0] >= 8 &&
seg_lengths[0] <= 12 &&
seg_lengths[1] >= 8 &&
seg_lengths[1] <= 12 &&
seg_lengths[2] >= 20
} else {
shapes_ok = segments == 2 &&
seg_lengths[0] >= 17 &&
seg_lengths[1] >= 50
}
if shapes_ok {
push_finding(out, WebhookUrl, start, end, High)
}
}
}
}
}