Skip to content

Commit 5224ed4

Browse files
committed
fix(stuff): working on next release
♬ Electric Callboy - Revery
1 parent 03e06dc commit 5224ed4

24 files changed

Lines changed: 500 additions & 189 deletions

‎src/agent/sandbox.rs‎

Lines changed: 6 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -121,89 +121,27 @@ pub struct ToolSandbox {
121121

122122
impl SandboxLike for ToolSandbox {
123123
fn temp_dir(&self) -> &Path {
124-
&self.temp_dir
124+
ToolSandbox::temp_dir(self)
125125
}
126126
fn resolve_safe_path(&self, path: &str) -> Result<PathBuf, String> {
127-
let full = validate_path_containment(&self.temp_dir, path)?;
128-
if !full.exists() {
129-
return Err(format!("Path does not exist: {}", path));
130-
}
131-
Ok(full)
127+
ToolSandbox::resolve_safe_path(self, path)
132128
}
133129
fn run_with_timeout(
134130
&self,
135131
cmd: &str,
136132
args: &[&str],
137133
timeout_secs: Option<u64>,
138134
) -> Result<ToolResult, String> {
139-
let dur = Duration::from_secs(timeout_secs.unwrap_or(self.timeout_secs));
140-
let child = unsafe {
141-
Command::new(cmd)
142-
.args(args)
143-
.stdout(Stdio::piped())
144-
.stderr(Stdio::piped())
145-
.pre_exec(disable_core_dumps)
146-
.spawn()
147-
}
148-
.map_err(|e| format!("spawn {}: {}", cmd, e))?;
149-
let dur = child
150-
.wait_with_timeout(dur)
151-
.map_err(|e| format!("timeout/err: {}", e))?;
152-
let ec = dur.status.code().unwrap_or(-1);
153-
let out = format!(
154-
"{}{}",
155-
String::from_utf8_lossy(&dur.stdout),
156-
String::from_utf8_lossy(&dur.stderr)
157-
);
158-
// Trim whitespace from output for reliable matching
159-
Ok(ToolResult {
160-
tool_call_id: "sandbox".to_string(),
161-
success: ec == 0,
162-
output: out.trim().to_string(),
163-
})
135+
ToolSandbox::run_with_timeout(self, cmd, args, timeout_secs)
164136
}
165137
fn validate_test_source(&self, path: &str, content: &str) -> Result<(), String> {
166-
validate_test_source_content(path, content)
138+
ToolSandbox::validate_test_source(self, path, content)
167139
}
168140
fn create_temp_file(&self, path: &str, content: &str) -> Result<PathBuf, String> {
169-
self.validate_test_source(path, content)
170-
.map_err(|e| format!("Validation failed: {}", e))?;
171-
172-
// Pre-condition: validate path containment before write
173-
let full = validate_path_containment(&self.temp_dir, path)?;
174-
175-
// Write the file
176-
std::fs::File::create(&full)
177-
.and_then(|mut f| f.write_all(content.as_bytes()))
178-
.map_err(|e| format!("Write failed: {}", e))?;
179-
180-
// Post-condition: verify containment after write (catches symlink swaps)
181-
let canonical_after = full
182-
.canonicalize()
183-
.map_err(|e| format!("Cannot canonicalize after write {:?}: {}", full, e))?;
184-
let canonical_root = self
185-
.temp_dir
186-
.canonicalize()
187-
.map_err(|e| format!("Cannot canonicalize sandbox root: {}", e))?;
188-
if !canonical_after.starts_with(&canonical_root) {
189-
// File was created via symlink outside sandbox - delete and error
190-
let _ = std::fs::remove_file(&full);
191-
return Err(format!("Path escapes sandbox after write: {}", path));
192-
}
193-
194-
Ok(full)
141+
ToolSandbox::create_temp_file(self, path, content)
195142
}
196143
fn is_path_allowed(&self, path: &Path) -> bool {
197-
let canonical = match path.canonicalize() {
198-
Ok(p) => p,
199-
Err(_) => {
200-
return match path.parent() {
201-
Some(parent) => parent.starts_with(&self.temp_dir),
202-
None => false,
203-
};
204-
}
205-
};
206-
canonical.starts_with(&self.temp_dir)
144+
ToolSandbox::is_path_allowed(self, path)
207145
}
208146
}
209147

‎src/agent/tools.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ impl Tool for PatternSearchTool {
6969
let output = std::process::Command::new("grep")
7070
.arg("-rn")
7171
.arg(format!("-C{}", ctx))
72+
.arg("--")
7273
.arg(pattern)
7374
.arg(path)
7475
.output()

‎src/report/ai_aggregation.rs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,16 @@ impl AiAggregationPhase {
216216
// Check for CWE mismatches
217217
let cwes: Vec<_> = findings.iter().filter_map(|f| f.cwe_id.as_ref()).collect();
218218
if cwes.iter().collect::<std::collections::HashSet<_>>().len() > 1 {
219-
let conflict =
220-
conflict_resolver::ConflictResolver::resolve_cwe_conflict(location, findings);
221-
conflicts.push(conflict);
219+
match conflict_resolver::ConflictResolver::resolve_cwe_conflict(location, findings)
220+
{
221+
Ok(conflict) => {
222+
conflicts.push(conflict);
223+
}
224+
Err(e) => {
225+
// Log the error but continue - findings with invalid CWEs are incomparable
226+
tracing::warn!("CWE conflict resolution skipped at {}: {}", location, e);
227+
}
228+
}
222229
continue;
223230
}
224231

‎src/report/ai_aggregation/conflict_resolver.rs‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Conflict resolution logic for AI aggregation
22
3+
use super::ScanError;
34
use super::models::*;
45
use crate::findings::{Severity, VulnerabilityFinding};
56

@@ -49,10 +50,36 @@ impl ConflictResolver {
4950
}
5051

5152
/// Resolve CWE conflict by keeping most specific (lowest CWE number)
53+
///
54+
/// Returns an error if any finding has an unparseable CWE ID.
55+
/// Unparseable CWEs indicate data corruption or model hallucination;
56+
/// silently sorting them last biases conflict resolution toward findings
57+
/// with parseable CWEs, which is worse than failing explicitly.
5258
pub fn resolve_cwe_conflict(
5359
location: &str,
5460
findings: &[&VulnerabilityFinding],
55-
) -> FindingConflict {
61+
) -> Result<FindingConflict, ScanError> {
62+
// Validate all CWEs first - fail fast on unparseable values
63+
for (i, finding) in findings.iter().enumerate() {
64+
if let Some(cwe) = &finding.cwe_id {
65+
// Check if CWE is parseable (format: CWE-NNN or just NNN)
66+
let cwe_num = cwe.trim_start_matches("CWE-").parse::<u32>();
67+
68+
if cwe_num.is_err() {
69+
return Err(ScanError::Parse {
70+
message: format!(
71+
"CWE conflict resolution failed: finding {} has unparseable CWE '{}' at {} \
72+
- findings with invalid CWEs are not comparable",
73+
i + 1,
74+
cwe,
75+
location
76+
),
77+
source: None,
78+
});
79+
}
80+
}
81+
}
82+
5683
let mut sorted: Vec<_> = findings.iter().collect();
5784
sorted.sort_by(|a, b| {
5885
let cwe_num = |cwe: &Option<String>| {
@@ -67,7 +94,7 @@ impl ConflictResolver {
6794
let conflict_type = ConflictType::CweMismatch;
6895
let resolution = ConflictResolution::KeptOne;
6996

70-
FindingConflict {
97+
Ok(FindingConflict {
7198
findings: findings.iter().map(|f| (*f).clone()).collect(),
7299
conflict_type,
73100
resolution,
@@ -76,7 +103,7 @@ impl ConflictResolver {
76103
most_specific.cwe_id.as_deref().unwrap_or("unknown"),
77104
location
78105
),
79-
}
106+
})
80107
}
81108

82109
/// Resolve confidence conflict by averaging

‎src/semgrep/parser.rs‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,16 @@ pub fn parse_json_output(
136136
.get("extra")
137137
.and_then(|e| e.get("severity"))
138138
.and_then(|s| s.as_str());
139+
let keyword_severity = parse_severity(&check_id);
139140
let base_severity = match extra_severity {
140141
Some("ERROR") => Severity::High,
141142
Some("WARNING") => Severity::Medium,
142143
Some("INFO") | Some("INVENTORY") => Severity::Low,
143-
_ => parse_severity(&check_id),
144-
};
145-
let keyword_severity = parse_severity(&check_id);
146-
let severity = if keyword_severity > base_severity {
147-
keyword_severity
148-
} else {
149-
base_severity
144+
_ => keyword_severity.unwrap_or_default(),
150145
};
146+
let severity = keyword_severity
147+
.filter(|ks| *ks > base_severity)
148+
.unwrap_or(base_severity);
151149

152150
let cwe_id = result
153151
.get("extra")

‎src/semgrep/rules.rs‎

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
use crate::findings::Severity;
22

3-
/// Default per-run semgrep timeout (client side) in seconds.
3+
/// Per-file semgrep timeout in seconds (passed to `--timeout` flag).
44
pub const DEFAULT_SEMGREP_TIMEOUT_SECS: u64 = 300;
55

6+
/// Wall-clock timeout for the entire semgrep run in seconds.
7+
/// Separate from `DEFAULT_SEMGREP_TIMEOUT_SECS` to avoid aborting
8+
/// large projects that legitimately need more total time.
9+
pub const DEFAULT_SEMGREP_WALL_CLOCK_SECS: u64 = 1800;
10+
611
/// Bundled taint-mode rules for Python (derived from presets/oss-python.toml)
712
const TAINT_PYTHON_RULES: &str = include_str!("rules/taint-python.yml");
813

@@ -31,8 +36,10 @@ const TAINT_CSHARP_RULES: &str = include_str!("rules/taint-csharp.yml");
3136
pub struct SemgrepRunner {
3237
pub rulesets: Vec<String>,
3338
pub exclude_rules: Vec<String>,
34-
/// Per-run timeout: passed to semgrep `--timeout` and enforced client-side.
39+
/// Per-file timeout: passed to semgrep `--timeout` flag.
3540
pub timeout_secs: u64,
41+
/// Wall-clock timeout for the entire semgrep run (client-side).
42+
pub wall_clock_timeout_secs: u64,
3643
/// Inline rule YAML documents (preset `custom_rules`), materialized to
3744
/// temp .yml files at scan time.
3845
pub custom_rules: Vec<String>,
@@ -52,6 +59,7 @@ impl SemgrepRunner {
5259
rulesets,
5360
exclude_rules,
5461
timeout_secs: DEFAULT_SEMGREP_TIMEOUT_SECS,
62+
wall_clock_timeout_secs: DEFAULT_SEMGREP_WALL_CLOCK_SECS,
5563
custom_rules: Vec::new(),
5664
languages: Vec::new(),
5765
max_target_bytes: None,
@@ -65,12 +73,18 @@ impl SemgrepRunner {
6573
self
6674
}
6775

68-
/// Builder: set per-run timeout in seconds.
76+
/// Builder: set per-file timeout in seconds.
6977
pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
7078
self.timeout_secs = timeout_secs;
7179
self
7280
}
7381

82+
/// Builder: set wall-clock timeout for the entire semgrep run.
83+
pub fn with_wall_clock_timeout(mut self, wall_clock_timeout_secs: u64) -> Self {
84+
self.wall_clock_timeout_secs = wall_clock_timeout_secs;
85+
self
86+
}
87+
7488
/// Builder: attach inline rule YAML documents from preset `custom_rules`.
7589
pub fn with_custom_rules(mut self, custom_rules: Vec<String>) -> Self {
7690
self.custom_rules = custom_rules;
@@ -196,22 +210,26 @@ pub struct RawFinding {
196210
pub message: Option<String>,
197211
}
198212

199-
/// Map check_id to severity based on keyword matching
200-
pub fn parse_severity(check_id: &str) -> Severity {
201-
// Keyword-based matching (substring), preserve raise-only semantics
213+
/// Map check_id to severity based on keyword matching.
214+
/// Returns `None` when no keyword matches, so the parser can distinguish
215+
/// a real keyword-derived severity (which may upgrade an explicit
216+
/// `extra.severity`) from an unknown fallback (which must not).
217+
pub fn parse_severity(check_id: &str) -> Option<Severity> {
202218
match check_id.to_lowercase().as_str() {
203-
s if s.contains("critical") => Severity::Critical,
204-
s if s.contains("high") => Severity::High,
205-
s if s.contains("medium") => Severity::Medium,
206-
s if s.contains("low") => Severity::Low,
207-
_ => Severity::Info,
219+
s if s.contains("critical") => Some(Severity::Critical),
220+
s if s.contains("high") => Some(Severity::High),
221+
s if s.contains("medium") => Some(Severity::Medium),
222+
s if s.contains("low") => Some(Severity::Low),
223+
s if s.contains("info") => Some(Severity::Info),
224+
_ => None,
208225
}
209226
}
210227

211228
/// Collect rule IDs from a list of YAML documents.
212229
/// Each document is expected to have a `rules` list with `id` fields.
213-
/// Returns a BTreeSet of all rule IDs found, or an error naming the document index
214-
/// and the serde error if any document is invalid YAML.
230+
/// Returns a BTreeSet of all rule IDs found. Malformed documents are
231+
/// skipped with a warning instead of aborting the entire collection,
232+
/// so a single bad rule does not suppress all taint rules.
215233
pub fn collect_rule_ids(docs: &[String]) -> Result<std::collections::BTreeSet<String>, String> {
216234
use serde::Deserialize;
217235

@@ -228,12 +246,16 @@ pub fn collect_rule_ids(docs: &[String]) -> Result<std::collections::BTreeSet<St
228246
let mut ids = std::collections::BTreeSet::new();
229247

230248
for (i, doc) in docs.iter().enumerate() {
231-
let parsed: RulesDoc = serde_yaml::from_str(doc)
232-
.map_err(|e| format!("Invalid YAML at document index {}: {}", i, e))?;
233-
234-
if let Some(rules) = parsed.rules {
235-
for rule in rules {
236-
ids.insert(rule.id);
249+
match serde_yaml::from_str::<RulesDoc>(doc) {
250+
Ok(parsed) => {
251+
if let Some(rules) = parsed.rules {
252+
for rule in rules {
253+
ids.insert(rule.id);
254+
}
255+
}
256+
}
257+
Err(e) => {
258+
tracing::warn!("Skipping malformed custom rule at index {}: {}", i, e);
237259
}
238260
}
239261
}
@@ -242,9 +264,9 @@ pub fn collect_rule_ids(docs: &[String]) -> Result<std::collections::BTreeSet<St
242264
}
243265

244266
/// Filter rules from a YAML document, excluding those whose IDs are in the exclude set.
245-
/// Returns Ok(None) if no rules remain after filtering, Ok(Some(string)) with the
246-
/// re-serialized document otherwise. On error, returns a string naming the problem.
247-
/// The exclude set is not silently ignored — it must be valid.
267+
/// Returns Ok(None) if no rules remain after filtering or the document is malformed,
268+
/// Ok(Some(string)) with the re-serialized document otherwise. Malformed documents
269+
/// are skipped with a warning instead of aborting the entire scan.
248270
pub fn filter_rules_excluding_ids(
249271
doc: &str,
250272
exclude: &std::collections::BTreeSet<String>,
@@ -265,8 +287,13 @@ pub fn filter_rules_excluding_ids(
265287
rest: std::collections::HashMap<String, serde_yaml::Value>,
266288
}
267289

268-
let parsed: RulesDoc =
269-
serde_yaml::from_str(doc).map_err(|e| format!("Failed to parse YAML document: {}", e))?;
290+
let parsed: RulesDoc = match serde_yaml::from_str(doc) {
291+
Ok(p) => p,
292+
Err(e) => {
293+
tracing::warn!("Skipping malformed derived taint rule document: {}", e);
294+
return Ok(None);
295+
}
296+
};
270297

271298
let filtered: Vec<Rule> = parsed
272299
.rules

‎src/semgrep/rules/taint-csharp.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ rules:
1111
pattern-sources:
1212
- pattern: Request.QueryString[$KEY]
1313
- pattern: Request.Form[$KEY]
14+
- pattern: Request.Query[$KEY]
15+
- pattern: Request.Headers[$KEY]
1416
pattern-sinks:
1517
- pattern: new SqlCommand($SQL)
1618
- pattern: $CMD.ExecuteReader()
@@ -28,8 +30,13 @@ rules:
2830
pattern-sources:
2931
- pattern: Request.QueryString[$KEY]
3032
- pattern: Request.Form[$KEY]
33+
- pattern: Request.Query[$KEY]
34+
- pattern: Request.Headers[$KEY]
3135
pattern-sinks:
3236
- pattern: Process.Start($CMD)
37+
- pattern: Process.Start($CMD, ...)
38+
- pattern: System.Diagnostics.Process.Start($CMD)
39+
- pattern: System.Diagnostics.Process.Start($CMD, ...)
3340

3441
- id: csharp-taint-path-traversal-high
3542
languages: [csharp]
@@ -43,6 +50,8 @@ rules:
4350
pattern-sources:
4451
- pattern: Request.QueryString[$KEY]
4552
- pattern: Request.Form[$KEY]
53+
- pattern: Request.Query[$KEY]
54+
- pattern: Request.Headers[$KEY]
4655
pattern-sinks:
4756
- pattern: File.ReadAllText($PATH)
4857
- pattern: File.Open($PATH, $MODE)
@@ -59,5 +68,7 @@ rules:
5968
pattern-sources:
6069
- pattern: Request.QueryString[$KEY]
6170
- pattern: Request.Form[$KEY]
71+
- pattern: Request.Query[$KEY]
72+
- pattern: Request.Headers[$KEY]
6273
pattern-sinks:
6374
- pattern: new BinaryFormatter().Deserialize($STREAM)

0 commit comments

Comments
 (0)