fix(ingest): materialize single-file PLC uploads so the scanner finds them
CI / Check (pull_request) Successful in 6m42s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Check (pull_request) Successful in 6m42s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
A PlcProject uploaded as a single .st/.xml file (not a zip) was stored as a content-addressed blob with no extension, so the PLC scanner's extension-based file discovery skipped it — an uploaded project produced zero findings. When an "extractable" blob turns out not to be an archive, materialize it into the artifact work dir under its original file name (from source_ref, which the upload handler sets to the uploaded filename). analyze_tree then discovers it by extension and reports a readable path. Falls back to the raw blob if the copy fails. Covers the demo's upload -> scan path end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bd80ccef49
commit
c8ab5177bb
@@ -162,14 +162,27 @@ fn ingest_blob(
|
|||||||
match blob::extract_zip(&stored, &dest) {
|
match blob::extract_zip(&stored, &dest) {
|
||||||
Ok(()) => dest,
|
Ok(()) => dest,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Not a zip (e.g. a tar.gz source archive) — keep the blob and
|
// Not a zip container — this is a single uploaded file (e.g. a
|
||||||
// note it so later stages can decide what to do.
|
// `.st`/`.xml` PLC project or a `.tar.gz`). The content-addressed
|
||||||
|
// blob has no extension, so materialize it into a working dir
|
||||||
|
// under its original name; extension-based scanners (PLC) can then
|
||||||
|
// discover it and report a readable path.
|
||||||
facts.push(DetectedFact::new(
|
facts.push(DetectedFact::new(
|
||||||
"archive_unextracted",
|
"archive_unextracted",
|
||||||
e.to_string(),
|
e.to_string(),
|
||||||
"ingest",
|
"ingest",
|
||||||
));
|
));
|
||||||
stored.clone()
|
match materialize_single(&stored, &dest, &blob_file_name(artifact)) {
|
||||||
|
Ok(dir) => dir,
|
||||||
|
Err(copy_err) => {
|
||||||
|
facts.push(DetectedFact::new(
|
||||||
|
"materialize_failed",
|
||||||
|
copy_err.to_string(),
|
||||||
|
"ingest",
|
||||||
|
));
|
||||||
|
stored.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -186,6 +199,27 @@ fn ingest_blob(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy a stored blob into `dest`/`name`, returning `dest`. Used when an
|
||||||
|
/// "extractable" artifact turns out to be a single file rather than an archive.
|
||||||
|
fn materialize_single(stored: &Path, dest: &Path, name: &str) -> Result<PathBuf, AgentError> {
|
||||||
|
std::fs::create_dir_all(dest)?;
|
||||||
|
std::fs::copy(stored, dest.join(name))?;
|
||||||
|
Ok(dest.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A safe, single-segment file name for an artifact, preserving the original
|
||||||
|
/// extension so scanners can identify it. Derives from `source_ref` (the
|
||||||
|
/// uploaded/original file name); `file_name` strips any directory components,
|
||||||
|
/// so this is traversal-safe. Falls back to the artifact id.
|
||||||
|
fn blob_file_name(artifact: &Artifact) -> String {
|
||||||
|
Path::new(&artifact.source_ref)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| format!("artifact-{}", artifact.id))
|
||||||
|
}
|
||||||
|
|
||||||
/// An artifact with no on-disk form: record a single fact, no hash/path.
|
/// An artifact with no on-disk form: record a single fact, no hash/path.
|
||||||
fn metadata_only(artifact: &Artifact, fact: DetectedFact) -> IngestedArtifact {
|
fn metadata_only(artifact: &Artifact, fact: DetectedFact) -> IngestedArtifact {
|
||||||
IngestedArtifact {
|
IngestedArtifact {
|
||||||
@@ -331,4 +365,52 @@ mod tests {
|
|||||||
assert_eq!(creds.ssh_key_path.as_deref(), Some("/default/ssh/key"));
|
assert_eq!(creds.ssh_key_path.as_deref(), Some("/default/ssh/key"));
|
||||||
assert!(creds.auth_token.is_none());
|
assert!(creds.auth_token.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single uploaded PLC file (not an archive) must land in a working dir
|
||||||
|
/// under its original name so the PLC scanner can discover it by extension
|
||||||
|
/// and report a readable path — the demo's upload → scan path.
|
||||||
|
#[test]
|
||||||
|
fn single_uploaded_plc_file_is_materialized_and_scannable() {
|
||||||
|
use compliance_core::models::PlcFormat;
|
||||||
|
|
||||||
|
let scratch = Scratch::new();
|
||||||
|
let store = scratch.0.join("store");
|
||||||
|
// Simulate the upload handler: bytes written to an `uploads/` path,
|
||||||
|
// `source_ref` carrying the original (clean) file name.
|
||||||
|
let uploads = scratch.0.join("uploads");
|
||||||
|
std::fs::create_dir_all(&uploads).expect("mkdir uploads");
|
||||||
|
let uploaded = uploads.join("a1b2c3_pump_station.st");
|
||||||
|
std::fs::write(
|
||||||
|
&uploaded,
|
||||||
|
"PROGRAM P\nVAR\n ApiKey : STRING := 'sk-live-1234';\nEND_VAR\nEND_PROGRAM\n",
|
||||||
|
)
|
||||||
|
.expect("write st");
|
||||||
|
|
||||||
|
let mut artifact = Artifact::plc_project("pump_station.st", PlcFormat::StructuredText);
|
||||||
|
artifact.stored_path = Some(uploaded.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
let ctx = ctx_for(&store, "t-plc");
|
||||||
|
let out = ingest_artifact(&artifact, &ctx).expect("ingest");
|
||||||
|
|
||||||
|
// Working path is a directory (not the extensionless blob) holding the
|
||||||
|
// file under its original name.
|
||||||
|
let wp = out.working_path.expect("working path");
|
||||||
|
assert!(wp.is_dir(), "expected a working dir, got {wp:?}");
|
||||||
|
assert!(wp.join("pump_station.st").is_file());
|
||||||
|
|
||||||
|
// The PLC scanner finds the hardcoded credential and reports a clean path.
|
||||||
|
let findings = crate::pipeline::plc::analyze_tree(&wp, "t-plc");
|
||||||
|
assert!(
|
||||||
|
!findings.is_empty(),
|
||||||
|
"scanner should flag the uploaded file"
|
||||||
|
);
|
||||||
|
assert!(findings
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.rule_id.as_deref() == Some("plc-hardcoded-credential")));
|
||||||
|
assert_eq!(
|
||||||
|
findings[0].file_path.as_deref(),
|
||||||
|
Some("pump_station.st"),
|
||||||
|
"finding should reference the original file name"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user