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.
@@ -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
+ }