mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-24 20:28:06 +02:00
[pitboss] phase 22: Track F.2 + F.3 — Cross-language framework probes + data store / external service / dangerous-local detection
This commit is contained in:
parent
c03326a658
commit
2395446655
43 changed files with 5213 additions and 82 deletions
165
src/surface/external.rs
Normal file
165
src/surface/external.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
//! External-service detection.
|
||||
//!
|
||||
//! Walks the post-pass-2 [`GlobalSummaries`] looking for callees that
|
||||
//! launch outbound network requests (HTTP, gRPC, SMTP, DNS) and emits
|
||||
//! one [`SurfaceNode::ExternalService`] per call. Detection is by
|
||||
//! callee leaf name + `sink_caps & SSRF` heuristic — both signals are
|
||||
//! consulted so a probe with no SSRF cap (DNS resolver, SMTP sender)
|
||||
//! still surfaces as an external service.
|
||||
|
||||
use super::{ExternalService, ExternalServiceKind, SourceLocation, SurfaceNode};
|
||||
use crate::labels::Cap;
|
||||
use crate::summary::{FuncSummary, GlobalSummaries};
|
||||
|
||||
struct ClientRule {
|
||||
leaf: &'static str,
|
||||
kind: ExternalServiceKind,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
const CLIENT_RULES: &[ClientRule] = &[
|
||||
// HTTP
|
||||
ClientRule { leaf: "requests.get", kind: ExternalServiceKind::HttpApi, label: "requests (Python)" },
|
||||
ClientRule { leaf: "requests.post", kind: ExternalServiceKind::HttpApi, label: "requests (Python)" },
|
||||
ClientRule { leaf: "httpx.get", kind: ExternalServiceKind::HttpApi, label: "httpx (Python)" },
|
||||
ClientRule { leaf: "httpx.post", kind: ExternalServiceKind::HttpApi, label: "httpx (Python)" },
|
||||
ClientRule { leaf: "urllib.request.urlopen", kind: ExternalServiceKind::HttpApi, label: "urllib" },
|
||||
ClientRule { leaf: "fetch", kind: ExternalServiceKind::HttpApi, label: "fetch (JS)" },
|
||||
ClientRule { leaf: "axios.get", kind: ExternalServiceKind::HttpApi, label: "axios" },
|
||||
ClientRule { leaf: "axios.post", kind: ExternalServiceKind::HttpApi, label: "axios" },
|
||||
ClientRule { leaf: "http.request", kind: ExternalServiceKind::HttpApi, label: "node http" },
|
||||
ClientRule { leaf: "got", kind: ExternalServiceKind::HttpApi, label: "got (JS)" },
|
||||
ClientRule { leaf: "HttpClient.send", kind: ExternalServiceKind::HttpApi, label: "Java HttpClient" },
|
||||
ClientRule { leaf: "HttpClient.execute", kind: ExternalServiceKind::HttpApi, label: "Java HttpClient" },
|
||||
ClientRule { leaf: "RestTemplate.exchange", kind: ExternalServiceKind::HttpApi, label: "Spring RestTemplate" },
|
||||
ClientRule { leaf: "RestTemplate.getForObject", kind: ExternalServiceKind::HttpApi, label: "Spring RestTemplate" },
|
||||
ClientRule { leaf: "OkHttpClient.newCall", kind: ExternalServiceKind::HttpApi, label: "OkHttp" },
|
||||
ClientRule { leaf: "http.Get", kind: ExternalServiceKind::HttpApi, label: "net/http (Go)" },
|
||||
ClientRule { leaf: "http.Post", kind: ExternalServiceKind::HttpApi, label: "net/http (Go)" },
|
||||
ClientRule { leaf: "http.NewRequest", kind: ExternalServiceKind::HttpApi, label: "net/http (Go)" },
|
||||
ClientRule { leaf: "client.Do", kind: ExternalServiceKind::HttpApi, label: "go http client" },
|
||||
ClientRule { leaf: "reqwest::get", kind: ExternalServiceKind::HttpApi, label: "reqwest (Rust)" },
|
||||
ClientRule { leaf: "reqwest::Client", kind: ExternalServiceKind::HttpApi, label: "reqwest (Rust)" },
|
||||
ClientRule { leaf: "Net::HTTP", kind: ExternalServiceKind::HttpApi, label: "Net::HTTP (Ruby)" },
|
||||
ClientRule { leaf: "HTTParty.get", kind: ExternalServiceKind::HttpApi, label: "HTTParty" },
|
||||
ClientRule { leaf: "Faraday", kind: ExternalServiceKind::HttpApi, label: "Faraday (Ruby)" },
|
||||
ClientRule { leaf: "curl_exec", kind: ExternalServiceKind::HttpApi, label: "PHP curl" },
|
||||
ClientRule { leaf: "file_get_contents", kind: ExternalServiceKind::HttpApi, label: "PHP file_get_contents" },
|
||||
ClientRule { leaf: "Guzzle", kind: ExternalServiceKind::HttpApi, label: "Guzzle (PHP)" },
|
||||
|
||||
// Message brokers
|
||||
ClientRule { leaf: "kafka.send", kind: ExternalServiceKind::MessageBroker, label: "Kafka" },
|
||||
ClientRule { leaf: "KafkaProducer.send", kind: ExternalServiceKind::MessageBroker, label: "Kafka" },
|
||||
ClientRule { leaf: "rabbitmq.publish", kind: ExternalServiceKind::MessageBroker, label: "RabbitMQ" },
|
||||
ClientRule { leaf: "amqp.publish", kind: ExternalServiceKind::MessageBroker, label: "AMQP" },
|
||||
ClientRule { leaf: "sqs.send_message", kind: ExternalServiceKind::MessageBroker, label: "AWS SQS" },
|
||||
ClientRule { leaf: "sns.publish", kind: ExternalServiceKind::MessageBroker, label: "AWS SNS" },
|
||||
|
||||
// Search indices
|
||||
ClientRule { leaf: "Elasticsearch", kind: ExternalServiceKind::SearchIndex, label: "Elasticsearch" },
|
||||
ClientRule { leaf: "elasticsearch.search", kind: ExternalServiceKind::SearchIndex, label: "Elasticsearch" },
|
||||
ClientRule { leaf: "OpenSearch", kind: ExternalServiceKind::SearchIndex, label: "OpenSearch" },
|
||||
ClientRule { leaf: "Algolia", kind: ExternalServiceKind::SearchIndex, label: "Algolia" },
|
||||
|
||||
// Auth providers
|
||||
ClientRule { leaf: "auth0", kind: ExternalServiceKind::AuthProvider, label: "Auth0" },
|
||||
ClientRule { leaf: "passport.authenticate", kind: ExternalServiceKind::AuthProvider, label: "Passport.js" },
|
||||
ClientRule { leaf: "OAuth2Client", kind: ExternalServiceKind::AuthProvider, label: "OAuth2 client" },
|
||||
ClientRule { leaf: "google.oauth2", kind: ExternalServiceKind::AuthProvider, label: "Google OAuth2" },
|
||||
|
||||
// SMTP
|
||||
ClientRule { leaf: "smtplib.SMTP", kind: ExternalServiceKind::HttpApi, label: "SMTP (Python)" },
|
||||
ClientRule { leaf: "Mail::send", kind: ExternalServiceKind::HttpApi, label: "Laravel Mail" },
|
||||
ClientRule { leaf: "ActionMailer", kind: ExternalServiceKind::HttpApi, label: "Rails ActionMailer" },
|
||||
|
||||
// DNS
|
||||
ClientRule { leaf: "socket.gethostbyname", kind: ExternalServiceKind::HttpApi, label: "DNS resolver" },
|
||||
ClientRule { leaf: "dns.lookup", kind: ExternalServiceKind::HttpApi, label: "DNS resolver" },
|
||||
ClientRule { leaf: "net.LookupIP", kind: ExternalServiceKind::HttpApi, label: "DNS resolver" },
|
||||
];
|
||||
|
||||
pub fn detect_external_services(summaries: &GlobalSummaries) -> Vec<SurfaceNode> {
|
||||
let mut out: Vec<SurfaceNode> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<(String, String)> =
|
||||
std::collections::HashSet::new();
|
||||
for (_key, summary) in summaries.iter() {
|
||||
for callee in &summary.callees {
|
||||
let Some(rule) = match_rule(&callee.name) else {
|
||||
continue;
|
||||
};
|
||||
let location = call_site_location(summary);
|
||||
if !seen.insert((location.file.clone(), rule.label.to_string())) {
|
||||
continue;
|
||||
}
|
||||
out.push(SurfaceNode::ExternalService(ExternalService {
|
||||
location,
|
||||
kind: rule.kind,
|
||||
label: rule.label.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Also surface any function whose own sink_caps include SSRF — the
|
||||
// function itself is an outbound network call site even if the
|
||||
// direct callee did not match the rule list. Use the function's
|
||||
// file as the location and synthesise a generic label.
|
||||
for (_key, summary) in summaries.iter() {
|
||||
if summary.sink_caps().contains(Cap::SSRF) {
|
||||
let loc = call_site_location(summary);
|
||||
let dedup = (loc.file.clone(), "Outbound HTTP".to_string());
|
||||
if seen.insert(dedup) {
|
||||
out.push(SurfaceNode::ExternalService(ExternalService {
|
||||
location: loc,
|
||||
kind: ExternalServiceKind::HttpApi,
|
||||
label: "Outbound HTTP".to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn match_rule(callee: &str) -> Option<&'static ClientRule> {
|
||||
let trimmed = callee.trim();
|
||||
let leaf = trimmed.rsplit("::").next().unwrap_or(trimmed);
|
||||
let leaf = leaf.rsplit('.').next().unwrap_or(leaf);
|
||||
CLIENT_RULES.iter().find(|r| {
|
||||
trimmed.to_ascii_lowercase().contains(&r.leaf.to_ascii_lowercase())
|
||||
|| leaf.eq_ignore_ascii_case(r.leaf)
|
||||
})
|
||||
}
|
||||
|
||||
fn call_site_location(summary: &FuncSummary) -> SourceLocation {
|
||||
SourceLocation {
|
||||
file: summary.file_path.clone(),
|
||||
line: 0,
|
||||
col: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::summary::CalleeSite;
|
||||
use crate::symbol::{FuncKey, Lang};
|
||||
|
||||
#[test]
|
||||
fn detects_requests_get() {
|
||||
let mut gs = GlobalSummaries::new();
|
||||
let key = FuncKey::new_function(Lang::Python, "client.py", "fetch_user", None);
|
||||
let summary = FuncSummary {
|
||||
name: "fetch_user".to_string(),
|
||||
file_path: "client.py".to_string(),
|
||||
lang: "python".to_string(),
|
||||
param_count: 0,
|
||||
callees: vec![CalleeSite::bare("requests.get".to_string())],
|
||||
..Default::default()
|
||||
};
|
||||
gs.insert(key, summary);
|
||||
let nodes = detect_external_services(&gs);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let SurfaceNode::ExternalService(es) = &nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(es.label, "requests (Python)");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue