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.
Files changed (62) hide show
  1. package/AGENTS.md +189 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +272 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +25 -3
  6. package/docs/AI-AGENTS.md +458 -0
  7. package/docs/CLI.md +421 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +350 -0
  10. package/docs/GITHUB-ACTION.md +281 -0
  11. package/docs/JAVASCRIPT-API.md +355 -0
  12. package/docs/OUTPUTS.md +409 -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 +140 -38
  19. package/src/action.js +317 -0
  20. package/src/cli.js +267 -22
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +549 -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 +175 -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 +378 -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/paths.js +35 -12
  45. package/src/lib/redact.js +65 -4
  46. package/src/lib/resolver.js +225 -43
  47. package/src/lib/targets.js +28 -0
  48. package/src/lib/triggers.js +12 -0
  49. package/src/lib/writer.js +45 -8
  50. package/src/rules/excessive-permissions.js +24 -33
  51. package/src/rules/index.js +19 -1
  52. package/src/rules/pull-request-target-checkout.js +149 -18
  53. package/src/rules/reusable-workflow-secrets.js +32 -0
  54. package/src/rules/script-injection.js +77 -12
  55. package/src/rules/secrets-in-env.js +101 -18
  56. package/src/rules/unpinned-action.js +3 -2
  57. package/src/rules/unpinned-container-image.js +39 -0
  58. package/src/rules/unpinned-docker-action.js +30 -0
  59. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  60. package/src/rules/workflow-run-artifact-execution.js +122 -0
  61. package/src/rules/workflow-structure.js +396 -0
  62. package/src/version.js +3 -0
@@ -3,44 +3,175 @@
3
3
  * head ref expose secrets to attacker-supplied code (pwn-request pattern).
4
4
  */
5
5
 
6
+ import { hasTrigger } from '../lib/triggers.js';
7
+ import { executesWorkspace, normalizeSourcePath } from '../lib/execution.js';
8
+ import semver from 'semver';
9
+
6
10
  export const id = 'pull-request-target-checkout';
7
11
  export const severity = 'critical';
8
- export const description = 'pull_request_target workflow checks out attacker-controlled head ref.';
9
-
10
- function hasPullRequestTarget(on) {
11
- if (!on) return false;
12
- if (typeof on === 'string') return on === 'pull_request_target';
13
- if (Array.isArray(on)) return on.includes('pull_request_target');
14
- if (typeof on === 'object') return Object.prototype.hasOwnProperty.call(on, 'pull_request_target');
15
- return false;
12
+ export const description = 'pull_request_target can fetch attacker-controlled code without an active checkout guard.';
13
+
14
+ // GitHub backported fork-PR checkout protection on 2026-07-20. Floating major
15
+ // tags v2-v7 received it automatically; immutable callers need one of these
16
+ // release commits (or a later release that a future warden version recognizes).
17
+ const PROTECTED_MINIMUM = new Map([
18
+ [2, '2.8.0'],
19
+ [3, '3.7.0'],
20
+ [4, '4.4.0'],
21
+ [5, '5.1.0'],
22
+ [6, '6.1.0'],
23
+ [7, '7.0.0'],
24
+ ]);
25
+ const KNOWN_PROTECTED_SHAS = new Set([
26
+ '0717577d45739eb3c851188b29f50ed6c0b2194e', // v2.8.0
27
+ 'a37ce9120846195fa4ece8f58b268e6043cb2f26', // v3.7.0
28
+ '11d5960a326750d5838078e36cf38b85af677262', // v4.4.0
29
+ 'fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09', // v5.1.0
30
+ 'd23441a48e516b6c34aea4fa41551a30e30af803', // v6.1.0
31
+ '9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0', // v7.0.0
32
+ '3d3c42e5aac5ba805825da76410c181273ba90b1', // v7.0.1
33
+ ]);
34
+
35
+ function checkoutProtection(step) {
36
+ const optOut = step.with_?.['allow-unsafe-pr-checkout'];
37
+ if (optOut === true || (typeof optOut === 'string' && optOut.trim().toLowerCase() === 'true')) {
38
+ return { active: false, reason: 'explicit-opt-out' };
39
+ }
40
+ if (typeof optOut === 'string' && optOut.includes('${{')) {
41
+ return { active: false, reason: 'dynamic-opt-out' };
42
+ }
43
+
44
+ const ref = step.uses?.ref ?? '';
45
+ if (KNOWN_PROTECTED_SHAS.has(ref.toLowerCase())) {
46
+ return { active: true, reason: 'protected-release-sha' };
47
+ }
48
+ const majorTag = ref.match(/^v([2-7])$/i);
49
+ if (majorTag) return { active: true, reason: 'protected-floating-major' };
50
+
51
+ const version = ref.match(/^v?(\d+)\.(\d+)(?:\.(\d+))?$/i);
52
+ if (version) {
53
+ const major = Number(version[1]);
54
+ const minimum = PROTECTED_MINIMUM.get(major);
55
+ const normalized = `${major}.${version[2]}.${version[3] ?? '0'}`;
56
+ if (minimum && semver.gte(normalized, minimum)) {
57
+ return { active: true, reason: 'protected-release-tag' };
58
+ }
59
+ }
60
+ return { active: false, reason: 'unknown-or-unprotected-version' };
16
61
  }
17
62
 
18
- function checksOutHead(step) {
19
- if (!step.uses) return false;
20
- if (step.uses.owner !== 'actions' || step.uses.repo !== 'checkout') return false;
63
+ export function checkoutOfUntrustedPr(step) {
64
+ if (!step.uses) return null;
65
+ if (
66
+ step.uses.owner?.toLowerCase() !== 'actions'
67
+ || step.uses.repo?.toLowerCase() !== 'checkout'
68
+ ) return null;
21
69
  const ref = step.with_?.ref;
22
- if (typeof ref !== 'string') return false;
23
- return /github\.event\.pull_request\.head/i.test(ref) || /github\.head_ref/i.test(ref);
70
+ const repository = step.with_?.repository;
71
+ const untrustedRef = typeof ref === 'string' && (
72
+ /github\.event\.pull_request\.head/i.test(ref)
73
+ || /github\.event\.pull_request\.merge_commit_sha/i.test(ref)
74
+ || /github\.head_ref/i.test(ref)
75
+ || /refs\/pull\//i.test(ref)
76
+ );
77
+ const untrustedRepository = typeof repository === 'string'
78
+ && /github\.event\.pull_request\.head\.repo/i.test(repository);
79
+ if (!untrustedRef && !untrustedRepository) return null;
80
+
81
+ const protection = checkoutProtection(step);
82
+ if (protection.active) return null;
83
+
84
+ return {
85
+ line: step.uses.line,
86
+ path: normalizeSourcePath(step.with_?.path),
87
+ protection: protection.reason,
88
+ };
89
+ }
90
+
91
+ export function fetchesUntrustedPrCode(step) {
92
+ if (typeof step.run !== 'string') return false;
93
+ if (/\bgh\s+pr\s+(?:checkout|co)\b/i.test(step.run)) return true;
94
+ return /\bgit\s+fetch\b[\s\S]*(?:(?:refs\/)?pull\/|github\.event\.pull_request\.(?:head|number)|github\.head_ref)/i
95
+ .test(step.run);
24
96
  }
25
97
 
26
98
  /**
27
99
  * @param {import('../lib/parser.js').WorkflowDoc} workflow
28
100
  */
29
101
  export function check(workflow) {
30
- if (!hasPullRequestTarget(workflow.on)) return [];
102
+ if (!hasTrigger(workflow.on, 'pull_request_target')) return [];
31
103
  const findings = [];
32
104
  for (const job of workflow.jobs) {
105
+ const untrustedSources = [];
106
+ let executed = false;
33
107
  for (const step of job.steps) {
34
- if (checksOutHead(step)) {
108
+ const checkout = checkoutOfUntrustedPr(step);
109
+ if (checkout) {
110
+ untrustedSources.push(checkout);
111
+ continue;
112
+ }
113
+ if (fetchesUntrustedPrCode(step)) {
114
+ untrustedSources.push({
115
+ line: step.runLine || step.line,
116
+ path: '.',
117
+ protection: 'not-applicable-direct-fetch',
118
+ });
119
+ continue;
120
+ }
121
+ const untrustedSource = untrustedSources.find(source => (
122
+ executesWorkspace(step, { sourcePaths: [source.path] })
123
+ ));
124
+ if (untrustedSource) {
35
125
  findings.push({
36
126
  id,
37
127
  severity,
38
- line: step.line,
39
- fields: { type: id, sev: severity, job: job.name },
40
- explain: 'avoid checking out PR head under pull_request_target - use pull_request, or split build/deploy',
128
+ line: step.runLine || step.line,
129
+ fields: {
130
+ type: id,
131
+ sev: severity,
132
+ job: job.name,
133
+ source_line: untrustedSource.line,
134
+ checkout_protection: untrustedSource.protection,
135
+ },
136
+ explain: remediation(untrustedSource.protection, true),
41
137
  });
138
+ executed = true;
139
+ break;
42
140
  }
43
141
  }
142
+ if (untrustedSources.length > 0 && !executed) {
143
+ const [untrustedSource] = untrustedSources;
144
+ findings.push({
145
+ id,
146
+ severity: 'high',
147
+ line: untrustedSource.line,
148
+ fields: {
149
+ type: id,
150
+ sev: 'high',
151
+ job: job.name,
152
+ source_line: untrustedSource.line,
153
+ stage: 'checkout-only',
154
+ checkout_protection: untrustedSource.protection,
155
+ },
156
+ explain: remediation(untrustedSource.protection, false),
157
+ });
158
+ }
44
159
  }
45
160
  return findings;
46
161
  }
162
+
163
+ function remediation(protection, executed) {
164
+ if (protection === 'explicit-opt-out' || protection === 'dynamic-opt-out') {
165
+ return executed
166
+ ? 'remove `allow-unsafe-pr-checkout` and keep the built-in fork guard; run pull-request code under `pull_request`, or split untrusted build from privileged publication'
167
+ : 'remove `allow-unsafe-pr-checkout` unless this job only inspects data; if the opt-out is required, use a dedicated path, disable persisted credentials, minimize permissions and secrets, and never execute the checkout';
168
+ }
169
+ if (protection === 'unknown-or-unprotected-version') {
170
+ return executed
171
+ ? 'pin the full SHA of a current protected actions/checkout release, or run pull-request code under `pull_request` and keep privileged publication in a separate trusted job'
172
+ : 'pin the full SHA of a current protected actions/checkout release and avoid consuming the pull-request checkout in this privileged job';
173
+ }
174
+ return executed
175
+ ? 'do not fetch and execute pull-request code in a privileged `pull_request_target` job; use `pull_request` or split untrusted build from privileged publication'
176
+ : 'avoid fetching pull-request code in a privileged `pull_request_target` job; if inspection is required, isolate it as data with minimal permissions and no persisted credentials';
177
+ }
@@ -0,0 +1,32 @@
1
+ export const id = 'reusable-workflow-secrets-inherit';
2
+ export const severity = 'high';
3
+ export const description = 'Cross-repository reusable workflow inherits every caller secret.';
4
+
5
+ /**
6
+ * @param {import('../lib/parser.js').WorkflowDoc} workflow
7
+ */
8
+ export function check(workflow) {
9
+ const findings = [];
10
+ for (const job of workflow.jobs) {
11
+ if (
12
+ job.uses?.kind !== 'reusable-workflow'
13
+ || job.secrets !== 'inherit'
14
+ ) {
15
+ continue;
16
+ }
17
+ findings.push({
18
+ id,
19
+ severity,
20
+ line: job.uses.line,
21
+ start: job.uses.start,
22
+ fields: {
23
+ type: id,
24
+ sev: severity,
25
+ job: job.name,
26
+ workflow: job.uses.raw,
27
+ },
28
+ explain: 'declare required names under the callee\'s `on.workflow_call.secrets`, then map only those secrets in this caller instead of using `inherit`',
29
+ });
30
+ }
31
+ return findings;
32
+ }
@@ -9,16 +9,26 @@
9
9
 
10
10
  export const id = 'script-injection';
11
11
  export const severity = 'critical';
12
- export const description = 'Untrusted GitHub context interpolated into shell script.';
12
+ export const description = 'Untrusted GitHub context is interpolated into run or github-script code.';
13
13
 
14
14
  const TAINTED_PATTERNS = [
15
15
  /github\.event\.issue\.(title|body)/i,
16
16
  /github\.event\.pull_request\.(title|body|head\.ref|head\.label)/i,
17
+ /github\.event\.pull_request\.head\.repo\.default_branch/i,
17
18
  /github\.event\.comment\.body/i,
18
19
  /github\.event\.review\.body/i,
20
+ /github\.event\.review_comment\.body/i,
19
21
  /github\.event\.discussion\.(title|body)/i,
20
- /github\.event\.commits\.[\d*]+\.message/i,
21
- /github\.event\.workflow_run\.head_branch/i,
22
+ /github\.event\.commits(?:\[[^\]]+\]|\.[\d*]+)\.message/i,
23
+ /github\.event\.commits(?:\[[^\]]+\]|\.[\d*]+)\.author\.(name|email)/i,
24
+ /github\.event\.head_commit\.message/i,
25
+ /github\.event\.head_commit\.author\.(name|email)/i,
26
+ /github\.event\.label\.name/i,
27
+ /github\.event\.milestone\.title/i,
28
+ /github\.event\.release\.(name|body|tag_name)/i,
29
+ /github\.event\.pages(?:\[[^\]]+\]|\.[\d*]+)\.page_name/i,
30
+ /github\.event\.workflow_run\.(display_title|head_branch)/i,
31
+ /github\.event\.workflow_run\.head_repository\.default_branch/i,
22
32
  /github\.head_ref/i,
23
33
  ];
24
34
 
@@ -28,29 +38,84 @@ const TAINTED_PATTERNS = [
28
38
  */
29
39
  function detectTaintedExpr(run) {
30
40
  if (typeof run !== 'string') return null;
31
- for (const pat of TAINTED_PATTERNS) {
32
- const m = run.match(new RegExp(`\\$\\{\\{[^}]*${pat.source}[^}]*\\}\\}`, 'i'));
33
- if (m) return m[0];
41
+ for (const expression of expressionsIn(run)) {
42
+ for (const pat of TAINTED_PATTERNS) {
43
+ if (pat.test(expression)) return expression;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function detectTaintedEnvExpr(value, taintedKeys) {
50
+ if (typeof value !== 'string') return null;
51
+ for (const expression of expressionsIn(value)) {
52
+ for (const key of taintedKeys) {
53
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
54
+ const property = `env(?:\\.${escaped}|\\[\\s*['"]${escaped}['"]\\s*\\])`;
55
+ if (new RegExp(property, 'i').test(expression)) return { expression, key };
56
+ }
34
57
  }
35
58
  return null;
36
59
  }
37
60
 
61
+ function expressionsIn(value) {
62
+ return value.match(/\$\{\{[\s\S]*?\}\}/g) ?? [];
63
+ }
64
+
65
+ function mergeTaintedEnv(inherited, env) {
66
+ const merged = new Set(inherited);
67
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return merged;
68
+ for (const [key, value] of Object.entries(env)) {
69
+ merged.delete(key);
70
+ if (
71
+ detectTaintedExpr(value)
72
+ || detectTaintedEnvExpr(value, inherited)
73
+ ) merged.add(key);
74
+ }
75
+ return merged;
76
+ }
77
+
38
78
  /**
39
79
  * @param {import('../lib/parser.js').WorkflowDoc} workflow
40
80
  */
41
81
  export function check(workflow) {
42
82
  const findings = [];
83
+ const workflowTaintedEnv = mergeTaintedEnv(new Set(), workflow.env);
43
84
  for (const job of workflow.jobs) {
85
+ const jobTaintedEnv = mergeTaintedEnv(workflowTaintedEnv, job.env);
44
86
  for (const step of job.steps) {
45
- if (!step.run) continue;
46
- const match = detectTaintedExpr(step.run);
47
- if (match) {
87
+ const stepTaintedEnv = mergeTaintedEnv(jobTaintedEnv, step.env);
88
+ const sinks = [];
89
+ if (step.run) sinks.push({ kind: 'run', value: step.run, line: step.runLine || step.line });
90
+ if (
91
+ step.uses?.owner?.toLowerCase() === 'actions'
92
+ && step.uses?.repo?.toLowerCase() === 'github-script'
93
+ && typeof step.with_?.script === 'string'
94
+ ) {
95
+ sinks.push({ kind: 'github-script', value: step.with_.script, line: step.line });
96
+ }
97
+ for (const sink of sinks) {
98
+ const directMatch = detectTaintedExpr(sink.value);
99
+ const envMatch = directMatch
100
+ ? null
101
+ : detectTaintedEnvExpr(sink.value, stepTaintedEnv);
102
+ const match = directMatch ?? envMatch?.expression;
103
+ if (!match) continue;
48
104
  findings.push({
49
105
  id,
50
106
  severity,
51
- line: step.line,
52
- fields: { type: id, sev: severity, job: job.name, expr: match },
53
- explain: 'move untrusted input through an env var and quote it in the script',
107
+ line: sink.line,
108
+ fields: {
109
+ type: id,
110
+ sev: severity,
111
+ job: job.name,
112
+ sink: sink.kind,
113
+ expr: match,
114
+ ...(envMatch ? { via_env: envMatch.key } : {}),
115
+ },
116
+ explain: sink.kind === 'github-script'
117
+ ? 'assign the expression to step-level `env`, then read it through `process.env`; do not interpolate `${{ }}` into the script body'
118
+ : 'assign the expression to step-level `env`, then read it with the shell\'s quoted variable syntax (for example, `"$VALUE"`), not `${{ env.VALUE }}`',
54
119
  });
55
120
  }
56
121
  }
@@ -1,51 +1,134 @@
1
1
  /**
2
- * Rule: secrets exposed via env at workflow/job level leak to every step,
3
- * including third-party actions. Prefer step-scoped `env`.
2
+ * Rule: secrets exposed via env at workflow/job level are available to every
3
+ * step in that scope, including third-party actions. Prefer step-scoped `env`
4
+ * unless job scope is required for condition evaluation.
4
5
  */
5
6
 
6
7
  export const id = 'secrets-in-env';
7
- export const severity = 'critical';
8
- export const description = 'Secret is exposed broadly via workflow- or job-level env.';
9
-
10
- const SECRET_EXPR = /\$\{\{\s*secrets\./i;
8
+ export const severity = 'medium';
9
+ export const description = 'Workflow- or job-level env exposes secrets to unrelated steps.';
11
10
 
12
11
  /**
13
12
  * @param {unknown} env
14
- * @returns {string[]} keys that reference secrets
13
+ * @returns {Array<{key: string, exposure: 'named-secret'|'all-secrets'}>}
15
14
  */
16
15
  function secretKeys(env) {
17
16
  if (!env || typeof env !== 'object') return [];
18
17
  const out = [];
19
18
  for (const [key, val] of Object.entries(env)) {
20
- if (typeof val === 'string' && SECRET_EXPR.test(val)) out.push(key);
19
+ if (typeof val !== 'string' || !containsSecretReference(val)) continue;
20
+ out.push({ key, exposure: secretExposure(val) });
21
21
  }
22
22
  return out;
23
23
  }
24
24
 
25
+ function secretExposure(value) {
26
+ if (/\btoJSON\s*\(\s*secrets\s*\)/i.test(value)) return 'all-secrets';
27
+ if (/\bsecrets\s*\.\s*\*/i.test(value)) return 'all-secrets';
28
+ for (const match of value.matchAll(/\bsecrets\s*\[([^\]]+)\]/gi)) {
29
+ if (!/^\s*(['"])[A-Za-z_][A-Za-z0-9_]*\1\s*$/.test(match[1])) {
30
+ return 'all-secrets';
31
+ }
32
+ }
33
+ if (/\bsecrets\s*(?:[,)]|\}\})/i.test(withoutQuotedStrings(value))) {
34
+ return 'all-secrets';
35
+ }
36
+ return 'named-secret';
37
+ }
38
+
39
+ function containsSecretReference(value) {
40
+ const expressions = value.match(/\$\{\{[\s\S]*?\}\}/g) ?? [];
41
+ return expressions.some(expression => (
42
+ /\bsecrets\s*(?:\.|\[|[,)]|\}\})/i.test(withoutQuotedStrings(expression))
43
+ ));
44
+ }
45
+
46
+ function withoutQuotedStrings(value) {
47
+ let output = '';
48
+ let quote = null;
49
+ let escaped = false;
50
+ for (const char of value) {
51
+ if (quote) {
52
+ if (escaped) {
53
+ escaped = false;
54
+ } else if (char === '\\') {
55
+ escaped = true;
56
+ } else if (char === quote) {
57
+ quote = null;
58
+ }
59
+ output += ' ';
60
+ } else if (char === '\'' || char === '"') {
61
+ quote = char;
62
+ output += ' ';
63
+ } else {
64
+ output += char;
65
+ }
66
+ }
67
+ return output;
68
+ }
69
+
25
70
  /**
26
71
  * @param {import('../lib/parser.js').WorkflowDoc} workflow
27
72
  */
28
73
  export function check(workflow) {
29
74
  const findings = [];
30
- for (const key of secretKeys(workflow.env)) {
75
+ for (const { key, exposure } of secretKeys(workflow.env)) {
76
+ const findingSeverity = 'high';
31
77
  findings.push({
32
78
  id,
33
- severity,
34
- line: 1,
35
- fields: { type: id, sev: severity, key, scope: 'workflow' },
36
- explain: `secret ${key} is in workflow-level env - every step (including 3rd-party) can read it`,
79
+ severity: findingSeverity,
80
+ line: workflow.envLine || 1,
81
+ fields: {
82
+ type: id,
83
+ sev: findingSeverity,
84
+ key,
85
+ scope: 'workflow',
86
+ exposure,
87
+ },
88
+ explain: exposure === 'all-secrets'
89
+ ? `replace ${key} with explicit named secret references, then scope each reference to only the consuming step or action input; never serialize or dynamically index the secrets context`
90
+ : `move ${key} to step-level env or a supported action input so only the step that consumes it receives the secret`,
37
91
  });
38
92
  }
39
93
  for (const job of workflow.jobs) {
40
- for (const key of secretKeys(job.env)) {
94
+ for (const { key, exposure } of secretKeys(job.env)) {
95
+ const findingSeverity = exposure === 'all-secrets' ? 'high' : severity;
41
96
  findings.push({
42
97
  id,
43
- severity,
44
- line: job.line,
45
- fields: { type: id, sev: severity, key, scope: 'job', job: job.name },
46
- explain: `secret ${key} is in job-level env for "${job.name}"`,
98
+ severity: findingSeverity,
99
+ line: job.envLine || job.line,
100
+ fields: {
101
+ type: id,
102
+ sev: findingSeverity,
103
+ key,
104
+ scope: 'job',
105
+ job: job.name,
106
+ exposure,
107
+ },
108
+ explain: exposure === 'all-secrets'
109
+ ? `replace ${key} with explicit named secret references, then scope each reference to only the consuming step or action input; never serialize or dynamically index the secrets context`
110
+ : `move ${key} to step-level env or an action input; if job scope is required for an if condition, keep every step in "${job.name}" trusted and minimize the secret's privileges`,
47
111
  });
48
112
  }
113
+ for (const step of job.steps) {
114
+ for (const { key, exposure } of secretKeys(step.env)) {
115
+ if (exposure !== 'all-secrets') continue;
116
+ findings.push({
117
+ id,
118
+ severity: 'high',
119
+ line: step.envLine || step.line,
120
+ fields: {
121
+ type: id,
122
+ sev: 'high',
123
+ key,
124
+ scope: 'step',
125
+ job: job.name,
126
+ exposure,
127
+ },
128
+ explain: `replace ${key} with explicit named secret references; dynamic indexing or toJSON(secrets) can expose every secret even when the env key is step-scoped`,
129
+ });
130
+ }
131
+ }
49
132
  }
50
133
  return findings;
51
134
  }
@@ -9,7 +9,7 @@ import { collectUses } from '../lib/parser.js';
9
9
 
10
10
  export const id = 'unpinned-action';
11
11
  export const severity = 'high';
12
- export const description = 'External action is not pinned to a commit SHA.';
12
+ export const description = 'External action or reusable workflow is not pinned to a full commit SHA.';
13
13
 
14
14
  const SHA_RE = /^[0-9a-f]{40}$/i;
15
15
 
@@ -26,13 +26,14 @@ export function check(workflow) {
26
26
  id,
27
27
  severity,
28
28
  line: ref.line,
29
+ start: ref.start,
29
30
  fields: {
30
31
  type: id,
31
32
  sev: severity,
32
33
  action: ref.raw,
33
34
  ref: ref.ref ?? '',
34
35
  },
35
- explain: `pin ${ref.owner}/${ref.repo} to a 40-char commit SHA - tags are mutable`,
36
+ explain: `replace the mutable ref in \`${ref.raw}\` with a reviewed full 40-character commit SHA, preserving any action or workflow subpath; retain the release tag as update metadata`,
36
37
  });
37
38
  }
38
39
  return findings;
@@ -0,0 +1,39 @@
1
+ import { collectImages } from '../lib/parser.js';
2
+
3
+ export const id = 'unpinned-container-image';
4
+ export const severity = 'high';
5
+ export const description = 'Job, service, or Docker action image is not pinned to a SHA-256 digest.';
6
+
7
+ const DIGEST_RE = /@sha256:[0-9a-f]{64}$/i;
8
+
9
+ /**
10
+ * @param {import('../lib/parser.js').WorkflowDoc} workflow
11
+ */
12
+ export function check(workflow) {
13
+ const findings = [];
14
+ for (const image of collectImages(workflow)) {
15
+ const localDockerAction = image.context === 'docker-action'
16
+ && !image.raw.startsWith('/')
17
+ && /(?:^|\/)Dockerfile$/.test(image.raw);
18
+ if (DIGEST_RE.test(image.raw) || localDockerAction) {
19
+ continue;
20
+ }
21
+ findings.push({
22
+ id,
23
+ severity,
24
+ line: image.line,
25
+ start: image.start,
26
+ fields: {
27
+ type: id,
28
+ sev: severity,
29
+ image: image.raw,
30
+ context: image.context,
31
+ job: image.jobName,
32
+ },
33
+ explain: image.raw.includes('${{')
34
+ ? 'make every possible expression or matrix value an immutable image reference ending in `@sha256:<64-hex-digest>`'
35
+ : 'resolve the intended image from its trusted registry, then replace the tag with the verified `@sha256:<64-hex-digest>` reference',
36
+ });
37
+ }
38
+ return findings;
39
+ }
@@ -0,0 +1,30 @@
1
+ import { collectUses } from '../lib/parser.js';
2
+
3
+ export const id = 'unpinned-docker-action';
4
+ export const severity = 'high';
5
+ export const description = 'Docker action image is not pinned to a SHA-256 digest.';
6
+
7
+ const DIGEST_RE = /@sha256:[0-9a-f]{64}$/i;
8
+
9
+ /**
10
+ * @param {import('../lib/parser.js').WorkflowDoc} workflow
11
+ */
12
+ export function check(workflow) {
13
+ const findings = [];
14
+ for (const { ref } of collectUses(workflow)) {
15
+ if (ref.kind !== 'docker' || DIGEST_RE.test(ref.raw)) continue;
16
+ findings.push({
17
+ id,
18
+ severity,
19
+ line: ref.line,
20
+ start: ref.start,
21
+ fields: {
22
+ type: id,
23
+ sev: severity,
24
+ image: ref.raw.slice('docker://'.length),
25
+ },
26
+ explain: 'resolve the intended image from its trusted registry, then replace the tag with the verified `@sha256:<64-hex-digest>` reference',
27
+ });
28
+ }
29
+ return findings;
30
+ }