Skip to content

Instantly share code, notes, and snippets.

@lynsei
Last active March 14, 2025 20:01
Show Gist options
  • Select an option

  • Save lynsei/2761ed91400f902964017bbf161b263a to your computer and use it in GitHub Desktop.

Select an option

Save lynsei/2761ed91400f902964017bbf161b263a to your computer and use it in GitHub Desktop.
[deno gist cleanup] / Gist search tool for deleting and searching through gists.

@softdist/gist-scanner

Scan your GitHub Gists for secrets, high-entropy tokens, regex patterns, and terms.

Install

deno install --allow-net --allow-env -n gist-scan https://jsr.io/@softdist/[email protected]
#!/usr/bin/env -S deno run --allow-net --allow-env
import { Command } from "https://deno.land/x/[email protected]/command/mod.ts";
import { bold, red, yellow } from "https://deno.land/[email protected]/fmt/colors.ts";
export class GistScanner {
private static SECRET_REGEX_STRING =
"(?i)(gh[pousr]_[A-Za-z0-9_]{36,})|([A-Fa-f0-9]{32,64})|([A-Za-z0-9+/]{40,}=*)|(eyJ[A-Za-z0-9-_=]+?\\.[A-Za-z0-9-_=]+?\\.[A-Za-z0-9-_=]+?)|([A-Za-z0-9]{20,})";
private headers = {
"Authorization": `Bearer ${Deno.env.get("GH_SCAN_TOKEN")}`,
"Accept": "application/vnd.github+json",
};
async fetchGists() {
let page = 1;
const allGists = [];
while (true) {
const res = await fetch(`https://api.github.com/gists?per_page=100&page=${page}`, { headers: this.headers });
if (!res.ok) throw new Error(`Failed to fetch gists: ${await res.text()}`);
const gists = await res.json();
if (gists.length === 0) break;
allGists.push(...gists);
page++;
}
return allGists;
}
async getFileContents(gist: any) {
for (const file of Object.values<any>(gist.files)) {
if (!file.content) {
const res = await fetch(file.raw_url, { headers: this.headers });
file.content = await res.text();
}
}
}
escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
highlightMatches(text: string, searchTerms: string[], regexTerms: string[]): string {
let highlighted = text;
for (const term of searchTerms) {
const regex = new RegExp(`(${this.escapeRegex(term)})`, 'gi');
highlighted = highlighted.replace(regex, yellow(bold('$1')));
}
for (const regStr of regexTerms) {
const regex = new RegExp(regStr, 'gi');
highlighted = highlighted.replace(regex, yellow(bold('$&')));
}
return highlighted;
}
matchesSearch(gist: any, searchTerms: string[], regexTerms: string[]): boolean {
const fieldsToSearch = [gist.description || "", ...Object.values<any>(gist.files).flatMap((file: any) => [file.filename, file.content || ""])];
return fieldsToSearch.some(field =>
searchTerms.some(term => field.includes(term)) ||
regexTerms.some(regStr => new RegExp(regStr, 'i').test(field))
);
}
async deleteGist(id: string) {
const res = await fetch(`https://api.github.com/gists/${id}`, { method: "DELETE", headers: this.headers });
if (res.status === 204) console.log(red(`Deleted gist: ${id}`));
else console.error(`Failed to delete gist ${id}: ${await res.text()}`);
}
async run() {
await new Command()
.name("gist-scan")
.version("0.1.0")
.description("Scan your GitHub gists for secrets, regex, and terms.")
.option("-t, --term <term:string>", "Search terms.", { collect: true })
.option("-r, --regex <regex:string>", "Regex patterns.", { collect: true })
.option("-s, --secrets", "Enable secret scanning.")
.option("-d, --delete", "Delete matched gists.")
.option("-y, --yes", "Auto-confirm deletions.")
.action(async (opts) => {
const searchTerms = opts.term || [];
const regexTerms = opts.regex || [];
if (opts.secrets) regexTerms.push(GistScanner.SECRET_REGEX_STRING);
if (!searchTerms.length && !regexTerms.length) {
console.error(red("Error: You must provide --term, --regex, or --secrets."));
Deno.exit(1);
}
console.log("Fetching gists...");
const gists = await this.fetchGists();
const matches = [];
for (const gist of gists) {
await this.getFileContents(gist);
if (this.matchesSearch(gist, searchTerms, regexTerms)) matches.push(gist);
}
if (!matches.length) return console.log(yellow("No gists matched your criteria."));
console.log(bold(`\\nFound ${matches.length} matching gist(s).`));
for (const gist of matches) {
console.log(bold(red("\\n=== MATCH ===")));
console.log(`ID: ${gist.id}`);
console.log(`Description: ${this.highlightMatches(gist.description || "", searchTerms, regexTerms)}`);
for (const file of Object.values<any>(gist.files)) {
console.log(`File: ${this.highlightMatches(file.filename, searchTerms, regexTerms)}`);
console.log(`Content:\\n${this.highlightMatches(file.content || "", searchTerms, regexTerms)}\\n`);
}
if (opts.delete && (opts.yes || prompt("Delete this gist? (y/n): ")?.toLowerCase() === "y")) {
await this.deleteGist(gist.id);
} else if (opts.delete) {
console.log(yellow(`Skipped gist: ${gist.id}`));
}
}
})
.parse(Deno.args);
}
}
if (import.meta.main) {
const scanner = new GistScanner();
await scanner.run();
}
# find multiple
deno run --allow-net --allow-env github-gist-manager.ts -t "foo" -t "bar"
# regex
deno run --allow-net --allow-env github-gist-manager.ts -r "foo.*bar"
# combined terms + regex
deno run --allow-net --allow-env github-gist-manager.ts -t "hello" -r "foo.*bar"
# delete with prompt
deno run --allow-net --allow-env github-gist-manager.ts -t "foo" --delete
# force delete
deno run --allow-net --allow-env github-gist-manager.ts -t "foo" --delete -y
# detect entropy
# - github access tokens
# - pat
# - jwt
# - entropy
# - passwords
(?i)(gh[pousr]_[A-Za-z0-9_]{36,})| # GitHub tokens (personal, OAuth, SSH, runner, etc.)
([A-Fa-f0-9]{32,64})| # Hex hashes (MD5, SHA1, SHA256, etc.)
([A-Za-z0-9+/]{40,}=*)| # Base64-like secrets (API keys, tokens)
(eyJ[A-Za-z0-9-_=]+?\.[A-Za-z0-9-_=]+?\.[A-Za-z0-9-_=]+?)| # JWT tokens
([A-Za-z0-9]{20,}) # High entropy alphanumeric (generic catch-all for passwords/tokens)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment