Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [main]
pull_request:
branches: [main]
# M-6: Weekly scheduled security audit
schedule:
- cron: '0 0 * * 0' # Every Sunday at midnight UTC

permissions:
contents: read
Expand Down Expand Up @@ -65,9 +68,24 @@ jobs:
- name: Install cargo-audit
working-directory: codebase
run: cargo install cargo-audit --locked --version 0.22.1
- name: Install cargo-deny
run: cargo install cargo-deny --locked --version 0.14.24
- name: Audit dependencies
working-directory: codebase
run: cargo audit --file Cargo.lock
# M-6: cargo-deny for supply chain security
- name: Deny check (licenses, advisories, bans)
working-directory: codebase
run: cargo deny check
# M-7: Verify OpenSSL is not in dependency tree
- name: Verify no OpenSSL dependency
working-directory: codebase
run: |
if cargo tree -i openssl 2>/dev/null | grep -q openssl; then
echo "ERROR: OpenSSL found in dependency tree"
exit 1
fi
echo "PASS: No OpenSSL in dependency tree"

e2e:
runs-on: ubuntu-latest
Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,46 @@
- **WASM gate**: Renamed feature `wasm` → `wasm-unstable` in `Cargo.toml` and
all `#[cfg]` sites. The WASM backend is gated until C-1 (allocator OOB) and
C-2 (unconstrained WASI imports) are resolved. See `docs/WASM.md`.

#### Wave 2 — CRITICAL WASM Hardening (2026-04-23)

- **C-1** (`compiler/src/backend/wasm.rs`): Harden WASM allocator to prevent OOB
access: emit `memory.grow` with trap-on-fail in `__alloc`, reserve fixed
static-data region above `heap_start`, and add runtime bounds check on every
`i32.store` with attacker-derived offsets. No `-1` pointer ever escapes.

- **C-2** (`compiler/src/backend/wasm.rs`): Drive WASI imports from effect row.
Modules without `!{FS}` emit no `fd_write`; without `!{IO}` emit no
`proc_exit`. Pure modules now compile to zero imports.

#### Wave 3 — Supply Chain & Build Hardening (2026-04-23)

- **H-1** (`build-system/src/manifest.rs`, `resolver.rs`, `lockfile.rs`,
`commands/add.rs`): Require commit SHA (`rev`) for all git dependencies.
Lockfile now records `archive_sha256` (SHA256 of downloaded archive bytes,
pre-extraction). Dependency syntax: `dep = { git = "...", rev = "<40-char-sha>" }`.
CLI: `gradient add https://github.com/user/repo.git#<sha>`.

- **H-2** (`build-system/src/resolver.rs`, `commands/fetch.rs`): Harden ZIP
extractor: reject symlink entries (via `unix_mode()` S_IFLNK check), reject
backslash separators and absolute Windows paths (`C:\`), canonicalize output
paths and verify they stay within destination directory.

- **M-1** (`build-system/src/manifest.rs`): Validate `[package].name` against
`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`. Reject flag-shaped names starting with `-`.

- **M-5** (`compiler/src/typechecker/env.rs`, `runtime/gradient_runtime.c`):
Replace removed `system()` builtin with `spawn(program, args)` — executes
programs directly via `posix_spawnp()` or `fork()`+`execvp()` without shell
invocation, eliminating shell injection vulnerabilities.

- **M-6** (`deny.toml`, `.github/workflows/ci.yml`): Add `cargo-deny` to CI
for license compliance, security advisories, and banned crate checks.
Add weekly `cargo audit` scheduled workflow. Pin Cranelift dependencies.

- **M-7** (`codebase/build-system/Cargo.toml`): Upgrade reqwest 0.11 → 0.12
with `default-features = false` and `rustls-tls` feature. Drop OpenSSL
transitive dependency.

- **L-2** (`scripts/install.sh`): Generate `SHA256SUMS` for release binaries
during install. Print installed binary SHA256 hashes post-install.
2 changes: 1 addition & 1 deletion codebase/build-system/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ serde = { version = "1", features = ["derive"] }
sha2 = "0.10"
hex = "0.4"
gradient-compiler = { path = "../compiler" }
reqwest = { version = "0.11", features = ["json"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
semver = "1.0"
tokio = { version = "1.0", features = ["rt-multi-thread"] }
zip = "0.6"
Expand Down
20 changes: 16 additions & 4 deletions codebase/build-system/src/commands/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,20 @@ fn add_path_dependency(project: &Project, dep_path: &str) {
}

/// Add a git-based dependency.
/// H-1: Requires a commit SHA (rev) for security. The URL may include #rev suffix.
fn add_git_dependency(project: &Project, url: &str) {
// H-1: Parse optional rev from URL suffix (e.g., https://.../repo.git#abc123)
let (clean_url, rev) = if let Some((base, rev_part)) = url.split_once('#') {
(base.to_string(), Some(rev_part.to_string()))
} else {
eprintln!("Error: Git dependency requires a commit SHA.");
eprintln!("Usage: gradient add https://github.com/user/repo.git#<40-char-sha>");
eprintln!(" gradient add https://github.com/user/repo.git#v1.0.0 (tag, less secure)");
process::exit(1);
};

// For now, extract name from URL (last component without .git)
let dep_name = extract_name_from_git_url(url);
let dep_name = extract_name_from_git_url(&clean_url);

// Check if already a dependency
if project.manifest.dependencies.contains_key(&dep_name) {
Expand All @@ -161,15 +172,16 @@ fn add_git_dependency(project: &Project, url: &str) {

// Add the dependency to our manifest
let manifest_path = project.root.join("gradient.toml");
if let Err(e) = manifest::add_git_dependency(&manifest_path, &dep_name, url) {
let rev_str = rev.as_deref().unwrap_or("");
if let Err(e) = manifest::add_git_dependency(&manifest_path, &dep_name, &clean_url, rev_str) {
eprintln!("Error: Failed to update `gradient.toml`: {}", e);
process::exit(1);
}

// Record in lockfile (no checksum available for git deps until fetched)
update_lockfile(&project.root, LockedPackage::with_git(&dep_name, "0.0.0", url, None, "sha256:"));
update_lockfile(&project.root, LockedPackage::with_git(&dep_name, "0.0.0", &clean_url, rev.as_deref(), "sha256:"));

println!("Added dependency '{}' (git: {})", dep_name, url);
println!("Added dependency '{}' (git: {}, rev: {})", dep_name, clean_url, rev_str);
println!("Updated gradient.lock");
}

Expand Down
29 changes: 29 additions & 0 deletions codebase/build-system/src/commands/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ fn extract_zip(data: &[u8], dest: &Path) -> Result<(), String> {
continue;
}

// H-2: Security hardening - reject symlinks, canonicalize paths
// In zip crate 0.6, symlinks are detected via unix_mode()
if let Some(mode) = file.unix_mode() {
if (mode & 0o170000) == 0o120000 { // S_IFLNK
return Err(format!(
"Invalid ZIP entry: symlinks are not allowed ('{}')",
name
));
}
}

// Strip top-level directory (GitHub zipballs have format: owner-repo-tag/)
let path_parts: Vec<&str> = name.split('/').collect();
if path_parts.len() < 2 {
Expand All @@ -259,8 +270,26 @@ fn extract_zip(data: &[u8], dest: &Path) -> Result<(), String> {
));
}

// H-2: Additional hardening - reject backslash separators and absolute Windows paths
if stripped_name.contains('\\') || stripped_name.starts_with("C:") {
return Err(format!(
"Invalid ZIP entry: illegal path component in '{}'",
name
));
}

let out_path = dest.join(&stripped_name);

// H-2: Canonicalize and verify the output path is within destination
let canonical_out = out_path.canonicalize().unwrap_or_else(|_| out_path.clone());
let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf());
if !canonical_out.starts_with(&canonical_dest) {
return Err(format!(
"Invalid ZIP entry: path escapes destination directory in '{}'",
name
));
}

if file.is_dir() {
std::fs::create_dir_all(&out_path)
.map_err(|e| format!("Failed to create directory: {}", e))?;
Expand Down
31 changes: 30 additions & 1 deletion codebase/build-system/src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ pub struct LockedPackage {
pub version: String,
pub source: String,
pub checksum: String,
/// H-1: SHA256 of downloaded archive bytes (pre-extraction)
#[serde(skip_serializing_if = "Option::is_none")]
pub archive_sha256: Option<String>,
}

impl LockedPackage {
Expand All @@ -170,6 +173,7 @@ impl LockedPackage {
version: version.to_string(),
source: format!("path:{}", path),
checksum: checksum.to_string(),
archive_sha256: None,
}
}

Expand All @@ -189,6 +193,7 @@ impl LockedPackage {
None => format!("git:{}", url),
},
checksum: checksum.to_string(),
archive_sha256: None,
}
}

Expand All @@ -205,6 +210,25 @@ impl LockedPackage {
version: version.to_string(),
source: format!("{}:{}#{}", registry, full_name, version),
checksum: checksum.to_string(),
archive_sha256: None,
}
}

/// H-1: Create a new locked package with archive SHA256
pub fn with_registry_archive(
name: &str,
version: &str,
registry: &str,
full_name: &str,
checksum: &str,
archive_sha256: &str,
) -> Self {
Self {
name: name.to_string(),
version: version.to_string(),
source: format!("{}:{}#{}", registry, full_name, version),
checksum: checksum.to_string(),
archive_sha256: Some(archive_sha256.to_string()),
}
}
}
Expand Down Expand Up @@ -384,12 +408,14 @@ mod tests {
version: "0.1.0".to_string(),
source: "path:../math-utils".to_string(),
checksum: "sha256:abc123".to_string(),
archive_sha256: None,
});
lockfile.add_package(LockedPackage {
name: "logging".to_string(),
version: "0.2.0".to_string(),
source: "path:../logging".to_string(),
checksum: "sha256:def456".to_string(),
archive_sha256: None,
});

let toml_str = lockfile.to_toml().unwrap();
Expand Down Expand Up @@ -484,12 +510,14 @@ checksum = "sha256:def789"
version: "0.1.0".to_string(),
source: "path:../dep".to_string(),
checksum: "sha256:old".to_string(),
archive_sha256: None,
});
lockfile.add_package(LockedPackage {
name: "dep".to_string(),
version: "0.2.0".to_string(),
source: "path:../dep".to_string(),
checksum: "sha256:new".to_string(),
archive_sha256: None,
});

assert_eq!(lockfile.packages.len(), 1);
Expand Down Expand Up @@ -614,7 +642,8 @@ checksum = "sha256:def789"
name: "dep-lib".to_string(),
version: "0.1.0".to_string(),
source: "path:dep-lib".to_string(),
checksum: checksum.clone(),
checksum,
archive_sha256: None,
});

// Validate: should pass
Expand Down
Loading
Loading