Merge branch 'semgrep-scan'

This commit is contained in:
2026-04-01 20:37:39 -05:00
26 changed files with 776 additions and 60 deletions

View File

@@ -4,3 +4,6 @@ web/node_modules/
web/src/api/
packs.dev/
packs.external/
tests/
docs/
*.md

1
Cargo.lock generated
View File

@@ -580,6 +580,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"url",
"utoipa",
"uuid",
"validator",

View File

@@ -62,6 +62,8 @@ pack_registry:
enabled: true
default_registry: https://registry.attune.example.com
cache_ttl: 300
allowed_source_hosts:
- registry.attune.example.com
# Test worker configuration
# worker:

View File

@@ -803,6 +803,7 @@ async fn handle_upload(
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- CLI users explicitly choose a local file to upload; this is not a server-side path sink.
let file_path = Path::new(&file);
if !file_path.exists() {
anyhow::bail!("File not found: {}", file);
@@ -811,6 +812,7 @@ async fn handle_upload(
anyhow::bail!("Not a file: {}", file);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The validated CLI-selected upload path is intentionally read and sent to the API.
let file_bytes = tokio::fs::read(file_path).await?;
let file_name = file_path
.file_name()

View File

@@ -840,6 +840,7 @@ async fn handle_upload(
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- CLI pack commands intentionally operate on operator-supplied local paths.
let pack_dir = Path::new(&path);
// Validate the directory exists and contains pack.yaml
@@ -855,6 +856,7 @@ async fn handle_upload(
}
// Read pack ref from pack.yaml so we can display it
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Reading local pack metadata from the user-selected pack directory is expected CLI behavior.
let pack_yaml_content =
std::fs::read_to_string(&pack_yaml_path).context("Failed to read pack.yaml")?;
let pack_yaml: serde_yaml_ng::Value =
@@ -957,6 +959,7 @@ fn append_dir_to_tar<W: std::io::Write>(
base: &Path,
dir: &Path,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The archiver walks a validated local directory selected by the CLI operator.
for entry in std::fs::read_dir(dir).context("Failed to read directory")? {
let entry = entry.context("Failed to read directory entry")?;
let entry_path = entry.path();
@@ -1061,6 +1064,7 @@ async fn handle_test(
use std::path::{Path, PathBuf};
// Determine if pack is a path or a pack name
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack test targets are local CLI inputs, not remote request paths.
let pack_path = Path::new(&pack);
let (pack_dir, pack_ref, pack_version) = if pack_path.exists() && pack_path.is_dir() {
// Local pack directory
@@ -1072,6 +1076,7 @@ async fn handle_test(
anyhow::bail!("pack.yaml not found in directory: {}", pack);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- This reads pack.yaml from a local directory explicitly selected by the CLI operator.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -1107,6 +1112,7 @@ async fn handle_test(
anyhow::bail!("pack.yaml not found for pack: {}", pack);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Installed pack tests intentionally read local metadata from the workspace packs directory.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -1120,6 +1126,7 @@ async fn handle_test(
// Load pack.yaml and extract test configuration
let pack_yaml_path = pack_dir.join("pack.yaml");
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Test configuration is loaded from the validated local pack directory.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -1484,6 +1491,7 @@ fn detect_source_type(source: &str, ref_spec: Option<&str>, no_registry: bool) -
async fn handle_checksum(path: String, json: bool, output_format: OutputFormat) -> Result<()> {
use attune_common::pack_registry::{calculate_directory_checksum, calculate_file_checksum};
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Checksum generation intentionally accepts arbitrary local paths from the CLI operator.
let path_obj = Path::new(&path);
if !path_obj.exists() {
@@ -1581,6 +1589,7 @@ async fn handle_index_entry(
) -> Result<()> {
use attune_common::pack_registry::calculate_directory_checksum;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Index-entry generation intentionally inspects a local pack directory chosen by the CLI operator.
let path_obj = Path::new(&path);
if !path_obj.exists() {
@@ -1606,6 +1615,7 @@ async fn handle_index_entry(
}
// Read and parse pack.yaml
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Reading local pack metadata for index generation is expected CLI behavior.
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;

View File

@@ -19,11 +19,13 @@ pub async fn handle_index_update(
output_format: OutputFormat,
) -> Result<()> {
// Load existing index
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Registry index maintenance is a local CLI/admin operation over operator-supplied files.
let index_file_path = Path::new(&index_path);
if !index_file_path.exists() {
return Err(anyhow::anyhow!("Index file not found: {}", index_path));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The CLI intentionally reads the local index file selected by the operator.
let index_content = fs::read_to_string(index_file_path)?;
let mut index: JsonValue = serde_json::from_str(&index_content)?;
@@ -34,6 +36,7 @@ pub async fn handle_index_update(
.ok_or_else(|| anyhow::anyhow!("Invalid index format: missing 'packs' array"))?;
// Load pack.yaml from the pack directory
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Local pack directories are explicit CLI inputs, not remote taint.
let pack_dir = Path::new(&pack_path);
if !pack_dir.exists() || !pack_dir.is_dir() {
return Err(anyhow::anyhow!("Pack directory not found: {}", pack_path));
@@ -47,6 +50,7 @@ pub async fn handle_index_update(
));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Reading pack.yaml from a local operator-selected pack directory is expected CLI behavior.
let pack_yaml_content = fs::read_to_string(&pack_yaml_path)?;
let pack_yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(&pack_yaml_content)?;
@@ -250,6 +254,7 @@ pub async fn handle_index_merge(
output_format: OutputFormat,
) -> Result<()> {
// Check if output file exists
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Index merge output is a local CLI path controlled by the operator.
let output_file_path = Path::new(&output_path);
if output_file_path.exists() && !force {
return Err(anyhow::anyhow!(
@@ -265,6 +270,7 @@ pub async fn handle_index_merge(
// Load and merge all input files
for input_path in &input_paths {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Index merge inputs are local operator-selected files.
let input_file_path = Path::new(input_path);
if !input_file_path.exists() {
if output_format == OutputFormat::Table {
@@ -277,6 +283,7 @@ pub async fn handle_index_merge(
output::print_info(&format!("Loading: {}", input_path));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The CLI intentionally reads each local input index file during merge.
let index_content = fs::read_to_string(input_file_path)?;
let index: JsonValue = serde_json::from_str(&index_content)?;

View File

@@ -172,6 +172,7 @@ async fn handle_upload(
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow upload reads local files chosen by the CLI operator; it is not a server-side path sink.
let action_path = Path::new(&action_file);
// ── 1. Validate & read the action YAML ──────────────────────────────
@@ -182,6 +183,7 @@ async fn handle_upload(
anyhow::bail!("Path is not a file: {}", action_file);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The action YAML is intentionally read from the validated local CLI path.
let action_yaml_content =
std::fs::read_to_string(action_path).context("Failed to read action YAML file")?;
@@ -216,6 +218,7 @@ async fn handle_upload(
}
// ── 4. Read and parse the workflow YAML ─────────────────────────────
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The workflow file path is confined to the pack directory before this local read occurs.
let workflow_yaml_content =
std::fs::read_to_string(&workflow_path).context("Failed to read workflow YAML file")?;
@@ -616,12 +619,41 @@ fn split_action_ref(action_ref: &str) -> Result<(String, String)> {
/// resolved relative to the action YAML's parent directory.
fn resolve_workflow_path(action_yaml_path: &Path, workflow_file: &str) -> Result<PathBuf> {
let action_dir = action_yaml_path.parent().unwrap_or(Path::new("."));
let pack_root = action_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Action YAML must live inside a pack actions/ directory"))?;
let canonical_pack_root = pack_root
.canonicalize()
.context("Failed to resolve pack root for workflow file")?;
let canonical_action_dir = action_dir
.canonicalize()
.context("Failed to resolve action directory for workflow file")?;
let canonical_workflow_path = normalize_path_from_base(&canonical_action_dir, workflow_file);
let resolved = action_dir.join(workflow_file);
if !canonical_workflow_path.starts_with(&canonical_pack_root) {
anyhow::bail!(
"Workflow file resolves outside the pack directory: {}",
workflow_file
);
}
// Canonicalize if possible (for better error messages), but don't fail
// if the file doesn't exist yet — we'll check existence later.
Ok(resolved)
Ok(canonical_workflow_path)
}
fn normalize_path_from_base(base: &Path, relative_path: &str) -> PathBuf {
let mut normalized = PathBuf::new();
for component in base.join(relative_path).components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
normalized
}
#[cfg(test)]
@@ -655,23 +687,62 @@ mod tests {
#[test]
fn test_resolve_workflow_path() {
let action_path = Path::new("/packs/mypack/actions/deploy.yaml");
let temp = tempfile::tempdir().unwrap();
let pack_dir = temp.path().join("mypack");
let actions_dir = pack_dir.join("actions");
let workflow_dir = actions_dir.join("workflows");
std::fs::create_dir_all(&workflow_dir).unwrap();
let action_path = actions_dir.join("deploy.yaml");
let workflow_path = workflow_dir.join("deploy.workflow.yaml");
std::fs::write(
&action_path,
"ref: mypack.deploy\nworkflow_file: workflows/deploy.workflow.yaml\n",
)
.unwrap();
std::fs::write(&workflow_path, "version: 1.0.0\n").unwrap();
let resolved =
resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap();
assert_eq!(
resolved,
PathBuf::from("/packs/mypack/actions/workflows/deploy.workflow.yaml")
);
resolve_workflow_path(&action_path, "workflows/deploy.workflow.yaml").unwrap();
assert_eq!(resolved, workflow_path.canonicalize().unwrap());
}
#[test]
fn test_resolve_workflow_path_relative() {
let action_path = Path::new("actions/deploy.yaml");
let temp = tempfile::tempdir().unwrap();
let pack_dir = temp.path().join("mypack");
let actions_dir = pack_dir.join("actions");
let workflows_dir = pack_dir.join("workflows");
std::fs::create_dir_all(&actions_dir).unwrap();
std::fs::create_dir_all(&workflows_dir).unwrap();
let action_path = actions_dir.join("deploy.yaml");
let workflow_path = workflows_dir.join("deploy.workflow.yaml");
std::fs::write(
&action_path,
"ref: mypack.deploy\nworkflow_file: ../workflows/deploy.workflow.yaml\n",
)
.unwrap();
std::fs::write(&workflow_path, "version: 1.0.0\n").unwrap();
let resolved =
resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap();
assert_eq!(
resolved,
PathBuf::from("actions/workflows/deploy.workflow.yaml")
);
resolve_workflow_path(&action_path, "../workflows/deploy.workflow.yaml").unwrap();
assert_eq!(resolved, workflow_path.canonicalize().unwrap());
}
#[test]
fn test_resolve_workflow_path_rejects_traversal_outside_pack() {
let temp = tempfile::tempdir().unwrap();
let pack_dir = temp.path().join("mypack");
let actions_dir = pack_dir.join("actions");
std::fs::create_dir_all(&actions_dir).unwrap();
let action_path = actions_dir.join("deploy.yaml");
let outside = temp.path().join("outside.yaml");
std::fs::write(&action_path, "ref: mypack.deploy\n").unwrap();
std::fs::write(&outside, "version: 1.0.0\n").unwrap();
let err = resolve_workflow_path(&action_path, "../../outside.yaml").unwrap_err();
assert!(err.to_string().contains("outside the pack directory"));
}
}

View File

@@ -802,6 +802,7 @@ fn resolve_ws_url(opts: &WaitOptions<'_>) -> Option<String> {
/// - `https://api.example.com` → `wss://api.example.com:8081`
/// - `http://api.example.com:9000` → `ws://api.example.com:8081`
fn derive_notifier_url(api_url: &str) -> Option<String> {
// nosemgrep: javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket -- The function upgrades https->wss and only returns ws for explicit http base URLs or test examples.
let url = url::Url::parse(api_url).ok()?;
let ws_scheme = match url.scheme() {
"https" => "wss",

View File

@@ -73,6 +73,7 @@ regex = { workspace = true }
# Version matching
semver = { workspace = true }
url = { workspace = true }
[dev-dependencies]
mockall = { workspace = true }

View File

@@ -658,6 +658,11 @@ pub struct PackRegistryConfig {
#[serde(default = "default_true")]
pub verify_checksums: bool,
/// Additional remote hosts allowed for pack archive/git downloads.
/// Hosts from enabled registry indices are implicitly allowed.
#[serde(default)]
pub allowed_source_hosts: Vec<String>,
/// Allow HTTP (non-HTTPS) registries
#[serde(default)]
pub allow_http: bool,
@@ -680,6 +685,7 @@ impl Default for PackRegistryConfig {
cache_enabled: true,
timeout: default_registry_timeout(),
verify_checksums: true,
allowed_source_hosts: Vec::new(),
allow_http: false,
}
}

View File

@@ -12,6 +12,7 @@ use crate::models::Runtime;
use crate::repositories::action::ActionRepository;
use crate::repositories::runtime::{self, RuntimeRepository};
use crate::repositories::FindById as _;
use regex::Regex;
use serde_json::Value as JsonValue;
use sqlx::{PgPool, Row};
use std::collections::{HashMap, HashSet};
@@ -94,10 +95,7 @@ pub struct PackEnvironmentManager {
impl PackEnvironmentManager {
/// Create a new pack environment manager
pub fn new(pool: PgPool, config: &Config) -> Self {
let base_path = PathBuf::from(&config.packs_base_dir)
.parent()
.map(|p| p.join("packenvs"))
.unwrap_or_else(|| PathBuf::from("/opt/attune/packenvs"));
let base_path = PathBuf::from(&config.runtime_envs_dir);
Self { pool, base_path }
}
@@ -399,19 +397,19 @@ impl PackEnvironmentManager {
}
fn calculate_env_path(&self, pack_ref: &str, runtime: &Runtime) -> Result<PathBuf> {
let runtime_name_lower = runtime.name.to_lowercase();
let template = runtime
.installers
.get("base_path_template")
.and_then(|v| v.as_str())
.unwrap_or("/opt/attune/packenvs/{pack_ref}/{runtime_name_lower}");
.unwrap_or("{pack_ref}/{runtime_name_lower}");
let runtime_name_lower = runtime.name.to_lowercase();
let path_str = template
.replace("{pack_ref}", pack_ref)
.replace("{runtime_ref}", &runtime.r#ref)
.replace("{runtime_name_lower}", &runtime_name_lower);
Ok(PathBuf::from(path_str))
resolve_env_path(&self.base_path, &path_str)
}
async fn upsert_environment_record(
@@ -528,6 +526,7 @@ impl PackEnvironmentManager {
let mut install_log = String::new();
// Create environment directory
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- env_path comes from validated runtime-env path construction under runtime_envs_dir.
let env_path = PathBuf::from(&pack_env.env_path);
if env_path.exists() {
warn!(
@@ -659,6 +658,8 @@ impl PackEnvironmentManager {
env_path,
&pack_path_str,
)?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The candidate command path is validated and confined before any execution is attempted.
let command = validate_installer_command(&command, pack_path, Path::new(env_path))?;
let args_template = installer
.get("args")
@@ -680,12 +681,17 @@ impl PackEnvironmentManager {
let cwd_template = installer.get("cwd").and_then(|v| v.as_str());
let cwd = if let Some(cwd_t) = cwd_template {
Some(self.resolve_template(
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Installer cwd values are validated to stay under the pack root or environment directory.
Some(validate_installer_path(
&self.resolve_template(
cwd_t,
pack_ref,
runtime_ref,
env_path,
&pack_path_str,
)?,
pack_path,
Path::new(env_path),
)?)
} else {
None
@@ -763,6 +769,7 @@ impl PackEnvironmentManager {
async fn execute_installer_action(&self, action: &InstallerAction) -> Result<String> {
debug!("Executing: {} {:?}", action.command, action.args);
// nosemgrep: rust.actix.command-injection.rust-actix-command-injection.rust-actix-command-injection -- action.command is accepted only after strict validation of executable shape and allowed path roots.
let mut cmd = Command::new(&action.command);
cmd.args(&action.args);
@@ -800,7 +807,9 @@ impl PackEnvironmentManager {
// Check file_exists condition
if let Some(file_path_template) = condition.get("file_exists").and_then(|v| v.as_str()) {
let file_path = file_path_template.replace("{pack_path}", &pack_path.to_string_lossy());
return Ok(PathBuf::from(file_path).exists());
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Conditional file checks are validated to stay under trusted pack/environment roots before filesystem access.
let validated = validate_installer_path(&file_path, pack_path, &self.base_path)?;
return Ok(PathBuf::from(validated).exists());
}
// Default: condition is true
@@ -816,6 +825,93 @@ impl PackEnvironmentManager {
}
}
fn resolve_env_path(base_path: &Path, path_str: &str) -> Result<PathBuf> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- This helper normalizes env paths and preserves legacy absolute templates while still rejecting parent traversal.
let raw_path = Path::new(path_str);
if raw_path.is_absolute() {
return normalize_relative_or_absolute_path(raw_path);
}
let joined = base_path.join(raw_path);
normalize_relative_or_absolute_path(&joined)
}
fn normalize_relative_or_absolute_path(path: &Path) -> Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
return Err(Error::validation(format!(
"Parent-directory traversal is not allowed in installer paths: {}",
path.display()
)));
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
Ok(normalized)
}
fn validate_installer_command(command: &str, pack_path: &Path, env_path: &Path) -> Result<String> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Command validation inspects the path form before enforcing allowed executable rules.
let command_path = Path::new(command);
if command_path.is_absolute() {
return validate_installer_path(command, pack_path, env_path);
}
if command.contains(std::path::MAIN_SEPARATOR) {
return Err(Error::validation(format!(
"Installer command must be a bare executable name or an allowed absolute path: {}",
command
)));
}
let command_name_re = Regex::new(r"^[A-Za-z0-9._+-]+$").expect("valid installer regex");
if !command_name_re.is_match(command) {
return Err(Error::validation(format!(
"Installer command contains invalid characters: {}",
command
)));
}
Ok(command.to_string())
}
fn validate_installer_path(path_str: &str, pack_path: &Path, env_path: &Path) -> Result<String> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Path validation normalizes candidate installer paths before enforcing root confinement.
let path = normalize_path(Path::new(path_str));
let normalized_pack_path = normalize_path(pack_path);
let normalized_env_path = normalize_path(env_path);
if path.starts_with(&normalized_pack_path) || path.starts_with(&normalized_env_path) {
Ok(path.to_string_lossy().to_string())
} else {
Err(Error::validation(format!(
"Installer path must remain under the pack or environment directory: {}",
path_str
)))
}
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
normalized
}
/// Collect the lowercase runtime names that require environment setup for a pack.
///
/// This queries the pack's actions, resolves their runtimes, and returns the names

View File

@@ -349,6 +349,7 @@ mod tests {
cache_enabled: true,
timeout: 120,
verify_checksums: true,
allowed_source_hosts: Vec::new(),
allow_http: false,
};

View File

@@ -11,10 +11,14 @@
use super::{Checksum, InstallSource, PackIndexEntry, RegistryClient};
use crate::config::PackRegistryConfig;
use crate::error::{Error, Result};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::net::lookup_host;
use tokio::process::Command;
use url::Url;
/// Progress callback type
pub type ProgressCallback = Arc<dyn Fn(ProgressEvent) + Send + Sync>;
@@ -53,6 +57,12 @@ pub struct PackInstaller {
/// Whether to verify checksums
verify_checksums: bool,
/// Whether HTTP remote sources are allowed
allow_http: bool,
/// Remote hosts allowed for archive/git installs
allowed_remote_hosts: Option<HashSet<String>>,
/// Progress callback (optional)
progress_callback: Option<ProgressCallback>,
}
@@ -106,17 +116,32 @@ impl PackInstaller {
.await
.map_err(|e| Error::internal(format!("Failed to create temp directory: {}", e)))?;
let (registry_client, verify_checksums) = if let Some(config) = registry_config {
let (registry_client, verify_checksums, allow_http, allowed_remote_hosts) =
if let Some(config) = registry_config {
let verify_checksums = config.verify_checksums;
(Some(RegistryClient::new(config)?), verify_checksums)
let allow_http = config.allow_http;
let allowed_remote_hosts = collect_allowed_remote_hosts(&config)?;
let allowed_remote_hosts = if allowed_remote_hosts.is_empty() {
None
} else {
(None, false)
Some(allowed_remote_hosts)
};
(
Some(RegistryClient::new(config)?),
verify_checksums,
allow_http,
allowed_remote_hosts,
)
} else {
(None, false, false, None)
};
Ok(Self {
temp_dir,
registry_client,
verify_checksums,
allow_http,
allowed_remote_hosts,
progress_callback: None,
})
}
@@ -152,6 +177,7 @@ impl PackInstaller {
/// Install from git repository
async fn install_from_git(&self, url: &str, git_ref: Option<&str>) -> Result<InstalledPack> {
self.validate_git_source(url).await?;
tracing::info!("Installing pack from git: {} (ref: {:?})", url, git_ref);
self.report_progress(ProgressEvent::StepStarted {
@@ -405,10 +431,12 @@ impl PackInstaller {
/// Download an archive from a URL
async fn download_archive(&self, url: &str) -> Result<PathBuf> {
let parsed_url = self.validate_remote_url(url).await?;
let client = reqwest::Client::new();
// nosemgrep: rust.actix.ssrf.reqwest-taint.reqwest-taint -- Remote source URLs are restricted to configured allowlisted hosts, HTTPS, and public IPs before request execution.
let response = client
.get(url)
.get(parsed_url.clone())
.send()
.await
.map_err(|e| Error::internal(format!("Failed to download archive: {}", e)))?;
@@ -421,11 +449,7 @@ impl PackInstaller {
}
// Determine filename from URL
let filename = url
.split('/')
.next_back()
.unwrap_or("archive.zip")
.to_string();
let filename = archive_filename_from_url(&parsed_url);
let archive_path = self.temp_dir.join(&filename);
@@ -442,6 +466,116 @@ impl PackInstaller {
Ok(archive_path)
}
async fn validate_remote_url(&self, raw_url: &str) -> Result<Url> {
let parsed = Url::parse(raw_url)
.map_err(|e| Error::validation(format!("Invalid remote URL '{}': {}", raw_url, e)))?;
if parsed.scheme() != "https" && !(self.allow_http && parsed.scheme() == "http") {
return Err(Error::validation(format!(
"Remote URL must use https{}: {}",
if self.allow_http {
" or http when pack_registry.allow_http is enabled"
} else {
""
},
raw_url
)));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(Error::validation(
"Remote URLs with embedded credentials are not allowed".to_string(),
));
}
let host = parsed.host_str().ok_or_else(|| {
Error::validation(format!("Remote URL is missing a host: {}", raw_url))
})?;
let normalized_host = host.to_ascii_lowercase();
if normalized_host == "localhost" {
return Err(Error::validation(format!(
"Remote URL host is not allowed: {}",
host
)));
}
if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
if !allowed_remote_hosts.contains(&normalized_host) {
return Err(Error::validation(format!(
"Remote URL host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
host
)));
}
}
if let Some(ip) = parsed.host().and_then(|host| match host {
url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
url::Host::Domain(_) => None,
}) {
ensure_public_ip(ip)?;
}
let port = parsed.port_or_known_default().ok_or_else(|| {
Error::validation(format!("Remote URL is missing a usable port: {}", raw_url))
})?;
let resolved = lookup_host((host, port))
.await
.map_err(|e| Error::validation(format!("Failed to resolve host '{}': {}", host, e)))?;
let mut saw_address = false;
for addr in resolved {
saw_address = true;
ensure_public_ip(addr.ip())?;
}
if !saw_address {
return Err(Error::validation(format!(
"Remote URL host did not resolve to any addresses: {}",
host
)));
}
Ok(parsed)
}
async fn validate_git_source(&self, raw_url: &str) -> Result<()> {
if raw_url.starts_with("http://") || raw_url.starts_with("https://") {
self.validate_remote_url(raw_url).await?;
return Ok(());
}
if let Some(host) = extract_git_host(raw_url) {
self.validate_remote_host(&host)?;
}
Ok(())
}
fn validate_remote_host(&self, host: &str) -> Result<()> {
let normalized_host = host.to_ascii_lowercase();
if normalized_host == "localhost" {
return Err(Error::validation(format!(
"Remote host is not allowed: {}",
host
)));
}
if let Some(allowed_remote_hosts) = &self.allowed_remote_hosts {
if !allowed_remote_hosts.contains(&normalized_host) {
return Err(Error::validation(format!(
"Remote host '{}' is not in the configured allowlist. Add it to pack_registry.allowed_source_hosts.",
host
)));
}
}
Ok(())
}
/// Extract an archive (zip or tar.gz)
async fn extract_archive(&self, archive_path: &Path) -> Result<PathBuf> {
let extract_dir = self.create_temp_dir().await?;
@@ -583,6 +717,7 @@ impl PackInstaller {
}
// Check in first subdirectory (common for GitHub archives)
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Archive inspection is limited to the temporary extraction directory created by this installer.
let mut entries = fs::read_dir(base_dir)
.await
.map_err(|e| Error::internal(format!("Failed to read directory: {}", e)))?;
@@ -618,6 +753,7 @@ impl PackInstaller {
})?;
// Read source directory
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Directory copy operates on installer-managed local paths, not request-derived paths.
let mut entries = fs::read_dir(src)
.await
.map_err(|e| Error::internal(format!("Failed to read source directory: {}", e)))?;
@@ -674,6 +810,111 @@ impl PackInstaller {
}
}
fn collect_allowed_remote_hosts(config: &PackRegistryConfig) -> Result<HashSet<String>> {
let mut hosts = HashSet::new();
for index in &config.indices {
if !index.enabled {
continue;
}
let parsed = Url::parse(&index.url).map_err(|e| {
Error::validation(format!("Invalid registry index URL '{}': {}", index.url, e))
})?;
let host = parsed.host_str().ok_or_else(|| {
Error::validation(format!(
"Registry index URL '{}' is missing a host",
index.url
))
})?;
hosts.insert(host.to_ascii_lowercase());
}
for host in &config.allowed_source_hosts {
let normalized = host.trim().to_ascii_lowercase();
if !normalized.is_empty() {
hosts.insert(normalized);
}
}
Ok(hosts)
}
fn extract_git_host(raw_url: &str) -> Option<String> {
if let Ok(parsed) = Url::parse(raw_url) {
return parsed.host_str().map(|host| host.to_ascii_lowercase());
}
raw_url.split_once('@').and_then(|(_, rest)| {
rest.split_once(':')
.map(|(host, _)| host.to_ascii_lowercase())
})
}
fn archive_filename_from_url(url: &Url) -> String {
let raw_name = url
.path_segments()
.and_then(|segments| segments.filter(|segment| !segment.is_empty()).next_back())
.unwrap_or("archive.bin");
let sanitized: String = raw_name
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => ch,
_ => '_',
})
.collect();
let filename = sanitized.trim_matches('.');
if filename.is_empty() {
"archive.bin".to_string()
} else {
filename.to_string()
}
}
fn ensure_public_ip(ip: IpAddr) -> Result<()> {
let is_blocked = match ip {
IpAddr::V4(ip) => {
let octets = ip.octets();
let is_documentation_range = matches!(
octets,
[192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _]
);
ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_broadcast()
|| is_documentation_range
|| ip.is_unspecified()
|| octets[0] == 0
}
IpAddr::V6(ip) => {
let segments = ip.segments();
let is_documentation_range = segments[0] == 0x2001 && segments[1] == 0x0db8;
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| is_documentation_range
|| ip == Ipv6Addr::LOCALHOST
}
};
if is_blocked {
return Err(Error::validation(format!(
"Remote URL resolved to a non-public address: {}",
ip
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -721,4 +962,52 @@ mod tests {
assert!(matches!(source, InstallSource::Git { .. }));
}
#[test]
fn test_archive_filename_from_url_sanitizes_path_segments() {
let url = Url::parse("https://example.com/releases/../../pack.zip?token=x").unwrap();
assert_eq!(archive_filename_from_url(&url), "pack.zip");
}
#[test]
fn test_ensure_public_ip_rejects_private_ipv4() {
let err = ensure_public_ip(IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))).unwrap_err();
assert!(err.to_string().contains("non-public"));
}
#[test]
fn test_collect_allowed_remote_hosts_includes_indices_and_overrides() {
let config = PackRegistryConfig {
indices: vec![crate::config::RegistryIndexConfig {
url: "https://registry.example.com/index.json".to_string(),
priority: 1,
enabled: true,
name: None,
headers: std::collections::HashMap::new(),
}],
allowed_source_hosts: vec!["github.com".to_string(), "cdn.example.com".to_string()],
..Default::default()
};
let hosts = collect_allowed_remote_hosts(&config).unwrap();
assert!(hosts.contains("registry.example.com"));
assert!(hosts.contains("github.com"));
assert!(hosts.contains("cdn.example.com"));
}
#[test]
fn test_extract_git_host_from_scp_style_source() {
assert_eq!(
extract_git_host("git@github.com:org/repo.git"),
Some("github.com".to_string())
);
}
#[test]
fn test_extract_git_host_from_git_scheme_source() {
assert_eq!(
extract_git_host("git://github.com/org/repo.git"),
Some("github.com".to_string())
);
}
}

View File

@@ -31,7 +31,7 @@
//! can reference the same workflow file with different configurations.
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use sqlx::PgPool;
use tracing::{debug, info, warn};
@@ -1091,7 +1091,10 @@ impl<'a> PackComponentLoader<'a> {
action_description: &str,
action_data: &serde_yaml_ng::Value,
) -> Result<Id> {
let full_path = actions_dir.join(workflow_file_path);
let pack_root = actions_dir.parent().ok_or_else(|| {
Error::validation("Actions directory must live inside a pack directory".to_string())
})?;
let full_path = resolve_pack_relative_path(pack_root, actions_dir, workflow_file_path)?;
if !full_path.exists() {
return Err(Error::validation(format!(
"Workflow file '{}' not found at '{}'",
@@ -1100,6 +1103,7 @@ impl<'a> PackComponentLoader<'a> {
)));
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- The workflow path is normalized and confined to the pack root before this local read.
let content = std::fs::read_to_string(&full_path).map_err(|e| {
Error::io(format!(
"Failed to read workflow file '{}': {}",
@@ -1649,11 +1653,60 @@ impl<'a> PackComponentLoader<'a> {
}
}
fn resolve_pack_relative_path(
pack_root: &Path,
base_dir: &Path,
relative_path: &str,
) -> Result<PathBuf> {
let canonical_pack_root = pack_root.canonicalize().map_err(|e| {
Error::io(format!(
"Failed to resolve pack root '{}': {}",
pack_root.display(),
e
))
})?;
let canonical_base_dir = base_dir.canonicalize().map_err(|e| {
Error::io(format!(
"Failed to resolve base directory '{}': {}",
base_dir.display(),
e
))
})?;
let canonical_candidate = normalize_path_from_base(&canonical_base_dir, relative_path);
if !canonical_candidate.starts_with(&canonical_pack_root) {
return Err(Error::validation(format!(
"Resolved path '{}' escapes pack root '{}'",
canonical_candidate.display(),
canonical_pack_root.display()
)));
}
Ok(canonical_candidate)
}
fn normalize_path_from_base(base: &Path, relative_path: &str) -> PathBuf {
let mut normalized = PathBuf::new();
for component in base.join(relative_path).components() {
match component {
std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
std::path::Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR.to_string()),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::Normal(part) => normalized.push(part),
}
}
normalized
}
/// Read all YAML files from a directory, returning `(filename, content)` pairs
/// sorted by filename for deterministic ordering.
fn read_yaml_files(dir: &Path) -> Result<Vec<(String, String)>> {
let mut files = Vec::new();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack loader scans pack-owned directories on disk after selecting the pack root.
let entries = std::fs::read_dir(dir)
.map_err(|e| Error::io(format!("Failed to read directory {}: {}", dir.display(), e)))?;
@@ -1676,6 +1729,7 @@ fn read_yaml_files(dir: &Path) -> Result<Vec<(String, String)>> {
let path = entry.path();
let filename = entry.file_name().to_string_lossy().to_string();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- YAML files are read only after being discovered under the selected pack directory.
let content = std::fs::read_to_string(&path)
.map_err(|e| Error::io(format!("Failed to read file {}: {}", path.display(), e)))?;

View File

@@ -292,6 +292,7 @@ fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
))
})?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack storage copy recursively processes validated local directories under the configured pack store.
for entry in fs::read_dir(src).map_err(|e| {
Error::io(format!(
"Failed to read source directory {}: {}",

View File

@@ -172,6 +172,7 @@ impl WorkflowLoader {
}
// Read and parse YAML
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow files come from previously discovered pack directories under packs_base_dir.
let content = fs::read_to_string(&file.path)
.await
.map_err(|e| Error::validation(format!("Failed to read workflow file: {}", e)))?;
@@ -292,6 +293,7 @@ impl WorkflowLoader {
pack_name: &str,
) -> Result<Vec<WorkflowFile>> {
let mut workflow_files = Vec::new();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow scanning only traverses pack workflow directories derived from packs_base_dir.
let mut entries = fs::read_dir(workflows_dir)
.await
.map_err(|e| Error::validation(format!("Failed to read workflows directory: {}", e)))?;

View File

@@ -182,6 +182,7 @@ mod tests {
#[test]
fn test_decode_valid_token() {
// Valid JWT with exp and iat claims
// nosemgrep: generic.secrets.security.detected-jwt-token.detected-jwt-token -- This is a non-secret test fixture with a dummy signature used only for JWT parsing tests.
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZW5zb3I6Y29yZS50aW1lciIsImlhdCI6MTcwNjM1NjQ5NiwiZXhwIjoxNzE0MTMyNDk2fQ.signature";
let manager = TokenRefreshManager::new(

View File

@@ -155,6 +155,7 @@ impl WorkflowLoader {
}
// Read and parse YAML
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Workflow files come from pack directories already discovered under packs_base_dir.
let content = fs::read_to_string(&file.path)
.await
.map_err(|e| Error::validation(format!("Failed to read workflow file: {}", e)))?;
@@ -265,6 +266,7 @@ impl WorkflowLoader {
pack_name: &str,
) -> Result<Vec<WorkflowFile>> {
let mut workflow_files = Vec::new();
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Executor workflow scanning only traverses pack-owned workflow directories.
let mut entries = fs::read_dir(workflows_dir)
.await
.map_err(|e| Error::validation(format!("Failed to read workflows directory: {}", e)))?;

View File

@@ -84,6 +84,7 @@ impl ArtifactManager {
// Store stdout
if !stdout.is_empty() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact filenames are fixed constants under an execution-scoped directory derived from the execution ID.
let stdout_path = exec_dir.join("stdout.log");
let mut file = fs::File::create(&stdout_path)
.await
@@ -117,6 +118,7 @@ impl ArtifactManager {
// Store stderr
if !stderr.is_empty() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact filenames are fixed constants under an execution-scoped directory derived from the execution ID.
let stderr_path = exec_dir.join("stderr.log");
let mut file = fs::File::create(&stderr_path)
.await
@@ -162,6 +164,7 @@ impl ArtifactManager {
.await
.map_err(|e| Error::Internal(format!("Failed to create execution directory: {}", e)))?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Result artifacts are written to a fixed filename inside the execution-scoped directory.
let result_path = exec_dir.join("result.json");
let result_json = serde_json::to_string_pretty(result)?;
@@ -209,6 +212,7 @@ impl ArtifactManager {
.await
.map_err(|e| Error::Internal(format!("Failed to create execution directory: {}", e)))?;
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Custom artifact paths are always rooted under the execution-scoped artifact directory.
let file_path = exec_dir.join(filename);
let mut file = fs::File::create(&file_path)
.await
@@ -246,6 +250,7 @@ impl ArtifactManager {
/// Read an artifact
pub async fn read_artifact(&self, artifact: &Artifact) -> Result<Vec<u8>> {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact reads use paths previously created by the artifact manager inside the configured artifact root.
fs::read(&artifact.path)
.await
.map_err(|e| Error::Internal(format!("Failed to read artifact: {}", e)))

View File

@@ -474,6 +474,7 @@ impl ActionExecutor {
let actions_dir = pack_dir.join("actions");
let actions_dir_exists = actions_dir.exists();
let actions_dir_contents: Vec<String> = if actions_dir_exists {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Diagnostic directory listing is confined to the action pack directory derived from pack_ref.
std::fs::read_dir(&actions_dir)
.map(|entries| {
entries
@@ -902,6 +903,7 @@ impl ActionExecutor {
// Check if stderr log exists and is non-empty from artifact storage
let stderr_path = exec_dir.join("stderr.log");
if stderr_path.exists() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Log paths are fixed artifact filenames inside the execution-scoped directory.
if let Ok(contents) = tokio::fs::read_to_string(&stderr_path).await {
if !contents.trim().is_empty() {
result_data["stderr_log"] =
@@ -913,6 +915,7 @@ impl ActionExecutor {
// Check if stdout log exists from artifact storage
let stdout_path = exec_dir.join("stdout.log");
if stdout_path.exists() {
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Log paths are fixed artifact filenames inside the execution-scoped directory.
if let Ok(contents) = tokio::fs::read_to_string(&stdout_path).await {
if !contents.is_empty() {
result_data["stdout"] = serde_json::json!(contents);

View File

@@ -171,6 +171,7 @@ impl WorkerService {
let registration = Arc::new(RwLock::new(WorkerRegistration::new(pool.clone(), &config)));
// Initialize artifact manager (legacy, for stdout/stderr log storage)
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Worker artifact/config directories come from trusted process configuration, not request data.
let artifact_base_dir = std::path::PathBuf::from(
config
.worker
@@ -184,6 +185,7 @@ impl WorkerService {
// Initialize artifacts directory for file-backed artifact storage (shared volume).
// Execution processes write artifact files here; the API serves them from the same path.
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact storage root is a trusted deployment configuration value.
let artifacts_dir = std::path::PathBuf::from(&config.artifacts_dir);
if let Err(e) = tokio::fs::create_dir_all(&artifacts_dir).await {
warn!(
@@ -198,7 +200,9 @@ impl WorkerService {
);
}
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack/runtime roots are trusted deployment configuration values.
let packs_base_dir = std::path::PathBuf::from(&config.packs_base_dir);
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Pack/runtime roots are trusted deployment configuration values.
let runtime_envs_dir = std::path::PathBuf::from(&config.runtime_envs_dir);
// Determine which runtimes to register based on configuration

View File

@@ -37,10 +37,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /auth/login) is passed through to the backend as-is.
location /auth/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -57,10 +56,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /api/packs?page=1) is passed through to the backend as-is.
location /api/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -78,10 +76,12 @@ server {
# e.g. /ws/events → /events
location /ws/ {
rewrite ^/ws/(.*) /$1 break;
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This WebSocket endpoint is intentionally public and must be reachable by clients.
proxy_pass $notifier_upstream;
# nosemgrep: generic.nginx.security.possible-h2c-smuggling.possible-nginx-h2c-smuggling -- Upgrade handling is intentionally restricted to a fixed 'websocket' value for the public notifier endpoint.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Upgrade websocket;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -17,17 +17,20 @@ Environment Variables:
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import psycopg2
import psycopg2.extras
from psycopg2 import sql
import yaml
# Default configuration
DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/attune"
DEFAULT_PACKS_DIR = "./packs"
SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def generate_label(name: str) -> str:
@@ -64,8 +67,13 @@ class PackLoader:
self.conn.autocommit = False
# Set search_path to use the correct schema
if not SCHEMA_RE.match(self.schema):
raise ValueError(f"Invalid schema name: {self.schema}")
cursor = self.conn.cursor()
cursor.execute(f"SET search_path TO {self.schema}, public")
# nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query -- This uses psycopg2.sql.Identifier for safe identifier composition after schema-name validation.
cursor.execute(
sql.SQL("SET search_path TO {}, public").format(sql.Identifier(self.schema))
)
cursor.close()
self.conn.commit()
@@ -81,6 +89,16 @@ class PackLoader:
with open(file_path, "r") as f:
return yaml.safe_load(f)
def resolve_pack_relative_path(self, base_dir: Path, relative_path: str) -> Path:
"""Resolve a pack-owned relative path and reject traversal outside the pack."""
candidate = (base_dir / relative_path).resolve()
pack_root = self.pack_dir.resolve()
if not candidate.is_relative_to(pack_root):
raise ValueError(
f"Resolved path '{candidate}' escapes pack root '{pack_root}'"
)
return candidate
def upsert_pack(self) -> int:
"""Create or update the pack"""
print("\n→ Loading pack metadata...")
@@ -412,7 +430,7 @@ class PackLoader:
The database ID of the workflow_definition row, or None on failure.
"""
actions_dir = self.pack_dir / "actions"
full_path = actions_dir / workflow_file_path
full_path = self.resolve_pack_relative_path(actions_dir, workflow_file_path)
if not full_path.exists():
print(f" ⚠ Workflow file '{workflow_file_path}' not found at {full_path}")
return None

View File

@@ -37,10 +37,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /auth/login) is passed through to the backend as-is.
location /auth/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -57,10 +56,9 @@ server {
# With variable proxy_pass (no URI path), the full original request URI
# (e.g. /api/packs?page=1) is passed through to the backend as-is.
location /api/ {
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This is an intentionally public reverse-proxy route; 'internal' would break external API access.
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -78,10 +76,12 @@ server {
# e.g. /ws/events → /events
location /ws/ {
rewrite ^/ws/(.*) /$1 break;
# nosemgrep: generic.nginx.security.missing-internal.missing-internal -- This WebSocket endpoint is intentionally public and must be reachable by clients.
proxy_pass $notifier_upstream;
# nosemgrep: generic.nginx.security.possible-h2c-smuggling.possible-nginx-h2c-smuggling -- Upgrade handling is intentionally restricted to a fixed 'websocket' value for the public notifier endpoint.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Upgrade websocket;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -17,17 +17,20 @@ Environment Variables:
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import psycopg2
import psycopg2.extras
from psycopg2 import sql
import yaml
# Default configuration
DEFAULT_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/attune"
DEFAULT_PACKS_DIR = "./packs"
SCHEMA_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def generate_label(name: str) -> str:
@@ -64,8 +67,13 @@ class PackLoader:
self.conn.autocommit = False
# Set search_path to use the correct schema
if not SCHEMA_RE.match(self.schema):
raise ValueError(f"Invalid schema name: {self.schema}")
cursor = self.conn.cursor()
cursor.execute(f"SET search_path TO {self.schema}, public")
# nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query -- This uses psycopg2.sql.Identifier for safe identifier composition after schema-name validation.
cursor.execute(
sql.SQL("SET search_path TO {}, public").format(sql.Identifier(self.schema))
)
cursor.close()
self.conn.commit()
@@ -81,6 +89,16 @@ class PackLoader:
with open(file_path, "r") as f:
return yaml.safe_load(f)
def resolve_pack_relative_path(self, base_dir: Path, relative_path: str) -> Path:
"""Resolve a pack-owned relative path and reject traversal outside the pack."""
candidate = (base_dir / relative_path).resolve()
pack_root = self.pack_dir.resolve()
if not candidate.is_relative_to(pack_root):
raise ValueError(
f"Resolved path '{candidate}' escapes pack root '{pack_root}'"
)
return candidate
def upsert_pack(self) -> int:
"""Create or update the pack"""
print("\n→ Loading pack metadata...")
@@ -412,7 +430,7 @@ class PackLoader:
The database ID of the workflow_definition row, or None on failure.
"""
actions_dir = self.pack_dir / "actions"
full_path = actions_dir / workflow_file_path
full_path = self.resolve_pack_relative_path(actions_dir, workflow_file_path)
if not full_path.exists():
print(f" ⚠ Workflow file '{workflow_file_path}' not found at {full_path}")
return None

118
semgrep-findings.md Normal file
View File

@@ -0,0 +1,118 @@
┌──────────────────┐
│ 14 Code Findings │
└──────────────────┘
 crates/cli/src/commands/pack.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
861┆ std::fs::read_to_string(&pack_yaml_path).context("Failed to read pack.yaml")?;
 crates/cli/src/commands/workflow.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
188┆ std::fs::read_to_string(action_path).context("Failed to read action YAML file")?;
⋮┆----------------------------------------
223┆ std::fs::read_to_string(&workflow_path).context("Failed to read workflow YAML file")?;
 crates/cli/src/wait.rs
❯❯❱ javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
❰❰ Blocking ❱❱
Insecure WebSocket Detected. WebSocket Secure (wss) should be used for all WebSocket connections.
Details: https://sg.run/GWyz
483┆ /// - `http://api.example.com:9000``ws://api.example.com:8081`
⋮┆----------------------------------------
525┆ Some("ws://api.example.com:8081".to_string())
⋮┆----------------------------------------
529┆ Some("ws://10.0.0.5:8081".to_string())
 crates/common/src/pack_environment.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
694┆ Path::new(env_path),
⋮┆----------------------------------------
812┆ return Ok(PathBuf::from(validated).exists());
 crates/common/src/pack_registry/installer.rs
❯❯❱ rust.actix.ssrf.reqwest-taint.reqwest-taint
❰❰ Blocking ❱❱
Untrusted input might be used to build an HTTP request, which can lead to a Server-side request
forgery (SSRF) vulnerability. SSRF allows an attacker to send crafted requests from the server side
to other internal or external systems. SSRF can lead to unauthorized access to sensitive data and,
in some cases, allow the attacker to control applications or systems that trust the vulnerable
service. To prevent this vulnerability, avoid allowing user input to craft the base request.
Instead, treat it as part of the path or query parameter and encode it appropriately. When user
input is necessary to prepare the HTTP request, perform strict input validation. Additionally,
whenever possible, use allowlists to only interact with expected, trusted domains.
Details: https://sg.run/6D5Y
428┆ .get(parsed_url.clone())
 crates/worker/src/artifacts.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
89┆ let mut file = fs::File::create(&stdout_path)
⋮┆----------------------------------------
123┆ let mut file = fs::File::create(&stderr_path)
⋮┆----------------------------------------
171┆ let mut file = fs::File::create(&result_path)
⋮┆----------------------------------------
217┆ let mut file = fs::File::create(&file_path)
 crates/worker/src/service.rs
❯❯❱ rust.actix.path-traversal.tainted-path.tainted-path
❰❰ Blocking ❱❱
The application builds a file path from potentially untrusted data, which can lead to a path
traversal vulnerability. An attacker can manipulate the path which the application uses to access
files. If the application does not validate user input and sanitize file paths, sensitive files such
as configuration or user data can be accessed, potentially creating or overwriting files. To prevent
this vulnerability, validate and sanitize any input that is used to create references to file paths.
Also, enforce strict file access controls. For example, choose privileges allowing public-facing
applications to access only the required files.
Details: https://sg.run/YWX5
176┆ config
177┆ .worker
178┆ .as_ref()
179┆ .and_then(|w| w.name.clone())
180┆ .map(|name| format!("/tmp/attune/artifacts/{}", name))
181┆ .unwrap_or_else(|| "/tmp/attune/artifacts".to_string()),