Scan your GitHub Gists for secrets, high-entropy tokens, regex patterns, and terms.
deno install --allow-net --allow-env -n gist-scan https://jsr.io/@softdist/[email protected]Scan your GitHub Gists for secrets, high-entropy tokens, regex patterns, and terms.
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) |