use super::BoundaryCategory;
use crate::analysis::parse::ParsedFile;
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::collections::HashSet;
use std::path::Path;
pub(super) fn classify_boundary(path: &str, custom: Option<&GlobSet>) -> Option<BoundaryCategory> {
let lower = path.to_ascii_lowercase();
let file_name = lower.rsplit('/').next().unwrap_or(lower.as_str());
if is_deploy_surface(&lower, file_name) {
return Some(BoundaryCategory::DeploySurface);
}
if is_supply_chain(file_name) {
return Some(BoundaryCategory::SupplyChain);
}
if is_secret_config(file_name) {
return Some(BoundaryCategory::SecretConfig);
}
let tokens = tokenize(path);
if is_request_trust(&tokens) {
return Some(BoundaryCategory::RequestTrust);
}
if is_access_control(&lower, &tokens) {
return Some(BoundaryCategory::AccessControl);
}
if let Some(set) = custom
&& set.is_match(path)
{
return Some(BoundaryCategory::Custom);
}
None
}
fn is_deploy_surface(lower: &str, file_name: &str) -> bool {
lower.contains(".github/workflows/")
|| lower.contains("/.circleci/")
|| lower.starts_with(".circleci/")
|| lower.ends_with(".tf")
|| lower.ends_with(".tfvars")
|| file_name == "containerfile"
|| file_name == "jenkinsfile"
|| file_name == "action.yml"
|| file_name == "action.yaml"
|| file_name == ".gitlab-ci.yml"
|| file_name == "azure-pipelines.yml"
|| file_name == "azure-pipelines.yaml"
|| file_name == "dockerfile"
|| file_name.starts_with("dockerfile.")
}
fn is_supply_chain(file_name: &str) -> bool {
matches!(
file_name,
"package.json"
| "package-lock.json"
| "yarn.lock"
| "pnpm-lock.yaml"
| "npm-shrinkwrap.json"
| "cargo.toml"
| "cargo.lock"
| "go.mod"
| "go.sum"
| "requirements.txt"
| "pyproject.toml"
| "poetry.lock"
| "pipfile"
| "pipfile.lock"
| "pom.xml"
| "build.gradle"
| "build.gradle.kts"
| "gemfile"
| "gemfile.lock"
)
}
fn is_secret_config(file_name: &str) -> bool {
file_name == ".env" || file_name.starts_with(".env.")
}
fn is_request_trust(tokens: &HashSet<String>) -> bool {
tokens.contains("cors") || tokens.contains("csp") || tokens.contains("helmet")
}
fn is_access_control(lower: &str, tokens: &HashSet<String>) -> bool {
const STRONG: &[&str] = &["oauth", "jwt", "rbac", "passport", "authentic", "authoriz"];
if STRONG.iter().any(|needle| lower.contains(needle)) {
return true;
}
const TOKENS: &[&str] = &[
"auth",
"authz",
"authn",
"login",
"logout",
"signin",
"signout",
"session",
"sessions",
"permission",
"permissions",
"acl",
"guard",
"guards",
"identity",
"credential",
"credentials",
];
TOKENS.iter().any(|token| tokens.contains(*token))
}
fn tokenize(text: &str) -> HashSet<String> {
let mut tokens = HashSet::new();
let mut current = String::new();
for ch in text.chars() {
if !ch.is_ascii_alphanumeric() {
flush(&mut current, &mut tokens);
continue;
}
if ch.is_ascii_uppercase()
&& current
.chars()
.last()
.is_some_and(|last| last.is_ascii_lowercase() || last.is_ascii_digit())
{
flush(&mut current, &mut tokens);
}
current.push(ch.to_ascii_lowercase());
}
flush(&mut current, &mut tokens);
tokens
}
fn flush(current: &mut String, tokens: &mut HashSet<String>) {
if !current.is_empty() {
tokens.insert(std::mem::take(current));
}
}
pub(super) fn build_custom_globset(patterns: &[String]) -> Option<GlobSet> {
if patterns.is_empty() {
return None;
}
let mut builder = GlobSetBuilder::new();
let mut added = false;
for pattern in patterns {
if let Ok(glob) = Glob::new(pattern) {
builder.add(glob);
added = true;
}
}
if !added {
return None;
}
builder.build().ok()
}
pub(super) fn classify_boundary_ast(path: &Path, content: &str) -> Option<BoundaryCategory> {
let language_label = crate::scan::language::detect_language(path)?;
let parsed = ParsedFile::new(content, Some(language_label));
let tree = parsed.tree()?;
walk_ast_for_boundary(tree.root_node(), content, language_label)
}
fn walk_ast_for_boundary(
node: tree_sitter::Node<'_>,
content: &str,
language_label: &str,
) -> Option<BoundaryCategory> {
if let Some(category) = match_node_for_boundary(node, content, language_label) {
return Some(category);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(category) = walk_ast_for_boundary(child, content, language_label) {
return Some(category);
}
}
None
}
const ACCESS_TOKENS: &[&str] = &[
"auth",
"authz",
"authn",
"login",
"logout",
"signin",
"signout",
"role",
"roles",
"permission",
"permissions",
"guard",
"guards",
"rbac",
"acl",
"jwt",
"jose",
];
const ACCESS_STRONG: &[&str] = &[
"oauth",
"authentic",
"authoriz",
"passport",
"jsonwebtoken",
"bcrypt",
"argon2",
"pwhash",
"passlib",
"authlib",
"flask_login",
"flask_jwt_extended",
"express-session",
"cookie-session",
"jwt-go",
"casbin",
"authboss",
"springframework.security",
"springsecurity",
"javax.security",
"jakarta.security",
"aspnetcore.authentication",
"aspnetcore.authorization",
"aspnetcore.identity",
"system.security",
"identitymodel",
"identityserver",
"cryptography",
];
const REQUEST_TRUST_TOKENS: &[&str] = &["cors", "csp", "helmet"];
fn text_names_boundary(text: &str, strong: &[&str], tokens: &[&str]) -> bool {
if strong.iter().any(|needle| text.contains(needle)) {
return true;
}
if tokens.is_empty() {
return false;
}
let found = tokenize(text);
tokens.iter().any(|token| found.contains(*token))
}
fn match_node_for_boundary(
node: tree_sitter::Node<'_>,
content: &str,
language: &str,
) -> Option<BoundaryCategory> {
let check_request_trust = match (language, node.kind()) {
("JavaScript" | "JavaScript React" | "TypeScript" | "TypeScript React", "decorator")
| ("Python", "decorator")
| ("Rust", "attribute_item" | "use_declaration")
| ("Java" | "Kotlin", "annotation")
| ("CSharp", "attribute" | "using_directive") => false,
(
"JavaScript" | "JavaScript React" | "TypeScript" | "TypeScript React",
"import_statement" | "export_statement" | "call_expression",
)
| ("Python", "import_statement" | "import_from_statement")
| ("Go", "import_spec" | "import_declaration")
| ("Java" | "Kotlin", "import_declaration") => true,
_ => return None,
};
let text = node
.utf8_text(content.as_bytes())
.ok()?
.to_ascii_lowercase();
if text_names_boundary(&text, ACCESS_STRONG, ACCESS_TOKENS) {
return Some(BoundaryCategory::AccessControl);
}
if check_request_trust && text_names_boundary(&text, &[], REQUEST_TRUST_TOKENS) {
return Some(BoundaryCategory::RequestTrust);
}
None
}