From a9721550f762f995a8cb70d611a0b6ad90e436f3 Mon Sep 17 00:00:00 2001 From: Tim Karlsson Date: Mon, 27 Jul 2026 16:19:22 +0200 Subject: [PATCH 1/4] Add Secrets Leak Guardrail skill 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. --- submissions/secrets-leak-guardrail/README.md | 38 +++++ submissions/secrets-leak-guardrail/SKILL.md | 76 +++++++++ .../secrets-leak-guardrail/metadata.json | 11 ++ .../scripts/scan_secrets.py | 153 ++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 submissions/secrets-leak-guardrail/README.md create mode 100644 submissions/secrets-leak-guardrail/SKILL.md create mode 100644 submissions/secrets-leak-guardrail/metadata.json create mode 100644 submissions/secrets-leak-guardrail/scripts/scan_secrets.py 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..ae2b3528 --- /dev/null +++ b/submissions/secrets-leak-guardrail/SKILL.md @@ -0,0 +1,76 @@ +--- +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. +--- + +Scan for leaked credentials before anything ships, and never redact silently. +The user needs to know a secret was caught. + +## 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..c5996010 --- /dev/null +++ b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py @@ -0,0 +1,153 @@ +#!/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 scan_secrets.py [--json] + echo "some text" | python 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): + 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: + if len(value) <= 12: + return value[:2] + "…" + value[-2:] + 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()) From 389c644d7c6cd4cb29df2c9eabd65732e21648f2 Mon Sep 17 00:00:00 2001 From: Tim Karlsson Date: Mon, 27 Jul 2026 16:51:05 +0200 Subject: [PATCH 2/4] Fix script usage docstring to include scripts/ prefix Matches the invocation shown in SKILL.md and README.md, per Copilot review feedback on the PR. --- submissions/secrets-leak-guardrail/scripts/scan_secrets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submissions/secrets-leak-guardrail/scripts/scan_secrets.py b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py index c5996010..17d40736 100644 --- a/submissions/secrets-leak-guardrail/scripts/scan_secrets.py +++ b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py @@ -8,8 +8,8 @@ candidates. Usage: - python scan_secrets.py [--json] - echo "some text" | python scan_secrets.py - + python scripts/scan_secrets.py [--json] + echo "some text" | python scripts/scan_secrets.py - """ from __future__ import annotations From 96bd1bcf2d9f7c1ddc678c8255d58bfc6e8f6946 Mon Sep 17 00:00:00 2001 From: Tim <19335340+Timziito@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:56:54 +0200 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- submissions/secrets-leak-guardrail/scripts/scan_secrets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/submissions/secrets-leak-guardrail/scripts/scan_secrets.py b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py index 17d40736..0448557f 100644 --- a/submissions/secrets-leak-guardrail/scripts/scan_secrets.py +++ b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py @@ -94,7 +94,9 @@ def scan_text(text: str, source: str) -> list[dict]: def _redact_middle(value: str) -> str: if len(value) <= 12: - return value[:2] + "…" + value[-2:] + if len(value) <= 2: + return "…redacted…" + return value[:1] + "…redacted…" + value[-1:] return value[:6] + "…redacted…" + value[-4:] From 7f56b545a3fa1957cab36f818ac37b7b7f617d6b Mon Sep 17 00:00:00 2001 From: Tim Karlsson Date: Mon, 27 Jul 2026 21:52:49 +0200 Subject: [PATCH 4/4] Address Copilot review feedback Fixes the issues flagged in the automated review: see PR discussion for details. --- submissions/secrets-leak-guardrail/SKILL.md | 3 --- .../secrets-leak-guardrail/scripts/scan_secrets.py | 9 ++++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/submissions/secrets-leak-guardrail/SKILL.md b/submissions/secrets-leak-guardrail/SKILL.md index ae2b3528..1d7ffd3b 100644 --- a/submissions/secrets-leak-guardrail/SKILL.md +++ b/submissions/secrets-leak-guardrail/SKILL.md @@ -7,9 +7,6 @@ description: >- leave the conversation. --- -Scan for leaked credentials before anything ships, and never redact silently. -The user needs to know a secret was caught. - ## Instructions 1. This applies whenever the agent is about to: write code containing real diff --git a/submissions/secrets-leak-guardrail/scripts/scan_secrets.py b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py index 0448557f..ba5193a2 100644 --- a/submissions/secrets-leak-guardrail/scripts/scan_secrets.py +++ b/submissions/secrets-leak-guardrail/scripts/scan_secrets.py @@ -68,6 +68,8 @@ def scan_text(text: str, source: str) -> 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, @@ -93,10 +95,11 @@ def scan_text(text: str, source: str) -> list[dict]: 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: - if len(value) <= 2: - return "…redacted…" - return value[:1] + "…redacted…" + value[-1:] + return "…redacted…" return value[:6] + "…redacted…" + value[-4:]