Skip to content

Commit b66e59e

Browse files
Provision keyless signed-rule publication with pinned publisher identity (#24)
* Provision keyless authenticated rule publication and durable verification * Reject stale publisher runs and fix portable failure injection * Disable unnecessary artifact metadata storage records
1 parent 1dd1d3d commit b66e59e

6 files changed

Lines changed: 546 additions & 6 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Publish authenticated rule snapshot
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: read
8+
9+
concurrency:
10+
group: publish-rules
11+
cancel-in-progress: false
12+
13+
jobs:
14+
publish:
15+
if: github.repository == 'CompleTetech-Security/node2shell-check' && github.ref == 'refs/heads/main'
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 15
18+
permissions:
19+
contents: write
20+
id-token: write
21+
attestations: write
22+
steps:
23+
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
24+
with:
25+
persist-credentials: false
26+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
27+
with:
28+
python-version: '3.14'
29+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
30+
with:
31+
node-version: '24'
32+
- name: Validate the trusted checkout
33+
run: python -m unittest -v
34+
- name: Build the existing embedded snapshot
35+
run: |
36+
test "$(git ls-remote origin refs/heads/main | cut -f1)" = "$GITHUB_SHA"
37+
mkdir dist
38+
test "$GITHUB_RUN_ATTEMPT" -lt 1000
39+
python tools/signed_rules/keyless_rules.py build --sequence "$((GITHUB_RUN_NUMBER * 1000 + GITHUB_RUN_ATTEMPT))" --commit "$GITHUB_SHA" --output dist/node2shell-rules.json
40+
- name: Attest the exact publication bytes
41+
id: attest
42+
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
43+
with:
44+
subject-path: dist/node2shell-rules.json
45+
create-storage-record: false
46+
- name: Publish immutable-named release assets
47+
env:
48+
GH_TOKEN: ${{ github.token }}
49+
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
50+
run: |
51+
test "$(git ls-remote origin refs/heads/main | cut -f1)" = "$GITHUB_SHA"
52+
cp "$BUNDLE_PATH" dist/node2shell-rules.sigstore.json
53+
gh release create "rules-v1-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}" --target "$GITHUB_SHA" --title "Authenticated Node2Shell rules ${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" --notes "Existing embedded snapshot, keylessly signed by the pinned main-branch publishing workflow. Verify publisher identity, expiry and durable sequence state before use. No automatic checker update or safety attestation." --latest=false dist/node2shell-rules.json dist/node2shell-rules.sigstore.json

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ Optional release tooling: [offline TUF bundle verification](tools/signed_rules/R
105105
uses separate optional dependencies and an explicitly provisioned public trust root.
106106
It never changes the standalone checker or automatically applies a rule update.
107107

108+
The optional [authenticated rule-publication channel](docs/KEYLESS_RULES.md)
109+
uses pinned GitHub/Sigstore publisher identity, expiring artifacts and durable
110+
rollback checks. Updates produce a separate review artifact; the checker keeps
111+
its embedded offline snapshot.
112+
108113
Optional family metadata tools: [Node capability and build-context collection](docs/NODE_METADATA.md).
109114
These separate formats do not change the Node2Shell inventory schema or infer
110115
application exposure from available APIs, lockfile declarations or image tags.

‎docs/KEYLESS_RULES.md‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Authenticated rule publication
2+
3+
The project uses GitHub Actions OIDC and Sigstore artifact attestations for its
4+
optional production rule-distribution channel. This adopts existing company
5+
repository control rather than requiring three new offline key custodians.
6+
The optional operator-managed TUF verifier remains available separately.
7+
8+
## Trust policy
9+
10+
Install the checker/tool and GitHub CLI through independently trusted channels.
11+
The verifier pins `CompleTetech-Security/node2shell-check`, repository ID
12+
`1431446570`, owner ID `347271566`, GitHub's OIDC issuer, and the exact
13+
`.github/workflows/publish-rules.yml@refs/heads/main` certificate identity.
14+
Source and signer commit digests must equal the authenticated envelope's commit;
15+
self-hosted runners are rejected. Repository rename/recreation is not silently
16+
accepted. Never obtain a replacement verifier or trust policy from the update
17+
being verified. These checks use maintained `gh attestation verify`, not custom
18+
cryptography. GitHub CLI with all documented attestation policy flags is required
19+
(validated locally with 2.92.0).
20+
21+
Trust the Sigstore CA/transparency roots obtained independently through
22+
`gh attestation trusted-root`, not roots supplied beside the artifact. Refresh
23+
those roots through that same trusted channel when importing newly signed
24+
material. Sigstore rotates its signing infrastructure; old offline roots cannot
25+
inform you of later revocations. See GitHub's
26+
[offline verification guidance](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/verify-attestations-offline)
27+
and [CLI policy reference](https://cli.github.com/manual/gh_attestation_verify).
28+
29+
## Publisher and recovery ownership
30+
31+
The company's repository administrators maintain publishing access, review the
32+
workflow and source changes, and own compromise recovery. This adopts the
33+
existing repository-admin role; no named human custodian is invented. GitHub
34+
Actions obtains a short-lived signing identity per run. No persistent project
35+
signing private key is generated, installed on this workstation or stored in CI.
36+
The trust boundary includes GitHub, Sigstore, repository administrators, the
37+
reviewed workflow and the trusted local GitHub CLI.
38+
39+
An authorized maintainer explicitly dispatches **Publish authenticated rule
40+
snapshot** on `main`. The job runs local-only tests, packages the existing
41+
embedded snapshot, attests those exact bytes and creates a new release with
42+
`node2shell-rules.json` and `node2shell-rules.sigstore.json`. Tags have the form
43+
`rules-v1-RUN_NUMBER-ATTEMPT`; no existing release/asset is overwritten and no
44+
unrelated software release is marked latest. There is no recurring schedule.
45+
Each dispatch is a release decision. Review advisory provenance and methodology
46+
before changing thresholds; republishing does not change the embedded rule date.
47+
The job checks the live `main` head before building and again before publication;
48+
stale reruns fail. Repository write permission authorizes dispatch; no additional
49+
human approval or protected environment is asserted by this policy.
50+
51+
Envelopes expire after 30 days. Sequence is `RUN_NUMBER * 1000 + ATTEMPT`, with
52+
attempts below 1,000. Rerunning an old run cannot outrank a newer run. Preserve
53+
workflow history/counters; deleting/recreating the workflow is not a recovery
54+
mechanism. A rollback of rule content must be a new reviewed publication with
55+
an increasing sequence and a changelog explaining the older content.
56+
57+
For compromise, disable publishing and revoke unauthorized repository access.
58+
Investigate the source/workflow and publish a reviewed recovery tool/policy
59+
through an independent trusted channel. Existing offline envelopes may remain
60+
valid until expiry; disabling a workflow does not remotely revoke cached files.
61+
Do not reset client state to accept an older sequence. A repository transfer,
62+
new numeric identity or a counter reset requires an explicit reviewed policy
63+
migration and independently distributed recovery instructions. Operator-managed
64+
offline TUF threshold roots remain an alternative for organizations requiring
65+
independent offline publisher custody; they are not claimed provisioned here.
66+
67+
## Explicit client operation
68+
69+
Select an exact release tag from the repository's authenticated release history.
70+
The following provisioning/download commands use the network intentionally;
71+
replace `rules-v1-N-A` with that exact tag and use new output directories:
72+
73+
```sh
74+
mkdir downloaded-rules
75+
gh release download rules-v1-N-A --repo CompleTetech-Security/node2shell-check --dir downloaded-rules
76+
python -c "import pathlib,subprocess; pathlib.Path('sigstore-trusted-root.jsonl').write_bytes(subprocess.check_output(['gh','attestation','trusted-root']))"
77+
python tools/signed_rules/keyless_rules.py verify --artifact downloaded-rules/node2shell-rules.json --bundle downloaded-rules/node2shell-rules.sigstore.json --trusted-root sigstore-trusted-root.jsonl --state client-rule-state.json --output verified-rule-review.json
78+
```
79+
80+
The verification command consumes local files only; GitHub CLI receives both
81+
`--bundle` and `--custom-trusted-root`. It checks the signature, artifact digest,
82+
certificate identity and source commit before the tool publishes output. Inputs
83+
and verifier output are bounded to four MiB; the child verifier has a timeout.
84+
Keep these files/directories controlled by the invoking user: this is not a
85+
hostile concurrent-filesystem sandbox. Do not share the private client state.
86+
87+
Duplicate/nonfinite/invalid JSON, wrong publisher/source, expired/future releases,
88+
malformed snapshots, older sequences and sequence reuse with different bytes
89+
fail closed. An identical verified artifact can be retried. State advances before
90+
output publication so a failed output cannot enable a replay; the old output
91+
survives. Keep durable state and backups. A detected backward clock fails closed.
92+
Deleting or restoring an old state manually defeats rollback protection and is
93+
not automatic recovery. Remove an abandoned `.lock` only after proving its
94+
process is no longer running. New output names are required unless `--force`
95+
is supplied; hard-link support is required for no-clobber publication. Errors
96+
exit 2, interruption exits 130 and is never retried automatically.
97+
98+
The output is a rule snapshot for source review and a later normal release
99+
build. It does not install or execute rules, update a running checker, prove
100+
advisory accuracy, attest a runtime, or turn metadata into a safety verdict.
101+
All local exploitability remains `not_tested`. Signature success and expiry do
102+
not prove you selected the newest release on first use; select the release
103+
through the trusted repository channel and retain state for later updates.

‎test_keyless_rules.py‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Synthetic release envelopes and mocked gh; no production requests or signing."""
2+
from datetime import datetime, timedelta, timezone
3+
import copy
4+
import hashlib
5+
import importlib.util
6+
import json
7+
import os
8+
from pathlib import Path
9+
import subprocess
10+
import tempfile
11+
import unittest
12+
from unittest.mock import patch
13+
14+
spec = importlib.util.spec_from_file_location("keyless_rules", Path(__file__).parent / "tools/signed_rules/keyless_rules.py")
15+
k = importlib.util.module_from_spec(spec)
16+
spec.loader.exec_module(k)
17+
18+
19+
class KeylessTests(unittest.TestCase):
20+
def setUp(self):
21+
self.temp = tempfile.TemporaryDirectory()
22+
self.addCleanup(self.temp.cleanup)
23+
self.root = Path(self.temp.name)
24+
self.artifact, self.bundle, self.trust, self.state, self.output = [self.root / name for name in ("rules.json", "bundle.json", "roots.jsonl", "state.json", "review.json")]
25+
self.bundle.write_text("synthetic-bundle", encoding="utf-8")
26+
self.trust.write_text("independent-synthetic-root", encoding="utf-8")
27+
self.now = datetime(2026, 10, 26, tzinfo=timezone.utc)
28+
self.value = k.build(1001, "a" * 40, self.now)
29+
self.artifact.write_bytes(k.encoded(self.value))
30+
31+
def verify(self, value=None, **kwargs):
32+
if value is not None:
33+
self.artifact.write_bytes(k.encoded(value))
34+
with patch("socket.socket", side_effect=AssertionError("offline only")):
35+
with patch.object(k, "verify_provenance") as proof:
36+
result = k.verify(self.artifact, self.bundle, self.trust, self.state, self.output, now=self.now, **kwargs)
37+
proof.assert_called_once()
38+
return result
39+
40+
def test_build_matches_embedded_snapshot_and_never_changes_checker(self):
41+
import node2shell_check as checker
42+
before = Path(checker.__file__).read_bytes()
43+
self.assertEqual({str(a): list(b) for a, b in checker.NODE2SHELL_FIXES.items()}, self.value["rules"]["fixes"])
44+
self.assertEqual(hashlib.sha256(before).hexdigest(), self.value["rules"]["checker_sha256"])
45+
self.verify()
46+
self.assertEqual(self.value["rules"], json.loads(self.output.read_bytes()))
47+
self.assertEqual(before, Path(checker.__file__).read_bytes())
48+
self.assertEqual(1001, json.loads(self.state.read_bytes())["sequence"])
49+
50+
def test_unsigned_tampered_and_untrusted_rejections_preserve_previous_output(self):
51+
self.verify()
52+
before = self.output.read_bytes()
53+
for error in (ValueError("wrong publisher"), subprocess.CalledProcessError(1, ["gh"]), subprocess.TimeoutExpired(["gh"], 1)):
54+
with patch.object(k, "verify_provenance", side_effect=error):
55+
with self.assertRaises((ValueError, subprocess.SubprocessError)):
56+
k.verify(self.artifact, self.bundle, self.trust, self.state, self.output, True, self.now)
57+
self.assertEqual(before, self.output.read_bytes())
58+
59+
def test_expiry_future_timestamps_and_schema_uncertainty_fail_closed(self):
60+
self.verify()
61+
before = self.output.read_bytes()
62+
cases = []
63+
for key, value in [("expires_at", k.timestamp(self.now)), ("issued_at", k.timestamp(self.now + timedelta(minutes=6))), ("sequence", True), ("source_commit", "../x")]:
64+
item = copy.deepcopy(self.value); item[key] = value; cases.append(item)
65+
for key, value in [("exploitability", "confirmed"), ("fixes", {"24": [22, 0, 1]}), ("as_of", "2099-01-01"), ("sources", ["https://private.invalid"] )]:
66+
item = copy.deepcopy(self.value); item["rules"][key] = value; cases.append(item)
67+
for value in cases:
68+
with self.assertRaises(ValueError): self.verify(value, force=True)
69+
self.assertEqual(before, self.output.read_bytes())
70+
71+
def test_rollback_sequence_reuse_and_same_artifact_retry(self):
72+
self.verify()
73+
self.verify(force=True)
74+
for sequence in (1000, 1001):
75+
value = copy.deepcopy(self.value); value["sequence"] = sequence
76+
value["rules"]["checker_sha256"] = "b" * 64
77+
with self.assertRaisesRegex(ValueError, "rollback|reuse"): self.verify(value, force=True)
78+
newer = copy.deepcopy(self.value); newer["sequence"] = 2001
79+
self.verify(newer, force=True)
80+
self.assertEqual(2001, json.loads(self.state.read_bytes())["sequence"])
81+
82+
def test_clock_rollback_invalid_state_and_existing_lock_are_not_bypassed(self):
83+
self.verify()
84+
prior = json.loads(self.state.read_bytes())
85+
prior["accepted_at"] = k.timestamp(self.now + timedelta(minutes=10))
86+
self.state.write_bytes(k.encoded(prior))
87+
with patch.object(k, "verify_provenance"):
88+
with self.assertRaisesRegex(ValueError, "Clock rollback"):
89+
k.verify(self.artifact, self.bundle, self.trust, self.state, self.output, True, self.now + timedelta(minutes=4))
90+
self.state.write_text('{"sequence":1}', encoding="utf-8")
91+
with self.assertRaisesRegex(ValueError, "durable state"): self.verify(force=True)
92+
lock = self.state.with_name(self.state.name + ".lock"); lock.write_text("other owner", encoding="utf-8")
93+
with self.assertRaises(FileExistsError): self.verify(force=True)
94+
self.assertEqual("other owner", lock.read_text())
95+
96+
def test_protected_paths_hardlinks_and_no_clobber(self):
97+
self.verify()
98+
with self.assertRaises(ValueError): self.verify()
99+
for path in (self.artifact, self.bundle, self.trust, self.state, self.state.with_name(self.state.name + ".lock"), Path(__file__).parent / "node2shell_check.py"):
100+
with self.assertRaises(ValueError):
101+
k.verify(self.artifact, self.bundle, self.trust, self.state, path, True, self.now)
102+
alias = self.root / "alias"; os.link(self.artifact, alias)
103+
with self.assertRaises(ValueError):
104+
k.verify(self.artifact, self.bundle, self.trust, self.state, alias, True, self.now)
105+
106+
def test_failed_output_fences_rollback_and_allows_verified_retry(self):
107+
real_atomic = k.atomic
108+
def failure(path, data, replace=False):
109+
if path.resolve() == self.output.resolve(): raise OSError("output unavailable")
110+
return real_atomic(path, data, replace)
111+
with patch.object(k, "atomic", side_effect=failure):
112+
with self.assertRaises(OSError): self.verify()
113+
self.assertEqual(1001, json.loads(self.state.read_bytes())["sequence"])
114+
self.assertFalse(self.output.exists())
115+
self.verify()
116+
self.assertEqual([], list(self.root.glob(".rules-*")))
117+
118+
def test_gh_policy_checks_cert_numeric_ids_and_exact_source_identity(self):
119+
good = [{"verificationResult": {"signature": {"certificate": {"sourceRepositoryIdentifier": k.REPOSITORY_ID, "sourceRepositoryOwnerIdentifier": k.OWNER_ID}}}}]
120+
for wrong in (False, True):
121+
value = copy.deepcopy(good)
122+
if wrong: value[0]["verificationResult"]["signature"]["certificate"]["sourceRepositoryIdentifier"] = "123"
123+
def run(command, **kwargs):
124+
self.assertIn("--custom-trusted-root", command)
125+
self.assertIn("--bundle", command)
126+
for expected in (k.IDENTITY, k.ISSUER, "--deny-self-hosted-runners", "--source-digest", "--signer-digest", "refs/heads/main"):
127+
self.assertIn(expected, command)
128+
self.assertNotIn("shell", kwargs)
129+
kwargs["stdout"].write(k.encoded(value))
130+
with patch.object(k.shutil, "which", return_value="trusted-gh"), patch.object(k.subprocess, "run", side_effect=run):
131+
if wrong:
132+
with self.assertRaisesRegex(ValueError, "numeric identity"):
133+
k.verify_provenance(self.artifact, self.bundle, self.trust, "a" * 40)
134+
else: k.verify_provenance(self.artifact, self.bundle, self.trust, "a" * 40)
135+
136+
def test_oversize_duplicate_json_and_malformed_output_never_publish(self):
137+
self.artifact.write_bytes(b" " * (k.MAX_BYTES + 1))
138+
with self.assertRaises(ValueError): self.verify()
139+
self.artifact.write_bytes(b'{"schema":1,"schema":2}')
140+
with self.assertRaises(ValueError): self.verify()
141+
self.assertFalse(self.state.exists()); self.assertFalse(self.output.exists())
142+
143+
144+
if __name__ == "__main__":
145+
unittest.main()

0 commit comments

Comments
 (0)