Skip to content

Commit 59bf62d

Browse files
committed
feat(update): working on next release
♬ Stefano Di Carlo - Tema
1 parent a22e0ea commit 59bf62d

21 files changed

Lines changed: 1106 additions & 247 deletions

‎src/exploit/prompt.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,12 @@ malicious = "../../../etc/passwd"
194194
/// Generic prompt template for unknown CWEs
195195
fn generic_prompt(finding: &VulnerabilityFinding, vulnerable_code: &str, expected: &str) -> String {
196196
let title = &finding.title;
197-
let cwe = finding.cwe_id.as_deref().unwrap_or("Unknown CWE");
197+
// "Unknown CWE" reads as a classification. The absence is the fact, and the
198+
// PoC writer needs to know it is inferring rather than confirming a class.
199+
let cwe = match finding.cwe_id.as_deref() {
200+
Some(c) if !c.is_empty() => c,
201+
_ => "(none assigned to this finding; infer the class from the code)",
202+
};
198203

199204
format!(
200205
r#"You are a security researcher generating a Proof of Concept (PoC) for a vulnerability.

‎src/llm/mod.rs‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,23 @@ async fn execute_with_retry(
429429
.unwrap_or("")
430430
.to_string();
431431

432+
// A model answering in prose may omit tool_calls entirely. A
433+
// tool_calls key that is present but not a list is a corrupt
434+
// payload, and parse_tool_calls would turn it into an empty vec,
435+
// leaving the agent as though the model chose not to act.
436+
match message.get("tool_calls") {
437+
None | Some(serde_json::Value::Null) => {}
438+
Some(tc) if tc.is_array() => {}
439+
Some(_) => {
440+
return Err(ScanError::Parse {
441+
message:
442+
"Malformed tools response: tool_calls is not a list"
443+
.to_string(),
444+
source: None,
445+
});
446+
}
447+
}
448+
432449
let tool_calls = parse_tool_calls(message);
433450

434451
if tools_response_is_malformed(&content, tool_calls.len()) {
@@ -951,11 +968,10 @@ impl LlmClient {
951968
Ok(response)
952969
}
953970
Err(e) => {
954-
tracing::warn!("LLM API (with tools) {} failed: {}", chat_url, e);
955-
Err(ScanError::Network {
956-
message: "LLM API request failed".to_string(),
957-
source: None,
958-
})
971+
// A malformed response is not a network failure. Collapsing every
972+
// error into Network made a corrupt payload indistinguishable from a
973+
// connection problem, and dropped the detail that says which.
974+
Err(e)
959975
}
960976
}
961977
}

‎src/llm_analysis.rs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,8 +1685,18 @@ impl LlmAnalyzer {
16851685
snippet.push('\n');
16861686
}
16871687
}
1688-
snippet.push_str(">>> VULNERABLE CODE <<<\n");
1689-
snippet.push_str(code);
1688+
// before/after are already omitted when empty. The vulnerable body was not:
1689+
// an absent snippet still produced a "VULNERABLE CODE" header
1690+
// over nothing, which reads as source that was examined.
1691+
if code.trim().is_empty() {
1692+
snippet.push_str(
1693+
"(no source captured for this finding; the location was \
1694+
reported without its code)\n",
1695+
);
1696+
} else {
1697+
snippet.push_str(">>> VULNERABLE CODE <<<\n");
1698+
snippet.push_str(code);
1699+
}
16901700
if !code.ends_with('\n') {
16911701
snippet.push('\n');
16921702
}

‎src/llm_verification.rs‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,22 +136,33 @@ where
136136
variables.insert("FILE_PATH".to_string(), finding.file_path.clone());
137137
variables.insert(
138138
"LINE_NUMBER".to_string(),
139-
finding
140-
.line_number
141-
.map(|l| l.to_string())
142-
.unwrap_or_default(),
139+
match finding.line_number {
140+
Some(l) => l.to_string(),
141+
None => "not recorded".to_string(),
142+
},
143143
);
144144
variables.insert(
145145
"CWE_ID".to_string(),
146-
finding.cwe_id.clone().unwrap_or_default(),
146+
match finding.cwe_id.as_deref() {
147+
Some(c) if !c.is_empty() => c.to_string(),
148+
_ => "not assigned".to_string(),
149+
},
147150
);
148151
variables.insert(
149152
"VULNERABILITY_DESCRIPTION".to_string(),
150153
finding.description.clone(),
151154
);
152155
variables.insert(
153156
"CODE_SNIPPET".to_string(),
154-
finding.code_snippet.clone().unwrap_or_default(),
157+
match finding.code_snippet.as_deref() {
158+
Some(s) if !s.trim().is_empty() => s.to_string(),
159+
// An empty slot here reads as "this code is empty", which is evidence
160+
// of something. Say that no source was captured, so the judge weighs
161+
// the reasoning instead of the absence of code.
162+
_ => "(no source snippet was captured for this finding; \
163+
do not read the absence of code as evidence either way)"
164+
.to_string(),
165+
},
155166
);
156167

157168
let prompt = render_template(RATIONALE_CHECK_PROMPT_TEMPLATE, &variables);

‎src/prompt/engine.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,10 @@ impl PromptEngine {
280280
// Git analysis
281281
vars.insert("FINDING_TITLE".to_string(), "<finding_title>".to_string());
282282
vars.insert("FILE_PATH".to_string(), "<file_path>".to_string());
283-
vars.insert("LINE_NUMBER".to_string(), "0".to_string());
283+
// "0" was the odd one out here: every other entry is bracketed as a
284+
// placeholder, and a bare 0 reads to anything consuming the registry as a
285+
// real line number.
286+
vars.insert("LINE_NUMBER".to_string(), "<line_number>".to_string());
284287
vars.insert(
285288
"VULNERABILITY_DESCRIPTION".to_string(),
286289
"<vulnerability_description>".to_string(),

‎src/scanner/phases/llm_phases/static_analysis.rs‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,29 @@ pub fn triage_snippet(path: &std::path::Path, max_lines: usize) -> std::io::Resu
627627
/// input here -- the function has no other judgement of its own -- and the bug
628628
/// this test exists for is entirely about what happens when the reply is a valid
629629
/// JSON that simply omits a file.
630+
/// The next batch cursor, or `None` once every file has been sent.
631+
///
632+
/// Returning the cursor instead of only the end lets the caller assert that the
633+
/// sweep terminates for any batch size. `batch_end = batch_start + batch_size` with
634+
/// a batch size of zero pins the cursor, and the sweep then runs forever without
635+
/// ever yielding, which no timeout can interrupt.
636+
/// Where the next batch ends, or `None` once every file has been sent.
637+
///
638+
/// Returning the end rather than only asserting it inside the loop is what makes
639+
/// the sweep's termination checkable. `end = start + batch_size` with a batch size
640+
/// of zero leaves the cursor pinned and the loop runs forever without ever yielding,
641+
/// which no timeout can interrupt — a mutation run caught this as a 600s timeout on
642+
/// `default_triage_batch_size -> 0` rather than as a failing test.
643+
pub fn next_batch_end(total: usize, start: usize, batch_size: u8) -> Option<usize> {
644+
if start >= total {
645+
return None;
646+
}
647+
Some(std::cmp::min(
648+
start + std::cmp::max(batch_size, 1) as usize,
649+
total,
650+
))
651+
}
652+
630653
pub async fn run_triage_cascade<'a>(
631654
client: &impl crate::llm::LlmChatClient,
632655
files: &'a [crate::indexer::FileInfo],
@@ -651,10 +674,12 @@ pub async fn run_triage_cascade<'a>(
651674
let mut skipped_files = Vec::new();
652675
let mut unreadable_files: Vec<String> = Vec::new();
653676

654-
// Process files in batches
677+
// The cursor arithmetic is extracted so it can be asserted directly. Running
678+
// the loop to observe a hang does not work: a `while` that never yields
679+
// cannot be interrupted by tokio::time::timeout, so a regression takes the
680+
// whole test binary down instead of failing one test.
655681
let mut batch_start = 0;
656-
while batch_start < files.len() {
657-
let batch_end = std::cmp::min(batch_start + batch_size as usize, files.len());
682+
while let Some(batch_end) = next_batch_end(files.len(), batch_start, batch_size) {
658683
let batch = &files[batch_start..batch_end];
659684

660685
// A file we cannot read is not a clean file. Sending it with an empty

‎src/scanner/phases/llm_phases/verification.rs‎

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -123,29 +123,42 @@ pub fn refute_with_primitive_check(
123123
if status != VerificationStatus::Confirmed {
124124
return (status, notes.to_string());
125125
}
126-
if let Some((_body, found)) = anchored_body_containing_primitive(finding, primitives) {
127-
let reason = format!(
128-
"not confirmed: {} found in the body of the function this finding names",
129-
found
130-
);
131-
let merged = if notes.trim().is_empty() {
132-
reason
133-
} else {
134-
format!("{notes} ({reason})")
135-
};
136-
return (VerificationStatus::FalsePositive, merged);
126+
match anchored_body_containing_primitive(finding, primitives) {
127+
Some(Ok((_body, found))) => {
128+
let reason = format!(
129+
"not confirmed: {} found in the body of the function this finding names",
130+
found
131+
);
132+
let merged = if notes.trim().is_empty() {
133+
reason
134+
} else {
135+
format!("{notes} ({reason})")
136+
};
137+
(VerificationStatus::FalsePositive, merged)
138+
}
139+
Some(Err(read_error)) => {
140+
let reason = format!("{read_error}: {path}", path = finding.file_path);
141+
let merged = if notes.trim().is_empty() {
142+
reason
143+
} else {
144+
format!("{notes} ({reason})")
145+
};
146+
(VerificationStatus::NeedsReview, merged)
147+
}
148+
None => (status, notes.to_string()),
137149
}
138-
(status, notes.to_string())
139150
}
140151

141152
/// The body of the function the title names, if it contains a primitive.
142153
///
143-
/// Returns the body and which primitive matched, so the reason names the
144-
/// evidence rather than asserting that a check happened.
154+
/// Returns:
155+
/// - `Some(Ok((body, primitive)))` if the file was read and a primitive was found
156+
/// - `Some(Err(String))` if the file could not be read (missing, permissions, etc.)
157+
/// - `None` if the file was read but no primitive was found, or if language/name could not be determined
145158
fn anchored_body_containing_primitive(
146159
finding: &VulnerabilityFinding,
147160
primitives: &std::collections::HashMap<String, Vec<String>>,
148-
) -> Option<(String, String)> {
161+
) -> Option<Result<(String, String), String>> {
149162
let language = extract_language_from_path(&finding.file_path);
150163
let for_language = primitives.get(&language)?;
151164
if for_language.is_empty() {
@@ -154,7 +167,10 @@ fn anchored_body_containing_primitive(
154167
let name = crate::llm_analysis::function_names_in_title(&finding.title)
155168
.into_iter()
156169
.next()?;
157-
let content = std::fs::read_to_string(&finding.file_path).ok()?;
170+
let content = match std::fs::read_to_string(&finding.file_path) {
171+
Ok(c) => c,
172+
Err(e) => return Some(Err(format!("cannot read file: {}", e))),
173+
};
158174
let ranges = crate::llm_analysis::function_line_ranges(&content, &language);
159175
let (start, end) = *ranges.get(&name)?;
160176

@@ -163,7 +179,7 @@ fn anchored_body_containing_primitive(
163179
.iter()
164180
.find(|p| !p.is_empty() && body.contains(p.as_str()))?
165181
.clone();
166-
Some((body, found))
182+
Some(Ok((body, found)))
167183
}
168184

169185
/// The function body, by its 1-indexed line range.

‎src/scanner/phases/other_phases/patching.rs‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::error::ScanResult;
22
use crate::findings::VulnerabilityFinding;
3+
use crate::llm::phase_llm_config;
34
use crate::scanner::phases::PhaseConfig;
45

56
/// Run auto patching phase (phase 19 of 23).
@@ -12,7 +13,7 @@ pub async fn run_auto_patching(
1213
findings,
1314
pb: _,
1415
analyzed_files,
15-
metrics_tracker: _,
16+
metrics_tracker,
1617
target_path,
1718
config,
1819
project_stack: _,
@@ -26,10 +27,23 @@ pub async fn run_auto_patching(
2627

2728
tracing::info!("Running auto patching phase");
2829

29-
let patcher = crate::staging::AutoPatcher::new(target_path.to_path_buf());
30+
let llm_config = match phase_llm_config(config, "auto_patching", None) {
31+
Ok(c) => c,
32+
Err(e) => {
33+
tracing::warn!("Auto patching has no usable LLM config: {e}");
34+
return Ok((findings, analyzed_files.to_vec()));
35+
}
36+
};
37+
let client = std::sync::Arc::new(crate::llm::LlmClient::with_metrics(
38+
llm_config,
39+
Some(metrics_tracker.clone()),
40+
));
41+
let patcher = crate::staging::AutoPatcher::with_llm(target_path.to_path_buf(), client);
3042
let patching_config = crate::staging::PatchingConfig::default();
3143

32-
match patcher.execute_batch_with_vuln_spec(&findings, &patching_config, Some(&config.vuln_spec))
44+
match patcher
45+
.execute_batch_with_vuln_spec(&findings, &patching_config, Some(&config.vuln_spec))
46+
.await
3347
{
3448
Ok(patched_findings) => {
3549
tracing::info!(

0 commit comments

Comments
 (0)