0.1.6: security hygiene, and right-click scanning
Malware scanning asks whether a file is hostile. The check that
actually loses people their accounts is a different one: what has
already been exposed, and what is about to be? crates/hound-supply/src/
hygiene.rs answers it, and runs as part of every project sweep.
- A secret file tracked by git. The emergency case: it is in the
history, in every clone, and in every fork. The advice says rotate
BEFORE `git rm --cached`, because removing a pushed secret does not
un-share it, and a test asserts that ordering.
- Credentials hardcoded in source, recognised by issuer format —
AWS, GitHub, Anthropic, OpenAI, Stripe, Slack, GitLab, npm, PyPI,
Google, and the PEM private-key headers.
- Secret files readable by every account on the machine.
- Secret files with nothing in .gitignore covering them: the near
miss that the next `git add -A` turns into the emergency above.
- GitHub Actions: pull_request_target with a checkout of the pull
request (a stranger's code, your secrets, your write token), a
secret echoed into the build log, a downloaded script piped into a
shell, and third-party actions on a moving tag.
Two rules govern all of it. **Findings are actionable**: no entropy
heuristics, because "high entropy string" is a coin flip a human then
has to adjudicate, and people stop reading after the second false
alarm. Every detector recognises a documented credential format or
reports a structural fact that is true or false. **Nothing secret is
copied into a finding** — a report naming the key it found has moved
the key into a log, a CI artefact, or an assistant's context window,
which is the thing being prevented. There is a test for that.
Tracked-file status comes from parsing .git/index rather than running
git: the sweep is pointed at repositories precisely because they are
not trusted, and starting a subprocess inside one is what a hostile
repository wants.
Against a deliberately bad test repository: four critical, five
warnings, and correctly silent on .env.example and actions/checkout@v4
— flagging those is how a scanner gets ignored.
Also in this release:
- Right-click "Scan for Threats with Hound" in Nemo, Caja and
Dolphin, whose menu entries are system files. GNOME Files and
Thunar keep theirs per-user, so `hound context-menu install`
handles those. The icon is symbolic, so the file manager recolours
it to the menu's own theme instead of dropping a violet dog into a
monochrome menu.
- A right-click while the app is already open hands the request to
the running instance rather than refusing. A menu item that
silently does nothing because the app happens to be open is
indefensible.
- Two fixes for the duplicate tray icon. The updater slept a fixed
600ms after SIGTERM and then started the replacement; if the old
process outlived that, the panel kept its item and the result was
two dogs, the older of which could not be clicked or closed because
nothing was behind it. It now waits for the process to actually
leave /proc, escalating to SIGKILL after five seconds. And the app
itself now holds an advisory lock for its lifetime, so a second
instance cannot exist — the kernel releases the lock however the
process dies, so a stale one is not a state that can happen.
402 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c3417e5f75
commit
aae41a9371
19 changed files with 1052 additions and 20 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
11
assets/icons/hound-symbolic.svg
Normal file
11
assets/icons/hound-symbolic.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="16px" height="16px" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 61.1 (89650) - https://sketch.com -->
|
||||
<title>hound-symbolic</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="art/icons/tld-to-aprove/dog" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g fill="#bebebe" fill-rule="nonzero" id="Shape-1">
|
||||
<path d="M3.80069371,23.1130888 C4.43848107,23.5650959 5.14832497,23.919844 5.9089202,24.1573863 L4.47060635,26.3139954 C4.11309659,26.8500464 3.3887224,26.9947828 2.85267132,26.637273 C2.35490959,26.3052997 2.19455701,25.6569993 2.46043143,25.1368862 L2.52939365,25.019338 L3.80069371,23.1130888 Z M14.5833333,1.16666667 C16.7811895,1.16666667 18.6893658,2.52489596 19.4646473,4.47968116 L19.5335,4.66666667 L24.3833333,4.66666667 C25.0448446,4.66666667 25.5894608,5.16717524 25.6591362,5.81016498 L25.6666667,5.94999885 L25.6666667,11.375 C25.6666667,14.0328704 23.5754085,16.2023012 20.9485691,16.3276156 L20.7083333,16.3333333 L18.7961667,16.3321667 L18.8104203,16.3635263 C19.2754933,17.3735749 19.6443042,18.3167095 19.9164818,19.1943797 L20.0445161,19.627766 C20.7657518,22.2003082 19.7286621,25.1941618 17.5638169,26.637392 C17.0277001,26.9948032 16.3033525,26.8499337 15.9459413,26.3138169 C15.5885301,25.7777001 15.7333996,25.0533525 16.2695164,24.6959413 C17.5764793,23.8246327 18.2515149,21.8759509 17.7978097,20.2576506 C17.4455369,19.0011449 16.8372737,17.5384042 15.9713012,15.8761384 C15.8759396,15.6930882 15.8261459,15.4897339 15.8261459,15.2833333 C15.8261459,14.6231337 16.3245332,14.0799436 16.9690039,14.0080605 L17.1092123,14 L20.7083333,14 C22.0976747,14 23.2349191,12.9206448 23.3272774,11.5547236 L23.3333333,11.375 L23.3333333,6.99883333 L17.6136472,7.00006824 L17.4704091,5.99857195 C17.2758266,4.63808519 16.1483073,3.59829995 14.780321,3.50658494 L14.5833333,3.5 L12.7831667,3.5 L12.5783017,3.50389564 C8.27042644,3.63569563 4.8164366,7.09906173 4.67140846,11.3859455 L4.66666667,11.6666667 L4.66631609,16.8880679 L4.67097109,17.092305 C4.76225687,18.9495018 6.29888585,20.4166667 8.16666667,20.4166667 C10.0996633,20.4166667 11.6666667,18.8496633 11.6666667,16.9166667 C11.6666667,16.1268183 11.523797,15.416107 11.2062777,14.4675176 L10.7570788,13.2056109 C10.1975498,11.6462738 9.90930052,10.544355 9.77521466,9.11410583 C9.71507223,8.47258663 10.1863712,7.90377749 10.8278904,7.84363507 C11.4694096,7.78349264 12.0382188,8.25479164 12.0983612,8.89631084 C12.1996371,9.97658728 12.4067419,10.8376191 12.8110905,12.0132787 L13.3678001,13.5759215 L13.4189446,13.7268763 C13.8098537,14.8947176 14,15.8406089 14,16.9166667 C14,20.1383277 11.3883277,22.75 8.16666667,22.75 C5.13975753,22.75 2.63542176,20.439154 2.35770657,17.4371488 L2.3393951,17.1781792 L2.33333333,16.9166667 L2.33333333,11.6666667 C2.33333333,6.09263102 6.68344803,1.51408374 12.2162292,1.1851268 L12.4308333,1.17483333 L12.4315796,1.16666667 L14.5833333,1.16666667 Z M16.1504743,6.81070338 L16.4543586,8.53411695 C16.5662457,9.1686603 16.1425491,9.77376175 15.5080058,9.88564887 C14.8734624,9.99753598 14.268361,9.57383938 14.1564739,8.93929603 L13.8525895,7.21588246 C13.7407024,6.58133911 14.164399,5.97623765 14.7989424,5.86435054 C15.4334857,5.75246343 16.0385872,6.17616002 16.1504743,6.81070338 Z"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
646
crates/hound-supply/src/hygiene.rs
Normal file
646
crates/hound-supply/src/hygiene.rs
Normal file
|
|
@ -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<HashSet<String>> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<Finding> {
|
||||
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<String> = ["/.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::<Vec<_>>()
|
||||
);
|
||||
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<Finding> {
|
||||
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<String> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String>>,
|
||||
md: &std::fs::Metadata,
|
||||
) -> Vec<Finding> {
|
||||
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<String>| -> 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)
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Result<Client> {
|
|||
/// Returns the process exit code.
|
||||
fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
||||
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"],
|
||||
|
|
|
|||
BIN
dist/hound_0.1.6_amd64.deb
vendored
Normal file
BIN
dist/hound_0.1.6_amd64.deb
vendored
Normal file
Binary file not shown.
22
gui/dist/app.js
vendored
22
gui/dist/app.js
vendored
|
|
@ -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.
|
||||
}
|
||||
});
|
||||
|
|
|
|||
4
gui/package-lock.json
generated
4
gui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hound-gui",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"description": "Hound Antivirus — desktop app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
5
gui/src-tauri/Cargo.lock
generated
5
gui/src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -710,7 +710,87 @@ fn load_state_icons() -> R<HashMap<String, Image<'static>>> {
|
|||
|
||||
// ── 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<std::fs::File> {
|
||||
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<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
#[tauri::command]
|
||||
fn take_scan_request() -> Vec<String> {
|
||||
// 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<String> = 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<String> = 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
9
packaging/filemanager/hound-scan.caja_action
Normal file
9
packaging/filemanager/hound-scan.caja_action
Normal file
|
|
@ -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
|
||||
12
packaging/filemanager/hound-scan.desktop
Normal file
12
packaging/filemanager/hound-scan.desktop
Normal file
|
|
@ -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
|
||||
9
packaging/filemanager/hound-scan.nemo_action
Normal file
9
packaging/filemanager/hound-scan.nemo_action
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue