actions-warden 0.2.0 → 0.3.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 +189 -0
- package/CONTRIBUTING.md +109 -0
- package/README.md +272 -224
- package/RELEASING.md +338 -0
- package/SECURITY.md +25 -3
- package/docs/AI-AGENTS.md +458 -0
- package/docs/CLI.md +421 -0
- package/docs/CONFIGURATION.md +340 -0
- package/docs/DEVELOPMENT.md +350 -0
- package/docs/GITHUB-ACTION.md +281 -0
- package/docs/JAVASCRIPT-API.md +355 -0
- package/docs/OUTPUTS.md +409 -0
- package/docs/README.md +27 -0
- package/examples/org-scan.yml +42 -0
- package/examples/upgrade-pr.yml +57 -0
- package/llms.txt +38 -0
- package/package.json +32 -10
- package/skills/actions-warden/SKILL.md +140 -38
- package/src/action.js +317 -0
- package/src/cli.js +267 -22
- package/src/commands/audit.js +189 -36
- package/src/commands/org-scan.js +549 -0
- package/src/commands/pin.js +59 -56
- package/src/commands/report.js +122 -10
- package/src/commands/upgrade.js +102 -62
- package/src/commands/verify.js +193 -0
- package/src/index.js +21 -4
- package/src/lib/action-status.js +27 -0
- package/src/lib/agent-mode.js +175 -0
- package/src/lib/annotations.js +250 -0
- package/src/lib/baseline.js +103 -0
- package/src/lib/cache.js +47 -10
- package/src/lib/concurrency.js +27 -0
- package/src/lib/config.js +185 -0
- package/src/lib/execution.js +71 -0
- package/src/lib/formatter.js +127 -8
- package/src/lib/github-org.js +374 -0
- package/src/lib/identity.js +62 -0
- package/src/lib/ignore.js +7 -6
- package/src/lib/org-checkpoint.js +378 -0
- package/src/lib/org-progress.js +60 -0
- package/src/lib/parser.js +326 -52
- package/src/lib/patcher.js +199 -0
- package/src/lib/paths.js +35 -12
- package/src/lib/redact.js +65 -4
- package/src/lib/resolver.js +225 -43
- package/src/lib/targets.js +28 -0
- package/src/lib/triggers.js +12 -0
- package/src/lib/writer.js +45 -8
- package/src/rules/excessive-permissions.js +24 -33
- package/src/rules/index.js +19 -1
- package/src/rules/pull-request-target-checkout.js +149 -18
- package/src/rules/reusable-workflow-secrets.js +32 -0
- package/src/rules/script-injection.js +77 -12
- package/src/rules/secrets-in-env.js +101 -18
- package/src/rules/unpinned-action.js +3 -2
- package/src/rules/unpinned-container-image.js +39 -0
- package/src/rules/unpinned-docker-action.js +30 -0
- package/src/rules/untrusted-self-hosted-runner.js +109 -0
- package/src/rules/workflow-run-artifact-execution.js +122 -0
- package/src/rules/workflow-structure.js +396 -0
- package/src/version.js +3 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parseWorkflowSource, collectUses } from '../lib/parser.js';
|
|
3
|
+
import { resolveTargets } from '../lib/targets.js';
|
|
4
|
+
import {
|
|
5
|
+
resolveRefToSha,
|
|
6
|
+
resolveToken,
|
|
7
|
+
verifyCommitInRepo,
|
|
8
|
+
} from '../lib/resolver.js';
|
|
9
|
+
import { readVersionComment } from '../lib/patcher.js';
|
|
10
|
+
import { occurrenceId, canonicalPath } from '../lib/identity.js';
|
|
11
|
+
import { format } from '../lib/formatter.js';
|
|
12
|
+
import { mapLimit } from '../lib/concurrency.js';
|
|
13
|
+
|
|
14
|
+
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
15
|
+
|
|
16
|
+
export async function verify({ cwd = process.cwd(), workflows, token } = {}) {
|
|
17
|
+
const files = await resolveTargets({ workflows, cwd });
|
|
18
|
+
const resolvedToken = resolveToken(token);
|
|
19
|
+
const checks = [];
|
|
20
|
+
const warnings = [];
|
|
21
|
+
const errors = [];
|
|
22
|
+
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
let source;
|
|
25
|
+
let workflow;
|
|
26
|
+
try {
|
|
27
|
+
source = await readFile(file, 'utf8');
|
|
28
|
+
workflow = parseWorkflowSource(source, file);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
errors.push({ file, error: String(error.message ?? error) });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const refs = collectUses(workflow)
|
|
35
|
+
.map(item => item.ref)
|
|
36
|
+
.filter(ref => ref.kind === 'external' || ref.kind === 'reusable-workflow');
|
|
37
|
+
const results = await mapLimit(refs, 4, async ref => {
|
|
38
|
+
const id = occurrenceId({
|
|
39
|
+
kind: 'verify',
|
|
40
|
+
file,
|
|
41
|
+
cwd,
|
|
42
|
+
line: ref.line,
|
|
43
|
+
start: ref.start,
|
|
44
|
+
subject: ref.raw,
|
|
45
|
+
});
|
|
46
|
+
if (!ref.ref || !SHA_RE.test(ref.ref)) {
|
|
47
|
+
return { type: 'error', value: {
|
|
48
|
+
id,
|
|
49
|
+
file,
|
|
50
|
+
line: ref.line,
|
|
51
|
+
action: ref.raw,
|
|
52
|
+
error: 'reference is not pinned to a full commit SHA',
|
|
53
|
+
} };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await verifyCommitInRepo({
|
|
58
|
+
owner: ref.owner,
|
|
59
|
+
repo: ref.repo,
|
|
60
|
+
sha: ref.ref,
|
|
61
|
+
token: resolvedToken,
|
|
62
|
+
cwd,
|
|
63
|
+
});
|
|
64
|
+
const version = readVersionComment(source, ref);
|
|
65
|
+
if (!version) {
|
|
66
|
+
const warning = {
|
|
67
|
+
id,
|
|
68
|
+
file,
|
|
69
|
+
line: ref.line,
|
|
70
|
+
action: ref.raw,
|
|
71
|
+
warning: 'pinned SHA has no actions-warden-ref version metadata',
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
type: 'warning',
|
|
75
|
+
value: warning,
|
|
76
|
+
check: { id, file, line: ref.line, action: ref.raw, sha: ref.ref },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const expected = await resolveRefToSha({
|
|
80
|
+
owner: ref.owner,
|
|
81
|
+
repo: ref.repo,
|
|
82
|
+
ref: version,
|
|
83
|
+
token: resolvedToken,
|
|
84
|
+
cwd,
|
|
85
|
+
});
|
|
86
|
+
if (expected.sha.toLowerCase() !== ref.ref.toLowerCase()) {
|
|
87
|
+
throw new Error(`version metadata ${version} resolves to ${expected.sha}, not ${ref.ref}`);
|
|
88
|
+
}
|
|
89
|
+
return { type: 'check', value: {
|
|
90
|
+
id,
|
|
91
|
+
file,
|
|
92
|
+
line: ref.line,
|
|
93
|
+
action: `${ref.owner}/${ref.repo}${ref.subpath ? `/${ref.subpath}` : ''}`,
|
|
94
|
+
version,
|
|
95
|
+
sha: ref.ref.toLowerCase(),
|
|
96
|
+
} };
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { type: 'error', value: {
|
|
99
|
+
id,
|
|
100
|
+
file,
|
|
101
|
+
line: ref.line,
|
|
102
|
+
action: ref.raw,
|
|
103
|
+
error: String(error.message ?? error),
|
|
104
|
+
} };
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
for (const result of results) {
|
|
108
|
+
if (result.type === 'error') errors.push(result.value);
|
|
109
|
+
if (result.type === 'warning') {
|
|
110
|
+
warnings.push(result.value);
|
|
111
|
+
checks.push(result.check);
|
|
112
|
+
}
|
|
113
|
+
if (result.type === 'check') checks.push(result.value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
files,
|
|
119
|
+
checks,
|
|
120
|
+
warnings,
|
|
121
|
+
errors,
|
|
122
|
+
status: errors.length === 0 ? 'OK' : 'FAIL',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function renderVerify(result, { format: outputFormat, cwd = process.cwd() }) {
|
|
127
|
+
if (outputFormat === 'json') {
|
|
128
|
+
return format('json', [], {
|
|
129
|
+
status: result.status,
|
|
130
|
+
json: {
|
|
131
|
+
schemaVersion: '1.0',
|
|
132
|
+
files: result.files.map(file => canonicalPath(file, cwd)),
|
|
133
|
+
checks: result.checks.map(check => ({ ...check, file: canonicalPath(check.file, cwd) })),
|
|
134
|
+
warnings: result.warnings.map(warning => ({
|
|
135
|
+
...warning,
|
|
136
|
+
file: canonicalPath(warning.file, cwd),
|
|
137
|
+
})),
|
|
138
|
+
errors: result.errors.map(error => ({
|
|
139
|
+
...error,
|
|
140
|
+
file: error.file ? canonicalPath(error.file, cwd) : undefined,
|
|
141
|
+
})),
|
|
142
|
+
status: result.status,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const records = [];
|
|
147
|
+
for (const check of result.checks) {
|
|
148
|
+
records.push({
|
|
149
|
+
label: 'VERIFIED',
|
|
150
|
+
fields: {
|
|
151
|
+
id: check.id,
|
|
152
|
+
file: canonicalPath(check.file, cwd),
|
|
153
|
+
line: check.line,
|
|
154
|
+
action: check.action,
|
|
155
|
+
version: check.version,
|
|
156
|
+
sha: check.sha,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
for (const warning of result.warnings) {
|
|
161
|
+
records.push({
|
|
162
|
+
label: 'WARNING',
|
|
163
|
+
fields: {
|
|
164
|
+
id: warning.id,
|
|
165
|
+
file: canonicalPath(warning.file, cwd),
|
|
166
|
+
line: warning.line,
|
|
167
|
+
action: warning.action,
|
|
168
|
+
msg: warning.warning,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
for (const error of result.errors) {
|
|
173
|
+
records.push({
|
|
174
|
+
label: 'ERROR',
|
|
175
|
+
fields: {
|
|
176
|
+
id: error.id,
|
|
177
|
+
file: error.file ? canonicalPath(error.file, cwd) : undefined,
|
|
178
|
+
line: error.line,
|
|
179
|
+
action: error.action,
|
|
180
|
+
msg: error.error,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
records.push({
|
|
185
|
+
label: 'SUMMARY',
|
|
186
|
+
fields: {
|
|
187
|
+
verified: result.checks.length,
|
|
188
|
+
warnings: result.warnings.length,
|
|
189
|
+
errors: result.errors.length,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return format(outputFormat, records, { status: result.status });
|
|
193
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,17 +3,34 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Each command can be invoked without spawning a subprocess:
|
|
5
5
|
*
|
|
6
|
-
* import { audit, pin, upgrade, report } from 'actions-warden';
|
|
6
|
+
* import { audit, pin, upgrade, report, scanOrganization } from 'actions-warden';
|
|
7
7
|
* const result = await audit({ cwd: '/repo' });
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
export { audit, renderAudit } from './commands/audit.js';
|
|
10
|
+
export { audit, auditSources, renderAudit } from './commands/audit.js';
|
|
11
11
|
export { pin, renderPin, rewriteUses } from './commands/pin.js';
|
|
12
12
|
export { upgrade, renderUpgrade } from './commands/upgrade.js';
|
|
13
13
|
export { report, renderReport } from './commands/report.js';
|
|
14
|
+
export { verify, renderVerify } from './commands/verify.js';
|
|
15
|
+
export { scanOrganization, renderOrganizationScan } from './commands/org-scan.js';
|
|
14
16
|
export { listRules } from './rules/index.js';
|
|
15
|
-
export { parseWorkflowFile, parseWorkflowSource, collectUses, parseActionRef } from './lib/parser.js';
|
|
16
|
-
export { format, renderToon, renderJson, renderText, summarize, SEVERITY_ORDER } from './lib/formatter.js';
|
|
17
|
+
export { parseWorkflowFile, parseWorkflowSource, collectUses, collectImages, parseActionRef } from './lib/parser.js';
|
|
18
|
+
export { format, renderToon, renderJson, renderText, renderSarif, summarize, SEVERITY_ORDER } from './lib/formatter.js';
|
|
17
19
|
export { discoverWorkflows } from './lib/paths.js';
|
|
18
20
|
export { redact } from './lib/redact.js';
|
|
19
21
|
export { parseIgnoreDirectives, isIgnored } from './lib/ignore.js';
|
|
22
|
+
export { loadConfig, DEFAULT_CONFIG } from './lib/config.js';
|
|
23
|
+
export {
|
|
24
|
+
fetchRepositoryWorkflowTree,
|
|
25
|
+
fetchRepositoryWorkflows,
|
|
26
|
+
isWorkflowPath,
|
|
27
|
+
listOrganizationRepositories,
|
|
28
|
+
MAX_REPOSITORY_WORKFLOW_BYTES,
|
|
29
|
+
MAX_WORKFLOW_BYTES,
|
|
30
|
+
MAX_WORKFLOW_FILES,
|
|
31
|
+
} from './lib/github-org.js';
|
|
32
|
+
export {
|
|
33
|
+
assignBaselineFingerprints,
|
|
34
|
+
loadBaseline,
|
|
35
|
+
serializeBaseline,
|
|
36
|
+
} from './lib/baseline.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether the JavaScript Action step should fail. Findings can be
|
|
3
|
+
* advisory, but parse and resolution errors are always operational failures.
|
|
4
|
+
*/
|
|
5
|
+
export function shouldFailAction({ command, result, failOnFindings }) {
|
|
6
|
+
if (result.status !== 'FAIL') return false;
|
|
7
|
+
if (!['audit', 'report', 'org-scan'].includes(command)) return true;
|
|
8
|
+
if (hasOperationalFailure(command, result)) return true;
|
|
9
|
+
return failOnFindings;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasOperationalFailure(command, result) {
|
|
13
|
+
if (command === 'audit') {
|
|
14
|
+
return result.findings.some(finding => finding.ruleId === 'parse-error');
|
|
15
|
+
}
|
|
16
|
+
if (command === 'org-scan') {
|
|
17
|
+
return (
|
|
18
|
+
result.errors.length > 0
|
|
19
|
+
|| result.findings.some(finding => finding.ruleId === 'parse-error')
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return (
|
|
23
|
+
result.audit.findings.some(finding => finding.ruleId === 'parse-error')
|
|
24
|
+
|| result.pin.errors.length > 0
|
|
25
|
+
|| result.upgrade.errors.length > 0
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit AI-agent execution defaults.
|
|
3
|
+
*
|
|
4
|
+
* There is no reliable cross-agent runtime signal, so callers opt in through
|
|
5
|
+
* `--agent-mode` or ACTIONS_WARDEN_MODE=agent. Organization artifact names are
|
|
6
|
+
* derived from the same identity used to validate resumable checkpoints.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { lstat } from 'node:fs/promises';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { loadBaseline } from './baseline.js';
|
|
13
|
+
import { loadConfig } from './config.js';
|
|
14
|
+
import { format } from './formatter.js';
|
|
15
|
+
import { createOrganizationCheckpointIdentity } from './org-checkpoint.js';
|
|
16
|
+
import { RULES } from '../rules/index.js';
|
|
17
|
+
|
|
18
|
+
export const AGENT_MODE_ENVIRONMENT_VARIABLE = 'ACTIONS_WARDEN_MODE';
|
|
19
|
+
|
|
20
|
+
const ORGANIZATION_RE = /^[A-Za-z0-9_.-]+$/;
|
|
21
|
+
const REPORT_EXTENSIONS = Object.freeze({
|
|
22
|
+
json: 'json',
|
|
23
|
+
sarif: 'sarif',
|
|
24
|
+
text: 'txt',
|
|
25
|
+
toon: 'toon',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve explicit CLI precedence over the environment opt-in.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {boolean} [options.optionValue]
|
|
33
|
+
* @param {string} [options.optionSource]
|
|
34
|
+
* @param {string} [options.environmentValue]
|
|
35
|
+
*/
|
|
36
|
+
export function resolveAgentMode({ optionValue, optionSource, environmentValue } = {}) {
|
|
37
|
+
if (optionSource === 'cli') return Boolean(optionValue);
|
|
38
|
+
if (environmentValue === undefined || environmentValue === '') return false;
|
|
39
|
+
if (environmentValue !== 'agent') {
|
|
40
|
+
throw new Error(`${AGENT_MODE_ENVIRONMENT_VARIABLE} must be "agent" when set`);
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build stable, scope-specific paths for an agent-initiated organization scan.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} options
|
|
49
|
+
* @param {string} options.organization
|
|
50
|
+
* @param {string} options.cwd
|
|
51
|
+
* @param {string[]} [options.repositories]
|
|
52
|
+
* @param {'all'|'public'|'private'|'internal'} options.visibility
|
|
53
|
+
* @param {boolean} options.includeArchived
|
|
54
|
+
* @param {boolean} options.includeDisabled
|
|
55
|
+
* @param {boolean} options.includeForks
|
|
56
|
+
* @param {number} [options.maxRepositories]
|
|
57
|
+
* @param {'low'|'medium'|'high'|'critical'} [options.severity]
|
|
58
|
+
* @param {boolean} options.explain
|
|
59
|
+
* @param {string|false} [options.configPath]
|
|
60
|
+
* @param {string} [options.baseline]
|
|
61
|
+
* @param {'toon'|'json'|'text'|'sarif'} options.reportFormat
|
|
62
|
+
*/
|
|
63
|
+
export async function createOrganizationAgentArtifacts({
|
|
64
|
+
organization,
|
|
65
|
+
cwd,
|
|
66
|
+
repositories,
|
|
67
|
+
visibility,
|
|
68
|
+
includeArchived,
|
|
69
|
+
includeDisabled,
|
|
70
|
+
includeForks,
|
|
71
|
+
maxRepositories,
|
|
72
|
+
severity,
|
|
73
|
+
explain,
|
|
74
|
+
configPath,
|
|
75
|
+
baseline,
|
|
76
|
+
reportFormat,
|
|
77
|
+
}) {
|
|
78
|
+
validateOrganization(organization);
|
|
79
|
+
const patterns = normalizePatterns(repositories);
|
|
80
|
+
const config = await loadConfig({
|
|
81
|
+
cwd,
|
|
82
|
+
path: configPath,
|
|
83
|
+
ruleIds: RULES.map(rule => rule.id),
|
|
84
|
+
});
|
|
85
|
+
const baselinePath = baseline ?? config.baseline;
|
|
86
|
+
const baselineData = baselinePath
|
|
87
|
+
? await loadBaseline({ path: baselinePath, cwd })
|
|
88
|
+
: { path: null, ids: new Set(), fingerprints: new Set() };
|
|
89
|
+
const identity = createOrganizationCheckpointIdentity({
|
|
90
|
+
organization,
|
|
91
|
+
repositories: patterns,
|
|
92
|
+
visibility,
|
|
93
|
+
includeArchived,
|
|
94
|
+
includeDisabled,
|
|
95
|
+
includeForks,
|
|
96
|
+
maxRepositories,
|
|
97
|
+
severity,
|
|
98
|
+
explain,
|
|
99
|
+
config,
|
|
100
|
+
baselineData,
|
|
101
|
+
});
|
|
102
|
+
const key = createHash('sha256')
|
|
103
|
+
.update(JSON.stringify(identity))
|
|
104
|
+
.digest('hex')
|
|
105
|
+
.slice(0, 32);
|
|
106
|
+
const groupedKey = key.match(/.{8}/g).join('.');
|
|
107
|
+
const checkpointPath = `.actions-warden-agent.${groupedKey}.checkpoint.json`;
|
|
108
|
+
const reportExtension = REPORT_EXTENSIONS[reportFormat];
|
|
109
|
+
if (!reportExtension) throw new Error(`invalid agent report format: ${reportFormat}`);
|
|
110
|
+
const reportPath = `.actions-warden-agent.${groupedKey}.report.${reportExtension}`;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
checkpointPath,
|
|
114
|
+
reportPath,
|
|
115
|
+
resume: await pathExists(resolve(cwd, checkpointPath)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Render the bounded stdout contract emitted after an agent-mode file report.
|
|
121
|
+
*/
|
|
122
|
+
export function renderOrganizationAgentReceipt({
|
|
123
|
+
result,
|
|
124
|
+
reportPath,
|
|
125
|
+
reportFormat,
|
|
126
|
+
checkpointPath,
|
|
127
|
+
resumed,
|
|
128
|
+
}) {
|
|
129
|
+
return format('json', [], {
|
|
130
|
+
status: result.status,
|
|
131
|
+
json: {
|
|
132
|
+
schemaVersion: '1.0',
|
|
133
|
+
kind: 'actions-warden-agent-receipt',
|
|
134
|
+
command: 'org-scan',
|
|
135
|
+
organization: result.organization,
|
|
136
|
+
status: result.status,
|
|
137
|
+
summary: result.summary,
|
|
138
|
+
report: {
|
|
139
|
+
path: reportPath,
|
|
140
|
+
format: reportFormat,
|
|
141
|
+
},
|
|
142
|
+
checkpoint: {
|
|
143
|
+
path: checkpointPath,
|
|
144
|
+
resumed,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateOrganization(organization) {
|
|
151
|
+
if (
|
|
152
|
+
typeof organization !== 'string'
|
|
153
|
+
|| !ORGANIZATION_RE.test(organization)
|
|
154
|
+
|| organization === '.'
|
|
155
|
+
|| organization === '..'
|
|
156
|
+
) throw new Error('invalid organization');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizePatterns(patterns) {
|
|
160
|
+
if (patterns === undefined) return [];
|
|
161
|
+
if (!Array.isArray(patterns) || patterns.some(pattern => typeof pattern !== 'string' || !pattern)) {
|
|
162
|
+
throw new Error('repository filters must be non-empty strings');
|
|
163
|
+
}
|
|
164
|
+
return [...new Set(patterns)];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function pathExists(path) {
|
|
168
|
+
try {
|
|
169
|
+
await lstat(path);
|
|
170
|
+
return true;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error.code === 'ENOENT') return false;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native GitHub Actions annotation support.
|
|
3
|
+
*
|
|
4
|
+
* This module stays independent of stdout and the Action runtime so collection,
|
|
5
|
+
* ordering, limits, and workflow-command encoding can be tested as pure logic.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
9
|
+
import { listRules } from '../rules/index.js';
|
|
10
|
+
import { redact } from './redact.js';
|
|
11
|
+
|
|
12
|
+
const LEVEL_ORDER = new Map([
|
|
13
|
+
['error', 0],
|
|
14
|
+
['warning', 1],
|
|
15
|
+
['notice', 2],
|
|
16
|
+
]);
|
|
17
|
+
const SEVERITY_ORDER = new Map([
|
|
18
|
+
['critical', 0],
|
|
19
|
+
['high', 1],
|
|
20
|
+
['medium', 2],
|
|
21
|
+
['low', 3],
|
|
22
|
+
]);
|
|
23
|
+
const RULE_DESCRIPTIONS = new Map(
|
|
24
|
+
listRules().map(rule => [rule.id, rule.description]),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {object} Annotation
|
|
29
|
+
* @property {'error'|'warning'|'notice'} level
|
|
30
|
+
* @property {string} title
|
|
31
|
+
* @property {string} message
|
|
32
|
+
* @property {string} [file]
|
|
33
|
+
* @property {number} [line]
|
|
34
|
+
* @property {number} [column]
|
|
35
|
+
* @property {string} [id]
|
|
36
|
+
* @property {'critical'|'high'|'medium'|'low'} [severity]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert one command result into native annotation records.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} input
|
|
43
|
+
* @param {string} input.command
|
|
44
|
+
* @param {object} input.result
|
|
45
|
+
* @param {string} [input.cwd]
|
|
46
|
+
* @returns {Annotation[]}
|
|
47
|
+
*/
|
|
48
|
+
export function collectAnnotations({ command, result, cwd = process.cwd() }) {
|
|
49
|
+
/** @type {Annotation[]} */
|
|
50
|
+
const annotations = [];
|
|
51
|
+
if (command === 'audit') {
|
|
52
|
+
annotations.push(...(result.findings ?? []).map(finding => (
|
|
53
|
+
findingAnnotation(finding, cwd)
|
|
54
|
+
)));
|
|
55
|
+
} else if (command === 'report') {
|
|
56
|
+
annotations.push(...(result.audit?.findings ?? []).map(finding => (
|
|
57
|
+
findingAnnotation(finding, cwd)
|
|
58
|
+
)));
|
|
59
|
+
annotations.push(...errorAnnotations('pin', result.pin?.errors, cwd));
|
|
60
|
+
annotations.push(...errorAnnotations('upgrade', result.upgrade?.errors, cwd));
|
|
61
|
+
} else if (command === 'org-scan') {
|
|
62
|
+
annotations.push(...(result.findings ?? []).map(finding => (
|
|
63
|
+
findingAnnotation({
|
|
64
|
+
...finding,
|
|
65
|
+
file: undefined,
|
|
66
|
+
fields: {
|
|
67
|
+
...finding.fields,
|
|
68
|
+
repository: finding.repository,
|
|
69
|
+
remote_path: finding.fields?.file,
|
|
70
|
+
source_url: finding.url,
|
|
71
|
+
},
|
|
72
|
+
}, cwd)
|
|
73
|
+
)));
|
|
74
|
+
annotations.push(...errorAnnotations('org-scan', result.errors, cwd));
|
|
75
|
+
} else if (command === 'verify') {
|
|
76
|
+
annotations.push(...(result.warnings ?? []).map(warning => ({
|
|
77
|
+
level: 'warning',
|
|
78
|
+
severity: 'medium',
|
|
79
|
+
title: 'actions-warden: verification warning',
|
|
80
|
+
message: diagnosticMessage(warning.warning, warning),
|
|
81
|
+
...location(warning, cwd),
|
|
82
|
+
id: warning.id,
|
|
83
|
+
})));
|
|
84
|
+
annotations.push(...errorAnnotations('verify', result.errors, cwd));
|
|
85
|
+
} else if (command === 'pin' || command === 'upgrade') {
|
|
86
|
+
annotations.push(...errorAnnotations(command, result.errors, cwd));
|
|
87
|
+
}
|
|
88
|
+
return annotations.sort(compareAnnotations);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Apply a deterministic per-level cap.
|
|
93
|
+
*
|
|
94
|
+
* GitHub currently limits warning and error annotations per Action step. A
|
|
95
|
+
* matching notice cap keeps output bounded and predictable.
|
|
96
|
+
*
|
|
97
|
+
* @param {Annotation[]} annotations
|
|
98
|
+
* @param {number} [limitPerLevel]
|
|
99
|
+
*/
|
|
100
|
+
export function limitAnnotations(annotations, limitPerLevel = 10) {
|
|
101
|
+
const limit = Math.max(0, Math.min(10, Number.isInteger(limitPerLevel) ? limitPerLevel : 10));
|
|
102
|
+
const counts = new Map();
|
|
103
|
+
const emitted = [];
|
|
104
|
+
const omittedByLevel = { error: 0, warning: 0, notice: 0 };
|
|
105
|
+
for (const annotation of [...annotations].sort(compareAnnotations)) {
|
|
106
|
+
const count = counts.get(annotation.level) ?? 0;
|
|
107
|
+
if (count >= limit) {
|
|
108
|
+
omittedByLevel[annotation.level] += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
counts.set(annotation.level, count + 1);
|
|
112
|
+
emitted.push(annotation);
|
|
113
|
+
}
|
|
114
|
+
const omitted = Object.values(omittedByLevel).reduce((sum, count) => sum + count, 0);
|
|
115
|
+
return { emitted, omitted, omittedByLevel };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Encode annotations as GitHub workflow commands.
|
|
120
|
+
*
|
|
121
|
+
* @param {Annotation[]} annotations
|
|
122
|
+
*/
|
|
123
|
+
export function renderAnnotationCommands(annotations) {
|
|
124
|
+
if (annotations.length === 0) return '';
|
|
125
|
+
return annotations.map(annotation => {
|
|
126
|
+
const properties = [];
|
|
127
|
+
if (annotation.file) properties.push(`file=${escapeProperty(annotation.file)}`);
|
|
128
|
+
if (annotation.file && annotation.line) properties.push(`line=${annotation.line}`);
|
|
129
|
+
if (annotation.file && annotation.column) properties.push(`col=${annotation.column}`);
|
|
130
|
+
properties.push(`title=${escapeProperty(annotation.title)}`);
|
|
131
|
+
const suffix = properties.length > 0 ? ` ${properties.join(',')}` : '';
|
|
132
|
+
return `::${annotation.level}${suffix}::${escapeData(redact(annotation.message))}`;
|
|
133
|
+
}).join('\n') + '\n';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findingAnnotation(finding, cwd) {
|
|
137
|
+
const severity = normalizeSeverity(finding.severity);
|
|
138
|
+
const description = finding.explain
|
|
139
|
+
|| RULE_DESCRIPTIONS.get(finding.ruleId)
|
|
140
|
+
|| 'GitHub Actions security finding.';
|
|
141
|
+
return {
|
|
142
|
+
level: annotationLevel(severity),
|
|
143
|
+
severity,
|
|
144
|
+
title: `actions-warden: ${finding.ruleId}`,
|
|
145
|
+
message: diagnosticMessage(description, {
|
|
146
|
+
...finding.fields,
|
|
147
|
+
ruleId: finding.ruleId,
|
|
148
|
+
id: finding.id,
|
|
149
|
+
}),
|
|
150
|
+
...location(finding, cwd),
|
|
151
|
+
id: finding.id,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function errorAnnotations(command, errors = [], cwd) {
|
|
156
|
+
return errors.map(error => ({
|
|
157
|
+
level: 'error',
|
|
158
|
+
severity: 'critical',
|
|
159
|
+
title: `actions-warden: ${command} error`,
|
|
160
|
+
message: diagnosticMessage(error.error, error),
|
|
161
|
+
...location(error, cwd),
|
|
162
|
+
id: error.id,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function diagnosticMessage(primary, diagnostic) {
|
|
167
|
+
const detailEntries = Object.entries(diagnostic ?? {})
|
|
168
|
+
.filter(([key, value]) => (
|
|
169
|
+
!['error', 'warning', 'explain', 'file', 'line', 'column', 'id'].includes(key)
|
|
170
|
+
&& value !== null
|
|
171
|
+
&& value !== undefined
|
|
172
|
+
&& ['string', 'number', 'boolean'].includes(typeof value)
|
|
173
|
+
))
|
|
174
|
+
.map(([key, value]) => `${key}=${String(value)}`);
|
|
175
|
+
const parts = [
|
|
176
|
+
String(primary || 'actions-warden reported a problem.'),
|
|
177
|
+
detailEntries.join(' '),
|
|
178
|
+
diagnostic?.id ? `[${diagnostic.id}]` : '',
|
|
179
|
+
].filter(Boolean);
|
|
180
|
+
return truncate(parts.join(' '), 16_000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function location(diagnostic, cwd) {
|
|
184
|
+
const file = normalizeFile(diagnostic?.file, cwd);
|
|
185
|
+
if (!file) return {};
|
|
186
|
+
const line = positiveInteger(diagnostic?.line) ?? 1;
|
|
187
|
+
const column = positiveInteger(diagnostic?.column);
|
|
188
|
+
return {
|
|
189
|
+
file,
|
|
190
|
+
line,
|
|
191
|
+
...(column ? { column } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizeFile(file, cwd) {
|
|
196
|
+
if (typeof file !== 'string' || file.length === 0 || file.includes('\0')) return undefined;
|
|
197
|
+
const root = resolve(cwd);
|
|
198
|
+
const absolute = resolve(root, file);
|
|
199
|
+
const rel = relative(root, absolute);
|
|
200
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
|
|
201
|
+
return rel.split(/[\\/]/).join('/');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizeSeverity(severity) {
|
|
205
|
+
return SEVERITY_ORDER.has(severity) ? severity : 'low';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function annotationLevel(severity) {
|
|
209
|
+
if (severity === 'critical' || severity === 'high') return 'error';
|
|
210
|
+
if (severity === 'medium') return 'warning';
|
|
211
|
+
return 'notice';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function compareAnnotations(a, b) {
|
|
215
|
+
return (LEVEL_ORDER.get(a.level) ?? 3) - (LEVEL_ORDER.get(b.level) ?? 3)
|
|
216
|
+
|| (SEVERITY_ORDER.get(a.severity) ?? 4) - (SEVERITY_ORDER.get(b.severity) ?? 4)
|
|
217
|
+
|| compareText(a.file, b.file)
|
|
218
|
+
|| (a.line ?? 0) - (b.line ?? 0)
|
|
219
|
+
|| compareText(a.title, b.title)
|
|
220
|
+
|| compareText(a.id, b.id);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function compareText(a, b) {
|
|
224
|
+
const left = String(a ?? '');
|
|
225
|
+
const right = String(b ?? '');
|
|
226
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function positiveInteger(value) {
|
|
230
|
+
const number = Number(value);
|
|
231
|
+
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function escapeData(value) {
|
|
235
|
+
return String(value)
|
|
236
|
+
.replaceAll('%', '%25')
|
|
237
|
+
.replaceAll('\r', '%0D')
|
|
238
|
+
.replaceAll('\n', '%0A');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function escapeProperty(value) {
|
|
242
|
+
return escapeData(value)
|
|
243
|
+
.replaceAll(':', '%3A')
|
|
244
|
+
.replaceAll(',', '%2C');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function truncate(value, maxLength) {
|
|
248
|
+
if (value.length <= maxLength) return value;
|
|
249
|
+
return `${value.slice(0, maxLength - 1)}…`;
|
|
250
|
+
}
|