use crate::artifact_graph::{ArtifactCapability, ArtifactCapabilityFact, ArtifactRelation};
use crate::detectors::patterns::line_invokes_shell_or_interpreter;
use crate::findings::{
ArtifactKind, EvidenceKind, Finding, MatchTarget, RecommendedAction, Severity, ThreatCategory,
};
use crate::services::artifact_orchestration::manifests::strip_inline_hash_comment;
use crate::services::artifact_orchestration::{ArtifactLink, ArtifactOrchestratorService};
use std::path::Path;
pub(crate) fn analyze_makefile(path: &Path, content: &str) -> Vec<Finding> {
let artifact_path = path.display().to_string();
let mut findings = Vec::new();
for line in content.lines().map(str::trim) {
let code = strip_inline_hash_comment(line);
let lower = code.to_ascii_lowercase();
if lower.contains("curl ") || lower.contains("wget ") {
findings.push(
Finding::builder(
"MANIFEST_MAKEFILE_REMOTE_DOWNLOAD",
ThreatCategory::SupplyChain,
)
.severity(Severity::Medium)
.action(RecommendedAction::RequireApproval)
.evidence_kind(EvidenceKind::Behavior)
.matched_on(MatchTarget::ReferencedFile {
path: artifact_path.clone(),
})
.artifact(ArtifactKind::PackageManifest, Some(artifact_path.clone()))
.match_value(line)
.reason("Makefile performs remote downloads")
.build(),
);
}
}
findings
}
pub(crate) fn makefile_capabilities(content: &str) -> Vec<ArtifactCapabilityFact> {
let mut has_network = false;
let mut has_exec = false;
for line in content.lines() {
let code = strip_inline_hash_comment(line.trim_start());
let lower = code.to_ascii_lowercase();
if !has_network && (lower.contains("curl ") || lower.contains("wget ")) {
has_network = true;
}
if !has_exec && line_invokes_shell_or_interpreter(&lower) {
has_exec = true;
}
}
let mut capabilities = Vec::new();
if has_network {
capabilities.push(ArtifactOrchestratorService::observed_capability(
ArtifactCapability::NetworkAccess,
));
}
if has_exec {
capabilities.push(ArtifactOrchestratorService::observed_capability(
ArtifactCapability::ProcessExecution,
));
}
capabilities
}
pub(crate) fn makefile_relations(content: &str) -> Vec<ArtifactLink> {
let mut links = Vec::new();
for line in content.lines().map(str::trim) {
let code = strip_inline_hash_comment(line);
let lower = code.to_ascii_lowercase();
if lower.contains("curl ") || lower.contains("wget ") {
links.push(ArtifactLink {
target: "remote-resource".to_string(),
relation: ArtifactRelation::Downloads,
});
}
if line_invokes_shell_or_interpreter(&lower) {
links.push(ArtifactLink {
target: code.to_string(),
relation: ArtifactRelation::Executes,
});
}
}
links
}
#[cfg(test)]
mod tests {
use super::*;
fn capability_present(caps: &[ArtifactCapabilityFact], target: ArtifactCapability) -> bool {
caps.iter().any(|fact| fact.capability == target)
}
fn relation_target_present(links: &[ArtifactLink], substring: &str) -> bool {
links.iter().any(|link| link.target.contains(substring))
}
#[test]
fn makefile_capabilities_detects_bash_recipe() {
let content = "all:\n\tbash install.sh\n";
let caps = makefile_capabilities(content);
assert!(capability_present(
&caps,
ArtifactCapability::ProcessExecution
));
}
#[test]
fn makefile_capabilities_detects_sh_recipe() {
let content = "all:\n\tsh install.sh\n";
let caps = makefile_capabilities(content);
assert!(capability_present(
&caps,
ArtifactCapability::ProcessExecution
));
}
#[test]
fn makefile_capabilities_skips_publish_recipe() {
let content = "publish:\n\t@echo \"Publish docs\"\n";
let caps = makefile_capabilities(content);
assert!(!capability_present(
&caps,
ArtifactCapability::ProcessExecution
));
}
#[test]
fn makefile_capabilities_skips_finish_step() {
let content = "ready:\n\t@echo \"Finish setup\"\n";
let caps = makefile_capabilities(content);
assert!(!capability_present(
&caps,
ArtifactCapability::ProcessExecution
));
}
#[test]
fn makefile_capabilities_skips_pure_comment_lines() {
let content = "# bash this script later\nall:\n\t@echo done\n";
let caps = makefile_capabilities(content);
assert!(!capability_present(
&caps,
ArtifactCapability::ProcessExecution
));
}
#[test]
fn makefile_relations_detects_sh_recipe() {
let content = "all:\n\tsh install.sh\n";
let links = makefile_relations(content);
assert!(relation_target_present(&links, "sh install.sh"));
}
#[test]
fn makefile_relations_skips_publish_recipe() {
let content = "publish:\n\t@echo \"Publish docs\"\n";
let links = makefile_relations(content);
assert!(!relation_target_present(&links, "Publish docs"));
assert!(!relation_target_present(&links, "publish"));
}
#[test]
fn analyze_makefile_ignores_curl_in_inline_comment() {
let content = "all:\n\t@echo ready # was: curl https://old\n";
let findings = analyze_makefile(Path::new("Makefile"), content);
assert!(
findings.is_empty(),
"inline `# ... curl ...` comment must not raise a finding; got: {findings:?}",
);
}
#[test]
fn analyze_makefile_detects_curl_with_trailing_comment() {
let content = "all:\n\tcurl https://x # download bootstrap\n";
let findings = analyze_makefile(Path::new("Makefile"), content);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_id, "MANIFEST_MAKEFILE_REMOTE_DOWNLOAD");
}
#[test]
fn makefile_capabilities_skips_wget_in_inline_comment() {
let content = "all:\n\t@echo done # used to wget https://old\n";
let caps = makefile_capabilities(content);
assert!(!capability_present(
&caps,
ArtifactCapability::NetworkAccess
));
}
#[test]
fn makefile_capabilities_skips_bash_in_inline_comment() {
let content = "all:\n\t@echo ok # legacy: bash install.sh\n";
let caps = makefile_capabilities(content);
assert!(!capability_present(
&caps,
ArtifactCapability::ProcessExecution
));
}
#[test]
fn makefile_relations_skips_curl_in_inline_comment() {
let content = "all:\n\t@echo ready # legacy curl https://x\n";
let links = makefile_relations(content);
assert!(!relation_target_present(&links, "remote-resource"));
}
}