diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml index b5631d7..9e815c6 100644 --- a/.github/workflows/dependabot-automerge.yaml +++ b/.github/workflows/dependabot-automerge.yaml @@ -2,14 +2,15 @@ name: Dependabot auto-merge on: pull_request -permissions: - contents: write - pull-requests: write +permissions: read-all jobs: auto-merge: if: github.actor == 'dependabot[bot]' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Fetch Dependabot metadata id: meta diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 36d4642..cea16b6 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -25,7 +25,7 @@ jobs: - name: Lint working-directory: node run: | - npm install + npm ci npx biome check test: @@ -45,7 +45,7 @@ jobs: - name: Install and test working-directory: node run: | - npm install + npm ci npx napi build --platform --release node __test__/index.test.mjs @@ -89,7 +89,7 @@ jobs: - name: Install dependencies working-directory: node - run: npm install + run: npm ci - name: Build (native) if: ${{ !matrix.settings.docker }} @@ -142,7 +142,7 @@ jobs: - name: Publish working-directory: node run: | - npm install + npm ci npx napi prepublish -t npm --skip-gh-release npm publish --access public --provenance env: diff --git a/Cargo.lock b/Cargo.lock index 6e8a531..b3a8416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1383,6 +1383,7 @@ dependencies = [ "sha2 0.11.0", "tempfile", "ureq", + "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fcea02a..647479a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ tempfile = "3.27.0" clap_complete = "4.6.0" fs2 = "0.4.3" pyo3 = { version = "0.28", features = ["extension-module"], optional = true } +walkdir = "2.5.0" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/README.md b/README.md index f19aa11..ee32420 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ murk restore | `murk edit [KEY] [--scoped]` | Edit secrets in `$EDITOR` | | `murk ls` | List key names | | `murk export` | Print all secrets as shell exports | -| `murk exec CMD...` | Run a command with secrets in the environment | +| `murk exec CMD...` | Run a command with secrets in the environment (`--only`, `--clean-env`) | | `murk diff [REF]` | Show secret changes since a git ref | | `murk import [FILE]` | Import secrets from a .env file | | `murk describe KEY "..."` | Set description for a key | @@ -211,6 +211,7 @@ murk restore | `murk circle` | List recipients | | `murk circle authorize PUBKEY [--name NAME]` | Add a recipient (age key, `ssh:path`, or `github:user`) | | `murk circle revoke RECIPIENT` | Remove a recipient | +| `murk scan [PATHS...]` | Scan files for leaked secret values | | `murk skeleton` | Export schema-only vault with no secrets or recipients | | `murk restore` | Recover key from BIP39 phrase | | `murk recover` | Show recovery phrase for current key | diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..ce668b8 --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,69 @@ +# Quick start for teammates + +You've been invited to a project that uses murk for secrets. Here's how to get set up. + +## 1. Install murk + +```bash +brew tap iicky/murk && brew install murk +``` + +Or: `cargo install murk-cli`, `pip install murk`, or download from [GitHub Releases](https://github.com/iicky/murk/releases). + +## 2. Generate your key + +```bash +murk init +``` + +This creates your keypair and prints 24 recovery words. **Write them down.** If you lose your key, these words are the only way to recover it. + +Your key is stored in `~/.config/murk/keys/`, not in the repo. + +## 3. Share your public key + +```bash +murk recover +``` + +Wait — that's for recovery. To get your public key for authorization: + +```bash +cat ~/.config/murk/keys/*.pub 2>/dev/null || murk info 2>/dev/null +``` + +Actually, the easiest path: ask the team lead to run: + +```bash +murk circle authorize github:YOUR_USERNAME +``` + +This fetches your SSH public keys from GitHub and adds you to the vault. No manual key exchange needed. + +## 4. Pull and use secrets + +Once authorized: + +```bash +git pull # get the updated .murk file +murk ls # see what secrets are available +murk get DATABASE_URL # print one secret +murk export # print all as shell exports +murk exec -- ./my-script.sh # run a command with secrets in env +``` + +## 5. Set up direnv (optional) + +```bash +murk env +direnv allow +``` + +Now secrets are automatically loaded when you `cd` into the project. + +## What to know + +- **Key names are public.** Anyone with repo access can see what secrets exist (e.g. `STRIPE_KEY`). Only values are encrypted. +- **Your key is your identity.** Don't share it. Don't paste it into chat. Don't commit it. +- **Recovery phrase = your key.** If someone has your 24 words, they have your key. +- **Revocation doesn't erase history.** If you leave the team, your access to old git history remains. The team should rotate secrets after revoking you. diff --git a/src/export.rs b/src/export.rs index d5caf30..a2debf9 100644 --- a/src/export.rs +++ b/src/export.rs @@ -215,6 +215,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -235,6 +236,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -257,6 +259,7 @@ mod tests { description: String::new(), example: None, tags: vec!["db".into()], + ..Default::default() }, ); vault.schema.insert( @@ -265,6 +268,7 @@ mod tests { description: String::new(), example: None, tags: vec!["api".into()], + ..Default::default() }, ); @@ -286,6 +290,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -435,6 +440,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -455,6 +461,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -474,6 +481,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -496,6 +504,7 @@ mod tests { description: String::new(), example: None, tags: vec!["db".into()], + ..Default::default() }, ); vault.schema.insert( @@ -504,6 +513,7 @@ mod tests { description: String::new(), example: None, tags: vec!["api".into()], + ..Default::default() }, ); @@ -526,6 +536,7 @@ mod tests { description: "orphan key".into(), example: None, tags: vec!["db".into()], + ..Default::default() }, ); vault.schema.insert( @@ -534,6 +545,7 @@ mod tests { description: "has a value".into(), example: None, tags: vec!["db".into()], + ..Default::default() }, ); @@ -558,6 +570,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); diff --git a/src/info.rs b/src/info.rs index 5592c21..98fbeaf 100644 --- a/src/info.rs +++ b/src/info.rs @@ -241,6 +241,7 @@ mod tests { description: "database url".into(), example: Some("postgres://...".into()), tags: vec!["db".into()], + ..Default::default() }, ); let bytes = test_vault_bytes(schema); @@ -265,6 +266,7 @@ mod tests { description: "db".into(), example: None, tags: vec!["db".into()], + ..Default::default() }, ); schema.insert( @@ -273,6 +275,7 @@ mod tests { description: "api".into(), example: None, tags: vec!["api".into()], + ..Default::default() }, ); let bytes = test_vault_bytes(schema); diff --git a/src/main.rs b/src/main.rs index 6dc53ec..bfe5da1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,9 +209,15 @@ enum Command { /// Run a command with secrets injected as environment variables #[command(trailing_var_arg = true)] Exec { + /// Only inject these specific keys (repeatable) + #[arg(long)] + only: Vec, /// Filter by tag (repeatable) #[arg(long)] tag: Vec, + /// Strip inherited environment (only murk secrets + PATH) + #[arg(long)] + clean_env: bool, /// Vault filename #[arg(long, env = "MURK_VAULT", default_value = ".murk")] vault: String, @@ -311,6 +317,15 @@ enum Command { vault: String, }, + /// Scan files for leaked secret values + Scan { + /// Files or directories to scan (defaults to current directory) + paths: Vec, + /// Vault filename + #[arg(long, env = "MURK_VAULT", default_value = ".murk")] + vault: String, + }, + /// Generate shell completions Completion { /// Shell to generate completions for @@ -1149,11 +1164,7 @@ fn cmd_edit(key: Option<&str>, scoped: bool, vault_path: &str) { vault .schema .entry(k.clone()) - .or_insert_with(|| murk_cli::types::SchemaEntry { - description: String::new(), - example: None, - tags: vec![], - }); + .or_insert_with(murk_cli::types::SchemaEntry::default); added += 1; } } @@ -1209,35 +1220,66 @@ fn overwrite_and_remove(path: &std::path::Path) { let _ = std::fs::remove_file(path); } -fn cmd_exec(command: &[String], tags: &[String], vault_path: &str) { +fn cmd_exec( + command: &[String], + only: &[String], + tags: &[String], + clean_env: bool, + vault_path: &str, +) { let (vault, murk, identity) = load_vault(vault_path); let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); - let secrets = murk_cli::resolve_secrets(&vault, &murk, &pubkey, tags); + let mut secrets = murk_cli::resolve_secrets(&vault, &murk, &pubkey, tags); + + // Filter to specific keys if --only is provided. + if !only.is_empty() { + secrets.retain(|k, _| only.contains(k)); + for key in only { + if !secrets.contains_key(key) { + die(&format_args!("key not found: {key}"), 1); + } + } + } let program = &command[0]; let args = &command[1..]; + let build_cmd = |cmd: &mut process::Command| { + if clean_env { + cmd.env_clear(); + // Preserve essential vars for the subprocess to function. + if let Ok(path) = std::env::var("PATH") { + cmd.env("PATH", path); + } + if let Ok(home) = std::env::var("HOME") { + cmd.env("HOME", home); + } + if let Ok(term) = std::env::var("TERM") { + cmd.env("TERM", term); + } + } else { + cmd.env_remove("MURK_KEY"); + cmd.env_remove("MURK_KEY_FILE"); + } + cmd.envs(&secrets); + }; + #[cfg(unix)] { use std::os::unix::process::CommandExt; - let err = process::Command::new(program) - .args(args) - .env_remove("MURK_KEY") - .env_remove("MURK_KEY_FILE") - .envs(&secrets) - .exec(); + let mut cmd = process::Command::new(program); + cmd.args(args); + build_cmd(&mut cmd); + let err = cmd.exec(); die(&err, 1); } #[cfg(not(unix))] { - let status = process::Command::new(program) - .args(args) - .env_remove("MURK_KEY") - .env_remove("MURK_KEY_FILE") - .envs(&secrets) - .status() - .unwrap_or_else(|e| die(&e, 1)); + let mut cmd = process::Command::new(program); + cmd.args(args); + build_cmd(&mut cmd); + let status = cmd.status().unwrap_or_else(|e| die(&e, 1)); process::exit(status.code().unwrap_or(1)); } } @@ -1924,6 +1966,83 @@ fn cmd_info(tags: &[String], json: bool, vault_path: &str) { } } +fn cmd_scan(paths: &[String], vault_path: &str) { + let (vault, murk, identity) = load_vault(vault_path); + let pubkey = identity.pubkey_string().unwrap_or_else(|e| die(&e, 1)); + let secrets = murk_cli::resolve_secrets(&vault, &murk, &pubkey, &[]); + + if secrets.is_empty() { + eprintln!("{} no secrets to scan for", "ok".green().bold()); + return; + } + + let scan_paths: Vec<&str> = if paths.is_empty() { + vec!["."] + } else { + paths.iter().map(String::as_str).collect() + }; + + let mut found = 0; + + for base in &scan_paths { + let walker = walkdir::WalkDir::new(base) + .follow_links(false) + .into_iter() + .filter_entry(|e| { + let name = e.file_name().to_string_lossy(); + // Skip hidden dirs, .git, target, node_modules, .murk files. + if e.file_type().is_dir() { + return !name.starts_with('.') && name != "target" && name != "node_modules"; + } + true + }); + + for entry in walker.flatten() { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + + // Skip binary-looking files and the vault itself. + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name.ends_with(".murk") || name.ends_with(".lock") { + continue; + } + + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, // skip binary/unreadable files + }; + + for (key, value) in &secrets { + if value.len() < 8 { + continue; // skip short values to avoid false positives + } + if content.contains(value.as_str()) { + eprintln!( + "{} {} leaked in {}", + "warn".yellow().bold(), + key.bold(), + path.display() + ); + found += 1; + } + } + } + } + + if found == 0 { + eprintln!("{} no leaked secrets found", "ok".green().bold()); + } else { + eprintln!( + "{} {found} leaked secret{} found", + "error".red().bold(), + if found == 1 { "" } else { "s" } + ); + process::exit(1); + } +} + fn cmd_skeleton(output: Option<&str>, vault_path: &str) { let vault = murk_cli::vault::read(Path::new(vault_path)).unwrap_or_else(|e| die(&e, 1)); @@ -2005,10 +2124,12 @@ fn main() { Command::Export { tag, json, vault } => cmd_export(&tag, json, &vault), Command::Edit { key, scoped, vault } => cmd_edit(key.as_deref(), scoped, &vault), Command::Exec { + only, tag, + clean_env, vault, command, - } => cmd_exec(&command, &tag, &vault), + } => cmd_exec(&command, &only, &tag, clean_env, &vault), Command::Authorize { pubkey, name, @@ -2044,6 +2165,7 @@ fn main() { Command::SetupMergeDriver => cmd_setup_merge_driver(), Command::Verify { vault } => cmd_verify(&vault), Command::Skeleton { output, vault } => cmd_skeleton(output.as_deref(), &vault), + Command::Scan { paths, vault } => cmd_scan(&paths, &vault), Command::Completion { shell } => cmd_completion(shell), } } diff --git a/src/merge.rs b/src/merge.rs index e18fc6b..1b57036 100644 --- a/src/merge.rs +++ b/src/merge.rs @@ -655,6 +655,7 @@ mod tests { description: "database url".into(), example: None, tags: vec![], + ..Default::default() }, ); @@ -709,6 +710,7 @@ mod tests { description: "api key".into(), example: None, tags: vec![], + ..Default::default() }, ); @@ -941,6 +943,7 @@ mod tests { description: "api".into(), example: None, tags: vec![], + ..Default::default() }, ); let mut theirs = base.clone(); @@ -950,6 +953,7 @@ mod tests { description: "stripe".into(), example: None, tags: vec![], + ..Default::default() }, ); diff --git a/src/recipients.rs b/src/recipients.rs index ddb7b08..2d24131 100644 --- a/src/recipients.rs +++ b/src/recipients.rs @@ -337,6 +337,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); vault.secrets.insert( @@ -441,6 +442,7 @@ mod tests { description: "db".into(), example: None, tags: vec![], + ..Default::default() }, ); vault.schema.insert( @@ -449,6 +451,7 @@ mod tests { description: "api".into(), example: None, tags: vec![], + ..Default::default() }, ); vault.secrets.insert( diff --git a/src/secrets.rs b/src/secrets.rs index 6434062..19fbde2 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -1,6 +1,6 @@ //! Secret CRUD operations on the in-memory `Murk` state. -use crate::{crypto, types}; +use crate::{crypto, now_utc, types}; /// Add or update a secret in the working state. /// If `scoped` is true, stores in scoped (encrypted to self only). @@ -27,6 +27,7 @@ pub fn add_secret( let is_new = !vault.schema.contains_key(key); + let now = now_utc(); if let Some(entry) = vault.schema.get_mut(key) { if let Some(d) = desc { entry.description = d.into(); @@ -38,6 +39,7 @@ pub fn add_secret( } } } + entry.updated = Some(now); } else { vault.schema.insert( key.into(), @@ -45,6 +47,8 @@ pub fn add_secret( description: desc.unwrap_or("").into(), example: None, tags: tags.to_vec(), + created: Some(now.clone()), + updated: Some(now), }, ); } @@ -86,17 +90,22 @@ pub fn import_secrets( murk: &mut types::Murk, pairs: &[(String, String)], ) -> Vec { + let now = now_utc(); let mut imported = Vec::new(); for (key, value) in pairs { murk.values.insert(key.clone(), value.clone()); - if !vault.schema.contains_key(key.as_str()) { + if let Some(entry) = vault.schema.get_mut(key.as_str()) { + entry.updated = Some(now.clone()); + } else { vault.schema.insert( key.clone(), types::SchemaEntry { description: String::new(), example: None, tags: vec![], + created: Some(now.clone()), + updated: Some(now.clone()), }, ); } @@ -121,12 +130,15 @@ pub fn describe_key( entry.tags = tags.to_vec(); } } else { + let now = now_utc(); vault.schema.insert( key.into(), types::SchemaEntry { description: description.into(), example: example.map(Into::into), tags: tags.to_vec(), + created: Some(now.clone()), + updated: Some(now), }, ); } @@ -272,6 +284,7 @@ mod tests { description: "desc".into(), example: None, tags: vec![], + ..Default::default() }, ); let mut murk = empty_murk(); @@ -321,6 +334,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); vault.schema.insert( @@ -329,6 +343,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -345,6 +360,7 @@ mod tests { description: String::new(), example: None, tags: vec!["db".into()], + ..Default::default() }, ); vault.schema.insert( @@ -353,6 +369,7 @@ mod tests { description: String::new(), example: None, tags: vec!["api".into()], + ..Default::default() }, ); vault.schema.insert( @@ -361,6 +378,7 @@ mod tests { description: String::new(), example: None, tags: vec![], + ..Default::default() }, ); @@ -377,6 +395,7 @@ mod tests { description: String::new(), example: None, tags: vec!["db".into()], + ..Default::default() }, ); @@ -409,6 +428,7 @@ mod tests { description: "old".into(), example: Some("old_ex".into()), tags: vec!["old_tag".into()], + ..Default::default() }, ); @@ -428,6 +448,7 @@ mod tests { description: "old".into(), example: None, tags: vec!["keep".into()], + ..Default::default() }, ); @@ -519,6 +540,7 @@ mod tests { description: "existing desc".into(), example: Some("ex".into()), tags: vec!["tag".into()], + ..Default::default() }, ); let mut murk = empty_murk(); diff --git a/src/types.rs b/src/types.rs index aafec8f..e830430 100644 --- a/src/types.rs +++ b/src/types.rs @@ -30,13 +30,19 @@ pub struct Vault { pub meta: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SchemaEntry { pub description: String, #[serde(skip_serializing_if = "Option::is_none")] pub example: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, + /// When the key was first added. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created: Option, + /// When the value was last updated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/vault.rs b/src/vault.rs index 5777ee9..de42f35 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -152,6 +152,7 @@ mod tests { description: "postgres connection string".into(), example: Some("postgres://user:pass@host/db".into()), tags: vec![], + ..Default::default() }, ); @@ -206,6 +207,7 @@ mod tests { description: "last".into(), example: None, tags: vec![], + ..Default::default() }, ); vault.schema.insert( @@ -214,6 +216,7 @@ mod tests { description: "first".into(), example: None, tags: vec![], + ..Default::default() }, );