use std::{fs, path::Path};
use anyhow::Result;
use serde::Serialize;
use crate::core::ast::infrastructure::compliance::scan_entity_compliance;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Severity {
Warning,
Info,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalFinding {
pub(crate) severity: Severity,
pub(crate) project: String,
pub(crate) check: String,
pub(crate) subject: String,
pub(crate) message: String,
}
const ENTRYPOINTS: &[&str] = &["server.ts", "worker.ts"];
const TENANT_GATES: &[(&str, &str)] = &[
(
"setupTenantFilter(",
"does not call setupTenantFilter — queries in this runtime are not tenant-filtered",
),
(
"setupRls(",
"does not call setupRls — PostgreSQL row-level security is not enforced in this runtime",
),
];
const BETTER_AUTH_CONFIG_MARKERS: &[&str] = &["betterAuthConfig(", "betterAuth("];
const ENCRYPTION_AWARE_ORM_MARKERS: &[&str] = &[
"createEncryptionAwareOrm(",
"createTenantAwareBetterAuthOrmProxy(",
];
const BETTER_AUTH_WIRING_FILES: &[&str] = &["registrations.ts", "auth.ts"];
fn better_auth_orm_is_unwrapped(sources: &str) -> bool {
let wires_better_auth = BETTER_AUTH_CONFIG_MARKERS
.iter()
.any(|marker| sources.contains(marker));
if !wires_better_auth {
return false;
}
!ENCRYPTION_AWARE_ORM_MARKERS
.iter()
.any(|marker| sources.contains(marker))
}
const NON_PRODUCTION_DIRS: &[&str] = &["node_modules", "dist", "__test__", "migrations"];
fn read_production_sources(project_path: &Path) -> String {
fn walk(dir: &Path, out: &mut String) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if !NON_PRODUCTION_DIRS.contains(&name.as_str()) {
walk(&path, out);
}
} else if name.ends_with(".ts") && !name.ends_with(".test.ts") {
if let Ok(text) = fs::read_to_string(&path) {
out.push_str(&text);
out.push('\n');
}
}
}
}
let mut out = String::new();
walk(project_path, &mut out);
out
}
pub(crate) const LOCAL_CHECK_IDS: &[&str] = &[
"encryptor-registration",
"tenant-em-wiring",
"better-auth-encryption-context",
"possible-misclassification",
"tenant-isolation-wiring",
"tenant-context-half-wired",
"retention-wiring",
"erasure-wiring",
"ai-provider-direct",
"managed-provider-credentials",
"object-store-wiring",
"object-store-static-credentials",
"object-store-public-access",
"object-store-bucket-managed-in-app",
"presigned-upload-unbounded",
"capability-wiring",
"email-provider-direct-in-managed",
"email-protected-data",
"sms-provider-direct-in-managed",
"sms-protected-data",
"whatsapp-provider-direct-in-managed",
"whatsapp-protected-data",
"voice-provider-direct-in-managed",
"voice-protected-data",
"payments-stripe-keys-in-managed",
"stripe-webhook-unverified",
"payments-protected-data",
];
const AI_PROVIDER_PACKAGES: &[&str] = &[
"openai",
"@anthropic-ai/sdk",
"@azure/openai",
"@aws-sdk/client-bedrock-runtime",
"@google/generative-ai",
"@google/genai",
"cohere-ai",
"@mistralai/mistralai",
"groq-sdk",
"together-ai",
];
const AI_SDK_PROVIDER_PREFIX: &str = "@ai-sdk/";
const AI_PROVIDER_KEYS: &[&str] = &[
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"AZURE_OPENAI_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"GEMINI_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
"COHERE_API_KEY",
"MISTRAL_API_KEY",
"GROQ_API_KEY",
];
const MESSAGING_PROVIDER_KEYS: &[&str] = &[
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"SENDGRID_API_KEY",
"MAILGUN_API_KEY",
"POSTMARK_SERVER_TOKEN",
"VONAGE_API_KEY",
"VONAGE_API_SECRET",
"NEXMO_API_KEY",
"NEXMO_API_SECRET",
"MESSAGEBIRD_ACCESS_KEY",
"MESSAGEBIRD_API_KEY",
];
const SMS_PROVIDER_PACKAGES: &[&str] = &["twilio", "vonage", "messagebird"];
const SMS_PROVIDER_PREFIXES: &[&str] = &["@aws-sdk/client-pinpoint", "@vonage/"];
const SNS_PACKAGE: &str = "@aws-sdk/client-sns";
pub(crate) fn direct_sms_providers(sources: &str) -> Vec<String> {
let sns_texts = sources.contains("PhoneNumber") || sources.contains("SetSMSAttributes");
let mut found: Vec<String> = imported_modules(sources)
.into_iter()
.filter(|module| {
SMS_PROVIDER_PACKAGES.contains(&module.as_str())
|| SMS_PROVIDER_PREFIXES.iter().any(|p| module.starts_with(p))
|| (module == SNS_PACKAGE && sns_texts)
})
.collect();
found.sort();
found.dedup();
found
}
fn balanced_args(text: &str, open: usize) -> &str {
let mut depth = 0usize;
for (i, ch) in text[open..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return &text[open + 1..open + i];
}
}
_ => {}
}
}
&text[open + 1..]
}
pub(crate) fn sms_sends_with_protected_data(source: &str) -> Vec<String> {
static SENDS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
static ASSIGN: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let sends = SENDS.get_or_init(|| {
regex::Regex::new(
r"(?:\b[\w$]*[sS][mM][sS][\w$]*\s*\??\.\s*send|\.messages\s*\.\s*create|\bnew\s+SendTextMessageCommand|\bnew\s+PublishCommand)\s*\(",
)
.expect("sms send pattern")
});
let assign = ASSIGN.get_or_init(|| {
regex::Regex::new(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*([^;]*)")
.expect("assignment pattern")
});
if !source.contains(".deanon") {
return Vec::new();
}
let assignments: Vec<(String, String)> = assign
.captures_iter(source)
.map(|c| (c[1].to_string(), c[2].to_string()))
.collect();
let mut tainted: Vec<String> = Vec::new();
for _ in 0..3 {
for (name, value) in &assignments {
if tainted.contains(name) {
continue;
}
let from_tainted = tainted.iter().any(|t| mentions(value, t));
if value.contains(".deanon") || from_tainted {
tainted.push(name.clone());
}
}
}
let twilio = source.contains("'twilio'") || source.contains("\"twilio\"");
let mut found = Vec::new();
for m in sends.find_iter(source) {
let call = m.as_str();
if call.contains("messages") && !twilio {
continue;
}
let args = balanced_args(source, m.end() - 1);
if call.contains("PublishCommand") && !args.contains("PhoneNumber") {
continue;
}
let leaks = message_bodies(args)
.iter()
.any(|body| body.contains(".deanon") || tainted.iter().any(|t| mentions(body, t)));
if leaks {
let line = source[..m.start()].matches('\n').count() + 1;
let callee: String = call.trim_end_matches('(').split_whitespace().collect();
found.push(format!("{callee} (line {line})"));
}
}
found
}
fn message_bodies(args: &str) -> Vec<String> {
static KEY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let key = KEY.get_or_init(|| {
regex::Regex::new(r"(?:^|[{,\s])(?:body|Body|MessageBody|Message)\s*(:|[,}])")
.expect("body key pattern")
});
let mut out = Vec::new();
for c in key.captures_iter(args) {
let marker = c.get(1).unwrap();
if marker.as_str() != ":" {
out.push("body".to_string());
continue;
}
let rest = &args[marker.end()..];
let mut depth = 0i32;
let mut end = rest.len();
let mut in_template = false;
for (i, ch) in rest.char_indices() {
match ch {
'`' => in_template = !in_template,
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' if depth > 0 => depth -= 1,
'}' | ')' if depth == 0 && !in_template => {
end = i;
break;
}
',' if depth == 0 && !in_template => {
end = i;
break;
}
_ => {}
}
}
out.push(rest[..end].trim().to_string());
}
out
}
fn mentions(text: &str, name: &str) -> bool {
let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
text.match_indices(name).any(|(i, _)| {
let before = text[..i].chars().next_back();
let after = text[i + name.len()..].chars().next();
!before.is_some_and(|c| is_ident(c) || c == '.') && !after.is_some_and(is_ident)
})
}
fn production_files(project_path: &Path) -> Vec<(String, String)> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if !NON_PRODUCTION_DIRS.contains(&name.as_str()) {
walk(root, &path, out);
}
} else if name.ends_with(".ts") && !name.ends_with(".test.ts") {
if let Ok(text) = fs::read_to_string(&path) {
let rel = path.strip_prefix(root).unwrap_or(&path);
out.push((rel.to_string_lossy().to_string(), text));
}
}
}
}
let mut out = Vec::new();
walk(project_path, project_path, &mut out);
out.sort();
out
}
const MANAGED_INSTANCE_MARKERS: &[&str] = &["INSTANCE_HMAC_KEY", "PLATFORM_GATEWAY_URL"];
fn imported_modules(sources: &str) -> Vec<String> {
static IMPORTS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = IMPORTS.get_or_init(|| {
regex::Regex::new(
r#"(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"]([^'"]+)['"]"#,
)
.expect("import pattern")
});
re.captures_iter(sources)
.map(|c| c[1].to_string())
.collect()
}
pub(crate) fn direct_ai_providers(sources: &str) -> Vec<String> {
let mut found: Vec<String> = imported_modules(sources)
.into_iter()
.filter(|module| {
AI_PROVIDER_PACKAGES.contains(&module.as_str())
|| module.starts_with(AI_SDK_PROVIDER_PREFIX)
})
.collect();
found.extend(
AI_PROVIDER_KEYS
.iter()
.filter(|key| sources.contains(**key))
.map(|key| key.to_string()),
);
found.sort();
found.dedup();
found
}
pub(crate) fn provider_credentials_read(sources: &str) -> Vec<String> {
let mut found: Vec<String> = MESSAGING_PROVIDER_KEYS
.iter()
.chain(AI_PROVIDER_KEYS.iter())
.filter(|key| sources.contains(**key))
.map(|key| key.to_string())
.collect();
found.sort();
found
}
const EMAIL_PROVIDER_PACKAGES: &[&str] = &[
"nodemailer",
"@aws-sdk/client-ses",
"@aws-sdk/client-sesv2",
"@sendgrid/mail",
"postmark",
"mailgun.js",
"mailgun-js",
];
pub(crate) fn is_email_credential(key: &str) -> bool {
key.starts_with("SMTP_")
|| key == "SENDGRID_API_KEY"
|| key.starts_with("POSTMARK_")
|| key.starts_with("MAILGUN_")
}
pub(crate) fn direct_email_providers(sources: &str) -> Vec<String> {
static KEYS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let keys = KEYS.get_or_init(|| {
regex::Regex::new(r"\b(?:SMTP_[A-Z0-9_]+|SENDGRID_API_KEY|POSTMARK_[A-Z0-9_]+|MAILGUN_[A-Z0-9_]+)\b")
.expect("email credential pattern")
});
let mut found: Vec<String> = imported_modules(sources)
.into_iter()
.filter(|module| {
EMAIL_PROVIDER_PACKAGES.iter().any(|p| {
module == p || module.starts_with(&format!("{p}/"))
})
})
.collect();
found.extend(keys.find_iter(sources).map(|m| m.as_str().to_string()));
found.sort();
found.dedup();
found
}
fn balanced_object(text: &str, open: usize) -> &str {
let bytes = text.as_bytes();
let mut depth = 0usize;
let mut quote: Option<u8> = None;
let mut i = open;
while i < bytes.len() {
let c = bytes[i];
match quote {
Some(q) => {
if c == b'\\' {
i += 1;
} else if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' | b'`' => quote = Some(c),
b'{' | b'(' | b'[' => depth += 1,
b'}' | b')' | b']' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return &text[open..=i];
}
}
_ => {}
},
}
i += 1;
}
&text[open..]
}
fn property_value(text: &str, start: usize) -> &str {
let bytes = text.as_bytes();
let mut depth = 0usize;
let mut quote: Option<u8> = None;
let mut i = start;
while i < bytes.len() {
let c = bytes[i];
match quote {
Some(q) => {
if c == b'\\' {
i += 1;
} else if c == q {
quote = None;
}
}
None => match c {
b'\'' | b'"' | b'`' => quote = Some(c),
b'{' | b'(' | b'[' => depth += 1,
b'}' | b')' | b']' if depth == 0 => return &text[start..i],
b'}' | b')' | b']' => depth -= 1,
b',' | b';' if depth == 0 => return &text[start..i],
_ => {}
},
}
i += 1;
}
&text[start..]
}
pub(crate) fn protected_data_in_email_subjects(sources: &str) -> Vec<String> {
static CALLS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
static SUBJECT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let calls = CALLS.get_or_init(|| {
regex::Regex::new(
r"(?:\b([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\.\s*(send|sendMail|sendEmail)|\bnew\s+(SendEmailCommand))\s*\(\s*\{",
)
.expect("email send pattern")
});
let subject = SUBJECT.get_or_init(|| {
regex::Regex::new(r#"(?:^|[{,\s])['"]?(?i:subject)['"]?\s*:"#).expect("subject pattern")
});
let mut found = Vec::new();
for call in calls.captures_iter(sources) {
let receiver = call.get(1).map(|m| m.as_str()).unwrap_or("");
let method = call.get(2).map(|m| m.as_str()).unwrap_or("");
let is_email = call.get(3).is_some()
|| method == "sendMail"
|| method == "sendEmail"
|| receiver.to_ascii_lowercase().contains("mail");
if !is_email {
continue;
}
let whole = call.get(0).unwrap();
let object = balanced_object(sources, whole.end() - 1);
for m in subject.find_iter(object) {
let value = property_value(object, m.end()).trim();
if value.contains(".deanon") {
found.push(value.split_whitespace().collect::<Vec<_>>().join(" "));
}
}
}
found.sort();
found.dedup();
found
}
pub(crate) fn is_managed_instance(project_path: &Path, sources: &str) -> bool {
MANAGED_INSTANCE_MARKERS
.iter()
.any(|marker| sources.contains(marker))
|| project_path
.join("api/controllers/relay.controller.ts")
.exists()
}
const STRIPE_KEYS: &[&str] = &["STRIPE_API_KEY", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"];
pub(crate) fn managed_stripe_credentials(sources: &str) -> Vec<String> {
let mut found = Vec::new();
for key in STRIPE_KEYS {
let entry = regex::Regex::new(&format!(
r"\b{key}\s*:\s*\{{[^{{}}]*?\btype\s*:\s*([^,\n}}]+)"
))
.expect("stripe key entry pattern");
let required = entry
.captures_iter(sources)
.any(|c| !c[1].trim().starts_with("optional("));
let direct = regex::Regex::new(&format!(
r#"process\.env(?:\.{key}\b|\[\s*['"]{key}['"]\s*\])"#
))
.expect("process.env pattern")
.is_match(sources);
if required || direct {
found.push(key.to_string());
}
}
if sources.contains("new Stripe(") && !sources.contains("createStripeClient") {
found.push("new Stripe(".to_string());
}
found
}
const STRIPE_WEBHOOK_MARKERS: &[&str] = &[
"stripe-signature",
"Stripe.Event",
"checkout.session.completed",
"invoice.payment_succeeded",
"customer.subscription.",
];
pub(crate) fn stripe_webhook_unverified(sources: &str) -> bool {
STRIPE_WEBHOOK_MARKERS.iter().any(|m| sources.contains(m))
&& !["constructEvent(", "constructEventAsync(", "verifyPlatformEvent("]
.iter()
.any(|v| sources.contains(v))
}
fn closing_bracket(text: &str, from: usize) -> usize {
let mut depth = 1i32;
let mut quote: Option<char> = None;
let mut prev = ' ';
for (i, c) in text[from..].char_indices() {
if let Some(q) = quote {
if c == q && prev != '\\' {
quote = None;
}
} else {
match c {
'\'' | '"' | '`' => quote = Some(c),
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
depth -= 1;
if depth == 0 {
return from + i;
}
}
_ => {}
}
}
prev = c;
}
text.len()
}
fn stripe_property_value(rest: &str) -> &str {
if rest.starts_with(['{', '[', '(']) {
let end = closing_bracket(rest, 1);
return &rest[..(end + 1).min(rest.len())];
}
let mut depth = 0i32;
let end = rest
.char_indices()
.find(|(_, ch)| match ch {
'(' | '{' | '[' => {
depth += 1;
false
}
')' | '}' | ']' => {
depth -= 1;
depth < 0
}
',' | '\n' => depth == 0,
_ => false,
})
.map(|(i, _)| i)
.unwrap_or(rest.len());
&rest[..end]
}
pub(crate) fn stripe_calls_with_protected_data(sources: &str) -> Vec<String> {
static CALL: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
static FIELD: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let call = CALL.get_or_init(|| {
regex::Regex::new(
r"\b[sS]tripe\w*\s*\.\s*([A-Za-z]+(?:\s*\.\s*[A-Za-z]+)?)\s*\.\s*(create|update)\s*\(",
)
.expect("stripe call pattern")
});
let field = FIELD.get_or_init(|| {
regex::Regex::new(r"\b(metadata|description|statement_descriptor(?:_suffix)?)\s*:\s*")
.expect("stripe field pattern")
});
let mut found = Vec::new();
for c in call.captures_iter(sources) {
let open = c.get(0).unwrap().end();
let args = &sources[open..closing_bracket(sources, open)];
let leaks = field.captures_iter(args).any(|f| {
stripe_property_value(&args[f.get(0).unwrap().end()..]).contains(".deanon")
});
if leaks {
let name = format!("{}.{}", c[1].split_whitespace().collect::<String>(), &c[2]);
if !found.contains(&name) {
found.push(name);
}
}
}
found
}
fn declared_object_stores(modules_path: &Path) -> Option<std::collections::HashMap<String, bool>> {
let mut dir = modules_path.canonicalize().ok()?;
loop {
let manifest = dir.join(".forklaunch").join("manifest.toml");
if manifest.exists() {
let text = fs::read_to_string(manifest).ok()?;
let value: toml::Value = toml::from_str(&text).ok()?;
let projects = value.get("projects")?.as_array()?;
return Some(
projects
.iter()
.filter_map(|p| {
let name = p.get("name")?.as_str()?.to_string();
let declared = p
.get("resources")
.and_then(|r| r.get("object_store"))
.and_then(|o| o.as_str())
.is_some();
Some((name, declared))
})
.collect(),
);
}
if !dir.pop() {
return None;
}
}
}
fn declared_capabilities(modules_path: &Path) -> Option<std::collections::HashMap<String, Vec<String>>> {
let mut dir = modules_path.canonicalize().ok()?;
loop {
let manifest = dir.join(".forklaunch").join("manifest.toml");
if manifest.exists() {
let value: toml::Value = toml::from_str(&fs::read_to_string(manifest).ok()?).ok()?;
return Some(
value
.get("projects")?
.as_array()?
.iter()
.filter_map(|p| {
let name = p.get("name")?.as_str()?.to_string();
let capabilities = p
.get("resources")
.and_then(|r| r.get("capabilities"))
.and_then(|c| c.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Some((name, capabilities))
})
.collect(),
);
}
if !dir.pop() {
return None;
}
}
}
fn uses_object_store(sources: &str) -> bool {
sources.contains("@forklaunch/infrastructure-s3") || sources.contains("@aws-sdk/client-s3")
}
pub(crate) fn static_storage_credentials(sources: &str) -> Vec<String> {
let mut found = Vec::new();
if sources.contains("S3_ACCESS_KEY_ID") && !sources.contains("s3ClientConfig(") {
found.push("S3_ACCESS_KEY_ID".to_string());
}
for key in ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] {
if sources.contains(key) {
found.push(key.to_string());
}
}
found
}
pub(crate) fn public_storage_access(sources: &str) -> Vec<String> {
static CORS_ANY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let cors_any = CORS_ANY.get_or_init(|| {
regex::Regex::new(r#"AllowedOrigins\s*:\s*\[\s*['"]\*['"]"#).expect("cors pattern")
});
let mut found: Vec<String> = [
"public-read",
"PutPublicAccessBlockCommand",
"DeletePublicAccessBlockCommand",
]
.iter()
.filter(|p| sources.contains(**p))
.map(|p| p.to_string())
.collect();
if cors_any.is_match(sources) {
found.push("AllowedOrigins: ['*']".to_string());
}
found
}
pub(crate) fn bucket_administration(sources: &str) -> Vec<String> {
[
"CreateBucketCommand",
"PutBucketPolicyCommand",
"DeleteBucketPolicyCommand",
"PutBucketCorsCommand",
"PutBucketAclCommand",
]
.iter()
.filter(|p| sources.contains(**p))
.map(|p| p.to_string())
.collect()
}
pub(crate) fn unbounded_presigned_uploads(sources: &str) -> Vec<String> {
static PRESIGNED_PUT: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let presigned_put = PRESIGNED_PUT.get_or_init(|| {
regex::Regex::new(r"(?s)getSignedUrl\s*\([^;]{0,300}?PutObjectCommand")
.expect("presigned put pattern")
});
let mut found = Vec::new();
if presigned_put.is_match(sources) {
found.push("getSignedUrl(PutObjectCommand)".to_string());
}
if sources.contains("createPresignedPost(") && !sources.contains("content-length-range") {
found.push("createPresignedPost without content-length-range".to_string());
}
found
}
const TENANT_BINDING_MARKERS: &[&str] = &[
"wrapEmWithTenantContext(",
"withEncryptionContext(",
"setEncryptionTenantId(",
];
const TENANT_FILTER_ONLY_MARKER: &str = "setFilterParams('tenant'";
const TENANT_CONTEXT_HELPER: &str = "wrapEmWithTenantContext(";
const PII_WORDS: &[&str] = &[
"email",
"phone",
"mobile",
"address",
"street",
"zipcode",
"postalcode",
"firstname",
"lastname",
"fullname",
"surname",
"birth",
"birthdate",
"birthday",
"dob",
"gender",
"nationality",
"passport",
"avatar",
"photo",
"latitude",
"longitude",
"geolocation",
"ipaddress",
];
const PHI_WORDS: &[&str] = &[
"ssn",
"medical",
"diagnosis",
"prescription",
"health",
"bloodtype",
"allergy",
"allergies",
"disability",
];
const PCI_WORDS: &[&str] = &[
"cardnumber",
"creditcard",
"pan",
"cvv",
"cvc",
"iban",
"accountnumber",
"routingnumber",
"cardexpiry",
];
fn identifier_words(name: &str) -> Vec<String> {
let mut words: Vec<String> = Vec::new();
let mut current = String::new();
for c in name.chars() {
if c == '_' || c == '-' {
if !current.is_empty() {
words.push(current.to_lowercase());
current = String::new();
}
} else if c.is_uppercase() && !current.is_empty() {
words.push(current.to_lowercase());
current = c.to_string();
} else {
current.push(c);
}
}
if !current.is_empty() {
words.push(current.to_lowercase());
}
let mut all = words.clone();
for pair in words.windows(2) {
all.push(format!("{}{}", pair[0], pair[1]));
}
all
}
pub(crate) fn suggest_classification(field_name: &str) -> Option<&'static str> {
let words = identifier_words(field_name);
for word in &words {
if PCI_WORDS.contains(&word.as_str()) {
return Some("pci");
}
}
for word in &words {
if PHI_WORDS.contains(&word.as_str()) {
return Some("phi");
}
}
for word in &words {
if PII_WORDS.contains(&word.as_str()) {
return Some("pii");
}
}
None
}
pub(crate) fn missing_tenant_gates(source: &str) -> Vec<&'static (&'static str, &'static str)> {
TENANT_GATES
.iter()
.filter(|(call, _)| !source.contains(call))
.collect()
}
pub(crate) fn direct_whatsapp_access(sources: &str) -> Vec<String> {
static TOKENS: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let tokens = TOKENS.get_or_init(|| {
regex::Regex::new(
r"\b(?:WHATSAPP_[A-Z0-9_]*(?:TOKEN|SECRET|API_KEY)|META_[A-Z0-9_]*(?:TOKEN|SECRET|APP_KEY|API_KEY))\b",
)
.expect("whatsapp token pattern")
});
let mut found: Vec<String> = tokens
.find_iter(sources)
.map(|m| m.as_str().to_string())
.collect();
if sources.contains("graph.facebook.com") {
found.push("graph.facebook.com".to_string());
}
found.extend(imported_modules(sources).into_iter().filter(|m| {
matches!(
m.as_str(),
"whatsapp" | "whatsapp-cloud-api" | "whatsapp-api-js" | "@aws-sdk/client-socialmessaging"
)
}));
found.sort();
found.dedup();
found
}
pub(crate) fn whatsapp_protected_sends(source: &str) -> Vec<String> {
static SEND: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
static ASSIGN: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let send = SEND.get_or_init(|| {
regex::Regex::new(
r"(?i)(?:whats_?app\w*\)?\s*\.\s*(?:sendTemplate|sendText)|SendWhatsAppMessageCommand)\s*\(",
)
.expect("whatsapp send pattern")
});
let assign = ASSIGN.get_or_init(|| {
regex::Regex::new(r"(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=([^;]*)")
.expect("assignment pattern")
});
if !source.to_ascii_lowercase().contains("whatsapp") {
return Vec::new();
}
let mut tainted: Vec<String> = Vec::new();
for _ in 0..3 {
for caps in assign.captures_iter(source) {
let name = caps[1].to_string();
if tainted.contains(&name) {
continue;
}
let rhs = &caps[2];
if rhs.contains(".deanon") || tainted.iter().any(|t| mentions(rhs, t)) {
tainted.push(name);
}
}
}
let mut found = Vec::new();
for m in send.find_iter(source) {
let args = call_args(&source[m.end()..]);
let leaks = args.contains(".deanon") || tainted.iter().any(|t| mentions(args, t));
if leaks {
let line = source[..m.start()].matches('\n').count() + 1;
let call: String = m.as_str().split_whitespace().collect();
found.push(format!("{line}: {}", call.trim_end_matches('(')));
}
}
found
}
fn call_args(rest: &str) -> &str {
let mut depth = 1usize;
for (i, c) in rest.char_indices() {
if i > 4000 {
return &rest[..i];
}
match c {
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
depth -= 1;
if depth == 0 {
return &rest[..i];
}
}
_ => {}
}
}
rest
}
pub(crate) const WHATSAPP_PHI_MARKER: &str = "entities hold health data (phi)";
pub(crate) fn run_local_checks(modules_path: &Path) -> Result<Vec<LocalFinding>> {
let mut findings: Vec<LocalFinding> = Vec::new();
if !modules_path.exists() {
return Ok(findings);
}
let app_has_phi = fs::read_dir(modules_path)?.flatten().any(|entry| {
entry.path().is_dir()
&& scan_entity_compliance(&entry.path())
.unwrap_or_default()
.iter()
.any(|e| e.field_classifications.values().any(|c| c == "phi"))
});
for entry in fs::read_dir(modules_path)? {
let entry = entry?;
let project_path = entry.path();
if !project_path.is_dir() {
continue;
}
let project = entry.file_name().to_string_lossy().to_string();
let owns_persistence = project_path.join("persistence").is_dir();
if owns_persistence {
for entrypoint in ENTRYPOINTS {
let entrypoint_path = project_path.join(entrypoint);
if !entrypoint_path.exists() {
continue;
}
let source = fs::read_to_string(&entrypoint_path)?;
for (_, consequence) in missing_tenant_gates(&source) {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "tenant-isolation-wiring".to_string(),
subject: (*entrypoint).to_string(),
message: format!("{} {}", entrypoint, consequence),
});
}
}
}
let entities = scan_entity_compliance(&project_path).unwrap_or_default();
if !entities.is_empty() {
let has_retention = entities.iter().any(|e| e.retention.is_some());
let has_sensitive = entities.iter().any(|e| {
e.field_classifications
.values()
.any(|classification| classification != "none")
});
let registrations =
fs::read_to_string(project_path.join("registrations.ts")).unwrap_or_default();
if has_retention && !registrations.contains("RetentionService") {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "retention-wiring".to_string(),
subject: "registrations.ts".to_string(),
message: "entities declare retention policies but RetentionService is not registered — nothing enforces them".to_string(),
});
}
if has_sensitive && !registrations.contains("ComplianceDataService") {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "erasure-wiring".to_string(),
subject: "registrations.ts".to_string(),
message: "entities hold classified data but ComplianceDataService is not registered — erasure/export requests cannot be served".to_string(),
});
}
let better_auth_sources = BETTER_AUTH_WIRING_FILES
.iter()
.map(|file| fs::read_to_string(project_path.join(file)).unwrap_or_default())
.collect::<Vec<_>>()
.join("\n");
if has_sensitive && better_auth_orm_is_unwrapped(&better_auth_sources) {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "better-auth-encryption-context".to_string(),
subject: "registrations.ts".to_string(),
message: "entities hold classified data but Better Auth is wired to the raw ORM — its reads run with no tenant encryption context and will fail to decrypt once a tenant id is used".to_string(),
});
}
let owns_orm = project_path.join("mikro-orm.config.ts").exists();
if has_sensitive && owns_orm {
let sources = read_production_sources(&project_path);
if !sources.contains("registerEncryptor(") {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "encryptor-registration".to_string(),
subject: "mikro-orm.config.ts".to_string(),
message: "entities declare classified fields but registerEncryptor() is never called — those columns are written as plaintext".to_string(),
});
}
let binds_tenant = TENANT_BINDING_MARKERS
.iter()
.any(|marker| sources.contains(marker));
if !binds_tenant {
findings.push(LocalFinding {
severity: Severity::Info,
project: project.clone(),
check: "tenant-em-wiring".to_string(),
subject: "registrations.ts".to_string(),
message: "entities hold classified data but no encryption tenant is ever bound — encrypted columns can only use the no-tenant key, and will stop decrypting if a tenant is introduced later. Note this is about the ENCRYPTION context specifically: setFilterParams('tenant', ..) scopes queries but does not set it".to_string(),
});
}
if sources.contains(TENANT_FILTER_ONLY_MARKER)
&& !sources.contains(TENANT_CONTEXT_HELPER)
{
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "tenant-context-half-wired".to_string(),
subject: "registrations.ts".to_string(),
message: "the EntityManager factory calls setFilterParams('tenant', ..) but never wrapEmWithTenantContext — callers get row filtering and believe they are tenant-scoped, while encrypted columns still decrypt with the no-tenant key".to_string(),
});
}
}
for entity in &entities {
for (field, classification) in &entity.field_classifications {
if classification != "none" {
continue;
}
if let Some(suggested) = suggest_classification(field) {
findings.push(LocalFinding {
severity: Severity::Info,
project: project.clone(),
check: "possible-misclassification".to_string(),
subject: format!("{}.{}", entity.entity_name, field),
message: format!(
"field name suggests '{}' data but it is classified 'none' — review the classification",
suggested
),
});
}
}
}
let has_phi = entities.iter().any(|e| {
e.field_classifications
.values()
.any(|classification| classification == "phi")
});
if has_phi {
let sources = read_production_sources(&project_path);
let providers = direct_ai_providers(&sources);
if !providers.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "ai-provider-direct".to_string(),
subject: providers.join(", "),
message: format!(
"entities hold health data (phi) and this service calls an AI provider directly ({}) — route model calls through the ForkLaunch model gateway (createModelGatewayClient, BAA-covered models only) or confirm the provider has signed a BAA covering this use",
providers.join(", ")
),
});
}
}
}
let project_sources = read_production_sources(&project_path);
if is_managed_instance(&project_path, &project_sources) {
let mut credentials = provider_credentials_read(&project_sources);
credentials.retain(|key| !is_email_credential(key));
if !credentials.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "managed-provider-credentials".to_string(),
subject: credentials.join(", "),
message: format!(
"this service runs as a managed instance but reads provider credentials ({}) — hosted instances do not get them; use the platform's instance gateway for one-time codes and createModelGatewayClient for models",
credentials.join(", ")
),
});
}
let stripe = managed_stripe_credentials(&project_sources);
if !stripe.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "payments-stripe-keys-in-managed".to_string(),
subject: stripe.join(", "),
message: format!(
"this service runs as a managed instance but depends on Stripe credentials ({}) — a hosted instance has none; build the client with createStripeClient() (forklaunch infra add <service> payments) and declare any key optional for use outside managed mode",
stripe.join(", ")
),
});
}
let providers = direct_email_providers(&project_sources);
if !providers.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "email-provider-direct-in-managed".to_string(),
subject: providers.join(", "),
message: format!(
"this service runs as a managed instance but sends email with its own provider ({}) — hosted instances get no mail credential; send with createEmailClient() (`forklaunch infra add <service> email`), which uses the instance's platform sending identity and suppression list",
providers.join(", ")
),
});
}
let sms_sdks = direct_sms_providers(&project_sources);
if !sms_sdks.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "sms-provider-direct-in-managed".to_string(),
subject: sms_sdks.join(", "),
message: format!(
"this service runs as a managed instance but calls an SMS vendor SDK directly ({}) — hosted instances hold no vendor credential or number; send with createSmsClient() (`forklaunch infra add <service> sms`), which the platform limits, attributes and opts out per instance",
sms_sdks.join(", ")
),
});
}
}
for (file, text) in production_files(&project_path) {
let calls = sms_sends_with_protected_data(&text);
if calls.is_empty() {
continue;
}
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "sms-protected-data".to_string(),
subject: format!("{file}: {}", calls.join(", ")),
message: format!(
"{}a `.deanon` value (the plaintext of a compliant field) reaches an SMS body in {file} — texts are not a secure channel: carriers store them and lock screens show them. Send a sign-in link or a neutral notice instead",
if app_has_phi {
"health data (phi): "
} else {
""
}
),
});
}
let subjects = protected_data_in_email_subjects(&project_sources);
if !subjects.is_empty() {
let has_phi = scan_entity_compliance(&project_path)
.unwrap_or_default()
.iter()
.any(|e| e.field_classifications.values().any(|c| c == "phi"));
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "email-protected-data".to_string(),
subject: subjects.join(", "),
message: format!(
"{}an email subject carries a decrypted protected value ({}) — subjects show in lock-screen and notification previews and in mail logs; keep the protected detail in the body (or behind a link) and use a generic subject",
if has_phi { "health data (phi): " } else { "" },
subjects.join(", ")
),
});
}
if is_managed_instance(&project_path, &project_sources) {
let direct = direct_whatsapp_access(&project_sources);
if !direct.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "whatsapp-provider-direct-in-managed".to_string(),
subject: direct.join(", "),
message: format!(
"this service runs as a managed instance but reaches WhatsApp directly ({}) — hosted instances hold no Meta token or AWS credential; send with createWhatsAppClient() (forklaunch infra add <service> whatsapp)",
direct.join(", ")
),
});
}
}
let holds_phi = entities
.iter()
.any(|e| e.field_classifications.values().any(|c| c == "phi"));
for (file, text) in production_files(&project_path) {
for send in whatsapp_protected_sends(&text) {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "whatsapp-protected-data".to_string(),
subject: format!("{file}:{send}"),
message: format!(
"{}a compliant field's plaintext (.deanon) is sent over WhatsApp ({file}:{send}) — WhatsApp is not covered by the AWS BAA and Meta signs no BAA; send a template that carries no protected data (a reminder to open the app) instead",
if holds_phi { format!("{WHATSAPP_PHI_MARKER} and ") } else { String::new() }
),
});
}
}
let has_phi_entities = entities.iter().any(|e| {
e.field_classifications
.values()
.any(|classification| classification == "phi")
});
findings.extend(voice_findings(
&project,
is_managed_instance(&project_path, &project_sources),
has_phi_entities,
&project_sources,
));
let receives_stripe_events = STRIPE_WEBHOOK_MARKERS
.iter()
.any(|m| read_production_sources(&project_path.join("api")).contains(m));
if receives_stripe_events && stripe_webhook_unverified(&project_sources) {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "stripe-webhook-unverified".to_string(),
subject: "webhook".to_string(),
message: "this service handles Stripe webhook events but never verifies a signature — check Stripe's with stripe.webhooks.constructEvent(rawBody, signature, secret), or, in managed mode, receive them as platform events verified with verifyPlatformEvent".to_string(),
});
}
let leaks = stripe_calls_with_protected_data(&project_sources);
if !leaks.is_empty() {
let phi = entities
.iter()
.any(|e| e.field_classifications.values().any(|c| c == "phi"));
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "payments-protected-data".to_string(),
subject: format!("{}{}", if phi { "phi: " } else { "" }, leaks.join(", ")),
message: format!(
"a decrypted (.deanon) value is sent to Stripe as metadata or a description ({}) — Stripe signs no BAA, and these fields are shown to anyone with dashboard access; send an opaque reference (the record id) instead",
leaks.join(", ")
),
});
}
}
let declared = declared_object_stores(modules_path);
for entry in fs::read_dir(modules_path)? {
let project_path = entry?.path();
if !project_path.is_dir() {
continue;
}
let project = project_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let sources = read_production_sources(&project_path);
let registrations =
fs::read_to_string(project_path.join("registrations.ts")).unwrap_or_default();
let wired = registrations.contains("S3ObjectStore");
if let Some(declared_in) = declared.as_ref().and_then(|d| d.get(&project)) {
let message = match (*declared_in, wired) {
(true, false) => Some(
"the manifest declares an object store but registrations.ts registers no S3ObjectStore — the service has nothing to call it with",
),
(false, true) => Some(
"registrations.ts registers an S3ObjectStore but the manifest declares no object store — the platform will not provision a bucket or grant access to one",
),
_ => None,
};
if let Some(message) = message {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "object-store-wiring".to_string(),
subject: "registrations.ts".to_string(),
message: message.to_string(),
});
}
}
if let Some(declared) = declared_capabilities(modules_path)
.as_ref()
.and_then(|d| d.get(&project))
{
for capability in crate::infra::capabilities::CAPABILITIES {
let is_declared = declared.iter().any(|c| c == capability.id);
let is_wired = registrations
.contains(&format!("{}:", capability.registration_key))
&& (is_declared || is_managed_instance(&project_path, &sources));
let message = match (is_declared, is_wired) {
(true, false) => Some(format!(
"the manifest declares {} but registrations.ts registers no {} — the service has nothing to call it with",
capability.id, capability.registration_key
)),
(false, true) => Some(format!(
"registrations.ts registers {} but the manifest does not declare {} — the platform will not provision it for this service",
capability.registration_key, capability.id
)),
_ => None,
};
if let Some(message) = message {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "capability-wiring".to_string(),
subject: capability.id.to_string(),
message,
});
}
}
}
if !uses_object_store(&sources) {
continue;
}
let credentials = static_storage_credentials(&sources);
if !credentials.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "object-store-static-credentials".to_string(),
subject: credentials.join(", "),
message: format!(
"the S3 client is built from long-lived keys ({}) — deployed on ForkLaunch the service's task role grants access to its own bucket; build the client with s3ClientConfig so keys are used only for local MinIO",
credentials.join(", ")
),
});
}
let public = public_storage_access(&sources);
if !public.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "object-store-public-access".to_string(),
subject: public.join(", "),
message: format!(
"code makes stored files reachable by anyone ({}) — keep the bucket private and hand out presignDownload links instead",
public.join(", ")
),
});
}
let administration = bucket_administration(&sources);
if !administration.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "object-store-bucket-managed-in-app".to_string(),
subject: administration.join(", "),
message: format!(
"app code administers the bucket ({}) — the platform creates the bucket and sets its policy and CORS, and the task role is not allowed to change them",
administration.join(", ")
),
});
}
let unbounded = unbounded_presigned_uploads(&sources);
if !unbounded.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.clone(),
check: "presigned-upload-unbounded".to_string(),
subject: unbounded.join(", "),
message: format!(
"a browser upload grant has no size limit ({}) — use ObjectStore.presignUpload, a presigned POST that enforces maxBytes and the content type",
unbounded.join(", ")
),
});
}
}
findings.sort_by(|a, b| {
(&a.project, &a.check, &a.subject, &a.message)
.cmp(&(&b.project, &b.check, &b.subject, &b.message))
});
Ok(findings)
}
const VOICE_PROVIDER_PACKAGES: &[&str] = &[
"@aws-sdk/client-connect",
"@aws-sdk/client-connectcontactlens",
"@vonage/voice",
"@vonage/server-sdk",
"@vonage/vcr-sdk",
];
const TWILIO_VOICE_MARKERS: &[&str] = &[".calls.create(", "VoiceResponse", "twiml.voice"];
const VOICE_PROVIDER_KEYS: &[&str] = &[
"CONNECT_INSTANCE_ID",
"AMAZON_CONNECT_INSTANCE_ID",
"CONNECT_CONTACT_FLOW_ID",
"VONAGE_API_KEY",
"VONAGE_API_SECRET",
"VONAGE_APPLICATION_ID",
"VONAGE_PRIVATE_KEY",
];
pub(crate) fn direct_voice_providers(sources: &str) -> Vec<String> {
let modules = imported_modules(sources);
let mut found: Vec<String> = modules
.iter()
.filter(|m| VOICE_PROVIDER_PACKAGES.contains(&m.as_str()))
.cloned()
.collect();
if modules.iter().any(|m| m == "twilio")
&& TWILIO_VOICE_MARKERS.iter().any(|marker| sources.contains(marker))
{
found.push("twilio (voice)".to_string());
}
found.extend(
VOICE_PROVIDER_KEYS
.iter()
.filter(|key| contains_word(sources, key))
.map(|key| key.to_string()),
);
found.sort();
found.dedup();
found
}
fn contains_word(text: &str, word: &str) -> bool {
text.match_indices(word).any(|(i, _)| {
let before = text[..i].chars().next_back();
let after = text[i + word.len()..].chars().next();
!before.is_some_and(|c| c.is_alphanumeric() || c == '_')
&& !after.is_some_and(|c| c.is_alphanumeric() || c == '_')
})
}
fn balanced(text: &str, open: usize) -> Option<&str> {
let bytes = text.as_bytes();
let mut depth = 0usize;
let mut i = open;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
if let Some(q) = quote {
if c == b'\\' {
i += 2;
continue;
}
if c == q {
quote = None;
}
} else {
match c {
b'\'' | b'"' | b'`' => quote = Some(c),
b'/' if bytes.get(i + 1) == Some(&b'/') => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
b'(' | b'{' | b'[' => depth += 1,
b')' | b'}' | b']' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return text.get(open + 1..i);
}
}
_ => {}
}
}
i += 1;
}
None
}
fn is_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
fn reads_deanon(expr: &str) -> bool {
expr.match_indices(".deanon")
.any(|(i, _)| !expr[i + 7..].chars().next().is_some_and(is_ident_char))
}
fn deanon_identifiers(sources: &str) -> Vec<String> {
static DECL: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = DECL.get_or_init(|| {
regex::Regex::new(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=([^;\n]*)")
.expect("declaration pattern")
});
re.captures_iter(sources)
.filter(|c| reads_deanon(&c[2]))
.map(|c| c[1].to_string())
.collect()
}
fn declared_object<'a>(sources: &'a str, ident: &str) -> Option<&'a str> {
let re = regex::Regex::new(&format!(
r"\b(?:const|let|var)\s+{}\s*(?::[^=;]+)?=\s*\{{",
regex::escape(ident)
))
.ok()?;
let m = re.find(sources)?;
balanced(sources, m.end() - 1)
}
fn attributes_of<'a>(sources: &'a str, argument: &'a str) -> Option<&'a str> {
static KEY: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let re = KEY.get_or_init(|| {
regex::Regex::new(r"\battributes\b\s*(:)?\s*").expect("attributes pattern")
});
let m = re.captures(argument)?;
let rest = &argument[m.get(0)?.end()..];
if m.get(1).is_none() {
return declared_object(sources, "attributes");
}
if rest.starts_with('{') {
let open = argument.len() - rest.len();
return balanced(argument, open);
}
let ident: String = rest.chars().take_while(|c| is_ident_char(*c)).collect();
if ident.is_empty() {
return Some(rest.split([',', '}']).next().unwrap_or(rest));
}
declared_object(sources, &ident).or(Some(&rest[..ident.len()]))
}
pub(crate) fn voice_attribute_leaks(sources: &str) -> Vec<String> {
let tainted = deanon_identifiers(sources);
let mut found = Vec::new();
for (index, _) in sources.match_indices("startOutboundCall(") {
let open = index + "startOutboundCall".len();
let Some(argument) = balanced(sources, open) else {
continue;
};
let argument = argument.trim();
let argument_object = if argument.starts_with('{') {
balanced(argument, 0).unwrap_or(argument)
} else {
let ident: String = argument.chars().take_while(|c| is_ident_char(*c)).collect();
match declared_object(sources, &ident) {
Some(object) => object,
None => continue,
}
};
let Some(attributes) = attributes_of(sources, argument_object) else {
continue;
};
if reads_deanon(attributes) {
found.push(".deanon".to_string());
}
for ident in &tainted {
if contains_word(attributes, ident) {
found.push(ident.clone());
}
}
}
found.sort();
found.dedup();
found
}
fn voice_findings(
project: &str,
managed: bool,
has_phi_entities: bool,
sources: &str,
) -> Vec<LocalFinding> {
let mut findings = Vec::new();
if managed {
let providers = direct_voice_providers(sources);
if !providers.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.to_string(),
check: "voice-provider-direct-in-managed".to_string(),
subject: providers.join(", "),
message: format!(
"this service runs as a managed instance but calls a voice provider itself ({}) — hosted instances get no vendor credentials; place calls with createVoiceClient(), which dials through the platform's Amazon Connect under the instance's own limits",
providers.join(", ")
),
});
}
}
let leaks = voice_attribute_leaks(sources);
if !leaks.is_empty() {
findings.push(LocalFinding {
severity: Severity::Warning,
project: project.to_string(),
check: "voice-protected-data".to_string(),
subject: leaks.join(", "),
message: format!(
"a protected value ({}) is passed in startOutboundCall attributes{} — Amazon Connect stores contact attributes in its contact records; pass an id the contact flow looks up instead",
leaks.join(", "),
if has_phi_entities {
" and this service holds health data (phi)"
} else {
""
}
),
});
}
findings
}
#[cfg(test)]
mod voice_tests {
use super::*;
#[test]
fn a_managed_service_calling_connect_or_twilio_voice_is_flagged() {
let sources = "getEnvVar('INSTANCE_HMAC_KEY');\nimport { ConnectClient } from '@aws-sdk/client-connect';\nimport twilio from 'twilio';\nclient.calls.create({ to, from, url });\nconst id = getEnvVar('CONNECT_INSTANCE_ID');";
let found = voice_findings("clinic", true, false, sources);
let f = found
.iter()
.find(|f| f.check == "voice-provider-direct-in-managed")
.expect("finding");
assert_eq!(
f.subject,
"@aws-sdk/client-connect, CONNECT_INSTANCE_ID, twilio (voice)"
);
assert!(voice_findings("clinic", false, false, sources).is_empty());
}
#[test]
fn twilio_for_sms_and_twilio_keys_are_left_to_managed_provider_credentials() {
let sources = "import twilio from 'twilio';\nclient.messages.create({ to, body });\ngetEnvVar('TWILIO_AUTH_TOKEN');";
assert!(direct_voice_providers(sources).is_empty());
assert!(direct_voice_providers("MY_CONNECT_INSTANCE_ID_OLD").is_empty());
}
#[test]
fn deanon_in_call_attributes_is_flagged_in_every_shape() {
let inline = "await voice.startOutboundCall({ to: p.phone.deanon, flow: 'appointment_reminder', attributes: { diagnosis: record.diagnosis.deanon } });";
assert_eq!(voice_attribute_leaks(inline), vec![".deanon"]);
let via_variable = "const dx = record.diagnosis.deanon;\nconst attrs = { dx, appointmentId: a.id };\nawait voice.startOutboundCall({ to, flow: 'x', attributes: attrs });";
assert_eq!(voice_attribute_leaks(via_variable), vec!["dx"]);
let shorthand = "const attributes = { name: patient.name.deanon };\nvoice.startOutboundCall({ to, flow, attributes });";
assert_eq!(voice_attribute_leaks(shorthand), vec![".deanon"]);
let request = "const request = { to, flow: 'x', attributes: { n: p.name.deanon() } };\nvoice.startOutboundCall(request);";
assert_eq!(voice_attribute_leaks(request), vec![".deanon"]);
}
#[test]
fn a_deanon_phone_number_in_to_is_not_a_leak() {
let sources = "const phone = patient.phone.deanon;\nawait voice.startOutboundCall({ to: phone, flow: 'appointment_reminder', attributes: { appointmentId: appt.id } });\nconst deanonymized = 1;";
assert!(voice_attribute_leaks(sources).is_empty());
assert!(voice_attribute_leaks("voice.startOutboundCall({ to, flow });").is_empty());
}
#[test]
fn phi_entities_make_the_leak_critical_in_the_message() {
let sources = "voice.startOutboundCall({ to, flow, attributes: { d: r.d.deanon } });";
let f = voice_findings("clinic", false, true, sources);
assert_eq!(f.len(), 1);
assert_eq!(f[0].check, "voice-protected-data");
assert!(f[0].message.contains("(phi)"));
assert!(!voice_findings("clinic", false, false, sources)[0].message.contains("(phi)"));
}
#[test]
fn voice_checks_are_listed() {
for id in ["voice-provider-direct-in-managed", "voice-protected-data"] {
assert!(LOCAL_CHECK_IDS.contains(&id));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_missing_tenant_gates_flags_absent_calls() {
let source = "const orm = ci.resolve(tokens.Orm);";
let missing = missing_tenant_gates(source);
assert_eq!(missing.len(), 2);
}
#[test]
fn test_missing_tenant_gates_passes_wired_entrypoint() {
let source = r#"
setupTenantFilter(orm, { logger });
setupRls(orm, { logger });
"#;
assert!(missing_tenant_gates(source).is_empty());
}
#[test]
fn test_better_auth_orm_unwrapped_is_flagged() {
let registrations = r#"
EntityManager: { factory: ({ Orm }, context) =>
wrapEmWithTenantContext(Orm.em.fork(), context?.tenantId) },
BetterAuth: { factory: ({ Orm }) =>
betterAuth(betterAuthConfig({ orm: Orm })) }
"#;
assert!(better_auth_orm_is_unwrapped(registrations));
}
#[test]
fn test_better_auth_orm_wrapped_passes() {
let registrations = r#"
BetterAuth: { factory: ({ Orm }) =>
betterAuth(betterAuthConfig({ orm: createEncryptionAwareOrm(Orm) })) }
"#;
assert!(!better_auth_orm_is_unwrapped(registrations));
}
#[test]
fn test_better_auth_platform_proxy_also_counts_as_wrapped() {
let registrations = r#"
orm: createTenantAwareBetterAuthOrmProxy(Orm)
"#;
assert!(!better_auth_orm_is_unwrapped(registrations));
}
#[test]
fn test_wrapper_in_auth_ts_counts_even_when_registrations_looks_raw() {
let registrations = "betterAuth(betterAuthConfig({ orm: Orm }))";
let auth = "const betterAuthOrm = createTenantAwareBetterAuthOrmProxy(orm);";
let combined = format!("{}\n{}", registrations, auth);
assert!(better_auth_orm_is_unwrapped(registrations));
assert!(!better_auth_orm_is_unwrapped(&combined));
}
#[test]
fn test_project_without_better_auth_is_not_flagged() {
let registrations = r#"
EntityManager: { factory: ({ Orm }, context) =>
wrapEmWithTenantContext(Orm.em.fork(), context?.tenantId) }
"#;
assert!(!better_auth_orm_is_unwrapped(registrations));
}
#[test]
fn test_suggest_classification_pii_camel_and_snake() {
assert_eq!(suggest_classification("email"), Some("pii"));
assert_eq!(suggest_classification("userEmail"), Some("pii"));
assert_eq!(suggest_classification("first_name"), Some("pii"));
assert_eq!(suggest_classification("billingAddress"), Some("pii"));
assert_eq!(suggest_classification("dateOfBirth"), Some("pii"));
assert_eq!(suggest_classification("birthDate"), Some("pii"));
}
#[test]
fn test_suggest_classification_strongest_wins() {
assert_eq!(suggest_classification("cardNumber"), Some("pci"));
assert_eq!(suggest_classification("ssn"), Some("phi"));
assert_eq!(suggest_classification("healthInsuranceNumber"), Some("phi"));
}
#[test]
fn test_suggest_classification_avoids_generic_names() {
assert_eq!(suggest_classification("name"), None);
assert_eq!(suggest_classification("description"), None);
assert_eq!(suggest_classification("externalId"), None);
assert_eq!(suggest_classification("billingProvider"), None);
assert_eq!(suggest_classification("cadence"), None);
}
}
#[cfg(test)]
mod fs_tests {
use super::*;
#[test]
fn test_run_local_checks_flags_unwired_entrypoint() {
let dir = std::env::temp_dir().join("fl-checks-test");
let proj = dir.join("svc");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(proj.join("persistence")).unwrap();
std::fs::write(proj.join("server.ts"), "const app = express();").unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
findings.len(),
2,
"expected 2 wiring findings, got: {:?}",
findings
);
assert!(
findings
.iter()
.all(|f| f.check == "tenant-isolation-wiring")
);
}
fn write_project_with_classified_entity(proj: &std::path::Path, registrations: &str) {
std::fs::create_dir_all(proj.join("persistence/entities")).unwrap();
std::fs::write(
proj.join("persistence/entities/account.entity.ts"),
r#"
export const AccountEntity = defineComplianceEntity({
name: 'Account',
properties: {
accountId: fp.string().compliance('none'),
password: fp.string().nullable().compliance('pii')
}
});
"#,
)
.unwrap();
std::fs::write(proj.join("registrations.ts"), registrations).unwrap();
std::fs::write(proj.join("mikro-orm.config.ts"), "export default {};").unwrap();
}
#[test]
fn test_run_local_checks_flags_missing_encryptor() {
let dir = std::env::temp_dir().join("fl-checks-no-encryptor");
let proj = dir.join("svc");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(&proj, "export const x = 1;");
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings
.iter()
.find(|f| f.check == "encryptor-registration")
.unwrap_or_else(|| panic!("expected the finding, got: {:?}", findings));
assert!(matches!(f.severity, Severity::Warning));
}
#[test]
fn test_run_local_checks_accepts_encryptor_in_mikro_orm_config() {
let dir = std::env::temp_dir().join("fl-checks-encryptor-ok");
let proj = dir.join("svc");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(&proj, "export const x = 1;");
std::fs::write(
proj.join("mikro-orm.config.ts"),
"registerEncryptor(new FieldEncryptor(key));",
)
.unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!findings.iter().any(|f| f.check == "encryptor-registration"),
"registering in mikro-orm.config.ts should satisfy the check, got: {:?}",
findings
);
}
#[test]
fn test_run_local_checks_reports_unbound_tenant_em_as_info() {
let dir = std::env::temp_dir().join("fl-checks-no-tenant-em");
let proj = dir.join("svc");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(&proj, "export const x = 1;");
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings
.iter()
.find(|f| f.check == "tenant-em-wiring")
.unwrap_or_else(|| panic!("expected the finding, got: {:?}", findings));
assert!(matches!(f.severity, Severity::Info));
}
#[test]
fn test_flags_factory_that_filters_without_binding_encryption() {
let dir = std::env::temp_dir().join("fl-checks-half-wired");
let proj = dir.join("iam");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(
&proj,
"registerEncryptor(enc); em.setFilterParams('tenant', { tenantId });",
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
findings
.iter()
.any(|f| f.check == "tenant-context-half-wired"),
"expected the half-wired finding, got: {:?}",
findings
);
}
#[test]
fn test_factory_using_the_helper_is_not_flagged() {
let dir = std::env::temp_dir().join("fl-checks-fully-wired");
let proj = dir.join("iam");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(
&proj,
"registerEncryptor(enc); wrapEmWithTenantContext(Orm.em.fork(), context?.tenantId);",
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!findings
.iter()
.any(|f| f.check == "tenant-context-half-wired"),
"a factory using the helper must not be flagged, got: {:?}",
findings
);
}
#[test]
fn test_entities_without_an_orm_are_not_flagged() {
let dir = std::env::temp_dir().join("fl-checks-queue-payload");
let proj = dir.join("worker");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(&proj, "export const x = 1;");
std::fs::remove_file(proj.join("mikro-orm.config.ts")).unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!findings
.iter()
.any(|f| f.check == "encryptor-registration" || f.check == "tenant-em-wiring"),
"a project with no ORM should not be flagged, got: {:?}",
findings
);
}
#[test]
fn test_tenant_binding_accepts_with_encryption_context() {
let dir = std::env::temp_dir().join("fl-checks-alt-binding");
let proj = dir.join("iam");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(&proj, "registerEncryptor(enc);");
std::fs::write(
proj.join("auth.ts"),
"await withEncryptionContext(orgId, () => em.findOne(Account, where));",
)
.unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!findings.iter().any(|f| f.check == "tenant-em-wiring"),
"withEncryptionContext should count as binding, got: {:?}",
findings
);
}
#[test]
fn test_tenant_binding_in_a_test_file_does_not_count() {
let dir = std::env::temp_dir().join("fl-checks-test-only-binding");
let proj = dir.join("svc");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(&proj, "registerEncryptor(enc);");
std::fs::create_dir_all(proj.join("__test__")).unwrap();
std::fs::write(
proj.join("__test__/thing.test.ts"),
"withEncryptionContext('org', () => {});",
)
.unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
findings.iter().any(|f| f.check == "tenant-em-wiring"),
"a binding call only in tests must still flag, got: {:?}",
findings
);
}
#[test]
fn test_run_local_checks_ignores_projects_without_classified_data() {
let dir = std::env::temp_dir().join("fl-checks-unclassified");
let proj = dir.join("svc");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(proj.join("persistence/entities")).unwrap();
std::fs::write(
proj.join("persistence/entities/thing.entity.ts"),
"export const ThingEntity = defineComplianceEntity({ name: 'Thing', properties: { label: fp.string().compliance('none') } });",
)
.unwrap();
std::fs::write(proj.join("registrations.ts"), "export const x = 1;").unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!findings
.iter()
.any(|f| f.check == "encryptor-registration" || f.check == "tenant-em-wiring"),
"unclassified project should be quiet, got: {:?}",
findings
);
}
#[test]
fn test_run_local_checks_flags_better_auth_raw_orm() {
let dir = std::env::temp_dir().join("fl-checks-ba-raw");
let proj = dir.join("iam");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(
&proj,
"BetterAuth: betterAuth(betterAuthConfig({ orm: Orm }))",
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
findings
.iter()
.any(|f| f.check == "better-auth-encryption-context"),
"expected the encryption-context finding, got: {:?}",
findings
);
}
#[test]
fn test_run_local_checks_passes_encryption_aware_better_auth() {
let dir = std::env::temp_dir().join("fl-checks-ba-wrapped");
let proj = dir.join("iam");
let _ = std::fs::remove_dir_all(&dir);
write_project_with_classified_entity(
&proj,
"BetterAuth: betterAuth(betterAuthConfig({ orm: createEncryptionAwareOrm(Orm) }))",
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!findings
.iter()
.any(|f| f.check == "better-auth-encryption-context"),
"wrapped ORM should not be flagged, got: {:?}",
findings
);
}
}
#[cfg(test)]
mod wiring_tests {
use super::*;
#[test]
fn direct_ai_providers_finds_sdk_imports_and_keys_only() {
let sources = r#"
import OpenAI from 'openai';
import { createAnthropic } from "@ai-sdk/anthropic";
const bedrock = require('@aws-sdk/client-bedrock-runtime');
const key = process.env.ANTHROPIC_API_KEY;
import { openai_helpers } from './openai';
import { createModelGatewayClient } from '@forklaunch/core/http';
"#;
assert_eq!(
direct_ai_providers(sources),
vec![
"@ai-sdk/anthropic",
"@aws-sdk/client-bedrock-runtime",
"ANTHROPIC_API_KEY",
"openai"
]
);
assert!(
direct_ai_providers("import { createModelGatewayClient } from '@forklaunch/core/http';")
.is_empty(),
"the gateway is not a direct provider"
);
}
#[test]
fn managed_signals_and_credentials() {
let dir = std::env::temp_dir().join("fl-wiring-managed-signal");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(is_managed_instance(&dir, "getEnvVar('INSTANCE_HMAC_KEY')"));
assert!(!is_managed_instance(&dir, "getEnvVar('DB_HOST')"));
std::fs::create_dir_all(dir.join("api/controllers")).unwrap();
std::fs::write(dir.join("api/controllers/relay.controller.ts"), "").unwrap();
assert!(is_managed_instance(&dir, ""));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
provider_credentials_read("TWILIO_AUTH_TOKEN OPENAI_API_KEY DB_HOST"),
vec!["OPENAI_API_KEY", "TWILIO_AUTH_TOKEN"]
);
}
fn write_phi_project(proj: &std::path::Path, service_source: &str) {
std::fs::create_dir_all(proj.join("persistence/entities")).unwrap();
std::fs::create_dir_all(proj.join("domain/services")).unwrap();
std::fs::write(
proj.join("persistence/entities/visit.entity.ts"),
r#"
export const VisitEntity = defineComplianceEntity({
name: 'Visit',
properties: {
id: fp.uuid().primary().compliance('none'),
notes: fp.text().compliance('phi')
}
});
"#,
)
.unwrap();
std::fs::write(
proj.join("registrations.ts"),
"registerEncryptor(x); ComplianceDataService; wrapEmWithTenantContext(em)",
)
.unwrap();
std::fs::write(proj.join("mikro-orm.config.ts"), "export default {};").unwrap();
std::fs::write(proj.join("domain/services/summary.service.ts"), service_source).unwrap();
}
#[test]
fn a_phi_service_calling_a_provider_directly_is_flagged() {
let dir = std::env::temp_dir().join("fl-wiring-ai-direct");
let _ = std::fs::remove_dir_all(&dir);
write_phi_project(
&dir.join("clinic"),
"import OpenAI from 'openai'; const c = new OpenAI({ apiKey: getEnvVar('OPENAI_API_KEY') });",
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings
.iter()
.find(|f| f.check == "ai-provider-direct")
.unwrap_or_else(|| panic!("expected ai-provider-direct, got {:?}", findings));
assert!(matches!(f.severity, Severity::Warning));
assert_eq!(f.subject, "OPENAI_API_KEY, openai");
assert!(f.message.contains("createModelGatewayClient"));
}
#[test]
fn a_phi_service_using_the_gateway_is_not_flagged() {
let dir = std::env::temp_dir().join("fl-wiring-ai-gateway");
let _ = std::fs::remove_dir_all(&dir);
write_phi_project(
&dir.join("clinic"),
"import { createModelGatewayClient } from '@forklaunch/core/http';",
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!findings.iter().any(|f| f.check == "ai-provider-direct"),
"{:?}",
findings
);
}
#[test]
fn a_non_phi_service_may_call_a_provider() {
let dir = std::env::temp_dir().join("fl-wiring-ai-no-phi");
let proj = dir.join("svc");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(proj.join("persistence/entities")).unwrap();
std::fs::write(
proj.join("persistence/entities/note.entity.ts"),
"export const N = defineComplianceEntity({ name: 'N', properties: { body: fp.text().compliance('pii') } });",
)
.unwrap();
std::fs::write(proj.join("registrations.ts"), "import OpenAI from 'openai';").unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(!findings.iter().any(|f| f.check == "ai-provider-direct"));
}
#[test]
fn a_managed_template_reading_provider_credentials_is_flagged() {
let dir = std::env::temp_dir().join("fl-wiring-managed-creds");
let proj = dir.join("iam");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(
proj.join("registrations.ts"),
"const gateway = getEnvVar('PLATFORM_GATEWAY_URL'); const t = getEnvVar('TWILIO_AUTH_TOKEN');",
)
.unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings
.iter()
.find(|f| f.check == "managed-provider-credentials")
.unwrap_or_else(|| panic!("expected the finding, got {:?}", findings));
assert_eq!(f.subject, "TWILIO_AUTH_TOKEN");
}
#[test]
fn an_ordinary_app_reading_twilio_is_not_a_managed_finding() {
let dir = std::env::temp_dir().join("fl-wiring-unmanaged-creds");
let proj = dir.join("messaging");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("registrations.ts"), "getEnvVar('TWILIO_AUTH_TOKEN')").unwrap();
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(!findings.iter().any(|f| f.check == "managed-provider-credentials"));
}
}
#[cfg(test)]
mod payments_tests {
use super::*;
#[test]
fn required_or_direct_stripe_keys_are_found_optional_ones_are_not() {
let required = "STRIPE_API_KEY: {\n lifetime: Lifetime.Singleton,\n type: string,\n value: getEnvVar('STRIPE_API_KEY') }";
assert_eq!(managed_stripe_credentials(required), vec!["STRIPE_API_KEY"]);
let optional = "STRIPE_API_KEY: {\n\t\tlifetime: Lifetime.Singleton,\n\t\ttype: optional(string),\n\t\tvalue: getEnvVar(\"STRIPE_API_KEY\") }\n factory: ({ STRIPE_API_KEY }) => createStripeClient({ Stripe, apiKey: STRIPE_API_KEY })";
assert!(managed_stripe_credentials(optional).is_empty());
assert_eq!(
managed_stripe_credentials("const s = new Stripe(process.env['STRIPE_SECRET_KEY']);"),
vec!["STRIPE_SECRET_KEY", "new Stripe("]
);
assert!(managed_stripe_credentials(
"isManagedInstance() ? createStripeClient({ Stripe }) : new Stripe(STRIPE_API_KEY!)"
)
.is_empty());
}
#[test]
fn webhooks_need_a_signature_check() {
assert!(stripe_webhook_unverified(
"const event = req.body as Stripe.Event; await handle(event);"
));
assert!(!stripe_webhook_unverified(
"stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], secret)"
));
assert!(!stripe_webhook_unverified(
"case 'checkout.session.completed': …; verifyPlatformEvent({ method, path, headers, body })"
));
assert!(!stripe_webhook_unverified("await stripe.customers.create({ email })"));
}
#[test]
fn deanon_values_inside_stripe_calls_are_found() {
let leak = "await this.stripeClient.checkout.sessions.create({\n mode: 'payment',\n metadata: { patient: patient.fullName.deanon, visit: visit.id },\n line_items\n});";
assert_eq!(stripe_calls_with_protected_data(leak), vec!["checkout.sessions.create"]);
let description = "stripe.paymentIntents.create({ amount, description: `Visit for ${p.diagnosis.deanon}`, currency })";
assert_eq!(stripe_calls_with_protected_data(description), vec!["paymentIntents.create"]);
let fine = "stripe.customers.create({ email: user.email.deanon, metadata: { userId: user.id } })";
assert!(stripe_calls_with_protected_data(fine).is_empty());
let outside = "const n = p.name.deanon;\nstripe.customers.create({ metadata: { ref: p.id } });";
assert!(stripe_calls_with_protected_data(outside).is_empty());
}
fn project(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fl-payments-checks-{name}"));
let _ = std::fs::remove_dir_all(&dir);
for (path, text) in files {
let p = dir.join("clinic").join(path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
dir
}
fn found(dir: &std::path::Path) -> Vec<(String, String)> {
let out = run_local_checks(dir)
.unwrap()
.into_iter()
.filter(|f| f.check.starts_with("payments") || f.check.starts_with("stripe"))
.map(|f| (f.check, f.subject))
.collect();
let _ = std::fs::remove_dir_all(dir);
out
}
#[test]
fn a_managed_service_with_a_required_stripe_key_is_flagged() {
let dir = project(
"library-events",
&[("services/webhook.service.ts", "handle(event: Stripe.Event) { switch (event.type) { case 'checkout.session.completed': } }")],
);
assert!(found(&dir).is_empty(), "a library is not the webhook receiver");
let dir = project(
"unverified-route",
&[("api/controllers/webhook.controller.ts", "const event = req.body as Stripe.Event; await service.handle(event);")],
);
assert_eq!(found(&dir), vec![("stripe-webhook-unverified".to_string(), "webhook".to_string())]);
let dir = project(
"managed-key",
&[(
"registrations.ts",
"getEnvVar('INSTANCE_HMAC_KEY'); STRIPE_API_KEY: { lifetime: Lifetime.Singleton, type: string, value: getEnvVar('STRIPE_API_KEY') }",
)],
);
assert_eq!(
found(&dir),
vec![("payments-stripe-keys-in-managed".to_string(), "STRIPE_API_KEY".to_string())]
);
let dir = project(
"unmanaged-key",
&[(
"registrations.ts",
"STRIPE_API_KEY: { lifetime: Lifetime.Singleton, type: string, value: getEnvVar('STRIPE_API_KEY') }",
)],
);
assert!(found(&dir).is_empty());
}
#[test]
fn protected_data_is_critical_only_when_the_service_holds_phi() {
let service = "await stripe.customers.create({ metadata: { dob: patient.dob.deanon } });";
let phi = project(
"phi",
&[
("domain/services/pay.service.ts", service),
(
"persistence/entities/patient.entity.ts",
"export const P = defineComplianceEntity({ name: 'P', properties: { dob: fp.string().compliance('phi') } });",
),
],
);
assert_eq!(
found(&phi),
vec![("payments-protected-data".to_string(), "phi: customers.create".to_string())]
);
let pii = project("pii", &[("domain/services/pay.service.ts", service)]);
assert_eq!(
found(&pii),
vec![("payments-protected-data".to_string(), "customers.create".to_string())]
);
}
#[test]
fn a_keyed_stripe_client_outside_managed_mode_is_not_undeclared_payments() {
let write = |name: &str, registrations: &str| {
let root = std::env::temp_dir().join(format!("fl-payments-wiring-{name}"));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".forklaunch")).unwrap();
std::fs::create_dir_all(root.join("src/modules/billing")).unwrap();
std::fs::write(
root.join(".forklaunch/manifest.toml"),
"app_name = \"demo\"\n\n[[projects]]\nname = \"billing\"\n[projects.resources]\ncache = \"redis\"\n",
)
.unwrap();
std::fs::write(root.join("src/modules/billing/registrations.ts"), registrations).unwrap();
let checks: Vec<String> = run_local_checks(&root.join("src/modules"))
.unwrap()
.into_iter()
.filter(|f| f.check == "capability-wiring")
.map(|f| f.subject)
.collect();
let _ = std::fs::remove_dir_all(&root);
checks
};
assert!(write("plain", "StripeClient: { factory: ({ STRIPE_API_KEY }) => new Stripe(STRIPE_API_KEY) }").is_empty());
assert_eq!(
write(
"managed",
"getEnvVar('INSTANCE_HMAC_KEY'); StripeClient: { factory: () => createStripeClient({ Stripe }) }"
),
vec!["payments"]
);
}
#[test]
fn payments_checks_are_listed() {
for id in [
"payments-stripe-keys-in-managed",
"stripe-webhook-unverified",
"payments-protected-data",
] {
assert!(LOCAL_CHECK_IDS.contains(&id), "{id} missing from LOCAL_CHECK_IDS");
}
}
}
#[cfg(test)]
mod object_store_tests {
use super::*;
fn app(name: &str, manifest_object_store: bool, registrations: &str, extra: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("fl-object-store-{name}"));
let _ = std::fs::remove_dir_all(&root);
let modules = root.join("src/modules");
let service = modules.join("files");
std::fs::create_dir_all(root.join(".forklaunch")).unwrap();
std::fs::create_dir_all(&service).unwrap();
let resources = if manifest_object_store {
"[projects.resources]\nobject_store = \"s3\"\n"
} else {
"[projects.resources]\ndatabase = \"postgresql\"\n"
};
std::fs::write(
root.join(".forklaunch/manifest.toml"),
format!("app_name = \"demo\"\n\n[[projects]]\nname = \"files\"\n{resources}"),
)
.unwrap();
std::fs::write(service.join("registrations.ts"), registrations).unwrap();
std::fs::write(service.join("uploads.ts"), extra).unwrap();
modules
}
const KEYLESS: &str = "import { S3ObjectStore, s3ClientConfig } from '@forklaunch/infrastructure-s3';\n\
new S3ObjectStore(otel, { bucket: S3_BUCKET, clientConfig: s3ClientConfig({ url: S3_URL, region: S3_REGION, accessKeyId: S3_ACCESS_KEY_ID, secretAccessKey: S3_SECRET_ACCESS_KEY }) });";
fn checks(modules: &std::path::Path) -> Vec<String> {
run_local_checks(modules)
.unwrap()
.into_iter()
.filter(|f| f.check.starts_with("object-store") || f.check == "presigned-upload-unbounded")
.map(|f| f.check)
.collect()
}
#[test]
fn keyless_declared_and_wired_store_is_clean() {
let modules = app("clean", true, KEYLESS, "await store.presignUpload(key, { contentType, maxBytes: 1000 });");
assert!(checks(&modules).is_empty(), "{:?}", checks(&modules));
}
#[test]
fn declared_and_wired_must_agree() {
let modules = app("undeclared", false, KEYLESS, "");
assert_eq!(checks(&modules), vec!["object-store-wiring"]);
let modules = app("unwired", true, "export {}", "");
assert_eq!(checks(&modules), vec!["object-store-wiring"]);
}
#[test]
fn static_keys_are_flagged_unless_behind_s3_client_config() {
let old = "import { S3ObjectStore } from '@forklaunch/infrastructure-s3';\n\
new S3ObjectStore(otel, { bucket: S3_BUCKET, clientConfig: { credentials: { accessKeyId: S3_ACCESS_KEY_ID, secretAccessKey: S3_SECRET_ACCESS_KEY } } });";
let modules = app("static", true, old, "");
assert_eq!(checks(&modules), vec!["object-store-static-credentials"]);
assert_eq!(
static_storage_credentials("import '@aws-sdk/client-s3'; process.env.AWS_SECRET_ACCESS_KEY"),
vec!["AWS_SECRET_ACCESS_KEY"]
);
}
#[test]
fn public_access_bucket_admin_and_unbounded_uploads() {
let code = "import { PutBucketCorsCommand, PutObjectCommand } from '@aws-sdk/client-s3';\n\
await s3.send(new PutObjectCommand({ Bucket, Key, ACL: 'public-read' }));\n\
await s3.send(new PutBucketCorsCommand({ CORSConfiguration: { CORSRules: [{ AllowedOrigins: ['*'] }] } }));\n\
const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket, Key }), { expiresIn: 3600 });";
let modules = app("risky", true, KEYLESS, code);
let mut found = checks(&modules);
found.sort();
assert_eq!(
found,
vec![
"object-store-bucket-managed-in-app",
"object-store-public-access",
"presigned-upload-unbounded"
]
);
assert_eq!(
public_storage_access(code),
vec!["public-read", "AllowedOrigins: ['*']"]
);
assert_eq!(
unbounded_presigned_uploads("createPresignedPost(s3, { Bucket, Key })"),
vec!["createPresignedPost without content-length-range"]
);
assert!(unbounded_presigned_uploads(
"createPresignedPost(s3, { Conditions: [['content-length-range', 1, 10]] })"
)
.is_empty());
}
#[test]
fn object_store_checks_score_through_the_report_card() {
for id in [
"object-store-wiring",
"object-store-static-credentials",
"object-store-public-access",
"object-store-bucket-managed-in-app",
"presigned-upload-unbounded",
] {
assert!(LOCAL_CHECK_IDS.contains(&id), "{id} missing from LOCAL_CHECK_IDS");
}
}
}
#[cfg(test)]
mod email_tests {
use super::*;
fn project(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fl-email-checks-{name}"));
let _ = std::fs::remove_dir_all(&dir);
for (path, text) in files {
let path = dir.join("svc").join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
dir
}
fn found(dir: &std::path::Path) -> Vec<(String, String)> {
let findings = run_local_checks(dir).unwrap();
let _ = std::fs::remove_dir_all(dir);
findings
.into_iter()
.filter(|f| {
f.check.starts_with("email-") || f.check == "managed-provider-credentials"
})
.map(|f| (f.check, f.subject))
.collect()
}
const MANAGED: &str = "getEnvVar('INSTANCE_HMAC_KEY');\n";
#[test]
fn a_managed_service_with_its_own_mail_provider_is_flagged_once() {
let dir = project(
"direct",
&[(
"registrations.ts",
&format!(
"{MANAGED}import nodemailer from 'nodemailer';\nimport {{ SESv2Client }} from '@aws-sdk/client-sesv2';\nconst key = getEnvVar('SENDGRID_API_KEY'); const host = getEnvVar('SMTP_HOST');"
),
)],
);
assert_eq!(
found(&dir),
vec![(
"email-provider-direct-in-managed".to_string(),
"@aws-sdk/client-sesv2, SENDGRID_API_KEY, SMTP_HOST, nodemailer".to_string()
)],
"SENDGRID_API_KEY is reported by the email check, not twice"
);
}
#[test]
fn email_credentials_leave_other_provider_credentials_to_the_generic_check() {
let dir = project(
"mixed",
&[(
"registrations.ts",
&format!("{MANAGED}getEnvVar('TWILIO_AUTH_TOKEN'); getEnvVar('POSTMARK_SERVER_TOKEN');"),
)],
);
let mut f = found(&dir);
f.sort();
assert_eq!(
f,
vec![
("email-provider-direct-in-managed".to_string(), "POSTMARK_SERVER_TOKEN".to_string()),
("managed-provider-credentials".to_string(), "TWILIO_AUTH_TOKEN".to_string()),
]
);
}
#[test]
fn an_unmanaged_app_or_the_platform_client_is_not_flagged() {
let dir = project(
"unmanaged",
&[("registrations.ts", "import nodemailer from 'nodemailer'; getEnvVar('SMTP_HOST');")],
);
assert!(found(&dir).is_empty());
let dir = project(
"gateway",
&[(
"registrations.ts",
&format!("{MANAGED}import {{ createEmailClient }} from '@forklaunch/core/http'; const smtpish = 'SMTP';"),
)],
);
assert!(found(&dir).is_empty());
}
#[test]
fn deanon_in_an_email_subject_is_flagged_and_critical_with_phi() {
let code = "await this.email.send({\n to: patient.email.deanon,\n subject: `Results for ${patient.name.deanon}, ready`,\n text: `Hi ${patient.name.deanon}`\n});";
let dir = project(
"subject-phi",
&[
("services/notify.ts", code),
(
"persistence/entities/patient.entity.ts",
"export const P = defineComplianceEntity({ name: 'P', properties: { diagnosis: fp.text().compliance('phi') } });",
),
],
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings
.iter()
.find(|f| f.check == "email-protected-data")
.unwrap_or_else(|| panic!("{findings:?}"));
assert_eq!(f.subject, "`Results for ${patient.name.deanon}, ready`");
assert!(f.message.starts_with("health data (phi)"));
}
#[test]
fn subject_detection_is_conservative() {
assert!(protected_data_in_email_subjects(
"EmailClient.send({ to: user.email.deanon, subject: 'Welcome', html: body.deanon })"
)
.is_empty());
assert!(protected_data_in_email_subjects(
"res.status(200).send({ subject: row.subject.deanon }); queue.send({ subject: x.deanon })"
)
.is_empty());
assert_eq!(
protected_data_in_email_subjects("transporter.sendMail({ from, to, subject: p.ssn.deanon })"),
vec!["p.ssn.deanon"]
);
assert_eq!(
protected_data_in_email_subjects(
"new SendEmailCommand({ Content: { Simple: { Subject: { Data: p.name.deanon }, Body } } })"
),
vec!["{ Data: p.name.deanon }"]
);
}
#[test]
fn email_checks_score_through_the_report_card() {
for id in ["email-provider-direct-in-managed", "email-protected-data"] {
assert!(LOCAL_CHECK_IDS.contains(&id), "{id} missing from LOCAL_CHECK_IDS");
}
}
}
#[cfg(test)]
mod sms_tests {
use super::*;
fn run(name: &str, files: &[(&str, &str)], phi: bool) -> Vec<LocalFinding> {
let dir = std::env::temp_dir().join(format!("fl-sms-{name}"));
let _ = std::fs::remove_dir_all(&dir);
let proj = dir.join("notify");
for (rel, text) in files {
let path = proj.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
if phi {
let entities = proj.join("persistence/entities");
std::fs::create_dir_all(&entities).unwrap();
std::fs::write(
entities.join("patient.entity.ts"),
"export const P = defineComplianceEntity({ name: 'P', properties: { id: fp.uuid().primary().compliance('none'), diagnosis: fp.text().compliance('phi') } });",
)
.unwrap();
}
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
findings
}
#[test]
fn a_managed_service_importing_an_sms_sdk_is_flagged_once_per_subject() {
let findings = run(
"sdk",
&[(
"registrations.ts",
"import twilio from 'twilio';\nimport { PinpointSMSVoiceV2Client } from '@aws-sdk/client-pinpoint-sms-voice-v2';\nconst k = getEnvVar('INSTANCE_HMAC_KEY'); const t = getEnvVar('TWILIO_AUTH_TOKEN');",
)],
false,
);
let sdk = findings
.iter()
.find(|f| f.check == "sms-provider-direct-in-managed")
.unwrap_or_else(|| panic!("{findings:?}"));
assert_eq!(sdk.subject, "@aws-sdk/client-pinpoint-sms-voice-v2, twilio");
let keys = findings
.iter()
.find(|f| f.check == "managed-provider-credentials")
.unwrap();
assert_eq!(keys.subject, "TWILIO_AUTH_TOKEN");
assert!(!sdk.subject.contains("TWILIO_AUTH_TOKEN"));
}
#[test]
fn sdk_imports_outside_managed_mode_and_sns_without_phones_are_fine() {
let unmanaged = run("unmanaged", &[("registrations.ts", "import twilio from 'twilio';")], false);
assert!(!unmanaged.iter().any(|f| f.check == "sms-provider-direct-in-managed"));
assert!(direct_sms_providers(
"import { SNSClient } from '@aws-sdk/client-sns'; publish({ TopicArn })"
)
.is_empty());
assert_eq!(
direct_sms_providers("import { SNSClient } from '@aws-sdk/client-sns'; ({ PhoneNumber })"),
vec!["@aws-sdk/client-sns"]
);
assert_eq!(
provider_credentials_read("VONAGE_API_SECRET MESSAGEBIRD_ACCESS_KEY"),
vec!["MESSAGEBIRD_ACCESS_KEY", "VONAGE_API_SECRET"]
);
}
#[test]
fn deanon_in_an_sms_body_is_flagged_critical_with_phi() {
let source = "export async function remind(p: Patient) {\n const note = `Your result: ${p.diagnosis.deanon}`;\n await this.smsClient.send({ to: p.phone.deanon, body: note });\n}\n";
let findings = run("phi", &[("domain/services/remind.service.ts", source)], true);
let f = findings
.iter()
.find(|f| f.check == "sms-protected-data")
.unwrap_or_else(|| panic!("{findings:?}"));
assert_eq!(f.subject, "domain/services/remind.service.ts: smsClient.send (line 3)");
assert!(f.message.starts_with("health data"));
let card = crate::core::report_card::build_local_report_card("app", 1, &findings, "t".into());
let json = serde_json::to_string(&card).unwrap();
assert!(json.contains("\"severity\":\"critical\""), "{json}");
let no_phi = run("nophi", &[("domain/services/remind.service.ts", source)], false);
let f = no_phi.iter().find(|f| f.check == "sms-protected-data").unwrap();
assert!(!f.message.starts_with("health data"));
}
#[test]
fn protected_data_detection_follows_the_body_only() {
assert!(sms_sends_with_protected_data(
"await sms.send({ to: user.phone.deanon, body: 'Your visit is confirmed' });"
)
.is_empty());
assert_eq!(
sms_sends_with_protected_data("sms.send({ to, body: `Hi ${user.name.deanon}` });").len(),
1
);
assert_eq!(
sms_sends_with_protected_data(
"const body = user.ssn.deanon;\nawait smsClient.send({ to: x, body });"
),
vec!["smsClient.send (line 2)"]
);
assert_eq!(
sms_sends_with_protected_data(
"import twilio from 'twilio';\nconst m = r.notes.deanon; const text = m + '!';\nclient.messages.create({ to, from, body: text });"
)
.len(),
1
);
assert_eq!(
sms_sends_with_protected_data(
"new SendTextMessageCommand({ DestinationPhoneNumber: to, MessageBody: r.notes.deanon })"
)
.len(),
1
);
assert!(sms_sends_with_protected_data("mailer.send({ to, body: r.notes.deanon });").is_empty());
assert!(sms_sends_with_protected_data("openai.messages.create({ body: r.notes.deanon });").is_empty());
assert!(sms_sends_with_protected_data("new PublishCommand({ TopicArn, Message: r.notes.deanon })").is_empty());
assert!(sms_sends_with_protected_data(
"const secret = r.notes.deanon; sms.send({ to, body: secretary });"
)
.is_empty());
}
#[test]
fn sms_checks_are_listed_and_scored() {
for id in ["sms-provider-direct-in-managed", "sms-protected-data"] {
assert!(LOCAL_CHECK_IDS.contains(&id));
}
}
}
#[cfg(test)]
mod whatsapp_tests {
use super::*;
fn service(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fl-whatsapp-checks-{name}"));
let _ = std::fs::remove_dir_all(&dir);
for (path, text) in files {
let full = dir.join("svc").join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, text).unwrap();
}
dir
}
const MANAGED: &str = "const g = getEnvVar('PLATFORM_GATEWAY_URL'); const k = getEnvVar('INSTANCE_HMAC_KEY');";
#[test]
fn direct_meta_access_is_flagged_in_a_managed_service() {
assert_eq!(
direct_whatsapp_access(
"fetch(`https://graph.facebook.com/v20.0/${id}/messages`, { headers: { authorization: `Bearer ${process.env.WHATSAPP_TOKEN}` } })"
),
vec!["WHATSAPP_TOKEN", "graph.facebook.com"]
);
assert_eq!(
direct_whatsapp_access("import { SocialMessagingClient } from '@aws-sdk/client-socialmessaging'; const s = getEnvVar('META_ACCESS_TOKEN');"),
vec!["@aws-sdk/client-socialmessaging", "META_ACCESS_TOKEN"]
);
assert!(direct_whatsapp_access("createWhatsAppClient(); const m = META_TITLE; const d = SERVICE_METADATA;").is_empty());
let dir = service(
"direct",
&[("registrations.ts", &format!("{MANAGED} const t = getEnvVar('WHATSAPP_ACCESS_TOKEN');"))],
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings
.iter()
.find(|f| f.check == "whatsapp-provider-direct-in-managed")
.unwrap_or_else(|| panic!("expected the finding, got {findings:?}"));
assert_eq!(f.subject, "WHATSAPP_ACCESS_TOKEN");
}
#[test]
fn an_unmanaged_service_calling_meta_is_not_a_managed_finding() {
let dir = service("unmanaged", &[("registrations.ts", "getEnvVar('WHATSAPP_TOKEN')")]);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(!findings.iter().any(|f| f.check == "whatsapp-provider-direct-in-managed"));
}
#[test]
fn deanon_values_reaching_a_whatsapp_send_are_found() {
let direct = "const whatsapp = ci.resolve(tokens.WhatsAppClient);\nawait whatsapp.sendText({ to: patient.phone.deanon, body: 'hi' });";
assert_eq!(whatsapp_protected_sends(direct), vec!["2: whatsapp.sendText"]);
let through_variables = "import { createWhatsAppClient } from '@forklaunch/core/http';
const diagnosis = record.diagnosis.deanon;
const note = `Your result: ${diagnosis}`;
await this.whatsappClient.sendTemplate({
to,
template: 'result_ready',
language: 'en_US',
components: [{ type: 'body', parameters: [{ type: 'text', text: note }] }]
});";
assert_eq!(whatsapp_protected_sends(through_variables), vec!["4: whatsappClient.sendTemplate"]);
let sdk = "await client.send(new SendWhatsAppMessageCommand({ originationPhoneNumberId, message: encode(p.name.deanon), metaApiVersion: 'v20.0' }));";
assert_eq!(whatsapp_protected_sends(sdk), vec!["1: SendWhatsAppMessageCommand"]);
}
#[test]
fn sends_without_plaintext_and_other_channels_are_not_flagged() {
let clean = "const name = patient.name.deanon;\nlog(name);\nawait whatsapp.sendTemplate({ to: phone, template: 'appointment_reminder', language: 'en_US' });";
assert!(whatsapp_protected_sends(clean).is_empty());
let sms = "// whatsapp later\nawait sms.sendText({ to: p.phone.deanon, body: 'x' });";
assert!(whatsapp_protected_sends(sms).is_empty());
let property = "const name = p.name.deanon;\nawait whatsapp.sendText({ to, body: user.name });";
assert!(whatsapp_protected_sends(property).is_empty());
}
#[test]
fn protected_data_is_marked_phi_when_entities_hold_health_data() {
let entity = "import { defineComplianceEntity, fp } from '@forklaunch/core/persistence';
export const PatientEntity = defineComplianceEntity({
name: 'Patient',
properties: {
diagnosis: fp.string().compliance('phi')
}
});";
let controller = "await whatsapp.sendText({ to: p.phone, body: p.diagnosis.deanon });";
let dir = service(
"phi",
&[
("persistence/entities/patient.entity.ts", entity),
("api/controllers/notify.controller.ts", controller),
],
);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings
.iter()
.find(|f| f.check == "whatsapp-protected-data")
.unwrap_or_else(|| panic!("expected the finding, got {findings:?}"));
assert_eq!(f.subject, "api/controllers/notify.controller.ts:1: whatsapp.sendText");
assert!(f.message.starts_with(WHATSAPP_PHI_MARKER), "{}", f.message);
let dir = service("nophi", &[("api/controllers/notify.controller.ts", controller)]);
let findings = run_local_checks(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let f = findings.iter().find(|f| f.check == "whatsapp-protected-data").unwrap();
assert!(!f.message.contains(WHATSAPP_PHI_MARKER));
}
#[test]
fn whatsapp_checks_are_listed() {
for id in ["whatsapp-provider-direct-in-managed", "whatsapp-protected-data"] {
assert!(LOCAL_CHECK_IDS.contains(&id), "{id} missing from LOCAL_CHECK_IDS");
}
}
}