[pitboss] phase 09: Track J.7 + Track L.7 — OPEN_REDIRECT corpus + redirect-aware adapters

This commit is contained in:
pitboss 2026-05-18 02:32:13 -05:00
parent 5697763f28
commit b881af5d93
47 changed files with 2592 additions and 32 deletions

View file

@ -28,6 +28,13 @@ pub mod php_twig;
pub mod php_unserialize;
pub mod python_jinja2;
pub mod python_pickle;
pub mod redirect_go;
pub mod redirect_java;
pub mod redirect_js;
pub mod redirect_php;
pub mod redirect_python;
pub mod redirect_ruby;
pub mod redirect_rust;
pub mod ruby_erb;
pub mod ruby_marshal;
pub mod xpath_java;
@ -57,6 +64,13 @@ pub use php_twig::PhpTwigAdapter;
pub use php_unserialize::PhpUnserializeAdapter;
pub use python_jinja2::PythonJinja2Adapter;
pub use python_pickle::PythonPickleAdapter;
pub use redirect_go::RedirectGoAdapter;
pub use redirect_java::RedirectJavaAdapter;
pub use redirect_js::RedirectJsAdapter;
pub use redirect_php::RedirectPhpAdapter;
pub use redirect_python::RedirectPythonAdapter;
pub use redirect_ruby::RedirectRubyAdapter;
pub use redirect_rust::RedirectRustAdapter;
pub use ruby_erb::RubyErbAdapter;
pub use ruby_marshal::RubyMarshalAdapter;
pub use xpath_java::XpathJavaAdapter;

View file

@ -0,0 +1,104 @@
//! Go [`super::super::FrameworkAdapter`] matching HTTP-redirect sink
//! constructions (`http.Redirect`, `gin.Context.Redirect`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one of
//! the canonical Go HTTP redirect entry points and the surrounding
//! source imports `net/http` or the gin framework.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectGoAdapter;
const ADAPTER_NAME: &str = "redirect-go";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "Redirect" | "Redirect302" | "Redirect301")
}
fn source_imports_go_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"net/http",
b"github.com/gin-gonic/gin",
b"github.com/labstack/echo",
b"github.com/gofiber/fiber",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectGoAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Go
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_go_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_go(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_gin_redirect() {
let src: &[u8] = b"package vuln\n\nimport (\n\t\"net/http\"\n\t\"github.com/gin-gonic/gin\"\n)\n\
func Run(c *gin.Context, v string) {\n\tc.Redirect(http.StatusFound, v)\n}\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Run".into(),
callees: vec![crate::summary::CalleeSite::bare("Redirect")],
..Default::default()
};
assert!(RedirectGoAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"package vuln\n\nfunc Add(a, b int) int { return a + b }\n";
let tree = parse_go(src);
let summary = FuncSummary {
name: "Add".into(),
..Default::default()
};
assert!(RedirectGoAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,106 @@
//! Java [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`HttpServletResponse.sendRedirect`,
//! Spring `ResponseEntity` 302 builders).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical servlet redirect entry points and the
//! surrounding source imports a servlet API.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectJavaAdapter;
const ADAPTER_NAME: &str = "redirect-java";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "sendRedirect" | "redirect")
}
fn source_imports_servlet(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"javax.servlet",
b"jakarta.servlet",
b"HttpServletResponse",
b"org.springframework.http",
b"org.springframework.web.servlet",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectJavaAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Java
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_servlet(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_java(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_send_redirect() {
let src: &[u8] = b"import javax.servlet.http.HttpServletResponse;\n\
class C { void run(HttpServletResponse r, String v) { r.sendRedirect(v); } }\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("sendRedirect")],
..Default::default()
};
assert!(RedirectJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"class C { int add(int a, int b) { return a + b; } }\n";
let tree = parse_java(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectJavaAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,111 @@
//! JavaScript [`super::super::FrameworkAdapter`] matching
//! HTTP-redirect sink constructions (Express `res.redirect`,
//! Koa `ctx.redirect`, raw Node `res.writeHead(302, { Location })`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Node redirect entry points and the surrounding
//! source imports the matching framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectJsAdapter;
const ADAPTER_NAME: &str = "redirect-js";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "redirect" | "writeHead")
}
fn source_imports_node_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"require('express')",
b"require(\"express\")",
b"from 'express'",
b"from \"express\"",
b"require('koa')",
b"require(\"koa\")",
b"require('http')",
b"require(\"http\")",
b"res.redirect",
b"ctx.redirect",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectJsAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::JavaScript
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_node_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_express_redirect() {
let src: &[u8] = b"const express = require('express');\n\
function run(req, res, v) { res.redirect(v); }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("redirect")],
..Default::default()
};
assert!(RedirectJsAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"function add(a, b) { return a + b; }\n";
let tree = parse_js(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectJsAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,111 @@
//! PHP [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`header("Location: ...")`,
//! Symfony `RedirectResponse`, Slim `Response::withHeader`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical PHP redirect entry points and the surrounding
//! source imports a recognised framework / writes a `Location:`
//! header.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectPhpAdapter;
const ADAPTER_NAME: &str = "redirect-php";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(
last,
"redirect" | "withRedirect" | "RedirectResponse" | "header"
)
}
fn source_imports_php_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"Symfony\\Component\\HttpFoundation",
b"Slim\\Psr7",
b"Psr\\Http\\Message",
b"Location:",
b"RedirectResponse",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectPhpAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Php
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_php_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_header_location() {
let src: &[u8] =
b"<?php\nfunction run($v) { header(\"Location: \" . $v); exit; }\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("header")],
..Default::default()
};
assert!(RedirectPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"<?php\nfunction add($a, $b) { return $a + $b; }\n";
let tree = parse_php(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectPhpAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,111 @@
//! Python [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`flask.redirect`, Django
//! `HttpResponseRedirect`, FastAPI `RedirectResponse`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Python web-framework redirect entry points and
//! the surrounding source imports the matching framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectPythonAdapter;
const ADAPTER_NAME: &str = "redirect-python";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(
last,
"redirect" | "HttpResponseRedirect" | "RedirectResponse"
)
}
fn source_imports_python_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"from flask",
b"import flask",
b"from django.http",
b"from django.shortcuts",
b"from starlette",
b"from fastapi.responses",
b"from werkzeug",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectPythonAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Python
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_python_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_python(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_python::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_flask_redirect() {
let src: &[u8] = b"from flask import redirect\n\
def run(value):\n return redirect(value)\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("redirect")],
..Default::default()
};
assert!(RedirectPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b):\n return a + b\n";
let tree = parse_python(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectPythonAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,109 @@
//! Ruby [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (Rails `redirect_to`, Sinatra `redirect`,
//! `Rack::Response#redirect`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Ruby web-framework redirect entry points and
//! the surrounding source imports / references a recognised
//! framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectRubyAdapter;
const ADAPTER_NAME: &str = "redirect-ruby";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
matches!(last, "redirect" | "redirect_to" | "redirect!" )
}
fn source_imports_ruby_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"Rack::Response",
b"require 'rack",
b"require \"rack",
b"require 'sinatra",
b"require \"sinatra",
b"ActionController",
b"Rails",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectRubyAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Ruby
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_ruby_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_ruby(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_rack_redirect() {
let src: &[u8] = b"require 'rack'\n\
def run(value)\n resp = Rack::Response.new\n resp.redirect(value)\n resp\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("redirect")],
..Default::default()
};
assert!(RedirectRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"def add(a, b)\n a + b\nend\n";
let tree = parse_ruby(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectRubyAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,110 @@
//! Rust [`super::super::FrameworkAdapter`] matching HTTP-redirect
//! sink constructions (`axum::response::Redirect::to`, actix-web
//! `HttpResponse::Found().append_header(("Location", v))`).
//!
//! Phase 09 (Track J.7). Fires when the function body invokes one
//! of the canonical Rust web-framework redirect entry points and the
//! surrounding source imports the matching framework module.
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
use crate::evidence::EntryKind;
use crate::summary::FuncSummary;
use crate::symbol::Lang;
pub struct RedirectRustAdapter;
const ADAPTER_NAME: &str = "redirect-rust";
fn callee_is_redirect(name: &str) -> bool {
let last = name.rsplit_once("::").map(|(_, s)| s).unwrap_or(name);
let last = last.rsplit_once('.').map(|(_, s)| s).unwrap_or(last);
matches!(last, "to" | "redirect" | "temporary" | "permanent" | "Found")
}
fn source_imports_rust_web(file_bytes: &[u8]) -> bool {
const NEEDLES: &[&[u8]] = &[
b"use axum::",
b"axum::response::Redirect",
b"use actix_web::",
b"use rocket::",
b"use warp::",
b"Redirect::to",
b"Redirect::permanent",
b"Redirect::temporary",
];
NEEDLES
.iter()
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
}
impl FrameworkAdapter for RedirectRustAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
_ast: tree_sitter::Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
let matches_call = super::any_callee_matches(summary, callee_is_redirect);
let matches_source = source_imports_rust_web(file_bytes);
if matches_call && matches_source {
Some(FrameworkBinding {
adapter: ADAPTER_NAME.to_owned(),
kind: EntryKind::Function,
route: None,
request_params: Vec::new(),
response_writer: None,
middleware: Vec::new(),
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn fires_on_axum_redirect_to() {
let src: &[u8] =
b"use axum::response::Redirect;\n\nfn run(v: String) -> Redirect { Redirect::to(&v) }\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "run".into(),
callees: vec![crate::summary::CalleeSite::bare("to")],
..Default::default()
};
assert!(RedirectRustAdapter
.detect(&summary, tree.root_node(), src)
.is_some());
}
#[test]
fn skips_plain_function() {
let src: &[u8] = b"fn add(a: i32, b: i32) -> i32 { a + b }\n";
let tree = parse_rust(src);
let summary = FuncSummary {
name: "add".into(),
..Default::default()
};
assert!(RedirectRustAdapter
.detect(&summary, tree.root_node(), src)
.is_none());
}
}