@starterculture/devkeep-actions 0.0.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.
- package/LICENSE +13 -0
- package/README.md +16 -0
- package/dist/consolidate-release.js +450 -0
- package/dist/crypting-candidates-scan.js +371 -0
- package/dist/draft-log-entry.js +718 -0
- package/package.json +31 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../../modules/core/dist/github/client.js
|
|
4
|
+
import { Octokit } from "@octokit/rest";
|
|
5
|
+
function createGithubClient(token) {
|
|
6
|
+
if (!token) {
|
|
7
|
+
throw new Error("GitHub token is required");
|
|
8
|
+
}
|
|
9
|
+
return new Octokit({ auth: token });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ../../modules/core/dist/github/retryOnConflict.js
|
|
13
|
+
async function retryOnConflict(attempt, maxAttempts = 3) {
|
|
14
|
+
for (let tryNumber = 1; ; tryNumber++) {
|
|
15
|
+
try {
|
|
16
|
+
return await attempt();
|
|
17
|
+
} catch (error) {
|
|
18
|
+
const status = error.status;
|
|
19
|
+
if (tryNumber >= maxAttempts || status !== 409 && status !== 422) {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, 300 * tryNumber));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ../../modules/devlore/dist/diff.js
|
|
28
|
+
function toDiffFile(file) {
|
|
29
|
+
return {
|
|
30
|
+
filename: file.filename,
|
|
31
|
+
status: file.status,
|
|
32
|
+
patch: file.patch ?? "(no patch available)"
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
async function getProjectDiff(octokit, { owner, repo, base, head }) {
|
|
36
|
+
try {
|
|
37
|
+
const { data } = await octokit.rest.repos.compareCommitsWithBasehead({
|
|
38
|
+
owner,
|
|
39
|
+
repo,
|
|
40
|
+
basehead: `${base}...${head}`
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
commitMessages: data.commits.map((commit) => commit.commit.message),
|
|
44
|
+
files: (data.files ?? []).map(toDiffFile)
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
const { data } = await octokit.rest.repos.getCommit({ owner, repo, ref: head });
|
|
48
|
+
return {
|
|
49
|
+
commitMessages: [data.commit.message],
|
|
50
|
+
files: (data.files ?? []).map(toDiffFile)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ../../modules/devcrypt/dist/patterns.js
|
|
56
|
+
var SECRET_PREFIX_PATTERNS = [
|
|
57
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
58
|
+
// AWS access key id
|
|
59
|
+
/\bsk-(ant-)?[A-Za-z0-9_-]{20,}\b/,
|
|
60
|
+
// OpenAI / Anthropic secret key
|
|
61
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/,
|
|
62
|
+
// GitHub personal access / other tokens
|
|
63
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
|
|
64
|
+
// GitHub fine-grained PAT
|
|
65
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
66
|
+
// Slack tokens
|
|
67
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/,
|
|
68
|
+
// Google API key
|
|
69
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/
|
|
70
|
+
// JWT-shaped
|
|
71
|
+
];
|
|
72
|
+
var PEM_BLOCK_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
73
|
+
var SECRET_KEYWORD = "(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|CREDENTIAL)";
|
|
74
|
+
var SECRET_ASSIGNMENT_PATTERN = new RegExp(`(?:[A-Za-z0-9]+[_-])*${SECRET_KEYWORD}(?:[_-][A-Za-z0-9]+)*\\s*[:=]\\s*["']([^"'\`]+)["']`, "i");
|
|
75
|
+
|
|
76
|
+
// ../../modules/devcrypt/dist/candidateScan.js
|
|
77
|
+
function addedLines(patch) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
let newLineNumber = 0;
|
|
80
|
+
for (const rawLine of patch.split("\n")) {
|
|
81
|
+
const hunkHeader = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(rawLine);
|
|
82
|
+
if (hunkHeader) {
|
|
83
|
+
newLineNumber = Number(hunkHeader[1]);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (rawLine.startsWith("+++") || rawLine.startsWith("---")) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (rawLine.startsWith("+")) {
|
|
90
|
+
lines.push({ text: rawLine.slice(1), lineNumber: newLineNumber });
|
|
91
|
+
newLineNumber++;
|
|
92
|
+
} else if (rawLine.startsWith(" ")) {
|
|
93
|
+
newLineNumber++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
function redactMatch(value) {
|
|
99
|
+
if (value.length <= 10) {
|
|
100
|
+
return "*".repeat(value.length);
|
|
101
|
+
}
|
|
102
|
+
return `${value.slice(0, 4)}${"*".repeat(value.length - 8)}${value.slice(-4)}`;
|
|
103
|
+
}
|
|
104
|
+
var VENDOR_URL_ASSIGNMENT_PATTERN = /(?:=|:|\()\s*["'](https?:\/\/(?!localhost|127\.0\.0\.1|(?:[a-z0-9-]+\.)?(?:github\.com|npmjs\.com|anthropic\.com))[a-z0-9.-]+\.[a-z]{2,}[^\s"']*)["']/i;
|
|
105
|
+
var ENV_VAR_REFERENCE_PATTERN = /\b(?:process\.env\.([A-Z_][A-Z0-9_]*)|os\.environ(?:\.get)?\s*\(\s*["']([A-Z_][A-Z0-9_]*)|os\.environ\s*\[\s*["']([A-Z_][A-Z0-9_]*)["']\s*\])/;
|
|
106
|
+
var VENDOR_IMPORT_PATTERN = /(?:import\s+(?:[\s\S]*?\sfrom\s+)?["'](?!\.|@devkeep\/|node:)([a-zA-Z0-9@][a-zA-Z0-9@/_.-]*)["']|require\(\s*["'](?!\.|@devkeep\/|node:)([a-zA-Z0-9@][a-zA-Z0-9@/_.-]*)["']\s*\))/;
|
|
107
|
+
function scanLineForSecrets(text, filename, lineNumber) {
|
|
108
|
+
const drafts = [];
|
|
109
|
+
for (const pattern of SECRET_PREFIX_PATTERNS) {
|
|
110
|
+
const match = pattern.exec(text);
|
|
111
|
+
if (match) {
|
|
112
|
+
drafts.push({
|
|
113
|
+
category: "secret",
|
|
114
|
+
label: `Possible key/token in ${filename}`,
|
|
115
|
+
reason: "Matches a known API key/token format",
|
|
116
|
+
sourceFile: filename,
|
|
117
|
+
sourceLine: lineNumber,
|
|
118
|
+
matchedValue: redactMatch(match[0])
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (PEM_BLOCK_PATTERN.test(text)) {
|
|
123
|
+
drafts.push({
|
|
124
|
+
category: "secret",
|
|
125
|
+
label: `Possible private key in ${filename}`,
|
|
126
|
+
reason: "Looks like a private key block",
|
|
127
|
+
sourceFile: filename,
|
|
128
|
+
sourceLine: lineNumber,
|
|
129
|
+
matchedValue: "-----BEGIN [REDACTED] PRIVATE KEY-----"
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const assignmentMatch = SECRET_ASSIGNMENT_PATTERN.exec(text);
|
|
133
|
+
if (assignmentMatch?.[1]) {
|
|
134
|
+
drafts.push({
|
|
135
|
+
category: "secret",
|
|
136
|
+
label: `Possible hardcoded secret in ${filename}`,
|
|
137
|
+
reason: "Assigns what looks like a secret value directly, not from an env/config reference",
|
|
138
|
+
sourceFile: filename,
|
|
139
|
+
sourceLine: lineNumber,
|
|
140
|
+
matchedValue: redactMatch(assignmentMatch[1])
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return drafts;
|
|
144
|
+
}
|
|
145
|
+
function scanLineForVendorArtifacts(text, filename, lineNumber) {
|
|
146
|
+
const drafts = [];
|
|
147
|
+
const urlMatch = VENDOR_URL_ASSIGNMENT_PATTERN.exec(text);
|
|
148
|
+
if (urlMatch?.[1]) {
|
|
149
|
+
drafts.push({
|
|
150
|
+
category: "vendor",
|
|
151
|
+
label: `New vendor URL in ${filename}`,
|
|
152
|
+
reason: "A new external URL assigned to a config value",
|
|
153
|
+
sourceFile: filename,
|
|
154
|
+
sourceLine: lineNumber,
|
|
155
|
+
matchedValue: urlMatch[1]
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const envMatch = ENV_VAR_REFERENCE_PATTERN.exec(text);
|
|
159
|
+
if (envMatch) {
|
|
160
|
+
const name = envMatch[1] ?? envMatch[2] ?? envMatch[3];
|
|
161
|
+
drafts.push({
|
|
162
|
+
category: "vendor",
|
|
163
|
+
label: `New env-var reference in ${filename}`,
|
|
164
|
+
reason: "References a new environment variable",
|
|
165
|
+
sourceFile: filename,
|
|
166
|
+
sourceLine: lineNumber,
|
|
167
|
+
matchedValue: name ?? text.trim()
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const importMatch = VENDOR_IMPORT_PATTERN.exec(text);
|
|
171
|
+
if (importMatch) {
|
|
172
|
+
const packageName = importMatch[1] ?? importMatch[2];
|
|
173
|
+
drafts.push({
|
|
174
|
+
category: "vendor",
|
|
175
|
+
label: `New vendor import in ${filename}`,
|
|
176
|
+
reason: "Imports a new third-party package",
|
|
177
|
+
sourceFile: filename,
|
|
178
|
+
sourceLine: lineNumber,
|
|
179
|
+
matchedValue: packageName ?? text.trim()
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return drafts;
|
|
183
|
+
}
|
|
184
|
+
function scanDiffFile(file) {
|
|
185
|
+
const drafts = [];
|
|
186
|
+
for (const { text, lineNumber } of addedLines(file.patch)) {
|
|
187
|
+
drafts.push(...scanLineForSecrets(text, file.filename, lineNumber));
|
|
188
|
+
drafts.push(...scanLineForVendorArtifacts(text, file.filename, lineNumber));
|
|
189
|
+
}
|
|
190
|
+
return drafts;
|
|
191
|
+
}
|
|
192
|
+
function scanDiffForCandidates(diff) {
|
|
193
|
+
return diff.files.flatMap(scanDiffFile);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ../../modules/devcrypt/dist/blindIndex.js
|
|
197
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
198
|
+
function computeBlindIndex(value, key) {
|
|
199
|
+
return createHmac("sha256", key).update(value, "utf-8").digest("hex");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ../../modules/devcrypt/dist/types.js
|
|
203
|
+
function normalizeEntry(raw) {
|
|
204
|
+
if (Array.isArray(raw.items)) {
|
|
205
|
+
return raw;
|
|
206
|
+
}
|
|
207
|
+
const legacy = raw;
|
|
208
|
+
const { encrypted, value, wrappedDek, keyId, iv, authTag, blindIndexHash, ...rest } = legacy;
|
|
209
|
+
return {
|
|
210
|
+
...rest,
|
|
211
|
+
items: [
|
|
212
|
+
{
|
|
213
|
+
id: legacy.id,
|
|
214
|
+
label: "",
|
|
215
|
+
encrypted,
|
|
216
|
+
value,
|
|
217
|
+
wrappedDek,
|
|
218
|
+
keyId,
|
|
219
|
+
iv,
|
|
220
|
+
authTag,
|
|
221
|
+
blindIndexHash,
|
|
222
|
+
legacyAad: true
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ../../modules/devcrypt/dist/vaultStore.js
|
|
229
|
+
function isNotFound(error) {
|
|
230
|
+
return typeof error === "object" && error !== null && "status" in error && error.status === 404;
|
|
231
|
+
}
|
|
232
|
+
async function getExistingFile(octokit, { owner, repo }, path) {
|
|
233
|
+
try {
|
|
234
|
+
const { data } = await octokit.rest.repos.getContent({ owner, repo, path });
|
|
235
|
+
if (Array.isArray(data) || data.type !== "file") {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return { content: Buffer.from(data.content, "base64").toString("utf-8"), sha: data.sha };
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (isNotFound(error)) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
var VAULT_ROOT = "devcrypt";
|
|
247
|
+
var INDEX_PATH = `${VAULT_ROOT}/index.json`;
|
|
248
|
+
function parseIndexValue(raw) {
|
|
249
|
+
const hash = raw.lastIndexOf("#");
|
|
250
|
+
return hash === -1 ? { path: raw } : { path: raw.slice(0, hash), itemId: raw.slice(hash + 1) };
|
|
251
|
+
}
|
|
252
|
+
async function readIndex(octokit, repoRef) {
|
|
253
|
+
const existing = await getExistingFile(octokit, repoRef, INDEX_PATH);
|
|
254
|
+
if (!existing) {
|
|
255
|
+
return { map: {} };
|
|
256
|
+
}
|
|
257
|
+
return { map: JSON.parse(existing.content), sha: existing.sha };
|
|
258
|
+
}
|
|
259
|
+
var ENTRY_SUBTREES = [`${VAULT_ROOT}/personal/`, `${VAULT_ROOT}/projects/`, `${VAULT_ROOT}/unscoped/`];
|
|
260
|
+
async function findDuplicate(octokit, repoRef, blindIndexHash) {
|
|
261
|
+
const { map } = await readIndex(octokit, repoRef);
|
|
262
|
+
const raw = map[blindIndexHash];
|
|
263
|
+
if (!raw) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const { path, itemId } = parseIndexValue(raw);
|
|
267
|
+
const existing = await getExistingFile(octokit, repoRef, path);
|
|
268
|
+
return existing ? { entry: normalizeEntry(JSON.parse(existing.content)), itemId } : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ../../modules/devcrypt/dist/candidateStore.js
|
|
272
|
+
var CANDIDATES_ROOT = `${VAULT_ROOT}/candidates`;
|
|
273
|
+
function candidatePath(id) {
|
|
274
|
+
return `${CANDIDATES_ROOT}/${id}.json`;
|
|
275
|
+
}
|
|
276
|
+
async function writeCandidate(octokit, repoRef, candidate) {
|
|
277
|
+
await retryOnConflict(async () => {
|
|
278
|
+
const path = candidatePath(candidate.id);
|
|
279
|
+
const existing = await getExistingFile(octokit, repoRef, path);
|
|
280
|
+
await octokit.rest.repos.createOrUpdateFileContents({
|
|
281
|
+
owner: repoRef.owner,
|
|
282
|
+
repo: repoRef.repo,
|
|
283
|
+
path,
|
|
284
|
+
message: existing ? `Update ${path} (Devcrypt)` : `Add ${path} (Devcrypt)`,
|
|
285
|
+
content: Buffer.from(JSON.stringify(candidate, null, 2), "utf-8").toString("base64"),
|
|
286
|
+
sha: existing?.sha
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ../../modules/devcrypt/dist/actions/scanCryptingCandidates.js
|
|
292
|
+
import { randomUUID } from "node:crypto";
|
|
293
|
+
async function scanCryptingCandidates(params) {
|
|
294
|
+
const { projectOctokit, vaultOctokit, projectOwner, projectRepoName, vaultRepo, baseSha, headSha, blindIndexKey } = params;
|
|
295
|
+
const diff = await getProjectDiff(projectOctokit, {
|
|
296
|
+
owner: projectOwner,
|
|
297
|
+
repo: projectRepoName,
|
|
298
|
+
base: baseSha,
|
|
299
|
+
head: headSha
|
|
300
|
+
});
|
|
301
|
+
const drafts = scanDiffForCandidates(diff);
|
|
302
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
303
|
+
let candidatesFlagged = 0;
|
|
304
|
+
for (const draft of drafts) {
|
|
305
|
+
if (draft.category === "vendor") {
|
|
306
|
+
const blindIndexHash = computeBlindIndex(draft.matchedValue, blindIndexKey);
|
|
307
|
+
const existingEntry = await findDuplicate(vaultOctokit, vaultRepo, blindIndexHash);
|
|
308
|
+
if (existingEntry) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const candidate = {
|
|
313
|
+
id: randomUUID(),
|
|
314
|
+
status: "pending",
|
|
315
|
+
commitSha: headSha,
|
|
316
|
+
projectOwner,
|
|
317
|
+
projectRepo: projectRepoName,
|
|
318
|
+
flaggedAt: now,
|
|
319
|
+
...draft
|
|
320
|
+
};
|
|
321
|
+
await writeCandidate(vaultOctokit, vaultRepo, candidate);
|
|
322
|
+
candidatesFlagged++;
|
|
323
|
+
}
|
|
324
|
+
return { candidatesFlagged };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ../../modules/devcrypt/dist/bin/scanCryptingCandidatesCli.js
|
|
328
|
+
function requireEnv(env, name) {
|
|
329
|
+
const value = env[name];
|
|
330
|
+
if (!value) {
|
|
331
|
+
throw new Error(`${name} is not set`);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
var defaultDeps = {
|
|
336
|
+
env: process.env,
|
|
337
|
+
scanCryptingCandidates,
|
|
338
|
+
createGithubClient,
|
|
339
|
+
log: console.log
|
|
340
|
+
};
|
|
341
|
+
async function main(deps = defaultDeps) {
|
|
342
|
+
const { env } = deps;
|
|
343
|
+
const [projectOwner, projectRepoName] = requireEnv(env, "PROJECT_REPO").split("/");
|
|
344
|
+
const [vaultOwner, vaultRepo] = requireEnv(env, "VAULT_REPO").split("/");
|
|
345
|
+
const baseSha = requireEnv(env, "BASE_SHA");
|
|
346
|
+
const headSha = requireEnv(env, "HEAD_SHA");
|
|
347
|
+
const projectOctokit = deps.createGithubClient(requireEnv(env, "GITHUB_TOKEN"));
|
|
348
|
+
const vaultOctokit = deps.createGithubClient(requireEnv(env, "DEVKEEP_GITHUB_TOKEN"));
|
|
349
|
+
const blindIndexKey = Buffer.from(requireEnv(env, "DEVCRYPT_BLIND_INDEX_KEY"), "base64");
|
|
350
|
+
const result = await deps.scanCryptingCandidates({
|
|
351
|
+
projectOctokit,
|
|
352
|
+
vaultOctokit,
|
|
353
|
+
projectOwner,
|
|
354
|
+
projectRepoName,
|
|
355
|
+
vaultRepo: { owner: vaultOwner, repo: vaultRepo },
|
|
356
|
+
baseSha,
|
|
357
|
+
headSha,
|
|
358
|
+
blindIndexKey
|
|
359
|
+
});
|
|
360
|
+
deps.log(`Flagged ${result.candidatesFlagged} crypting candidate(s)`);
|
|
361
|
+
}
|
|
362
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
363
|
+
main().catch((error) => {
|
|
364
|
+
console.error(error);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
export {
|
|
369
|
+
main,
|
|
370
|
+
requireEnv
|
|
371
|
+
};
|