Skip to content

Commit 330a45d

Browse files
author
nate
committed
Harden Jenkins Actions policy guard
1 parent ff8b890 commit 330a45d

8 files changed

Lines changed: 334 additions & 33 deletions

File tree

‎.github/CI_POLICY.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ or `workflow_call` (reusable by a manually dispatched workflow) as events.
88
No `push`, `pull_request`, `pull_request_target`, `schedule`, `issues`,
99
`workflow_run`, or other automatic Actions events are allowed.
1010

11-
* Jenkins' `Jenkinsfile` runs `scripts/ci/check-github-actions-policy.py`.
11+
* Jenkins installs the pinned CI Python tools, then runs
12+
`scripts/ci/check-github-actions-policy.py` and its focused self-tests.
13+
The guard parses YAML and rejects duplicate top-level `on` or trigger keys,
14+
anchors, aliases, merge keys, explicit document markers/directives and tags. It
15+
accepts block or flow mappings but permits only `workflow_dispatch` and
16+
`workflow_call` under the workflow's top-level `on` key.
1217
* The GitHub release/promotion workflows are retained as **manual-only**
1318
fallback/publishing operations. They can consume Actions minutes when started.
1419
* CodeQL, GitHub Pages, smoke/soak and repro workflows remain manual-only;

‎Jenkinsfile‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,19 @@ arena hash_table dyn_array str_intern log str_util index_policy workspace platfo
1717
}
1818
1919
stages {
20+
stage('Install Python CI tools') {
21+
steps {
22+
sh 'CBM_CI_VENV="$WORKSPACE/.ci-venv" scripts/ci/install-python-tools.sh'
23+
}
24+
}
2025
stage('Lint') {
2126
steps {
2227
sh '''
2328
set -eu
2429
rm -f .jenkins-installed-cli
25-
python3 scripts/ci/check-github-actions-policy.py
30+
"$WORKSPACE/.ci-venv/bin/python" scripts/ci/check-github-actions-policy.py
31+
"$WORKSPACE/.ci-venv/bin/python" scripts/ci/test_github_actions_policy.py
32+
python3 scripts/ci/test_sync_ancestry.py
2633
python3 tests/test_jenkins_daemon_recovery.py
2734
scripts/lint.sh --ci CLANG_FORMAT=clang-format-20
2835
'''
@@ -43,11 +50,6 @@ arena hash_table dyn_array str_intern log str_util index_policy workspace platfo
4350
'''
4451
}
4552
}
46-
stage('Install Python CI tools') {
47-
steps {
48-
sh 'CBM_CI_VENV="$WORKSPACE/.ci-venv" scripts/ci/install-python-tools.sh'
49-
}
50-
}
5153
stage('License gate') {
5254
steps {
5355
withCredentials([usernamePassword(credentialsId: 'github-https-token',

‎docs/upstream-sync/ci-policy.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# CI policy and ancestry guard receipt
2+
3+
## Workflow policy
4+
5+
`scripts/ci/check-github-actions-policy.py` parses every top-level
6+
`.github/workflows/*.yml` and `*.yaml` file with pinned PyYAML 6.0.3. PyYAML is
7+
installed from `requirements-ci.txt` before Jenkins lint runs.
8+
9+
Supported workflow syntax is YAML block and flow mappings. The document must
10+
contain exactly one implicit YAML document whose root is a mapping and whose
11+
top-level `on` value is a nonempty mapping. Quoted keys and event names are
12+
handled as YAML strings; ordinary and alternative indentation and flow event
13+
mappings receive the same checks. Only these event keys pass:
14+
15+
* `workflow_dispatch`
16+
* `workflow_call`
17+
18+
Aliases, anchors, merge keys, duplicate top-level `on` keys, duplicate event
19+
keys within `on`, explicit document starts/ends, YAML directives, explicit
20+
tags, invalid YAML, non-mapping `on` values, and unsupported trigger names
21+
fail closed.
22+
Scalar `on` values such as `on: push` and block scalars fail because `on` must
23+
be a mapping. Duplicate keys outside the trigger declaration are outside this
24+
guard's scope.
25+
26+
Run locally:
27+
28+
```sh
29+
scripts/ci/install-python-tools.sh
30+
.ci-venv/bin/python scripts/ci/check-github-actions-policy.py
31+
.ci-venv/bin/python scripts/ci/test_github_actions_policy.py
32+
python3 scripts/ci/test_sync_ancestry.py
33+
```
34+
35+
The policy self-test exercises real repository workflows and malicious and
36+
positive fixtures for indentation, quoted keys, flow syntax, aliases/anchors,
37+
merge keys, duplicate trigger keys, scalar forms, directives, explicit
38+
documents, and tags.
39+
40+
## Upstream ancestry helper
41+
42+
`scripts/ci/check-sync-ancestry.py --checkpoint SHA --target SHA --merge SHA`
43+
requires full commit IDs, resolves each exact object, proves checkpoint is an
44+
ancestor of the pinned upstream target, and proves target is in a non-first
45+
parent's history for the integration merge. Git replacement refs are disabled
46+
for these graph checks, so a synthetic replacement cannot manufacture the
47+
ancestry claim. The helper is local/manual and does not run hosted Actions.
48+
Jenkins runs its self-test, which constructs true and replacement-ref-faked
49+
histories.
50+
51+
## Validation record
52+
53+
Passed locally on 2026-11-01:
54+
55+
```text
56+
python3 scripts/ci/check-github-actions-policy.py
57+
GitHub Actions policy: manual/reusable only; Jenkins owns automatic CI
58+
python3 scripts/ci/test_github_actions_policy.py
59+
Ran 4 tests; OK (real workflows plus positive/negative fixtures)
60+
python3 scripts/ci/test_sync_ancestry.py
61+
Ran 3 tests; OK (true ancestry, replacement-ref spoof, invalid inputs)
62+
git diff --check
63+
passed
64+
```
65+
66+
The exact-worktree Codebase Memory CLI discovery attempt (`cbm projects
67+
--json`; `cbm status --json`) failed with `codebase-memory-cli: secure CLI
68+
coordination could not be created (cache-resolve)`. Git porcelain was empty
69+
before and after this probe; source discovery continued with `rg` and direct
70+
reads. Graph evidence is unavailable for this run. Parent should record the CLI
71+
failure in its canonical backlog.
72+
73+
Jenkins remains the full integration gate and was not run locally. No hosted
74+
Actions workflow was run.

‎requirements-ci.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Python tooling required by repository CI gates.
22
# Keep this separate from pkg/pypi/requirements-publish.txt: these packages are
33
# CI analyzers, not runtime or registry-publishing dependencies.
4+
PyYAML==6.0.3
45
scancode-toolkit==32.5.0
56
# pluggy 1.7.0 rejects plugincode 32.0.0's pre_scan hookspec module ("did not
67
# find any 'pre_scan' hooks"), which breaks `scancode` at import. Keep the
Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,68 @@
11
#!/usr/bin/env python3
2-
"""Jenkins guard: forbid billable, automatically triggered GitHub Actions.
3-
4-
Only manually dispatched or reusable workflows are permitted in this fork.
5-
When importing upstream changes, keep this policy even if upstream adds CI.
6-
"""
2+
"""Jenkins guard: allow only manual or reusable GitHub Actions workflows."""
73
from pathlib import Path
8-
import re
94
import sys
105

6+
import yaml
7+
from yaml.events import AliasEvent, DocumentEndEvent, DocumentStartEvent, NodeEvent
8+
from yaml.nodes import MappingNode, ScalarNode
9+
1110
ROOT = Path(__file__).resolve().parents[2]
1211
WORKFLOWS = ROOT / ".github" / "workflows"
1312
ALLOWED = {"workflow_dispatch", "workflow_call"}
14-
ON = re.compile(r"""^(?:on|"on"|'on'):\s*(?:#.*)?$""")
15-
EVENT = re.compile(r"^ ([A-Za-z_][A-Za-z0-9_-]*):")
13+
14+
15+
class PolicyLoader(yaml.SafeLoader):
16+
# GitHub Actions uses YAML 1.2, where the unquoted key `on` is a string.
17+
yaml_implicit_resolvers = {
18+
key: [(tag, regexp) for tag, regexp in resolvers
19+
if tag != "tag:yaml.org,2002:bool"]
20+
for key, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
21+
}
1622

1723
def inspect(path: Path) -> list[str]:
18-
lines = path.read_text(encoding="utf-8").splitlines()
19-
triggers = []
20-
found = False
21-
for idx, line in enumerate(lines):
22-
if not ON.fullmatch(line):
23-
continue
24-
if found:
25-
return [f"{path.name}: multiple on sections"]
26-
found = True
27-
for sub in lines[idx + 1:]:
28-
if sub and not sub.startswith((" ", "#")):
29-
break
30-
match = EVENT.match(sub)
31-
if match:
32-
triggers.append(match.group(1))
33-
if not found or not triggers:
34-
return [f"{path.name}: missing or non-block YAML on declaration"]
24+
text = path.read_text(encoding="utf-8")
25+
try:
26+
for event in yaml.parse(text, Loader=PolicyLoader):
27+
if isinstance(event, AliasEvent) or (isinstance(event, NodeEvent) and event.anchor):
28+
raise ValueError("anchors and aliases are unsupported")
29+
if isinstance(event, DocumentStartEvent) and (event.explicit or event.version or event.tags):
30+
raise ValueError("explicit YAML documents and directives are unsupported")
31+
if isinstance(event, DocumentEndEvent) and event.explicit:
32+
raise ValueError("explicit YAML document endings are unsupported")
33+
if isinstance(event, NodeEvent) and event.tag:
34+
raise ValueError("explicit YAML tags are unsupported")
35+
root = yaml.compose(text, Loader=PolicyLoader)
36+
if not isinstance(root, MappingNode):
37+
raise ValueError("workflow document root must be a mapping")
38+
on_nodes = [value for key, value in root.value
39+
if isinstance(key, ScalarNode) and key.value == "on"]
40+
if len(on_nodes) != 1:
41+
return [f"{path.name}: expected exactly one top-level 'on' key"]
42+
if not isinstance(on_nodes[0], MappingNode):
43+
return [f"{path.name}: top-level 'on' value must be a mapping"]
44+
trigger_keys = []
45+
for key, _ in on_nodes[0].value:
46+
if not isinstance(key, ScalarNode):
47+
raise ValueError("trigger event names must be scalar strings")
48+
if key.value in trigger_keys:
49+
return [f"{path.name}: duplicate trigger event {key.value!r}"]
50+
trigger_keys.append(key.value)
51+
docs = list(yaml.load_all(text, Loader=PolicyLoader))
52+
except (yaml.YAMLError, ValueError) as exc:
53+
return [f"{path.name}: unsupported or invalid YAML: {exc}"]
54+
55+
if len(docs) != 1 or not isinstance(docs[0], dict):
56+
return [f"{path.name}: expected one YAML document with a workflow mapping"]
57+
workflow = docs[0]
58+
if "on" not in workflow or not isinstance(workflow["on"], dict) or not workflow["on"]:
59+
return [f"{path.name}: missing or non-mapping 'on' trigger declaration"]
60+
events = workflow["on"]
61+
if any(not isinstance(event, str) for event in events):
62+
return [f"{path.name}: trigger event names must be strings"]
3563
return [f"{path.name}: forbidden GitHub Actions trigger {event!r}"
36-
for event in triggers if event not in ALLOWED]
64+
for event in events if event not in ALLOWED]
65+
3766

3867
def main() -> int:
3968
errors = []
@@ -49,5 +78,6 @@ def main() -> int:
4978
print("GitHub Actions policy: manual/reusable only; Jenkins owns automatic CI")
5079
return 0
5180

81+
5282
if __name__ == "__main__":
5383
sys.exit(main())

‎scripts/ci/check-sync-ancestry.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env python3
2+
"""Verify a real upstream checkpoint and merge-parent ancestry using Git."""
3+
import argparse
4+
import os
5+
import re
6+
import subprocess
7+
import sys
8+
9+
10+
def git(*args: str) -> str:
11+
env = os.environ | {"GIT_NO_REPLACE_OBJECTS": "1"}
12+
return subprocess.check_output(["git", *args], text=True, stderr=subprocess.PIPE, env=env).strip()
13+
14+
15+
def exact_commit(value: str) -> str:
16+
if not re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", value):
17+
raise ValueError(f"expected a full commit SHA, got {value!r}")
18+
resolved = git("rev-parse", "--verify", f"{value}^{{commit}}")
19+
if resolved != value:
20+
raise ValueError(f"commit did not resolve to the exact supplied SHA: {value}")
21+
return resolved
22+
23+
24+
def verify(checkpoint: str, target: str, merge: str) -> None:
25+
checkpoint = exact_commit(checkpoint)
26+
target = exact_commit(target)
27+
merge = exact_commit(merge)
28+
if subprocess.run(["git", "merge-base", "--is-ancestor", checkpoint, target],
29+
env=os.environ | {"GIT_NO_REPLACE_OBJECTS": "1"}, check=False).returncode:
30+
raise ValueError("checkpoint is not an ancestor of pinned target")
31+
parents = git("rev-list", "--parents", "-n", "1", merge).split()[1:]
32+
if len(parents) < 2:
33+
raise ValueError("result is not a merge commit")
34+
if not any(subprocess.run(["git", "merge-base", "--is-ancestor", target, parent],
35+
env=os.environ | {"GIT_NO_REPLACE_OBJECTS": "1"},
36+
check=False).returncode == 0 for parent in parents[1:]):
37+
raise ValueError("pinned target is not in a non-first merge-parent history")
38+
39+
40+
def main() -> int:
41+
parser = argparse.ArgumentParser(description=__doc__)
42+
parser.add_argument("--checkpoint", required=True, help="full upstream checkpoint commit SHA")
43+
parser.add_argument("--target", required=True, help="full pinned upstream target commit SHA")
44+
parser.add_argument("--merge", required=True, help="full integration merge commit SHA")
45+
args = parser.parse_args()
46+
try:
47+
verify(args.checkpoint, args.target, args.merge)
48+
except (ValueError, subprocess.CalledProcessError) as exc:
49+
print(f"sync ancestry check failed: {exc}", file=sys.stderr)
50+
return 1
51+
print("sync ancestry: exact checkpoint, pinned target, and merge parent verified")
52+
return 0
53+
54+
55+
if __name__ == "__main__":
56+
sys.exit(main())
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env python3
2+
"""Focused policy-guard tests; also validates every workflow in this checkout."""
3+
import importlib.util
4+
from pathlib import Path
5+
import tempfile
6+
import unittest
7+
8+
SCRIPT = Path(__file__).with_name("check-github-actions-policy.py")
9+
SPEC = importlib.util.spec_from_file_location("github_actions_policy", SCRIPT)
10+
policy = importlib.util.module_from_spec(SPEC)
11+
SPEC.loader.exec_module(policy)
12+
13+
14+
class PolicyTests(unittest.TestCase):
15+
def rejects(self, text):
16+
with tempfile.TemporaryDirectory() as directory:
17+
path = Path(directory) / "fixture.yml"
18+
path.write_text(text, encoding="utf-8")
19+
self.assertTrue(policy.inspect(path), text)
20+
21+
def accepts(self, text):
22+
with tempfile.TemporaryDirectory() as directory:
23+
path = Path(directory) / "fixture.yml"
24+
path.write_text(text, encoding="utf-8")
25+
self.assertEqual([], policy.inspect(path), text)
26+
27+
def test_manual_and_reusable_block_and_flow_forms(self):
28+
self.accepts("name: valid\non:\n workflow_dispatch:\n inputs: {}\n")
29+
self.accepts("'on': {workflow_call: {}}\n")
30+
self.accepts('"on": {"workflow_dispatch": null}\n')
31+
32+
def test_rejects_automatic_events_across_yaml_forms(self):
33+
for document in (
34+
"on:\n push:\n",
35+
"'on':\n 'pull_request': {}\n",
36+
'on: {"schedule": [{cron: "* * * * *"}]}\n',
37+
"on: push\n",
38+
"on: |\n workflow_dispatch\n",
39+
):
40+
with self.subTest(document=document):
41+
self.rejects(document)
42+
43+
def test_rejects_duplicate_alias_merge_document_directive_and_tag(self):
44+
for document in (
45+
"on: {workflow_dispatch: null}\non: {push: null}\n",
46+
"on: {workflow_dispatch: null, workflow_dispatch: null}\n",
47+
"on: &events {workflow_dispatch: null}\n",
48+
"on: *events\n",
49+
"on: {<<: {workflow_dispatch: null}, push: null}\n",
50+
"---\non: {workflow_dispatch: null}\n",
51+
"on: {workflow_dispatch: null}\n...\n",
52+
"%YAML 1.2\non: {workflow_dispatch: null}\n",
53+
"on: !!map {workflow_dispatch: null}\n",
54+
):
55+
with self.subTest(document=document):
56+
self.rejects(document)
57+
58+
def test_real_workflows(self):
59+
paths = sorted((*policy.WORKFLOWS.glob("*.yml"), *policy.WORKFLOWS.glob("*.yaml")))
60+
self.assertTrue(paths)
61+
errors = [error for path in paths for error in policy.inspect(path)]
62+
self.assertEqual([], errors)
63+
64+
65+
if __name__ == "__main__":
66+
unittest.main()

0 commit comments

Comments
 (0)