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 @@
+
+
\ 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