use std::collections::BTreeSet;
use crate::DeclId;
use crate::calls::CallSite;
use crate::fact::{FactPayload, FactProvenance, FactStore};
use crate::flow::{ValueFlow, ValueSet};
use crate::flow_intra::FlowEnv;
use crate::table_stub::DeclLike;
pub fn emit_declaration_facts<I>(store: &mut FactStore, prov: &FactProvenance, decls: I) -> usize
where
I: IntoIterator<Item = (DeclId, String)>,
{
let before = store.len();
for (decl, logical_id) in decls {
let f = crate::fact::mint_fact(prov.clone(), FactPayload::Declaration { decl, logical_id });
store.push(f);
}
store.len() - before
}
pub fn emit_reference_facts<I>(store: &mut FactStore, prov: &FactProvenance, refs: I) -> usize
where
I: IntoIterator<Item = (DeclId, String)>,
{
let before = store.len();
for (from_decl, to_logical_id) in refs {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::Reference {
from_decl,
to_logical_id,
},
);
store.push(f);
}
store.len() - before
}
pub fn emit_call_facts(
store: &mut FactStore,
prov: &FactProvenance,
from_logical_id: &str,
calls: &[CallSite],
) -> usize {
let before = store.len();
for c in calls {
let to = c.callee_parts.join(".").to_ascii_lowercase();
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::DependencyEdge {
from_logical_id: from_logical_id.to_string(),
to_logical_id: to,
edge_kind: "Calls".to_string(),
},
);
store.push(f);
}
store.len() - before
}
pub fn emit_privilege_facts<I>(store: &mut FactStore, prov: &FactProvenance, grants: I) -> usize
where
I: IntoIterator<Item = (String, String, String)>,
{
let before = store.len();
for (grantee, privilege, on) in grants {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::Privilege {
grantee,
privilege,
on,
},
);
store.push(f);
}
store.len() - before
}
pub fn emit_dynamic_sql_facts<I>(store: &mut FactStore, prov: &FactProvenance, sites: I) -> usize
where
I: IntoIterator<Item = String>,
{
let before = store.len();
for site in sites {
let f = crate::fact::mint_fact(prov.clone(), FactPayload::DynamicSqlEvidence { site });
store.push(f);
}
store.len() - before
}
pub fn emit_unknown_facts<I>(store: &mut FactStore, prov: &FactProvenance, unknowns: I) -> usize
where
I: IntoIterator<Item = (String, String)>,
{
let before = store.len();
for (target_logical_id, reason) in unknowns {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::Opacity {
target_logical_id,
reason,
},
);
store.push(f);
}
store.len() - before
}
pub fn emit_flow_env_facts(
store: &mut FactStore,
prov: &FactProvenance,
unit_logical_id: &str,
env: &FlowEnv,
) -> usize {
emit_flow_facts(
store,
prov,
unit_logical_id,
env.iter()
.map(|(name, flow)| (name.to_string(), flow.clone())),
)
}
pub fn emit_flow_facts<I, N>(
store: &mut FactStore,
prov: &FactProvenance,
unit_logical_id: &str,
flows: I,
) -> usize
where
I: IntoIterator<Item = (N, ValueFlow)>,
N: Into<String>,
{
let before = store.len();
let unit = unit_logical_id.trim().to_string();
let mut rows: Vec<(String, ValueFlow)> = flows
.into_iter()
.map(|(name, flow)| (normalise_flow_name(name.into()), flow))
.collect();
rows.sort_by(|(left, _), (right, _)| left.cmp(right));
for (name, flow) in rows {
if let Some(value) = flow.constant.clone() {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::ConstantValue {
unit_logical_id: unit.clone(),
name: name.clone(),
value,
},
));
}
if !matches!(flow.value_set, ValueSet::Top) {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::ValueSet {
unit_logical_id: unit.clone(),
name: name.clone(),
value_set: flow.value_set.clone(),
},
));
}
if let Some(shape) = flow.string_shape.clone() {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::StringShape {
unit_logical_id: unit.clone(),
name: name.clone(),
shape,
},
));
}
if !flow.taint.kinds.is_empty() {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::Taint {
unit_logical_id: unit.clone(),
name: name.clone(),
kinds: flow.taint.kinds.clone(),
},
));
}
if !flow.taint.cleansed_by.is_empty() {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::Sanitizer {
unit_logical_id: unit.clone(),
name,
cleansed_by: flow.taint.cleansed_by,
},
));
}
}
store.len() - before
}
fn normalise_flow_name(name: String) -> String {
name.trim().to_ascii_uppercase()
}
pub fn emit_declarations_from<T: DeclLike>(
store: &mut FactStore,
prov: &FactProvenance,
source: &T,
) -> usize {
emit_declaration_facts(store, prov, source.iter_decls())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExceptionHandlerSite {
pub unit_logical_id: String,
pub scope: String,
pub body_class: String,
}
#[must_use]
fn classify_handler_body(body: &str) -> &'static str {
let norm = body.trim().to_ascii_lowercase();
let stmts: Vec<&str> = norm
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if stmts.is_empty() || stmts.iter().all(|s| s.eq(&"null")) {
return "noop";
}
if stmts
.iter()
.any(|s| s.eq(&"commit") || s.starts_with("commit "))
{
return "commit";
}
if stmts
.iter()
.any(|s| s.eq(&"rollback") || s.starts_with("rollback ") || s.starts_with("rollback to"))
{
return "rollback";
}
"other"
}
fn keyword_boundary_before(src: &str, at: usize) -> bool {
src[..at]
.chars()
.next_back()
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'))
}
#[must_use]
pub fn scan_exception_handlers(unit_logical_id: &str, source: &str) -> Vec<ExceptionHandlerSite> {
let lower = source.to_ascii_lowercase();
let Some(mut idx) = lower.find("exception") else {
return Vec::new();
};
loop {
let end = idx + "exception".len();
let boundary = keyword_boundary_before(&lower, idx)
&& lower[end..]
.chars()
.next()
.is_none_or(|c| !(c.is_alphanumeric() || c == '_'));
if boundary {
break;
}
match lower[end..].find("exception") {
Some(next) => idx = end + next,
None => return Vec::new(),
}
}
let section = &lower[idx + "exception".len()..];
let mut sites = Vec::new();
for chunk in section.split(" when ").skip(1) {
let Some((scope_raw, rest)) = chunk.split_once(" then ") else {
continue;
};
let body = rest
.split(" when ")
.next()
.unwrap_or(rest)
.rsplit_once(" end")
.map_or(rest, |(b, _)| b);
let scope_norm = scope_raw.split_whitespace().collect::<Vec<_>>().join(" ");
let scope = if scope_norm.split_whitespace().any(|w| w == "others") {
"others".to_string()
} else {
scope_norm
};
sites.push(ExceptionHandlerSite {
unit_logical_id: unit_logical_id.to_string(),
scope,
body_class: classify_handler_body(body).to_string(),
});
}
sites
}
pub fn emit_exception_handler_facts<I>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize
where
I: IntoIterator<Item = ExceptionHandlerSite>,
{
let before = store.len();
for site in sites {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::ExceptionHandler {
unit_logical_id: site.unit_logical_id,
scope: site.scope,
body_class: site.body_class,
},
);
store.push(f);
}
store.len() - before
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorForLoopSite {
pub unit_logical_id: String,
pub loop_var: String,
pub has_body_dml: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MissingInstrumentationSite {
pub unit_logical_id: String,
}
const INSTRUMENTATION_MARKERS: &[&str] = &[
"dbms_output.put_line",
"dbms_application_info",
"raise_application_error",
"apex_debug",
"logger.",
"log_",
".log(",
".info(",
".warn(",
".error(",
".debug(",
"audit_",
];
fn body_has_dml(body: &str) -> bool {
["insert ", "update ", "delete ", "merge "]
.iter()
.any(|kw| {
body.match_indices(kw)
.any(|(at, _)| keyword_boundary_before(body, at))
})
}
#[must_use]
pub fn scan_cursor_for_loops(unit_logical_id: &str, source: &str) -> Vec<CursorForLoopSite> {
let lower = source.to_ascii_lowercase();
let mut sites = Vec::new();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("for ") {
let at = search_from + rel;
search_from = at + 4;
if !keyword_boundary_before(&lower, at) {
continue;
}
let after = &lower[at + 4..];
let Some((var_raw, rest)) = after.split_once(" in ") else {
continue;
};
let loop_var = var_raw.trim();
if loop_var.is_empty() || loop_var.split_whitespace().count() != 1 {
continue;
}
let Some((in_clause, body_and_more)) = rest.split_once(" loop ") else {
continue;
};
if in_clause.contains("..") {
continue;
}
let ic = in_clause.trim();
let looks_cursor =
ic.contains("select") || ic.contains('(') || ic.split_whitespace().count() == 1;
if !looks_cursor {
continue;
}
let body = body_and_more
.split_once(" end loop")
.map_or(body_and_more, |(b, _)| b);
sites.push(CursorForLoopSite {
unit_logical_id: unit_logical_id.to_string(),
loop_var: loop_var.to_string(),
has_body_dml: body_has_dml(body),
});
}
sites
}
#[must_use]
pub fn scan_missing_instrumentation(
unit_logical_id: &str,
source: &str,
) -> Vec<MissingInstrumentationSite> {
let lower = source.to_ascii_lowercase();
let has_body = lower
.match_indices("begin")
.any(|(at, _)| keyword_boundary_before(&lower, at));
if !has_body {
return Vec::new();
}
if INSTRUMENTATION_MARKERS.iter().any(|m| lower.contains(m)) {
return Vec::new();
}
vec![MissingInstrumentationSite {
unit_logical_id: unit_logical_id.to_string(),
}]
}
pub fn emit_cursor_for_loop_facts<I>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize
where
I: IntoIterator<Item = CursorForLoopSite>,
{
let before = store.len();
for site in sites {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::CursorForLoop {
unit_logical_id: site.unit_logical_id,
loop_var: site.loop_var,
has_body_dml: site.has_body_dml,
},
);
store.push(f);
}
store.len() - before
}
pub fn emit_missing_instrumentation_facts<I>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize
where
I: IntoIterator<Item = MissingInstrumentationSite>,
{
let before = store.len();
for site in sites {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::MissingInstrumentation {
unit_logical_id: site.unit_logical_id,
},
);
store.push(f);
}
store.len() - before
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HardcodedCredentialSite {
pub unit_logical_id: String,
pub marker: String,
}
const CREDENTIAL_MARKERS: &[&str] = &[
"identified by",
"password",
"passwd",
"pwd",
"secret",
"api_key",
"apikey",
"credential",
"private_key",
];
pub(crate) fn mask_string_literals(lower: &str) -> String {
let bytes = lower.as_bytes();
let mut out = String::with_capacity(lower.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\'' {
out.push('\'');
i += 1;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
out.push_str("__");
i += 2;
continue;
}
out.push('\'');
i += 1;
break;
}
out.push('_');
i += 1;
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
#[must_use]
pub fn scan_hardcoded_credentials(
unit_logical_id: &str,
source: &str,
) -> Vec<HardcodedCredentialSite> {
let lower = mask_string_literals(&source.to_ascii_lowercase());
let mut sites = Vec::new();
for marker in CREDENTIAL_MARKERS {
let mut from = 0;
while let Some(rel) = lower[from..].find(marker) {
let at = from + rel;
from = at + marker.len();
let rest = &lower[at + marker.len()..];
let stmt = rest.split(';').next().unwrap_or(rest);
if let Some(q) = stmt.find('\'') {
if q <= 64 {
sites.push(HardcodedCredentialSite {
unit_logical_id: unit_logical_id.to_string(),
marker: (*marker).to_string(),
});
}
}
}
}
sites
}
pub fn emit_hardcoded_credential_facts<I>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize
where
I: IntoIterator<Item = HardcodedCredentialSite>,
{
let before = store.len();
for site in sites {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::HardcodedCredential {
unit_logical_id: site.unit_logical_id,
marker: site.marker,
},
);
store.push(f);
}
store.len() - before
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvokerRightsSite {
pub unit_logical_id: String,
}
#[must_use]
pub fn scan_invoker_rights(unit_logical_id: &str, source: &str) -> Vec<InvokerRightsSite> {
let masked = mask_string_literals(&source.to_ascii_lowercase());
let collapsed: String = masked.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.contains("authid current_user") {
vec![InvokerRightsSite {
unit_logical_id: unit_logical_id.to_string(),
}]
} else {
Vec::new()
}
}
pub fn emit_invoker_rights_facts<I>(store: &mut FactStore, prov: &FactProvenance, sites: I) -> usize
where
I: IntoIterator<Item = InvokerRightsSite>,
{
let before = store.len();
for site in sites {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::InvokerRights {
unit_logical_id: site.unit_logical_id,
},
);
store.push(f);
}
store.len() - before
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnitFactSite {
pub unit_logical_id: String,
}
fn collapsed_masked(source: &str) -> String {
mask_string_literals(&source.to_ascii_lowercase())
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn scan_ref_cursor_return(unit_logical_id: &str, source: &str) -> Vec<UnitFactSite> {
let c = collapsed_masked(source);
if c.contains("return sys_refcursor") || c.contains("return ref cursor") {
vec![UnitFactSite {
unit_logical_id: unit_logical_id.to_string(),
}]
} else {
Vec::new()
}
}
#[must_use]
pub fn scan_dml_in_function(unit_logical_id: &str, source: &str) -> Vec<UnitFactSite> {
let masked = mask_string_literals(&source.to_ascii_lowercase());
let is_function = masked
.match_indices("function")
.any(|(at, _)| keyword_boundary_before(&masked, at));
if is_function && body_has_dml(&masked) {
vec![UnitFactSite {
unit_logical_id: unit_logical_id.to_string(),
}]
} else {
Vec::new()
}
}
#[must_use]
pub fn scan_unbounded_bulk_collect(unit_logical_id: &str, source: &str) -> Vec<UnitFactSite> {
let masked = mask_string_literals(&source.to_ascii_lowercase());
let mut sites = Vec::new();
let mut from = 0;
while let Some(rel) = masked[from..].find("bulk collect into") {
let at = from + rel;
from = at + "bulk collect into".len();
let stmt = masked[at..].split(';').next().unwrap_or(&masked[at..]);
if !stmt.contains("limit") {
sites.push(UnitFactSite {
unit_logical_id: unit_logical_id.to_string(),
});
}
}
sites
}
fn emit_unit_facts<I, F>(store: &mut FactStore, prov: &FactProvenance, sites: I, mk: F) -> usize
where
I: IntoIterator<Item = UnitFactSite>,
F: Fn(String) -> FactPayload,
{
let before = store.len();
for site in sites {
let f = crate::fact::mint_fact(prov.clone(), mk(site.unit_logical_id));
store.push(f);
}
store.len() - before
}
pub fn emit_ref_cursor_return_facts<I: IntoIterator<Item = UnitFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
emit_unit_facts(store, prov, sites, |unit_logical_id| {
FactPayload::RefCursorReturn { unit_logical_id }
})
}
pub fn emit_dml_in_function_facts<I: IntoIterator<Item = UnitFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
emit_unit_facts(store, prov, sites, |unit_logical_id| {
FactPayload::DmlInFunction { unit_logical_id }
})
}
pub fn emit_unbounded_bulk_collect_facts<I: IntoIterator<Item = UnitFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
emit_unit_facts(store, prov, sites, |unit_logical_id| {
FactPayload::UnboundedBulkCollect { unit_logical_id }
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DetailFactSite {
pub unit_logical_id: String,
pub detail: String,
}
#[must_use]
pub fn scan_deprecated_features(unit_logical_id: &str, source: &str) -> Vec<DetailFactSite> {
let m = mask_string_literals(&source.to_ascii_lowercase());
let mut sites = Vec::new();
let mut push = |feature: &str| {
sites.push(DetailFactSite {
unit_logical_id: unit_logical_id.to_string(),
detail: feature.to_string(),
});
};
if m.match_indices("dbms_job")
.any(|(at, _)| keyword_boundary_before(&m, at))
{
push("dbms_job (deprecated; use DBMS_SCHEDULER)");
}
if m.contains("(+)") {
push("legacy (+) outer-join operator (use ANSI JOIN)");
}
if m.contains("commit work") || m.contains("rollback work") {
push("legacy `WORK` transaction-control keyword");
}
sites
}
#[must_use]
pub fn scan_deterministic_misuse(unit_logical_id: &str, source: &str) -> Vec<DetailFactSite> {
let m = mask_string_literals(&source.to_ascii_lowercase());
let is_deterministic = m
.match_indices("deterministic")
.any(|(at, _)| keyword_boundary_before(&m, at));
if !is_deterministic {
return Vec::new();
}
let mut sites = Vec::new();
let mut push = |c: &str| {
sites.push(DetailFactSite {
unit_logical_id: unit_logical_id.to_string(),
detail: c.to_string(),
});
};
if body_has_dml(&m) {
push("row-level DML");
}
for (needle, label) in [
("sysdate", "SYSDATE"),
("systimestamp", "SYSTIMESTAMP"),
("current_timestamp", "CURRENT_TIMESTAMP"),
("dbms_random", "DBMS_RANDOM"),
(".nextval", "sequence .NEXTVAL"),
] {
if m.contains(needle) {
push(label);
}
}
sites
}
pub fn emit_deprecated_feature_facts<I: IntoIterator<Item = DetailFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
let before = store.len();
for s in sites {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::DeprecatedFeature {
unit_logical_id: s.unit_logical_id,
feature: s.detail,
},
);
store.push(f);
}
store.len() - before
}
pub fn emit_deterministic_misuse_facts<I: IntoIterator<Item = DetailFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
let before = store.len();
for s in sites {
let f = crate::fact::mint_fact(
prov.clone(),
FactPayload::DeterministicMisuse {
unit_logical_id: s.unit_logical_id,
construct: s.detail,
},
);
store.push(f);
}
store.len() - before
}
#[must_use]
pub fn scan_mutating_table_trigger(unit_logical_id: &str, source: &str) -> Vec<DetailFactSite> {
let c = collapsed_masked(source);
if !c.contains("trigger") || !c.contains("for each row") {
return Vec::new();
}
let Some(trig_at) = c.find("trigger") else {
return Vec::new();
};
let after = &c[trig_at..];
let Some(on_rel) = after.find(" on ") else {
return Vec::new();
};
let tail = &after[on_rel + 4..];
let raw = tail
.split([' ', '(', '\n', '\t'])
.next()
.unwrap_or("")
.trim_end_matches(|ch: char| !(ch.is_alphanumeric() || ch == '_'));
if raw.is_empty() {
return Vec::new();
}
let table = raw.rsplit('.').next().unwrap_or(raw).to_string();
if table.is_empty() {
return Vec::new();
}
let body_refs = [
format!("from {table}"),
format!("update {table}"),
format!("insert into {table}"),
format!("delete from {table}"),
format!("merge into {table}"),
];
if body_refs.iter().any(|p| c.contains(p.as_str())) {
vec![DetailFactSite {
unit_logical_id: unit_logical_id.to_string(),
detail: table,
}]
} else {
Vec::new()
}
}
#[must_use]
pub fn scan_log_without_reraise(unit_logical_id: &str, source: &str) -> Vec<InvokerRightsSite> {
let lower = mask_string_literals(&source.to_ascii_lowercase());
let Some(mut idx) = lower.find("exception") else {
return Vec::new();
};
loop {
let end = idx + "exception".len();
let boundary = keyword_boundary_before(&lower, idx)
&& lower[end..]
.chars()
.next()
.is_none_or(|ch| !(ch.is_alphanumeric() || ch == '_'));
if boundary {
break;
}
match lower[end..].find("exception") {
Some(next) => idx = end + next,
None => return Vec::new(),
}
}
let section = &lower[idx + "exception".len()..];
for chunk in section.split(" when ").skip(1) {
let Some((_scope, rest)) = chunk.split_once(" then ") else {
continue;
};
let body = rest
.split(" when ")
.next()
.unwrap_or(rest)
.rsplit_once(" end")
.map_or(rest, |(b, _)| b);
let has_log = INSTRUMENTATION_MARKERS.iter().any(|m| body.contains(m));
let has_raise = body
.match_indices("raise")
.any(|(at, _)| keyword_boundary_before(body, at));
if has_log && !has_raise {
return vec![InvokerRightsSite {
unit_logical_id: unit_logical_id.to_string(),
}];
}
}
Vec::new()
}
#[must_use]
pub fn scan_cross_schema_write(unit_logical_id: &str, source: &str) -> Vec<DetailFactSite> {
let unit_schema = unit_logical_id
.split('.')
.next()
.unwrap_or("")
.to_ascii_lowercase();
let m = mask_string_literals(&source.to_ascii_lowercase());
let mut sites = Vec::new();
for lead in ["insert into ", "update ", "delete ", "merge into "] {
let mut from = 0;
while let Some(rel) = m[from..].find(lead) {
let at = from + rel;
from = at + lead.len();
if !keyword_boundary_before(&m, at) {
continue;
}
let mut rest = &m[at + lead.len()..];
if lead == "delete " {
rest = rest.trim_start();
if let Some(after_from) = rest.strip_prefix("from ") {
rest = after_from.trim_start();
}
}
let target = rest
.split([' ', '(', ';', '\n', '\t'])
.next()
.unwrap_or("")
.trim();
if let Some((schema, obj)) = target.split_once('.')
&& !schema.is_empty()
&& !obj.is_empty()
&& schema != unit_schema
&& schema.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
{
sites.push(DetailFactSite {
unit_logical_id: unit_logical_id.to_string(),
detail: format!("{schema}.{}", obj.split('.').next().unwrap_or(obj)),
});
}
}
}
sites
}
pub fn emit_mutating_table_trigger_facts<I: IntoIterator<Item = DetailFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
let before = store.len();
for s in sites {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::MutatingTableTrigger {
unit_logical_id: s.unit_logical_id,
table: s.detail,
},
));
}
store.len() - before
}
pub fn emit_log_without_reraise_facts<I: IntoIterator<Item = InvokerRightsSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
let before = store.len();
for s in sites {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::LogWithoutReraise {
unit_logical_id: s.unit_logical_id,
},
));
}
store.len() - before
}
pub fn emit_cross_schema_write_facts<I: IntoIterator<Item = DetailFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
let before = store.len();
for s in sites {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::CrossSchemaWrite {
unit_logical_id: s.unit_logical_id,
target: s.detail,
},
));
}
store.len() - before
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SynonymFactSite {
pub unit_logical_id: String,
pub synonym: String,
pub target: String,
}
const SENSITIVITY_MARKERS: &[&str] = &[
"password",
"passwd",
"pwd",
"credential",
"secret",
"token",
"apikey",
"api_key",
"private_key",
"ssn",
"salary",
"payroll",
"bank",
"account",
"acct",
"card",
"tax",
"patient",
"medical",
"wallet",
];
fn name_is_sensitive(name: &str) -> bool {
SENSITIVITY_MARKERS.iter().any(|m| name.contains(m))
}
#[must_use]
pub fn scan_sensitive_public_synonym(unit_logical_id: &str, source: &str) -> Vec<SynonymFactSite> {
let c = collapsed_masked(source);
let mut sites = Vec::new();
let mut from = 0;
while let Some(rel) = c[from..].find("public synonym ") {
let at = from + rel;
from = at + "public synonym ".len();
let rest = &c[at + "public synonym ".len()..];
let Some((syn_raw, after)) = rest.split_once(" for ") else {
continue;
};
let synonym = syn_raw
.split([' ', '(', ';', '\n', '\t'])
.next()
.unwrap_or("")
.rsplit('.')
.next()
.unwrap_or("")
.to_string();
let target = after
.split([' ', '(', ';', '\n', '\t'])
.next()
.unwrap_or("")
.trim_end_matches(';')
.to_string();
let tgt_name = target.rsplit('.').next().unwrap_or(&target);
if synonym.is_empty() || target.is_empty() {
continue;
}
if name_is_sensitive(&synonym) || name_is_sensitive(tgt_name) {
sites.push(SynonymFactSite {
unit_logical_id: unit_logical_id.to_string(),
synonym,
target,
});
}
}
sites
}
pub fn emit_sensitive_public_synonym_facts<I: IntoIterator<Item = SynonymFactSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
let before = store.len();
for s in sites {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::SensitivePublicSynonym {
unit_logical_id: s.unit_logical_id,
synonym: s.synonym,
target: s.target,
},
));
}
store.len() - before
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IsNullIndexedSite {
pub unit_logical_id: String,
pub column: String,
}
fn simple_ident(tok: &str) -> String {
tok.rsplit('.')
.next()
.unwrap_or(tok)
.trim_matches(|ch: char| !(ch.is_alphanumeric() || ch == '_'))
.to_string()
}
fn indexed_columns(c: &str) -> BTreeSet<String> {
let mut cols = BTreeSet::new();
let mut from = 0;
while let Some(rel) = c[from..].find("index ") {
let at = from + rel;
from = at + "index ".len();
let pre = &c[..at];
if !pre
.trim_end()
.rsplit([' ', '\n', '\t'])
.next()
.map(|w| w == "create" || w == "unique" || w == "bitmap")
.unwrap_or(false)
{
continue;
}
let rest = &c[at + "index ".len()..];
let Some(on_rel) = rest.find(" on ") else {
continue;
};
let after_on = &rest[on_rel + 4..];
let Some(lp) = after_on.find('(') else {
continue;
};
let Some(rp) = after_on[lp..].find(')') else {
continue;
};
for raw in after_on[lp + 1..lp + rp].split(',') {
let id = simple_ident(raw.split_whitespace().next().unwrap_or(""));
if !id.is_empty() {
cols.insert(id);
}
}
}
cols
}
#[must_use]
pub fn scan_is_null_on_indexed_column(
unit_logical_id: &str,
source: &str,
) -> Vec<IsNullIndexedSite> {
let c = collapsed_masked(source);
let indexed = indexed_columns(&c);
if indexed.is_empty() {
return Vec::new();
}
let mut flagged: BTreeSet<String> = BTreeSet::new();
let mut from = 0;
while let Some(rel) = c[from..].find(" is null") {
let at = from + rel;
from = at + " is null".len();
let col = simple_ident(c[..at].rsplit([' ', '(', ',']).next().unwrap_or(""));
if !col.is_empty() && indexed.contains(&col) {
flagged.insert(col);
}
}
flagged
.into_iter()
.map(|column| IsNullIndexedSite {
unit_logical_id: unit_logical_id.to_string(),
column,
})
.collect()
}
pub fn emit_is_null_on_indexed_column_facts<I: IntoIterator<Item = IsNullIndexedSite>>(
store: &mut FactStore,
prov: &FactProvenance,
sites: I,
) -> usize {
let before = store.len();
for s in sites {
store.push(crate::fact::mint_fact(
prov.clone(),
FactPayload::IsNullOnIndexedColumn {
unit_logical_id: s.unit_logical_id,
column: s.column,
},
));
}
store.len() - before
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calls::{CallContext, CallSite};
use crate::fact::FactKind;
use crate::flow::{ConstantValue, StringShape, Taint, TaintCleanser, TaintKind};
fn prov() -> FactProvenance {
FactProvenance {
component: "plsql-ir".into(),
component_version: "0.1.0".into(),
run_id: String::new(),
source_logical_id: None,
source_file: None,
}
}
fn flow_fixture_rows() -> Vec<(String, ValueFlow)> {
(0..10)
.map(|idx| {
let int_value = idx.to_string();
let next_value = (idx + 1).to_string();
(
format!("v_{idx:02}"),
ValueFlow {
taint: Taint {
kinds: vec![if idx % 2 == 0 {
TaintKind::UserInput
} else {
TaintKind::BindVariable
}],
cleansed_by: vec![if idx % 2 == 0 {
TaintCleanser::DbmsAssert
} else {
TaintCleanser::HexEncode
}],
},
constant: Some(ConstantValue::Int {
value: int_value.clone(),
}),
value_set: ValueSet::OneOf {
values: vec![
ConstantValue::Int { value: int_value },
ConstantValue::Int { value: next_value },
],
},
string_shape: Some(StringShape::InterpolatedWithFix {
literal_prefix: format!("select {idx} from "),
literal_suffix: String::from(" where id = :id"),
}),
},
)
})
.collect()
}
fn flow_payload_rows(store: &FactStore) -> Vec<String> {
store
.facts
.iter()
.filter_map(|fact| match &fact.payload {
FactPayload::ConstantValue {
unit_logical_id,
name,
value,
} => Some(format!(
"constant_value|{unit_logical_id}|{name}|{}",
constant_value_label(value)
)),
FactPayload::ValueSet {
unit_logical_id,
name,
value_set,
} => Some(format!(
"value_set|{unit_logical_id}|{name}|{}",
value_set_label(value_set)
)),
FactPayload::StringShape {
unit_logical_id,
name,
shape,
} => Some(format!(
"string_shape|{unit_logical_id}|{name}|{}",
string_shape_label(shape)
)),
FactPayload::Taint {
unit_logical_id,
name,
kinds,
} => Some(format!("taint|{unit_logical_id}|{name}|{kinds:?}")),
FactPayload::Sanitizer {
unit_logical_id,
name,
cleansed_by,
} => Some(format!(
"sanitizer|{unit_logical_id}|{name}|{cleansed_by:?}"
)),
_ => None,
})
.collect()
}
fn constant_value_label(value: &ConstantValue) -> String {
match value {
ConstantValue::Int { value } => format!("int:{value}"),
ConstantValue::Float { value } => format!("float:{value}"),
ConstantValue::Str { value } => format!("str:{value}"),
ConstantValue::Bool { value } => format!("bool:{value}"),
ConstantValue::Null => String::from("null"),
}
}
fn value_set_label(value_set: &ValueSet) -> String {
match value_set {
ValueSet::Top => String::from("top"),
ValueSet::Bottom => String::from("bottom"),
ValueSet::OneOf { values } => {
let labels: Vec<String> = values.iter().map(constant_value_label).collect();
format!("one_of:{}", labels.join(","))
}
ValueSet::Range { lo, hi } => {
format!(
"range:{}..{}",
constant_value_label(lo),
constant_value_label(hi)
)
}
}
}
fn string_shape_label(shape: &StringShape) -> String {
match shape {
StringShape::Literal { value } => format!("literal:{value}"),
StringShape::InterpolatedWithFix {
literal_prefix,
literal_suffix,
} => format!("fix:{literal_prefix}|{literal_suffix}"),
StringShape::FullyOpaque => String::from("fully_opaque"),
StringShape::Empty => String::from("empty"),
}
}
#[test]
fn declaration_facts_emitted_and_counted() {
let mut store = FactStore::default();
let n = emit_declaration_facts(
&mut store,
&prov(),
vec![
(DeclId::new(1), "hr.employees".to_string()),
(DeclId::new(2), "hr.departments".to_string()),
],
);
assert_eq!(n, 2);
assert_eq!(store.by_kind(FactKind::Declaration).count(), 2);
}
#[test]
fn declaration_facts_dedupe_identical_entries() {
let mut store = FactStore::default();
emit_declaration_facts(
&mut store,
&prov(),
vec![(DeclId::new(1), "hr.x".to_string())],
);
let n2 = emit_declaration_facts(
&mut store,
&prov(),
vec![(DeclId::new(1), "hr.x".to_string())],
);
assert_eq!(n2, 0);
assert_eq!(store.len(), 1);
}
#[test]
fn reference_facts_emitted() {
let mut store = FactStore::default();
let n = emit_reference_facts(
&mut store,
&prov(),
vec![(DeclId::new(3), "hr.audit_pkg".to_string())],
);
assert_eq!(n, 1);
assert_eq!(store.by_kind(FactKind::Reference).count(), 1);
}
#[test]
fn call_facts_join_callee_path() {
let mut store = FactStore::default();
let calls = vec![CallSite {
callee_parts: vec!["BILLING_PKG".into(), "POST_INVOICE".into()],
callee_display: "billing_pkg.post_invoice".into(),
arg_count: 2,
context: CallContext::Statement,
}];
let n = emit_call_facts(&mut store, &prov(), "hr.run_billing", &calls);
assert_eq!(n, 1);
let f = store.by_kind(FactKind::DependencyEdge).next().unwrap();
assert!(
matches!(
&f.payload,
FactPayload::DependencyEdge { from_logical_id, to_logical_id, edge_kind }
if from_logical_id == "hr.run_billing"
&& to_logical_id == "billing_pkg.post_invoice"
&& edge_kind == "Calls"
),
"unexpected DependencyEdge payload: {:?}",
f.payload
);
}
#[test]
fn mixed_families_filter_independently() {
let mut store = FactStore::default();
emit_declaration_facts(&mut store, &prov(), vec![(DeclId::new(1), "a".into())]);
emit_reference_facts(&mut store, &prov(), vec![(DeclId::new(1), "b".into())]);
emit_call_facts(
&mut store,
&prov(),
"a",
&[CallSite {
callee_parts: vec!["C".into()],
callee_display: "c".into(),
arg_count: 0,
context: CallContext::Statement,
}],
);
assert_eq!(store.by_kind(FactKind::Declaration).count(), 1);
assert_eq!(store.by_kind(FactKind::Reference).count(), 1);
assert_eq!(store.by_kind(FactKind::DependencyEdge).count(), 1);
assert_eq!(store.len(), 3);
}
#[test]
fn flow_fact_projection_covers_ten_fixtures_per_family() {
let mut store = FactStore::default();
let emitted = emit_flow_facts(&mut store, &prov(), "hr.flow_pkg", flow_fixture_rows());
assert_eq!(emitted, 50);
assert_eq!(store.by_kind(FactKind::ConstantValue).count(), 10);
assert_eq!(store.by_kind(FactKind::ValueSet).count(), 10);
assert_eq!(store.by_kind(FactKind::StringShape).count(), 10);
assert_eq!(store.by_kind(FactKind::Taint).count(), 10);
assert_eq!(store.by_kind(FactKind::Sanitizer).count(), 10);
}
#[test]
fn flow_fact_projection_has_golden_payload_snapshot() {
let mut store = FactStore::default();
let flow = ValueFlow {
taint: Taint {
kinds: vec![TaintKind::UserInput, TaintKind::DynamicSql],
cleansed_by: vec![TaintCleanser::DbmsAssert],
},
constant: Some(ConstantValue::Str {
value: String::from("select * from users"),
}),
value_set: ValueSet::Range {
lo: ConstantValue::Int {
value: String::from("1"),
},
hi: ConstantValue::Int {
value: String::from("9"),
},
},
string_shape: Some(StringShape::InterpolatedWithFix {
literal_prefix: String::from("select * from "),
literal_suffix: String::from(" where id = :id"),
}),
};
emit_flow_facts(&mut store, &prov(), "hr.flow_pkg", vec![("v_sql", flow)]);
assert_eq!(
flow_payload_rows(&store),
vec![
"constant_value|hr.flow_pkg|V_SQL|str:select * from users",
"value_set|hr.flow_pkg|V_SQL|range:int:1..int:9",
"string_shape|hr.flow_pkg|V_SQL|fix:select * from | where id = :id",
"taint|hr.flow_pkg|V_SQL|[UserInput, DynamicSql]",
"sanitizer|hr.flow_pkg|V_SQL|[DbmsAssert]",
]
);
}
#[test]
fn flow_fact_ids_are_stable_for_normalized_semantic_names() {
let flow = ValueFlow {
taint: Taint {
kinds: vec![TaintKind::UserInput],
cleansed_by: vec![TaintCleanser::DbmsAssert],
},
constant: Some(ConstantValue::Str {
value: String::from("safe_table"),
}),
value_set: ValueSet::OneOf {
values: vec![ConstantValue::Str {
value: String::from("safe_table"),
}],
},
string_shape: Some(StringShape::Literal {
value: String::from("safe_table"),
}),
};
let mut left = FactStore::default();
let mut right = FactStore::default();
emit_flow_facts(
&mut left,
&prov(),
"hr.flow_pkg",
vec![(" v_table ", flow.clone())],
);
emit_flow_facts(&mut right, &prov(), "hr.flow_pkg", vec![("V_TABLE", flow)]);
let left_ids: Vec<_> = left.facts.iter().map(|fact| fact.id.clone()).collect();
let right_ids: Vec<_> = right.facts.iter().map(|fact| fact.id.clone()).collect();
assert_eq!(left_ids, right_ids);
assert_eq!(left_ids.len(), 5);
}
#[test]
fn flow_env_projection_consumes_solver_output() {
let stmts = crate::lower_statement_body("v_sql := 'select * from ' || p_table;");
let env = crate::analyze_flow(
&stmts,
&crate::TaintSources {
user_input_names: vec![String::from("p_table")],
bind_names: vec![],
},
);
let mut store = FactStore::default();
emit_flow_env_facts(&mut store, &prov(), "hr.flow_pkg", &env);
assert_eq!(store.by_kind(FactKind::Taint).count(), 1);
assert_eq!(store.by_kind(FactKind::StringShape).count(), 1);
}
#[test]
fn privilege_facts_emitted_and_filterable() {
let mut store = FactStore::default();
let n = emit_privilege_facts(
&mut store,
&prov(),
vec![
("HR_ROLE".into(), "EXECUTE".into(), "hr.billing_pkg".into()),
("PUBLIC".into(), "SELECT".into(), "hr.audit_v".into()),
],
);
assert_eq!(n, 2);
assert_eq!(store.by_kind(FactKind::Privilege).count(), 2);
let f = store.by_kind(FactKind::Privilege).next().unwrap();
assert!(
matches!(
&f.payload,
FactPayload::Privilege { grantee, privilege, on }
if grantee == "HR_ROLE"
&& privilege == "EXECUTE"
&& on == "hr.billing_pkg"
),
"unexpected Privilege payload: {:?}",
f.payload
);
}
#[test]
fn dynamic_sql_facts_emitted() {
let mut store = FactStore::default();
let n = emit_dynamic_sql_facts(
&mut store,
&prov(),
vec![
"hr.run_dyn: EXECUTE IMMEDIATE <sql-like, 1 bind>".to_string(),
"hr.run_dyn2: OPEN cur FOR <opaque>".to_string(),
],
);
assert_eq!(n, 2);
assert_eq!(store.by_kind(FactKind::DynamicSqlEvidence).count(), 2);
}
#[test]
fn unknown_facts_carry_reason_evidence() {
let mut store = FactStore::default();
let n = emit_unknown_facts(
&mut store,
&prov(),
vec![
("hr.remote_call".into(), "DbLinkRemoteObject".into()),
("hr.wrapped_pkg".into(), "WrappedSource".into()),
],
);
assert_eq!(n, 2);
let f = store.by_kind(FactKind::Opacity).next().unwrap();
assert!(
matches!(
&f.payload,
FactPayload::Opacity { target_logical_id, reason }
if target_logical_id == "hr.remote_call"
&& reason == "DbLinkRemoteObject"
),
"unexpected Opacity payload: {:?}",
f.payload
);
}
#[test]
fn fact004_families_dedupe_and_filter_independently() {
let mut store = FactStore::default();
emit_privilege_facts(
&mut store,
&prov(),
vec![("R".into(), "EXECUTE".into(), "o".into())],
);
let dup = emit_privilege_facts(
&mut store,
&prov(),
vec![("R".into(), "EXECUTE".into(), "o".into())],
);
assert_eq!(dup, 0);
emit_dynamic_sql_facts(&mut store, &prov(), vec!["site".into()]);
emit_unknown_facts(&mut store, &prov(), vec![("t".into(), "r".into())]);
assert_eq!(store.by_kind(FactKind::Privilege).count(), 1);
assert_eq!(store.by_kind(FactKind::DynamicSqlEvidence).count(), 1);
assert_eq!(store.by_kind(FactKind::Opacity).count(), 1);
assert_eq!(store.len(), 3);
}
struct FakeDeclSource;
impl DeclLike for FakeDeclSource {
fn iter_decls(&self) -> Vec<(DeclId, String)> {
vec![
(DeclId::new(10), "hr.p1".into()),
(DeclId::new(11), "hr.p2".into()),
]
}
}
#[test]
fn emit_declarations_from_trait_source() {
let mut store = FactStore::default();
let n = emit_declarations_from(&mut store, &prov(), &FakeDeclSource);
assert_eq!(n, 2);
}
#[test]
fn classify_handler_body_buckets() {
assert_eq!(classify_handler_body(" NULL; "), "noop");
assert_eq!(classify_handler_body("null;null;"), "noop");
assert_eq!(classify_handler_body(""), "noop");
assert_eq!(classify_handler_body("commit;"), "commit");
assert_eq!(classify_handler_body("rollback to sp1;"), "rollback");
assert_eq!(classify_handler_body("rollback; null;"), "rollback");
assert_eq!(classify_handler_body("log_error(sqlerrm);"), "other");
}
#[test]
fn scan_when_others_then_null_is_noop_others() {
let src = "begin do_work; exception when others then null; end;";
let sites = scan_exception_handlers("hr.pkg.run", src);
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].scope, "others");
assert_eq!(sites[0].body_class, "noop");
assert_eq!(sites[0].unit_logical_id, "hr.pkg.run");
}
#[test]
fn scan_named_handler_and_commit_classified() {
let src = "BEGIN x; EXCEPTION WHEN no_data_found THEN COMMIT; WHEN OTHERS THEN raise; END;";
let sites = scan_exception_handlers("hr.p", src);
assert_eq!(sites.len(), 2);
assert_eq!(sites[0].scope, "no_data_found");
assert_eq!(sites[0].body_class, "commit");
assert_eq!(sites[1].scope, "others");
assert_eq!(sites[1].body_class, "other");
}
#[test]
fn scan_ignores_identifier_containing_exception() {
let src = "declare bad_exception number; begin null; end;";
assert!(scan_exception_handlers("hr.p", src).is_empty());
}
#[test]
fn emit_exception_handler_facts_pushes_typed_facts() {
let mut store = FactStore::default();
let sites = scan_exception_handlers(
"hr.pkg.run",
"begin go; exception when others then null; end;",
);
let n = emit_exception_handler_facts(&mut store, &prov(), sites);
assert_eq!(n, 1);
assert_eq!(store.by_kind(FactKind::ExceptionHandler).count(), 1);
}
#[test]
fn scan_cursor_for_loop_query_form_detected() {
let s = scan_cursor_for_loops(
"hr.pkg.p",
"begin for r in (select id from emps) loop dbms_output.put_line(r.id); end loop; end;",
);
assert_eq!(s.len(), 1);
assert_eq!(s[0].loop_var, "r");
assert!(!s[0].has_body_dml);
}
#[test]
fn scan_cursor_for_loop_with_dml_sets_flag() {
let s = scan_cursor_for_loops(
"hr.pkg.p",
"begin for rec in (select * from src) loop insert into dst values (rec.a); end loop; end;",
);
assert_eq!(s.len(), 1);
assert!(s[0].has_body_dml, "INSERT in body must set has_body_dml");
}
#[test]
fn scan_cursor_for_loop_bare_cursor_name_detected() {
let s = scan_cursor_for_loops(
"hr.pkg.p",
"begin for c in emp_cur loop go(c); end loop; end;",
);
assert_eq!(s.len(), 1);
assert_eq!(s[0].loop_var, "c");
}
#[test]
fn scan_numeric_range_loop_is_not_a_cursor_loop() {
let s = scan_cursor_for_loops(
"hr.pkg.p",
"begin for i in 1..10 loop go(i); end loop; end;",
);
assert!(s.is_empty(), "numeric range must yield no site, got {s:?}");
}
#[test]
fn scan_for_keyword_inside_identifier_ignored() {
let s = scan_cursor_for_loops("hr.pkg.p", "begin before_x := 1; end;");
assert!(s.is_empty(), "got {s:?}");
}
#[test]
fn missing_instrumentation_flagged_when_body_has_no_marker() {
let s = scan_missing_instrumentation("hr.pkg.silent", "begin update t set a=1; end;");
assert_eq!(s.len(), 1);
assert_eq!(s[0].unit_logical_id, "hr.pkg.silent");
}
#[test]
fn missing_instrumentation_not_flagged_when_marker_present() {
let s = scan_missing_instrumentation(
"hr.pkg.logged",
"begin dbms_output.put_line('x'); update t set a=1; end;",
);
assert!(s.is_empty(), "instrumented body must not flag, got {s:?}");
}
#[test]
fn missing_instrumentation_flagged_past_begin_suffixed_decoy() {
let s = scan_missing_instrumentation(
"hr.pkg.silent",
"procedure p is v_begin_dt date; begin update t set x=1; end;",
);
assert_eq!(
s.len(),
1,
"real BEGIN past a v_begin_dt decoy must yield one site: {s:?}"
);
assert_eq!(s[0].unit_logical_id, "hr.pkg.silent");
}
#[test]
fn missing_instrumentation_skips_specs_without_body() {
let s = scan_missing_instrumentation("hr.pkg.spec", "procedure p(x in number);");
assert!(s.is_empty(), "got {s:?}");
}
#[test]
fn emit_cursor_for_loop_and_missing_instrumentation_facts_are_typed() {
let mut store = FactStore::default();
let cfl = scan_cursor_for_loops(
"hr.pkg.p",
"begin for r in (select 1 from dual) loop null; end loop; end;",
);
let n1 = emit_cursor_for_loop_facts(&mut store, &prov(), cfl);
assert_eq!(n1, 1);
assert_eq!(store.by_kind(FactKind::CursorForLoop).count(), 1);
let mi = scan_missing_instrumentation("hr.pkg.p", "begin null; end;");
let n2 = emit_missing_instrumentation_facts(&mut store, &prov(), mi);
assert_eq!(n2, 1);
assert_eq!(store.by_kind(FactKind::MissingInstrumentation).count(), 1);
}
#[test]
fn scan_identified_by_literal_flagged() {
let s =
scan_hardcoded_credentials("hr.admin", "alter user hr identified by 'Sup3rSecret';");
assert_eq!(s.len(), 1);
assert_eq!(s[0].marker, "identified by");
}
#[test]
fn scan_password_assignment_literal_flagged() {
let s = scan_hardcoded_credentials("hr.pkg.connect", "begin v_password := 'hunter2'; end;");
assert!(s.iter().any(|x| x.marker.eq("password")));
}
#[test]
fn scan_credential_marker_with_bind_not_flagged() {
let s = scan_hardcoded_credentials("hr.pkg.connect", "begin v_password := p_input; end;");
assert!(s.is_empty(), "bind, not a literal: {s:?}");
}
#[test]
fn scan_credential_keyword_in_identifier_ignored() {
let s = scan_hardcoded_credentials("hr.pkg.p", "begin x := old_password_hash; end;");
assert!(s.is_empty(), "{s:?}");
}
#[test]
fn emit_hardcoded_credential_facts_typed() {
let mut store = FactStore::default();
let sites = scan_hardcoded_credentials("hr.admin", "alter user x identified by 'p';");
let n = emit_hardcoded_credential_facts(&mut store, &prov(), sites);
assert_eq!(n, 1);
assert_eq!(store.by_kind(FactKind::HardcodedCredential).count(), 1);
}
#[test]
fn scan_authid_current_user_flagged_whitespace_insensitive() {
let s = scan_invoker_rights(
"hr.pkg",
"create or replace package hr.pkg\n authid\tcurrent_user as ...",
);
assert_eq!(s.len(), 1);
}
#[test]
fn scan_authid_definer_not_flagged() {
let s = scan_invoker_rights("hr.pkg", "create package hr.pkg authid definer as ...");
assert!(s.is_empty(), "{s:?}");
}
#[test]
fn scan_authid_current_user_inside_literal_not_flagged() {
let s = scan_invoker_rights(
"hr.pkg",
"begin msg := 'note: authid current_user is risky'; end;",
);
assert!(s.is_empty(), "literal mention must not flag: {s:?}");
}
#[test]
fn emit_invoker_rights_facts_typed() {
let mut store = FactStore::default();
let sites = scan_invoker_rights("hr.pkg", "package p authid current_user as end;");
let n = emit_invoker_rights_facts(&mut store, &prov(), sites);
assert_eq!(n, 1);
assert_eq!(store.by_kind(FactKind::InvokerRights).count(), 1);
}
#[test]
fn scan_ref_cursor_return_detects_sys_refcursor() {
assert_eq!(
scan_ref_cursor_return("hr.f", "function f return sys_refcursor is begin ... end;")
.len(),
1
);
assert!(
scan_ref_cursor_return("hr.f", "function f return number is begin ... end;").is_empty()
);
}
#[test]
fn scan_dml_in_function_only_flags_functions_with_dml() {
assert_eq!(
scan_dml_in_function(
"hr.f",
"function f return number is begin insert into log values(1); return 1; end;"
)
.len(),
1
);
assert!(
scan_dml_in_function("hr.f", "function f return number is begin return 1; end;")
.is_empty()
);
assert!(
scan_dml_in_function("hr.p", "procedure p is begin delete from t; end;").is_empty()
);
}
#[test]
fn scan_dml_in_function_finds_dml_after_identifier_decoy() {
assert_eq!(
scan_dml_in_function(
"hr.f",
"function f(p int) return number is v_last_update date; \
begin update t set c = 1 where id = p; return 1; end;",
)
.len(),
1,
"decoy `v_last_update` local must not mask the genuine `update t`",
);
assert_eq!(
scan_dml_in_function(
"hr.f",
"function f(p int) return number is deleted_flag char(1); \
begin delete from t where id = p; return 1; end;",
)
.len(),
1,
"decoy `deleted_flag` local must not mask the genuine `delete from t`",
);
assert_eq!(
scan_dml_in_function(
"hr.f",
"function f(p int) return number is last_inserted int; \
begin insert into log values (p); return 1; end;",
)
.len(),
1,
"decoy `last_inserted` local must not mask the genuine `insert into`",
);
assert!(
scan_dml_in_function(
"hr.f",
"function f return number is v_last_update date; begin return 1; end;",
)
.is_empty(),
"identifier-only `v_last_update` must not be read as DML",
);
}
#[test]
fn scan_unbounded_bulk_collect_flags_missing_limit() {
assert_eq!(
scan_unbounded_bulk_collect(
"hr.p",
"begin select id bulk collect into ids from huge_t; end;"
)
.len(),
1
);
assert!(
scan_unbounded_bulk_collect(
"hr.p",
"begin fetch c bulk collect into ids limit 100; end;"
)
.is_empty()
);
}
#[test]
fn emit_sec007_qual007_qual003_facts_typed() {
let mut store = FactStore::default();
emit_ref_cursor_return_facts(
&mut store,
&prov(),
scan_ref_cursor_return("hr.f", "function f return sys_refcursor is begin end;"),
);
emit_dml_in_function_facts(
&mut store,
&prov(),
scan_dml_in_function(
"hr.f",
"function f return int is begin update t set a=1; end;",
),
);
emit_unbounded_bulk_collect_facts(
&mut store,
&prov(),
scan_unbounded_bulk_collect("hr.p", "begin x bulk collect into y from t; end;"),
);
assert_eq!(store.by_kind(FactKind::RefCursorReturn).count(), 1);
assert_eq!(store.by_kind(FactKind::DmlInFunction).count(), 1);
assert_eq!(store.by_kind(FactKind::UnboundedBulkCollect).count(), 1);
}
#[test]
fn scan_deprecated_features_detects_known_forms() {
let s = scan_deprecated_features(
"hr.p",
"begin dbms_job.submit(j); select a from t1, t2 where t1.id = t2.id (+); commit work; end;",
);
let feats: Vec<&str> = s.iter().map(|x| x.detail.as_str()).collect();
assert!(feats.iter().any(|f| f.contains("dbms_job")));
assert!(feats.iter().any(|f| f.contains("(+)")));
assert!(feats.iter().any(|f| f.contains("WORK")));
assert!(scan_deprecated_features("hr.q", "begin commit; end;").is_empty());
}
#[test]
fn scan_deprecated_in_literal_not_flagged() {
let s = scan_deprecated_features("hr.p", "begin msg := 'use dbms_job here'; end;");
assert!(s.is_empty(), "{s:?}");
}
#[test]
fn scan_deterministic_misuse_requires_pragma_and_construct() {
let s = scan_deterministic_misuse(
"hr.f",
"function f return date deterministic is begin return sysdate; end;",
);
assert!(s.iter().any(|x| x.detail.eq("SYSDATE")));
assert!(
scan_deterministic_misuse(
"hr.g",
"function g(x int) return int deterministic is begin return x*2; end;"
)
.is_empty()
);
assert!(
scan_deterministic_misuse(
"hr.h",
"function h return date is begin return sysdate; end;"
)
.is_empty()
);
}
#[test]
fn emit_qual005_qual008_facts_typed() {
let mut store = FactStore::default();
emit_deprecated_feature_facts(
&mut store,
&prov(),
scan_deprecated_features("hr.p", "begin dbms_job.run(1); end;"),
);
emit_deterministic_misuse_facts(
&mut store,
&prov(),
scan_deterministic_misuse(
"hr.f",
"function f return int deterministic is begin insert into log values(1); return 1; end;",
),
);
assert_eq!(store.by_kind(FactKind::DeprecatedFeature).count(), 1);
assert_eq!(store.by_kind(FactKind::DeterministicMisuse).count(), 1);
}
#[test]
fn scan_mutating_table_trigger_flags_self_reference() {
let s = scan_mutating_table_trigger(
"hr.trg_emp",
"create trigger trg_emp before insert on employees for each row \
begin select count(*) into n from employees; end;",
);
assert_eq!(s.len(), 1);
assert_eq!(s[0].detail, "employees");
assert!(
scan_mutating_table_trigger(
"hr.t",
"create trigger t after insert on employees begin null; end;"
)
.is_empty()
);
}
#[test]
fn scan_log_without_reraise_flags_swallowed_after_log() {
let s = scan_log_without_reraise(
"hr.p",
"begin go; exception when others then dbms_output.put_line('failed'); end;",
);
assert_eq!(s.len(), 1);
assert!(
scan_log_without_reraise(
"hr.p",
"begin go; exception when others then logger.error('x'); raise; end;"
)
.is_empty()
);
}
#[test]
fn scan_cross_schema_write_flags_other_schema_dml() {
let s = scan_cross_schema_write(
"hr.pkg.p",
"begin insert into fin.ledger(a) values(1); update hr.local set x=1; end;",
);
assert_eq!(s.len(), 1, "only the fin.* write is cross-schema: {s:?}");
assert_eq!(s[0].detail, "fin.ledger");
}
#[test]
fn scan_cross_schema_write_flags_from_less_delete() {
let s =
scan_cross_schema_write("hr.proc1", "begin delete fin.audit_log where id = 5; end;");
assert_eq!(s.len(), 1, "FROM-less cross-schema delete must flag: {s:?}");
assert_eq!(s[0].detail, "fin.audit_log");
}
#[test]
fn scan_cross_schema_write_from_and_from_less_delete_agree() {
let with_from = scan_cross_schema_write("hr.p", "begin delete from fin.audit; end;");
let without_from = scan_cross_schema_write("hr.p", "begin delete fin.audit; end;");
assert_eq!(with_from.len(), 1);
assert_eq!(without_from.len(), 1);
assert_eq!(with_from[0].detail, without_from[0].detail);
assert_eq!(without_from[0].detail, "fin.audit");
}
#[test]
fn emit_qual006_qual002_dep001_facts_typed() {
let mut store = FactStore::default();
emit_mutating_table_trigger_facts(
&mut store,
&prov(),
scan_mutating_table_trigger(
"hr.trg",
"create trigger trg before update on accounts for each row begin update accounts set z=1; end;",
),
);
emit_log_without_reraise_facts(
&mut store,
&prov(),
scan_log_without_reraise(
"hr.p",
"begin x; exception when others then log_error('e'); end;",
),
);
emit_cross_schema_write_facts(
&mut store,
&prov(),
scan_cross_schema_write("hr.p", "begin delete from fin.audit; end;"),
);
assert_eq!(store.by_kind(FactKind::MutatingTableTrigger).count(), 1);
assert_eq!(store.by_kind(FactKind::LogWithoutReraise).count(), 1);
assert_eq!(store.by_kind(FactKind::CrossSchemaWrite).count(), 1);
}
#[test]
fn scan_sensitive_public_synonym_flags_credential_target() {
let s = scan_sensitive_public_synonym(
"hr.ddl",
"create public synonym emp_pwd for hr.employee_passwords;",
);
assert_eq!(s.len(), 1);
assert_eq!(s[0].synonym, "emp_pwd");
assert_eq!(s[0].target, "hr.employee_passwords");
}
#[test]
fn scan_public_synonym_benign_not_flagged() {
let s = scan_sensitive_public_synonym(
"hr.ddl",
"create public synonym depts for hr.departments;",
);
assert!(s.is_empty(), "benign synonym must not flag: {s:?}");
}
#[test]
fn scan_private_synonym_not_flagged() {
let s = scan_sensitive_public_synonym("hr.ddl", "create synonym sal for hr.salary_tbl;");
assert!(s.is_empty(), "{s:?}");
}
#[test]
fn emit_sensitive_public_synonym_facts_typed() {
let mut store = FactStore::default();
let sites = scan_sensitive_public_synonym(
"hr.ddl",
"create or replace public synonym bank_acct for fin.bank_accounts;",
);
let n = emit_sensitive_public_synonym_facts(&mut store, &prov(), sites);
assert_eq!(n, 1);
assert_eq!(store.by_kind(FactKind::SensitivePublicSynonym).count(), 1);
}
#[test]
fn scan_is_null_on_indexed_column_flags_correlated_case() {
let s = scan_is_null_on_indexed_column(
"hr.q",
"create index emp_dt_ix on employees(deleted_at); \
begin select id from employees where deleted_at is null; end;",
);
assert_eq!(s.len(), 1);
assert_eq!(s[0].column, "deleted_at");
}
#[test]
fn scan_is_null_without_index_not_flagged() {
let s = scan_is_null_on_indexed_column(
"hr.q",
"begin select id from employees where deleted_at is null; end;",
);
assert!(s.is_empty(), "{s:?}");
}
#[test]
fn scan_is_not_null_never_matches() {
let s = scan_is_null_on_indexed_column(
"hr.q",
"create index ix on t(c); begin select 1 from t where c is not null; end;",
);
assert!(s.is_empty(), "`is not null` must not match: {s:?}");
}
#[test]
fn scan_is_null_on_non_indexed_column_not_flagged() {
let s = scan_is_null_on_indexed_column(
"hr.q",
"create index ix on t(a); begin select 1 from t where b is null; end;",
);
assert!(s.is_empty(), "b is not indexed: {s:?}");
}
#[test]
fn emit_is_null_on_indexed_column_facts_typed() {
let mut store = FactStore::default();
let sites = scan_is_null_on_indexed_column(
"hr.q",
"create unique index ix on t(k); begin delete from t where k is null; end;",
);
let n = emit_is_null_on_indexed_column_facts(&mut store, &prov(), sites);
assert_eq!(n, 1);
assert_eq!(store.by_kind(FactKind::IsNullOnIndexedColumn).count(), 1);
}
}