diff --git a/submissions/secrets-leak-guardrail/README.md b/submissions/secrets-leak-guardrail/README.md new file mode 100644 index 00000000..c8d8b342 --- /dev/null +++ b/submissions/secrets-leak-guardrail/README.md @@ -0,0 +1,38 @@ +# Secrets Leak Guardrail + +Before code, logs, or an exported file leave the conversation, this skill +scans for the credential shapes that show up by accident: an AWS key pasted +into a debugging session, a GitHub token left in a `.env` dump, a connection +string with a live password in it. + +## How it works + +`scripts/scan_secrets.py` does two passes: known credential formats by regex +(AWS, GitHub, GitLab, Slack, Google, JWTs, PEM keys, bearer tokens, +`password=` connection strings), plus a Shannon-entropy check on any +`SECRET`/`TOKEN`/`PASSWORD`-named assignment that doesn't match a known +format, catching the vendor-specific key formats the regex list doesn't know +about yet. Every finding is reported, never silently fixed. The agent asks +before stripping anything, since a docs example and a live credential can look +identical to a regex. + +## Usage + +```bash +python scripts/scan_secrets.py path/to/file-or-folder +python scripts/scan_secrets.py path/to/file --json +echo "some pasted text" | python scripts/scan_secrets.py - +``` + +No dependencies beyond the Python standard library. + +## Limits + +This is a pattern scanner, not a secrets-management tool. It won't catch a +credential format it doesn't know, and the entropy check is a heuristic that +can both miss short low-entropy passwords and flag legitimate random IDs. It +buys a last-look-before-sharing check, not a guarantee. + +--- + +Skill by Tim Karlsson (╯°□°)╯︵ ┻━┻ Works 60% of the time, every time. diff --git a/submissions/secrets-leak-guardrail/SKILL.md b/submissions/secrets-leak-guardrail/SKILL.md new file mode 100644 index 00000000..1d7ffd3b --- /dev/null +++ b/submissions/secrets-leak-guardrail/SKILL.md @@ -0,0 +1,73 @@ +--- +name: secrets-leak-guardrail +description: >- + Use this skill before sharing, sending, committing, or displaying any code, + config, log output, or exported file the agent generated or was asked to + paste, to catch API keys, tokens, passwords, and private keys before they + leave the conversation. +--- + +## Instructions + +1. This applies whenever the agent is about to: write code containing real + configuration, paste log or console output, export a file, post to an + external destination (chat, email, a ticket, a repository), or answer a + question by quoting real environment variables or connection strings. + +2. Run the bundled scanner when a Python environment is available: + + ```bash + python scripts/scan_secrets.py + ``` + + It also reads stdin (`... | python scripts/scan_secrets.py -`) for pasted + text that isn't in a file yet. Without Python available, read the content + directly and check it against the patterns in the **What counts as a + secret** section below. + +3. The scanner flags two kinds of finding: + - **High confidence**: matched a known credential format (AWS keys, + GitHub/GitLab/Slack tokens, PEM private keys, JWTs, bearer tokens, + `password=`-style connection strings). + - **Medium confidence**: a `SECRET`/`TOKEN`/`PASSWORD`-named variable + assigned a high-entropy value that didn't match a known format. Judge + these yourself; entropy is a heuristic, not proof. + +4. For every finding, before sharing the content: stop and tell the user + exactly what was found and where (file/line, redacted so the actual value + isn't repeated back). Ask whether it's a real, live credential to strip, or + a test/example value that's fine to leave. Don't assume either way. + +5. If the user confirms it's real, redact or remove it and explain what + replaced it (an environment-variable reference, a placeholder, removal + entirely) rather than silently deleting the line. + +6. Never treat a finding as resolved just because it was mentioned once. + Re-scan after edits before the content actually goes out. + +## What counts as a secret + +Cloud provider keys (AWS `AKIA…`/`ASIA…`, Google `AIza…`), platform tokens +(GitHub `ghp_…`, GitLab `glpat-…`, Slack `xox…`), API keys matching common +vendor formats, PEM-format private keys, JWTs, bearer tokens, and +`password=`/`pwd=` values inside connection strings. Anything else that reads +as a live, working credential even if its format isn't on this list. The +scanner's list is a floor, not a ceiling. + +## Guardrails + +- Never repeat a found secret back in full, even to describe it. Always + redact the middle of the value. +- Never silently strip or silently allow a finding through. Every finding gets + surfaced to the user before the content ships, no exceptions. +- Don't flag obvious documentation placeholders (`your_api_key_here`, + `sk-EXAMPLE...`, `changeme`) as if they were real, but if genuinely + uncertain whether something is a placeholder, ask rather than assume either + way. +- This is a leak check, not a secrets-management setup. Don't turn a "found a + key in this file" moment into an unsolicited lecture on secret rotation + unless asked. + +## Tone + +Direct and calm: what was found, where, what it looks like, what to do next. diff --git a/submissions/secrets-leak-guardrail/metadata.json b/submissions/secrets-leak-guardrail/metadata.json new file mode 100644 index 00000000..4d6abec3 --- /dev/null +++ b/submissions/secrets-leak-guardrail/metadata.json @@ -0,0 +1,11 @@ +{ + "name": "Secrets Leak Guardrail", + "description": "Scan code, config, logs, or exported files for API keys, tokens, and passwords before they're shared. Flags every finding to the user instead of redacting silently.", + "platforms": ["Cowork", "Copilot Studio", "Scout"], + "tags": ["security", "guardrail", "secrets", "credentials", "scripts", "governance"], + "author": "Tim Karlsson", + "authorUrl": "https://github.com/Timziito", + "version": "1.0.0", + "createdAt": "2026-07-26", + "updatedAt": "2026-07-26" +} diff --git a/submissions/secrets-leak-guardrail/scripts/scan_secrets.py b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py new file mode 100644 index 00000000..ba5193a2 --- /dev/null +++ b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Scan text or files for likely leaked secrets before they're shared. + +Deterministic only: known credential formats by regex, plus a Shannon-entropy +check for `KEY = ` assignments that don't match a named +pattern. Judging whether a match is a *real* secret versus a fictional +documentation example is left to the agent. This script only finds +candidates. + +Usage: + python scripts/scan_secrets.py [--json] + echo "some text" | python scripts/scan_secrets.py - +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import sys + +# Known formats: (name, regex). Ordered roughly by how load-bearing a false +# positive would be to explain. +PATTERNS: list[tuple[str, re.Pattern]] = [ + ("AWS Access Key ID", re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b")), + ("AWS Secret Access Key (heuristic)", re.compile(r"(?i)aws_secret_access_key\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}['\"]?")), + ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b")), + ("GitLab token", re.compile(r"\bglpat-[A-Za-z0-9\-_]{20}\b")), + ("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,72}\b")), + ("OpenAI/Anthropic-style API key", re.compile(r"\b(sk|rk)-[A-Za-z0-9]{20,64}\b")), + ("Google API key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")), + ("Azure Storage connection string", re.compile(r"AccountKey=[A-Za-z0-9+/=]{20,}")), + ("Generic connection-string password", re.compile(r"(?i)(password|pwd)\s*=\s*[^;'\"\s]{6,}")), + ("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")), + ("PEM private key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----")), + ("Bearer token", re.compile(r"(?i)authorization:\s*bearer\s+[A-Za-z0-9\-._~+/]{20,}")), +] + +# KEY = value assignments where the value's own shape suggests a secret, even +# with no format above matching. Ceiling: entropy is a heuristic, not proof. +# Flag as "possible", never as a confirmed hit. +ASSIGNMENT = re.compile( + r"(?im)^\s*([A-Za-z_][A-Za-z0-9_]*(?:SECRET|TOKEN|API_?KEY|PASSWORD|PWD|CREDENTIAL)[A-Za-z0-9_]*)\s*[:=]\s*['\"]?([^\s'\"]{12,})['\"]?\s*$" +) + +ENTROPY_THRESHOLD = 3.5 # bits/char; typical English prose sits well below this + + +def shannon_entropy(value: str) -> float: + if not value: + return 0.0 + counts: dict[str, int] = {} + for ch in value: + counts[ch] = counts.get(ch, 0) + 1 + length = len(value) + return -sum((n / length) * math.log2(n / length) for n in counts.values()) + + +def looks_like_placeholder(value: str) -> bool: + lowered = value.lower() + return any(word in lowered for word in ("example", "your_", "changeme", "placeholder", "xxxx", "<", "{{")) + + +def scan_text(text: str, source: str) -> list[dict]: + findings: list[dict] = [] + for line_number, line in enumerate(text.splitlines(), start=1): + for name, pattern in PATTERNS: + for match in pattern.finditer(line): + if looks_like_placeholder(match.group(0)): + continue + findings.append({ + "source": source, + "line": line_number, + "kind": name, + "confidence": "high", + "excerpt": _redact_middle(match.group(0)), + }) + + for match in ASSIGNMENT.finditer(line): + key, value = match.group(1), match.group(2) + if looks_like_placeholder(value): + continue + entropy = shannon_entropy(value) + if entropy >= ENTROPY_THRESHOLD: + findings.append({ + "source": source, + "line": line_number, + "kind": f"High-entropy value assigned to {key}", + "confidence": "medium", + "excerpt": _redact_middle(f"{key}={value}"), + }) + return findings + + +def _redact_middle(value: str) -> str: + # Short values (a short password, a connection-string fragment) get no + # characters revealed at all, revealing even 2+2 chars of something + # this short exposes too large a fraction of the actual secret. + if len(value) <= 12: + return "…redacted…" + return value[:6] + "…redacted…" + value[-4:] + + +def iter_files(path: str): + if os.path.isfile(path): + yield path + return + for root, _dirs, files in os.walk(path): + if os.sep + ".git" in root + os.sep: + continue + for name in files: + yield os.path.join(root, name) + + +def run(path: str) -> list[dict]: + findings: list[dict] = [] + for file_path in iter_files(path): + try: + with open(file_path, encoding="utf-8", errors="ignore") as handle: + text = handle.read() + except (OSError, UnicodeDecodeError): + continue + findings.extend(scan_text(text, file_path)) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", help="file, directory, or '-' for stdin") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + if args.path == "-": + findings = scan_text(sys.stdin.read(), "") + elif os.path.exists(args.path): + findings = run(args.path) + else: + raise SystemExit(f"No such file or directory: {args.path}") + + if args.json: + print(json.dumps(findings, indent=2)) + return 0 + + if not findings: + print("No known secret patterns or high-entropy assignments found.") + return 0 + + print(f"{len(findings)} possible secret(s) found:\n") + for item in findings: + print(f"[{item['confidence']}] {item['kind']}") + print(f" {item['source']}:{item['line']} {item['excerpt']}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main())