@kaydash9999/banker-plugins 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +66 -0
  4. package/README.md +23 -11
  5. package/codex/manifest.json +5 -5
  6. package/codex/transform-matrix.md +5 -5
  7. package/commands/front-qa.md +2 -2
  8. package/commands/setup.md +13 -4
  9. package/hooks/graceful-pause.mjs +222 -0
  10. package/hooks/hooks.json +2 -1
  11. package/hooks/register.mjs +12 -0
  12. package/package.json +4 -1
  13. package/skills/3d-intro-build/SKILL.md +1 -1
  14. package/skills/3d-intro-setup/SKILL.md +1 -1
  15. package/skills/all-in-one/SKILL.md +1 -1
  16. package/skills/append_wiki/SKILL.md +1 -1
  17. package/skills/arch-diagram/SKILL.md +1 -1
  18. package/skills/audit-mock/SKILL.md +1 -1
  19. package/skills/audit-security/SKILL.md +1 -1
  20. package/skills/audit-web-page/SKILL.md +1 -1
  21. package/skills/cleansing-memory/SKILL.md +1 -1
  22. package/skills/compact-copy/SKILL.md +1 -1
  23. package/skills/compact-wiki/SKILL.md +1 -1
  24. package/skills/curation/SKILL.md +1 -1
  25. package/skills/deep-init/SKILL.md +1 -1
  26. package/skills/deep-research/SKILL.md +1 -1
  27. package/skills/docs-setup/SKILL.md +1 -1
  28. package/skills/humanizer/SKILL.md +1 -1
  29. package/skills/interval-report/SKILL.md +1 -1
  30. package/skills/lineage/SKILL.md +152 -16
  31. package/skills/lineage/lineage.py +1210 -108
  32. package/skills/make-notion-guide/SKILL.md +1 -1
  33. package/skills/motion-graphic-make/SKILL.md +1 -1
  34. package/skills/motion-graphic-setup/SKILL.md +1 -1
  35. package/skills/nothing-design/SKILL.md +1 -1
  36. package/skills/obsidizer/SKILL.md +1 -1
  37. package/skills/omc-reference/SKILL.md +1 -1
  38. package/skills/payload-mon/SKILL.md +1 -1
  39. package/skills/pdf-vision-extract/SKILL.md +1 -1
  40. package/skills/play-qa/SKILL.md +1 -1
  41. package/skills/ralph-qa/SKILL.md +304 -112
  42. package/skills/ralph-qa/references/gemini-read-only.toml +9 -0
  43. package/skills/ralph-qa/references/gemini-seat.mjs +207 -0
  44. package/skills/ralph-qa/references/payload-scan.mjs +154 -0
  45. package/skills/ralph-qa/references/verifier-probe.mjs +881 -780
  46. package/skills/ready-compact/SKILL.md +1 -1
  47. package/skills/refresh-git-ignore/SKILL.md +1 -1
  48. package/skills/refresh-readme/SKILL.md +1 -1
  49. package/skills/rfp-author/SKILL.md +1 -1
  50. package/skills/setup-bypass-permissions/SKILL.md +123 -0
  51. package/skills/setup-bypass-permissions/agents/openai.yaml +4 -0
  52. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +381 -0
  53. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.ps1 +247 -0
  54. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +241 -0
  55. package/skills/{harness-factory → setup-harness-factory}/SKILL.md +3 -3
  56. package/skills/setup-insane-search/SKILL.md +1 -1
  57. package/skills/setup-java/SKILL.md +1 -1
  58. package/skills/setup-lsp/SKILL.md +1 -1
  59. package/skills/setup-mcp/SKILL.md +1 -1
  60. package/skills/setup-node/SKILL.md +1 -1
  61. package/skills/setup-omc/SKILL.md +1 -1
  62. package/skills/setup-omc-hud/SKILL.md +15 -1
  63. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +247 -0
  64. package/skills/setup-playwright/SKILL.md +1 -1
  65. package/skills/setup-pwsh/SKILL.md +1 -1
  66. package/skills/setup-python/SKILL.md +1 -1
  67. package/skills/setup-sandbox/SKILL.md +1 -1
  68. package/skills/setup-stitch/SKILL.md +1 -1
  69. package/skills/setup-tmux/SKILL.md +1 -1
  70. package/skills/smart-compact/SKILL.md +1 -1
  71. package/skills/summary-wiki/SKILL.md +1 -1
  72. package/skills/tone-compact/SKILL.md +1 -1
  73. package/skills/ultra-init/SKILL.md +1 -1
  74. package/skills/ultra-interview/SKILL.md +1 -1
  75. package/skills/ultra-ui-qa/SKILL.md +1 -1
  76. package/skills/update-banker/SKILL.md +1 -1
  77. package/skills/vertical-pptx/SKILL.md +1 -1
  78. package/skills/vertical-pptx-setup/SKILL.md +1 -1
  79. package/skills/visual-ralph/SKILL.md +1 -1
  80. package/skills/graceful_pause/SKILL.md +0 -95
@@ -0,0 +1,9 @@
1
+ # ralph-qa gemini seat: an admin-tier policy (gemini --admin-policy) that denies every tool. The
2
+ # payload comes on stdin, so the seat needs no tool, and a read tool would also read the folders
3
+ # the settings add to the workspace. The admin tier outranks the user's policies, settings
4
+ # `tools.allowed`, trusted MCP servers and the approval mode (gemini-cli 0.62.0).
5
+ # gemini-seat.mjs check refuses to start the seat when the rules below are changed.
6
+ [[rule]]
7
+ toolName = "*"
8
+ decision = "deny"
9
+ priority = 100
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gemini-seat: what ralph-qa checks before a gemini seat runs and removes after it ran.
4
+ *
5
+ * node gemini-seat.mjs check <seat folder> prints the read-only policy's path (exit 0), or
6
+ * says why the seat must not run (exit 1)
7
+ * node gemini-seat.mjs clean <seat folder> removes gemini's record of that folder's sessions
8
+ * node gemini-seat.mjs sweep <base folder> removes the records of seat folders under it that
9
+ * are gone (a run cut short before its clean)
10
+ *
11
+ * check: the admin policy next to this script must be the shipped deny-all one, as gemini reads
12
+ * a missing or changed policy file without a word and opens its tools (0.62.0). And no folder
13
+ * above the seat folder, outside the home, may hold `.gemini/.env` or `.env`: gemini reads the
14
+ * first one it finds walking up to the root, and a GOOGLE_GEMINI_BASE_URL there sends the
15
+ * payload elsewhere. On Windows any account may create such a folder under C:\.
16
+ * clean: gemini 0.62.0 records every headless session, payload and answer, under
17
+ * <home>/.gemini/tmp/<id>/chats, kept 30 days by default, and no setting stops it. The folders
18
+ * whose `.project_root` names the seat folder go: tmp/<id> and history/<id>, and the same under
19
+ * <home>/.cache/.gemini, where gemini keeps them in its macOS sandbox. A seat still running
20
+ * when its run cleaned up writes its session again into a folder without `.project_root`;
21
+ * projects.json, which maps the seat folder's path to that folder's name, then ties it to the
22
+ * seat, and its entry (a path, no payload) stays. <home> is $GEMINI_CLI_HOME when set, as gemini
23
+ * reads it. The seat folder may be gone already (a run cut short): clean matches its path as
24
+ * written and as resolved through the nearest folder that still exists, and sweep finds every
25
+ * <base>/ralph-qa.<id>/gemini record whose folder is gone. A run still going keeps its folder, so
26
+ * sweep leaves its records. On Windows the paths are compared by their long names (TEMP may be
27
+ * spelt with an 8.3 short one).
28
+ * Exit 2 on a usage error, or for check, a seat folder that does not exist.
29
+ */
30
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { basename, dirname, join, resolve, sep } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ export const POLICY = fileURLToPath(new URL("./gemini-read-only.toml", import.meta.url));
36
+
37
+ // The policy's rules as gemini reads them, comments and blank lines aside: one rule, every tool
38
+ // denied. Any other rule could open a tool to the seat.
39
+ const POLICY_LINES = ["[[rule]]", 'toolName = "*"', 'decision = "deny"', "priority = 100"];
40
+
41
+ // ASCII only, and only spaces and tabs around a rule: gemini's TOML parser drops a file with a
42
+ // BOM or a no-break space, and then opens every tool, while trim() would let one through.
43
+ export function policyIntact(text) {
44
+ if (typeof text !== "string" || /[^\t\r\n\x20-\x7e]/.test(text)) return false;
45
+ const lines = text.split(/\r?\n/).map((l) => l.replace(/#.*/, "").replace(/^[ \t]+|[ \t\r]+$/g, "")).filter(Boolean);
46
+ return lines.length === POLICY_LINES.length && lines.every((l, i) => l === POLICY_LINES[i]);
47
+ }
48
+
49
+ // realpath as the OS gives it: on Windows that spells an 8.3 short name (C:\Users\RUNNER~1) long,
50
+ // as a home folder is. A path that does not exist is resolved through its nearest folder that
51
+ // does, so a seat folder that is gone still matches what gemini recorded under a link above it.
52
+ const OS_REALPATH = process.platform === "win32" ? realpathSync.native : realpathSync;
53
+ const realWith = (realpath) => (p) => {
54
+ const rest = [];
55
+ for (let dir = resolve(p); ; dir = dirname(dir)) {
56
+ try {
57
+ return join(realpath(dir), ...rest);
58
+ } catch {
59
+ if (dirname(dir) === dir) return resolve(p);
60
+ rest.unshift(basename(dir));
61
+ }
62
+ }
63
+ };
64
+ const real = realWith(OS_REALPATH);
65
+ const norm = (p, platform) => (platform === "win32" ? resolve(p).toLowerCase() : resolve(p));
66
+ const geminiHome = (env) => env.GEMINI_CLI_HOME || homedir();
67
+
68
+ function inside(dir, root, platform) {
69
+ const d = norm(dir, platform);
70
+ const r = norm(root, platform);
71
+ return d === r || d.startsWith(r.endsWith(sep) ? r : r + sep);
72
+ }
73
+
74
+ // The .env files gemini could read for a run in `seat` that sit in folders above it outside the
75
+ // home ($GEMINI_CLI_HOME and the account's home both count as the home).
76
+ export function strayEnvFiles(seat, { env = process.env, platform = process.platform, exists = existsSync, realpath = OS_REALPATH } = {}) {
77
+ const longName = realWith(realpath);
78
+ const homes = [geminiHome(env), homedir()].map(longName);
79
+ const found = [];
80
+ for (let dir = longName(seat); ; dir = dirname(dir)) {
81
+ if (!homes.some((h) => inside(dir, h, platform))) {
82
+ found.push(...[join(dir, ".gemini", ".env"), join(dir, ".env")].filter((f) => exists(f)));
83
+ }
84
+ if (dirname(dir) === dir) return found;
85
+ }
86
+ }
87
+
88
+ const listDir = (dir) => {
89
+ try {
90
+ return readdirSync(dir);
91
+ } catch {
92
+ return [];
93
+ }
94
+ };
95
+
96
+ // The folder a session folder's .project_root names; null for a link or a file, undefined for a
97
+ // folder without the marker.
98
+ function projectRoot(dir) {
99
+ try {
100
+ if (!lstatSync(dir).isDirectory()) return null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ try {
105
+ return readFileSync(join(dir, ".project_root"), "utf8").trim();
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ // <g>/projects.json, gemini's map from a project folder to its folder name under tmp/ and
112
+ // history/, read the other way round: folder name to project folder.
113
+ function registered(g) {
114
+ try {
115
+ const { projects } = JSON.parse(readFileSync(join(g, "projects.json"), "utf8"));
116
+ if (!projects || typeof projects !== "object" || Array.isArray(projects)) return new Map();
117
+ return new Map(Object.entries(projects).filter(([, name]) => typeof name === "string").map(([owner, name]) => [name, owner]));
118
+ } catch {
119
+ return new Map();
120
+ }
121
+ }
122
+
123
+ // Each folder gemini keeps under tmp/ or history/, here and under .cache/.gemini, with the
124
+ // folder its .project_root names. A folder without the marker (a seat that wrote its session
125
+ // again after its run cleaned up) gets the project projects.json gives its name. Only folders
126
+ // read from tmp/ and history/ are looked up, so no name there reaches another folder.
127
+ function projectFolders(env) {
128
+ const home = geminiHome(env);
129
+ return [join(home, ".gemini"), join(home, ".cache", ".gemini")].flatMap((g) => {
130
+ const names = registered(g);
131
+ return ["tmp", "history"].flatMap((kind) => listDir(join(g, kind)).map((name) => {
132
+ const dir = join(g, kind, name);
133
+ const root = projectRoot(dir);
134
+ return { dir, owner: root === undefined ? names.get(name) : root };
135
+ }));
136
+ }).filter((p) => p.owner);
137
+ }
138
+
139
+ // The folders gemini keeps for sessions run in `seat`, matched by the path as written and as
140
+ // resolved; the seat folder may be gone already.
141
+ export function sessionFolders(seat, { env = process.env, platform = process.platform } = {}) {
142
+ const want = new Set([norm(seat, platform), norm(real(seat), platform)]);
143
+ return projectFolders(env).filter((p) => want.has(norm(p.owner, platform))).map((p) => p.dir);
144
+ }
145
+
146
+ // The session folders of seat folders under `base` that are gone: .project_root names
147
+ // <base>/ralph-qa.<id>/gemini and that folder no longer exists (a run cut short before its clean).
148
+ // A run still going has its seat folder, so its records stay.
149
+ const SEAT_PARENT = /^ralph-qa\.[A-Za-z0-9]+$/;
150
+ export function staleSessionFolders(base, { env = process.env, platform = process.platform, exists = existsSync } = {}) {
151
+ const roots = new Set([norm(base, platform), norm(real(base), platform)]);
152
+ const stale = ({ owner }) => {
153
+ const o = norm(owner, platform);
154
+ return basename(o) === "gemini" && SEAT_PARENT.test(basename(dirname(o))) && roots.has(dirname(dirname(o))) && !exists(owner);
155
+ };
156
+ return projectFolders(env).filter(stale).map((p) => p.dir);
157
+ }
158
+
159
+ function check(seat) {
160
+ let text = null;
161
+ try {
162
+ text = readFileSync(POLICY, "utf8");
163
+ } catch {
164
+ // reported as not intact below
165
+ }
166
+ if (!policyIntact(text)) {
167
+ process.stderr.write(`gemini-seat: ${POLICY} is missing or is not the shipped deny-all policy; the seat would run with its tools open\n`);
168
+ return 1;
169
+ }
170
+ const stray = strayEnvFiles(seat);
171
+ if (stray.length) {
172
+ process.stderr.write(`gemini-seat: gemini would read ${stray.join(", ")} (above the seat folder, outside the home); remove it or make the seat folder elsewhere\n`);
173
+ return 1;
174
+ }
175
+ process.stdout.write(POLICY + "\n");
176
+ return 0;
177
+ }
178
+
179
+ function remove(dirs) {
180
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
181
+ process.stdout.write(JSON.stringify({ removed: dirs }) + "\n");
182
+ return 0;
183
+ }
184
+
185
+ function main(argv) {
186
+ const [cmd, folder, ...rest] = argv;
187
+ if (!["check", "clean", "sweep"].includes(cmd) || !folder || rest.length) {
188
+ process.stderr.write("usage: node gemini-seat.mjs check|clean <seat folder> | sweep <base folder>\n");
189
+ return 2;
190
+ }
191
+ if (cmd === "check" && !existsSync(folder)) {
192
+ process.stderr.write(`gemini-seat: no such folder: ${folder}\n`);
193
+ return 2;
194
+ }
195
+ if (cmd === "check") return check(folder);
196
+ return remove(cmd === "clean" ? sessionFolders(folder) : staleSessionFolders(folder));
197
+ }
198
+
199
+ const invokedDirectly = () => {
200
+ try {
201
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
202
+ } catch {
203
+ return false;
204
+ }
205
+ };
206
+
207
+ if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * payload-scan: secret-like strings in a file that is about to go to an external CLI seat.
4
+ *
5
+ * node payload-scan.mjs <file>
6
+ *
7
+ * Prints {"file", "count", "hits": [{"line", "kind"}]} and exits 0 when nothing was found,
8
+ * 1 when something was, 2 when the file cannot be read or scanned or the arguments are not one
9
+ * file. A private key hit also carries `end`, the line of its END marker (null when there is
10
+ * none): the whole block is the secret, not just the BEGIN line. The matched text itself is never
11
+ * printed, so the report cannot become the leak. The patterns cover common key and token
12
+ * formats, private key blocks (PGP too), secret assignments (quoted, or unquoted with letters
13
+ * and digits), passwords (quoted, with a symbol, alone on a config or env-file line, or after a
14
+ * Dockerfile `ENV KEY value`), secrets given as command-line flags, curl's user and password flag,
15
+ * hex keys, Basic and Bearer credentials, credentials in URLs and
16
+ * long high-entropy strings (a lockfile's sha512- integrity hash and a Go or Terraform h1: hash
17
+ * aside); they do not prove a payload clean. A Docker secret file's path after a *_FILE key is
18
+ * not read as the password it points to. Every repeat and lookahead is bounded (a run longer than
19
+ * 65536 characters is read in pieces) or anchored, so a long line takes linear time and cannot
20
+ * overflow the regex stack.
21
+ */
22
+ import { readFileSync, realpathSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const SECRET_KEY = String.raw`(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|access[_-]?key|credential|비밀번호|패스워드)[\w-]{0,64}["']?\s*[:=]\s*`;
26
+ // `pass` and `passphrase` count only as a word of their own (DB_PASS, "pass"), not inside bypass.
27
+ const PASS_WORD = String.raw`(?:passw(?:or)?d|pwd|passcode|(?<![a-z])pass(?:phrase)?(?![a-z]))`;
28
+ const PASSWORD_KEY = String.raw`(?:${PASS_WORD}|비밀번호|패스워드)[\w-]{0,64}["']?\s*[:=]\s*`;
29
+ // An unquoted value that is not code: none of the characters an expression or a reference
30
+ // holds, and at least one symbol (an identifier has none).
31
+ const BARE = String.raw`[^\s;,"'().\[\]{}$<>]`;
32
+ // at most 8 words before PRIVATE KEY (RSA, EC, OPENSSH, ENCRYPTED, PGP): an open nested repeat
33
+ // overflows V8's regex stack on a key marker before millions of words
34
+ const PRIVATE_KEY = String.raw`(?:[A-Z0-9]{1,32} ){0,8}PRIVATE KEY(?: BLOCK)?-----`;
35
+
36
+ export const PATTERNS = [
37
+ ["aws-access-key", /(?<![A-Z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])/],
38
+ ["github-token", /(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,65536}|github_pat_[A-Za-z0-9_]{22,65536})/],
39
+ ["gitlab-token", /(?<![A-Za-z0-9])glpat-[A-Za-z0-9_-]{20,65536}/],
40
+ ["anthropic-key", /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,65536}/],
41
+ ["openai-key", /(?<![A-Za-z0-9-])sk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,65536}/],
42
+ ["stripe-key", /(?<![A-Za-z0-9])[rs]k_(?:live|test)_[A-Za-z0-9]{16,65536}/],
43
+ ["npm-token", /(?<![A-Za-z0-9])npm_[A-Za-z0-9]{36}/],
44
+ ["huggingface-token", /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{30,65536}/],
45
+ ["google-api-key", /(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}/],
46
+ ["slack-token", /(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{10,65536}/],
47
+ ["slack-webhook", /hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/],
48
+ ["private-key", new RegExp(`-----BEGIN ${PRIVATE_KEY}`)],
49
+ ["jwt", /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,65536}\.eyJ[A-Za-z0-9_-]{10,65536}\.[A-Za-z0-9_-]{10,65536}/],
50
+ ["bearer-token", /\bbearer\s+[A-Za-z0-9._~+/-]{20,65536}/i],
51
+ ["basic-auth", /\bauthorization["']?\s*[:=]\s*["']?basic\s+[A-Za-z0-9+/=]{8,65536}/i],
52
+ ["url-credentials", /[a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]{0,256}:[^\s@/]{3,256}@/i],
53
+ // quoted: 12 characters or more, or 8 to 11 holding a digit (a word like "development" is not one)
54
+ ["assigned-secret", new RegExp(SECRET_KEY + String.raw`["'](?:[^"'\s]{12,256}|(?=[^"'\s]{0,10}\d)[^"'\s]{8,11})["']`, "i")],
55
+ ["assigned-secret-bare", new RegExp(SECRET_KEY + String.raw`(?=[^\s"']{0,256}\d)(?=[^\s"']{0,256}[A-Za-z])[^\s"'(\[{]{12,256}`, "i")],
56
+ ["assigned-password", new RegExp(PASSWORD_KEY + String.raw`(?:["'][^"'\s]{6,256}["']|(?=${BARE}{0,256}[^\w\s;,"'().\[\]{}$<>])${BARE}{6,256})`, "i")],
57
+ ["env-password", /^[+\- ]?\s*(?:export\s+)?[A-Za-z0-9_]{0,64}(?:passw(?:or)?d|pwd|passcode)[A-Za-z0-9_]{0,64}=[^\s$"'][^\s"']{5,256}/i],
58
+ // a password alone on a config line: YAML, compose lists, .properties, Dockerfile ENV, diff marks
59
+ ["config-password", new RegExp(String.raw`^[+\- ]?\s*(?:-\s+|export\s+|ENV\s+)?[\w.-]{0,64}${PASS_WORD}[\w.-]{0,64}["']?\s*[:=]\s*["']?(?![-$<{%])[^\s"',;()\[\]{}]{6,256}["']?\s*(?:#.*)?$`, "i")],
60
+ // a Dockerfile's legacy `ENV KEY value` form, the value alone after the space
61
+ ["dockerfile-env-password", new RegExp(String.raw`^[+\- ]?\s*ENV\s+[\w.-]{0,64}${PASS_WORD}[\w.-]{0,64}\s+["']?(?![-$<{%])[^\s"',;()\[\]{}=]{6,256}["']?\s*(?:#.*)?$`, "i")],
62
+ ["flag-secret", /--(?:password|passwd|pass|token|api-key|apikey|secret|client-secret)(?:=|\s+)(?![-$])[^\s"']{6,256}/i],
63
+ // curl's -u flag with a user and password pair, glued to it or not; a uid:gid (docker) that is
64
+ // the whole value, quoted or not, a host:/path (rsync), a date format (date -u +%H:%M) or an id
65
+ // substitution ($(id -u):$(id -g)) is no credential. A user:group of names is not told apart:
66
+ // that would let admin:admin through.
67
+ ["curl-user", /(?<!\S)(?:-u\s*|--user(?:\s+|=))(?!(["']?)\d{1,10}:\d{1,10}\1(?=["'`\s;|&)]|$))(?!["']?\+%|\))[^\s:=]{1,256}:(?!\/)\S{3,256}/],
68
+ ["assigned-hex", /(?:key|auth|token|secret)[\w-]{0,64}["']?\s*[:=]\s*["']?[0-9a-f]{32,65536}(?![0-9a-z])/i],
69
+ ];
70
+
71
+ // Long runs of base64-like characters whose Shannon entropy is that of random data, as
72
+ // lineage's redaction measures them (32 characters or more, 4.5 bits per character or more).
73
+ const LONG_RUN = /[A-Za-z0-9+/=]{32,65536}/g;
74
+ const ENTROPY_FLOOR = 4.5;
75
+
76
+ export function entropy(s) {
77
+ const counts = new Map();
78
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
79
+ let bits = 0;
80
+ for (const n of counts.values()) bits -= (n / s.length) * Math.log2(n / s.length);
81
+ return bits;
82
+ }
83
+
84
+ // A subresource-integrity hash (`sha512-<base64>`, as lockfiles hold) and a Go module or
85
+ // Terraform lock hash (`h1:` and 43 base64 characters and `=`, as go.sum and .terraform.lock.hcl
86
+ // hold) are public checksums.
87
+ const SRI_PREFIX = /sha(?:256|384|512)-$/;
88
+ const H1_PREFIX = /(?:^|[^A-Za-z0-9+/=])h1:$/;
89
+ const publicHash = (line, m) => SRI_PREFIX.test(line.slice(Math.max(0, m.index - 7), m.index))
90
+ || (/^[A-Za-z0-9+/]{43}=$/.test(m[0]) && H1_PREFIX.test(line.slice(Math.max(0, m.index - 4), m.index)));
91
+ const highEntropy = (line) => [...line.matchAll(LONG_RUN)].some((m) => !publicHash(line, m) && entropy(m[0]) >= ENTROPY_FLOOR);
92
+
93
+ // A *_FILE key's absolute path, the Docker secret file official images take a password from, is
94
+ // not that password: the kinds that read a value after a key name see a `-` there instead. The
95
+ // other kinds still read the line as it is, so a key's own format shows wherever it sits.
96
+ const SECRET_FILE_PATH = /(?<![\w.-])([\w.-]{0,128}_FILE["']?(?:[ \t]*[:=][ \t]*|[ \t]+)["']?)\/[\w.@-]{1,128}(?:\/[\w.@-]{1,128}){0,32}(?=["']?(?:[\s,;#]|$))/gi;
97
+ const KEYED = new Set(["assigned-secret", "assigned-secret-bare", "assigned-password", "env-password", "config-password",
98
+ "dockerfile-env-password"]);
99
+
100
+ const KEY_END = new RegExp(`-----END ${PRIVATE_KEY}`);
101
+
102
+ // Each line's secret-like kinds, once per kind and line. A private key closes at the first END
103
+ // marker at or after its line: the END lines are found once and walked with the lines, so many
104
+ // BEGIN lines without an END still take linear time.
105
+ export function scan(text) {
106
+ const hits = [];
107
+ const lines = String(text).split(/\r?\n/);
108
+ const ends = lines.flatMap((l, i) => (KEY_END.test(l) ? [i + 1] : []));
109
+ let next = 0;
110
+ lines.forEach((line, i) => {
111
+ while (next < ends.length && ends[next] < i + 1) next += 1;
112
+ const keyed = line.replace(SECRET_FILE_PATH, "$1-");
113
+ for (const [kind, re] of PATTERNS) {
114
+ if (!re.test(KEYED.has(kind) ? keyed : line)) continue;
115
+ hits.push(kind === "private-key" ? { line: i + 1, kind, end: ends[next] ?? null } : { line: i + 1, kind });
116
+ }
117
+ if (highEntropy(line)) hits.push({ line: i + 1, kind: "high-entropy" });
118
+ });
119
+ return { count: hits.length, hits };
120
+ }
121
+
122
+ function main(argv) {
123
+ if (argv.length !== 1) {
124
+ process.stderr.write("usage: node payload-scan.mjs <file> (exactly one file)\n");
125
+ return 2;
126
+ }
127
+ const file = argv[0];
128
+ let text;
129
+ try {
130
+ text = readFileSync(file, "utf8");
131
+ } catch (e) {
132
+ process.stderr.write(`payload-scan: cannot read ${file}: ${e.code ?? e.message}\n`);
133
+ return 2;
134
+ }
135
+ let result;
136
+ try {
137
+ result = scan(text);
138
+ } catch (e) {
139
+ process.stderr.write(`payload-scan: cannot scan ${file}: ${e.message}\n`);
140
+ return 2;
141
+ }
142
+ process.stdout.write(JSON.stringify({ file, ...result }, null, 2) + "\n");
143
+ return result.count === 0 ? 0 : 1;
144
+ }
145
+
146
+ const invokedDirectly = () => {
147
+ try {
148
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
149
+ } catch {
150
+ return false;
151
+ }
152
+ };
153
+
154
+ if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));