diff --git a/.gitignore b/.gitignore index 87cc2b5..44f8e03 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ completions/fisher.fish conf.d/sponge.fish functions/_sponge*.fish functions/sponge_*.fish +!functions/sponge_filter_secrets.fish # ─────────────────── AI Sessions and Rules ────────────────── # Matches CLAUDE.md, .claud*, etc. diff --git a/conf.d/sponge_privacy.fish b/conf.d/sponge_privacy.fish new file mode 100644 index 0000000..675fd5b --- /dev/null +++ b/conf.d/sponge_privacy.fish @@ -0,0 +1,113 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# ╭──────────────────────────────────────────────────────────╮ +# │ Sponge Privacy Pattern Registration │ +# ╰──────────────────────────────────────────────────────────╯ +# +# Two-layer approach to keeping credentials out of shell history: +# +# Layer 1 — Static patterns (registered as universal, persistent): +# Covers structural signatures: auth flags, env var assignments, +# credential-bearing URLs, Authorization headers, sshpass, etc. +# Patterns are added idempotently; user additions are preserved. +# +# Layer 2 — Dynamic secret values (registered as session globals): +# On the first prompt (after secrets.fish has loaded), reads the +# literal values of all exported variables whose names indicate +# credentials (TOKEN, PASSWORD, SECRET, KEY, etc.) and adds them +# as a session-scoped pattern overlay. Refreshes automatically +# each login, so rotated tokens never leave stale patterns behind. +# +# Layer 3 — Per-command filter (sponge_filter_secrets): +# Catches mid-session variables set after login — e.g. a token +# exported interactively or sourced from a project .env file. +# +# To add your own persistent patterns: +# set -U -a sponge_regex_patterns 'your-regex-here' + +if not status is-interactive + return +end + +# Only register if sponge is loaded +if not set -q sponge_version + return +end + +# ──────────────────── Layer 1: Static patterns ──────────────────── + +set -l _privacy_patterns + +# Common auth flags with values: --password x, --token x, --passphrase x, --api-key x +set -a _privacy_patterns '--(?:password|passwd|passphrase|token|secret|api[-_]key)(?:\s+|=)\S+' + +# Inline env var assignments with sensitive names: GITHUB_TOKEN=xxx, MY_API_KEY=abc +set -a _privacy_patterns '(?i)\b[A-Z][A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|PRIVATE_KEY|ACCESS_KEY|AUTH_KEY|CREDENTIAL)[A-Z0-9_]*=\S+' + +# Fish set with sensitive variable names: set -gx GITHUB_TOKEN xxx, set -U MY_SECRET yyy +set -a _privacy_patterns '(?i)set\s+-\S+\s+\S*(?:password|passwd|token|secret|api.?key|private.?key|access.?key|credential)\S*\s+\S+' + +# URLs with embedded credentials: https://user:password@host +set -a _privacy_patterns 'https?://[^:@\s]+:[^@\s]+@' + +# HTTP Authorization headers: curl -H "Authorization: Bearer xxx" +set -a _privacy_patterns 'curl\s.*[Aa]uthorization:' + +# Basic auth flags: curl -u user:pass, wget --user user --password pass +set -a _privacy_patterns '(?:curl|wget)\s.*(?:-u|--user)\s+\S+:\S+' + +# sshpass — exposes credentials as a CLI argument by design +set -a _privacy_patterns '\bsshpass\b' + +# Docker login with inline password +set -a _privacy_patterns 'docker\s+login\s.*(?:-p|--password)\s+\S+' + +# openssl passphrase arguments: -passin pass:xxx, -passout env:VAR +set -a _privacy_patterns 'openssl\s.*-pass(?:in|out)\s+\S+' + +# Idempotent registration into universal sponge_regex_patterns +for _pattern in $_privacy_patterns + if not contains -- $_pattern $sponge_regex_patterns + set -U -a sponge_regex_patterns $_pattern + end +end + +# ──────────── Layer 2: Dynamic secret values (session globals) ──────────── + +# Runs once on the first prompt — by which point config.fish and secrets.fish +# have fully loaded, so all secret env vars are in scope. +# Builds a session-scoped global that combines the universal static patterns +# with the literal values of any credential-holding env vars. Globals shadow +# universals in Fish, so the combined list is what sponge sees for this session. +function __sponge_register_secret_values --on-event fish_prompt + functions --erase __sponge_register_secret_values # run exactly once + + set -l secret_values + + set -l sensitive_vars (set --names --export | string match --regex -- \ + '(?i)(?:TOKEN|PASSWORD|PASSWD|SECRET|API[_-]KEY|PRIVATE[_-]KEY|ACCESS[_-]KEY|AUTH[_-]KEY|CREDENTIAL|KOPIA_PASSWORD)') + + for var in $sensitive_vars + set -l value $$var + # Skip empty, short, or path-like values + test (string length -- $value) -gt 8; or continue + string match --quiet --regex '^[/~]' -- $value; and continue + set -a secret_values (string escape --style=regex -- $value) + end + + if test (count $secret_values) -gt 0 + # Merge static universals + dynamic values into a session global + set -g sponge_regex_patterns $sponge_regex_patterns $secret_values + end +end + +# ──────────── Layer 3: Mid-session filter (sponge_filter_secrets) ───────── + +# Catches credentials in variables exported after login (e.g. project .env files). +# Only register if not already in sponge_filters. +if functions --query sponge_filter_secrets + if not contains -- sponge_filter_secrets $sponge_filters + set -U -a sponge_filters sponge_filter_secrets + end +end diff --git a/docs/fish-config.index b/docs/fish-config.index index 531404f..06dacd4 100644 --- a/docs/fish-config.index +++ b/docs/fish-config.index @@ -168,6 +168,9 @@ plugins=# 8. FISHER PLUGINS fisher=# 8. FISHER PLUGINS fisher-managed=## Fisher-Managed Plugins sponge=## Fisher-Managed Plugins +sponge-filtering=## Sponge History Filtering +history-filtering=## Sponge History Filtering +privacy=## Sponge History Filtering bundled-plugins=## Bundled Plugin Functionality fish-plugins-manifest=## fish_plugins Manifest diff --git a/docs/fish-config.md b/docs/fish-config.md index cf34cb0..8f4fa0f 100644 --- a/docs/fish-config.md +++ b/docs/fish-config.md @@ -1426,6 +1426,38 @@ commit them. Fisher installs and updates them automatically. jorgebucaran/fisher Plugin manager itself meaningful-ooo/sponge Remove failed commands from history +## Sponge History Filtering + +Sponge removes failed commands from history and, via conf.d/sponge_privacy.fish, +also filters privacy-sensitive commands through three layers: + +Layer 1 — Static patterns (universal, persistent across sessions): +Commands matching any of these structural signatures are never recorded: + + --password / --token / --passphrase / --api-key flags with values + Inline env assignments: GITHUB_TOKEN=xxx, MY_API_KEY=abc + Fish set with sensitive names: set -gx GITHUB_TOKEN xxx + URLs with embedded credentials: https://user:pass@host + HTTP Authorization headers: curl -H "Authorization: ..." + Basic auth flags: curl -u user:pass + sshpass, docker login -p, openssl -passin/-passout + +Layer 2 — Dynamic secret values (session globals, refreshed each login): +On the first prompt, after secrets.fish has loaded, the literal values of +all exported variables whose names suggest credentials (TOKEN, PASSWORD, +SECRET, API_KEY, etc.) are collected, regex-escaped, and added as a +session-scoped overlay. Because globals shadow universals in Fish, the +combined list is what sponge sees. Rotating a token takes effect on the +next login automatically. + +Layer 3 — Per-command filter (sponge_filter_secrets): +Catches credentials in variables exported after login, such as tokens +sourced from a project .env file mid-session. + +To add your own persistent patterns: + + set -U -a sponge_regex_patterns 'your-regex-here' + ## Bundled Plugin Functionality The remaining plugin functionality is bundled directly with this config rather diff --git a/functions/sponge_filter_secrets.fish b/functions/sponge_filter_secrets.fish new file mode 100644 index 0000000..b6e7f36 --- /dev/null +++ b/functions/sponge_filter_secrets.fish @@ -0,0 +1,51 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# sponge_filter_secrets +# +# DESCRIPTION +# Custom sponge filter that prevents commands from being stored in history +# when they contain the literal value of any exported environment variable +# whose name indicates it holds a credential (TOKEN, PASSWORD, SECRET, +# API_KEY, etc.). This catches shell-expansion leakage where a variable +# value is embedded directly in the command string at execution time — a +# case that static regex patterns cannot cover. +# +# Any variable whose name matches the sensitive-name heuristic and whose +# value is longer than 8 characters (excluding bare paths) is checked. +# The value is escaped for literal regex matching before comparison. +# +# ARGUMENTS +# command The exact command that was entered +# exit_code Exit code of the command (unused) +# previously_in_history "true"/"false" flag (unused) +# +# RETURNS +# 0 Command contains a secret value — filter out of history +# 1 No secret value found — keep in history +# +# EXAMPLE +# # Register with sponge (done automatically by conf.d/sponge_privacy.fish): +# set -U -a sponge_filters sponge_filter_secrets + +function sponge_filter_secrets --argument-names command + # Find all exported variables with security-sensitive names + set -l sensitive_vars (set --names --export | string match --regex -- \ + '(?i)(?:TOKEN|PASSWORD|PASSWD|SECRET|API[_-]KEY|PRIVATE[_-]KEY|ACCESS[_-]KEY|AUTH[_-]KEY|CREDENTIAL|KOPIA_PASSWORD)') + + for var in $sensitive_vars + set -l value $$var + + # Skip empty, short, or path-like values — not real credentials + test (string length -- $value) -gt 8; or continue + string match --quiet --regex '^[/~]' -- $value; and continue + + # Filter if the literal value appears anywhere in the command + if string match --quiet --regex -- (string escape --style=regex -- $value) $command + return 0 + end + end + + return 1 +end