// Inter-procedural taint analysis: tracks data flow across function boundaries.
// Phase 1: Text-based function summary collection (which params flow to sinks)
// Phase 2: AST-based call-site checking (tainted args matched against summaries)
///|
pub(all) struct FuncTaintSummary {
name : String
file : String
line : Int
params : Array[String]
param_sinks : Map[String, Array[TaintSink]]
}
///|
pub(all) struct InterProceduralContext {
summaries : Map[String, FuncTaintSummary]
}
///|
pub fn InterProceduralContext::new() -> InterProceduralContext {
{ summaries: Map([]) }
}
///|
priv struct IPFuncSig {
name : String
params : Array[String]
line : Int
}
// ── Phase 1: Function summary collection ──
///|
pub fn collect_func_summaries(
source : String,
file : String,
ctx : InterProceduralContext,
) -> Unit {
let sigs = ip_extract_func_sigs(source)
for sig in sigs {
let body = ip_extract_func_body(source, sig.line)
if body.length() > 0 {
let summary = ip_build_summary(sig, file, body)
if summary.param_sinks.length() > 0 {
ctx.summaries[summary.name] = summary
}
}
}
}
///|
fn ip_extract_func_sigs(source : String) -> Array[IPFuncSig] {
let results : Array[IPFuncSig] = []
let mut line_num = 0
for line in source.split("\n") {
line_num += 1
let owned = line.to_owned()
let trimmed = owned.trim()
if trimmed.has_prefix("//") || trimmed.length() == 0 {
continue
}
let trimmed_s = trimmed.to_owned()
if !ip_has_fn_keyword(trimmed_s) || trimmed_s.has_prefix("extern") {
continue
}
let name = ip_get_fn_name(trimmed_s)
if name.length() == 0 || !trimmed_s.contains("(") {
continue
}
let paren_content = ip_between_parens(trimmed_s)
let params = ip_parse_params(paren_content)
if params.length() > 0 {
results.push({ name, params, line: line_num })
}
}
results
}
///|
fn ip_has_fn_keyword(line : String) -> Bool {
line.has_prefix("fn ") ||
line.has_prefix("pub fn ") ||
line.has_prefix("priv fn ") ||
line.has_prefix("pub(all) fn ")
}
///|
fn ip_get_fn_name(line : String) -> String {
let pos = ip_indexof_str(line, "fn ", 0)
if pos < 0 {
return ""
}
let rest = line[pos + 3:].to_owned().trim().to_owned()
ip_extract_ident(rest)
}
///|
fn ip_extract_ident(s : String) -> String {
let buf = StringBuilder::new()
for i = 0; i < s.length(); i = i + 1 {
if ip_is_ident_ch(s[i]) {
buf.write_string(s[i].to_int().unsafe_to_char().to_string())
} else {
break
}
}
buf.to_string()
}
///|
fn ip_is_ident_ch(c : UInt16) -> Bool {
(c >= char16('a') && c <= char16('z')) ||
(c >= char16('A') && c <= char16('Z')) ||
(c >= char16('0') && c <= char16('9')) ||
c == char16('_')
}
///|
fn ip_between_parens(s : String) -> String {
let open = char16('(')
let close = char16(')')
let mut start = -1
let mut depth = 0
for i = 0; i < s.length(); i = i + 1 {
if s[i] == open {
if depth == 0 {
start = i
}
depth += 1
} else if s[i] == close {
depth -= 1
if depth == 0 && start >= 0 {
return s[start + 1:i].to_owned()
}
}
}
""
}
///|
fn ip_parse_params(params_str : String) -> Array[String] {
let names : Array[String] = []
if params_str.length() == 0 {
return names
}
for part in params_str.split(",") {
let seg = part.to_owned().trim()
if seg.length() == 0 || seg.has_prefix("self") {
continue
}
let clean = if seg.has_prefix("~") {
seg[1:].to_owned()
} else {
seg.to_owned()
}
let name = ip_extract_ident(clean)
if name.length() > 0 && name != "self" {
names.push(name)
}
}
names
}
///|
fn ip_extract_func_body(source : String, start_line : Int) -> String {
let lines : Array[String] = []
for line in source.split("\n") {
lines.push(line.to_owned())
}
let brace_open = char16('{')
let brace_close = char16('}')
let mut depth = 0
let mut started = false
let buf = StringBuilder::new()
let from = if start_line - 1 >= 0 { start_line - 1 } else { 0 }
for i = from; i < lines.length(); i = i + 1 {
let line = lines[i]
for j = 0; j < line.length(); j = j + 1 {
if line[j] == brace_open {
depth += 1
if !started {
started = true
}
} else if line[j] == brace_close {
depth -= 1
if started && depth == 0 {
return buf.to_string()
}
}
}
if started {
buf.write_string(line)
buf.write_char('\n')
}
}
buf.to_string()
}
///|
fn ip_build_summary(
sig : IPFuncSig,
file : String,
body : String,
) -> FuncTaintSummary {
let param_sinks : Map[String, Array[TaintSink]] = Map([])
for param in sig.params {
let all_names = ip_find_anamees(body, param)
let sinks : Array[TaintSink] = []
for name in all_names {
let found = ip_find_sinks_for_var(body, name)
for s in found {
if !ip_has_sink(sinks, s) {
sinks.push(s)
}
}
}
if sinks.length() > 0 {
param_sinks[param] = sinks
}
}
{ name: sig.name, file, line: sig.line, params: sig.params, param_sinks }
}
///|
fn ip_find_anamees(body : String, param : String) -> Array[String] {
let names : Array[String] = [param]
let patterns = ["let ", "let mut "]
for pat in patterns {
let mut pos = 0
while pos < body.length() {
let idx = ip_indexof_str(body, pat, pos)
if idx < 0 {
break
}
let rest_start = idx + pat.length()
if rest_start >= body.length() {
break
}
let after = body[rest_start:].to_owned()
let aname = ip_extract_ident(after)
if aname.length() > 0 {
let eq_pos = find_char_in_str(after, '=')
if eq_pos > 0 && eq_pos + 1 < after.length() {
let rhs = after[eq_pos + 1:].to_owned().trim().to_owned()
if ip_starts_with_word(rhs, param) {
names.push(aname)
}
}
}
pos = idx + 1
}
}
names
}
///|
fn ip_starts_with_word(s : String, word : String) -> Bool {
if !s.has_prefix(word) {
return false
}
if word.length() >= s.length() {
return true
}
!ip_is_ident_ch(s[word.length()])
}
///|
fn ip_find_sinks_for_var(body : String, var_name : String) -> Array[TaintSink] {
let sinks : Array[TaintSink] = []
if ip_var_in_call_args(body, var_name, "set_header") ||
ip_var_in_call_args(body, var_name, "add_header") ||
ip_var_in_call_args(body, var_name, "append_header") ||
ip_var_in_call_args(body, var_name, "set_response_header") {
sinks.push(HeaderValue)
}
if ip_var_in_call_args(body, var_name, "html") ||
ip_var_in_call_args(body, var_name, "inner_html") ||
ip_var_in_call_args(body, var_name, "set_inner_html") ||
ip_var_in_call_args(body, var_name, "render_html") {
sinks.push(HtmlOutput)
}
if ip_var_in_call_args(body, var_name, "redirect") ||
ip_var_in_call_args(body, var_name, "redirect_to") {
sinks.push(RedirectUrl)
}
if ip_var_in_call_args(body, var_name, "read_file") ||
ip_var_in_call_args(body, var_name, "write_file") ||
ip_var_in_call_args(body, var_name, "read_file_to_string") ||
ip_var_in_call_args(body, var_name, "open") {
sinks.push(FilePathConcat)
}
sinks
}
///|
fn ip_var_in_call_args(body : String, var_name : String, meth : String) -> Bool {
let search = meth + "("
let mut pos = 0
while pos < body.length() {
let idx = ip_indexof_str(body, search, pos)
if idx < 0 {
return false
}
let args_start = idx + search.length()
let args_end = ip_find_close_paren(body, args_start)
if args_end > args_start {
let args_text = body[args_start:args_end].to_owned()
if ip_has_word(args_text, var_name) {
return true
}
}
pos = idx + 1
}
false
}
///|
fn ip_has_sink(sinks : Array[TaintSink], s : TaintSink) -> Bool {
let s_str = taint_sink_str(s)
sinks.iter().any(fn(existing) { taint_sink_str(existing) == s_str })
}
// ── Phase 2: Cross-function taint checking ──
///|
priv struct CrossFuncVisitor {
findings : Array[Finding]
file : String
source : String
ctx : InterProceduralContext
tainted_vars : Map[String, TaintSource]
}
///|
pub fn run_cross_func_taint(
impls : @list.List[@syntax.Impl],
file : String,
source : String,
findings : Array[Finding],
ctx : InterProceduralContext,
_imports : ImportContext,
) -> Unit {
impls.each(fn(impl_) {
let visitor : CrossFuncVisitor = {
findings,
file,
source,
ctx,
tainted_vars: Map([]),
}
@syntax.IterVisitor::visit_Impl(visitor, impl_)
})
}
///|
fn ip_sink_to_rule_id(sink : TaintSink) -> RuleId {
let cwe = match sink {
HtmlOutput => 79
SqlQuery => 89
FilePathConcat => 22
HeaderValue => 113
CookieValue => 614
ShellCommand => 78
ExternJsEval => 94
RedirectUrl => 601
}
{ cwe, variant: "taint-" + taint_sink_str(sink) }
}
///|
impl @syntax.IterVisitor for CrossFuncVisitor with fn visit_Expr_DotApply(
env,
self~,
method_name~,
args~,
return_self~,
loc~,
) {
let mname = method_name.name
match classify_source_method(mname) {
Some(source) => {
let receiver = expr_to_var_name(self)
match receiver {
Some(name) => {
env.tainted_vars[name + "." + mname] = source
env.tainted_vars[name] = source
}
None => ()
}
}
None => ()
}
@syntax.IterVisitor::base(env).visit_Expr_DotApply(
self~,
method_name~,
args~,
return_self~,
loc~,
)
}
///|
impl @syntax.IterVisitor for CrossFuncVisitor with fn visit_Expr_Apply(
env,
func~,
args~,
loc~,
) {
let callee_name = match func {
Ident(id~, ..) =>
match id.name {
Ident(name~) => name
Dot(id~, ..) => id
}
Method(method_name~, ..) => method_name.name
_ => ""
}
if callee_name.length() > 0 {
match env.ctx.summaries.get(callee_name) {
Some(summary) => ip_check_call_site(env, summary, args, loc)
None => ()
}
}
@syntax.IterVisitor::base(env).visit_Expr_Apply(func~, args~, loc~)
}
///|
fn ip_check_call_site(
env : CrossFuncVisitor,
summary : FuncTaintSummary,
args : @list.List[@syntax.Argument],
loc : @basic.Location,
) -> Unit {
let mut pos_idx = 0
for arg in args {
let taint = ip_get_arg_taint(env, arg)
match taint {
Some(taint_source) => {
let param_name = match arg.kind {
Labelled(label) => label.name
_ =>
if pos_idx < summary.params.length() {
summary.params[pos_idx]
} else {
""
}
}
if param_name.length() > 0 {
match summary.param_sinks.get(param_name) {
Some(sinks) =>
for sink in sinks {
env.findings.push(
make_finding(
ip_sink_to_rule_id(sink),
Error,
Medium,
"Tainted data from " +
taint_source_str(taint_source) +
" flows through " +
summary.name +
"() to " +
taint_sink_str(sink) +
" sink without sanitization",
env.file,
env.source,
loc,
),
)
}
None => ()
}
}
}
None => ()
}
match arg.kind {
Labelled(_)
| LabelledPun(_)
| LabelledOption(..)
| LabelledOptionPun(..) => ()
_ => pos_idx += 1
}
}
}
///|
fn ip_get_arg_taint(
env : CrossFuncVisitor,
arg : @syntax.Argument,
) -> TaintSource? {
let arg_var = expr_to_var_name(arg.value)
match arg_var {
Some(var_name) =>
match env.tainted_vars.get(var_name) {
Some(s) => Some(s)
None => None
}
None => expr_to_source(arg.value)
}
}
///|
impl @syntax.IterVisitor for CrossFuncVisitor with fn visit_Expr_Let(
env,
pattern~,
expr~,
body~,
loc~,
) {
let var_name = pattern_to_var_name(pattern)
match var_name {
Some(name) => {
let source = expr_to_source(expr)
match source {
Some(s) => env.tainted_vars[name] = s
None => {
let rhs_var = expr_to_var_name(expr)
match rhs_var {
Some(from) =>
match env.tainted_vars.get(from) {
Some(s) => env.tainted_vars[name] = s
None => ()
}
None => ()
}
}
}
}
None => ()
}
@syntax.IterVisitor::base(env).visit_Expr_Let(pattern~, expr~, body~, loc~)
}
///|
impl @syntax.IterVisitor for CrossFuncVisitor with fn visit_Expr_LetMut(
env,
binder~,
ty~,
expr~,
body~,
loc~,
) {
let name = binder.name
let source = expr_to_source(expr)
match source {
Some(s) => env.tainted_vars[name] = s
None => {
let rhs_var = expr_to_var_name(expr)
match rhs_var {
Some(from) =>
match env.tainted_vars.get(from) {
Some(s) => env.tainted_vars[name] = s
None => ()
}
None => ()
}
}
}
@syntax.IterVisitor::base(env).visit_Expr_LetMut(
binder~,
ty~,
expr~,
body~,
loc~,
)
}
// ── String helpers ──
///|
fn ip_indexof_str(haystack : String, needle : String, from : Int) -> Int {
let n_len = needle.length()
if n_len == 0 || haystack.length() < n_len {
return -1
}
let limit = haystack.length() - n_len
for i = from; i <= limit; i = i + 1 {
let mut ok = true
for j = 0; j < n_len; j = j + 1 {
if haystack[i + j] != needle[j] {
ok = false
break
}
}
if ok {
return i
}
}
-1
}
///|
fn ip_find_close_paren(s : String, from : Int) -> Int {
let open = char16('(')
let close = char16(')')
let mut depth = 1
for i = from; i < s.length(); i = i + 1 {
if s[i] == open {
depth += 1
} else if s[i] == close {
depth -= 1
if depth == 0 {
return i
}
}
}
-1
}
///|
fn ip_has_word(text : String, word : String) -> Bool {
let mut pos = 0
while pos < text.length() {
let idx = ip_indexof_str(text, word, pos)
if idx < 0 {
return false
}
let before_ok = idx == 0 || !ip_is_ident_ch(text[idx - 1])
let after_idx = idx + word.length()
let after_ok = after_idx >= text.length() ||
!ip_is_ident_ch(text[after_idx])
if before_ok && after_ok {
return true
}
pos = idx + 1
}
false
}