actions-warden 0.1.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/LICENSE +21 -0
- package/README.md +355 -0
- package/SECURITY.md +52 -0
- package/package.json +68 -0
- package/src/cli.js +155 -0
- package/src/commands/audit.js +161 -0
- package/src/commands/pin.js +199 -0
- package/src/commands/report.js +93 -0
- package/src/commands/upgrade.js +281 -0
- package/src/index.js +19 -0
- package/src/lib/cache.js +68 -0
- package/src/lib/formatter.js +136 -0
- package/src/lib/ignore.js +134 -0
- package/src/lib/parser.js +279 -0
- package/src/lib/paths.js +99 -0
- package/src/lib/redact.js +44 -0
- package/src/lib/resolver.js +224 -0
- package/src/lib/writer.js +56 -0
- package/src/rules/excessive-permissions.js +79 -0
- package/src/rules/index.js +18 -0
- package/src/rules/pull-request-target-checkout.js +46 -0
- package/src/rules/script-injection.js +59 -0
- package/src/rules/secrets-in-env.js +51 -0
- package/src/rules/unpinned-action.js +39 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: workflows and jobs should not grant `write-all` or default-broad
|
|
3
|
+
* permissions when scoped tokens would suffice.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const id = 'excessive-permissions';
|
|
7
|
+
export const severity = 'medium';
|
|
8
|
+
export const description = 'Workflow or job grants overly broad GITHUB_TOKEN permissions.';
|
|
9
|
+
|
|
10
|
+
const BROAD_VALUES = new Set(['write-all', 'write', 'all']);
|
|
11
|
+
const WRITE_SCOPES = new Set([
|
|
12
|
+
'contents',
|
|
13
|
+
'actions',
|
|
14
|
+
'packages',
|
|
15
|
+
'deployments',
|
|
16
|
+
'id-token',
|
|
17
|
+
'issues',
|
|
18
|
+
'pull-requests',
|
|
19
|
+
'security-events',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {unknown} permissions
|
|
24
|
+
* @returns {string|null} - returns offending scope label, or null
|
|
25
|
+
*/
|
|
26
|
+
function inspect(permissions) {
|
|
27
|
+
if (permissions === undefined || permissions === null) {
|
|
28
|
+
return 'unset-default'; // GitHub default is permissive when not declared
|
|
29
|
+
}
|
|
30
|
+
if (typeof permissions === 'string') {
|
|
31
|
+
return BROAD_VALUES.has(permissions) ? permissions : null;
|
|
32
|
+
}
|
|
33
|
+
if (typeof permissions === 'object') {
|
|
34
|
+
for (const [scope, val] of Object.entries(permissions)) {
|
|
35
|
+
if (val === 'write' && WRITE_SCOPES.has(scope)) {
|
|
36
|
+
return `${scope}=write`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {import('../lib/parser.js').WorkflowDoc} workflow
|
|
45
|
+
*/
|
|
46
|
+
export function check(workflow) {
|
|
47
|
+
const findings = [];
|
|
48
|
+
const topScope = inspect(workflow.permissions);
|
|
49
|
+
if (topScope === 'unset-default') {
|
|
50
|
+
findings.push({
|
|
51
|
+
id,
|
|
52
|
+
severity: 'low',
|
|
53
|
+
line: 1,
|
|
54
|
+
fields: { type: id, sev: 'low', scope: 'workflow-default' },
|
|
55
|
+
explain: 'declare `permissions:` at workflow root with least-privilege scopes',
|
|
56
|
+
});
|
|
57
|
+
} else if (topScope) {
|
|
58
|
+
findings.push({
|
|
59
|
+
id,
|
|
60
|
+
severity,
|
|
61
|
+
line: 1,
|
|
62
|
+
fields: { type: id, sev: severity, scope: topScope, target: 'workflow' },
|
|
63
|
+
explain: `workflow grants ${topScope} - narrow to specific scopes`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const job of workflow.jobs) {
|
|
67
|
+
const jobScope = inspect(job.permissions);
|
|
68
|
+
if (jobScope && jobScope !== 'unset-default') {
|
|
69
|
+
findings.push({
|
|
70
|
+
id,
|
|
71
|
+
severity,
|
|
72
|
+
line: job.line,
|
|
73
|
+
fields: { type: id, sev: severity, scope: jobScope, job: job.name },
|
|
74
|
+
explain: `job "${job.name}" grants ${jobScope}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule registry.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as unpinned from './unpinned-action.js';
|
|
6
|
+
import * as perms from './excessive-permissions.js';
|
|
7
|
+
import * as secrets from './secrets-in-env.js';
|
|
8
|
+
import * as injection from './script-injection.js';
|
|
9
|
+
import * as prTarget from './pull-request-target-checkout.js';
|
|
10
|
+
|
|
11
|
+
export const RULES = [unpinned, perms, secrets, injection, prTarget];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @returns {Array<{id: string, severity: string, description: string}>}
|
|
15
|
+
*/
|
|
16
|
+
export function listRules() {
|
|
17
|
+
return RULES.map(r => ({ id: r.id, severity: r.severity, description: r.description }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: workflows triggered by `pull_request_target` that check out the PR
|
|
3
|
+
* head ref expose secrets to attacker-supplied code (pwn-request pattern).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const id = 'pull-request-target-checkout';
|
|
7
|
+
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;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function checksOutHead(step) {
|
|
19
|
+
if (!step.uses) return false;
|
|
20
|
+
if (step.uses.owner !== 'actions' || step.uses.repo !== 'checkout') return false;
|
|
21
|
+
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);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('../lib/parser.js').WorkflowDoc} workflow
|
|
28
|
+
*/
|
|
29
|
+
export function check(workflow) {
|
|
30
|
+
if (!hasPullRequestTarget(workflow.on)) return [];
|
|
31
|
+
const findings = [];
|
|
32
|
+
for (const job of workflow.jobs) {
|
|
33
|
+
for (const step of job.steps) {
|
|
34
|
+
if (checksOutHead(step)) {
|
|
35
|
+
findings.push({
|
|
36
|
+
id,
|
|
37
|
+
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',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: attacker-controlled fields like `github.event.issue.title` interpolated
|
|
3
|
+
* directly into `run:` scripts allow arbitrary command execution.
|
|
4
|
+
*
|
|
5
|
+
* Safe pattern: pass through env vars and quote, e.g.
|
|
6
|
+
* env: { TITLE: ${{ github.event.issue.title }} }
|
|
7
|
+
* run: echo "$TITLE"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const id = 'script-injection';
|
|
11
|
+
export const severity = 'critical';
|
|
12
|
+
export const description = 'Untrusted GitHub context interpolated into shell script.';
|
|
13
|
+
|
|
14
|
+
const TAINTED_PATTERNS = [
|
|
15
|
+
/github\.event\.issue\.(title|body)/i,
|
|
16
|
+
/github\.event\.pull_request\.(title|body|head\.ref|head\.label)/i,
|
|
17
|
+
/github\.event\.comment\.body/i,
|
|
18
|
+
/github\.event\.review\.body/i,
|
|
19
|
+
/github\.event\.discussion\.(title|body)/i,
|
|
20
|
+
/github\.event\.commits\.[\d*]+\.message/i,
|
|
21
|
+
/github\.event\.workflow_run\.head_branch/i,
|
|
22
|
+
/github\.head_ref/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} run
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
function detectTaintedExpr(run) {
|
|
30
|
+
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];
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {import('../lib/parser.js').WorkflowDoc} workflow
|
|
40
|
+
*/
|
|
41
|
+
export function check(workflow) {
|
|
42
|
+
const findings = [];
|
|
43
|
+
for (const job of workflow.jobs) {
|
|
44
|
+
for (const step of job.steps) {
|
|
45
|
+
if (!step.run) continue;
|
|
46
|
+
const match = detectTaintedExpr(step.run);
|
|
47
|
+
if (match) {
|
|
48
|
+
findings.push({
|
|
49
|
+
id,
|
|
50
|
+
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',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return findings;
|
|
59
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: secrets exposed via env at workflow/job level leak to every step,
|
|
3
|
+
* including third-party actions. Prefer step-scoped `env`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
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;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {unknown} env
|
|
14
|
+
* @returns {string[]} keys that reference secrets
|
|
15
|
+
*/
|
|
16
|
+
function secretKeys(env) {
|
|
17
|
+
if (!env || typeof env !== 'object') return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const [key, val] of Object.entries(env)) {
|
|
20
|
+
if (typeof val === 'string' && SECRET_EXPR.test(val)) out.push(key);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {import('../lib/parser.js').WorkflowDoc} workflow
|
|
27
|
+
*/
|
|
28
|
+
export function check(workflow) {
|
|
29
|
+
const findings = [];
|
|
30
|
+
for (const key of secretKeys(workflow.env)) {
|
|
31
|
+
findings.push({
|
|
32
|
+
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`,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
for (const job of workflow.jobs) {
|
|
40
|
+
for (const key of secretKeys(job.env)) {
|
|
41
|
+
findings.push({
|
|
42
|
+
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}"`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return findings;
|
|
51
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule: external action references must be pinned to a 40-character commit SHA.
|
|
3
|
+
*
|
|
4
|
+
* Tags like `v4` or `main` can be rewritten by repo owners (or attackers with
|
|
5
|
+
* write access), making them an unstable supply-chain link.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { collectUses } from '../lib/parser.js';
|
|
9
|
+
|
|
10
|
+
export const id = 'unpinned-action';
|
|
11
|
+
export const severity = 'high';
|
|
12
|
+
export const description = 'External action is not pinned to a commit SHA.';
|
|
13
|
+
|
|
14
|
+
const SHA_RE = /^[0-9a-f]{40}$/i;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {import('../lib/parser.js').WorkflowDoc} workflow
|
|
18
|
+
* @returns {Array<{id: string, severity: string, line: number, fields: object, explain: string}>}
|
|
19
|
+
*/
|
|
20
|
+
export function check(workflow) {
|
|
21
|
+
const findings = [];
|
|
22
|
+
for (const { ref } of collectUses(workflow)) {
|
|
23
|
+
if (ref.kind !== 'external' && ref.kind !== 'reusable-workflow') continue;
|
|
24
|
+
if (ref.ref && SHA_RE.test(ref.ref)) continue;
|
|
25
|
+
findings.push({
|
|
26
|
+
id,
|
|
27
|
+
severity,
|
|
28
|
+
line: ref.line,
|
|
29
|
+
fields: {
|
|
30
|
+
type: id,
|
|
31
|
+
sev: severity,
|
|
32
|
+
action: ref.raw,
|
|
33
|
+
ref: ref.ref ?? '',
|
|
34
|
+
},
|
|
35
|
+
explain: `pin ${ref.owner}/${ref.repo} to a 40-char commit SHA - tags are mutable`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
}
|