diff --git a/Cargo.lock b/Cargo.lock index ea5d3e7..fa38df1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "hound" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "clap", @@ -1104,7 +1104,7 @@ dependencies = [ [[package]] name = "hound-api" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "serde", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "hound-defs" -version = "0.1.5" +version = "0.1.6" dependencies = [ "ed25519-dalek", "serde", @@ -1124,7 +1124,7 @@ dependencies = [ [[package]] name = "hound-mcp" -version = "0.1.5" +version = "0.1.6" dependencies = [ "hound-api", "hound-supply", @@ -1134,7 +1134,7 @@ dependencies = [ [[package]] name = "hound-supply" -version = "0.1.5" +version = "0.1.6" dependencies = [ "hound-defs", "serde", @@ -1143,7 +1143,7 @@ dependencies = [ [[package]] name = "houndd" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 5eb4b44..3ba925d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.5" +version = "0.1.6" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus.git" diff --git a/assets/icons/hound-symbolic.svg b/assets/icons/hound-symbolic.svg new file mode 100644 index 0000000..e3749fa --- /dev/null +++ b/assets/icons/hound-symbolic.svg @@ -0,0 +1,11 @@ + + + + hound-symbolic + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/crates/hound-supply/src/hygiene.rs b/crates/hound-supply/src/hygiene.rs new file mode 100644 index 0000000..916a233 --- /dev/null +++ b/crates/hound-supply/src/hygiene.rs @@ -0,0 +1,646 @@ +//! Security hygiene: credentials, git, and CI. +//! +//! Malware scanning asks "is this file hostile?". This module asks the +//! question that actually loses people their accounts: **what has already +//! been exposed, and what is about to be?** +//! +//! The threats here are not exotic. A `.env` committed to a public repository +//! is the single most common way a working key reaches an attacker, and it +//! happens most often to people moving fast with a coding assistant — the +//! assistant writes the file, the assistant runs `git add -A`, and nothing in +//! between says no. So these checks lean toward the mundane and the certain +//! rather than the clever and the probabilistic. +//! +//! Two rules govern everything below. +//! +//! **A finding must be actionable.** "High entropy string" is not a finding, +//! it is a coin flip that a person then has to adjudicate. Every detector +//! here either recognises a specific credential format whose issuer is known, +//! or reports a structural fact — this file is tracked by git, this workflow +//! checks out untrusted code — that is true or false, not likely. +//! +//! **Nothing secret is ever copied into a finding.** A report naming the key +//! it found is a report that leaks the key to wherever the report goes: a log, +//! a CI artefact, an assistant's context window. Findings carry the kind of +//! credential and where it lives, never its value. + +use crate::{Finding, Severity}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// Credential formats worth reporting, keyed on a prefix or shape their +/// issuer actually uses. Each entry is a real, documented format — no +/// entropy heuristics, because a false "you have leaked a key" is expensive +/// in a way a missed one is not: people stop reading after the second one. +const CREDENTIALS: &[(&str, &str, &str)] = &[ + // (needle, what it is, who to tell) + ("AKIA", "an AWS access key", "AWS"), + ("ASIA", "a temporary AWS access key", "AWS"), + ("ghp_", "a GitHub personal access token", "GitHub"), + ("gho_", "a GitHub OAuth token", "GitHub"), + ("ghs_", "a GitHub server token", "GitHub"), + ("ghu_", "a GitHub user token", "GitHub"), + ("github_pat_", "a GitHub fine-grained token", "GitHub"), + ("sk-ant-", "an Anthropic API key", "Anthropic"), + ("sk-proj-", "an OpenAI project key", "OpenAI"), + ("sk_live_", "a live Stripe secret key", "Stripe"), + ("rk_live_", "a live Stripe restricted key", "Stripe"), + ("xoxb-", "a Slack bot token", "Slack"), + ("xoxp-", "a Slack user token", "Slack"), + ("xapp-", "a Slack app token", "Slack"), + ("glpat-", "a GitLab personal access token", "GitLab"), + ("dop_v1_", "a DigitalOcean token", "DigitalOcean"), + ("SG.", "a SendGrid API key", "SendGrid"), + ("npm_", "an npm access token", "npm"), + ("pypi-AgEIcHlwaS5vcmc", "a PyPI upload token", "PyPI"), + ("AIza", "a Google API key", "Google"), + ("-----BEGIN RSA PRIVATE KEY", "an RSA private key", "whoever it authenticates to"), + ("-----BEGIN OPENSSH PRIVATE KEY", "an SSH private key", "whoever it authenticates to"), + ("-----BEGIN DSA PRIVATE KEY", "a DSA private key", "whoever it authenticates to"), + ("-----BEGIN EC PRIVATE KEY", "an EC private key", "whoever it authenticates to"), + ("-----BEGIN PGP PRIVATE KEY", "a PGP private key", "whoever it authenticates to"), +]; + +/// Files that hold credentials by convention. Presence is not a problem; +/// being committed, or world-readable, is. +const SECRET_FILENAMES: &[&str] = &[ + ".env", + ".env.local", + ".env.production", + ".env.development", + ".envrc", + "credentials", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + ".npmrc", + ".pypirc", + ".netrc", + "secrets.json", + "serviceaccount.json", + "service-account.json", + ".dockercfg", +]; + +const SECRET_SUFFIXES: &[&str] = &[".pem", ".key", ".p12", ".pfx", ".keystore", ".jks"]; + +/// An example file is meant to be committed — that is its whole purpose. +const EXAMPLE_MARKERS: &[&str] = &[ + ".example", ".sample", ".template", ".dist", "example.", "sample.", +]; + +pub fn looks_like_secret_file(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if EXAMPLE_MARKERS.iter().any(|m| lower.contains(m)) { + return false; + } + if SECRET_FILENAMES.contains(&lower.as_str()) { + return true; + } + // .env.something, but not .env.example (handled above). + if lower.starts_with(".env.") { + return true; + } + SECRET_SUFFIXES.iter().any(|s| lower.ends_with(s)) +} + +/// Find a credential in text, returning what it is rather than what it says. +/// +/// Never returns the matched value. A report that quotes the key it found has +/// moved the key somewhere new — into a log, a CI artefact, an assistant's +/// context — which is the thing we are trying to prevent. +pub fn credential_kind(text: &str) -> Option<(&'static str, &'static str)> { + CREDENTIALS + .iter() + .find(|(needle, _, _)| text.contains(needle)) + .map(|(_, what, issuer)| (*what, *issuer)) +} + +/// Everything git is tracking, if this is a git repository. +/// +/// Read from the index file's paths rather than by running `git` — the sweep +/// may be pointed at a repository whose hooks or config we have no reason to +/// trust, and running a subprocess in it is exactly the thing a hostile +/// repository wants. Parsing the index is boring and cannot execute anything. +pub fn tracked_paths(repo: &Path) -> Option> { + let index = repo.join(".git").join("index"); + let data = std::fs::read(index).ok()?; + if data.len() < 12 || &data[0..4] != b"DIRC" { + return None; + } + let version = u32::from_be_bytes(data[4..8].try_into().ok()?); + let count = u32::from_be_bytes(data[8..12].try_into().ok()?) as usize; + // Versions 2 and 3 share the entry layout used below. Version 4 path- + // compresses entries against the previous one, which this does not + // decode — returning None means "cannot tell", and callers treat that as + // "do not claim a file is committed". + if !(2..=3).contains(&version) { + return None; + } + + let mut out = HashSet::new(); + let mut pos = 12; + for _ in 0..count { + // 62 bytes of fixed fields, then a NUL-terminated path, then padding + // to an 8-byte boundary. + if pos + 62 > data.len() { + break; + } + let name_len = u16::from_be_bytes(data[pos + 60..pos + 62].try_into().ok()?) as usize; + let start = pos + 62; + let end = if name_len < 0xFFF { + start + name_len + } else { + match data[start..].iter().position(|b| *b == 0) { + Some(n) => start + n, + None => break, + } + }; + if end > data.len() { + break; + } + if let Ok(name) = std::str::from_utf8(&data[start..end]) { + out.insert(name.to_string()); + } + let entry_len = 62 + (end - start) + 1; + pos += (entry_len + 7) & !7; + } + Some(out) +} + +/// What .gitignore actually covers, as literal lines. Deliberately not a +/// gitignore engine: this is used only to answer "is there a rule that +/// plausibly covers .env", and a wrong answer in the permissive direction +/// costs a duplicate finding rather than a missed one. +fn gitignore_lines(repo: &Path) -> Vec { + std::fs::read_to_string(repo.join(".gitignore")) + .map(|s| { + s.lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect() + }) + .unwrap_or_default() +} + +/// Does .gitignore plausibly cover this filename? +pub fn gitignore_covers(repo: &Path, name: &str) -> bool { + ignored_by(&gitignore_lines(repo), name) +} + +fn ignored_by(lines: &[String], name: &str) -> bool { + lines.iter().any(|l| { + let pat = l.trim_start_matches('/').trim_end_matches('/'); + pat == name || (pat.starts_with('*') && name.ends_with(&pat[1..])) || { + // ".env*" covers ".env.local" + pat.ends_with('*') && name.starts_with(&pat[..pat.len() - 1]) + } + }) +} + +/// Is this path inside a git repository, and if so where is its root? +pub fn repo_root(start: &Path) -> Option { + let mut cur = Some(start); + while let Some(dir) = cur { + if dir.join(".git").exists() { + return Some(dir.to_path_buf()); + } + cur = dir.parent(); + } + None +} + +/// A secret file that git is tracking. The highest-value finding here: it is +/// certain, it is severe, and the fix is time-critical. +pub fn committed_secret(repo: &Path, rel: &str, name: &str) -> Option { + Some(Finding::new( + "committed-secret", + Severity::Critical, + rel.to_string(), + repo.join(rel).to_string_lossy().into_owned(), + format!( + "{name} is tracked by git, so it is in the repository's history and in \ + every clone and fork of it. If this repository is public, or ever \ + becomes public, treat anything in this file as known to strangers." + ), + "hygiene: git index", + "Rotate every credential in this file first — removing it from git does not \ + un-share what has already been pushed. Then `git rm --cached` the file and \ + add it to .gitignore.", + )) +} + +/// A credential sitting in a file that is not a designated secrets file — +/// hardcoded in source, in a notebook, in a config committed by hand. +pub fn hardcoded_credential(path: &Path, what: &str, issuer: &str, tracked: bool) -> Finding { + let severity = if tracked { Severity::Critical } else { Severity::Warning }; + Finding::new( + "hardcoded-credential", + severity, + what.to_string(), + path.to_string_lossy().into_owned(), + if tracked { + format!( + "This file contains {what} and is tracked by git, so the key is in the \ + repository's history. Anyone with the repository has the key." + ) + } else { + format!( + "This file contains {what} written directly into it. It is not committed \ + yet, which is the good news — a key in source code reaches everyone the \ + code reaches." + ) + }, + "hygiene: credential format", + format!( + "Revoke it with {issuer} and issue a replacement, then read the new one \ + from the environment or a secrets manager instead of storing it in a file." + ), + ) +} + +/// A private key any user on the machine can read. +pub fn permissive_secret(path: &Path, mode: u32) -> Finding { + Finding::new( + "world-readable-secret", + Severity::Warning, + format!("mode {:o}", mode & 0o777), + path.to_string_lossy().into_owned(), + "This file holds a private key or credential and can be read by any account \ + on this machine. On a shared or compromised system that is the same as \ + handing it over.", + "hygiene: file permissions", + format!("Run: chmod 600 {}", path.display()), + ) +} + +/// A repository with secret files and no .gitignore rule covering them. The +/// near-miss that becomes `committed-secret` on the next `git add -A`. +pub fn unignored_secret(repo: &Path, name: &str) -> Finding { + Finding::new( + "unignored-secret", + Severity::Warning, + name.to_string(), + repo.join(".gitignore").to_string_lossy().into_owned(), + format!( + "{name} holds credentials and nothing in .gitignore covers it. It is not \ + committed yet, but the next `git add -A` will commit it — which is how \ + most leaked keys are leaked." + ), + "hygiene: .gitignore", + format!("Add a line to .gitignore: {name}"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_credential_formats_are_recognised() { + for (text, expect_issuer) in [ + ("AKIAIOSFODNN7EXAMPLE", "AWS"), + ("ghp_16CharsOfNonsenseHere0000000000000", "GitHub"), + ("sk-ant-api03-abcdef", "Anthropic"), + ("xoxb-1234-5678-abcdef", "Slack"), + ("-----BEGIN OPENSSH PRIVATE KEY-----", "whoever it authenticates to"), + ] { + let got = credential_kind(text).unwrap_or_else(|| panic!("missed {text}")); + assert_eq!(got.1, expect_issuer); + } + } + + /// The rule that keeps this usable: no entropy guessing. A random-looking + /// string is not a credential, and reporting it as one teaches people to + /// ignore the report. + #[test] + fn random_looking_text_is_not_a_credential() { + for text in [ + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "const id = 'b3d4f5a6-1234-5678-9abc-def012345678';", + "postgres://user:password@localhost:5432/db", + ] { + assert!( + credential_kind(text).is_none(), + "{text} is not a recognisable credential format" + ); + } + } + + /// A finding that quotes the secret has moved the secret somewhere new — + /// a log, a CI artefact, an assistant's context window. + #[test] + fn a_finding_never_contains_the_secret() { + let secret = "ghp_supersecretvalue000000000000000000"; + let (what, issuer) = credential_kind(secret).unwrap(); + let f = hardcoded_credential(Path::new("/tmp/app.py"), what, issuer, true); + let whole = format!("{f:?}"); + assert!( + !whole.contains(secret), + "the finding leaked the credential it found" + ); + assert!(!whole.contains("supersecret")); + } + + #[test] + fn secret_filenames_are_recognised_and_examples_are_not() { + for yes in [".env", ".env.production", "id_rsa", "server.pem", ".npmrc", "private.key"] { + assert!(looks_like_secret_file(yes), "{yes} should be a secret file"); + } + // Committing these is the point of them. + for no in [".env.example", ".env.sample", "config.example.json", "id_rsa.pub", "README.md"] { + assert!(!looks_like_secret_file(no), "{no} must not be flagged"); + } + } + + #[test] + fn gitignore_patterns_cover_the_obvious_shapes() { + let lines: Vec = ["/.env", "*.pem", ".env*", "node_modules/"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert!(ignored_by(&lines, ".env")); + assert!(ignored_by(&lines, "server.pem")); + assert!(ignored_by(&lines, ".env.local")); + assert!(!ignored_by(&lines, "id_rsa")); + } + + #[test] + fn a_committed_secret_tells_you_to_rotate_before_deleting() { + let f = committed_secret(Path::new("/repo"), ".env", ".env").unwrap(); + assert_eq!(f.severity, Severity::Critical); + let advice = f.advice.to_lowercase(); + assert!( + advice.contains("rotate"), + "removing a pushed secret does not un-share it; the advice must say so" + ); + assert!(advice.find("rotate") < advice.find("git rm"), "rotate comes first"); + } + + #[test] + fn the_git_index_parses_and_names_real_paths() { + // The repository this test runs in. + let root = repo_root(Path::new(env!("CARGO_MANIFEST_DIR"))) + .expect("the workspace is a git repository"); + let tracked = tracked_paths(&root).expect("its index should parse"); + // A file that is certainly committed — not this one, which may be + // new and unstaged while it is being written. + assert!( + tracked.contains("Cargo.toml"), + "the index should list the workspace manifest, saw {} entries", + tracked.len() + ); + assert!( + tracked.iter().any(|p| p.ends_with("sweep.rs")), + "and the paths should be repository-relative, saw e.g. {:?}", + tracked.iter().take(3).collect::>() + ); + assert!(tracked.len() > 20, "a real repository has more than 20 files"); + } +} + +// ── CI workflows ──────────────────────────────────────────────────────────── + +/// Audit a GitHub Actions workflow. +/// +/// Workflows are where a repository's security is actually decided: they hold +/// the credentials, they run on every push, and one of them has a failure mode +/// that hands a stranger a shell with your secrets in the environment. +/// +/// This is text analysis, not YAML parsing, and deliberately so — the patterns +/// below are recognisable in the raw file, and a YAML parser would need to be +/// fed untrusted input from a repository we are scanning precisely because we +/// do not trust it. +pub fn audit_workflow(path: &Path, text: &str) -> Vec { + let mut out = Vec::new(); + let loc = path.to_string_lossy().into_owned(); + + // The big one. `pull_request_target` runs with the base repository's + // secrets and write token; checking out the pull request's head then runs + // a stranger's code with them in scope. It is the single most exploited + // GitHub Actions mistake. + let targets_pr = text.contains("pull_request_target"); + let checks_out_head = text.contains("github.event.pull_request.head.sha") + || text.contains("github.event.pull_request.head.ref") + || text.contains("${{ github.head_ref }}"); + if targets_pr && checks_out_head { + out.push(Finding::new( + "workflow-pr-target-checkout", + Severity::Critical, + "pull_request_target with a checkout of the pull request", + loc.clone(), + "This workflow runs with your repository's secrets and a write token, and \ + then checks out the code from the pull request. Anyone who opens a pull \ + request can put a script in it and have this workflow run it — with your \ + secrets available to it.", + "hygiene: github actions", + "Use `pull_request` instead, which runs without secrets. If you genuinely \ + need secrets, split it: an untrusted job that builds, and a separate \ + trusted job that consumes the result without checking out the fork.", + )); + } + + // Piping a fetched script into a shell inside CI: whoever controls that + // URL controls the build, and the build has the secrets. + for marker in ["curl", "wget"] { + if let Some(i) = text.find(marker) { + let line: String = text[i..].lines().next().unwrap_or_default().to_string(); + if (line.contains("| sh") || line.contains("| bash") || line.contains("|sh")) + && !line.contains("#") + { + out.push(Finding::new( + "workflow-pipe-to-shell", + Severity::Warning, + "a downloaded script is piped straight into a shell", + loc.clone(), + "This workflow downloads a script and runs it without checking what \ + it is. Whoever controls that URL — now or after it changes hands — \ + controls this build, and the build can read your secrets.", + "hygiene: github actions", + "Pin the script to a known checksum, or install the tool from a \ + package manager with a pinned version.", + )); + break; + } + } + } + + // An action referenced by a moving tag runs whatever that tag points at + // today. Tags are mutable; a compromised or sold action updates under you. + let mut floating: Vec = Vec::new(); + for line in text.lines() { + let t = line.trim(); + let Some(rest) = t.strip_prefix("- uses:").or_else(|| t.strip_prefix("uses:")) else { + continue; + }; + let spec = rest.trim().trim_matches('"').trim_matches('\''); + // Local and container actions are not the concern here. + if spec.starts_with('.') || spec.starts_with("docker://") { + continue; + } + let Some((name, reference)) = spec.rsplit_once('@') else { + continue; + }; + // A 40-character hex reference is a commit, which cannot move. + let pinned = reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()); + // Actions published by GitHub itself are a different risk profile from + // a personal repository, and flagging every actions/checkout@v4 buries + // the finding that matters. + let first_party = name.starts_with("actions/") || name.starts_with("github/"); + if !pinned && !first_party { + floating.push(spec.to_string()); + } + } + if !floating.is_empty() { + floating.sort(); + floating.dedup(); + out.push(Finding::new( + "workflow-unpinned-action", + Severity::Warning, + floating.join(", "), + loc.clone(), + "These third-party actions are referenced by a tag or branch, which the \ + author can move at any time. If one of those repositories is sold or \ + compromised, the new code runs in your builds automatically, with your \ + secrets.", + "hygiene: github actions", + "Pin each to a full commit SHA — `uses: owner/action@<40-char sha>` — and \ + let Dependabot propose updates.", + )); + } + + // Printing a secret puts it in the log, and logs outlive the run. + if text.contains("echo ${{ secrets.") || text.contains("echo \"${{ secrets.") { + out.push(Finding::new( + "workflow-secret-echoed", + Severity::Critical, + "a secret is printed to the build log", + loc, + "This workflow prints a secret into the build log. GitHub masks known \ + secrets in output, but masking fails on transformed values, and anyone \ + who can read the run can read the log.", + "hygiene: github actions", + "Remove the echo. If you are debugging, print whether the value is empty \ + rather than what it is.", + )); + } + + out +} + +/// Is this path a CI workflow worth auditing? +pub fn is_workflow(path: &Path) -> bool { + let s = path.to_string_lossy(); + (s.contains(".github/workflows/") || s.contains(".gitlab-ci")) + && (s.ends_with(".yml") || s.ends_with(".yaml")) +} + +#[cfg(test)] +mod workflow_tests { + use super::*; + + fn find<'a>(fs: &'a [Finding], kind: &str) -> Option<&'a Finding> { + fs.iter().find(|f| f.kind == kind) + } + + /// The canonical GitHub Actions vulnerability: repository secrets plus a + /// checkout of a stranger's branch. + #[test] + fn pr_target_with_a_fork_checkout_is_critical() { + let yaml = r#" +on: + pull_request_target: +jobs: + build: + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - run: npm install && npm test +"#; + let fs = audit_workflow(Path::new(".github/workflows/ci.yml"), yaml); + let f = find(&fs, "workflow-pr-target-checkout").expect("must be caught"); + assert_eq!(f.severity, Severity::Critical); + } + + /// pull_request_target on its own is fine — it is the checkout that makes + /// it dangerous, and flagging the trigger alone would be noise. + #[test] + fn pr_target_without_a_fork_checkout_is_not_flagged() { + let yaml = "on:\n pull_request_target:\njobs:\n label:\n steps:\n - run: gh pr edit --add-label triage\n"; + let fs = audit_workflow(Path::new("w.yml"), yaml); + assert!(find(&fs, "workflow-pr-target-checkout").is_none()); + } + + /// And a normal pull_request trigger with a head checkout is the ordinary, + /// safe arrangement. + #[test] + fn an_ordinary_pull_request_workflow_is_clean() { + let yaml = "on:\n pull_request:\njobs:\n test:\n steps:\n - uses: actions/checkout@v4\n - run: cargo test\n"; + let fs = audit_workflow(Path::new("w.yml"), yaml); + assert!(fs.is_empty(), "clean workflow produced {fs:?}"); + } + + #[test] + fn unpinned_third_party_actions_are_flagged_and_first_party_are_not() { + let yaml = "jobs:\n a:\n steps:\n - uses: actions/checkout@v4\n - uses: randomdev/deploy-action@main\n"; + let fs = audit_workflow(Path::new("w.yml"), yaml); + let f = find(&fs, "workflow-unpinned-action").expect("third-party must be flagged"); + assert!(f.subject.contains("randomdev/deploy-action@main")); + assert!( + !f.subject.contains("actions/checkout"), + "flagging every actions/checkout buries the finding that matters" + ); + } + + #[test] + fn a_sha_pinned_action_is_not_flagged() { + let yaml = "jobs:\n a:\n steps:\n - uses: randomdev/act@8f4b7a2c1d0e5f6a9b8c7d6e5f4a3b2c1d0e9f8a\n"; + let fs = audit_workflow(Path::new("w.yml"), yaml); + assert!(find(&fs, "workflow-unpinned-action").is_none()); + } + + #[test] + fn echoing_a_secret_is_critical() { + let yaml = "jobs:\n a:\n steps:\n - run: echo ${{ secrets.NPM_TOKEN }}\n"; + let fs = audit_workflow(Path::new("w.yml"), yaml); + assert_eq!( + find(&fs, "workflow-secret-echoed").unwrap().severity, + Severity::Critical + ); + } + + #[test] + fn piping_a_download_into_a_shell_is_flagged() { + let yaml = "jobs:\n a:\n steps:\n - run: curl -sSL https://example.com/i.sh | bash\n"; + let fs = audit_workflow(Path::new("w.yml"), yaml); + assert!(find(&fs, "workflow-pipe-to-shell").is_some()); + } + + #[test] + fn workflow_paths_are_recognised() { + assert!(is_workflow(Path::new("/r/.github/workflows/ci.yml"))); + assert!(is_workflow(Path::new("/r/.gitlab-ci.yml"))); + assert!(!is_workflow(Path::new("/r/docker-compose.yml"))); + assert!(!is_workflow(Path::new("/r/.github/workflows/README.md"))); + } + + /// Every finding must be actionable by someone who is not a security + /// engineer — that is the whole product thesis. + #[test] + fn every_workflow_finding_says_what_to_do() { + let yaml = "on:\n pull_request_target:\njobs:\n a:\n steps:\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.head.sha }}\n - uses: x/y@main\n - run: echo ${{ secrets.TOKEN }}\n"; + let fs = audit_workflow(Path::new("w.yml"), yaml); + assert!(fs.len() >= 3); + for f in &fs { + assert!(f.advice.len() > 30, "{} has thin advice", f.kind); + assert!( + !f.explanation.contains("CWE") && !f.explanation.contains("CVE-"), + "{} explains itself in jargon", + f.kind + ); + } + } +} diff --git a/crates/hound-supply/src/lib.rs b/crates/hound-supply/src/lib.rs index b913df2..5320fec 100644 --- a/crates/hound-supply/src/lib.rs +++ b/crates/hound-supply/src/lib.rs @@ -17,6 +17,7 @@ //! is a finding that gets ignored, and an ignored finding is worse than //! none because it also costs trust. +pub mod hygiene; pub mod injection; pub mod installscript; pub mod lockfile; diff --git a/crates/hound-supply/src/sweep.rs b/crates/hound-supply/src/sweep.rs index 962b636..fb47124 100644 --- a/crates/hound-supply/src/sweep.rs +++ b/crates/hound-supply/src/sweep.rs @@ -12,7 +12,7 @@ //! because a sweep that walks into a 40GB dataset directory is a sweep //! somebody kills halfway through and never runs again. -use crate::{injection, installscript, lockfile, mcp, pickle, Finding, Report, Severity}; +use crate::{hygiene, injection, installscript, lockfile, mcp, pickle, Finding, Report, Severity}; use hound_defs::Index; use std::path::{Path, PathBuf}; @@ -33,6 +33,11 @@ const MAX_FILES: u64 = 200_000; /// Manifests and configs are small. Anything larger is not one. const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; +/// How much of a file to consider for credential formats. A token lives near +/// the top of a config or in a line of source; reading a 200 MB fixture to +/// look for one makes a sweep nobody runs twice. +const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024; + /// Model files are large by nature, but the pickle header is at the front, /// so we only ever read this much of one. const PICKLE_PREFIX_BYTES: usize = 512 * 1024; @@ -69,6 +74,13 @@ pub fn sweep_with(root: &Path, index: Option<&Index>) -> Report { ..Default::default() }; + // Repository context, resolved once. Whether a file is tracked by git is + // the difference between "a key is on your disk" and "a key is in every + // clone of this repository", which is the difference between a warning + // and an emergency. + let repo = hygiene::repo_root(root); + let tracked = repo.as_deref().and_then(hygiene::tracked_paths); + let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)]; let mut truncated = false; @@ -106,6 +118,13 @@ pub fn sweep_with(root: &Path, index: Option<&Index>) -> Report { report.examined += 1; report.findings.extend(scan_file(&path, md.len())); report.findings.extend(scan_lockfile(&path, md.len(), index)); + report.findings.extend(scan_hygiene( + &path, + md.len(), + repo.as_deref(), + tracked.as_ref(), + &md, + )); } if truncated { break; @@ -131,6 +150,89 @@ pub fn sweep_with(root: &Path, index: Option<&Index>) -> Report { report.sorted() } +/// Credentials, permissions, and CI configuration. +/// +/// Reads a file at most once, and only files small enough and plausible +/// enough to hold what we are looking for — a sweep that reads every byte of +/// a repository to look for a token is a sweep nobody runs twice. +fn scan_hygiene( + path: &Path, + size: u64, + repo: Option<&Path>, + tracked: Option<&std::collections::HashSet>, + md: &std::fs::Metadata, +) -> Vec { + use std::os::unix::fs::MetadataExt as _; + + let mut out = Vec::new(); + let name = file_name_lower(path); + let rel = repo + .and_then(|r| path.strip_prefix(r).ok()) + .map(|p| p.to_string_lossy().replace('\\', "/")); + let is_tracked = |rel: &Option| -> bool { + match (tracked, rel) { + (Some(t), Some(r)) => t.contains(r.as_str()), + _ => false, + } + }; + + if hygiene::looks_like_secret_file(&name) { + // Committed is the emergency; the rest is hygiene. + if is_tracked(&rel) { + if let (Some(repo), Some(rel)) = (repo, rel.as_deref()) { + out.extend(hygiene::committed_secret(repo, rel, &name)); + } + } + // Readable by everyone on the machine. + if md.mode() & 0o077 != 0 { + out.push(hygiene::permissive_secret(path, md.mode())); + } + // Not committed, and nothing stopping the next `git add -A` from + // committing it. This is the near miss that becomes the emergency + // above, and it is the one worth catching. + if let Some(repo) = repo { + if !is_tracked(&rel) && !hygiene::gitignore_covers(repo, &name) { + out.push(hygiene::unignored_secret(repo, &name)); + } + } + } + + // Credential formats, in files that could plausibly contain text. + if size <= MAX_TEXT_BYTES && !looks_binary(&name) { + if let Ok(text) = std::fs::read_to_string(path) { + if let Some((what, issuer)) = hygiene::credential_kind(&text) { + // A designated secrets file holding a secret is not news; it + // is what the file is for. Being committed already produced a + // finding above. + if !hygiene::looks_like_secret_file(&name) { + out.push(hygiene::hardcoded_credential( + path, + what, + issuer, + is_tracked(&rel), + )); + } + } + if hygiene::is_workflow(path) { + out.extend(hygiene::audit_workflow(path, &text)); + } + } + } + + out +} + +/// Files whose extension says they are not text worth grepping. +fn looks_binary(name: &str) -> bool { + const BINARY: &[&str] = &[ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", + ".xz", ".zst", ".tar", ".mp4", ".mp3", ".wav", ".woff", ".woff2", ".ttf", + ".so", ".dylib", ".dll", ".class", ".jar", ".wasm", ".pyc", ".o", ".a", + ".bin", ".pack", ".idx", + ]; + BINARY.iter().any(|e| name.ends_with(e)) +} + /// Sweep with no definitions loaded. pub fn sweep(root: &Path) -> Report { sweep_with(root, None) diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 83ae14b..6f424ae 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -35,6 +35,11 @@ struct Cli { enum Cmd { /// Show engine status (daemon version, engine, signature-DB age) Status, + /// Add or remove the file-manager right-click entry for this user + ContextMenu { + #[arg(value_parser = ["install", "remove"])] + action: String, + }, /// Report what Hound currently cannot see Selfcheck { /// Emit machine-readable JSON instead of human text @@ -416,6 +421,45 @@ fn client(sock: &Option) -> Result { /// Returns the process exit code. fn run(client: &Client, cmd: &Cmd) -> Result { match cmd { + Cmd::ContextMenu { action } => { + // Nemo, Caja and Dolphin read menu entries from system + // directories, so the package installs those. GNOME Files and + // Thunar keep theirs per-user by design, which no .deb can write + // to — hence this command. + let home = std::env::var("HOME").context("no HOME")?; + let home = std::path::PathBuf::from(home); + let nautilus = home.join(".local/share/nautilus/scripts/Scan for Threats with Hound"); + let installed = if action == "install" { + std::fs::create_dir_all(nautilus.parent().expect("has a parent"))?; + std::fs::write( + &nautilus, + "#!/bin/sh\n\ + # Installed by `hound context-menu install`.\n\ + # Nautilus passes the selection in this variable, newline separated.\n\ + exec hound-gui --scan $NAUTILUS_SCRIPT_SELECTED_FILE_PATHS\n", + )?; + std::fs::set_permissions( + &nautilus, + std::os::unix::fs::PermissionsExt::from_mode(0o755), + )?; + true + } else { + let _ = std::fs::remove_file(&nautilus); + false + }; + + if installed { + println!("{} added to GNOME Files (right-click → Scripts)", "✔".green().bold()); + println!(" For Thunar: Edit → Configure custom actions → +"); + println!(" Command: {}", "hound-gui --scan %F".yellow()); + println!(" Appears: tick Directories and Other Files"); + println!(); + println!(" Nemo, Caja and Dolphin already have it — the package installs those."); + } else { + println!("{} removed from GNOME Files", "✔".green().bold()); + } + Ok(0) + } Cmd::Selfcheck { json } => { let v = client.raw_call("selfcheck", None)?; if *json { @@ -908,6 +952,22 @@ fn install_app_update(version: &str, deb_url: &str, sha256: &str, assume_yes: bo Ok(true) } +/// Wait for a process to disappear. Returns false if it is still there when +/// the deadline passes. +/// +/// It is not our child, so waitpid does not apply — /proc is the answer to +/// "is this pid still alive", and checking it is cheap. +fn wait_for_exit(pid: u32, within: std::time::Duration) -> bool { + let deadline = std::time::Instant::now() + within; + while std::time::Instant::now() < deadline { + if !std::path::Path::new(&format!("/proc/{pid}")).exists() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + !std::path::Path::new(&format!("/proc/{pid}")).exists() +} + /// Restart any running desktop app so it picks up the new binary. /// /// The app can notice its own package being replaced and reopen itself, but @@ -983,14 +1043,24 @@ fn restart_desktop_apps() { // is a view over the daemon. unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; + // Wait for it to actually be gone before starting its replacement. + // Sleeping a fixed interval and hoping produced two tray icons, one + // of them a corpse: the panel keeps an item until the process that + // registered it drops off the bus, so starting the new one first + // leaves a dog down there that cannot be clicked or closed. + if !wait_for_exit(pid, std::time::Duration::from_secs(5)) { + // SAFETY: a process that ignored SIGTERM for five seconds is not + // going to shut down cleanly, and leaving it running is the very + // thing that produces the duplicate icon. + unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) }; + wait_for_exit(pid, std::time::Duration::from_secs(2)); + } + let mut cmd = std::process::Command::new("/usr/bin/hound-gui"); cmd.env_clear().envs(session).uid(uid).gid(gid); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); - // Give the old process a moment to release the tray icon, or the - // panel can end up showing two. - std::thread::sleep(std::time::Duration::from_millis(600)); match cmd.spawn() { Ok(_) => println!(" reopened the Hound window for uid {uid}"), Err(e) => eprintln!(" could not reopen the Hound window: {e}"), @@ -1195,6 +1265,8 @@ mod tests { for argv in [ vec!["hound", "status"], vec!["hound", "selfcheck"], + vec!["hound", "context-menu", "install"], + vec!["hound", "context-menu", "remove"], vec!["hound", "scan", "/tmp"], vec!["hound", "update"], vec!["hound", "update", "--check"], diff --git a/dist/hound_0.1.6_amd64.deb b/dist/hound_0.1.6_amd64.deb new file mode 100644 index 0000000..f4f24a3 Binary files /dev/null and b/dist/hound_0.1.6_amd64.deb differ diff --git a/gui/dist/app.js b/gui/dist/app.js index 1cc77e1..131dc25 100644 --- a/gui/dist/app.js +++ b/gui/dist/app.js @@ -638,4 +638,24 @@ $("set-monochrome").addEventListener("change", (e) => { invoke("set_tray_style", { monochrome: prefs.monochrome === true }).catch(() => {}); })(); -boot(); +// A right-click in the file manager arrives either as a startup argument or, +// when the window is already open, as an event from the second launch. +listen("scan-request", (e) => { + const paths = e.payload || []; + if (paths.length) { + switchTab("protection"); + doScan(paths[0]); + } +}); + +boot().then(async () => { + try { + const paths = await invoke("take_scan_request"); + if (paths && paths.length) { + switchTab("protection"); + doScan(paths[0]); + } + } catch { + // No pending request is the normal case. + } +}); diff --git a/gui/package-lock.json b/gui/package-lock.json index e4989e3..b27401a 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "hound-gui", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hound-gui", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.7.2", diff --git a/gui/package.json b/gui/package.json index 295f8f3..69a4b77 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "hound-gui", - "version": "0.1.5", + "version": "0.1.6", "description": "Hound Antivirus — desktop app", "type": "module", "scripts": { diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock index 30608b0..f633b21 100644 --- a/gui/src-tauri/Cargo.lock +++ b/gui/src-tauri/Cargo.lock @@ -1467,7 +1467,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hound-api" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "serde", @@ -1477,10 +1477,11 @@ dependencies = [ [[package]] name = "hound-gui" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "hound-api", + "libc", "serde", "serde_json", "sha2", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index 529fe2f..6a4532f 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hound-gui" description = "Hound Antivirus desktop app (Tauri 2)" -version = "0.1.5" +version = "0.1.6" edition = "2021" license = "MIT" repository = "https://git.joelovestech.com/Hound/Antivirus" @@ -17,6 +17,7 @@ tauri-plugin-dialog = "2" tauri-plugin-notification = "2" tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } +libc = "0.2" sha2 = "0.10" ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } serde_json = "1" diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 9969061..6a89f3a 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -710,7 +710,87 @@ fn load_state_icons() -> R>> { // ── App ───────────────────────────────────────────────────────────────────── +/// Refuse to start if this user already has Hound running. +/// +/// Two processes mean two tray icons, and the second one is a puzzle: it +/// looks identical and does nothing, because the panel is showing an item +/// registered by a process that is no longer answering. An advisory lock held +/// for the lifetime of the process is the cheap, correct answer — the kernel +/// releases it however we exit, including a crash, so a stale lock is not a +/// state that can happen. +fn claim_single_instance() -> Option { + let dir = std::env::var("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + let path = dir.join("hound-gui.lock"); + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&path) + .ok()?; + // SAFETY: flock on a file descriptor we own. LOCK_NB means this returns + // rather than waiting if somebody else holds it. + let rc = unsafe { + libc::flock( + std::os::unix::io::AsRawFd::as_raw_fd(&file), + libc::LOCK_EX | libc::LOCK_NB, + ) + }; + if rc == 0 { + Some(file) + } else { + None + } +} + +/// Paths passed on the command line, from a file manager's context menu. +/// +/// Kept in a global rather than threaded through the builder because the +/// front-end asks for them once it is ready to display results, and by then +/// the argument vector is long gone. +static SCAN_ON_START: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +#[tauri::command] +fn take_scan_request() -> Vec { + // Taken, not read: a second call must not rescan, or reopening the window + // would start the scan again. + SCAN_ON_START + .lock() + .map(|mut g| std::mem::take(&mut *g)) + .unwrap_or_default() +} + pub fn run() { + // "hound-gui --scan PATH…" is how a file manager's right-click arrives. + let mut args = std::env::args().skip(1).peekable(); + let mut wanted: Vec = Vec::new(); + while let Some(a) = args.next() { + match a.as_str() { + "--scan" => wanted.extend(args.by_ref()), + other if !other.starts_with('-') => wanted.push(other.to_string()), + _ => {} + } + } + + // Held for the lifetime of the process; dropping it releases the lock. + let Some(_instance_lock) = claim_single_instance() else { + // Already running. Hand the request to the instance that owns the + // window rather than refusing — a right-click that silently does + // nothing because the app happens to be open is indefensible. + if !wanted.is_empty() { + let _ = handoff_scan(&wanted); + } else { + eprintln!("hound-gui is already running for this user"); + } + return; + }; + if !wanted.is_empty() { + if let Ok(mut g) = SCAN_ON_START.lock() { + *g = wanted; + } + } + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) @@ -732,7 +812,8 @@ pub fn run() { realtime_set_enabled, set_state, set_theme_resolved, - set_tray_style + set_tray_style, + take_scan_request ]) .setup(|app| { let handle = app.handle().clone(); @@ -862,6 +943,7 @@ pub fn run() { app.manage(UpdateMenuItem(install_item.clone())); start_watcher(handle.clone(), watcher_icons); + start_handoff_watcher(handle.clone()); Ok(()) }) @@ -894,6 +976,56 @@ pub fn run() { .expect("error while running Hound"); } +/// Notice scan requests dropped by a second launch and act on them. +/// +/// Polling a filename is unglamorous and exactly right here: the event is +/// rare, a missed one is retried a second later, and there is no daemon, +/// socket or bus name to keep alive. +fn start_handoff_watcher(app: tauri::AppHandle) { + std::thread::spawn(move || { + let dir = std::env::var("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + let request = dir.join("hound-gui.scan"); + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + let Ok(body) = std::fs::read_to_string(&request) else { + continue; + }; + let _ = std::fs::remove_file(&request); + let paths: Vec = body + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + if paths.is_empty() { + continue; + } + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + let _ = w.emit("scan-request", paths); + } + } + }); +} + +/// Pass a scan request to the instance that already owns the window. +/// +/// A line per path in a file the running instance watches. A socket would be +/// tidier; a file is one syscall, survives the reader being busy, and cannot +/// leave a half-written request behind because the rename is atomic. +fn handoff_scan(paths: &[String]) -> std::io::Result<()> { + let dir = std::env::var("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + let tmp = dir.join("hound-gui.scan.tmp"); + std::fs::write(&tmp, paths.join("\n"))?; + std::fs::rename(tmp, dir.join("hound-gui.scan")) +} + fn main() { run() } diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 666210e..1277c4d 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hound Antivirus", - "version": "0.1.5", + "version": "0.1.6", "identifier": "com.joelovestech.hound", "build": { "frontendDist": "../dist", diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index 8252f3e..cdef083 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -60,6 +60,22 @@ install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp" # than asking people to open a terminal for every settings change. install -Dm644 "$ROOT/packaging/polkit/com.houndav.hound.policy" \ "$STAGE/usr/share/polkit-1/actions/com.houndav.hound.policy" + +# Right-click "Scan for Threats with Hound" in the file managers whose menu +# entries are system-wide files. GNOME and Thunar keep theirs per-user, so +# those are installed by `hound context-menu install` instead. +# A symbolic icon, so the file manager recolours it to whatever the menu +# theme is rather than dropping a violet dog into a monochrome menu. GTK does +# the recolouring itself when the icon is named -symbolic and lives here. +install -Dm644 "$ROOT/assets/icons/hound-symbolic.svg" \ + "$STAGE/usr/share/icons/hicolor/symbolic/apps/hound-symbolic.svg" + +install -Dm644 "$ROOT/packaging/filemanager/hound-scan.nemo_action" \ + "$STAGE/usr/share/nemo/actions/hound-scan.nemo_action" +install -Dm644 "$ROOT/packaging/filemanager/hound-scan.caja_action" \ + "$STAGE/usr/share/caja/actions/hound-scan.caja_action" +install -Dm644 "$ROOT/packaging/filemanager/hound-scan.desktop" \ + "$STAGE/usr/share/kio/servicemenus/hound-scan.desktop" install -Dm644 "$ROOT/packaging/systemd/houndd.service" \ "$STAGE/lib/systemd/system/houndd.service" install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \ diff --git a/packaging/filemanager/hound-scan.caja_action b/packaging/filemanager/hound-scan.caja_action new file mode 100644 index 0000000..82422a3 --- /dev/null +++ b/packaging/filemanager/hound-scan.caja_action @@ -0,0 +1,9 @@ +[Caja Action] +Name=Scan for Threats with Hound +Comment=Scan the selected files or folders for malware and exposed credentials +Exec=hound-gui --scan %F +Icon-Name=hound-symbolic +Selection=Any +Extensions=any; +Quote=double +EscapeSpaces=true diff --git a/packaging/filemanager/hound-scan.desktop b/packaging/filemanager/hound-scan.desktop new file mode 100644 index 0000000..ecfe23a --- /dev/null +++ b/packaging/filemanager/hound-scan.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Service +ServiceTypes=KonqPopupMenu/Plugin,all/allfiles,inode/directory +MimeType=application/octet-stream;inode/directory; +Actions=houndScan; +X-KDE-Priority=TopLevel +Icon=hound-symbolic + +[Desktop Action houndScan] +Name=Scan for Threats with Hound +Icon=hound-symbolic +Exec=hound-gui --scan %F diff --git a/packaging/filemanager/hound-scan.nemo_action b/packaging/filemanager/hound-scan.nemo_action new file mode 100644 index 0000000..18e61df --- /dev/null +++ b/packaging/filemanager/hound-scan.nemo_action @@ -0,0 +1,9 @@ +[Nemo Action] +Name=Scan for Threats with Hound +Comment=Scan the selected files or folders for malware and exposed credentials +Exec=hound-gui --scan %F +Icon-Name=hound-symbolic +Selection=Any +Extensions=any; +Quote=double +EscapeSpaces=true