diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index b175c6d4..4246ec3b 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -65,7 +65,7 @@ workflows: '.github/workflows/scorecard.yml': - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' '.github/workflows/secret-scanner.yml': - - 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' + - 'hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113' '.github/workflows/supervised-fleet-scan.yml': - 'actions/checkout@v7.0.1' '.github/workflows/vendored-bot-drift.yml': @@ -173,6 +173,13 @@ dependencies: commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 + 'hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113': + ref: '4404c3c0474e17753fe7689f3485c02890aac113' + commit: 'sha1-4404c3c0474e17753fe7689f3485c02890aac113' + owner_id: 6759885 + repo_id: 1326697643 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' 'hyperpolymath/deed-ecosystem@main': ref: 'main' commit: 'sha1-ed83d6927e8fb21431e403dbf6d7a4af96772746' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 1090100b..d3e6e233 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -20,6 +20,11 @@ jobs: - uses: actions/checkout@v7.0.1 - name: Install PyYAML (for workflow YAML validation) run: pip install pyyaml --quiet + - name: Test workflow lock-map and regeneration audit + run: | + python3 tests/workflow-lock-map-test.py + python3 tests/lock-regeneration-test.py + python3 scripts/audit-workflow-lock-map.py - name: Verify automation quarantine run: bash tests/automation-quarantine-test.sh - name: Verify fix-outcome verdicts diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index dbac2872..71fca371 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,9 +1,22 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -# This workflow is managed by gh actions-lock. name: Secret Scanner +# Consumer migration to the published cicd-suite scanner. The three contexts +# this repository's rulesets require — scan / gitleaks, scan / rust-secrets, +# scan / shell-secrets — are produced by the callee's gitleaks job and by its +# supplemental-results matrix; none is renamed, added or dropped here. +# +# Migration boundaries: +# * `fast-pr` stays false: non-PR runs scan full history, and introduced-commit +# PR scanning is not enabled before the security owner accepts it. +# * No `secrets:` line. The callee declares no workflow_call secret and the +# scanner installs its own checksum-pinned binary, so `secrets: inherit` +# would forward every repository and organisation secret for no benefit. +# * `contents: read` is the whole grant; the previous caller's +# security-events / pull-requests / actions grants are not carried over. +# * Triggers are unchanged: pull_request and push to main, nothing else. + on: pull_request: push: @@ -14,15 +27,18 @@ concurrency: cancel-in-progress: true permissions: - actions: read contents: read jobs: scan: + # This job-level block REPLACES the workflow-level one. `contents: read` is + # the complete grant the callee's jobs declare; granting more is needless + # authority, granting less rejects the run at startup with zero jobs. permissions: - security-events: write contents: read - pull-requests: write - actions: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 - # secret-scanner-reusable declares no caller secret, so none is passed. + uses: hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@4404c3c0474e17753fe7689f3485c02890aac113 + with: + # The canary passed, but introduced-commit PR coverage is not approved. + # Every run continues to scan full history. + fast-pr: false + # Deliberately no `secrets:` line — see the boundary note above. diff --git a/audits/workflow-lock-map-2026-10-31/REPORT.md b/audits/workflow-lock-map-2026-10-31/REPORT.md new file mode 100644 index 00000000..8421725e --- /dev/null +++ b/audits/workflow-lock-map-2026-10-31/REPORT.md @@ -0,0 +1,25 @@ +# Fleet workflow lock-map investigation + +Scope: this repository, branch candidate `f8d11228f62abd8784f249995966a449a65c796b` (scanner migration PR #632), compared with main `83e35bcfd04d43960d635fec5397da8dfa344268`. This is not a catalogue or an estate certification. No lock was changed by this investigation. + +## Observed coverage + +* All **26** current `.github/workflows/*.yml` workflow files have keys in `actions.lock`, both at main and at the candidate. A key is only map coverage, not proof of a runnable workflow or complete closure. There is no observed unmapped workflow to classify as intentionally unmanaged; no owner-approved exception list was supplied. +* Three entries are explicitly empty: `label-triage.yml`, `labels.yml` and `lock-sync-gate.yml`. The workflow YAML contains no `uses` in these files. Their comments describe an intentional no-action design; these are **mapped zero-use workflows**, not missing lock-map entries or evidence of an unmanaged exemption. An empty mapping for a workflow that actually has an action or remote reusable call is a finding. +* The other 23 workflow files have direct external refs and/or a local reusable call; the inventory finds all their directly named external refs in the appropriate workflow map and records for all mapped refs. The local call in `fleet-report-back.yml` points to `fleet-report-back-reusable.yml`, whose own lock entry names its checkout, cache and artifact actions. The local caller's map does not separately list all callee actions. That is a distinction requiring runner/tool semantics, not by itself proof of missing closure. Main run [40933665680](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/40933665680) succeeded; it does not prove every ref or event path was exercised. +* The four remaining standards job-level calls in the candidate are mapped to the existing standards record. The migrated scanner is mapped to a cicd-suite record. Current `gh-actions-lock` [documents](https://github.com/github/gh-actions-lock#limitations) that remote job-level reusable calls are not locked by the tool. Do not infer complete remote closure from the caller map or CLI local verification. +* On this candidate, the pinned scanner source at `hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113`, `.github/workflows/secret-scanner-reusable.yml`, was fetched from GitHub and its external `uses` set is `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1`, matching the candidate's suite record and an existing dependency record. The [PR scanner run](https://github.com/hyperpolymath/gitbot-fleet/actions/runs/41227361766) at that candidate created jobs and passed the controls, scan and three contexts. It is not a post-merge run. +* At their **locked commits**, direct composite sources for `deed-ecosystem/validate-action`, `k9-ecosystem/validate-action`, and `smtp-notify-action` have no nested `uses`; `actions/upload-pages-artifact` names `actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f`, which is listed in its record and has a record of its own. These observations do not establish full recursive closure for all other actions, other workflows, or all inputs. +* The earlier fleet report's **4 of 14** map observation (M13/F7) was about the published **cicd-suite** repository, not this repository. Its report separately identifies real regeneration risks here (F2/F3). Neither that observation nor this inventory proves a missing workflow key in fleet. + +## Regression coverage added before any further lock edits + +`tests/workflow-lock-map-test.py` covers a mapped zero-use workflow, an unexpected missing map, an explicit unmanaged exception (reported as unverified, exit 2), typo exceptions, missing direct action and job-level reusable edges, missing nested records, local reusable coverage at its own path, malformed YAML/ref handling, and an extra workflow edge requiring review. `scripts/audit-workflow-lock-map.py` checks the actual map; it emits findings separately from source-review notes. Its successful exit means only that these map-level checks passed. It cannot detect an edge and its target removed together, discover remote or local composite closure, validate commit/repository identities, or decide whether an extra entry is necessary. + +`tests/lock-regeneration-test.py` and `scripts/audit-lock-regeneration.py` separately test comparison to a previously reviewed graph, including dropped composite/reusable edges with no dangling target and malformed ref representations. That comparison cannot establish that the old graph was complete, nor can it review a new pin; it is not an authoritative regeneration or provenance check. Fixtures and the map inventory run in E2E, which already installs PyYAML. They do **not** automatically compare each PR's new lock to the base; PR #631 proposes separate SHA-bound source receipts and broader audit coverage and is still open. + +## Not run / decision gate + +* Official `gh actions-lock` regeneration and provenance verification were not run: its release asset was inaccessible from this sandbox. No generated lock diff is claimed. +* No complete, recursive source inventory and provenance verification for **all** transitive dependencies was performed; no owner-confirmed intentionally unmanaged workflows were identified. The possibility of a missing closure outside the specific sources above remains open. +* Before any further lock regeneration, review source-derived SHA-bound closure (including local composites and reusable workflow subpaths), run the separate dropped-closure checks with a reviewed baseline, use the official tooling where available, and validate actual runner startup. Do not rewrite pinned tag refs or immutable resolutions merely to make a check pass. Coordinate with PR #631 rather than treating this map inventory or the catalogue as a fix. diff --git a/scripts/audit-lock-regeneration.py b/scripts/audit-lock-regeneration.py new file mode 100755 index 00000000..c6db5b98 --- /dev/null +++ b/scripts/audit-lock-regeneration.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Compare reviewed closure against regeneration; does not discover remote closure.""" +import re +import sys +import yaml + + +class UniqueLoader(yaml.SafeLoader): + pass + + +def unique_mapping(loader, node, deep=False): + """Construct a YAML mapping, raising ValueError for duplicate keys. + + Pass deep through to PyYAML's key and value construction. YAML construction + errors and TypeError for unhashable keys propagate to the caller. + """ + mapping = {} + for key_node, value_node in node.value: + item = loader.construct_object(key_node, deep=deep) + if item in mapping: + raise ValueError(f'duplicate YAML key: {item!r}') + mapping[item] = loader.construct_object(value_node, deep=deep) + return mapping + + +UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping) + + +def key(value): + """Normalize an owner/repository@ref key, preserving the ref's case. + + Raise ValueError if the reference is not in the supported form. + """ + if not isinstance(value, str) or not re.fullmatch(r'[^\s/@]+/[^\s/@]+@[^\s@]+', value): + raise ValueError(f'invalid dependency reference: {value!r}') + repo, ref = value.split('@') + return f'{repo.lower()}@{ref}' + + +def load(path): + """Load and validate a lock file as (dependencies, workflow roots). + + Dependency keys and edges are normalized; each dependency value contains + its ref, commit, and set of edges. Raise ValueError for invalid records, + references, or missing targets. File, YAML, and key construction errors + propagate to the caller. + """ + with open(path, encoding='utf-8') as f: + doc = yaml.load(f, Loader=UniqueLoader) + if not isinstance(doc, dict) or not isinstance(doc.get('workflows'), dict) or not isinstance(doc.get('dependencies'), dict): + raise ValueError(f'{path}: expected workflows and dependencies mappings') + deps = {} + for name, record in doc['dependencies'].items(): + k = key(name) + if k in deps or not isinstance(record, dict): + raise ValueError(f'{path}: duplicate or invalid record {name}') + ref, commit = record.get('ref'), record.get('commit') + if not isinstance(ref, str) or not re.fullmatch(r'[^\s@]+', ref) or ref.startswith('sha1-'): + raise ValueError(f'{path}: invalid ref for {name}') + if not isinstance(commit, str) or not re.fullmatch(r'sha1-[0-9a-f]{40}', commit): + raise ValueError(f'{path}: invalid commit for {name}') + named_ref = name.split('@')[1] + if re.fullmatch(r'[0-9a-f]{40}', named_ref): + if commit != 'sha1-' + named_ref: + raise ValueError(f'{path}: SHA key and commit differ for {name}') + elif named_ref != ref: + raise ValueError(f'{path}: tag ref differs from key for {name}') + edges = record.get('uses', []) + if not isinstance(edges, list): + raise ValueError(f'{path}: invalid uses for {name}') + deps[k] = (ref, commit, {key(edge) for edge in edges}) + roots = set() + for workflow, edges in doc['workflows'].items(): + if not isinstance(workflow, str) or not isinstance(edges, list): + raise ValueError(f'{path}: invalid workflow {workflow!r}') + roots.update(key(edge) for edge in edges) + for edge in roots | set().union(*(r[2] for r in deps.values())): + if edge not in deps: + raise ValueError(f'{path}: missing dependency {edge}') + return deps, roots + + +def audit(old_path, new_path): + """Compare reviewed dependencies reachable from proposed workflow roots. + + For roots present in the reviewed lock, check that their reviewed + reachable records, refs, commits, and edges remain in the proposed lock. + Print a success summary; new sources still require independent review. + Raise ValueError on a mismatch and propagate lock loading errors. + """ + old, _ = load(old_path) + new, roots = load(new_path) + queue = list(roots & old.keys()) + seen = set() + while queue: + k = queue.pop() + if k in seen: + continue + seen.add(k) + if k not in new: + raise ValueError(f'dropped reviewed dependency {k}') + if old[k][:2] != new[k][:2]: + raise ValueError(f'changed reviewed ref/commit {k}') + missing = old[k][2] - new[k][2] + if missing: + raise ValueError(f'dropped reviewed edges from {k}: {sorted(missing)}') + queue.extend(old[k][2]) + print(f'Preserved reviewed reachable closure ({len(seen)} records); new sources require independent review.') + + +if __name__ == '__main__': + if len(sys.argv) != 3: + sys.exit(f'usage: {sys.argv[0]} REVIEWED_LOCK PROPOSED_LOCK') + try: + audit(*sys.argv[1:]) + except (OSError, ValueError, yaml.YAMLError) as exc: + sys.exit(f'lock regeneration audit: {exc}') diff --git a/scripts/audit-workflow-lock-map.py b/scripts/audit-workflow-lock-map.py new file mode 100755 index 00000000..46794ce5 --- /dev/null +++ b/scripts/audit-workflow-lock-map.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Inventory workflow lock-map coverage, not remote source closure or provenance.""" +import argparse +import pathlib +import re +import sys +import yaml + + +class UniqueLoader(yaml.SafeLoader): + pass + + +def unique_mapping(loader, node, deep=False): + """Construct a YAML mapping, raising ValueError for duplicate keys. + + Pass deep through to PyYAML's key and value construction. YAML construction + errors and TypeError for unhashable keys propagate to the caller. + """ + result = {} + for key, value in node.value: + k = loader.construct_object(key, deep=deep) + if k in result: + raise ValueError(f'duplicate YAML key: {k!r}') + result[k] = loader.construct_object(value, deep=deep) + return result + + +UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping) + + +def load(path): + """Read UTF-8 YAML with duplicate keys rejected. + + File, YAML, duplicate-key, and key construction errors propagate. + """ + with path.open(encoding='utf-8') as f: + return yaml.load(f, Loader=UniqueLoader) + + +def ref_key(value): + """Normalize a remote uses reference to owner/repository@ref. + + Lowercase the owner and repository, discard any subpath, and preserve the ref's case. Return None for local + and docker references; raise ValueError for unsupported or invalid input. + """ + if not isinstance(value, str) or not value or re.search(r'\s', value): + raise ValueError(f'invalid uses: {value!r}') + if value.startswith('./'): + return None + if value.startswith('docker://'): + return None + m = re.fullmatch(r'([^/@]+)/([^/@]+)(?:/[^@]+)?@([^@]+)', value) + if not m: + raise ValueError(f'unsupported or invalid uses: {value!r}') + return f'{m[1].lower()}/{m[2].lower()}@{m[3]}' + + +def inspect(root, unmanaged=()): + """Return (findings, notes) for the workflow lock map under root. + + Unmanaged contains repository-relative workflow paths to treat as explicit + exceptions; their dependency closure remains unverified. The inventory + does not verify remote source closure or provenance. Invalid structures or + references raise ValueError; file, YAML, and construction errors propagate. + """ + root = pathlib.Path(root).resolve() + wfdir = root / '.github/workflows' + lock = load(wfdir / 'actions.lock') + if not isinstance(lock, dict) or not isinstance(lock.get('workflows'), dict) or not isinstance(lock.get('dependencies'), dict): + raise ValueError('invalid workflows or dependencies mapping') + workflows, deps = lock['workflows'], lock['dependencies'] + files = {p.relative_to(root).as_posix(): p for p in sorted(wfdir.glob('*')) if p.suffix in ('.yml', '.yaml') and p.is_file()} + unmanaged = set(unmanaged) + findings, notes = [], [] + for path in sorted(unmanaged - files.keys()): + findings.append(f'UNMANAGED-UNKNOWN {path}') + for path in sorted(workflows.keys() - files.keys()): + findings.append(f'STALE-WORKFLOW {path}') + depkeys = set() + for dep in deps: + norm = ref_key(dep) + if norm is None or norm in depkeys: + raise ValueError(f'invalid or duplicate dependency key: {dep!r}') + depkeys.add(norm) + for dep, record in deps.items(): + if not isinstance(record, dict) or not isinstance(record.get('uses', []), list): + raise ValueError(f'invalid dependency record: {dep!r}') + for edge in record.get('uses', []): + normalized = ref_key(edge) + if normalized is None: + raise ValueError(f'invalid dependency edge: {edge!r}') + if normalized not in depkeys: + findings.append(f'MISSING-TRANSITIVE-RECORD {dep} {normalized}') + for name, file in files.items(): + doc = load(file) + if not isinstance(doc, dict) or not isinstance(doc.get('jobs'), dict): + raise ValueError(f'{name}: expected jobs mapping') + refs, local_calls, unsupported = set(), [], [] + for job in doc['jobs'].values(): + if not isinstance(job, dict): + raise ValueError(f'{name}: invalid job') + entries = [] + if 'uses' in job: + entries.append((job['uses'], True)) + steps = job.get('steps', []) + if not isinstance(steps, list): + raise ValueError(f'{name}: invalid steps') + for step in steps: + if not isinstance(step, dict): + raise ValueError(f'{name}: invalid step') + if 'uses' in step: + entries.append((step['uses'], False)) + for raw, is_job in entries: + normalized = ref_key(raw) + if normalized: + refs.add(normalized) + if is_job: + notes.append(f'REMOTE-REUSABLE-SOURCE-REVIEW {name} {normalized}') + elif raw.startswith('./') and is_job: + local = (root / raw).resolve() + if not local.is_relative_to(wfdir) or local.suffix not in ('.yml', '.yaml') or not local.is_file(): + findings.append(f'INVALID-LOCAL-WORKFLOW {name} {raw}') + else: + local_calls.append(local.relative_to(root).as_posix()) + else: + unsupported.append(raw) + for raw in unsupported: + notes.append(f'NOT-LOCKED-SOURCE-REVIEW {name} {raw}') + if name not in workflows: + if name in unmanaged: + notes.append(f'INTENTIONALLY-UNMANAGED {name} (dependency closure not verified)') + else: + findings.append(f'MISSING-WORKFLOW {name}') + continue + if name in unmanaged: + findings.append(f'UNMANAGED-BUT-MAPPED {name}') + mapped = workflows[name] + if not isinstance(mapped, list): + raise ValueError(f'{name}: expected lock list') + normalized_mapped = [ref_key(item) for item in mapped] + if None in normalized_mapped or len(set(normalized_mapped)) != len(normalized_mapped): + raise ValueError(f'{name}: invalid or duplicate lock reference') + mapped_set = set(normalized_mapped) + for ref in sorted(refs - mapped_set): + findings.append(f'MISSING-DIRECT-EDGE {name} {ref}') + for ref in sorted(mapped_set - depkeys): + findings.append(f'MISSING-RECORD {name} {ref}') + if not refs and not local_calls and not unsupported and not mapped_set: + notes.append(f'NO-USES-MAPPED {name}') + for target in local_calls: + notes.append(f'LOCAL-REUSABLE {name} -> {target} (callee checked separately)') + for ref in sorted(mapped_set - refs): + notes.append(f'EXTRA-OR-TRANSITIVE-EDGE {name} {ref} (source review needed)') + return findings, notes + + +def main(): + """Print the inventory and return its command-line status. + + Return 1 for findings, 2 for caught audit errors or unmanaged exceptions, + and 0 otherwise. Caught errors go to stderr; argument parsing may exit. + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', nargs='?', default='.') + parser.add_argument('--unmanaged', action='append', default=[], help='explicit workflow path excluded by an owner; still requires manual closure review') + args = parser.parse_args() + try: + findings, notes = inspect(args.root, args.unmanaged) + except (OSError, ValueError, TypeError, yaml.YAMLError) as exc: + print(f'NOT-RUN: {exc}', file=sys.stderr) + return 2 + for item in notes: + print('NOTE:', item) + for item in findings: + print('FINDING:', item) + print(f'workflow-map findings={len(findings)}; remote source closure and provenance NOT VERIFIED') + if findings: + return 1 + return 2 if args.unmanaged else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/lock-regeneration-test.py b/tests/lock-regeneration-test.py new file mode 100644 index 00000000..6a8ade32 --- /dev/null +++ b/tests/lock-regeneration-test.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +import copy +import pathlib +import subprocess +import sys +import tempfile +import unittest +import yaml + +script = pathlib.Path(__file__).resolve().parents[1] / 'scripts/audit-lock-regeneration.py' +a, b, c = 'a' * 40, 'b' * 40, 'c' * 40 +base = {'workflows': {'.github/workflows/test.yml': ['org/reusable@v1']}, 'dependencies': { + 'org/reusable@v1': {'ref': 'v1', 'commit': 'sha1-' + a, 'uses': ['org/composite@' + b]}, + 'org/composite@' + b: {'ref': 'v2', 'commit': 'sha1-' + b, 'uses': ['org/child@v3']}, + 'org/child@v3': {'ref': 'v3', 'commit': 'sha1-' + c}}} +with tempfile.TemporaryDirectory() as d: + old, new = pathlib.Path(d) / 'old.yml', pathlib.Path(d) / 'new.yml' + old.write_text(yaml.safe_dump(base)) + def check(label, doc, expected): + new.write_text(yaml.safe_dump(doc)) + result = subprocess.run([sys.executable, script, old, new], capture_output=True, text=True) + assert (result.returncode == 0) == expected, (label, result.stdout, result.stderr) + print('ok:', label) + check('unchanged closure', copy.deepcopy(base), True) + doc = copy.deepcopy(base); doc['dependencies']['org/reusable@v1'].pop('uses'); del doc['dependencies']['org/composite@' + b]; del doc['dependencies']['org/child@v3'] + check('dropped reusable closure without dangling edge', doc, False) + doc = copy.deepcopy(base); doc['dependencies']['org/composite@' + b].pop('uses'); del doc['dependencies']['org/child@v3'] + check('dropped composite closure without dangling edge', doc, False) + doc = copy.deepcopy(base); del doc['dependencies']['org/child@v3']; check('dangling edge', doc, False) + doc = copy.deepcopy(base); doc['dependencies']['org/composite@' + b]['ref'] = 'sha1-' + b; check('lost reviewed tag', doc, False) + doc = copy.deepcopy(base); doc['dependencies']['org/child@v3']['commit'] = c; check('malformed commit', doc, False) + doc = copy.deepcopy(base); doc['dependencies']['org/child@v3']['ref'] = 'v4'; check('wrong tag', doc, False) + doc = copy.deepcopy(base); doc['dependencies']['org/composite@' + b]['commit'] = 'sha1-' + c; check('SHA mismatch', doc, False) + + +class RegenerationAuditTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.old = pathlib.Path(self.tmp.name) / 'old.yml' + self.new = pathlib.Path(self.tmp.name) / 'new.yml' + + def run_audit(self, reviewed, proposed): + for path, doc in ((self.old, reviewed), (self.new, proposed)): + path.write_text(doc if isinstance(doc, str) else yaml.safe_dump(doc), encoding='utf-8') + return subprocess.run([sys.executable, script, self.old, self.new], capture_output=True, text=True) + + def test_changed_reviewed_tag_resolution_is_rejected(self): + doc = copy.deepcopy(base) + doc['dependencies']['org/child@v3']['commit'] = 'sha1-' + a + result = self.run_audit(base, doc) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn('changed reviewed ref/commit org/child@v3', result.stderr) + + def test_changed_reviewed_sha_record_ref_is_rejected(self): + doc = copy.deepcopy(base) + doc['dependencies']['org/composite@' + b]['ref'] = 'v4' + result = self.run_audit(base, doc) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn('changed reviewed ref/commit org/composite@' + b, result.stderr) + + def test_added_edges_and_new_roots_are_allowed_but_require_review(self): + doc = copy.deepcopy(base) + doc['dependencies']['org/child@v3']['uses'] = ['new/action@v1'] + doc['dependencies']['new/action@v1'] = {'ref': 'v1', 'commit': 'sha1-' + a} + doc['workflows']['new.yml'] = ['new/action@v1'] + result = self.run_audit(base, doc) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('Preserved reviewed reachable closure (3 records)', result.stdout) + self.assertIn('new sources require independent review', result.stdout) + + def test_removed_old_root_does_not_force_unused_closure(self): + proposed = {'workflows': {}, 'dependencies': {}} + result = self.run_audit(base, proposed) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('closure (0 records)', result.stdout) + + def test_cycles_and_case_insensitive_repository_names(self): + reviewed = copy.deepcopy(base) + reviewed['dependencies']['org/child@v3']['uses'] = ['ORG/Reusable@v1'] + proposed = copy.deepcopy(reviewed) + proposed['workflows']['.github/workflows/test.yml'] = ['ORG/REUSABLE@v1'] + result = self.run_audit(reviewed, proposed) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('closure (3 records)', result.stdout) + + def test_dropped_edge_is_identified_even_when_record_remains(self): + doc = copy.deepcopy(base) + doc['dependencies']['org/reusable@v1']['uses'] = [] + result = self.run_audit(base, doc) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn('dropped reviewed edges from org/reusable@v1', result.stderr) + self.assertIn('org/composite@' + b, result.stderr) + + def test_invalid_documents_fail_on_either_input(self): + invalid = [ + ('workflows: {}\nworkflows: {}\ndependencies: {}\n', 'duplicate YAML key'), + ({'workflows': [], 'dependencies': {}}, 'expected workflows and dependencies mappings'), + ({'workflows': {'test.yml': ['org/missing@v1']}, 'dependencies': {}}, 'missing dependency'), + ({'workflows': {'test.yml': 'org/action@v1'}, 'dependencies': {}}, 'invalid workflow'), + ({'workflows': {}, 'dependencies': {'org/action@v1': []}}, 'invalid record'), + ({'workflows': {}, 'dependencies': {'org/action@v1': {'ref': 'v1', 'commit': 'sha1-' + a, 'uses': 'org/child@v1'}}}, 'invalid uses'), + ({'workflows': {}, 'dependencies': {'org/action/sub@v1': {}}}, 'invalid dependency reference'), + ({'workflows': {}, 'dependencies': {'org/action@v1': {'ref': 'v1', 'commit': 'sha1-' + a}, 'ORG/ACTION@v1': {'ref': 'v1', 'commit': 'sha1-' + a}}}, 'duplicate or invalid record'), + ] + for doc, message in invalid: + for old_is_invalid in (True, False): + with self.subTest(message=message, old_is_invalid=old_is_invalid): + result = self.run_audit(doc if old_is_invalid else base, base if old_is_invalid else doc) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn(message, result.stderr) + + def test_cli_requires_two_paths_and_handles_missing_file(self): + result = subprocess.run([sys.executable, script], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertIn('usage:', result.stderr) + self.old.write_text(yaml.safe_dump(base), encoding='utf-8') + result = subprocess.run([sys.executable, script, self.old, self.new], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertIn('lock regeneration audit:', result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/workflow-lock-map-test.py b/tests/workflow-lock-map-test.py new file mode 100644 index 00000000..a6e10fbb --- /dev/null +++ b/tests/workflow-lock-map-test.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +import pathlib +import subprocess +import sys +import tempfile +import unittest +import yaml + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'scripts/audit-workflow-lock-map.py' + + +class WorkflowMapTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = pathlib.Path(self.tmp.name) + self.dir = self.root / '.github/workflows' + self.dir.mkdir(parents=True) + self.lock = {'workflows': {'.github/workflows/main.yml': ['acme/action@v1'], + '.github/workflows/empty.yml': []}, + 'dependencies': {'acme/action@v1': {'ref': 'v1', 'commit': 'sha1-' + 'a' * 40}}} + self.write('main.yml', {'jobs': {'build': {'steps': [{'uses': 'Acme/action/subpath@v1'}]}}}) + self.write('empty.yml', {'jobs': {'build': {'steps': [{'run': 'echo hello'}]}}}) + + def write(self, name, data): + (self.dir / name).write_text(yaml.safe_dump(data), encoding='utf-8') + + def run_audit(self, *args): + (self.dir / 'actions.lock').write_text(yaml.safe_dump(self.lock), encoding='utf-8') + result = subprocess.run([sys.executable, SCRIPT, self.root, *args], text=True, capture_output=True) + return result.returncode, result.stdout + result.stderr + + def test_mapped_no_uses_is_not_missing_or_unmanaged(self): + code, output = self.run_audit() + self.assertEqual(code, 0, output) + self.assertIn('NO-USES-MAPPED .github/workflows/empty.yml', output) + self.assertNotIn('INTENTIONALLY-UNMANAGED', output) + self.assertIn('remote source closure and provenance NOT VERIFIED', output) + + def test_missing_workflow_is_not_silently_exempted(self): + del self.lock['workflows']['.github/workflows/empty.yml'] + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('MISSING-WORKFLOW .github/workflows/empty.yml', output) + + def test_explicit_unmanaged_is_not_a_closure_pass(self): + del self.lock['workflows']['.github/workflows/empty.yml'] + code, output = self.run_audit('--unmanaged', '.github/workflows/empty.yml') + self.assertEqual(code, 2, output) + self.assertIn('INTENTIONALLY-UNMANAGED', output) + self.assertIn('closure not verified', output) + + def test_invalid_unmanaged_exception_fails(self): + code, output = self.run_audit('--unmanaged', '.github/workflows/typo.yml') + self.assertEqual(code, 1, output) + self.assertIn('UNMANAGED-UNKNOWN', output) + + def test_missing_direct_ref_even_with_workflow_key(self): + self.lock['workflows']['.github/workflows/main.yml'] = [] + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('MISSING-DIRECT-EDGE', output) + + def test_reusable_missing_edge_is_failure(self): + self.write('main.yml', {'jobs': {'scan': {'uses': 'acme/suite/.github/workflows/scan.yml@abc'}}}) + self.lock['workflows']['.github/workflows/main.yml'] = [] + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('MISSING-DIRECT-EDGE', output) + self.assertIn('REMOTE-REUSABLE-SOURCE-REVIEW', output) + + def test_missing_nested_record(self): + self.lock['dependencies']['acme/action@v1']['uses'] = ['acme/child@v1'] + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('MISSING-TRANSITIVE-RECORD', output) + + def test_local_reusable_is_checked_at_its_own_path(self): + self.write('main.yml', {'jobs': {'call': {'uses': './.github/workflows/callee.yml'}}}) + self.lock['workflows']['.github/workflows/main.yml'] = [] + self.write('callee.yml', {'jobs': {'run': {'steps': [{'uses': 'acme/action@v1'}]}}}) + self.lock['workflows']['.github/workflows/callee.yml'] = [] + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('LOCAL-REUSABLE', output) + self.assertIn('MISSING-DIRECT-EDGE .github/workflows/callee.yml', output) + + def test_malformed_and_duplicate_yaml_fail_closed(self): + self.write('main.yml', {'jobs': {'run': {'steps': [{'uses': 'no-at-sign'}]}}}) + code, output = self.run_audit() + self.assertEqual(code, 2, output) + self.assertIn('NOT-RUN', output) + (self.dir / 'main.yml').write_text('jobs: {}\njobs: {}\n', encoding='utf-8') + code, output = self.run_audit() + self.assertEqual(code, 2, output) + self.assertIn('duplicate YAML key', output) + + def test_extra_edge_is_review_not_proof_of_failure(self): + self.lock['workflows']['.github/workflows/empty.yml'] = ['acme/action@v1'] + code, output = self.run_audit() + self.assertEqual(code, 0, output) + self.assertIn('EXTRA-OR-TRANSITIVE-EDGE', output) + + def test_mapped_ref_without_record_and_stale_workflow(self): + self.lock['workflows']['.github/workflows/main.yml'].append('acme/missing@v2') + self.lock['workflows']['.github/workflows/deleted.yml'] = [] + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('MISSING-RECORD .github/workflows/main.yml acme/missing@v2', output) + self.assertIn('STALE-WORKFLOW .github/workflows/deleted.yml', output) + + def test_mapped_workflow_cannot_also_be_unmanaged(self): + code, output = self.run_audit('--unmanaged', '.github/workflows/main.yml') + self.assertEqual(code, 1, output) + self.assertIn('UNMANAGED-BUT-MAPPED .github/workflows/main.yml', output) + + def test_invalid_local_workflow_paths(self): + for path in ('./.github/workflows/missing.yml', './outside.yml', './.github/workflows/actions.lock'): + with self.subTest(path=path): + self.write('main.yml', {'jobs': {'call': {'uses': path}}}) + self.lock['workflows']['.github/workflows/main.yml'] = [] + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('INVALID-LOCAL-WORKFLOW .github/workflows/main.yml ' + path, output) + + def test_local_actions_and_docker_are_reported_for_review(self): + self.write('main.yml', {'jobs': {'build': {'steps': [ + {'uses': './my-action'}, {'uses': 'docker://example/image:1'}]}}}) + self.lock['workflows']['.github/workflows/main.yml'] = [] + code, output = self.run_audit() + self.assertEqual(code, 0, output) + self.assertIn('NOT-LOCKED-SOURCE-REVIEW .github/workflows/main.yml ./my-action', output) + self.assertIn('NOT-LOCKED-SOURCE-REVIEW .github/workflows/main.yml docker://example/image:1', output) + + def test_normalizes_owner_repo_and_subpath_but_preserves_ref_case(self): + self.write('main.yml', {'jobs': {'build': {'steps': [{'uses': 'ACME/ACTION/sub@V1'}]}}}) + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('MISSING-DIRECT-EDGE .github/workflows/main.yml acme/action@V1', output) + self.lock['workflows']['.github/workflows/main.yml'] = ['acme/action@V1'] + self.lock['dependencies'] = {'Acme/Action@V1': {}} + code, output = self.run_audit() + self.assertEqual(code, 0, output) + + def test_duplicate_normalized_refs_and_bad_records_fail_closed(self): + cases = [ + ('duplicate dependency', lambda: self.lock['dependencies'].update({'ACME/ACTION@v1': {}})), + ('duplicate map edge', lambda: self.lock['workflows']['.github/workflows/main.yml'].append('ACME/ACTION/sub@v1')), + ('invalid record', lambda: self.lock['dependencies'].update({'acme/action@v1': []})), + ('invalid transitive edge', lambda: self.lock['dependencies']['acme/action@v1'].update({'uses': ['./local']})), + ('invalid workflow map', lambda: self.lock['workflows'].update({'.github/workflows/main.yml': 'acme/action@v1'})), + ] + original = yaml.safe_dump(self.lock) + for label, mutate in cases: + with self.subTest(label=label): + self.lock = yaml.safe_load(original) + mutate() + code, output = self.run_audit() + self.assertEqual(code, 2, output) + self.assertIn('NOT-RUN:', output) + + def test_invalid_workflow_shapes_fail_closed(self): + cases = [ + {'jobs': []}, + {'jobs': {'build': None}}, + {'jobs': {'build': {'steps': {}}}}, + {'jobs': {'build': {'steps': [None]}}}, + {'jobs': {'build': {'steps': [{'uses': 'acme/action@bad ref'}]}}}, + ] + for doc in cases: + with self.subTest(doc=doc): + self.write('main.yml', doc) + code, output = self.run_audit() + self.assertEqual(code, 2, output) + self.assertIn('NOT-RUN:', output) + + def test_yaml_extension_is_inventoried(self): + self.write('extra.yaml', {'jobs': {'run': {'steps': []}}}) + code, output = self.run_audit() + self.assertEqual(code, 1, output) + self.assertIn('MISSING-WORKFLOW .github/workflows/extra.yaml', output) + + +class ScannerCallerTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + root = pathlib.Path(__file__).resolve().parents[1] / '.github/workflows' + # BaseLoader preserves GitHub's `on` key and the spelling of scalar values. + cls.workflow = yaml.load((root / 'secret-scanner.yml').read_text(encoding='utf-8'), Loader=yaml.BaseLoader) + cls.lock = yaml.safe_load((root / 'actions.lock').read_text(encoding='utf-8')) + + def test_scanner_triggers_and_access(self): + self.assertEqual(self.workflow['on'], {'pull_request': '', 'push': {'branches': ['main']}}) + self.assertEqual(self.workflow['permissions'], {'contents': 'read'}) + self.assertEqual(set(self.workflow['jobs']), {'scan'}) + job = self.workflow['jobs']['scan'] + self.assertEqual(job['permissions'], {'contents': 'read'}) + self.assertNotIn('secrets', job) + self.assertEqual(job['with'], {'fast-pr': 'false'}) + + def test_scanner_pin_and_lock_closure(self): + sha = '4404c3c0474e17753fe7689f3485c02890aac113' + suite = 'hyperpolymath/cicd-suite@' + sha + checkout = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + self.assertEqual(self.workflow['jobs']['scan']['uses'], + 'hyperpolymath/cicd-suite/.github/workflows/secret-scanner-reusable.yml@' + sha) + self.assertEqual(self.lock['workflows']['.github/workflows/secret-scanner.yml'], [suite]) + record = self.lock['dependencies'][suite] + self.assertEqual((record['ref'], record['commit']), (sha, 'sha1-' + sha)) + self.assertEqual(record['uses'], [checkout]) + self.assertIn(checkout, self.lock['dependencies']) + standards = 'hyperpolymath/standards@75bd5994470661a099df562d6d5ad2fe744dbd66' + self.assertIn(standards, self.lock['dependencies']) + self.assertTrue(any(standards in edges for edges in self.lock['workflows'].values())) + + +if __name__ == '__main__': + unittest.main()