actions-warden 0.2.0 → 0.4.0

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 (63) hide show
  1. package/AGENTS.md +199 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +284 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +30 -3
  6. package/docs/AI-AGENTS.md +474 -0
  7. package/docs/CLI.md +474 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +373 -0
  10. package/docs/GITHUB-ACTION.md +285 -0
  11. package/docs/JAVASCRIPT-API.md +357 -0
  12. package/docs/OUTPUTS.md +414 -0
  13. package/docs/README.md +27 -0
  14. package/examples/org-scan.yml +42 -0
  15. package/examples/upgrade-pr.yml +57 -0
  16. package/llms.txt +38 -0
  17. package/package.json +32 -10
  18. package/skills/actions-warden/SKILL.md +151 -40
  19. package/src/action.js +306 -0
  20. package/src/cli.js +494 -56
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +544 -0
  23. package/src/commands/pin.js +59 -56
  24. package/src/commands/report.js +122 -10
  25. package/src/commands/upgrade.js +102 -62
  26. package/src/commands/verify.js +193 -0
  27. package/src/index.js +21 -4
  28. package/src/lib/action-status.js +27 -0
  29. package/src/lib/agent-mode.js +174 -0
  30. package/src/lib/annotations.js +250 -0
  31. package/src/lib/baseline.js +103 -0
  32. package/src/lib/cache.js +47 -10
  33. package/src/lib/concurrency.js +27 -0
  34. package/src/lib/config.js +185 -0
  35. package/src/lib/execution.js +71 -0
  36. package/src/lib/formatter.js +127 -8
  37. package/src/lib/github-org.js +374 -0
  38. package/src/lib/identity.js +62 -0
  39. package/src/lib/ignore.js +7 -6
  40. package/src/lib/org-checkpoint.js +461 -0
  41. package/src/lib/org-progress.js +60 -0
  42. package/src/lib/parser.js +326 -52
  43. package/src/lib/patcher.js +199 -0
  44. package/src/lib/path-equality.js +30 -0
  45. package/src/lib/paths.js +35 -12
  46. package/src/lib/redact.js +65 -4
  47. package/src/lib/resolver.js +225 -43
  48. package/src/lib/targets.js +28 -0
  49. package/src/lib/triggers.js +12 -0
  50. package/src/lib/writer.js +48 -8
  51. package/src/rules/excessive-permissions.js +24 -33
  52. package/src/rules/index.js +19 -1
  53. package/src/rules/pull-request-target-checkout.js +149 -18
  54. package/src/rules/reusable-workflow-secrets.js +32 -0
  55. package/src/rules/script-injection.js +77 -12
  56. package/src/rules/secrets-in-env.js +101 -18
  57. package/src/rules/unpinned-action.js +3 -2
  58. package/src/rules/unpinned-container-image.js +39 -0
  59. package/src/rules/unpinned-docker-action.js +30 -0
  60. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  61. package/src/rules/workflow-run-artifact-execution.js +122 -0
  62. package/src/rules/workflow-structure.js +396 -0
  63. package/src/version.js +3 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Durable organization-scan checkpoints.
3
+ *
4
+ * Checkpoints contain validated report data and Git tree revisions, never
5
+ * tokens or raw workflow YAML. Every update uses the guarded atomic writer.
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { readFile, stat } from 'node:fs/promises';
10
+ import { resolve } from 'node:path';
11
+ import { resolveRepositoryFile } from './config.js';
12
+ import { summarize } from './formatter.js';
13
+ import { isWorkflowPath } from './github-org.js';
14
+ import { canonicalPath } from './identity.js';
15
+ import { redactDeep } from './redact.js';
16
+ import { writeFileGuarded } from './writer.js';
17
+ import { listRules } from '../rules/index.js';
18
+ import { VERSION } from '../version.js';
19
+
20
+ const CHECKPOINT_KIND = 'actions-warden-org-scan';
21
+ const CHECKPOINT_SCHEMA_VERSION = '1.1';
22
+ const LEGACY_CHECKPOINT_SCHEMA_VERSION = '1.0';
23
+ const FIRST_ANALYSIS_GENERATION_TOOL_VERSION = '0.3.0';
24
+ const TOOL_VERSION_RE = /^\d+\.\d+\.\d+$/;
25
+ // Increment this whenever organization discovery, parsing, finding identity,
26
+ // rule evaluation, or persisted result semantics can change. Package releases
27
+ // that leave those behaviors compatible must keep the generation unchanged.
28
+ export const ORGANIZATION_ANALYSIS_GENERATION = 1;
29
+ export const MAX_ORGANIZATION_CHECKPOINT_BYTES = 256 * 1024 * 1024;
30
+ const NAME_RE = /^[A-Za-z0-9_.-]+$/;
31
+ const SHA_RE = /^[0-9a-f]{40}$/i;
32
+ const ID_RE = /^[0-9a-f]{16}$/;
33
+ const SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
34
+ const RULE_IDS = new Set(['parse-error', ...listRules().map(rule => rule.id)]);
35
+ const RULES_HASH = digest(listRules());
36
+
37
+ export function createOrganizationCheckpointIdentity({
38
+ organization,
39
+ repositories,
40
+ visibility,
41
+ includeArchived,
42
+ includeDisabled,
43
+ includeForks,
44
+ maxRepositories,
45
+ severity,
46
+ explain,
47
+ config,
48
+ baselineData,
49
+ }) {
50
+ return {
51
+ analysisGeneration: ORGANIZATION_ANALYSIS_GENERATION,
52
+ rulesHash: RULES_HASH,
53
+ organization: organization.toLowerCase(),
54
+ scope: {
55
+ repositories,
56
+ visibility,
57
+ includeArchived,
58
+ includeDisabled,
59
+ includeForks,
60
+ maxRepositories: maxRepositories ?? null,
61
+ severity: severity ?? null,
62
+ explain,
63
+ },
64
+ configHash: digest({
65
+ ignorePaths: config.ignorePaths,
66
+ rules: config.rules,
67
+ runnerPolicy: config.runnerPolicy,
68
+ }),
69
+ baselineHash: digest({
70
+ ids: [...baselineData.ids].sort(),
71
+ fingerprints: [...baselineData.fingerprints].sort(),
72
+ }),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Create the stable key used by automatic agent artifacts.
78
+ *
79
+ * Generation 1 deliberately retains the exact identity serialization used by
80
+ * v0.3.0 so existing automatic checkpoints remain discoverable after an
81
+ * upgrade. Later generations use the version-independent compatibility
82
+ * identity directly.
83
+ */
84
+ export function createOrganizationCheckpointArtifactKey(identity) {
85
+ const compatibleIdentity = identity.analysisGeneration === 1
86
+ ? {
87
+ toolVersion: FIRST_ANALYSIS_GENERATION_TOOL_VERSION,
88
+ rulesHash: identity.rulesHash,
89
+ organization: identity.organization,
90
+ scope: {
91
+ repositories: identity.scope.repositories,
92
+ visibility: identity.scope.visibility,
93
+ includeArchived: identity.scope.includeArchived,
94
+ includeDisabled: identity.scope.includeDisabled,
95
+ includeForks: identity.scope.includeForks,
96
+ maxRepositories: identity.scope.maxRepositories,
97
+ severity: identity.scope.severity,
98
+ explain: identity.scope.explain,
99
+ },
100
+ configHash: identity.configHash,
101
+ baselineHash: identity.baselineHash,
102
+ }
103
+ : identity;
104
+ return createHash('sha256')
105
+ .update(identity.analysisGeneration === 1
106
+ ? JSON.stringify(compatibleIdentity)
107
+ : stableStringify(compatibleIdentity))
108
+ .digest('hex')
109
+ .slice(0, 32);
110
+ }
111
+
112
+ export async function loadOrganizationCheckpoint({ path, cwd, identity }) {
113
+ const resolvedPath = await resolveRepositoryFile(path, cwd);
114
+ const metadata = await stat(resolvedPath);
115
+ if (!metadata.isFile()) throw new Error('organization checkpoint must be a file');
116
+ if (metadata.size > MAX_ORGANIZATION_CHECKPOINT_BYTES) {
117
+ throw new Error(
118
+ `organization checkpoint exceeds ${MAX_ORGANIZATION_CHECKPOINT_BYTES} bytes`,
119
+ );
120
+ }
121
+
122
+ let checkpoint;
123
+ try {
124
+ checkpoint = JSON.parse(await readFile(resolvedPath, 'utf8'));
125
+ } catch (error) {
126
+ if (error instanceof SyntaxError) throw new Error('organization checkpoint is not valid JSON');
127
+ throw error;
128
+ }
129
+ if (
130
+ !isRecord(checkpoint)
131
+ || ![CHECKPOINT_SCHEMA_VERSION, LEGACY_CHECKPOINT_SCHEMA_VERSION]
132
+ .includes(checkpoint.schemaVersion)
133
+ ) {
134
+ throw new Error(
135
+ `organization checkpoint schemaVersion must be "${CHECKPOINT_SCHEMA_VERSION}"`
136
+ + ` or "${LEGACY_CHECKPOINT_SCHEMA_VERSION}"`,
137
+ );
138
+ }
139
+ if (checkpoint.kind !== CHECKPOINT_KIND) {
140
+ throw new Error('file is not an actions-warden organization checkpoint');
141
+ }
142
+ const storedIdentity = normalizeCheckpointIdentity(checkpoint);
143
+ validateIdentity(storedIdentity, identity);
144
+ if (!Array.isArray(checkpoint.repositories)) {
145
+ throw new Error('organization checkpoint repositories must be an array');
146
+ }
147
+
148
+ const results = new Map();
149
+ for (const value of checkpoint.repositories) {
150
+ const result = validateCheckpointResult(value, identity);
151
+ const key = result.repository.toLowerCase();
152
+ if (results.has(key)) throw new Error(`duplicate checkpoint repository: ${result.repository}`);
153
+ results.set(key, result);
154
+ }
155
+ return {
156
+ path: resolvedPath,
157
+ results,
158
+ migrationRequired: checkpoint.schemaVersion !== CHECKPOINT_SCHEMA_VERSION
159
+ || checkpoint.toolVersion !== VERSION,
160
+ };
161
+ }
162
+
163
+ export async function validateOrganizationCheckpointPath({ path, cwd }) {
164
+ return writeFileGuarded({ path, content: '', cwd, dryRun: true });
165
+ }
166
+
167
+ export async function writeOrganizationCheckpoint({
168
+ path,
169
+ cwd,
170
+ identity,
171
+ repositoryResults,
172
+ }) {
173
+ const repositories = [...repositoryResults]
174
+ .map(result => serializeCheckpointResult(result, cwd))
175
+ .sort((left, right) => left.repository.localeCompare(right.repository));
176
+ const content = `${JSON.stringify(redactDeep({
177
+ schemaVersion: CHECKPOINT_SCHEMA_VERSION,
178
+ kind: CHECKPOINT_KIND,
179
+ toolVersion: VERSION,
180
+ identity,
181
+ repositories,
182
+ }), null, 2)}\n`;
183
+ if (Buffer.byteLength(content) > MAX_ORGANIZATION_CHECKPOINT_BYTES) {
184
+ throw new Error(
185
+ `organization checkpoint exceeds ${MAX_ORGANIZATION_CHECKPOINT_BYTES} bytes`,
186
+ );
187
+ }
188
+ return writeFileGuarded({ path, content, cwd, dryRun: false });
189
+ }
190
+
191
+ export function canReuseCheckpointResult(result, repository, treeSha) {
192
+ return result.repository === repository.fullName
193
+ && result.branch === repository.defaultBranch
194
+ && result.treeSha === treeSha
195
+ && result.errors.length === 0;
196
+ }
197
+
198
+ export function restoreCheckpointResult(result, { repository, cwd, sourceUrl }) {
199
+ const repositoryRoot = resolve(cwd, repository.owner, repository.name);
200
+ const findings = result.findings.map(finding => ({
201
+ ...finding,
202
+ file: resolve(repositoryRoot, ...finding.file.split('/')),
203
+ repository: repository.fullName,
204
+ branch: repository.defaultBranch,
205
+ url: sourceUrl(repository, finding.file, finding.line),
206
+ }));
207
+ return {
208
+ repository,
209
+ revision: { branch: repository.defaultBranch, treeSha: result.treeSha },
210
+ files: [...result.files],
211
+ findings,
212
+ errors: result.errors.map(error => ({
213
+ repository: repository.fullName,
214
+ ...(error.path ? { path: error.path } : {}),
215
+ error: error.error,
216
+ })),
217
+ summary: { ...result.summary },
218
+ status: result.status,
219
+ };
220
+ }
221
+
222
+ function serializeCheckpointResult(result, cwd) {
223
+ const repositoryRoot = resolve(cwd, result.repository.owner, result.repository.name);
224
+ return {
225
+ repository: result.repository.fullName,
226
+ branch: result.revision.branch,
227
+ treeSha: result.revision.treeSha,
228
+ files: [...result.files],
229
+ findings: result.findings.map(finding => {
230
+ const copy = { ...finding, file: canonicalPath(finding.file, repositoryRoot) };
231
+ delete copy.repository;
232
+ delete copy.branch;
233
+ delete copy.url;
234
+ return copy;
235
+ }),
236
+ errors: result.errors.map(error => ({
237
+ ...(error.path ? { path: error.path } : {}),
238
+ error: error.error,
239
+ })),
240
+ summary: result.summary,
241
+ status: result.status,
242
+ };
243
+ }
244
+
245
+ function validateIdentity(actual, expected) {
246
+ if (!isRecord(actual)) throw new Error('organization checkpoint identity is invalid');
247
+ for (const field of [
248
+ 'analysisGeneration',
249
+ 'rulesHash',
250
+ 'organization',
251
+ 'scope',
252
+ 'configHash',
253
+ 'baselineHash',
254
+ ]) {
255
+ if (stableStringify(actual[field]) !== stableStringify(expected[field])) {
256
+ throw new Error(`organization checkpoint does not match current ${field}`);
257
+ }
258
+ }
259
+ }
260
+
261
+ function normalizeCheckpointIdentity(checkpoint) {
262
+ if (!isRecord(checkpoint.identity)) {
263
+ throw new Error('organization checkpoint identity is invalid');
264
+ }
265
+ if (checkpoint.schemaVersion === CHECKPOINT_SCHEMA_VERSION) {
266
+ if (typeof checkpoint.toolVersion !== 'string' || !TOOL_VERSION_RE.test(checkpoint.toolVersion)) {
267
+ throw new Error('organization checkpoint toolVersion is invalid');
268
+ }
269
+ return checkpoint.identity;
270
+ }
271
+ if (checkpoint.identity.toolVersion !== FIRST_ANALYSIS_GENERATION_TOOL_VERSION) {
272
+ throw new Error(
273
+ `organization checkpoint toolVersion ${String(checkpoint.identity.toolVersion)}`
274
+ + ' has no compatible analysis generation',
275
+ );
276
+ }
277
+ return {
278
+ analysisGeneration: 1,
279
+ rulesHash: checkpoint.identity.rulesHash,
280
+ organization: checkpoint.identity.organization,
281
+ scope: checkpoint.identity.scope,
282
+ configHash: checkpoint.identity.configHash,
283
+ baselineHash: checkpoint.identity.baselineHash,
284
+ };
285
+ }
286
+
287
+ function validateCheckpointResult(value, identity) {
288
+ if (!isRecord(value)) throw new Error('organization checkpoint repository result is invalid');
289
+ const repository = validateFullName(value.repository);
290
+ const branch = validateBranch(value.branch);
291
+ const treeSha = value.treeSha === null ? null : validateSha(value.treeSha, repository);
292
+ const files = validateFiles(value.files, repository);
293
+ const findings = validateFindings(value.findings, {
294
+ repository,
295
+ files: new Set(files),
296
+ explain: identity.scope.explain,
297
+ });
298
+ const errors = validateErrors(value.errors, repository);
299
+ const summary = validateSummary(value.summary, files, findings, repository);
300
+ const status = findings.length === 0 && errors.length === 0 ? 'OK' : 'FAIL';
301
+ if (value.status !== status) {
302
+ throw new Error(`organization checkpoint status is inconsistent for ${repository}`);
303
+ }
304
+ return { repository, branch, treeSha, files, findings, errors, summary, status };
305
+ }
306
+
307
+ function validateFullName(value) {
308
+ if (typeof value !== 'string') throw new Error('checkpoint repository name must be a string');
309
+ const parts = value.split('/');
310
+ if (
311
+ parts.length !== 2
312
+ || parts.some(part => !NAME_RE.test(part) || part === '.' || part === '..')
313
+ ) throw new Error(`invalid checkpoint repository: ${value}`);
314
+ return value;
315
+ }
316
+
317
+ function validateBranch(value) {
318
+ if (value === null) return null;
319
+ if (
320
+ typeof value !== 'string'
321
+ || !value
322
+ || value.includes('\0')
323
+ || value.startsWith('/')
324
+ || value.endsWith('/')
325
+ ) throw new Error('invalid checkpoint default branch');
326
+ return value;
327
+ }
328
+
329
+ function validateSha(value, repository) {
330
+ if (typeof value !== 'string' || !SHA_RE.test(value)) {
331
+ throw new Error(`invalid checkpoint tree SHA for ${repository}`);
332
+ }
333
+ return value.toLowerCase();
334
+ }
335
+
336
+ function validateFiles(value, repository) {
337
+ if (!Array.isArray(value)) throw new Error(`invalid checkpoint files for ${repository}`);
338
+ if (value.some(path => !isWorkflowPath(path))) {
339
+ throw new Error(`invalid checkpoint workflow path for ${repository}`);
340
+ }
341
+ if (new Set(value).size !== value.length) {
342
+ throw new Error(`duplicate checkpoint workflow path for ${repository}`);
343
+ }
344
+ if (value.some((path, index) => index > 0 && value[index - 1].localeCompare(path) > 0)) {
345
+ throw new Error(`checkpoint workflow paths are not ordered for ${repository}`);
346
+ }
347
+ return [...value];
348
+ }
349
+
350
+ function validateFindings(value, { repository, files, explain }) {
351
+ if (!Array.isArray(value)) throw new Error(`invalid checkpoint findings for ${repository}`);
352
+ return value.map(finding => {
353
+ if (
354
+ !isRecord(finding)
355
+ || typeof finding.id !== 'string'
356
+ || !ID_RE.test(finding.id)
357
+ || typeof finding.fingerprint !== 'string'
358
+ || !ID_RE.test(finding.fingerprint)
359
+ || !RULE_IDS.has(finding.ruleId)
360
+ || !SEVERITIES.has(finding.severity)
361
+ || !files.has(finding.file)
362
+ || !Number.isInteger(finding.line)
363
+ || finding.line < 0
364
+ || !isRecord(finding.fields)
365
+ || !isJsonValue(finding.fields)
366
+ || finding.fields.file !== `${repository}/${finding.file}`
367
+ || finding.fields.sev !== finding.severity
368
+ || (explain ? typeof finding.explain !== 'string' : finding.explain !== undefined)
369
+ ) {
370
+ throw new Error(`invalid checkpoint finding for ${repository}`);
371
+ }
372
+ return {
373
+ id: finding.id,
374
+ ruleId: finding.ruleId,
375
+ severity: finding.severity,
376
+ file: finding.file,
377
+ line: finding.line,
378
+ fields: finding.fields,
379
+ ...(finding.explain === undefined ? {} : { explain: finding.explain }),
380
+ fingerprint: finding.fingerprint,
381
+ };
382
+ });
383
+ }
384
+
385
+ function validateErrors(value, repository) {
386
+ if (!Array.isArray(value)) throw new Error(`invalid checkpoint errors for ${repository}`);
387
+ return value.map(error => {
388
+ if (
389
+ !isRecord(error)
390
+ || typeof error.error !== 'string'
391
+ || (error.path !== undefined && !isWorkflowPath(error.path))
392
+ ) throw new Error(`invalid checkpoint error for ${repository}`);
393
+ return {
394
+ ...(error.path === undefined ? {} : { path: error.path }),
395
+ error: error.error,
396
+ };
397
+ });
398
+ }
399
+
400
+ function validateSummary(value, files, findings, repository) {
401
+ if (!isRecord(value)) throw new Error(`invalid checkpoint summary for ${repository}`);
402
+ const fields = [
403
+ 'files',
404
+ 'findings',
405
+ 'totalFindings',
406
+ 'suppressed',
407
+ 'critical',
408
+ 'high',
409
+ 'medium',
410
+ 'low',
411
+ ];
412
+ if (fields.some(field => !Number.isSafeInteger(value[field]) || value[field] < 0)) {
413
+ throw new Error(`invalid checkpoint summary for ${repository}`);
414
+ }
415
+ const counts = summarize(findings);
416
+ if (
417
+ value.files !== files.length
418
+ || value.findings !== findings.length
419
+ || value.totalFindings !== value.findings + value.suppressed
420
+ || [...SEVERITIES].some(severity => value[severity] !== counts[severity])
421
+ ) throw new Error(`inconsistent checkpoint summary for ${repository}`);
422
+ return Object.fromEntries(fields.map(field => [field, value[field]]));
423
+ }
424
+
425
+ function digest(value) {
426
+ return createHash('sha256').update(stableStringify(value)).digest('hex');
427
+ }
428
+
429
+ function stableStringify(value) {
430
+ return JSON.stringify(stableValue(value));
431
+ }
432
+
433
+ function stableValue(value) {
434
+ if (Array.isArray(value)) return value.map(stableValue);
435
+ if (isRecord(value)) {
436
+ return Object.fromEntries(
437
+ Object.entries(value)
438
+ .sort(([left], [right]) => left.localeCompare(right))
439
+ .map(([key, item]) => [key, stableValue(item)]),
440
+ );
441
+ }
442
+ return value;
443
+ }
444
+
445
+ function isJsonValue(value, seen = new Set()) {
446
+ if (value === null || ['string', 'boolean'].includes(typeof value)) return true;
447
+ if (typeof value === 'number') return Number.isFinite(value);
448
+ if (!value || typeof value !== 'object' || seen.has(value)) return false;
449
+ seen.add(value);
450
+ const valid = Array.isArray(value)
451
+ ? value.every(item => isJsonValue(item, seen))
452
+ : Object.entries(value).every(([key, item]) => (
453
+ typeof key === 'string' && isJsonValue(item, seen)
454
+ ));
455
+ seen.delete(value);
456
+ return valid;
457
+ }
458
+
459
+ function isRecord(value) {
460
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
461
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Human-readable rendering for structured organization-scan progress events.
3
+ * Progress is a separate channel from final report serialization.
4
+ */
5
+
6
+ import { redact } from './redact.js';
7
+
8
+ export function formatOrganizationProgress(event) {
9
+ switch (event.type) {
10
+ case 'scan-started':
11
+ return line(`starting organization scan for ${event.organization}`);
12
+ case 'checkpoint-loaded':
13
+ return line(`loaded checkpoint with ${event.repositories} completed repositories`);
14
+ case 'checkpoint-created':
15
+ return line('created organization scan checkpoint');
16
+ case 'discovery-started':
17
+ return line(`discovering repositories in ${event.organization}`);
18
+ case 'discovery-completed':
19
+ return line(
20
+ `selected ${event.selected} of ${event.discovered} discovered repositories`
21
+ + ` (${event.eligible} eligible)`,
22
+ );
23
+ case 'repository-started':
24
+ return line(`[${event.position}/${event.total}] scanning ${event.repository}`);
25
+ case 'request-retry':
26
+ return line(
27
+ `${event.repository ? `${event.repository}: ` : ''}`
28
+ + `GitHub request retry ${event.attempt}/${event.maxRetries}`
29
+ + ` after ${event.reason} (${event.delayMs}ms)`,
30
+ );
31
+ case 'repository-completed':
32
+ return line(
33
+ `[${event.completed}/${event.total}] ${event.reused ? 'resumed' : 'completed'} `
34
+ + `${event.repository}: ${event.status}, ${count(event.files, 'file')}, `
35
+ + `${count(event.findings, 'finding')}, ${count(event.errors, 'error')}`,
36
+ );
37
+ case 'scan-completed':
38
+ return line(
39
+ `finished ${event.organization}: ${event.status}, ${event.completed}/${event.total} `
40
+ + `repositories, ${event.reused} resumed, ${event.findings} findings, `
41
+ + `${event.errors} errors in ${event.elapsedMs}ms`,
42
+ );
43
+ default:
44
+ return '';
45
+ }
46
+ }
47
+
48
+ function line(value) {
49
+ const safe = [...redact(String(value))]
50
+ .map(char => {
51
+ const code = char.charCodeAt(0);
52
+ return code < 32 || code === 127 ? ' ' : char;
53
+ })
54
+ .join('');
55
+ return `[actions-warden] ${safe}\n`;
56
+ }
57
+
58
+ function count(value, noun) {
59
+ return `${value} ${noun}${value === 1 ? '' : 's'}`;
60
+ }