Skip to content

Commit 123fa23

Browse files
author
Simon Morley
committed
feat: wire up NTE embeddings and add supplemental timing to scan API
extract_features() existed but was never called after the OSS extraction — embeddings were hardcoded to None. Now collect_timing_samples_raw() generates 64-dim L2-normalized embeddings from timing samples automatically. Added timing_samples field to ScanRequest and discover_with_timing() on Engine so callers can request multi-sample timing profiles for open ports after discovery. The CLI scan subcommand gets --timing-samples to expose this.
1 parent 5507420 commit 123fa23

5 files changed

Lines changed: 91 additions & 9 deletions

File tree

src/cli/mod.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ pub struct ScanArgs {
8585
/// Network interface for XDP (auto-detect if omitted)
8686
#[arg(long)]
8787
pub interface: Option<String>,
88+
/// Supplemental timing samples per open port (0 = skip)
89+
#[arg(long, default_value = "0")]
90+
pub timing_samples: u16,
8891
}
8992

9093
/// Arguments for the `time` subcommand.
@@ -143,13 +146,15 @@ pub enum OutputFmt {
143146
/// Run a port scan and return the result.
144147
///
145148
/// Creates an Engine, builds a ScanRequest, and delegates to `engine.discover()`.
149+
/// When `timing_samples > 0`, open ports are re-probed for multi-sample timing profiles.
146150
pub async fn run_scan(
147151
target: &str,
148152
port_spec: PortSpec,
149153
pacing: PacingProfile,
150154
timeout_ms: u32,
151155
interface: Option<String>,
152-
) -> Result<ScanResult, String> {
156+
timing_samples: u16,
157+
) -> Result<(ScanResult, Vec<crate::TimingResult>), String> {
153158
let engine = create_engine(interface.clone())?;
154159

155160
let request = ScanRequest {
@@ -160,13 +165,18 @@ pub async fn run_scan(
160165
timeout_ms,
161166
interface,
162167
max_ports: None,
168+
timing_samples: if timing_samples > 0 {
169+
Some(timing_samples)
170+
} else {
171+
None
172+
},
163173
};
164174

165-
let result = engine.discover(&request).await;
175+
let (result, timing_results) = engine.discover_with_timing(&request).await;
166176
if let Some(ref e) = result.error {
167177
Err(e.clone())
168178
} else {
169-
Ok(result)
179+
Ok((result, timing_results))
170180
}
171181
}
172182

src/engine.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,43 @@ impl Engine {
251251
}
252252
}
253253

254+
/// Run a port discovery scan, then collect supplemental timing for open ports.
255+
///
256+
/// If `request.timing_samples` is `Some(n)` with `n > 0`, each open port is
257+
/// re-probed `n` times to build a multi-sample timing profile with embeddings.
258+
/// Closed/filtered/firewalled ports are skipped.
259+
pub async fn discover_with_timing(
260+
&self,
261+
request: &crate::ScanRequest,
262+
) -> (ScanResult, Vec<TimingResult>) {
263+
let scan_result = self.discover(request).await;
264+
let mut timing_results = Vec::new();
265+
266+
let sample_count = request.timing_samples.unwrap_or(0);
267+
if sample_count == 0 {
268+
return (scan_result, timing_results);
269+
}
270+
271+
for port in &scan_result.ports {
272+
if port.state != crate::PortState::Open {
273+
continue;
274+
}
275+
let timing_req = TimingRequest {
276+
request_id: Uuid::new_v4(),
277+
scan_id: None,
278+
target_host: scan_result.target_ip.to_string(),
279+
target_port: port.port,
280+
sample_count: sample_count as u32,
281+
timeout_ms: request.timeout_ms,
282+
banner_timeout_ms: None,
283+
};
284+
let result = self.collect_timing(&timing_req).await;
285+
timing_results.push(result);
286+
}
287+
288+
(scan_result, timing_results)
289+
}
290+
254291
/// Backend string identifier.
255292
pub fn backend_str(&self) -> &str {
256293
match self {
@@ -652,6 +689,7 @@ mod tests {
652689
timeout_ms: 500,
653690
interface: None,
654691
max_ports: None,
692+
timing_samples: None,
655693
};
656694
let result = engine.discover(&request).await;
657695
assert_eq!(result.request_id, request.request_id);
@@ -674,6 +712,7 @@ mod tests {
674712
timeout_ms: 1000,
675713
interface: None,
676714
max_ports: None,
715+
timing_samples: None,
677716
};
678717
let result = engine.discover(&request).await;
679718
assert!(result.error.is_some());
@@ -701,6 +740,7 @@ mod tests {
701740
timeout_ms: 500,
702741
interface: None,
703742
max_ports: Some(3),
743+
timing_samples: None,
704744
};
705745
let result = engine.discover(&request).await;
706746
assert_eq!(

src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ pub struct ScanRequest {
236236
/// Maximum number of ports to scan (truncates expanded port list).
237237
#[serde(default)]
238238
pub max_ports: Option<u32>,
239+
/// Number of supplemental timing samples per open port (0 or None = skip).
240+
/// After discovery, open ports are re-probed N times to build timing profiles.
241+
#[serde(default)]
242+
pub timing_samples: Option<u16>,
239243
}
240244

241245
/// Result of a port scan.

src/main.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ async fn main() {
2828
args.timeout,
2929
args.output,
3030
args.interface,
31+
args.timing_samples,
3132
)
3233
.await;
3334
}
@@ -61,7 +62,7 @@ async fn main() {
6162
let timeout = cli.timeout.unwrap_or(2000);
6263
let output = cli.output.unwrap_or(OutputFmt::Pretty);
6364

64-
run_scan_command(&target, &ports, stealth, timeout, output, cli.interface).await;
65+
run_scan_command(&target, &ports, stealth, timeout, output, cli.interface, 0).await;
6566
}
6667
}
6768
}
@@ -73,6 +74,7 @@ async fn run_scan_command(
7374
timeout: u32,
7475
output: OutputFmt,
7576
interface: Option<String>,
77+
timing_samples: u16,
7678
) {
7779
let port_spec = match limpet::PortSpec::parse(ports_str) {
7880
Ok(s) => s,
@@ -84,10 +86,31 @@ async fn run_scan_command(
8486

8587
let pacing: limpet::scanner::stealth::PacingProfile = stealth.into();
8688

87-
match cli::run_scan(target, port_spec, pacing, timeout, interface).await {
88-
Ok(result) => match output {
89-
OutputFmt::Pretty => print!("{}", cli::format_pretty(&result, target)),
90-
OutputFmt::Json => println!("{}", cli::format_json(&result)),
89+
match cli::run_scan(target, port_spec, pacing, timeout, interface, timing_samples).await {
90+
Ok((result, timing_results)) => match output {
91+
OutputFmt::Pretty => {
92+
print!("{}", cli::format_pretty(&result, target));
93+
if !timing_results.is_empty() {
94+
println!("\nTiming profiles ({} ports):", timing_results.len());
95+
for tr in &timing_results {
96+
let emb = if tr.embedding.is_some() { "64-dim" } else { "none" };
97+
println!(
98+
" port {}: {} samples, mean={:.2}µs, embedding={}",
99+
tr.target_port,
100+
tr.samples.len(),
101+
tr.stats.mean,
102+
emb,
103+
);
104+
}
105+
}
106+
}
107+
OutputFmt::Json => {
108+
let out = serde_json::json!({
109+
"scan": result,
110+
"timing": timing_results,
111+
});
112+
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
113+
}
91114
},
92115
Err(e) => {
93116
eprintln!("Scan failed: {e}");

src/timing/userspace.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ pub async fn collect_timing_samples_raw(
224224
}
225225
};
226226

227+
let embedding = match super::embeddings::extract_features(&samples) {
228+
Ok(fv) => Some(fv.to_embedding()),
229+
Err(_) => None,
230+
};
231+
227232
TimingResult {
228233
request_id: request.request_id,
229234
scan_id: request.scan_id,
@@ -234,7 +239,7 @@ pub async fn collect_timing_samples_raw(
234239
stats,
235240
collected_at: chrono::Utc::now(),
236241
error: None,
237-
embedding: None,
242+
embedding,
238243
banner: None, // SYN-only: no handshake, no banner
239244
source_ip: None,
240245
worker_node: None,

0 commit comments

Comments
 (0)