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.
- package/AGENTS.md +14 -4
- package/README.md +14 -2
- package/SECURITY.md +10 -5
- package/docs/AI-AGENTS.md +21 -5
- package/docs/CLI.md +76 -23
- package/docs/DEVELOPMENT.md +25 -2
- package/docs/GITHUB-ACTION.md +11 -7
- package/docs/JAVASCRIPT-API.md +7 -5
- package/docs/OUTPUTS.md +10 -5
- package/examples/upgrade-pr.yml +1 -1
- package/package.json +1 -1
- package/skills/actions-warden/SKILL.md +31 -22
- package/src/action.js +4 -15
- package/src/cli.js +314 -121
- package/src/commands/org-scan.js +6 -11
- package/src/lib/agent-mode.js +5 -6
- package/src/lib/org-checkpoint.js +90 -7
- package/src/lib/path-equality.js +30 -0
- package/src/lib/writer.js +3 -0
- package/src/version.js +1 -1
package/src/commands/org-scan.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* fetch workflow YAML from each default branch, and aggregate audit findings.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
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 &&
|
|
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',
|
package/src/lib/agent-mode.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
87
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
'
|
|
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