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,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 URL. TTL stored in the cache entry. Files live
5
- * in `.actions-warden-cache/` inside the working directory.
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 { createHash } from 'node:crypto';
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
- return resolve(cwd, '.actions-warden-cache');
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
- if (Date.now() - entry.savedAt > Math.min(entry.ttlMs, ttlMs)) return undefined;
49
- return entry.value;
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({ key, value, ttlMs = 3600 * 1000, cwd = process.cwd() }) {
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
- await writeFile(join(dir, `${digest(key)}.json`), JSON.stringify(entry), 'utf8');
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
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Output formatter supporting TOON (Token-Oriented Object Notation), JSON, and
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 `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
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
  };