actions-warden 0.3.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.
@@ -3,8 +3,7 @@
3
3
  * fetch workflow YAML from each default branch, and aggregate audit findings.
4
4
  */
5
5
 
6
- import { realpath } from 'node:fs/promises';
7
- import { basename, dirname, join, resolve } from 'node:path';
6
+ import { resolve } from 'node:path';
8
7
  import picomatch from 'picomatch';
9
8
  import { auditSources } from './audit.js';
10
9
  import { loadBaseline } from '../lib/baseline.js';
@@ -27,6 +26,7 @@ import {
27
26
  } from '../lib/org-checkpoint.js';
28
27
  import { resolveToken } from '../lib/resolver.js';
29
28
  import { listRules, RULES } from '../rules/index.js';
29
+ import { sameFilePath } from '../lib/path-equality.js';
30
30
 
31
31
  const VISIBILITIES = new Set(['all', 'public', 'private', 'internal']);
32
32
  const ORGANIZATION_RE = /^[A-Za-z0-9_.-]+$/;
@@ -126,7 +126,7 @@ export async function scanOrganization({
126
126
  });
127
127
  const loadedCheckpoint = resume
128
128
  ? await loadOrganizationCheckpoint({ path: checkpointPath, cwd, identity: checkpointIdentity })
129
- : { results: new Map() };
129
+ : { results: new Map(), migrationRequired: false };
130
130
  if (resume) {
131
131
  await emitProgress(onProgress, {
132
132
  type: 'checkpoint-loaded',
@@ -177,9 +177,9 @@ export async function scanOrganization({
177
177
  identity: checkpointIdentity,
178
178
  results: checkpointResults,
179
179
  });
180
- if (checkpointPath && !resume) {
180
+ if (checkpointPath && (!resume || loadedCheckpoint.migrationRequired)) {
181
181
  await persistCheckpoint();
182
- await emitProgress(onProgress, { type: 'checkpoint-created' });
182
+ if (!resume) await emitProgress(onProgress, { type: 'checkpoint-created' });
183
183
  }
184
184
 
185
185
  let completed = 0;
@@ -495,18 +495,13 @@ function createCheckpointWriter({ path, cwd, identity, results }) {
495
495
 
496
496
  async function assertCheckpointDoesNotReplaceControlFile(path, cwd, controlFiles) {
497
497
  const requested = resolve(cwd, path);
498
- const checkpoint = comparablePath(join(await realpath(dirname(requested)), basename(requested)));
499
498
  for (const controlFile of controlFiles) {
500
- if (controlFile && comparablePath(resolve(controlFile)) === checkpoint) {
499
+ if (controlFile && await sameFilePath(requested, resolve(controlFile))) {
501
500
  throw new Error('checkpoint path cannot replace the active config or baseline');
502
501
  }
503
502
  }
504
503
  }
505
504
 
506
- function comparablePath(path) {
507
- return process.platform === 'win32' ? path.toLowerCase() : path;
508
- }
509
-
510
505
  async function emitRetryProgress(onProgress, retry, repository) {
511
506
  await emitProgress(onProgress, {
512
507
  type: 'request-retry',
@@ -6,13 +6,15 @@
6
6
  * derived from the same identity used to validate resumable checkpoints.
7
7
  */
8
8
 
9
- import { createHash } from 'node:crypto';
10
9
  import { lstat } from 'node:fs/promises';
11
10
  import { resolve } from 'node:path';
12
11
  import { loadBaseline } from './baseline.js';
13
12
  import { loadConfig } from './config.js';
14
13
  import { format } from './formatter.js';
15
- import { createOrganizationCheckpointIdentity } from './org-checkpoint.js';
14
+ import {
15
+ createOrganizationCheckpointArtifactKey,
16
+ createOrganizationCheckpointIdentity,
17
+ } from './org-checkpoint.js';
16
18
  import { RULES } from '../rules/index.js';
17
19
 
18
20
  export const AGENT_MODE_ENVIRONMENT_VARIABLE = 'ACTIONS_WARDEN_MODE';
@@ -99,10 +101,7 @@ export async function createOrganizationAgentArtifacts({
99
101
  config,
100
102
  baselineData,
101
103
  });
102
- const key = createHash('sha256')
103
- .update(JSON.stringify(identity))
104
- .digest('hex')
105
- .slice(0, 32);
104
+ const key = createOrganizationCheckpointArtifactKey(identity);
106
105
  const groupedKey = key.match(/.{8}/g).join('.');
107
106
  const checkpointPath = `.actions-warden-agent.${groupedKey}.checkpoint.json`;
108
107
  const reportExtension = REPORT_EXTENSIONS[reportFormat];
@@ -18,7 +18,14 @@ import { listRules } from '../rules/index.js';
18
18
  import { VERSION } from '../version.js';
19
19
 
20
20
  const CHECKPOINT_KIND = 'actions-warden-org-scan';
21
- const CHECKPOINT_SCHEMA_VERSION = '1.0';
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;
22
29
  export const MAX_ORGANIZATION_CHECKPOINT_BYTES = 256 * 1024 * 1024;
23
30
  const NAME_RE = /^[A-Za-z0-9_.-]+$/;
24
31
  const SHA_RE = /^[0-9a-f]{40}$/i;
@@ -41,7 +48,7 @@ export function createOrganizationCheckpointIdentity({
41
48
  baselineData,
42
49
  }) {
43
50
  return {
44
- toolVersion: VERSION,
51
+ analysisGeneration: ORGANIZATION_ANALYSIS_GENERATION,
45
52
  rulesHash: RULES_HASH,
46
53
  organization: organization.toLowerCase(),
47
54
  scope: {
@@ -66,6 +73,42 @@ export function createOrganizationCheckpointIdentity({
66
73
  };
67
74
  }
68
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
+
69
112
  export async function loadOrganizationCheckpoint({ path, cwd, identity }) {
70
113
  const resolvedPath = await resolveRepositoryFile(path, cwd);
71
114
  const metadata = await stat(resolvedPath);
@@ -83,13 +126,21 @@ export async function loadOrganizationCheckpoint({ path, cwd, identity }) {
83
126
  if (error instanceof SyntaxError) throw new Error('organization checkpoint is not valid JSON');
84
127
  throw error;
85
128
  }
86
- if (!isRecord(checkpoint) || checkpoint.schemaVersion !== CHECKPOINT_SCHEMA_VERSION) {
87
- throw new Error(`organization checkpoint schemaVersion must be "${CHECKPOINT_SCHEMA_VERSION}"`);
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
+ );
88
138
  }
89
139
  if (checkpoint.kind !== CHECKPOINT_KIND) {
90
140
  throw new Error('file is not an actions-warden organization checkpoint');
91
141
  }
92
- validateIdentity(checkpoint.identity, identity);
142
+ const storedIdentity = normalizeCheckpointIdentity(checkpoint);
143
+ validateIdentity(storedIdentity, identity);
93
144
  if (!Array.isArray(checkpoint.repositories)) {
94
145
  throw new Error('organization checkpoint repositories must be an array');
95
146
  }
@@ -101,7 +152,12 @@ export async function loadOrganizationCheckpoint({ path, cwd, identity }) {
101
152
  if (results.has(key)) throw new Error(`duplicate checkpoint repository: ${result.repository}`);
102
153
  results.set(key, result);
103
154
  }
104
- return { path: resolvedPath, results };
155
+ return {
156
+ path: resolvedPath,
157
+ results,
158
+ migrationRequired: checkpoint.schemaVersion !== CHECKPOINT_SCHEMA_VERSION
159
+ || checkpoint.toolVersion !== VERSION,
160
+ };
105
161
  }
106
162
 
107
163
  export async function validateOrganizationCheckpointPath({ path, cwd }) {
@@ -120,6 +176,7 @@ export async function writeOrganizationCheckpoint({
120
176
  const content = `${JSON.stringify(redactDeep({
121
177
  schemaVersion: CHECKPOINT_SCHEMA_VERSION,
122
178
  kind: CHECKPOINT_KIND,
179
+ toolVersion: VERSION,
123
180
  identity,
124
181
  repositories,
125
182
  }), null, 2)}\n`;
@@ -188,7 +245,7 @@ function serializeCheckpointResult(result, cwd) {
188
245
  function validateIdentity(actual, expected) {
189
246
  if (!isRecord(actual)) throw new Error('organization checkpoint identity is invalid');
190
247
  for (const field of [
191
- 'toolVersion',
248
+ 'analysisGeneration',
192
249
  'rulesHash',
193
250
  'organization',
194
251
  'scope',
@@ -201,6 +258,32 @@ function validateIdentity(actual, expected) {
201
258
  }
202
259
  }
203
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
+
204
287
  function validateCheckpointResult(value, identity) {
205
288
  if (!isRecord(value)) throw new Error('organization checkpoint repository result is invalid');
206
289
  const repository = validateFullName(value.repository);
@@ -0,0 +1,30 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * Compare two existing or prospective file paths after resolving their real
6
+ * parents. Existing entries are fully resolved so a control-file symlink is
7
+ * compared with its target rather than only with the symlink name.
8
+ */
9
+ export async function sameFilePath(left, right) {
10
+ const [leftPath, rightPath] = await Promise.all([
11
+ canonicalPath(left),
12
+ canonicalPath(right),
13
+ ]);
14
+ return comparablePath(leftPath) === comparablePath(rightPath);
15
+ }
16
+
17
+ async function canonicalPath(path) {
18
+ try {
19
+ return await realpath(path);
20
+ } catch (error) {
21
+ if (error.code !== 'ENOENT') throw error;
22
+ return join(await realpath(dirname(path)), basename(path));
23
+ }
24
+ }
25
+
26
+ function comparablePath(path) {
27
+ return process.platform === 'win32' || process.platform === 'darwin'
28
+ ? path.toLowerCase()
29
+ : path;
30
+ }
package/src/lib/writer.js CHANGED
@@ -44,6 +44,9 @@ export async function writeFileGuarded({ path, content, dryRun = true, cwd = pro
44
44
  if (entry.isSymbolicLink()) {
45
45
  throw new Error(`refusing to write through a symlink: ${path}`);
46
46
  }
47
+ if (!entry.isFile()) {
48
+ throw new Error(`refusing to replace a non-file path: ${path}`);
49
+ }
47
50
  existingMode = entry.mode & 0o777;
48
51
  } catch (error) {
49
52
  if (error.code !== 'ENOENT') throw error;
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Runtime version embedded in the GitHub Action bundle. The version-sync check
2
2
  // keeps this value aligned with package.json, the lockfile, and plugin metadata.
3
- export const VERSION = '0.3.0';
3
+ export const VERSION = '0.4.0';