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.
- package/AGENTS.md +199 -0
- package/CONTRIBUTING.md +109 -0
- package/README.md +284 -224
- package/RELEASING.md +338 -0
- package/SECURITY.md +30 -3
- package/docs/AI-AGENTS.md +474 -0
- package/docs/CLI.md +474 -0
- package/docs/CONFIGURATION.md +340 -0
- package/docs/DEVELOPMENT.md +373 -0
- package/docs/GITHUB-ACTION.md +285 -0
- package/docs/JAVASCRIPT-API.md +357 -0
- package/docs/OUTPUTS.md +414 -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 +151 -40
- package/src/action.js +306 -0
- package/src/cli.js +494 -56
- package/src/commands/audit.js +189 -36
- package/src/commands/org-scan.js +544 -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 +174 -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 +461 -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/path-equality.js +30 -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 +48 -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,103 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { canonicalPath, occurrenceId } from './identity.js';
|
|
3
|
+
import { resolveRepositoryFile } from './config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load a versioned baseline and return its finding IDs.
|
|
7
|
+
*/
|
|
8
|
+
export async function loadBaseline({ path, cwd = process.cwd() }) {
|
|
9
|
+
const resolvedPath = await resolveRepositoryFile(path, cwd);
|
|
10
|
+
const raw = JSON.parse(await readFile(resolvedPath, 'utf8'));
|
|
11
|
+
if (!raw || typeof raw !== 'object' || raw.schemaVersion !== '1.0') {
|
|
12
|
+
throw new Error('baseline schemaVersion must be "1.0"');
|
|
13
|
+
}
|
|
14
|
+
if (!Array.isArray(raw.findings)) {
|
|
15
|
+
throw new Error('baseline findings must be an array');
|
|
16
|
+
}
|
|
17
|
+
const ids = new Set();
|
|
18
|
+
const fingerprints = new Set();
|
|
19
|
+
for (const finding of raw.findings) {
|
|
20
|
+
if (!finding || typeof finding !== 'object' || typeof finding.id !== 'string') {
|
|
21
|
+
throw new Error('every baseline finding must contain a string id');
|
|
22
|
+
}
|
|
23
|
+
ids.add(finding.id);
|
|
24
|
+
if (finding.fingerprint !== undefined && typeof finding.fingerprint !== 'string') {
|
|
25
|
+
throw new Error('baseline finding fingerprints must be strings');
|
|
26
|
+
}
|
|
27
|
+
if (finding.fingerprint) fingerprints.add(finding.fingerprint);
|
|
28
|
+
}
|
|
29
|
+
return { path: resolvedPath, ids, fingerprints };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Serialize current findings deterministically for review and version control.
|
|
34
|
+
*/
|
|
35
|
+
export function serializeBaseline(findings, cwd = process.cwd()) {
|
|
36
|
+
const records = findings
|
|
37
|
+
.filter(finding => finding.ruleId !== 'parse-error')
|
|
38
|
+
.map(finding => ({
|
|
39
|
+
id: finding.id,
|
|
40
|
+
fingerprint: finding.fingerprint,
|
|
41
|
+
ruleId: finding.ruleId,
|
|
42
|
+
severity: finding.severity,
|
|
43
|
+
file: canonicalPath(finding.file, cwd),
|
|
44
|
+
line: finding.line,
|
|
45
|
+
}))
|
|
46
|
+
.sort((a, b) => (
|
|
47
|
+
a.file.localeCompare(b.file)
|
|
48
|
+
|| a.line - b.line
|
|
49
|
+
|| a.ruleId.localeCompare(b.ruleId)
|
|
50
|
+
|| a.id.localeCompare(b.id)
|
|
51
|
+
));
|
|
52
|
+
return `${JSON.stringify({
|
|
53
|
+
schemaVersion: '1.0',
|
|
54
|
+
generatedBy: 'actions-warden',
|
|
55
|
+
findings: records,
|
|
56
|
+
}, null, 2)}\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Attach line-independent semantic fingerprints while distinguishing repeated
|
|
61
|
+
* equivalent findings by their source-order ordinal.
|
|
62
|
+
*/
|
|
63
|
+
export function assignBaselineFingerprints(findings, cwd = process.cwd()) {
|
|
64
|
+
const ordered = [...findings].sort((a, b) => (
|
|
65
|
+
canonicalPath(a.file, cwd).localeCompare(canonicalPath(b.file, cwd))
|
|
66
|
+
|| a.line - b.line
|
|
67
|
+
|| a.ruleId.localeCompare(b.ruleId)
|
|
68
|
+
|| a.id.localeCompare(b.id)
|
|
69
|
+
));
|
|
70
|
+
const occurrences = new Map();
|
|
71
|
+
for (const finding of ordered) {
|
|
72
|
+
const semantic = stableValue(Object.fromEntries(
|
|
73
|
+
Object.entries(finding.fields ?? {})
|
|
74
|
+
.filter(([key]) => !['file', 'line', 'sev', 'source_line'].includes(key)),
|
|
75
|
+
));
|
|
76
|
+
const key = JSON.stringify({
|
|
77
|
+
ruleId: finding.ruleId,
|
|
78
|
+
file: canonicalPath(finding.file, cwd),
|
|
79
|
+
fields: semantic,
|
|
80
|
+
});
|
|
81
|
+
const ordinal = occurrences.get(key) ?? 0;
|
|
82
|
+
occurrences.set(key, ordinal + 1);
|
|
83
|
+
finding.fingerprint = occurrenceId({
|
|
84
|
+
kind: 'baseline',
|
|
85
|
+
file: finding.file,
|
|
86
|
+
cwd,
|
|
87
|
+
subject: `${key}#${ordinal}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return findings;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function stableValue(value) {
|
|
94
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
95
|
+
if (value && typeof value === 'object') {
|
|
96
|
+
return Object.fromEntries(
|
|
97
|
+
Object.entries(value)
|
|
98
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
99
|
+
.map(([key, item]) => [key, stableValue(item)]),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
package/src/lib/cache.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* On-disk cache for GitHub API responses.
|
|
3
3
|
*
|
|
4
|
-
* Keyed by sha1 of the request
|
|
5
|
-
*
|
|
4
|
+
* Keyed by sha1 of the request identity. TTL is stored in each cache entry.
|
|
5
|
+
* Files live outside the scanned repository, under ACTIONS_WARDEN_CACHE_DIR,
|
|
6
|
+
* XDG_CACHE_HOME, or the user's platform cache directory.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { mkdir, readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
9
10
|
import { resolve, join } from 'node:path';
|
|
10
|
-
import {
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* @typedef {object} CacheEntry
|
|
14
16
|
* @property {number} savedAt - ms epoch
|
|
15
17
|
* @property {number} ttlMs
|
|
16
18
|
* @property {unknown} value
|
|
19
|
+
* @property {string|undefined} etag
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
22
|
/**
|
|
@@ -21,7 +24,11 @@ import { createHash } from 'node:crypto';
|
|
|
21
24
|
* @returns {string}
|
|
22
25
|
*/
|
|
23
26
|
export function cacheDir(cwd = process.cwd()) {
|
|
24
|
-
|
|
27
|
+
const base = process.env.ACTIONS_WARDEN_CACHE_DIR
|
|
28
|
+
?? (process.env.XDG_CACHE_HOME
|
|
29
|
+
? join(process.env.XDG_CACHE_HOME, 'actions-warden')
|
|
30
|
+
: join(homedir(), '.cache', 'actions-warden'));
|
|
31
|
+
return join(resolve(base), digest(resolve(cwd)));
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
/**
|
|
@@ -40,13 +47,28 @@ function digest(key) {
|
|
|
40
47
|
* @returns {Promise<unknown|undefined>}
|
|
41
48
|
*/
|
|
42
49
|
export async function readCache({ key, ttlMs = 3600 * 1000, cwd = process.cwd() }) {
|
|
50
|
+
const cached = await readCacheEntry({ key, ttlMs, cwd });
|
|
51
|
+
return cached?.fresh ? cached.value : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read cache metadata, optionally including an expired value for ETag
|
|
56
|
+
* revalidation.
|
|
57
|
+
*/
|
|
58
|
+
export async function readCacheEntry({
|
|
59
|
+
key,
|
|
60
|
+
ttlMs = 3600 * 1000,
|
|
61
|
+
cwd = process.cwd(),
|
|
62
|
+
allowExpired = false,
|
|
63
|
+
}) {
|
|
43
64
|
const path = join(cacheDir(cwd), `${digest(key)}.json`);
|
|
44
65
|
try {
|
|
45
66
|
const raw = await readFile(path, 'utf8');
|
|
46
67
|
/** @type {CacheEntry} */
|
|
47
68
|
const entry = JSON.parse(raw);
|
|
48
|
-
|
|
49
|
-
return
|
|
69
|
+
const fresh = Date.now() - entry.savedAt <= Math.min(entry.ttlMs, ttlMs);
|
|
70
|
+
if (!fresh && !allowExpired) return undefined;
|
|
71
|
+
return { value: entry.value, etag: entry.etag, fresh };
|
|
50
72
|
} catch {
|
|
51
73
|
return undefined;
|
|
52
74
|
}
|
|
@@ -58,11 +80,26 @@ export async function readCache({ key, ttlMs = 3600 * 1000, cwd = process.cwd()
|
|
|
58
80
|
* @param {unknown} opts.value
|
|
59
81
|
* @param {number} [opts.ttlMs]
|
|
60
82
|
* @param {string} [opts.cwd]
|
|
83
|
+
* @param {string} [opts.etag]
|
|
61
84
|
*/
|
|
62
|
-
export async function writeCache({
|
|
85
|
+
export async function writeCache({
|
|
86
|
+
key,
|
|
87
|
+
value,
|
|
88
|
+
ttlMs = 3600 * 1000,
|
|
89
|
+
cwd = process.cwd(),
|
|
90
|
+
etag,
|
|
91
|
+
}) {
|
|
63
92
|
const dir = cacheDir(cwd);
|
|
64
93
|
await mkdir(dir, { recursive: true });
|
|
65
94
|
/** @type {CacheEntry} */
|
|
66
|
-
const entry = { savedAt: Date.now(), ttlMs, value };
|
|
67
|
-
|
|
95
|
+
const entry = { savedAt: Date.now(), ttlMs, value, ...(etag ? { etag } : {}) };
|
|
96
|
+
const path = join(dir, `${digest(key)}.json`);
|
|
97
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
98
|
+
try {
|
|
99
|
+
await writeFile(temp, JSON.stringify(entry), { encoding: 'utf8', mode: 0o600 });
|
|
100
|
+
await rename(temp, path);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await unlink(temp).catch(() => {});
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
68
105
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Order-preserving bounded async map.
|
|
3
|
+
*/
|
|
4
|
+
export async function mapLimit(values, limit, worker) {
|
|
5
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error('concurrency limit must be positive');
|
|
6
|
+
const results = new Array(values.length);
|
|
7
|
+
let cursor = 0;
|
|
8
|
+
let stopped = false;
|
|
9
|
+
async function run() {
|
|
10
|
+
for (;;) {
|
|
11
|
+
if (stopped) return;
|
|
12
|
+
const index = cursor;
|
|
13
|
+
cursor += 1;
|
|
14
|
+
if (index >= values.length) return;
|
|
15
|
+
try {
|
|
16
|
+
results[index] = await worker(values[index], index);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
stopped = true;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
await Promise.all(
|
|
24
|
+
Array.from({ length: Math.min(limit, values.length) }, () => run()),
|
|
25
|
+
);
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import picomatch from 'picomatch';
|
|
4
|
+
import { parseDocument } from 'yaml';
|
|
5
|
+
|
|
6
|
+
const CONFIG_NAMES = ['.actions-warden.yml', '.actions-warden.yaml'];
|
|
7
|
+
const SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
|
8
|
+
const TOP_LEVEL_KEYS = new Set([
|
|
9
|
+
'version',
|
|
10
|
+
'baseline',
|
|
11
|
+
'ignore-paths',
|
|
12
|
+
'rules',
|
|
13
|
+
'runner-policy',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
17
|
+
path: null,
|
|
18
|
+
baseline: null,
|
|
19
|
+
ignorePaths: [],
|
|
20
|
+
rules: {},
|
|
21
|
+
runnerPolicy: {
|
|
22
|
+
trustedGroups: [],
|
|
23
|
+
selfHostedLabels: [],
|
|
24
|
+
flagUnknownGroups: false,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load and strictly validate repository policy.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {string} [options.cwd]
|
|
33
|
+
* @param {string|false} [options.path]
|
|
34
|
+
* @param {string[]} [options.ruleIds]
|
|
35
|
+
*/
|
|
36
|
+
export async function loadConfig({
|
|
37
|
+
cwd = process.cwd(),
|
|
38
|
+
path,
|
|
39
|
+
ruleIds = [],
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (path === false) {
|
|
42
|
+
return { ...DEFAULT_CONFIG, runnerPolicy: { ...DEFAULT_CONFIG.runnerPolicy } };
|
|
43
|
+
}
|
|
44
|
+
let resolvedPath;
|
|
45
|
+
if (path) {
|
|
46
|
+
resolvedPath = await resolveRepositoryFile(path, cwd);
|
|
47
|
+
} else {
|
|
48
|
+
for (const candidate of CONFIG_NAMES) {
|
|
49
|
+
try {
|
|
50
|
+
resolvedPath = await resolveRepositoryFile(candidate, cwd);
|
|
51
|
+
break;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== 'ENOENT') throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!resolvedPath) return { ...DEFAULT_CONFIG, runnerPolicy: { ...DEFAULT_CONFIG.runnerPolicy } };
|
|
58
|
+
|
|
59
|
+
const source = await readFile(resolvedPath, 'utf8');
|
|
60
|
+
const document = parseDocument(source);
|
|
61
|
+
if (document.errors.length > 0) {
|
|
62
|
+
throw new Error(`invalid actions-warden config: ${document.errors[0].message}`);
|
|
63
|
+
}
|
|
64
|
+
const raw = document.toJS() ?? {};
|
|
65
|
+
if (!isRecord(raw)) throw new Error('actions-warden config must be a mapping');
|
|
66
|
+
rejectUnknownKeys(raw, TOP_LEVEL_KEYS, 'config');
|
|
67
|
+
if (raw.version !== undefined && raw.version !== 1) {
|
|
68
|
+
throw new Error('actions-warden config version must be 1');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const knownRules = new Set(ruleIds);
|
|
72
|
+
const rules = validateRules(raw.rules, knownRules);
|
|
73
|
+
const runnerPolicy = validateRunnerPolicy(raw['runner-policy']);
|
|
74
|
+
const baseline = optionalString(raw.baseline, 'baseline');
|
|
75
|
+
const ignorePaths = stringArray(raw['ignore-paths'], 'ignore-paths');
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
path: resolvedPath,
|
|
79
|
+
baseline,
|
|
80
|
+
ignorePaths,
|
|
81
|
+
rules,
|
|
82
|
+
runnerPolicy,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function filterIgnoredPaths(files, config, cwd) {
|
|
87
|
+
if (config.ignorePaths.length === 0) return files;
|
|
88
|
+
const matchers = config.ignorePaths.map(pattern => picomatch(pattern, { dot: true }));
|
|
89
|
+
return files.filter(file => {
|
|
90
|
+
const path = relative(cwd, file).split(sep).join('/');
|
|
91
|
+
return !matchers.some(matches => matches(path));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function resolveRepositoryFile(path, cwd = process.cwd()) {
|
|
96
|
+
const requestedRoot = resolve(cwd);
|
|
97
|
+
const requested = resolve(requestedRoot, path);
|
|
98
|
+
if (!isAbsolute(path) && isOutside(relative(requestedRoot, requested))) {
|
|
99
|
+
throw new Error(`path traversal rejected: ${path}`);
|
|
100
|
+
}
|
|
101
|
+
const [root, target] = await Promise.all([
|
|
102
|
+
realpath(requestedRoot),
|
|
103
|
+
realpath(requested),
|
|
104
|
+
]);
|
|
105
|
+
if (isOutside(relative(root, target))) {
|
|
106
|
+
throw new Error(`repository file escapes working directory: ${path}`);
|
|
107
|
+
}
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateRules(value, knownRules) {
|
|
112
|
+
if (value === undefined) return {};
|
|
113
|
+
if (!isRecord(value)) throw new Error('config.rules must be a mapping');
|
|
114
|
+
const rules = {};
|
|
115
|
+
for (const [ruleId, policy] of Object.entries(value)) {
|
|
116
|
+
if (knownRules.size > 0 && !knownRules.has(ruleId)) {
|
|
117
|
+
throw new Error(`unknown rule in config: ${ruleId}`);
|
|
118
|
+
}
|
|
119
|
+
if (!isRecord(policy)) throw new Error(`config.rules.${ruleId} must be a mapping`);
|
|
120
|
+
rejectUnknownKeys(policy, new Set(['enabled', 'severity']), `config.rules.${ruleId}`);
|
|
121
|
+
if (policy.enabled !== undefined && typeof policy.enabled !== 'boolean') {
|
|
122
|
+
throw new Error(`config.rules.${ruleId}.enabled must be a boolean`);
|
|
123
|
+
}
|
|
124
|
+
if (policy.severity !== undefined && !SEVERITIES.has(policy.severity)) {
|
|
125
|
+
throw new Error(`config.rules.${ruleId}.severity must be low, medium, high, or critical`);
|
|
126
|
+
}
|
|
127
|
+
rules[ruleId] = {
|
|
128
|
+
enabled: policy.enabled ?? true,
|
|
129
|
+
severity: policy.severity,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return rules;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateRunnerPolicy(value) {
|
|
136
|
+
if (value === undefined) return { ...DEFAULT_CONFIG.runnerPolicy };
|
|
137
|
+
if (!isRecord(value)) throw new Error('config.runner-policy must be a mapping');
|
|
138
|
+
rejectUnknownKeys(
|
|
139
|
+
value,
|
|
140
|
+
new Set(['trusted-groups', 'self-hosted-labels', 'flag-unknown-groups']),
|
|
141
|
+
'config.runner-policy',
|
|
142
|
+
);
|
|
143
|
+
const flagUnknownGroups = value['flag-unknown-groups'] ?? false;
|
|
144
|
+
if (typeof flagUnknownGroups !== 'boolean') {
|
|
145
|
+
throw new Error('config.runner-policy.flag-unknown-groups must be a boolean');
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
trustedGroups: stringArray(value['trusted-groups'], 'runner-policy.trusted-groups'),
|
|
149
|
+
selfHostedLabels: stringArray(
|
|
150
|
+
value['self-hosted-labels'],
|
|
151
|
+
'runner-policy.self-hosted-labels',
|
|
152
|
+
),
|
|
153
|
+
flagUnknownGroups,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
158
|
+
for (const key of Object.keys(value)) {
|
|
159
|
+
if (!allowed.has(key)) throw new Error(`unknown ${label} key: ${key}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stringArray(value, label) {
|
|
164
|
+
if (value === undefined) return [];
|
|
165
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || !item)) {
|
|
166
|
+
throw new Error(`config.${label} must be an array of non-empty strings`);
|
|
167
|
+
}
|
|
168
|
+
return [...new Set(value)];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function optionalString(value, label) {
|
|
172
|
+
if (value === undefined) return null;
|
|
173
|
+
if (typeof value !== 'string' || !value) {
|
|
174
|
+
throw new Error(`config.${label} must be a non-empty string`);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isRecord(value) {
|
|
180
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isOutside(path) {
|
|
184
|
+
return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path);
|
|
185
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative indicators that a step may execute or interpret files from the
|
|
3
|
+
* current workspace. Avoid treating harmless shell commands such as `echo` as
|
|
4
|
+
* code execution while covering common build/test/package entry points.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const WORKSPACE_COMMAND = /(?:^|[;&|])\s*(?:sudo\s+)?(?:(?:\.{0,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9_.-]+[\\/])[^\s;&|]+|source\s+[^\s;&|]+|make(?:\s|$)|npm\s+(?:ci|install|test|run|exec)\b|npx\b|pnpm\s+(?:install|test|run|exec)\b|yarn\s+(?:install|test|run|exec)\b|bun\s+(?:install|test|run|x)\b|(?:bash|sh|zsh|python3?|ruby|node|perl|php)\s+[^\s;&|]+|(?:pwsh|powershell)(?:\.exe)?\s+(?:-File\s+)?[^\s;&|]+|cmd(?:\.exe)?\s+\/c\s+[^\s;&|]+|java\s+-jar\s+[^\s;&|]+|cargo\s+(?:build|test|run)\b|go\s+(?:build|test|run)\b|mvn\b|gradle\b|\.\/gradlew\b|dotnet\s+(?:build|test|run|publish)\b|bundle\s+exec\b|rake\b|pytest\b)/im;
|
|
8
|
+
const WORKSPACE_ACTION = /(?:^|[-_/])(build|builder|compile|exec|package|publish|runner|test|deploy)(?:[-_/]|$)/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('./parser.js').StepNode} step
|
|
12
|
+
*/
|
|
13
|
+
export function executesWorkspace(step, { sourcePaths = ['.'] } = {}) {
|
|
14
|
+
let executes = false;
|
|
15
|
+
if (step.uses?.kind === 'local') executes = true;
|
|
16
|
+
if (typeof step.run === 'string' && WORKSPACE_COMMAND.test(step.run)) executes = true;
|
|
17
|
+
if (step.uses?.kind === 'external') {
|
|
18
|
+
const identity = [
|
|
19
|
+
step.uses.owner,
|
|
20
|
+
step.uses.repo,
|
|
21
|
+
step.uses.subpath,
|
|
22
|
+
].filter(Boolean).join('/');
|
|
23
|
+
if (WORKSPACE_ACTION.test(identity)) executes = true;
|
|
24
|
+
if (Object.values(step.with_ ?? {}).some(
|
|
25
|
+
value => typeof value === 'string' && WORKSPACE_COMMAND.test(value),
|
|
26
|
+
)) executes = true;
|
|
27
|
+
}
|
|
28
|
+
if (!executes) return false;
|
|
29
|
+
|
|
30
|
+
const normalized = sourcePaths.map(normalizePath);
|
|
31
|
+
if (normalized.includes('.')) return true;
|
|
32
|
+
const candidates = [
|
|
33
|
+
step.run,
|
|
34
|
+
step.workingDirectory,
|
|
35
|
+
step.uses?.raw,
|
|
36
|
+
...Object.values(step.with_ ?? {}),
|
|
37
|
+
].filter(value => typeof value === 'string');
|
|
38
|
+
return normalized.some(sourcePath => (
|
|
39
|
+
candidates.some(candidate => referencesPath(candidate, sourcePath))
|
|
40
|
+
));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeSourcePath(value) {
|
|
44
|
+
if (typeof value !== 'string' || !value.trim()) return '.';
|
|
45
|
+
const expanded = normalizeTrustedRoots(value);
|
|
46
|
+
if (expanded.includes('${{')) return '.';
|
|
47
|
+
return normalizePath(expanded);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizePath(value) {
|
|
51
|
+
const normalized = normalizeTrustedRoots(String(value))
|
|
52
|
+
.trim()
|
|
53
|
+
.replaceAll('\\', '/')
|
|
54
|
+
.replace(/^\.\/+/, '');
|
|
55
|
+
return normalized.replace(/\/+$/, '') || '.';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeTrustedRoots(value) {
|
|
59
|
+
return String(value)
|
|
60
|
+
.replace(/\$\{\{\s*runner\.temp\s*\}\}/gi, '__runner_temp__')
|
|
61
|
+
.replace(/\$(?:\{RUNNER_TEMP\}|RUNNER_TEMP\b)|%RUNNER_TEMP%/g, '__runner_temp__')
|
|
62
|
+
.replace(/\$\{\{\s*github\.workspace\s*\}\}/gi, '.')
|
|
63
|
+
.replace(/\$(?:\{GITHUB_WORKSPACE\}|GITHUB_WORKSPACE\b)|%GITHUB_WORKSPACE%/g, '.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function referencesPath(value, path) {
|
|
67
|
+
const normalizedValue = normalizePath(value);
|
|
68
|
+
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
69
|
+
return new RegExp(`(?:^|[\\s"'=:/])${escaped}(?:$|[\\s"'/:;&|])`, 'i')
|
|
70
|
+
.test(normalizedValue);
|
|
71
|
+
}
|
package/src/lib/formatter.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Output formatter supporting TOON (Token-Oriented Object Notation), JSON,
|
|
3
|
-
* plain text.
|
|
2
|
+
* Output formatter supporting TOON (Token-Oriented Object Notation), JSON,
|
|
3
|
+
* plain text, and SARIF.
|
|
4
4
|
*
|
|
5
5
|
* TOON output rules:
|
|
6
6
|
* - One record per line: `LABEL: key=value key=value`
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - Trailing `STATUS: OK` or `STATUS: FAIL` signal for machine consumers
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { redact } from './redact.js';
|
|
12
|
+
import { redact, redactDeep } from './redact.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Severity ordering, lowest-to-highest.
|
|
@@ -22,8 +22,8 @@ export const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
|
|
|
22
22
|
*/
|
|
23
23
|
function quoteIfNeeded(value) {
|
|
24
24
|
if (value === '') return '""';
|
|
25
|
-
if (/[\s="]/.test(value)) {
|
|
26
|
-
return
|
|
25
|
+
if (/[\s="\\]/.test(value) || [...value].some(char => char.charCodeAt(0) < 32)) {
|
|
26
|
+
return JSON.stringify(value);
|
|
27
27
|
}
|
|
28
28
|
return value;
|
|
29
29
|
}
|
|
@@ -65,7 +65,7 @@ export function renderToon(records, options = {}) {
|
|
|
65
65
|
* @returns {string}
|
|
66
66
|
*/
|
|
67
67
|
export function renderJson(payload) {
|
|
68
|
-
return JSON.stringify(payload, null, 2) + '\n';
|
|
68
|
+
return JSON.stringify(redactDeep(payload), null, 2) + '\n';
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
@@ -79,7 +79,7 @@ export function renderText(records, options = {}) {
|
|
|
79
79
|
const lines = records.map(r => {
|
|
80
80
|
const kv = Object.entries(r.fields)
|
|
81
81
|
.filter(([, v]) => v !== null && v !== undefined && v !== '')
|
|
82
|
-
.map(([k, v]) => `${k}=${redact(String(v))}`)
|
|
82
|
+
.map(([k, v]) => `${k}=${escapeText(redact(String(v)))}`)
|
|
83
83
|
.join(' ');
|
|
84
84
|
return `[${r.label}] ${kv}`;
|
|
85
85
|
});
|
|
@@ -87,10 +87,14 @@ export function renderText(records, options = {}) {
|
|
|
87
87
|
return lines.join('\n') + '\n';
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function escapeText(value) {
|
|
91
|
+
return JSON.stringify(value).slice(1, -1);
|
|
92
|
+
}
|
|
93
|
+
|
|
90
94
|
/**
|
|
91
95
|
* Format records into the requested output mode.
|
|
92
96
|
*
|
|
93
|
-
* @param {'toon'|'json'|'text'} format
|
|
97
|
+
* @param {'toon'|'json'|'text'|'sarif'} format
|
|
94
98
|
* @param {Array<{label: string, fields: Record<string, unknown>}>} records
|
|
95
99
|
* @param {{status?: 'OK'|'FAIL', json?: unknown}} [options]
|
|
96
100
|
* @returns {string}
|
|
@@ -101,12 +105,126 @@ export function format(format_, records, options = {}) {
|
|
|
101
105
|
return renderJson(options.json ?? recordsToJson(records, options));
|
|
102
106
|
case 'text':
|
|
103
107
|
return renderText(records, options);
|
|
108
|
+
case 'sarif':
|
|
109
|
+
return renderSarif(records, options);
|
|
104
110
|
case 'toon':
|
|
105
111
|
default:
|
|
106
112
|
return renderToon(records, options);
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Render records as SARIF 2.1.0 for GitHub code scanning.
|
|
118
|
+
*
|
|
119
|
+
* @param {Array<{label: string, fields: Record<string, unknown>}>} records
|
|
120
|
+
* @param {{status?: string}} [options]
|
|
121
|
+
*/
|
|
122
|
+
export function renderSarif(records, options = {}) {
|
|
123
|
+
const ruleRecords = records.filter(record => record.label === 'RULE');
|
|
124
|
+
const resultRecords = records.filter(record => (
|
|
125
|
+
record.label === 'FINDING'
|
|
126
|
+
|| record.label === 'ERROR'
|
|
127
|
+
|| record.label === 'WARNING'
|
|
128
|
+
|| record.label === 'PIN'
|
|
129
|
+
|| record.label === 'UPGRADE'
|
|
130
|
+
));
|
|
131
|
+
const rules = new Map();
|
|
132
|
+
for (const record of ruleRecords) {
|
|
133
|
+
const id = String(record.fields.id ?? 'actions-warden');
|
|
134
|
+
rules.set(id, {
|
|
135
|
+
id,
|
|
136
|
+
shortDescription: {
|
|
137
|
+
text: String(record.fields.description ?? id),
|
|
138
|
+
},
|
|
139
|
+
defaultConfiguration: {
|
|
140
|
+
level: sarifLevel(String(record.fields.severity ?? 'low')),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const results = resultRecords.map(record => {
|
|
146
|
+
const fields = record.fields;
|
|
147
|
+
const ruleId = String(fields.type ?? `actions-warden/${record.label.toLowerCase()}`);
|
|
148
|
+
if (!rules.has(ruleId)) {
|
|
149
|
+
rules.set(ruleId, {
|
|
150
|
+
id: ruleId,
|
|
151
|
+
shortDescription: { text: ruleId },
|
|
152
|
+
defaultConfiguration: {
|
|
153
|
+
level: record.label === 'ERROR'
|
|
154
|
+
? 'error'
|
|
155
|
+
: sarifLevel(String(fields.sev ?? 'low')),
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const file = fields.file ? String(fields.file) : null;
|
|
160
|
+
const line = Number(fields.line);
|
|
161
|
+
return {
|
|
162
|
+
ruleId,
|
|
163
|
+
level: record.label === 'ERROR'
|
|
164
|
+
? 'error'
|
|
165
|
+
: record.label === 'WARNING'
|
|
166
|
+
? 'warning'
|
|
167
|
+
: sarifLevel(String(fields.sev ?? (record.label === 'FINDING' ? 'warning' : 'low'))),
|
|
168
|
+
message: {
|
|
169
|
+
text: String(fields.explain ?? fields.msg ?? summarizeFields(record.label, fields)),
|
|
170
|
+
},
|
|
171
|
+
...(file ? {
|
|
172
|
+
locations: [{
|
|
173
|
+
physicalLocation: {
|
|
174
|
+
artifactLocation: { uri: file.replace(/\\/g, '/') },
|
|
175
|
+
...(Number.isInteger(line) && line > 0
|
|
176
|
+
? { region: { startLine: line } }
|
|
177
|
+
: {}),
|
|
178
|
+
},
|
|
179
|
+
}],
|
|
180
|
+
} : {}),
|
|
181
|
+
...(fields.id ? {
|
|
182
|
+
partialFingerprints: {
|
|
183
|
+
// GitHub code scanning recognizes this key and uses the suffix to
|
|
184
|
+
// distinguish multiple results with the same semantic fingerprint.
|
|
185
|
+
primaryLocationLineHash: `${fields.fingerprint ?? fields.id}:1`,
|
|
186
|
+
'actions-warden/id': String(fields.id),
|
|
187
|
+
...(fields.fingerprint
|
|
188
|
+
? { 'actions-warden/semantic': String(fields.fingerprint) }
|
|
189
|
+
: {}),
|
|
190
|
+
},
|
|
191
|
+
} : {}),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return renderJson({
|
|
196
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
197
|
+
version: '2.1.0',
|
|
198
|
+
runs: [{
|
|
199
|
+
tool: {
|
|
200
|
+
driver: {
|
|
201
|
+
name: 'actions-warden',
|
|
202
|
+
informationUri: 'https://github.com/chiz0me/actions-warden',
|
|
203
|
+
rules: [...rules.values()],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
invocations: [{
|
|
207
|
+
executionSuccessful: options.status !== 'ERROR',
|
|
208
|
+
}],
|
|
209
|
+
results,
|
|
210
|
+
}],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sarifLevel(severity) {
|
|
215
|
+
if (severity === 'critical' || severity === 'high' || severity === 'error') return 'error';
|
|
216
|
+
if (severity === 'medium' || severity === 'warning') return 'warning';
|
|
217
|
+
return 'note';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function summarizeFields(label, fields) {
|
|
221
|
+
const details = Object.entries(fields)
|
|
222
|
+
.filter(([key, value]) => !['id', 'file', 'line'].includes(key) && value != null && value !== '')
|
|
223
|
+
.map(([key, value]) => `${key}=${String(value)}`)
|
|
224
|
+
.join(' ');
|
|
225
|
+
return `${label}: ${details}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
110
228
|
/**
|
|
111
229
|
* Convert records to a generic JSON payload when no custom JSON is supplied.
|
|
112
230
|
*
|
|
@@ -116,6 +234,7 @@ export function format(format_, records, options = {}) {
|
|
|
116
234
|
*/
|
|
117
235
|
function recordsToJson(records, options) {
|
|
118
236
|
return {
|
|
237
|
+
schemaVersion: '1.0',
|
|
119
238
|
records: records.map(r => ({ label: r.label, ...r.fields })),
|
|
120
239
|
status: options.status ?? null,
|
|
121
240
|
};
|