///|
pub fn scan_project(root : String, config : Config) -> ScanResult {
let findings : Array[Finding] = []
let errors : Array[String] = []
let mut files_scanned = 0
let mod_imports = analyze_mod_imports(root)
let interproc_ctx = InterProceduralContext::new()
let mut has_web = false
let incremental = config.changed_files.length() > 0
let changed_set : Map[String, Bool] = Map([])
if incremental {
for f in config.changed_files {
changed_set[f] = true
}
}
// Phase 1: Regular rules + collect function summaries
walk_packages(root, config, fn(pkg_dir, mbt_files) {
let pkg_imports = analyze_imports(pkg_dir)
let imports = merge_imports(mod_imports, pkg_imports)
if imports.has_mocket || imports.has_crescent || imports.has_rabbita {
has_web = true
}
for file in mbt_files {
let source = @fs.read_file_to_string(file) catch {
_ => {
errors.push("Failed to read " + file)
continue
}
}
let (impls, reports) = @parser.parse_string(source, name=file)
for report in reports {
errors.push("Parse error in " + file + ": " + report.to_string())
}
collect_func_summaries(source, file, interproc_ctx)
let should_scan = if incremental {
is_changed_file(file, changed_set)
} else {
true
}
if should_scan {
files_scanned += 1
run_all_rules(impls, file, source, findings, imports, config)
}
}
})
// Phase 2: Cross-function taint analysis (only for web projects)
if has_web && interproc_ctx.summaries.length() > 0 {
walk_packages(root, config, fn(pkg_dir, mbt_files) {
let pkg_imports = analyze_imports(pkg_dir)
let imports = merge_imports(mod_imports, pkg_imports)
if imports.has_mocket || imports.has_crescent || imports.has_rabbita {
for file in mbt_files {
if incremental && !is_changed_file(file, changed_set) {
continue
}
let source = @fs.read_file_to_string(file) catch { _ => continue }
let (impls, _) = @parser.parse_string(source, name=file)
run_cross_func_taint(
impls, file, source, findings, interproc_ctx, imports,
)
}
}
})
}
{ findings: dedup_findings(findings), files_scanned, errors }
}
///|
fn dedup_findings(findings : Array[Finding]) -> Array[Finding] {
let seen : Map[String, Bool] = Map([])
let result : Array[Finding] = []
for f in findings {
let key = f.file + ":" + f.fingerprint + ":" + rule_id_str(f.rule_id)
if !seen.contains(key) {
seen[key] = true
result.push(f)
}
}
result
}
///|
fn run_all_rules(
impls : @list.List[@syntax.Impl],
file : String,
source : String,
findings : Array[Finding],
imports : ImportContext,
config : Config,
) -> Unit {
let cwe116_id = "CWE-116/replace-escaping"
if config.is_enabled(cwe116_id) {
run_cwe116(
impls,
file,
source,
findings,
config.get_severity(cwe116_id, cwe116_info().default_severity),
)
}
let cwe094_id = "CWE-94/eval-extern"
if config.is_enabled(cwe094_id) {
run_cwe094(
impls,
file,
source,
findings,
config.get_severity(cwe094_id, cwe094_info().default_severity),
)
}
if imports.has_cmark {
let cwe079c_id = "CWE-79/cmark-unsafe"
if config.is_enabled(cwe079c_id) {
run_cwe079_cmark(
impls,
file,
source,
findings,
config.get_severity(cwe079c_id, cwe079_cmark_info().default_severity),
)
}
}
if imports.has_rabbita {
let cwe079i_id = "CWE-79/inner-html"
if config.is_enabled(cwe079i_id) {
run_cwe079_inner_html(
impls,
file,
source,
findings,
config.get_severity(
cwe079i_id,
cwe079_inner_html_info().default_severity,
),
)
}
}
if imports.has_mocket || imports.has_crescent {
let cwe942_id = "CWE-942/cors-credentials"
if config.is_enabled(cwe942_id) {
run_cwe942(
impls,
file,
source,
findings,
config.get_severity(cwe942_id, cwe942_info().default_severity),
)
}
let cwe614_id = "CWE-614/cookie-attrs"
if config.is_enabled(cwe614_id) {
run_cwe614(
impls,
file,
source,
findings,
config.get_severity(cwe614_id, cwe614_info().default_severity),
)
}
let cwe346_id = "CWE-346/ws-origin"
if config.is_enabled(cwe346_id) {
run_cwe346(
impls,
file,
source,
findings,
config.get_severity(cwe346_id, cwe346_info().default_severity),
)
}
}
// CWE-770 only for crescent (has max_request_body_bytes); mocket has no body limit API
if imports.has_crescent {
let cwe770_id = "CWE-770/no-body-limit"
if config.is_enabled(cwe770_id) {
run_cwe770(
impls,
file,
source,
findings,
config.get_severity(cwe770_id, cwe770_info().default_severity),
)
}
}
let cwe022_id = "CWE-22/path-concat"
if config.is_enabled(cwe022_id) {
run_cwe022(
impls,
file,
source,
findings,
config.get_severity(cwe022_id, cwe022_info().default_severity),
)
}
let cwe113_id = "CWE-113/crlf-injection"
if config.is_enabled(cwe113_id) {
run_cwe113(
impls,
file,
source,
findings,
config.get_severity(cwe113_id, cwe113_info().default_severity),
)
}
if imports.has_mocket || imports.has_crescent {
let cwe079t_id = "CWE-79/template-injection"
if config.is_enabled(cwe079t_id) {
run_cwe079_template(
impls,
file,
source,
findings,
config.get_severity(cwe079t_id, cwe079_template_info().default_severity),
)
}
}
// General-purpose rules — skip for standard library modules (core/x/parser/lexer)
// where unsafe calls and abort are intentional implementation details
if !imports.is_stdlib {
let cwe676_id = "CWE-676/unsafe-call"
if config.is_enabled(cwe676_id) {
run_cwe676(
impls,
file,
source,
findings,
config.get_severity(cwe676_id, cwe676_info().default_severity),
)
}
let cwe248_id = "CWE-248/panic-reachable"
if config.is_enabled(cwe248_id) {
run_cwe248(
impls,
file,
source,
findings,
config.get_severity(cwe248_id, cwe248_info().default_severity),
)
}
let cwe704_id = "CWE-704/unsafe-cast"
if config.is_enabled(cwe704_id) {
run_cwe704(
impls,
file,
source,
findings,
config.get_severity(cwe704_id, cwe704_info().default_severity),
)
}
}
}
///|
fn walk_packages(
root : String,
config : Config,
callback : (String, Array[String]) -> Unit,
) -> Unit {
walk_dir(root, config, callback)
}
///|
fn walk_dir(
dir : String,
config : Config,
callback : (String, Array[String]) -> Unit,
) -> Unit {
let entries = @fs.read_dir(dir) catch { _ => return }
let mbt_files : Array[String] = []
let subdirs : Array[String] = []
for entry in entries {
let full = @path.Path(dir).join(@path.Path(entry)).0
if should_exclude(entry, config) {
continue
}
let is_dir = @fs.is_dir(full) catch { _ => false }
if is_dir {
subdirs.push(full)
} else if entry.has_suffix(".mbt") &&
!entry.has_suffix("_test.mbt") &&
!entry.has_suffix("_wbtest.mbt") {
if !should_exclude_file(entry, config) {
mbt_files.push(full)
}
}
}
if mbt_files.length() > 0 {
callback(dir, mbt_files)
}
for subdir in subdirs {
walk_dir(subdir, config, callback)
}
}
///|
fn should_exclude(name : String, config : Config) -> Bool {
if name.has_prefix(".") {
return true
}
for pat in config.exclude {
if name == pat {
return true
}
}
false
}
///|
fn should_exclude_file(name : String, config : Config) -> Bool {
for pat in config.exclude {
if pat.has_prefix("*") {
let suffix = pat[1:].to_owned()
if name.has_suffix(suffix) {
return true
}
}
if name == pat {
return true
}
}
false
}
///|
fn is_changed_file(file : String, changed_set : Map[String, Bool]) -> Bool {
if changed_set.contains(file) {
return true
}
for entry in changed_set {
if file.has_suffix(entry.0) || entry.0.has_suffix(file) {
return true
}
}
false
}
///|
pub fn load_changed_files(path : String) -> Array[String] {
let content = @fs.read_file_to_string(path) catch { _ => return [] }
let files : Array[String] = []
for line in content.split("\n") {
let trimmed = line.to_owned().trim().to_owned()
if trimmed.length() > 0 && trimmed.has_suffix(".mbt") {
files.push(trimmed)
}
}
files
}
///|
pub fn list_all_rules() -> Array[RuleInfo] {
[
cwe116_info(),
cwe079_cmark_info(),
cwe079_inner_html_info(),
cwe079_template_info(),
cwe094_info(),
cwe113_info(),
cwe942_info(),
cwe614_info(),
cwe770_info(),
cwe346_info(),
cwe022_info(),
cwe676_info(),
cwe248_info(),
cwe704_info(),
]
}