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
|
@@ -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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
if (!step.uses) return
|
|
20
|
-
if (
|
|
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
|
-
|
|
23
|
-
|
|
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 (!
|
|
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
|
-
|
|
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: {
|
|
40
|
-
|
|
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
|
|
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
|
|
21
|
-
/github\.event\.
|
|
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
|
|
32
|
-
const
|
|
33
|
-
|
|
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
|
-
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
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:
|
|
52
|
-
fields: {
|
|
53
|
-
|
|
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
|
|
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 = '
|
|
8
|
-
export const description = '
|
|
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
|
|
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
|
|
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: {
|
|
36
|
-
|
|
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: {
|
|
46
|
-
|
|
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: `
|
|
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
|
+
}
|