diff --git a/.gitignore b/.gitignore index 47a09bd..0e147aa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/skills/engineering/charm-tech-baseline/README.md b/skills/engineering/charm-tech-baseline/README.md new file mode 100644 index 0000000..10e1cca --- /dev/null +++ b/skills/engineering/charm-tech-baseline/README.md @@ -0,0 +1,67 @@ +# charm-tech-baseline skill + +Reusable AI-agent skill that audits a single repository against the Canonical Charm Tech baseline and, where the gaps are mechanical, fixes them. + +## Why this exists + +The 26.10 cycle's baseline work produced a set of data: which SSDLC requirements apply at which tier, what carve-outs we accept, which best-of-class tools we measured and skipped, which sweeps we ran and which we retracted. None of that is useful if it lives only as a PROGRESS log: the next time a new repo lands the work has to be re-derived. + +This skill consolidates the cycle's output into a form an AI agent can load on demand and apply to one repo at a time. + +## How to use it + +```bash +# audit +scripts/check.py # auto-detect tier, JSON output +scripts/check.py --tier=personal --format=markdown +scripts/check.py --only=security-md,dependabot + +# fix (mechanical only — agent must judge) +scripts/fixes/add-security-md.py +scripts/fixes/add-dependabot.py +``` + +The skill itself is the canonical entry point — see [`SKILL.md`](SKILL.md). An AI agent loads `SKILL.md`, runs `check.py`, reads the JSON report, and uses the references to explain gaps and propose remediation. + +## Layout + +``` +SKILL.md # the skill (router; loaded by the agent) +README.md # this file (human-readable) +references/ # static knowledge; loaded by the agent on demand + ssdlc-framework.md # SEC0023 matrix + per-requirement summary + decisions.md # settled carve-outs + skipped-tools.md # tools we measured and skipped, with the basis + open-investigations.md # items waiting on external triggers +assets/ # file templates used by fix scripts +scripts/ # deterministic checks + fixes + check.py # umbrella runner; emits JSON or markdown + detect-tier.py # remote-URL inspection → tier name + checks/ # one script per control + fixes/ # one script per mechanical remediation + lib/ # shared Python helpers (common.py) +tests/ # functional tests for the runner + a couple of checks +``` + +## Tests + +A small functional suite exercises `detect-tier.py`, the `check.py` runner, and the checks with the most parsing logic. Each test writes a tiny fixture tree and runs the real script as a subprocess — no mocking. + +```bash +uv run --with pytest pytest tests/ +``` + +Coverage is deliberately shallow: one pass + one fail per exercised check. Add a test file under `tests/checks/` when a new check ships non-trivial parsing. + +## Agent-generic + +The skill follows the common [agent-skill format](https://agentskills.io) (YAML frontmatter + Markdown body + standard subdirectories). It does not reference agent-specific tools, slash commands, or harness features. Any agent that can read files, run shell commands, and interpret JSON can use it. + +## Maintenance + +When a future cycle's baseline work changes a decision: + +1. Update the relevant `references/*.md` entry (note the date and the new evidence). +2. If a new check is warranted, add a script to `scripts/checks/` and a matching entry to `SKILL.md`'s coverage table. +3. If a previously-skipped tool now has measured value, update `skipped-tools.md` *with the new measurement*; do not silently re-recommend. +4. Re-run `skill-scanner` and `scripts/validate_skill.py`. diff --git a/skills/engineering/charm-tech-baseline/SKILL.md b/skills/engineering/charm-tech-baseline/SKILL.md new file mode 100644 index 0000000..e1001fc --- /dev/null +++ b/skills/engineering/charm-tech-baseline/SKILL.md @@ -0,0 +1,160 @@ +--- +name: charm-tech-baseline +description: Audit a repository against the Canonical Charm Tech baseline — SSDLC compliance, supply-chain hygiene, and best-of-class extras — and explain or fix the gaps. Tier-aware (product / canonical / personal), agent-generic, ships deterministic check + fix scripts. +metadata: + source: Canonical Charm Tech, 26.10 cycle + audience: any AI coding agent operating on a single repo at a time +--- + +# Charm Tech baseline audit + +Run a structured audit of a single repository's compliance with the Canonical Charm Tech baseline that the 26.10 cycle distilled from SSDLC (SEC0023–SEC0061), the Astral OSS-security best-of-class review, and per-tool measurement work. Emit a JSON report of findings, explain each gap in human-readable form, and optionally apply deterministic fixes for the mechanical ones. Three tiers — `product`, `canonical`, `personal` — control which checks apply; tier is detected from the remote URL or passed explicitly. + +## When to use + +Use when: + +- A new Canonical or personal repo is being stood up and needs the baseline applied from scratch. +- An existing repo needs a compliance gap analysis ahead of a cycle's audit. +- A repo is being promoted up tiers (personal → canonical, canonical → product) and the requirement set widens. +- A targeted check is needed (`--only=security-md`, `--only=dependabot`) to verify one specific control. + +Skip when: + +- The change is docs-only or a single bug fix — nothing in this skill applies. +- The repo isn't a long-lived first-party project (forks of upstream projects, scratch repos, demo recordings). +- The user is asking about *one* specific tool (e.g. "how do I add zizmor") — answer directly using [`references/decisions.md`](references/decisions.md) rather than running a full audit. + +## How to use + +### 1. Detect the tier + +Run [`scripts/detect-tier.py`](scripts/detect-tier.py) inside the target repo. It inspects `git remote get-url origin`, resolves forks of `canonical/*` back to the upstream slug (via `gh repo view --json isFork,parent`, or an `upstream` remote as fallback), and prints one of: + +- `product` — Canonical-owned repo classified as a *product* in the SEC0023 applicability matrix (full SSDLC requirements scale to the planned release type). Charm Tech examples this cycle: `operator`, `pebble`, `jubilant`, `concierge`, `charmlibs`. +- `canonical` — Canonical-owned repo that is **not** a SEC0023 product (tooling repos, demo charms, internal helpers). Gets cross-cutting requirements only (SECURITY.md, Dependabot, supply-chain hygiene), not the per-requirement SSDLC procedures. +- `personal` — Non-Canonical repo with no canonical upstream (a from-scratch project, or one in flight before transfer). Gets best-of-class hygiene only; no Canonical-internal requirements apply. A personal-account *fork* of `canonical/` resolves to that upstream's tier, not `personal`. + +If detection is ambiguous, ask the user; do not guess. Tier classification flips obligations on/off in the report, so getting it wrong wastes effort or hides gaps. + +The user may override: pass `--tier=product|canonical|personal` to every script. The check runner always echoes the resolved tier in the report header. + +### 2. Run the umbrella check + +```bash +scripts/check.py [--tier=] [--only=[,...]] [--format=json|markdown] +``` + +This dispatches the per-control scripts in [`scripts/checks/`](scripts/checks/) that apply to the resolved tier and aggregates the results. Default output format is JSON for agent consumption; pass `--format=markdown` for a human-readable summary. + +Each check exits with one of: + +- `0` — pass +- `1` — fail (gap found) +- `2` — not applicable for this tier (script skipped itself; no human action needed) +- `3` — couldn't determine (for example, external API unreachable; surface as a note in the report rather than a finding) + +### 3. Interpret the report + +The agent's job is to read the JSON, identify the gaps that need human judgement vs the ones that are mechanical, and propose a remediation plan. Use the references to ground every recommendation: + +- [`references/ssdlc-framework.md`](references/ssdlc-framework.md) — what each SEC0XXX requirement actually mandates, where artefacts live, who reviews. +- [`references/decisions.md`](references/decisions.md) — settled cycle-level decisions and carve-outs (for example, admin bypass is `pull_request` not `always`). +- [`references/skipped-tools.md`](references/skipped-tools.md) — tools that were *measured* and skipped (harden-runner, actionlint, pydoclint, prek, shellcheck), with the basis. **Do not re-recommend these without new evidence.** +- [`references/open-investigations.md`](references/open-investigations.md) — items waiting on external triggers (`uv audit` stable, GitHub native L7 firewall GA, OpenSSF Scorecard rollout gated on operator). + +### 4. Apply mechanical fixes + +For each gap, decide: + +- **Mechanical** (file missing, standard template applies): run the matching script in [`scripts/fixes/`](scripts/fixes/), for example, `scripts/fixes/add-security-md.py`. These scripts copy from [`assets/`](assets/) templates and stage the change for review. +- **Judgement-required** (SECURITY.md customisation beyond the template, threat model authoring, SEC0045 event scoping, security documentation extension): produce a draft for human review; do not commit autonomously. + +Never apply a fix the user did not ask for. The skill's job is to surface gaps, explain them, and offer remediation — not to mutate a repository unprompted. + +## Check coverage + +The skill currently ships these checks. New checks land in [`scripts/checks/`](scripts/checks/); each new entry must extend the JSON report schema additively (no breaking changes to existing fields). + +| Check ID | Tiers | Mandate | Notes | +|---|---|---|---| +| `security-md` | all | SEC0025 / SEC0026 + V2.0 cross-cutting | File presence + disclosure-policy link. | +| `dependabot` | all | SEC0025 | `.github/dependabot.{yml,yaml}` with ≥1 ecosystem **and** a cooldown of ≥7 days on each (Charm Tech baseline — charmlibs#499). Cooldown values validated via python3+PyYAML; falls back to a presence-only check when those are missing. | +| `code-of-conduct` | all | Convention | Ubuntu-CoC link-only form (not Contributor Covenant). | +| `contributing` | product, canonical | Convention | `CONTRIBUTING.md` *or* `HACKING.md` *or* `docs/contributing.md` accepted, AND a `# Pull requests` heading so the validate-pr-title.py "Read more" URL anchors. Template at [`assets/CONTRIBUTING.md.template`](assets/CONTRIBUTING.md.template) follows the dominant Charm Tech pattern (substantive standalone doc; no SECURITY/CoC cross-links — those live in their own files). | +| `agents-md` | all | Best-of-class | Minimal AGENTS.md (warns past 200 lines). | +| `pre-commit-config` | all | Convention | Flags `rev:` version pins (versions belong in `pyproject.toml`). | +| `gha-sha-pinning` | all | Astral best-of-class | All actions SHA-pinned; no exceptions allowed. | +| `yaml-extension` | all | Convention | YAML files under `.github/` must use `.yaml`, not `.yml`. Mechanical fix at [`scripts/fixes/rename-yml-to-yaml.py`](scripts/fixes/rename-yml-to-yaml.py) uses `git mv`; a manual sweep is still needed for `workflow_call uses:` paths, README links, and downstream action consumers. | +| `workflow-secrets` | all | Canonical Security "Repository security" — Secrets | Scans `.github/workflows/*.y*ml` for four leakage patterns: (1) workflow-level `env:` referencing `${{ secrets.* }}`; (2) job-level `env:` referencing `${{ secrets.* }}` (both over-scope the secret beyond the step that needs it); (3) `run: echo`/`printf`/`cat` interpolating a secret expression (log-masking not guaranteed for every transformation); (4) `secrets: inherit` on reusable-workflow calls. Env-scope checks need python3+PyYAML; textual checks (echo/inherit) run either way. Personal-tier: advisory. | +| `uv-exclude-newer` | all | Canonical Security "How-To: Secure a repo" — Minimum release age | Requires `[tool.uv].exclude-newer` in `pyproject.toml` set to a rolling ≥7-day quarantine (friendly duration like `"7 days"` / `"1 week"`, or ISO 8601 like `"P7D"`). Complements Dependabot cooldown by covering every OTHER uv resolution path — manual `uv add`, `uv lock` regens, uvx bootstraps, CI re-resolves — that Dependabot cooldown alone doesn't reach. Accepts RFC 3339 timestamps (absolute snapshot) with a note recommending rolling durations instead. Uses python3+tomllib (stdlib 3.11+) for validation; falls back to a text-only presence check when unavailable. `na` on non-uv projects (no `pyproject.toml`); `fail` when `uv.lock` exists but `[tool.uv]` doesn't (this IS a uv project that just needs the section added). | +| `dependency-review` | product, canonical | Cycle sweep | `actions/dependency-review-action` wired. | +| `attest-build-provenance` | product, canonical | SEC0023 best-of-class | Required when a publish/release workflow exists. | +| `attest-sbom-deprecated` | all | Upstream deprecation | Flags `uses: actions/attest-sbom@…` in any workflow. The action is deprecated; swap in `actions/attest` (same `subject-path` / `sbom-path` inputs, same SBOM predicate). Ref: [actions/attest-sbom](https://github.com/actions/attest-sbom) deprecation notice. | +| `openssf-scorecard` | product, canonical | Best-of-class (gated on operator) | Workflow + README badge. | +| `conventional-commits` | product, canonical | Convention | PR-title validation workflow. Fix installs operator-style `validate-pr-title.yaml` + `check-conventional-pr-title.py` from [`assets/`](assets/) and rewrites the help URL to this repo. | +| `immutable-releases` | product, canonical | GitHub-side toggle | Inspects latest release via `gh api`. | +| `trusted-publishing` | all | Astral best-of-class + cycle baseline | Workflows publishing to PyPI use Trusted Publishing (`pypa/gh-action-pypi-publish` with `id-token: write`, no `password`/`username`). Flags `twine upload` and any token-input use. `na` when no PyPI publish workflow is present. Templates: **personal/canonical** → [`assets/trusted-publishing.yaml.template`](assets/trusted-publishing.yaml.template) (inline CycloneDX SBOM + `attest-build-provenance` + `attest`); **product** → [`assets/trusted-publishing-product.yaml.template`](assets/trusted-publishing-product.yaml.template) + [`assets/sbom-secscan.yaml.template`](assets/sbom-secscan.yaml.template) + [`assets/sbomber-manifest-sdist.yaml.template`](assets/sbomber-manifest-sdist.yaml.template) + [`assets/sbomber-manifest-wheel.yaml.template`](assets/sbomber-manifest-wheel.yaml.template). Environment name is `publish-pypi`. **When applying any of these templates the agent must (a) replace every `REPLACE_WITH_*` marker, (b) modernise each SHA-pinned action to the latest release — look up the current commit SHA on GitHub and update the `# vX.Y.Z` comment — and (c) verify the result passes zizmor with no findings before opening the PR.** | +| `repo-settings` | all | Cycle baseline | Either declared in `canonical-repo-automation` (CRA) or live settings match the baseline (squash-only merges, delete-branch-on-merge, secret scanning + push protection, Dependabot security updates, private vulnerability reporting, selected-actions allowlist). Drift on a CRA-enrolled repo is `judgement` (run CRA apply); a non-enrolled canonical-owned repo gets a `judgement` remediation pointing at CRA enrolment; personal-tier gets the mechanical `apply-repo-settings.py` fix. | +| `secscan-workflow` | product | SEC0025 | Two flavours accepted: sbomber-driven (workflow invokes `canonical/sbomber` + `.sbomber-manifest*.yaml` with `clients.secscan` and per-artifact `ssdlc_params`), or direct `canonical-secscan-client` with `--ssdlc-product-name` / `--ssdlc-cycle` CLI params. | +| `sbom-workflow` | product | SEC0027 | `sbom-request` workflow or `.sbomber-manifest-*.yaml`. Workflows must trigger on `release` / `schedule` / `push: tags:` (per-cycle cadence); `workflow_dispatch`-only fails. | +| `tiobe-config` | product | SEC0024 | TIOBE TICS workflow present, references `secrets.TICSAUTHTOKEN`, and language linters declared (Python: `flake8` + `pylint`; Go: `staticcheck`). | +| `tqi-security-target` | product | SEC0024 | Informational — target lives in *TiCS Targets* spreadsheet. | +| `sec0030-coverage` | product | SEC0030 V1.3 | Looks for seven required sections in `docs/explanation/security.md` or `SECURITY.md`. | +| `sec0045-events` | product | SEC0045 | Per-product disposition; greps for OWASP Application Logging Vocabulary event-name tokens (`authn_*`, `authz_*`, `sys_*`, `user_*`, `session_*`, `excessive_use`, `malicious_*`, `input_validation_*`) as well as the broader `OWASP`/`securitylog` name references. Pass signal recorded in evidence (`event-name-tokens` vs `name-reference-only`). | +| `threat-model-drive` | product | SEC0028 | Informational — model lives in SSDLC Artifacts Drive. | +| `vulnerability-response-plan` | product, canonical | SEC0026 | Informational — plan lives in SSDLC Artifacts Drive. | + +Checks that emit `unknown` status are informational and require the agent to verify the off-repo source (Drive sheet, *TiCS Targets* spreadsheet, etc.). The agent should not silently treat `unknown` as `pass`. + +## Report shape + +The umbrella check emits the following JSON shape. Agents should rely on this contract; new checks must extend the schema additively. + +```json +{ + "schema_version": 1, + "repo": "git@github.com:canonical/example", + "tier": "canonical", + "tier_source": "detected|override", + "generated_at": "2026-06-27T20:00:00Z", + "checks": [ + { + "id": "security-md", + "status": "pass|fail|na|unknown", + "tier_applies": ["product", "canonical", "personal"], + "summary": "SECURITY.md is present and links to the Ubuntu disclosure policy.", + "evidence": {"path": "SECURITY.md", "lines": 41}, + "remediation": null + }, + { + "id": "dependabot", + "status": "fail", + "tier_applies": ["product", "canonical"], + "summary": "No .github/dependabot.yaml found.", + "evidence": {}, + "remediation": { + "kind": "mechanical", + "script": "scripts/fixes/add-dependabot.py", + "human_review": "Confirm the ecosystem set matches the repo (Python? Go? Actions?)." + } + } + ], + "notes": [ + "secscan check (external API) returned exit 3; re-run with network access." + ] +} +``` + +Field rules: + +- `status` is one of the four strings above; no other values allowed. +- `tier_applies` lists the tiers the check is defined for. A check that is `na` for the current tier still appears in the report so the agent can confirm coverage; the agent omits these from the user-facing summary unless asked. +- `remediation.kind` is `mechanical` (script can be invoked unattended after human review) or `judgement` (agent must draft, not invoke). + +## What this skill is *not* + +- **Not a replacement for the SSDLC framework.** The framework lives in Canonical's GRC / OCISO documents; this skill summarises the parts a per-repo audit needs. Authoritative decisions belong with GRC. +- **Not a tool installer.** Checks assume the repo's tool config exists (zizmor, ruff, pre-commit, etc.); they look at config files, not binaries. Tool *adoption* is per-repo work, not this skill's job. +- **Not a one-size-fits-all CI policy.** Tier decides which checks apply; the agent must respect the tier and not push product-tier obligations onto personal repos. +- **Not a substitute for `skill-scanner`.** Run `skill-scanner` over this skill itself before each change to confirm hygiene; do not hand-edit findings out. diff --git a/skills/engineering/charm-tech-baseline/assets/AGENTS.md.template b/skills/engineering/charm-tech-baseline/assets/AGENTS.md.template new file mode 100644 index 0000000..2615629 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/AGENTS.md.template @@ -0,0 +1,40 @@ +# AGENTS.md + + + +## What this repo is + +{{REPO_DESCRIPTION_ONE_SENTENCE}} + +## Dev setup + +```bash +{{SETUP_COMMANDS}} +``` + +## Tests + +```bash +{{TEST_COMMANDS}} +``` + +## Lint + +```bash +{{LINT_COMMANDS}} +``` + +## Conventions + +- Commits follow [Conventional Commits](https://www.conventionalcommits.org/). +- PRs are reviewed before merge; CI must pass. +- For deeper guidance see [{{DEPTH_LINK_TITLE}}]({{DEPTH_LINK}}). + +## Security + +See [SECURITY.md](SECURITY.md). diff --git a/skills/engineering/charm-tech-baseline/assets/CODE_OF_CONDUCT.md b/skills/engineering/charm-tech-baseline/assets/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0345021 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +This project follows the [Ubuntu Code of Conduct](https://ubuntu.com/community/ethos/code-of-conduct). + +Concerns and reports go to the [Ubuntu Community Council](https://wiki.ubuntu.com/CommunityCouncil), which administers the CoC's reporting and enforcement process. See the [Ubuntu Code of Conduct](https://ubuntu.com/community/ethos/code-of-conduct) for details. diff --git a/skills/engineering/charm-tech-baseline/assets/CONTRIBUTING.md.template b/skills/engineering/charm-tech-baseline/assets/CONTRIBUTING.md.template new file mode 100644 index 0000000..0474f8c --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/CONTRIBUTING.md.template @@ -0,0 +1,66 @@ +We welcome contributions to this project! + +Before working on changes, please consider [opening an issue](https://github.com/REPLACE_WITH_OWNER/REPLACE_WITH_REPO/issues) explaining your use case. If you would like to chat with us about your use cases or proposed implementation, you can reach us on [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com) or [Discourse](https://discourse.charmhub.io/). + + + +# AI + +You're welcome to submit pull requests that are partly or entirely generated using generative AI tools. However, you must review the code yourself before moving the PR out of draft -- by submitting the PR, you are claiming personal responsibility for its quality and suitability. If you are not capable of reviewing the PR, please do not submit it (maybe you'd like to open an issue instead). PRs that are clearly (co-)authored by tools will be closed without review unless there is a human author that claims responsibility for the PR. + +Please do not use tools (such as GitHub Copilot) to provide PR reviews. The Charm Tech team also has access to these tools, and will use them when appropriate. + +# Pull requests + +Changes are proposed as [pull requests on GitHub](https://github.com/REPLACE_WITH_OWNER/REPLACE_WITH_REPO/pulls). + +- Work on a branch in your own fork. +- Sequence your commits logically if possible. But don't worry too much -- we'll squash to `main` after review. +- Don't force-push after review has started. +- Follow [conventional commit style](https://www.conventionalcommits.org/en/) for the PR title (not required for individual commits). + +The allowed PR-title types — enforced by `.github/workflows/validate-pr-title.yaml` — are: + +`chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `test` + +Examples: + +- feat: add support for X +- fix!: correct the type hinting for config data +- docs: clarify how to use Y +- ci: tighten the publish workflow + +We consider this project too small to use scopes, so we don't use them. + +## Branch updates + +Before you ask for review, please rebase your branch onto `main` so that your changes will merge cleanly. + +If you need to bring in the latest changes from `main` after the review has started, please use a merge commit. + +# Releasing + + + diff --git a/skills/engineering/charm-tech-baseline/assets/SECURITY.md.template b/skills/engineering/charm-tech-baseline/assets/SECURITY.md.template new file mode 100644 index 0000000..6674b9c --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/SECURITY.md.template @@ -0,0 +1,40 @@ +# Security policy + +## Supported versions + + + +## Reporting a vulnerability + +Please provide a description of the issue, the steps you took to +create the issue, affected versions, and, if known, mitigations for +the issue. + +The easiest way to report a security issue is through [GitHub's +security advisories for this project](https://github.com/{{REPO}}/security/advisories/new). +See [Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) +for instructions on using the feature. + +You may also send email to {{CONTACT}}. If you want to encrypt your +email, follow [Canonical's reporting instructions](https://ubuntu.com/security/disclosure-policy#contact-us). + +If you have a deadline for public disclosure, please let us know. Our +vulnerability management team intends to respond within 3 working days +of your report. This project aims to resolve all vulnerabilities +within 90 days. + +The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy) +contains more information about what you can expect when you contact +us, and what we expect from you. + +To stay informed about vulnerabilities, watch: + +- The [GitHub Security Advisories for `{{REPO}}`](https://github.com/{{REPO}}/security/advisories). +- The project's release history. +- Relevant [Ubuntu Security Notices](https://ubuntu.com/security/notices) when a vulnerability + also affects an Ubuntu-packaged component. diff --git a/skills/engineering/charm-tech-baseline/assets/check-conventional-pr-title.py.template b/skills/engineering/charm-tech-baseline/assets/check-conventional-pr-title.py.template new file mode 100644 index 0000000..8efc09a --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/check-conventional-pr-title.py.template @@ -0,0 +1,87 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Check that a PR title follows the Conventional Commits specification. + +Reads the PR title from the PR_TITLE environment variable. +Exits with a non-zero status and prints an error message if the title is invalid. + +Reference: https://www.conventionalcommits.org/en/v1.0.0/ + +This repo defines a restricted set of commit types and disallows scopes in PR titles. +""" + +from __future__ import annotations + +import os +import re +import sys + +_TYPES = frozenset({ + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'test', +}) + +# [optional scope][optional !]: +_PATTERN = re.compile( + r'^(?P[A-Za-z]+)' # lower-case only, but let this be validated by _TYPES + r'(?:\((?P[^()]+)\))?' + r'(?P!)?' + r': ' + r'(?P.+)$' +) + +# Adjust this URL when copying into a new repo — point at /CONTRIBUTING.md#pull-requests. +_HELP_URL = 'https://github.com/REPLACE_WITH_OWNER/REPLACE_WITH_REPO/blob/main/CONTRIBUTING.md#pull-requests' + + +def _main() -> None: + title = os.environ.get('PR_TITLE', '').strip() + if not title: + print('PR_TITLE environment variable is not set or empty.', file=sys.stderr) + sys.exit(1) + + match = _PATTERN.match(title) + if not match: + print( + f'PR title does not follow Conventional Commits format.\n' + f'Expected: [!]: \n' + f'Got: {title!r}\n' + f'Read more: {_HELP_URL}', + file=sys.stderr, + ) + sys.exit(1) + + scope = match.group('scope') + if scope is not None: + print( + f'Scopes must not be used in PR titles.\n' + f'Got: {title!r}\n' + f'Read more: {_HELP_URL}', + file=sys.stderr, + ) + sys.exit(1) + + commit_type = match.group('type') + if commit_type not in _TYPES: + print( + f'Invalid type {commit_type!r} in PR title.\n' + f'Valid types: {", ".join(sorted(_TYPES))}\n' + f'Got: {title!r}\n' + f'Read more: {_HELP_URL}', + file=sys.stderr, + ) + sys.exit(1) + + print(f'OK: {title!r}') + + +if __name__ == '__main__': + _main() diff --git a/skills/engineering/charm-tech-baseline/assets/dependabot.yaml.template b/skills/engineering/charm-tech-baseline/assets/dependabot.yaml.template new file mode 100644 index 0000000..340337d --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/dependabot.yaml.template @@ -0,0 +1,100 @@ +# Routine version-update sweeps only. CVE patches are raised by the +# repo-level "Dependabot security updates" toggle (managed in +# canonical-repo-automation: features.dependabot_security_updates = true), +# which is event-driven and does not honour the schedule below. +# +# Canonical shape per OP0xx (Dependabot config conventions for Charm Tech +# repos). Customise the ecosystem set to match this repo: keep github-actions, +# then pick ONE of the uv / gomod / pip blocks below. Delta from the canonical +# shape (per-repo `groups`, extra directories, etc.) belongs in the spec. +version: 2 + +updates: + # GitHub Actions: routine lane (monthly, single grouped PR) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + open-pull-requests-limit: 100 + commit-message: + prefix: "chore" + cooldown: + default-days: 7 + groups: + actions: + patterns: + - "*" + + # Python (uv): routine lane (monthly, grouped along three seams). + # For a pip repo, swap `package-ecosystem: "uv"` → `"pip"`. For a Go + # repo, delete this whole block and uncomment the gomod block below. + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + open-pull-requests-limit: 100 + commit-message: + prefix: "chore" + cooldown: + default-days: 7 + semver-major-days: 14 + groups: + # Charm Tech's own releases (we trust the release). + # Prune to whatever this repo actually depends on. + charm-tech: + patterns: + - "ops" + - "ops-scenario" + - "ops-tracing" + - "jubilant" + - "pytest-jubilant" + # Linters / type-checkers / formatters. Majors ride along; we do not + # pin these and a major bump is low-risk to review in a batch. + dev-tooling: + patterns: + - "ruff" + - "pyright" + - "ty" + - "codespell" + - "coverage" + - "pre-commit" + - "types-*" + # Test runner + other shared test deps. + test-deps: + patterns: + - "pytest" + - "pytest-*" + # Everything else, minor + patch only. A runtime MAJOR falls through + # to its own ungrouped PR so it never silently rides a patch bundle. + runtime: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Go modules: routine lane (monthly, single grouped PR; a runtime + # major falls through to its own ungrouped PR). Uncomment for Go repos. + # - package-ecosystem: "gomod" + # directory: "/" + # schedule: + # interval: "monthly" + # labels: + # - "dependencies" + # open-pull-requests-limit: 100 + # commit-message: + # prefix: "chore" + # cooldown: + # default-days: 7 + # semver-major-days: 14 + # groups: + # gomod: + # patterns: + # - "*" + # update-types: + # - "minor" + # - "patch" diff --git a/skills/engineering/charm-tech-baseline/assets/sbom-secscan.yaml.template b/skills/engineering/charm-tech-baseline/assets/sbom-secscan.yaml.template new file mode 100644 index 0000000..c213a7e --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/sbom-secscan.yaml.template @@ -0,0 +1,86 @@ +# Reusable workflow: generate SBOMs and run secscan via canonical/sbomber. +# Tier: product. Called from the Publish workflow (see trusted-publishing- +# product.yml.template) after the release is built. +# +# Matrix: one row per artefact type (sdist, wheel). Each row reads a +# corresponding .sbomber-manifest-.yaml at the repo root, prepared +# from sbomber-manifest-{sdist,wheel}.yaml.template. +# +# Runs on Canonical self-hosted runners because sbomber submits to an +# internal service (sbom-request.canonical.com) not reachable from public +# GitHub-hosted runners. +# +# Before committing: modernise every SHA-pinned action below. For each `uses:` +# line, look up the latest release on GitHub, replace the SHA with the current +# commit SHA of that release, and update the trailing `# vX.Y.Z` version +# comment. The pins in this template are a snapshot and drift over time. The +# result must pass zizmor with no findings. + +name: SBOM and secscan + +on: + workflow_call: + workflow_dispatch: + +permissions: {} + +jobs: + scan: + strategy: + fail-fast: false + matrix: + manifest: [sdist, wheel] + name: SBOM generation + runs-on: [self-hosted, self-hosted-linux-amd64-jammy-private-endpoint-medium] + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Install apt build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libapt-pkg-dev python3-apt + + - name: Checkout sbomber + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + repository: canonical/sbomber + path: scanner + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: false + + - name: Install secscan client + run: | + sudo snap install canonical-secscan-client + sudo snap connect canonical-secscan-client:home system:home + + - name: Prepare artefacts + run: | + cd scanner + ./sbomber prepare ../.sbomber-manifest-${{ matrix.manifest }}.yaml + + - name: Submit artefacts + run: | + cd scanner + ./sbomber submit + + - name: Wait for scans + run: | + cd scanner + ./sbomber poll --wait --timeout 30 + + - name: Download reports + run: cd scanner && ./sbomber download + + - name: Upload reports + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: secscan-report-upload-${{ matrix.manifest }} + path: ./scanner/reports/ + if-no-files-found: error diff --git a/skills/engineering/charm-tech-baseline/assets/sbomber-manifest-sdist.yaml.template b/skills/engineering/charm-tech-baseline/assets/sbomber-manifest-sdist.yaml.template new file mode 100644 index 0000000..2708da5 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/sbomber-manifest-sdist.yaml.template @@ -0,0 +1,24 @@ +# sbomber manifest — sdist artefacts. Committed as .sbomber-manifest-sdist.yaml +# at the repo root. Consumed by sbom-secscan.yaml. +# +# Fill in before use: +# - clients.sbom.email — owner-of-record; a real @canonical.com address. +# - artifacts[] — one entry per sdist the release produces. +# `name` matches the sdist tarball's project name (as it appears in dist/). + +clients: + sbom: + service_url: https://sbom-request.canonical.com + department: charm_engineering + email: REPLACE_WITH_OWNER_EMAIL@canonical.com + team: charm_tech + secscan: {} + +artifacts: + - name: 'REPLACE_WITH_PROJECT_NAME' + type: 'sdist' + compression: 'gz' + ssdlc_params: + name: 'REPLACE_WITH_PROJECT_NAME' + version: '' + channel: 'stable' diff --git a/skills/engineering/charm-tech-baseline/assets/sbomber-manifest-wheel.yaml.template b/skills/engineering/charm-tech-baseline/assets/sbomber-manifest-wheel.yaml.template new file mode 100644 index 0000000..0a18131 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/sbomber-manifest-wheel.yaml.template @@ -0,0 +1,23 @@ +# sbomber manifest — wheel artefacts. Committed as .sbomber-manifest-wheel.yaml +# at the repo root. Consumed by sbom-secscan.yaml. +# +# Fill in before use: +# - clients.sbom.email — owner-of-record; a real @canonical.com address. +# - artifacts[] — one entry per wheel the release produces. +# `name` matches the wheel's project name (as it appears in dist/). + +clients: + sbom: + service_url: https://sbom-request.canonical.com + department: charm_engineering + email: REPLACE_WITH_OWNER_EMAIL@canonical.com + team: charm_tech + secscan: {} + +artifacts: + - name: 'REPLACE_WITH_PROJECT_NAME' + type: 'wheel' + ssdlc_params: + name: 'REPLACE_WITH_PROJECT_NAME' + version: '' + channel: 'stable' diff --git a/skills/engineering/charm-tech-baseline/assets/trusted-publishing-product.yaml.template b/skills/engineering/charm-tech-baseline/assets/trusted-publishing-product.yaml.template new file mode 100644 index 0000000..4fe1577 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/trusted-publishing-product.yaml.template @@ -0,0 +1,71 @@ +# Canonical shape for a Trusted-Publishing-based PyPI release workflow. +# Tier: product (SBOM handled by canonical/sbomber via a reusable secscan job). +# For tier=personal|canonical, use trusted-publishing.yaml.template instead. +# +# Fill in before use: +# - REPLACE_WITH_PROJECT_NAME (PyPI project slug, appears in environment.url) +# - Tag pattern under `on.push.tags` +# +# Before committing: modernise every SHA-pinned action below. For each `uses:` +# line, look up the latest release on GitHub, replace the SHA with the current +# commit SHA of that release, and update the trailing `# vX.Y.Z` version +# comment. The pins in this template are a snapshot and drift over time. The +# result must pass zizmor with no findings. +# +# This template pairs with: +# - .github/workflows/sbom-secscan.yaml (from sbom-secscan.yaml.template) +# - .sbomber-manifest-sdist.yaml (from sbomber-manifest-sdist.yaml.template) +# - .sbomber-manifest-wheel.yaml (from sbomber-manifest-wheel.yaml.template) +# +# Anti-patterns this template avoids (and zizmor enforces): +# - No password:/username: on pypa/gh-action-pypi-publish (Trusted Publishing). +# - No twine upload step. +# - Every third-party action is SHA-pinned with a version comment. +# - id-token: write + attestations: write scoped to the publish job only. +# - persist-credentials: false on every checkout. +# - enable-cache: false on setup-uv. + +name: Publish + +on: + push: + tags: ['v*'] + +permissions: {} + +jobs: + publish: + name: Build and publish to PyPI (Trusted Publishing) + runs-on: ubuntu-latest + environment: + name: publish-pypi + url: https://pypi.org/p/REPLACE_WITH_PROJECT_NAME + permissions: + id-token: write # OIDC to PyPI + sigstore for attestations. + attestations: write # Write build-provenance predicate. + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: false + + - name: Build sdist and wheel + run: uv build + + - name: Attest build provenance (SLSA) + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: 'dist/*' + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + + secscan: + # SBOM + Canonical secscan (product-tier requirement). + # The reusable workflow generates SBOMs via canonical/sbomber for each + # artefact type in .sbomber-manifest-*.yaml. + uses: ./.github/workflows/sbom-secscan.yaml diff --git a/skills/engineering/charm-tech-baseline/assets/trusted-publishing.yaml.template b/skills/engineering/charm-tech-baseline/assets/trusted-publishing.yaml.template new file mode 100644 index 0000000..e15b447 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/trusted-publishing.yaml.template @@ -0,0 +1,88 @@ +# Canonical shape for a Trusted-Publishing-based PyPI release workflow. +# Tier: personal, canonical (inline CycloneDX SBOM + dual attestation). +# For tier=product, use trusted-publishing-product.yaml.template instead. +# +# Fill in before use: +# - REPLACE_WITH_PROJECT_NAME (PyPI project slug, appears in environment.url) +# - Tag pattern under `on.push.tags` (default: v*; operator uses [1-3].*) +# +# Before committing: modernise every SHA-pinned action below. For each `uses:` +# line, look up the latest release on GitHub, replace the SHA with the current +# commit SHA of that release, and update the trailing `# vX.Y.Z` version +# comment. The pins in this template are a snapshot and drift over time. The +# result must pass zizmor (which enforces SHA pinning, permissions:{}, +# persist-credentials:false, and the rest of the anti-patterns listed below). +# +# Anti-patterns this template avoids (and zizmor enforces): +# - No password:/username: on pypa/gh-action-pypi-publish (Trusted Publishing). +# - No twine upload step. +# - No floating action tags — every third-party action is SHA-pinned with a +# version comment. +# - id-token: write + attestations: write scoped to this job only. +# - persist-credentials: false on every checkout. +# - enable-cache: false on setup-uv (avoids cache-poisoning of the release). +# +# Two separate attestations are produced: SLSA provenance (how it was built) +# via attest-build-provenance, and a CycloneDX SBOM predicate (what's inside) +# via attest. attest-build-provenance does not accept sbom-path, so the +# SBOM predicate needs its own call. (attest-sbom is deprecated; actions/attest +# is the direct replacement and accepts the same subject-path/sbom-path inputs.) + +name: Publish + +on: + push: + tags: ['v*'] + +permissions: {} + +jobs: + publish: + name: Build and publish to PyPI (Trusted Publishing) + runs-on: ubuntu-latest + environment: + name: publish-pypi + url: https://pypi.org/p/REPLACE_WITH_PROJECT_NAME + permissions: + id-token: write # OIDC to PyPI + sigstore for attestations. + attestations: write # Write build-provenance + SBOM predicates. + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: false + + - name: Build sdist and wheel + run: uv build + + - name: Generate CycloneDX SBOM + run: | + uv sync --frozen --no-dev + uv run --with cyclonedx-bom cyclonedx-py environment .venv \ + --output-format JSON \ + --output-file sbom.cdx.json + + - name: Attest build provenance (SLSA) + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: 'dist/*' + + - name: Attest SBOM (CycloneDX) + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: 'dist/*' + sbom-path: sbom.cdx.json + + - name: Upload SBOM artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: sbom + path: sbom.cdx.json + if-no-files-found: error + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/skills/engineering/charm-tech-baseline/assets/validate-pr-title.yaml.template b/skills/engineering/charm-tech-baseline/assets/validate-pr-title.yaml.template new file mode 100644 index 0000000..ec634f0 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/assets/validate-pr-title.yaml.template @@ -0,0 +1,21 @@ +--- +name: "Validate PR Title" +# Ensure that the PR title conforms to the Conventional Commits and our choice of types and scopes, so that library version bumps can be detected automatically + +on: + pull_request: + types: [opened, edited, synchronize] + +permissions: {} + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + - run: python3 .github/check-conventional-pr-title.py + env: + PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/skills/engineering/charm-tech-baseline/references/decisions.md b/skills/engineering/charm-tech-baseline/references/decisions.md new file mode 100644 index 0000000..9baaa4a --- /dev/null +++ b/skills/engineering/charm-tech-baseline/references/decisions.md @@ -0,0 +1,237 @@ +# Settled decisions and carve-outs + +These are the **settled** decisions from the 26.10 cycle. Do not +reopen without new evidence; do not flag them as "gaps" in an audit +report. + +## Admin bypass — `pull_request`, with an emergency escape hatch + +Charm Tech ruleset `bypass_actors` = **`Admins` / `pull_request`** (not +`Admins` / `always`, not full no-bypass). Admins still go through a PR +— so reviews and required checks remain visible — rather than pushing +straight to a protected branch. The bypass is deliberately retained as +an emergency escape hatch (broken CI blocking an urgent fix, incident +response). + +A repo configured with full no-bypass is **not** a gap — it is a +stricter policy. A repo configured with `Admins` / `always` **is** a +gap — flag it. + +## SHA-pinning — every third-party action, no exceptions + +Every Charm Tech repo SHA-pins **every** third-party GitHub Action. +`actions/*`, `github/*`, `pypa/*`, `canonical/*` — all pin to a commit +SHA, same as any other third-party action. The prior ref-pin carve-out +for GitHub-owned and PyPA-owned actions has been retired: the `pypa/*` +open investigation surfaced `release/v1` as a moving *branch* (not a +tag), and rather than special-case one org and leave the others on a +weaker rationale, the whole allowlist is dropped. Uniform SHA-pinning is +the policy. + +The standard form is `actions/checkout@<40-char-sha>. # v4.x.y` — SHA +first, human-readable tag in a trailing comment so Dependabot can bump +both together. + +A `.github/zizmor.yaml` config file is **no longer required**. zizmor's +default `unpinned-uses` rule already enforces SHA-pinning across the +board; the previous config existed only to carry the now-retired +allowlist. Existing `.github/zizmor.yaml` files that only encode the old +allowlist should be deleted (the check no longer looks for a config +file, only for a workflow that invokes zizmor). + +A repo with `actions/checkout@v4`, `pypa/gh-action-pypi-publish@release/v1`, +or `canonical/foo@v2` **is** a gap. A repo with all `uses:` refs pinned +to 40-char SHAs is not. + +## Tool pinning — `pyproject.toml` is the source of truth + +All tool version pins belong in `[dependency-groups]` in +`pyproject.toml`, locked via `uv.lock`. This applies across every +invocation surface — pre-commit hooks (`language: system` calling the +tool from the lockfile), CI, local dev. **No tool versions in +`.pre-commit-config.yaml` `rev:` fields, no version pins in CI +workflows.** + +## pip-audit (non-product tier only) + Dependabot everywhere + +Two-tier: + +- **Product tier** (operator, jubilant, pytest-jubilant, charmlibs): + Dependabot only. Per-release secscan with `--ssdlc-*` covers the + SSDLC requirement. +- **Non-product tier** (hyrum, charmhub-listing-review, + api_demo_server): pip-audit in CI alongside Dependabot. + +Forward note: planned replacement of `pip-audit` with `uv audit` once +stable — see [`open-investigations.md`](open-investigations.md). + +## Linear history + +Required across Charm Tech repos via CRA. Squash + linear merge +strategy. + +## Required status checks pinned to the GitHub Actions app + +`integration_id = 15368` on every CRA ruleset for Charm Tech repos. +Without this, any app or PAT can satisfy a rule by posting a status of +the same name. Done for Charm Tech in +[canonical/canonical-repo-automation#873](https://github.com/canonical/canonical-repo-automation/pull/873) +(merged 2026-06-09). + +This applies to **Canonical-managed rulesets only**, which are +configured centrally in `canonical-repo-automation`, not per-repo. A +personal-tier audit must not flag this. + +## Immutable releases — on by default; blocked on tooling for some + +ON for: operator, jubilant, pytest-jubilant, charmhub-listing-review, +hyrum. + +OFF (legitimate blocker) for: concierge (goreleaser incompat — +[canonical/concierge#172](https://github.com/canonical/concierge/issues/172)), +pebble (snap build — +[canonical/pebble#856](https://github.com/canonical/pebble/issues/856)). + +OFF (action needed) for: api_demo_server (no releases yet; toggle when +first release approaches), charmlibs. + +A check for immutable releases must distinguish "blocked on known +tooling" from "needs toggling". + +## SEC0045 applicability — per-product + +Resolved 2026-06-09: + +- **Done**: ops (operator#1905), pebble (pebble#666) +- **In scope this cycle**: concierge (concierge#208). +- **Deferred**: charmlibs (future cycle). +- **Out of scope**: jubilant, pytest-jubilant (no user/admin/auth + surface), charm-ubuntu. + +A check should not flag "no SEC0045 events" on jubilant or +pytest-jubilant. + +## CODEOWNERS — only required when membership exceeds the Charm Tech team + +Resolved 2026-07-02. The Canonical Security "Repository security" and +"How-To: Secure a repo" pages recommend a `CODEOWNERS` file with +code-owner review required, at minimum for `.github/workflows/`. + +For a repo whose maintainer set is exactly the Charm Tech team, a +`CODEOWNERS` file adds no filtering signal over the existing +required-review rule (every PR would need review from the same people +who already review every PR). Overhead without benefit. + +**A `CODEOWNERS` file is only required for a charm-tech repo when its +maintainer/contributor set is broader than the Charm Tech team** — +external contributors, cross-team ownership, or per-directory owners +mapping to different sub-teams. In this cycle, that's **charmlibs only** +(has a substantive `CODEOWNERS` mapping to `@canonical/charmlibs-maintainers`). + +A repo without `CODEOWNERS` whose maintainer set is the Charm Tech team +is **not** a gap. A check should not flag it. A check *should* flag a +CODEOWNERS file that references only the Charm Tech team wholesale (no +benefit; adds review friction) — i.e. the anti-pattern is a file that +does nothing. + +## PR-review ergonomics — conversation resolution and dismiss-stale-on-push not required + +Resolved 2026-07-02. The Canonical Security "How-To: Secure a repo" +page recommends both `Require conversation resolution` and `dismiss +stale reviews on new commits` on protected branches. The team has +decided **against** requiring either at this time. + +**Conversation resolution required.** Rationale: the team already +treats unresolved threads as a review-blocker socially; making it a +merge gate adds friction (author must chase every threaded "nit" the +reviewer intended as advisory), and every pushed follow-up commit +would need a fresh comment-resolution round. Net-negative for +Charm-Tech-sized PRs where the reviewer set is small and consistent. + +**Dismiss stale reviews on new commits.** Rationale: most Charm Tech +PRs iterate quickly under reviewer feedback; auto-dismissing on every +push makes even trivial rebases / typo fixes re-trigger the full +review round. The `Require last push approval` flag (already +recommended in the CRA baseline) covers the substantive risk +(unreviewed content sneaking in after approval) without the churn of +full dismissal. + +Both to be re-evaluated in a future cycle (26.10+1), especially if the +team scales or if we see a real incident tied to their absence. + +A repo without `require_conversation_resolution` or without dismiss- +stale-on-push on its default-branch ruleset is **not** a gap in this +cycle. A check should not flag either. + +## Signed commits — not required this cycle + +Resolved 2026-07-02, after the Canonical Security "Repository security" +and "How-To: Secure a repo" pages (published Jul 01, 2026) recommended +`Require signed commits` on protected branches. + +The team has decided **against** requiring signed commits at this time. +Rationale: the onboarding cost across current + occasional contributors +outweighs the marginal supply-chain benefit while the org allowlist, +SHA-pinned actions, branch protection, PR review, and required checks +are already in place. To be re-evaluated in a future cycle (26.10+1), +particularly if Security makes it an org-level baseline. + +A repo without `require_signed_commits` on its default-branch ruleset +is **not** a gap in this cycle. A check should not flag it. + +## `uv.no-build` — deferred; not required this cycle + +Resolved 2026-07-03, after the first cross-repo rollout of the uv +hardening pattern surfaced a fundamental gap in uv itself. + +A global `[tool.uv].no-build = true` refuses to build **any** source +distribution — including the workspace project's own editable install +during `uv sync` / `uv run`. There is no allow-list or workspace-exempt +flag in uv today. The only workaround is the deny-list form +`no-build-package = []`, which is +unmaintainable at scale and silently regresses the moment a new +sdist-only dep is added (the whole point of `no-build` was defence +against exactly that). + +The fleet-wide scan (2026-07-02) confirmed 0/571 dependencies across the +canonical/* uv projects are sdist-only, so `no-build` provides zero +current benefit. The remaining pattern (`exclude-newer = "7 days"`, +see the `--locked` decision below for why that's the only survivor) +already gives a week's warning on any new dep before it can enter the +resolution — the practical supply-chain protection is intact. + +Revisit when uv gains an allow-list, a workspace-exempt flag, or a +per-source override that keeps the workspace project buildable without +enumerating every dep. + +A repo without `[tool.uv].no-build` is **not** a gap in this cycle. A +check should not flag it. + +## `--locked` in CI — deferred; not required this cycle + +Resolved 2026-07-03, immediately after the `no-build` deferral above. + +The rollout paired `[tool.uv].exclude-newer = "7 days"` (rolling +supply-chain quarantine) with `uv run --locked` (lockfile freshness +enforcement). CI failed daily with: + + error: The lockfile at `uv.lock` needs to be updated, + but `--locked` was provided. + +`exclude-newer = "7 days"` is a rolling value uv resolves to `now() - +7 days` at each invocation and records as an absolute timestamp in +`uv.lock`. Every day the newly-computed floor differs from the one +stored in the lock, so `--locked` re-resolves, sees a config change, +and errors — regardless of whether any package actually shifted. + +The two settings are fundamentally incompatible. The supply-chain +protection from rolling `exclude-newer` outweighs the drift-detection +value of `--locked`, so `--locked` has to go. A future PR-time +lockfile freshness check (e.g. structural "if `pyproject.toml` +changed, `uv.lock` must also change") can restore some of the +protection without the daily false positive; that check is out of +scope for this cycle. + +A repo whose CI workflows don't pass `--locked` (or `--frozen`) to +`uv run` / `uv sync` is **not** a gap in this cycle. A check should +not flag it. diff --git a/skills/engineering/charm-tech-baseline/references/open-investigations.md b/skills/engineering/charm-tech-baseline/references/open-investigations.md new file mode 100644 index 0000000..05260ff --- /dev/null +++ b/skills/engineering/charm-tech-baseline/references/open-investigations.md @@ -0,0 +1,115 @@ +# Open investigations + +Items the 26.10 cycle could not close because they're waiting on +external triggers. When auditing, treat these as "watch but don't +flag as a gap" — the audit can note the status but should not push +the repo to adopt yet. + +## `uv audit` — wait for stable release + +**Status:** Preview as of 2026-06-27. uv 0.11.21 ships the subcommand +by default with an `--preview-features audit-command` opt-in flag to +silence the experimental warning. The roadmap tracker +[astral-sh/uv#18506](https://github.com/astral-sh/uv/issues/18506) +shows 1 / 61 sub-items complete; meaningful MVP work (SARIF output, +config-based ignores, fix mode, PEP 792 yanked-distribution flagging) +is still in flight. + +**Plan when stable:** + +- Replace `pip-audit` on the non-product tier (charmhub-listing-review, + api_demo_server, hyrum). +- Add `uv audit` to the product tier (operator, jubilant, + pytest-jubilant, charmlibs) as a belt-and-braces CI gate alongside + Dependabot. Does not change SSDLC compliance — Dependabot continuous + monitoring + per-release secscan still covers the requirement. +- Configuration: per-advisory ignores in `[tool.uv.audit] ignore = + [...]` in `pyproject.toml`. Job-level `UV_MALWARE_CHECK: "1"` for + install-time malware checks. + +**Baseline scan (2026-06-27, advisory only):** + +- jubilant — `cryptography 46.0.7` (GHSA-537c-gmf6-5ccf, bundled + OpenSSL, fixed 48.0.1) + `pytest 8.3.5` (GHSA-6w46-j5rx-g56g, + tmpdir, fixed 9.0.3). +- pytest-jubilant — `pytest 8.3.5`. +- charmlibs/nginx_k8s — `pytest 8.3.5`. +- charmlibs/rollingops — `cryptography 46.0.5` with 3 CVEs (bundled + OpenSSL, buffer overflow, DNS name constraint). Highest-priority of + the lot since `cryptography` is a runtime dep there. Single bump to + `>=48.0.1` clears all three. +- operator, charmhub-listing-review, api_demo_server, hyrum, other + charmlibs packages — clean. + +## OpenSSF Scorecard — operator in progress, rest gated + +**Status:** Adopt — operator in progress, current score **8.1/10**. +Other 9 repos gated behind operator's adoption so they can pick up +whatever score-improvement conventions land there first. + +**Decision basis:** OpenSSF Scorecard is an Astral best-of-class extra, +*not* a GRC/OCISO ask. + +**Plan:** wait for operator's adoption to settle (improvements on +branch-protection wiring, allowlisted-checks shape, badge rendering), +then sweep the workflow + consider a badge across the other 9 repos. + +## Go-module minimum-release-age — no native equivalent + +**Status:** Gap identified 2026-07-02 while rolling out +`exclude-newer = "7 days"` across the fleet's uv repos (see +[`decisions.md`](decisions.md) and the `uv-exclude-newer` check). + +The Python side is covered: `[tool.uv].exclude-newer = "7 days"` +gives every uv resolution path (manual `uv add`, `uv lock` regens, +uvx bootstraps, CI re-resolves) a rolling 7-day quarantine on fresh +releases, complementing the existing Dependabot cooldown. + +**Go has no native equivalent.** Go's module resolver offers: + +- `go.sum` + `-mod=readonly` (default) — analogous to `uv.lock` + + `--locked`. Prevents silent modification of go.mod/go.sum during + `go build`/`go test`. Already in place on pebble and concierge. +- Dependabot / Renovate `minimumReleaseAge` — works for the `gomod` + ecosystem, so the Dependabot-authored path is covered. + +But there is no `go.mod` directive, `GOFLAGS` value, or `go` env var +that says "refuse to consider modules published in the last N days" +during resolution. So the residual vector — a developer running +`go get -u @latest` or `go mod tidy` locally, pulling a fresh +release straight into go.sum before Dependabot could have cooled it +down — is unaddressed on pebble and concierge. + +**Options considered:** + +1. **Custom CI check** — script that reads go.sum, queries + `proxy.golang.org` for each module's `.info` (which has a `Time` + field), fails if any version is younger than 7 days. Bespoke; + ~30 lines of Bash/Go. Would sit alongside `govulncheck`. +2. **Private module proxy with a cooldown policy** — Athens (open + source) or JFrog Artifactory. Whole team would need to point + `GOPROXY` at it. Overkill for two Go repos. +3. **PR-diff inspection** — only cross-check go.sum entries added by + the PR. Cheaper variant of (1). +4. **Accept the residual risk** — pebble and concierge have small + dependency graphs, tight review cadence, and are already covered + by `govulncheck` + `dependency-review-action` (`fail-on-severity: + high`, fleet-wide). + +**Plan:** deferred. Watch `golang/go` issues for a native +`min-release-age` proposal. If the fleet's Go footprint grows or a +concrete incident traces to this vector, revisit and default to +option 3 (PR-diff inspection — smallest surface area, no proxy +infra). Not a `fail` in the audit report on Go repos today. + +A check should not flag pebble or concierge for missing a Go-side +release-age control in the 26.10 cycle. + +## `pypa/*` ref-pin posture — resolved + +**Resolved:** the team took option (a) — tighten to SHA-pin — and +extended it to the whole allowlist (`actions/*`, `github/*`, `pypa/*`, +`canonical/*`). Every third-party action pins to a SHA, no exceptions. +See [`decisions.md`](decisions.md). `.github/zizmor.yaml` config files +are no longer needed; the check ([`scripts/checks/gha-sha-pinning.py`](../scripts/checks/gha-sha-pinning.py)) +flags any non-SHA `uses:` ref. diff --git a/skills/engineering/charm-tech-baseline/references/skipped-tools.md b/skills/engineering/charm-tech-baseline/references/skipped-tools.md new file mode 100644 index 0000000..8223110 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/references/skipped-tools.md @@ -0,0 +1,136 @@ +# Tools we measured and skipped + +The 26.10 cycle piloted these tools against the actual Charm Tech +estate and chose not to adopt them. Each entry records the +measurement, the verdict, and what would have to change for the +verdict to flip. **Do not re-recommend any of these in an audit report +unless new evidence appears.** + +## `harden-runner` (StepSecurity) + +**Verdict:** Skip. **Date:** 2026-06-27. + +**Basis:** Charm Tech does not want to take a third-party action +dependency for runtime egress monitoring across every workflow, even +in audit-mode where the data stays on-runner. The action itself is a +supply-chain dependency for every workflow that uses it. + +**What would flip the verdict:** GitHub's native L7 egress firewall +hits GA; at that point use the native control instead. Adopting +harden-runner in the interim is not worth the migration. + +## `actionlint` + +**Verdict:** Skip. **Date:** 2026-06-27. + +**Basis:** Measured against the live estate (operator, pebble, +jubilant, pytest-jubilant, charmlibs, concierge, +charmhub-listing-review, api_demo_server, hyrum). 32 total findings +resolved to **zero real bugs**: + +- ~25 self-hosted runner-label warnings (silenceable via a one-time + `.github/actionlint.yaml` config). +- 5 `matrix.disabled` references in operator's + `observability-charm-tests.yaml` — intentional scaffolding (disable + hook left in place for future use). +- 1 `schedule.timezone:` on jubilant — GitHub supports it; actionlint's + schema is stale. + +Forward value is "catches future expression typos at PR time" — not +enough to anchor adding a third-party action across 10 repos when +nothing in the corpus is wrong. + +**What would flip the verdict:** actionlint releases address the +stale-schema gaps (timezone, others), AND a wave of new GHA syntax is +adopted that materially raises the typo-rate. + +## `pydoclint` + +**Verdict:** Skip. **Date:** 2026-06-27. + +**Basis:** Piloted on jubilant (65 findings) and operator (282 +findings) with the right config (`--style=google +--arg-type-hints-in-docstring=False --check-return-types=False`). +After triage of representative samples: + +- DOC103 / DOC101 sig-drift cluster: almost entirely varargs naming + pedantry (`*foo` in signature, `foo` in docstring without `*`) or + cases where the existing prose already conveys the arg purpose + clearly (`Framework.observe`, `Status.get_units`, `Container.exec` + uses a Sphinx cross-reference to `pebble.Client.exec`). +- DOC502 / DOC503 raises mismatches: transitive blindness (pydoclint + only sees direct `raise` statements; doesn't follow helpers). +- DOC606 inline-attribute findings: PEP 257 inline attribute + docstrings are intentionally the chosen style. + +After filtering, **0 real bugs** across both pilots. Adding pydoclint +as a gate would lock in style preferences (mandatory `Returns:` / +`Raises:` / `Args:` sections) that aren't currently the estate's +convention. + +**What would flip the verdict:** the team decides it *does* want +mandatory `Returns:` / `Raises:` / `Args:` sections everywhere as a +style policy — then pydoclint becomes a way to enforce that decision. + +## `prek` (Rust pre-commit replacement) + +**Verdict:** Skip. **Date:** 2026-06-27. + +**Basis:** The "10× faster" claim is on prek's *framework startup +overhead*, not on the hook work itself. Charm Tech pre-commit wall-clock +is dominated by ruff (already Rust), codespell, zizmor, pyright — so the +framework speedup doesn't show up where engineers would notice. The +"no Python install" benefit is hollow for a Python-heavy team where +every developer already has Python. Drop-in compatibility is +almost-but-not-quite — occasional divergence on `language: python` +hook installs. + +**Cost to switch:** CI workflow changes per repo, CONTRIBUTING / +dev-setup updates, onboarding ambiguity (two install paths to +document), cross-team friction. + +**What would flip the verdict:** pre-commit is somehow removed from +maintenance and prek becomes the de-facto path, OR prek extends past +1.0 with hardened compatibility guarantees AND a meaningfully +different feature set. + +## `shellcheck` + +**Verdict:** Skip. **Date:** 2026-06-27. + +**Basis:** Surveyed the entire estate — only 10 real `.sh` files total +(1 pebble, 7 charmlibs test helpers, 1 charmhub-listing-review, 1 +api_demo_server; ~300 lines combined). `shellcheck` reports **zero +findings** against all of them. + +**What would flip the verdict:** estate grows a non-trivial shell +codebase (anything more than mechanical test pack helpers). + +## `yamllint` / `hadolint` / `dprint` + +**Verdict:** Skip (carried over from the original survey — not +re-measured). + +**Basis:** No high-value target on this estate; ruff covers most of +the formatting role. + +## `hypothesis` property-based testing + +**Verdict:** Out of scope for repo-setup. **Date:** 2026-06-27. + +**Basis:** Adopting Hypothesis well requires per-test engineering +judgement; not a repo-setup sweep decision. + +## `pytest-xdist` + +**Verdict:** Skip estate-wide. Adopt only where the suite is already +long enough to dominate xdist's startup cost. **Date:** 2026-06-15. + +**Basis:** Measured wall-clock against jubilant (279 tests), +pytest-jubilant (27), hyrum (93), charmhub-listing-review (104). Every +suite went 2× to 13× **slower** under `-n auto` because per-worker +startup dominates. operator is currently the only Charm Tech Python +suite long enough to clear the threshold (already on `-n auto`). + +**What would flip the verdict:** a suite grows past ~3s of wall-clock +without xdist; then re-measure. diff --git a/skills/engineering/charm-tech-baseline/references/ssdlc-framework.md b/skills/engineering/charm-tech-baseline/references/ssdlc-framework.md new file mode 100644 index 0000000..82aeb27 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/references/ssdlc-framework.md @@ -0,0 +1,77 @@ +# SSDLC framework — summary for per-repo audits + +Source documents: Canonical Library. This file summarises the +parts a per-repo audit needs; it is not the authoritative copy. + +## SEC0023 applicability matrix + +Two dimensions select the requirement set: **product classification** +and **release type planned in the cycle**. + +### Product classifications observed on this estate + +| Class | Examples | Notes | +|---|---|---| +| Tools & frameworks | `operator`, `jubilant`, `pytest-jubilant`, `charmlibs`, `concierge`, `charmhub-listing-review`, `api_demo_server` | Major / Minor / Initial release types; **no LTS concept**; **no Penetration Testing or PSIRT Integration mandate**. | +| Component & Platform | `pebble` | Full LTS treatment; Penetration Testing Request + Security Documentation + PSIRT Integration apply on LTS. | +| Packaging (Opinionated) | `charm-ubuntu` | Requirements apply only to the packaging layer + opinionated decisions. Underlying packaged artefact follows its own class. | + +### Per-classification requirement scaling + +**Tools & frameworks:** + +- **Major** — SBOM, Vulnerability Management, Vulnerability Scanning, + Static Code Analysis, Threat Model, Security Event Logging, Security + Documentation. +- **Minor** — SBOM, Vulnerability Scanning only. +- **Initial** — as Major minus Security Documentation. + +**Component & Platform (pebble):** + +- **LTS** — full set including Penetration Testing Request, Security + Documentation, and PSIRT Integration. +- **Major** — SBOM, Vulnerability Management, Vulnerability Scanning, + SCA, Threat Model, Security Event Logging, Security Documentation. +- **Minor** — SBOM, Vulnerability Scanning. +- **Initial** — as Major minus Security Documentation. + +### Cross-cutting (apply regardless of class) + +- **`SECURITY.md`** in every Canonical repository (public or + private) — independent of release type. Use the SSDLC template; + adopt the [Ubuntu disclosure policy](https://ubuntu.com/security/disclosure-policy) + unless there is a clear reason not to. Reference: the LXD + `SECURITY.md`. +- **CI/CD integration of dependency monitoring** — Dependabot (or + Renovate) on every Canonical repo; downstream products / repos that + vendor or bundle code must additionally run software-composition + scanning on every PR/merge. +- **High/Critical (CVSS) and any CISA KEV-listed** vulnerabilities + must be remediated before cycle close, or a Risk Acceptance Form + filed with GRC/OCISO. + +## Per-requirement reference + +| Requirement | Doc | What an auditor checks | +|---|---|---| +| Static Code Analysis (SCA) | SEC0024 | TIOBE TICS configured (correct viewer config; `flake8`/`pylint` or `staticcheck` available); per-repo Security metric (TQI) target recorded in the *TiCS Targets* spreadsheet. | +| Vulnerability Discovery & Identification | SEC0025 | Dependabot config exists; for downstream/bundled products, a software-composition scanner (`OSV-Scanner`, `Trivy`) is wired into CI; secscan runs at least once per cycle with `--ssdlc-product-name`, `--ssdlc-cycle`, `--ssdlc-product-channel`, `--ssdlc-product-version`. | +| Vulnerability Response (Management) | SEC0026 | `SECURITY.md` present and adopting Ubuntu disclosure policy; SSDLC Vulnerability Response plan authored and reviewed within the past 6 months. | +| Vulnerability Response Standard | SEC0061 | "Know your upstream" data exists for any vendored/bundled component; SEC0061 Annex I upstream/downstream checklist walked. | +| Vulnerability Embargo Policy | — | Standing embargo subteam exists; embargo process documented (need-to-know, ≤90-day embargoes, no silent patching without GRC approval). | +| SBOM | SEC0027 | SBOM generated via `sbom-request.canonical.com`; review requested in `~SSDLC`; required at every release type. | +| Threat Modeling | SEC0028 | Threat model in the central SSDLC Artifacts Drive folder; refreshed every cycle; demonstrates no unacceptable residual risk; any accepted risk has a Risk Acceptance Form. | +| Penetration Testing | SEC0029 | Centrally budgeted by CISO Office; not mandated for tools & frameworks; pebble represented if it hits LTS-equivalent commitment. | +| Security Documentation | SEC0030 | All seven sections per V1.3: Product architecture, Secure by design, Cryptography (A overview, B internal, C exposed, D providing packages, E transit/at-rest), Hardening guidelines, Logging and monitoring, Secure decommissioning, Security lifecycle (EOL + maintained versions + delivery + verification). Plus Reporting a vulnerability. | +| Security Event Logging | SEC0045 | For products with an authn/user/admin surface, the 17 OWASP Application Logging Vocabulary events emitted as JSON (or logfmt), prefer OTLP. | +| PSIRT Coordination | SEC0037 / SEC0038 | Contact path to PSIRT documented (`security@ubuntu.com`, Launchpad private security bug, GitHub Security Advisory); CNA process understood; 24h notice before a security release. | + +## Where artifacts live + +- **Threat models** — central SSDLC Artifacts Drive (Charm SDK + consolidated sheet for ops/ops-scenario/ops-tracing/jubilant/concierge; + separate sheet for pebble). +- **SBOMs** — central SSDLC Artifacts directory, per-product folder. +- **Vulnerability tracker** — GRC *Vulnerability Tracker* template per + product. +- **TQI security targets** — *TiCS Targets 26.10* spreadsheet. diff --git a/skills/engineering/charm-tech-baseline/scripts/check.py b/skills/engineering/charm-tech-baseline/scripts/check.py new file mode 100755 index 0000000..f746c25 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/check.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Umbrella check runner. Dispatches every script in checks/ that +applies to the resolved tier and emits a single JSON report. + +Usage: + check.py [--tier=product|canonical|personal] + [--only=[,...]] + [--format=json|markdown] +""" + +from __future__ import annotations + +import datetime +import json +import re +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +sys.path.insert(0, str(SCRIPT_DIR)) +from lib.common import origin_url # noqa: E402 + + +def usage() -> None: + sys.stderr.write((__doc__ or "").strip() + "\n") + + +def main() -> int: + tier_override = "" + only_filter = "" + fmt = "json" + + for arg in sys.argv[1:]: + if arg.startswith("--tier="): + tier_override = arg[len("--tier="):] + elif arg.startswith("--only="): + only_filter = arg[len("--only="):] + elif arg.startswith("--format="): + fmt = arg[len("--format="):] + elif arg in ("-h", "--help"): + print((__doc__ or "").strip()) + return 0 + else: + print(f"Unknown argument: {arg}", file=sys.stderr) + return 2 + + if tier_override: + tier = tier_override + tier_source = "override" + else: + result = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "detect-tier.py")], + capture_output=True, text=True, + ) + tier = result.stdout.strip() + tier_source = "detected" + + if tier == "unknown": + print( + "Could not detect tier; pass --tier=product|canonical|personal", + file=sys.stderr, + ) + return 2 + + repo = origin_url() + generated_at = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + checks_dir = SCRIPT_DIR / "checks" + if only_filter: + selected = [] + for check_id in only_filter.split(","): + path = checks_dir / f"{check_id}.py" + if path.is_file(): + selected.append(path) + else: + print(f"Unknown check: {check_id}", file=sys.stderr) + return 2 + else: + selected = sorted(checks_dir.glob("*.py")) if checks_dir.is_dir() else [] + + results: list[dict] = [] + notes: list[str] = [] + for path in selected: + proc = subprocess.run( + [str(path), f"--tier={tier}"], + capture_output=True, text=True, + ) + out = proc.stdout.strip() + if not out: + notes.append( + f"check {path.name} produced no output (exit {proc.returncode})" + ) + continue + try: + results.append(json.loads(out)) + except json.JSONDecodeError: + notes.append(f"check {path.name} produced unparseable output") + + if fmt == "json": + report = { + "schema_version": 1, + "repo": repo, + "tier": tier, + "tier_source": tier_source, + "generated_at": generated_at, + "checks": results, + "notes": notes, + } + print(json.dumps(report)) + return 0 + + # Markdown summary path — human spot-checks; agents should prefer JSON. + print("# Repo-setup audit\n") + print(f"- Repo: `{repo}`") + print(f"- Tier: **{tier}** ({tier_source})") + print(f"- Generated: {generated_at}\n") + print("## Findings\n") + for r in results: + print(f"- **{r.get('status')}** (`{r.get('id')}`) — {r.get('summary')}") + if notes: + print("\n## Notes\n") + for n in notes: + print(f"- {n}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/agents-md-content.py b/skills/engineering/charm-tech-baseline/scripts/checks/agents-md-content.py new file mode 100755 index 0000000..ff2d8db --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/agents-md-content.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +"""Check: AGENTS.md content is trustworthy (Layer 1 staleness checks). +Tier coverage: product, canonical, personal. + +Implements the five Layer 1 checks from +roadmap/26.10/repo-setup/agents-md-validation.md (canonical-work-queue): + +1. Commands parse and their entry-point tool resolves in a dev environment. +2. Safe commands actually pass: runnable (lint/format-check/unit-test/build) + commands are executed and must exit 0; environment-gated commands + (integration needing Docker/LXD/juju, root-only tests, anything that would + mutate the tree or start a long-running process) are only parsed and + reported verify-manually, with the gating dependency named. +3. Paths and symbols resolve: every referenced file exists; every named + gocheck test suite (`-check.f ` after a `go test `) still + lives in the named package; every "`Symbol` in `path`" reference resolves. +4. Version pins mentioned in prose (`tool@vX.Y.Z`) match what + .github/workflows actually pin. +5. Scope lint: flag harness-shaped content (attribution trailers, tool + hints, per-agent config) that belongs in harness config, not AGENTS.md. + +This is a content check, not a presence check — see agents-md.py for +presence/length. If AGENTS.md is absent this check is n/a. + +Convention: one script emits exactly one JSON result (see lib/common.py); +all five sub-checks are folded into a single pass/fail with per-sub-check +evidence, following check.py's one-line-of-JSON-per-script contract. +""" +from __future__ import annotations + +import re +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, run, tier_applies, +) + +CHECK_ID = "agents-md-content" +APPLIES = "product,canonical,personal" + +RUNNABLE_TIMEOUT_SECONDS = 180 + +FENCE_RE = re.compile(r"```[ \t]*([A-Za-z0-9_+-]*)\n(.*?)\n?```", re.DOTALL) +FENCE_LANGS = {"", "bash", "sh", "shell", "console", "zsh"} +TABLE_ROW_RE = re.compile(r"^\|(.+)\|[ \t]*$") +INLINE_CODE_RE = re.compile(r"`([^`\n]+)`") +COMMAND_ENTRYPOINTS = { + "go", "tox", "make", "uv", "pytest", "docker", "npm", "cargo", "python", + "python3", "pip", "sudo", "gofmt", "staticcheck", "govulncheck", "ruff", + "black", "flake8", "pyright", "ty", "mypy", "just", +} +ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + +# Check 2 classification. Order matters: side-effecting first, then +# environment-gate keywords, then the runnable allowlist. Anything matching +# none of these is treated conservatively as environment-gated ("not +# recognised as safe" rather than risk running an unknown command). +SIDE_EFFECT_RE = re.compile( + r"\bgo run\b|\bgo install\b|\bgo fmt\b(?!.*-l)|\bpip install\b" + r"|\buv tool install\b|\buv pip install\b|(?:tox -e|make)\s+format\b" + r"|\bruff format\b(?!.*(--check|--diff))|\bblack\b(?!.*--check)" + r"|\bmake\s+run\b|\bmake\s+cli-help\b|\bnpm install\b" +) +ENV_GATE_PATTERNS = [ + (re.compile(r"\bdocker\b|\bcompose\b", re.IGNORECASE), "Docker"), + (re.compile(r"\blxd\b", re.IGNORECASE), "LXD"), + (re.compile(r"\bjuju\b", re.IGNORECASE), "juju"), + (re.compile(r"\bcharmcraft\b", re.IGNORECASE), "charmcraft"), + (re.compile(r"\bsudo\b|\broot\b", re.IGNORECASE), "root/sudo"), + (re.compile(r"\bintegration\b", re.IGNORECASE), "integration environment"), + (re.compile(r"CHARM_PATH|packed charms?", re.IGNORECASE), "packed charms"), +] +RUNNABLE_HINT_RE = re.compile( + r"\btest\b|\bunit\b|\bpytest\b|\blint\b|\bbuild\b|\bvet\b|--check\b|--diff\b" + r"|staticcheck|govulncheck|pyright|\bty check\b|gofmt -l", + re.IGNORECASE, +) + +VERSION_PIN_RE = re.compile(r"([A-Za-z0-9_.\-/]+)@v(\d+\.\d+(?:\.\d+)?)") +SUITE_RE = re.compile( + r"go test\s+(\.[^\s]+)\s+.*-check\.f[= ]([A-Za-z_][A-Za-z0-9_]*)" +) +SYMBOL_IN_PATH_RE = re.compile(r"`([A-Za-z_][\w.]*)`\s+in\s+`([^`]+)`") +MD_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") +FILE_EXT_RE = re.compile( + r"\.(md|py|go|toml|yaml|yml|txt|cfg|ini|sh|json|lock)$", re.IGNORECASE +) +KNOWN_EXTENSIONLESS_FILENAMES = {"dockerfile", "makefile", "license", "copying"} + +SCOPE_LINT_PATTERNS = [ + (re.compile(r"Co-Authored-By", re.IGNORECASE), "attribution trailer (Co-Authored-By)"), + (re.compile(r"Generated (with|by)\s*\[?Claude", re.IGNORECASE), "Claude attribution line"), + (re.compile(r"Claude Code", re.IGNORECASE), "harness name (Claude Code)"), + (re.compile(r"claude\.ai/code", re.IGNORECASE), "harness URL (claude.ai/code)"), + (re.compile(r"GitHub Copilot|\bCopilot\b"), "harness name (Copilot)"), + (re.compile(r"\bChatGPT\b|\bOpenAI\b"), "harness name (ChatGPT/OpenAI)"), + (re.compile(r"\bAnthropic\b"), "harness vendor name (Anthropic)"), + (re.compile(r"\U0001F916"), "robot-emoji attribution marker"), + (re.compile(r"\.claude/"), "harness-specific config path (.claude/)"), +] + + +def extract_commands(text: str) -> list[tuple[str, str]]: + """Return (raw_command, source) pairs from fenced shell blocks and + markdown table cells that look like commands.""" + commands: list[tuple[str, str]] = [] + for m in FENCE_RE.finditer(text): + lang = m.group(1).lower() + if lang not in FENCE_LANGS: + continue + for line in m.group(2).splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + cmd = re.split(r"\s+#\s?", line, maxsplit=1)[0].strip() + if cmd: + commands.append((cmd, "fenced")) + for line in text.splitlines(): + row = TABLE_ROW_RE.match(line.strip()) + if not row: + continue + for cell in row.group(1).split("|"): + cell = cell.strip() + code_m = INLINE_CODE_RE.fullmatch(cell) + if not code_m: + continue + candidate = code_m.group(1).strip() + first_tok = candidate.split()[0] if candidate.split() else "" + if first_tok in COMMAND_ENTRYPOINTS: + commands.append((candidate, "table")) + return commands + + +def entry_point_tool(cmd: str) -> str: + """First non-assignment token of the whole command line. A tool + introduced mid-line by an explicit install step (e.g. `go install X && + X ...`) is intentionally not checked here — it's expected to be absent + until that install step runs.""" + try: + tokens = shlex.split(cmd) + except ValueError: + return "" + for tok in tokens: + if ENV_ASSIGN_RE.match(tok): + continue + return tok + return "" + + +def classify_command(cmd: str) -> tuple[str, str]: + """Return (bucket, reason). bucket is 'runnable' or 'environment-gated'.""" + if SIDE_EFFECT_RE.search(cmd): + return ( + "environment-gated", + "would mutate the working tree or start a long-running process — not executed automatically", + ) + for pattern, name in ENV_GATE_PATTERNS: + if pattern.search(cmd): + return "environment-gated", name + if RUNNABLE_HINT_RE.search(cmd): + return "runnable", "" + return "environment-gated", "not recognised as a safe check command — verify manually" + + +def looks_like_path(cand: str) -> bool: + if not cand or " " in cand or cand.startswith(("http://", "https://")): + return False + if "/" in cand: + # Exclude Go/domain-style import paths (github.com/..., gopkg.in/..., + # golang.org/..., honnef.co/...) — a dotted first segment that isn't + # itself a relative-path marker ("." / "..") means "module path", + # not "local file". + first_seg = cand.split("/", 1)[0] + if "." in first_seg and not first_seg.startswith("."): + return False + return True + if cand.startswith("."): + return True + if FILE_EXT_RE.search(cand): + return True + return cand.lower() in KNOWN_EXTENSIONLESS_FILENAMES + + +def extract_referenced_paths(text: str) -> set[str]: + paths: set[str] = set() + for m in MD_LINK_RE.finditer(text): + target = m.group(1) + if target.startswith(("http://", "https://", "#", "mailto:")): + continue + paths.add(target) + for m in INLINE_CODE_RE.finditer(text): + cand = m.group(1).strip() + if looks_like_path(cand): + paths.add(cand) + return paths + + +def workflow_texts(root: Path) -> dict[str, str]: + wf_dir = root / ".github" / "workflows" + out: dict[str, str] = {} + if not wf_dir.is_dir(): + return out + for p in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + out[str(p.relative_to(root))] = p.read_text(errors="replace") + except OSError: + continue + return out + + +def check_version_drift( + pins: list[tuple[str, str]], workflows: dict[str, str] +) -> tuple[list[dict], list[dict]]: + """Return (drifted, checked). checked includes every pin that could be + cross-referenced against a workflow (pass or fail), for evidence + transparency — e.g. a tool version pinned differently in an unrelated + workflow is visible even when the AGENTS.md claim matches somewhere.""" + drifted: list[dict] = [] + checked: list[dict] = [] + for tool, doc_version in pins: + found: set[str] = set() + for text in workflows.values(): + for vm in re.finditer(re.escape(tool) + r"@v(\d+\.\d+(?:\.\d+)?)", text): + found.add(f"v{vm.group(1)}") + if not found: + continue + entry = {"tool": tool, "doc_version": doc_version, "ci_versions": sorted(found)} + checked.append(entry) + if doc_version not in found: + drifted.append(entry) + return drifted, checked + + +def scope_lint(text: str) -> list[str]: + return [label for pattern, label in SCOPE_LINT_PATTERNS if pattern.search(text)] + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + root = cd_repo_root() + + p = Path("AGENTS.md") + if not p.is_file(): + emit_check( + CHECK_ID, "na", + "No AGENTS.md to content-check (see agents-md check for presence).", + ) + return EXIT_NA + + text = p.read_text(errors="replace") + + # --- Checks 1 & 2: commands --- + commands = extract_commands(text) + missing_tools: list[dict] = [] + seen_missing_tools: set[str] = set() + runnable_results: list[dict] = [] + gated: list[dict] = [] + + for cmd, source in commands: + tool = entry_point_tool(cmd) + if tool and tool not in seen_missing_tools and shutil.which(tool) is None: + seen_missing_tools.add(tool) + missing_tools.append({"command": cmd, "tool": tool}) + + bucket, reason = classify_command(cmd) + if bucket == "environment-gated": + gated.append({"command": cmd, "gating_dependency": reason}) + continue + + try: + tokens = shlex.split(cmd) + except ValueError: + runnable_results.append({"command": cmd, "status": "unparseable"}) + continue + try: + proc = run(tokens, cwd=root, timeout=RUNNABLE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + runnable_results.append({"command": cmd, "status": "timeout"}) + continue + except OSError as exc: + runnable_results.append({"command": cmd, "status": "error", "detail": str(exc)}) + continue + runnable_results.append({ + "command": cmd, + "status": "pass" if proc.returncode == 0 else "fail", + "returncode": proc.returncode, + "stderr_tail": proc.stderr[-500:] if proc.returncode != 0 else "", + }) + + failed_runnable = [r for r in runnable_results if r["status"] != "pass"] + + # --- Check 3: paths & symbols --- + ref_paths = extract_referenced_paths(text) + missing_paths = sorted(rp for rp in ref_paths if not (root / rp).exists()) + + symbol_findings: list[dict] = [] + for sym, sym_path in SYMBOL_IN_PATH_RE.findall(text): + target = root / sym_path + if not target.is_file(): + symbol_findings.append({"symbol": sym, "path": sym_path, "problem": "path does not exist"}) + continue + body = target.read_text(errors="replace") + if not re.search(rf"\b{re.escape(sym)}\b", body): + symbol_findings.append({"symbol": sym, "path": sym_path, "problem": "symbol not found in file"}) + + suite_findings: list[dict] = [] + for pkg, suite in SUITE_RE.findall(text): + pkg_dir = (root / pkg).resolve() + if not pkg_dir.is_dir(): + suite_findings.append({"suite": suite, "package": pkg, "problem": "package directory does not exist"}) + continue + found = False + for go_file in pkg_dir.rglob("*.go"): + try: + if re.search(rf"\b{re.escape(suite)}\b", go_file.read_text(errors="replace")): + found = True + break + except OSError: + continue + if not found: + suite_findings.append({"suite": suite, "package": pkg, "problem": "suite identifier not found anywhere in package"}) + + # --- Check 4: version pins vs CI --- + pins = [ + (path.rstrip("/").split("/")[-1], f"v{ver}") + for path, ver in VERSION_PIN_RE.findall(text) + ] + workflows = workflow_texts(root) + version_drift, version_checked = check_version_drift(pins, workflows) + + # --- Check 5: scope lint --- + scope_findings = scope_lint(text) + + problems: list[str] = [] + if missing_tools: + problems.append(f"{len(missing_tools)} command tool(s) not resolvable") + if failed_runnable: + problems.append(f"{len(failed_runnable)} runnable command(s) did not pass") + if missing_paths: + problems.append(f"{len(missing_paths)} referenced path(s) missing") + if symbol_findings: + problems.append(f"{len(symbol_findings)} referenced symbol(s) unresolved") + if suite_findings: + problems.append(f"{len(suite_findings)} named test suite(s) not found in package") + if version_drift: + problems.append(f"{len(version_drift)} version pin(s) drifted from CI") + if scope_findings: + problems.append(f"{len(scope_findings)} scope-lint finding(s) (harness-shaped content)") + + evidence = { + "path": "AGENTS.md", + "commands_extracted": len(commands), + "missing_tools": missing_tools, + "runnable_checked": len(runnable_results), + "runnable_failed": failed_runnable, + "environment_gated": gated, + "paths_checked": sorted(ref_paths), + "missing_paths": missing_paths, + "symbol_findings": symbol_findings, + "suite_findings": suite_findings, + "version_pins_checked": version_checked, + "version_drift": version_drift, + "scope_lint_findings": scope_findings, + } + + if problems: + emit_check( + CHECK_ID, "fail", + "AGENTS.md content check: " + "; ".join(problems) + ".", + evidence, + { + "kind": "judgement", + "human_review": ( + "Review the evidence fields for the failing sub-check(s) " + "(missing_tools / runnable_failed / missing_paths / " + "symbol_findings / suite_findings / version_drift / " + "scope_lint_findings) and update AGENTS.md or the " + "underlying repo to match." + ), + }, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, "pass", + f"AGENTS.md content verified: {len(commands)} command(s) parsed " + f"({len(runnable_results)} run, {len(gated)} environment-gated/" + f"verify-manually), {len(ref_paths)} path(s) resolved, " + f"{len(version_checked)} version pin(s) cross-checked against CI, " + "no scope-lint findings.", + evidence, + ) + return EXIT_PASS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/agents-md.py b/skills/engineering/charm-tech-baseline/scripts/checks/agents-md.py new file mode 100755 index 0000000..084f899 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/agents-md.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Check: AGENTS.md present (best-of-class; agent-onboarding entry point). +Tier coverage: product, canonical. Personal-tier: informational only. + +Convention: keep it minimal — a short pointer file, not an encyclopaedia. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, EXIT_UNKNOWN, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "agents-md" +APPLIES = "product,canonical,personal" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + p = Path("AGENTS.md") + if p.is_file(): + lines = p.read_text().count("\n") + if lines > 200: + emit_check( + CHECK_ID, "fail", + f"AGENTS.md present but at {lines} lines is well past the 'keep it minimal' convention.", + {"path": "AGENTS.md", "lines": lines}, + {"kind": "judgement", "human_review": "Trim AGENTS.md down — point at HACKING/CONTRIBUTING for depth; keep AGENTS.md to setup commands and conventions only."}, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, "pass", + f"AGENTS.md present ({lines} lines).", + {"path": "AGENTS.md", "lines": lines}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "No AGENTS.md found.", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-agents-md.py", "human_review": "Customise the dev-setup commands for this repo (uv / go / make / just)."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/attest-build-provenance.py b/skills/engineering/charm-tech-baseline/scripts/checks/attest-build-provenance.py new file mode 100755 index 0000000..839072f --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/attest-build-provenance.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Check: actions/attest-build-provenance present in release / publish workflows, +with a `subject-path:` input, AND ordered to run *before* the publish step. +Tier coverage: product, canonical. + +Ordering rules: + - Same job: attest step index must be < publish step index. + - Cross-job: attest job must transitively appear in the publish job's + `needs:` chain (e.g. build-and-attest -> publish via uploaded artefacts). + +Uses python3 + PyYAML for parsing. Falls back to `unknown` if either is +unavailable, rather than emitting a brittle grep-based verdict. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, EXIT_UNKNOWN, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "attest-build-provenance" +APPLIES = "product,canonical" + +ATTEST = "actions/attest-build-provenance" +PUBLISH_ACTIONS = ( + "pypa/gh-action-pypi-publish", + "snapcore/action-publish", + "goreleaser/goreleaser-action", + "softprops/action-gh-release", +) +TEST_PYPI_HOSTS = ("test.pypi.org", "testpypi.org") + + +def _targets_test_pypi(step: dict) -> bool: + with_block = step.get("with") or {} + url = (with_block.get("repository-url") or with_block.get("repository_url") or "") + if any(h in url for h in TEST_PYPI_HOSTS): + return True + run_str = step.get("run") or "" + if any(h in run_str for h in TEST_PYPI_HOSTS): + return True + env = step.get("env") or {} + url2 = (env.get("TWINE_REPOSITORY_URL") or env.get("TWINE_REPOSITORY") or "") + return any(h in url2 for h in TEST_PYPI_HOSTS) + + +def is_publish_step(step) -> bool: + if not isinstance(step, dict): + return False + uses = step.get("uses") or "" + run_str = step.get("run") or "" + is_action_publish = any(p in uses for p in PUBLISH_ACTIONS) + is_twine_publish = "twine upload" in run_str + if not (is_action_publish or is_twine_publish): + return False + if _targets_test_pypi(step): + return False + return True + + +def is_attest_step(step) -> bool: + if not isinstance(step, dict): + return False + uses = step.get("uses") or "" + return ATTEST in uses + + +def needs_of(job) -> list[str]: + needs = job.get("needs") if isinstance(job, dict) else None + if needs is None: + return [] + if isinstance(needs, str): + return [needs] + return list(needs) + + +def transitive_needs(jobs: dict, start: str) -> set[str]: + seen: set[str] = set() + stack = [start] + while stack: + n = stack.pop() + if n in seen or n not in jobs: + continue + seen.add(n) + stack.extend(needs_of(jobs[n])) + seen.discard(start) + return seen + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + if not Path(".github/workflows").is_dir(): + emit_check(CHECK_ID, "na", "No .github/workflows directory; nothing to attest.") + return EXIT_NA + + try: + import yaml # type: ignore + except Exception: + emit_check( + CHECK_ID, "unknown", + "PyYAML not installed (python3 -c 'import yaml' fails); cannot parse workflow YAML.", + ) + return EXIT_UNKNOWN + + failures: list[str] = [] + publish_workflows: list[str] = [] + attested_workflows: list[str] = [] + + wf_dir = Path(".github/workflows") + workflow_paths = sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))) + + for path in workflow_paths: + try: + with open(path) as f: + doc = yaml.safe_load(f) + except Exception as e: + failures.append(f"{path}: YAML parse error ({e})") + continue + if not isinstance(doc, dict): + continue + jobs = doc.get("jobs") or {} + if not isinstance(jobs, dict): + continue + + job_steps: dict[str, dict] = {} + for jname, job in jobs.items(): + if not isinstance(job, dict): + continue + steps = job.get("steps") or [] + rec = {"attests": [], "publishes": []} + for i, step in enumerate(steps): + if not isinstance(step, dict): + continue + if is_attest_step(step): + with_block = step.get("with") or {} + subj = ( + with_block.get("subject-path") + or with_block.get("subject-checksums") + or with_block.get("subject-digest") + ) + rec["attests"].append({"index": i, "has_subject": bool(subj)}) + if is_publish_step(step): + rec["publishes"].append({"index": i}) + job_steps[jname] = rec + + publish_jobs = [j for j, r in job_steps.items() if r["publishes"]] + if not publish_jobs: + continue + publish_workflows.append(str(path)) + + workflow_ok = True + for pjob in publish_jobs: + prec = job_steps[pjob] + first_publish_idx = min(p["index"] for p in prec["publishes"]) + + same_job_attests = [a for a in prec["attests"] if a["index"] < first_publish_idx] + same_job_attest_no_order = [a for a in prec["attests"] if a["index"] >= first_publish_idx] + if same_job_attests: + if not any(a["has_subject"] for a in same_job_attests): + failures.append(f"{path}:{pjob}: attest step has no subject-path/subject-digest/subject-checksums") + workflow_ok = False + continue + if same_job_attest_no_order: + failures.append(f"{path}:{pjob}: attest step runs AFTER the publish step (must be before)") + workflow_ok = False + continue + + upstream = transitive_needs(jobs, pjob) + upstream_attests: list[tuple[str, dict]] = [] + for uj in upstream: + for a in job_steps.get(uj, {}).get("attests", []): + upstream_attests.append((uj, a)) + if not upstream_attests: + failures.append(f"{path}:{pjob}: publish step has no attest step in this job or in any upstream `needs:` job") + workflow_ok = False + continue + if not any(a["has_subject"] for _, a in upstream_attests): + ujs = ",".join(sorted({uj for uj, _ in upstream_attests})) + failures.append(f"{path}:{pjob}: upstream attest step(s) in {ujs} have no subject-path") + workflow_ok = False + continue + if workflow_ok: + attested_workflows.append(str(path)) + + evidence = { + "publish_workflows": publish_workflows, + "attested_workflows": attested_workflows, + "failures": failures, + } + + if not publish_workflows: + emit_check(CHECK_ID, "na", "No publish/release workflow found; attestation not applicable.") + return EXIT_NA + + if not failures: + emit_check( + CHECK_ID, "pass", + "Build provenance attestation present, with subject-path, ordered before publish.", + evidence, + ) + return EXIT_PASS + + top = failures[0] if len(failures) == 1 else f"{failures[0]} (and {len(failures)-1} more)" + reason_q = top.replace('"', '\\"') + emit_check( + CHECK_ID, "fail", + f"Publish workflow(s) missing or misordered attestation: {reason_q}", + evidence, + {"kind": "judgement", "human_review": "Wire actions/attest-build-provenance@ BEFORE the publish step in the same job, or in an upstream job in the publish jobs `needs:` chain. Set `with.subject-path` to the published artefact glob (e.g. dist/*). Concierge skip applies until goreleaser build/publish split lands."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/attest-sbom-deprecated.py b/skills/engineering/charm-tech-baseline/scripts/checks/attest-sbom-deprecated.py new file mode 100755 index 0000000..b5c1b78 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/attest-sbom-deprecated.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Check: no use of the deprecated actions/attest-sbom action. +Tier coverage: all. + +actions/attest-sbom is deprecated in favour of actions/attest. Since v4 it +already runs as a thin wrapper over actions/attest, and its inputs +(subject-path, sbom-path) are compatible with the new action — so this is +a straight action swap with no behaviour change. + +Ref: https://github.com/actions/attest-sbom (deprecation notice). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "attest-sbom-deprecated" +APPLIES = "all" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check(CHECK_ID, "na", "No .github/workflows/ directory.") + return EXIT_NA + + hits: list[str] = [] + for path in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + if "actions/attest-sbom" in path.read_text(errors="replace"): + hits.append(str(path)) + except OSError: + continue + + if not hits: + emit_check( + CHECK_ID, "pass", + "No use of the deprecated actions/attest-sbom action.", + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "actions/attest-sbom is deprecated; swap to actions/attest (same inputs).", + {"workflows": hits}, + { + "kind": "judgement", + "human_review": ( + "Replace `uses: actions/attest-sbom@` with " + "`uses: actions/attest@` in the listed workflows; " + "subject-path and sbom-path inputs are compatible. " + "Ref: https://github.com/actions/attest-sbom (deprecation notice)." + ), + }, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/code-of-conduct.py b/skills/engineering/charm-tech-baseline/scripts/checks/code-of-conduct.py new file mode 100755 index 0000000..3913d61 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/code-of-conduct.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Check: CODE_OF_CONDUCT.md present. +Tier coverage: product, canonical. (Best-practice for personal too; +emitted as a softer fail.) + +Decision: link-only form pointing at the Ubuntu Code of Conduct, not a +full Contributor Covenant template. See references/decisions.md. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "code-of-conduct" +APPLIES = "product,canonical,personal" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + for path in ("CODE_OF_CONDUCT.md", "docs/CODE_OF_CONDUCT.md", ".github/CODE_OF_CONDUCT.md"): + p = Path(path) + if p.is_file(): + text = p.read_text(errors="replace") + if re.search(r"ubuntu\.com/community/ethos/code-of-conduct", text, re.IGNORECASE): + emit_check( + CHECK_ID, "pass", + "CODE_OF_CONDUCT.md present and links to the Ubuntu Code of Conduct.", + {"path": path}, + ) + return EXIT_PASS + emit_check( + CHECK_ID, "fail", + "CODE_OF_CONDUCT.md present but does not link to the Ubuntu Code of Conduct (cycle convention: link-only form).", + {"path": path}, + {"kind": "judgement", "human_review": "Replace with link-only form pointing at https://ubuntu.com/community/ethos/code-of-conduct (Ubuntu CoC has its own reporting/enforcement path via Community Council)."}, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, "fail", + "No CODE_OF_CONDUCT.md found.", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-code-of-conduct.py", "human_review": "None — template is fixed link-only form."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/contributing.py b/skills/engineering/charm-tech-baseline/scripts/checks/contributing.py new file mode 100755 index 0000000..1e7d119 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/contributing.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Check: CONTRIBUTING.md (or equivalent) present AND documents the PR +workflow with a "Pull requests" anchor. +Tier coverage: product, canonical. + +Why the anchor matters: .github/check-conventional-pr-title.py (the +validate-pr-title workflow's helper) prints an error message ending in +"Read more: https://github.com///blob//CONTRIBUTING.md#pull-requests". +If the destination file has no `# Pull requests` heading the link +silently lands at the top of the document — the audited Charm Tech +pattern (10 of 14 repos) is to have the heading. + +Accepted variants for the file itself: CONTRIBUTING.md, HACKING.md, +docs/contributing.md, docs/how-to/contribute.md, .github/CONTRIBUTING.md +(pebble uses HACKING.md). +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "contributing" +APPLIES = "product,canonical" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + found = "" + for path in ("CONTRIBUTING.md", "HACKING.md", "docs/contributing.md", "docs/how-to/contribute.md", ".github/CONTRIBUTING.md"): + if Path(path).is_file(): + found = path + break + + if not found: + emit_check( + CHECK_ID, "fail", + "No CONTRIBUTING.md / HACKING.md / docs/contributing found.", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-contributing.py", "human_review": "Customise the dev-setup pointer / project description for the repo (Python / Go / docs)."}, + ) + return EXIT_FAIL + + text = Path(found).read_text(errors="replace") + # ^#{1,3}\s+pull\s+requests?\s*$ (multiline, case-insensitive) + if re.search(r"^#{1,3}[ \t]+pull[ \t]+requests?[ \t]*$", text, re.IGNORECASE | re.MULTILINE): + emit_check( + CHECK_ID, "pass", + f"Contributing guidance present at {found} with a Pull requests section.", + {"path": found, "pull_requests_heading": True}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + f"{found} present but has no 'Pull requests' heading — the validate-pr-title.py \"Read more\" URL (#pull-requests) will not anchor.", + {"path": found, "pull_requests_heading": False}, + {"kind": "judgement", "human_review": "Add a `# Pull requests` (or `## Pull requests`) section listing the allowed Conventional-Commits types (chore, ci, docs, feat, fix, perf, refactor, revert, test) and the no-scopes rule. See assets/CONTRIBUTING.md.template for the canonical shape; for pebble-style HACKING.md the anchor can live there instead."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/conventional-commits.py b/skills/engineering/charm-tech-baseline/scripts/checks/conventional-commits.py new file mode 100755 index 0000000..8cc1383 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/conventional-commits.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Check: Conventional-commits PR-title enforcement workflow present. +Tier coverage: product, canonical. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "conventional-commits" +APPLIES = "product,canonical" + +PATTERN = re.compile(r"amannn/action-semantic-pull-request|conventional-commit|check-conventional-pr-title") + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, "fail", + "No .github/workflows directory.", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-validate-pr-title.py", "human_review": "Installs operator-style validate-pr-title.yaml + check-conventional-pr-title.py. Confirm CONTRIBUTING.md documents the allowed types."}, + ) + return EXIT_FAIL + + hit = "" + for path in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + if PATTERN.search(path.read_text(errors="replace")): + hit = str(path) + break + except OSError: + continue + + if hit: + emit_check( + CHECK_ID, "pass", + "PR-title Conventional-Commits enforcement wired up.", + {"workflow": hit}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "No Conventional-Commits PR-title workflow found.", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-validate-pr-title.py", "human_review": "Installs operator-style validate-pr-title.yaml + check-conventional-pr-title.py (source: canonical/operator). Confirm CONTRIBUTING.md documents the allowed type list (chore/ci/docs/feat/fix/perf/refactor/revert/test) and disallows scopes."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/dependabot.py b/skills/engineering/charm-tech-baseline/scripts/checks/dependabot.py new file mode 100755 index 0000000..444d353 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/dependabot.py @@ -0,0 +1,195 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyyaml"] +# /// +"""Check: .github/dependabot.yml exists, declares package ecosystems, and each +ecosystem has a cooldown of at least 7 days (Charm Tech baseline — see +charmlibs#499). The cooldown delays raising a PR for a freshly published +release so a malicious upload caught and yanked inside the window never +reaches CI. + +Tier coverage: product, canonical. (Personal-tier sees this as best-practice +advisory rather than mandatory.) + +Mandate: SEC0025 (Vulnerability Discovery & Identification) — cross-cutting +requirement for every Canonical repo. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, EXIT_UNKNOWN, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +import yaml + +CHECK_ID = "dependabot" +APPLIES = "product,canonical,personal" +MIN_COOLDOWN_DAYS = 7 + +ECOSYSTEM_LINE_RE = re.compile(r"^[ \t]*-[ \t]+package-ecosystem:", re.MULTILINE) +PRECOMMIT_ECO_RE = re.compile( + r"""^[ \t]*-[ \t]+package-ecosystem:[ \t]*["']?pre-commit["']?[ \t]*$""", + re.MULTILINE, +) +PRECOMMIT_REV_RE = re.compile(r"^[ \t]*rev:[ \t]+", re.MULTILINE) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + found_path = "" + for path in (".github/dependabot.yml", ".github/dependabot.yaml"): + if Path(path).is_file(): + found_path = path + break + + if not found_path: + if tier == "personal": + emit_check( + CHECK_ID, "fail", + "No Dependabot config (personal tier — recommended, not mandated).", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-dependabot.py", "human_review": "Confirm the default ecosystem set matches the repo."}, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, "fail", + "No .github/dependabot.yml found (required cross-cutting per SEC0025).", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-dependabot.py", "human_review": "Confirm the default ecosystem set matches the repo (pip/uv, gomod, github-actions, docker)."}, + ) + return EXIT_FAIL + + text = Path(found_path).read_text(errors="replace") + ecos = len(ECOSYSTEM_LINE_RE.findall(text)) + if ecos == 0: + emit_check( + CHECK_ID, "fail", + f"{found_path} exists but declares no package-ecosystem entries.", + {"path": found_path}, + {"kind": "judgement", "human_review": "Add package-ecosystem blocks for each language/runtime the repo uses (pip/uv, gomod, github-actions, docker)."}, + ) + return EXIT_FAIL + + # Cross-check: if .pre-commit-config.yaml carries any rev: entries, + # dependabot must declare the pre-commit ecosystem to bump the SHAs. + # See references/decisions.md § "Remote pre-commit hooks — SHA-pin". + pc_config = "" + for path in (".pre-commit-config.yaml", ".pre-commit-config.yml"): + if Path(path).is_file(): + pc_config = path + break + if pc_config: + try: + pc_text = Path(pc_config).read_text(errors="replace") + except OSError: + pc_text = "" + if PRECOMMIT_REV_RE.search(pc_text) and not PRECOMMIT_ECO_RE.search(text): + emit_check( + CHECK_ID, "fail", + f"{found_path} is missing a pre-commit package-ecosystem entry — required because {pc_config} carries rev: entries whose SHAs need Dependabot bumps (Charm Tech baseline).", + {"path": found_path, "ecosystems": ecos, "pre_commit_config": pc_config}, + {"kind": "judgement", "human_review": "Add a pre-commit package-ecosystem block to dependabot.yaml (same shape as github-actions, cooldown ≥7 days). See assets/dependabot.yaml.template."}, + ) + return EXIT_FAIL + + # Parse and validate cooldown. + try: + doc = yaml.safe_load(text) + except Exception: + # Fall through to presence check. + if not re.search(r"^[ \t]*cooldown:", text, re.MULTILINE): + emit_check( + CHECK_ID, "fail", + f"Dependabot configured with {ecos} ecosystem(s), but no cooldown: block found and YAML parser errored — could not auto-validate.", + {"path": found_path, "ecosystems": ecos, "cooldown_validated": False}, + {"kind": "judgement", "human_review": "Add a cooldown block to every package-ecosystem entry with default-days/semver-*-days ≥7."}, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, "unknown", + "Dependabot present with cooldown block, but YAML parser errored — cooldown values not validated.", + {"path": found_path, "ecosystems": ecos, "cooldown_validated": False}, + ) + return EXIT_UNKNOWN + + if not isinstance(doc, dict): + # Match parse-error behaviour. + if not re.search(r"^[ \t]*cooldown:", text, re.MULTILINE): + emit_check( + CHECK_ID, "fail", + f"Dependabot configured with {ecos} ecosystem(s), but no cooldown: block found and YAML parser errored — could not auto-validate.", + {"path": found_path, "ecosystems": ecos, "cooldown_validated": False}, + {"kind": "judgement", "human_review": "Add a cooldown block to every package-ecosystem entry with default-days/semver-*-days ≥7."}, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, "unknown", + "Dependabot present with cooldown block, but YAML parser errored — cooldown values not validated.", + {"path": found_path, "ecosystems": ecos, "cooldown_validated": False}, + ) + return EXIT_UNKNOWN + + updates = doc.get("updates") or [] + problems: list[str] = [] + for entry in updates: + if not isinstance(entry, dict): + continue + eco = entry.get("package-ecosystem", "?") + direc = entry.get("directory", entry.get("directories", "?")) + label = f"{eco}@{direc}" + cd = entry.get("cooldown") + if not isinstance(cd, dict): + problems.append(f"{label}: no cooldown block") + continue + keys = ("default-days", "semver-major-days", "semver-minor-days", "semver-patch-days") + if "default-days" not in cd and not any(k in cd for k in keys): + problems.append(f"{label}: cooldown block present but no *-days field set") + continue + for k in keys: + if k in cd: + try: + v = int(cd[k]) + except (TypeError, ValueError): + problems.append(f"{label}: cooldown.{k} not an integer ({cd[k]!r})") + continue + if v < MIN_COOLDOWN_DAYS: + problems.append(f"{label}: cooldown.{k}={v} < {MIN_COOLDOWN_DAYS}") + + if not problems: + emit_check( + CHECK_ID, "pass", + f"Dependabot configured with {ecos} ecosystem(s); cooldown ≥{MIN_COOLDOWN_DAYS} days on every entry.", + {"path": found_path, "ecosystems": ecos, "cooldown_validated": True}, + ) + return EXIT_PASS + + evidence = { + "path": found_path, + "ecosystems": ecos, + "cooldown_validated": True, + "detail": {"problems": problems, "ecosystems": len(updates)}, + } + emit_check( + CHECK_ID, "fail", + f"Dependabot present but cooldown below Charm Tech baseline (≥{MIN_COOLDOWN_DAYS} days) on one or more ecosystems.", + evidence, + {"kind": "judgement", "human_review": "Set cooldown.default-days (and any per-semver-tier overrides) to at least 7 on every package-ecosystem entry. See assets/dependabot.yaml.template."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/dependency-review.py b/skills/engineering/charm-tech-baseline/scripts/checks/dependency-review.py new file mode 100755 index 0000000..8d20b08 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/dependency-review.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Check: actions/dependency-review-action workflow present on PRs. +Tier coverage: product, canonical. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "dependency-review" +APPLIES = "product,canonical" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, "fail", + "No .github/workflows/ directory.", + {}, + {"kind": "judgement", "human_review": "Set up workflows; then add dependency-review."}, + ) + return EXIT_FAIL + + hit = "" + for path in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + if "actions/dependency-review-action" in path.read_text(errors="replace"): + hit = str(path) + break + except OSError: + continue + + if hit: + emit_check( + CHECK_ID, "pass", + "dependency-review-action wired up.", + {"workflow": hit}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "No actions/dependency-review-action workflow.", + {}, + {"kind": "judgement", "human_review": "Add a dependency-review.yaml workflow on pull_request, ~10 lines. Reference: canonical/operator#2587 (open as of 2026-06-27)."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/gha-sha-pinning.py b/skills/engineering/charm-tech-baseline/scripts/checks/gha-sha-pinning.py new file mode 100755 index 0000000..ae18beb --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/gha-sha-pinning.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Check: every third-party GHA action is SHA-pinned. No exceptions — +`actions/*`, `github/*`, `pypa/*`, `canonical/*` all pin to a commit +SHA, same as any other third-party action (see references/decisions.md). + +Tier coverage: product, canonical, personal. + +Implementation: greps `uses:` lines under .github/workflows/. A ref is +SHA-pinned iff it matches a 40-char hex string. Local action refs +(`./...`) are skipped. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "gha-sha-pinning" +APPLIES = "product,canonical,personal" + +USES_RE = re.compile(r"^[ \t]*-?[ \t]*uses:[ \t]+(.+)$") +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check(CHECK_ID, "na", "No .github/workflows directory.") + return EXIT_NA + + violations = 0 + violators: list[str] = [] + total = 0 + + for p in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + text = p.read_text(errors="replace") + except OSError: + continue + for line in text.splitlines(): + m = USES_RE.match(line) + if not m: + continue + ref = m.group(1).strip() + # take first whitespace-delimited token, strip quotes + ref = ref.split()[0] if ref else "" + if ref.startswith('"') and ref.endswith('"'): + ref = ref[1:-1] + if ref.startswith("'") and ref.endswith("'"): + ref = ref[1:-1] + if not ref: + continue + if ref.startswith("./") or ref.startswith("."): + continue + total += 1 + after_at = ref.split("@", 1)[1] if "@" in ref else ref + if not SHA_RE.match(after_at): + violations += 1 + violators.append(ref) + + if violations == 0: + emit_check( + CHECK_ID, "pass", + "All third-party GHA actions SHA-pinned (no exceptions).", + {"actions_inspected": total}, + ) + return EXIT_PASS + + trimmed = ",".join(violators) + emit_check( + CHECK_ID, "fail", + f"{violations} third-party action ref(s) not SHA-pinned.", + {"actions_inspected": total, "non_pinned": trimmed}, + {"kind": "judgement", "human_review": "Replace each non-pinned ref with the upstream commit SHA + a # vX.Y.Z comment. No allowlist exceptions — actions/, github/, pypa/, canonical/ all pin the same way."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/immutable-releases.py b/skills/engineering/charm-tech-baseline/scripts/checks/immutable-releases.py new file mode 100755 index 0000000..40f4cd8 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/immutable-releases.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Check: GitHub releases are immutable (latest release's `immutable: true`). +Tier coverage: product, canonical. + +Implementation: uses `gh api repos///releases` if `gh` is +available; falls back to a 'unknown' note if it isn't. + +Known blockers (do not flag as fail): + - pebble: snap build (pebble#856) + - concierge: goreleaser monolith (concierge#172 / #142) +Heuristically: if the repo origin is canonical/{pebble,concierge,charmlibs}, +emit a note explaining the blocker. +""" +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, EXIT_UNKNOWN, + emit_check, origin_url, parse_tier, run, tier_applies, +) + +CHECK_ID = "immutable-releases" +APPLIES = "product,canonical" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + url = origin_url() + slug = url[len("https://github.com/"):] if url.startswith("https://github.com/") else url + + if slug == "canonical/pebble": + emit_check(CHECK_ID, "na", "pebble immutable-releases blocked on snap-build process update (pebble#856).") + return EXIT_NA + if slug == "canonical/concierge": + emit_check(CHECK_ID, "na", "concierge immutable-releases blocked on goreleaser build/publish split (concierge#172 / #142).") + return EXIT_NA + + if not shutil.which("gh"): + emit_check(CHECK_ID, "unknown", "gh CLI not installed; cannot query release immutability flag.") + return EXIT_UNKNOWN + + r = run(["gh", "api", f"repos/{slug}/releases?per_page=1", "--jq", ".[0].immutable // empty"]) + flag = r.stdout.strip() if r.returncode == 0 else "" + if not flag: + emit_check(CHECK_ID, "na", f"No releases on {slug} yet — toggle the setting before the first release.") + return EXIT_NA + + if flag == "true": + emit_check( + CHECK_ID, "pass", + "Latest release is immutable.", + {"slug": slug}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "Latest release is NOT immutable. Setting needs to be flipped, or an active blocker tracked.", + {"slug": slug}, + {"kind": "judgement", "human_review": "Flip the per-repo Make-published-releases-immutable toggle in GitHub Settings. If blocked on tooling (goreleaser, snap-build), record the blocker upstream and revisit when the upstream lands."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/openssf-scorecard.py b/skills/engineering/charm-tech-baseline/scripts/checks/openssf-scorecard.py new file mode 100755 index 0000000..22ff2a8 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/openssf-scorecard.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Check: OpenSSF Scorecard workflow + README badge. +Tier coverage: product, canonical. (Operator pilots; rest gated behind +its adoption — see references/open-investigations.md.) + +Pass — workflow uses ossf/scorecard-action; README has the badge. +Partial — workflow OR badge present but not both — emitted as fail with + human_review noting which half is missing. +Fail — neither present. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "openssf-scorecard" +APPLIES = "product,canonical" + +BADGE_RE = re.compile(r"securityscorecards\.dev/projects/github\.com|scorecard\.dev/projects") + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + workflow = "" + wf_dir = Path(".github/workflows") + if wf_dir.is_dir(): + for p in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + if "ossf/scorecard-action" in p.read_text(errors="replace"): + workflow = str(p) + break + except OSError: + continue + + badge = "" + for readme in ("README.md", "README.rst", "README.txt", "readme.md"): + p = Path(readme) + if not p.is_file(): + continue + try: + if BADGE_RE.search(p.read_text(errors="replace")): + badge = readme + break + except OSError: + continue + + if workflow and badge: + emit_check( + CHECK_ID, "pass", + f"OpenSSF Scorecard workflow ({workflow}) and README badge ({badge}) present.", + {"workflow": workflow, "badge_in": badge}, + ) + return EXIT_PASS + + if not workflow and not badge: + emit_check( + CHECK_ID, "fail", + "No OpenSSF Scorecard workflow or badge. (Note: 26.10-cycle rollout is gated behind operator's adoption — see references/open-investigations.md.)", + {}, + {"kind": "judgement", "human_review": "Wait for operator adoption to settle and propagate its workflow + branch-protection wiring; do not invent conventions ahead of it."}, + ) + return EXIT_FAIL + + missing = "workflow" + if not badge: + missing = "README badge" + if not workflow: + missing = "workflow" + emit_check( + CHECK_ID, "fail", + f"Partial OpenSSF Scorecard setup — {missing} missing.", + {"workflow": workflow, "badge_in": badge}, + {"kind": "judgement", "human_review": "Add the missing half (workflow or badge) to match the operator-led convention."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/pre-commit-config.py b/skills/engineering/charm-tech-baseline/scripts/checks/pre-commit-config.py new file mode 100755 index 0000000..8feb7ff --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/pre-commit-config.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Check: .pre-commit-config.yaml present (informational). +Tier coverage: product, canonical, personal. + +Cycle convention (see references/decisions.md): tool versions live in +pyproject.toml [dependency-groups], not in the pre-commit config's +rev: fields. The hooks invoke tools via `language: system` against +the lockfile. A config that pins versions in rev: fields is flagged +as a soft fail because it duplicates the source of truth. + +Carve-out: hooks from pre-commit/pre-commit-hooks (end-of-file-fixer, +trailing-whitespace, check-yaml, check-added-large-files, …) are +generic file-hygiene checks with no Python-tool counterpart in +pyproject.toml dependency-groups. Pinning their rev: is the standard +way to use them and does not duplicate any other source of truth, so +they are exempted from the *tool-version* count. + +The carve-out still has to be **SHA-pinned**, not tag-pinned — same +discipline as gha-sha-pinning (see references/decisions.md § "Remote +pre-commit hooks — SHA-pin, don't tag-pin"). A `rev: v5.0.0` on an +exempt repo is a gap; a `rev: <40-char hex> # frozen: v5.0.0` is +the shape. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "pre-commit-config" +APPLIES = "product,canonical,personal" + +EXEMPT_REPOS = {"https://github.com/pre-commit/pre-commit-hooks"} + +REPO_RE = re.compile(r"^[ \t]*-[ \t]*repo:[ \t]*(.*)$") +REV_LINE_RE = re.compile(r"^[ \t]*rev:[ \t]+(.*)$") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def scan_revs(text: str) -> tuple[int, int]: + """Return (tool_revs, tag_pinned_exempt_revs). + + tool_revs counts rev: entries on non-exempt repos (duplicating the + dependency-group source of truth). + + tag_pinned_exempt_revs counts rev: entries on the exempt carve-out + that aren't full 40-char SHAs — the carve-out still has to be + SHA-pinned, same discipline as gha-sha-pinning. + """ + tool_revs = 0 + tag_pinned = 0 + cur = "" + for line in text.splitlines(): + m = REPO_RE.match(line) + if m: + cur = m.group(1).strip().replace('"', "").replace("'", "").rstrip() + continue + rm = REV_LINE_RE.match(line) + if not rm: + continue + val = rm.group(1) + # Strip trailing comment (e.g. `# frozen: v5.0.0`) before matching. + val = re.sub(r"[ \t]*#.*$", "", val) + val = val.replace('"', "").replace("'", "").strip() + if cur in EXEMPT_REPOS: + if not SHA_RE.match(val): + tag_pinned += 1 + else: + tool_revs += 1 + return tool_revs, tag_pinned + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + if not Path(".pre-commit-config.yaml").is_file() and not Path(".pre-commit-config.yml").is_file(): + emit_check( + CHECK_ID, "fail", + "No .pre-commit-config.yaml found.", + {}, + {"kind": "judgement", "human_review": "Add a minimal config for the languages in use; hooks should use language: system to invoke tools from the uv-locked dependency-groups."}, + ) + return EXIT_FAIL + + config = ".pre-commit-config.yaml" + if Path(".pre-commit-config.yml").is_file(): + config = ".pre-commit-config.yml" + + try: + text = Path(config).read_text(errors="replace") + except OSError: + text = "" + versioned_revs, tag_pinned_revs = scan_revs(text) + + if versioned_revs > 0: + emit_check( + CHECK_ID, "fail", + f"Pre-commit config pins {versioned_revs} rev: version(s). Cycle convention is to invoke tools via language: system from pyproject.toml [dependency-groups].", + {"config": config, "versioned_revs": versioned_revs}, + {"kind": "judgement", "human_review": "Move tool versions to pyproject.toml [dependency-groups]; replace each pinned hook with a language: system equivalent. Reference: pytest-jubilant#86."}, + ) + return EXIT_FAIL + + if tag_pinned_revs > 0: + emit_check( + CHECK_ID, "fail", + f"Pre-commit config has {tag_pinned_revs} rev: entry(s) pinned by tag rather than full SHA. Cycle convention (see decisions.md § Remote pre-commit hooks): SHA-pin, don't tag-pin — same discipline as gha-sha-pinning.", + {"config": config, "tag_pinned_revs": tag_pinned_revs}, + {"kind": "judgement", "human_review": "Resolve each tag-pinned rev: to its 40-char commit SHA and add a `# frozen: ` trailing comment. Dependabot pre-commit ecosystem will bump the SHA."}, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, "pass", + "Pre-commit config present, no tool versions duplicated in rev: fields, and any surviving rev: is SHA-pinned.", + {"config": config}, + ) + return EXIT_PASS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/repo-settings.py b/skills/engineering/charm-tech-baseline/scripts/checks/repo-settings.py new file mode 100755 index 0000000..0789f4c --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/repo-settings.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Check: GitHub repo settings are either declared in canonical-repo-automation +(CRA) — the Terraform/Terragrunt control plane that owns repo settings for +Charm Tech — or, for repos not enrolled in CRA, set manually to the baseline. + +Tier coverage: product, canonical, personal. + - product / canonical: prefer CRA enrolment; otherwise live settings must + match the baseline. + - personal: live settings only (CRA does not manage personal repos). + +Mandate: cycle baseline — canonical-repo-automation (CRA) is the real +control plane for Charm Tech repo settings. CRA already declares: + - allowed_actions = "selected" + - private vulnerability reporting on (group-wide) + - Dependabot security updates on (group-wide) + - squash-only merges, delete-branch-on-merge + - secret scanning + push protection (PR #812 group-wide) +Without CRA the same posture must be set per-repo via Settings or `gh api`. + +Baseline settings checked (live): + - allow_squash_merge=true, allow_merge_commit=false, allow_rebase_merge=false + - delete_branch_on_merge=true + - security_and_analysis.secret_scanning.status=enabled + - security_and_analysis.secret_scanning_push_protection.status=enabled + - security_and_analysis.dependabot_security_updates.status=enabled + - private vulnerability reporting enabled + - allowed actions != "all" (selected / local_only) — canonical/product only + +CRA enrolment is detected via `gh api` against +canonical/canonical-repo-automation. If gh is unavailable or the query fails, +the check emits `unknown` and falls back to checking live settings. +""" +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, EXIT_UNKNOWN, + emit_check, origin_url, parse_tier, run, tier_applies, +) + +CHECK_ID = "repo-settings" +APPLIES = "product,canonical,personal" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + url = origin_url() + slug = url[len("https://github.com/"):] if url.startswith("https://github.com/") else url + + if not slug or slug == url: + emit_check(CHECK_ID, "unknown", "Could not parse owner/repo from origin URL.") + return EXIT_UNKNOWN + + owner = slug.split("/", 1)[0] + name = slug.rsplit("/", 1)[-1] + + if not shutil.which("gh"): + emit_check(CHECK_ID, "unknown", "gh CLI not installed; cannot inspect live repo settings or CRA enrolment.") + return EXIT_UNKNOWN + + # --- 1. CRA enrolment --- + managed_by_cra = False + cra_path = "" + if tier != "personal" and owner == "canonical": + r = run(["gh", "api", "repos/canonical/canonical-repo-automation", "--jq", ".default_branch"]) + cra_branch = r.stdout.strip() if r.returncode == 0 else "main" + if not cra_branch: + cra_branch = "main" + r2 = run([ + "gh", "api", + f"repos/canonical/canonical-repo-automation/git/trees/{cra_branch}?recursive=1", + "--jq", f'.tree[].path | select(test("(^|/)repos/{name}/"))', + ]) + if r2.returncode == 0: + first = "" + for ln in r2.stdout.splitlines(): + if ln.strip(): + first = ln.strip() + break + if first: + managed_by_cra = True + cra_path = first + + # --- 2. Live settings --- + r = run(["gh", "api", f"repos/{slug}"]) + if r.returncode != 0: + emit_check( + CHECK_ID, "unknown", + f"Could not fetch repos/{slug} via gh api (auth scope or network).", + {"slug": slug}, + ) + return EXIT_UNKNOWN + + try: + repo_json = json.loads(r.stdout) + except json.JSONDecodeError: + emit_check( + CHECK_ID, "unknown", + f"Could not fetch repos/{slug} via gh api (auth scope or network).", + {"slug": slug}, + ) + return EXIT_UNKNOWN + + def get(obj, *keys, default="unknown"): + cur = obj + for k in keys: + if not isinstance(cur, dict): + return default + cur = cur.get(k) + if cur is None: + return default + return cur + + def bool_or_str(v): + if v is True: + return "true" + if v is False: + return "false" + if v is None: + return "null" + return str(v) + + squash = bool_or_str(repo_json.get("allow_squash_merge")) + merge_commit = bool_or_str(repo_json.get("allow_merge_commit")) + rebase = bool_or_str(repo_json.get("allow_rebase_merge")) + delete_on_merge = bool_or_str(repo_json.get("delete_branch_on_merge")) + + sa = repo_json.get("security_and_analysis") or {} + ss_secret = (sa.get("secret_scanning") or {}).get("status") or "unknown" + ss_push = (sa.get("secret_scanning_push_protection") or {}).get("status") or "unknown" + ss_dep = (sa.get("dependabot_security_updates") or {}).get("status") or "unknown" + + pvr = "unknown" + r = run(["gh", "api", f"repos/{slug}/private-vulnerability-reporting"]) + if r.returncode == 0: + try: + pvr_json = json.loads(r.stdout) + pvr = bool_or_str(pvr_json.get("enabled")) + except json.JSONDecodeError: + pvr = "unknown" + + allowed_actions = "unknown" + if tier != "personal": + r = run(["gh", "api", f"repos/{slug}/actions/permissions"]) + if r.returncode == 0: + try: + perms_json = json.loads(r.stdout) + allowed_actions = perms_json.get("allowed_actions") or "unknown" + except json.JSONDecodeError: + allowed_actions = "unknown" + + problems: list[str] = [] + unverifiable: list[str] = [] + + if squash != "true": + problems.append("allow_squash_merge != true") + if merge_commit != "false": + problems.append("allow_merge_commit != false") + if rebase != "false": + problems.append("allow_rebase_merge != false") + if delete_on_merge != "true": + problems.append("delete_branch_on_merge != true") + + def verify_admin_field(name: str, value: str, want: str) -> None: + if value in ("unknown", "null", ""): + unverifiable.append(f"{name} (token lacks admin scope)") + elif value == want: + return + else: + problems.append(f"{name} != {want} ({value})") + + verify_admin_field("secret_scanning", ss_secret, "enabled") + verify_admin_field("secret_scanning_push_protection", ss_push, "enabled") + verify_admin_field("dependabot_security_updates", ss_dep, "enabled") + verify_admin_field("private_vulnerability_reporting", pvr, "true") + + if tier != "personal": + if allowed_actions in ("selected", "local_only"): + pass + elif allowed_actions in ("unknown", "null", ""): + unverifiable.append("allowed_actions (token lacks admin scope)") + else: + problems.append(f"allowed_actions={allowed_actions} (expected 'selected' or 'local_only')") + + unverifiable_note = "" + if unverifiable: + unverifiable_note = f" (unverifiable from this token: {'; '.join(unverifiable)})" + + evidence = { + "slug": slug, + "managed_by_cra": managed_by_cra, + "cra_path": cra_path, + "allow_squash_merge": squash, + "allow_merge_commit": merge_commit, + "allow_rebase_merge": rebase, + "delete_branch_on_merge": delete_on_merge, + "secret_scanning": ss_secret, + "push_protection": ss_push, + "dependabot_security_updates": ss_dep, + "private_vulnerability_reporting": pvr, + "allowed_actions": allowed_actions, + } + + if managed_by_cra and not problems: + emit_check( + CHECK_ID, "pass", + f"Settings declared in CRA ({cra_path}); merge/branch-deletion posture matches the baseline{unverifiable_note}.", + evidence, + ) + return EXIT_PASS + + if managed_by_cra and problems: + joined = "; ".join(problems) + emit_check( + CHECK_ID, "fail", + f"Repo is declared in CRA ({cra_path}) but live settings drift from the baseline: {joined}. Run a CRA apply to reconcile; do not patch live settings directly.", + evidence, + {"kind": "judgement", "human_review": "Drift between CRA-declared and live settings. Re-apply CRA for the relevant group rather than mutating GitHub directly — direct patches will be overwritten on the next apply."}, + ) + return EXIT_FAIL + + if not problems: + if tier == "personal": + emit_check( + CHECK_ID, "pass", + f"Live settings match the baseline (personal tier — CRA enrolment not expected){unverifiable_note}.", + evidence, + ) + return EXIT_PASS + emit_check( + CHECK_ID, "pass", + f"Live settings match the baseline. Not declared in CRA — confirm whether this repo should be enrolled in canonical-repo-automation{unverifiable_note}.", + evidence, + ) + return EXIT_PASS + + joined = "; ".join(problems) + if tier == "personal": + emit_check( + CHECK_ID, "fail", + f"Live settings drift from baseline: {joined}.", + evidence, + {"kind": "mechanical", "script": "scripts/fixes/apply-repo-settings.py", "human_review": "Review each setting before applying; the fix script patches the repo via gh api."}, + ) + return EXIT_FAIL + + emit_check( + CHECK_ID, "fail", + f"Repo is NOT declared in canonical-repo-automation and live settings drift from baseline: {joined}. Either enrol the repo in CRA (preferred for canonical-owned repos) or apply the settings manually.", + evidence, + {"kind": "judgement", "human_review": "Preferred: open a CRA PR declaring this repo under the appropriate groups//repos/ tree so settings are managed centrally. Fallback (if CRA enrolment is intentionally out of scope): run scripts/fixes/apply-repo-settings.py to patch the live settings via gh api."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/sbom-workflow.py b/skills/engineering/charm-tech-baseline/scripts/checks/sbom-workflow.py new file mode 100755 index 0000000..1c16598 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/sbom-workflow.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Check: SBOM workflow / manifest present and triggered per cycle. +Tier coverage: product only. + +Mandate: SEC0027 (every release type). Generated via sbom-request.canonical.com. +Some repos carry an in-repo manifest (.sbomber-manifest-*.yaml); some +integrate the SBOM request as a CI workflow step. + +A `workflow_dispatch:`-only workflow satisfies presence but not cadence — +SBOM must be regenerated every release / cycle. Pass requires at least one +of the cadence triggers: `release`, `schedule`, or tag-pushes +(`push: tags:` or `push: branches:` + `tags:` filter). Manifests-only repos +are accepted unconditionally — the manifest is consumed by an external +sbom-request pipeline that owns its own cadence. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "sbom-workflow" +APPLIES = "product" + +WORKFLOW_KEY_RE = re.compile(r"sbom-request|sbomber|sbom-secscan", re.IGNORECASE) + + +def find_manifests() -> list[str]: + hits: list[str] = [] + gh = Path(".github") + root_candidates: list[Path] = [] + # maxdepth 2: .github + .github/ + if gh.is_dir(): + for p in gh.iterdir(): + if p.is_file() and ( + re.search(r"sbomber-manifest.*\.ya?ml$", p.name) + or re.search(r"^sbom.*\.ya?ml$", p.name) + ): + root_candidates.append(p) + if p.is_dir(): + for p2 in p.iterdir(): + if p2.is_file() and ( + re.search(r"sbomber-manifest.*\.ya?ml$", p2.name) + or re.search(r"^sbom.*\.ya?ml$", p2.name) + ): + root_candidates.append(p2) + for p in root_candidates[:3]: + hits.append(str(p)) + return hits + + +def find_workflow() -> str: + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + return "" + for p in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + if WORKFLOW_KEY_RE.search(p.read_text(errors="replace")): + return str(p) + except OSError: + continue + return "" + + +def cadence_check_yaml(workflow: str) -> tuple[bool, str]: + """Return (ok, reason). reason is empty on ok.""" + try: + import yaml # type: ignore + except Exception: + return cadence_check_grep(workflow) + try: + with open(workflow) as f: + doc = yaml.safe_load(f) + except Exception as e: + return False, f"could not parse workflow triggers (PARSE_ERROR {e})" + if not isinstance(doc, dict): + return False, "could not parse workflow triggers (UNKNOWN_ON_SHAPE)" + on = doc.get(True) + if on is None: + on = doc.get("on") + if on is None: + return False, "could not parse workflow triggers (MISSING_ON)" + if isinstance(on, str): + on = {on: None} + elif isinstance(on, list): + on = {k: None for k in on} + if not isinstance(on, dict): + return False, "could not parse workflow triggers (UNKNOWN_ON_SHAPE)" + triggers = set(on.keys()) + if "release" in triggers or "schedule" in triggers: + return True, "" + push = on.get("push") + if isinstance(push, dict) and ("tags" in push or "tags-ignore" in push): + return True, "" + joined = ",".join(sorted(str(t) for t in triggers)) + return False, f"workflow triggers ({joined}) include no cadence trigger (release / schedule / push: tags)" + + +def cadence_check_grep(workflow: str) -> tuple[bool, str]: + try: + text = Path(workflow).read_text(errors="replace") + except OSError: + return False, "no release/schedule/push-tags trigger found (grep fallback; install python3+PyYAML for accurate check)" + if re.search(r"^[ \t]*(release|schedule):", text, re.MULTILINE): + return True, "" + if re.search(r"push:[ \t]*\n[ \t]+tags:", text): + return True, "" + return False, "no release/schedule/push-tags trigger found (grep fallback; install python3+PyYAML for accurate check)" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + manifests = find_manifests() + workflow = find_workflow() + + if not manifests and not workflow: + emit_check( + CHECK_ID, "fail", + "No SBOM workflow or manifest found.", + {}, + {"kind": "judgement", "human_review": "Request SBOM via sbom-request.canonical.com Web UI or REST API; store in the SSDLC Artifacts directory and request review in ~SSDLC. Add a CI step that triggers SBOM generation per release if useful."}, + ) + return EXIT_FAIL + + cadence_ok = True + cadence_reason = "" + if workflow: + cadence_ok, cadence_reason = cadence_check_yaml(workflow) + + parts = list(manifests) + ([workflow] if workflow else []) + found = ",".join(parts).strip().strip(",") + + if cadence_ok: + emit_check( + CHECK_ID, "pass", + "SBOM workflow / manifest present with per-cycle cadence.", + {"found": found}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + f"SBOM workflow present but cadence not guaranteed: {cadence_reason}.", + {"found": found, "workflow": workflow}, + {"kind": "judgement", "human_review": "SBOM must regenerate per release/cycle. Add `on: release: types: [published]` (preferred for release-cut workflows) or `on: schedule:` (for unreleased products) or `on: push: tags: [v*]`. workflow_dispatch alone is not sufficient."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/sec0030-coverage.py b/skills/engineering/charm-tech-baseline/scripts/checks/sec0030-coverage.py new file mode 100755 index 0000000..c3d5dfb --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/sec0030-coverage.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Check: SEC0030 V1.3 Security Documentation coverage. +Tier coverage: product only. + +Looks for either docs/explanation/security.md (Sphinx-stack convention) +or an expanded SECURITY.md that covers the seven V1.3 sections. +The check is heuristic — it looks for headings, not deep content. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "sec0030-coverage" +APPLIES = "product" + +REQUIRED = [ + ("Product architecture", "Product architecture"), + ("Secure by design", "Secure by design"), + ("Cryptography", "Crypt"), + ("Hardening", "Hardening"), + ("Logging and monitoring", "Logging|Monitoring"), + ("Decommissioning", "Decommissioning"), + ("Security lifecycle", "Security lifecycle|Security updates"), +] + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + target = "" + for path in ("docs/explanation/security.md", "docs/security.md", "SECURITY.md"): + if Path(path).is_file(): + target = path + break + + if not target: + emit_check( + CHECK_ID, "fail", + "No security documentation found (docs/explanation/security.md or expanded SECURITY.md).", + {}, + {"kind": "judgement", "human_review": "Either author docs/explanation/security.md (preferred) or expand SECURITY.md to cover the seven SEC0030 V1.3 sections."}, + ) + return EXIT_FAIL + + text = Path(target).read_text(errors="replace") + missing_parts: list[str] = [] + for label, pattern in REQUIRED: + rx = re.compile(rf"^#{{1,4}} .*({pattern})", re.IGNORECASE | re.MULTILINE) + if not rx.search(text): + missing_parts.append(label) + + if not missing_parts: + emit_check( + CHECK_ID, "pass", + f"SEC0030 V1.3 coverage looks complete in {target}.", + {"path": target}, + ) + return EXIT_PASS + + trimmed = "; ".join(missing_parts) + emit_check( + CHECK_ID, "fail", + f"SEC0030 V1.3 missing section(s) in {target}: {trimmed}", + {"path": target, "missing": trimmed}, + {"kind": "judgement", "human_review": "Add the missing section(s). See operator#2571, pebble#893, jubilant#332, charm-ubuntu#87 for reference patterns (sentence-case headers, Mermaid diagrams, bulleted hardening with To-harden intro, channels-bullet-list at end of Reporting)."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/sec0045-events.py b/skills/engineering/charm-tech-baseline/scripts/checks/sec0045-events.py new file mode 100755 index 0000000..22126ce --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/sec0045-events.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Check: SEC0045 Security Event Logging — heuristic. +Tier coverage: product only. + +Applicability is per-product (see references/decisions.md). Where the +per-product disposition is settled we short-circuit. For other products we +look for evidence the OWASP Application Logging Vocabulary has been +adopted — either by name reference (OWASP / owasp-logger / securitylog) or +by emitted event-name tokens (authn_*, authz_*, sys_*, user_created/updated, +excessive_use, malicious_*, input_validation_*). + +Output is informational: a pass means evidence exists, NOT that the events +match the doc's required set. A fail means the agent should confirm whether +the product genuinely has no auth/admin/user surface, or whether logging is +missing. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, origin_url, parse_tier, tier_applies, +) + +CHECK_ID = "sec0045-events" +APPLIES = "product" + +NAMED_RE = re.compile(r"OWASP|owasp-logger|securitylog|security_event|security-event") +OWASP_RE = re.compile( + r"authn_(login|password|token|impersonation|create|sso|2fa)_(succ|fail|change|created|revoked|expired|lock|use|unlock)" + r"|authz_(fail|change|admin|impersonation)" + r"|excessive_use" + r"|input_validation_(fail)" + r"|malicious_(direct_reference|attack_tool|cors|excess_use)" + r"|sys_(startup|shutdown|restart|crash|monitor_disabled|monitor_enabled|config_change)" + r"|user_(created|updated|deleted|archived|suspended)" + r"|session_(created|expired|use_after_expire|hijacked|renewed)" +) + + +def find_matches(pattern: re.Pattern[str], max_hits: int = 5) -> list[str]: + hits: list[str] = [] + for ext in ("*.go", "*.py"): + for p in Path(".").rglob(ext): + if not p.is_file(): + continue + try: + text = p.read_text(errors="replace") + except OSError: + continue + if pattern.search(text): + # strip leading ./ to match grep -rEl output shape + s = str(p) + if not s.startswith("./") and not s.startswith("/"): + s = "./" + s + hits.append(s) + if len(hits) >= max_hits: + return hits + return hits + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + url = origin_url() + slug = url[len("https://github.com/"):] if url.startswith("https://github.com/") else url + + if slug == "canonical/operator": + emit_check(CHECK_ID, "pass", "SEC0045 done long ago via canonical/operator#1905.") + return EXIT_PASS + if slug in ("canonical/jubilant", "canonical/pytest-jubilant"): + emit_check(CHECK_ID, "na", "Out of scope: no user/admin/auth surface.") + return EXIT_NA + if slug == "canonical/charmlibs": + emit_check(CHECK_ID, "na", "Applicable but deferred to a future cycle.") + return EXIT_NA + + named_evidence = find_matches(NAMED_RE) + token_evidence = find_matches(OWASP_RE) + + if token_evidence: + found = ",".join(token_evidence) + emit_check( + CHECK_ID, "pass", + "Code emits OWASP Application Logging Vocabulary event tokens.", + {"evidence_files": found, "signal": "event-name-tokens"}, + ) + return EXIT_PASS + + if named_evidence: + found = ",".join(named_evidence) + emit_check( + CHECK_ID, "pass", + "Code references SEC0045 / OWASP security event logging by name (but no specific event-name tokens detected — confirm the 17 OWASP events are covered).", + {"evidence_files": found, "signal": "name-reference-only"}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "No SEC0045 security-event logging detected: neither OWASP-named files nor any event-name tokens (authn_/authz_/sys_/user_/session_/excessive_use/malicious_/input_validation_). Confirm applicability per the per-product disposition in references/decisions.md.", + {}, + {"kind": "judgement", "human_review": "If the product emits user/admin/auth events, implement the OWASP Application Logging Vocabulary events in JSON (or logfmt) per canonical/operator#1905 / canonical/concierge#208. The 17 events span authn_*, authz_*, sys_*, user_*, session_*, plus excessive_use, malicious_*, input_validation_*."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/secscan-workflow.py b/skills/engineering/charm-tech-baseline/scripts/checks/secscan-workflow.py new file mode 100755 index 0000000..f09e4dc --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/secscan-workflow.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Check: canonical-secscan-client (or equivalent) workflow present, with the +SSDLC identification details wired up so results land in the long-term scan +registry. +Tier coverage: product only. + +Mandate: SEC0025. Run at least once per cycle per product with SSDLC +identification. + +Two flavours are accepted: + +1. **sbomber-driven** (Charm Tech default): the workflow checks out or + invokes `canonical/sbomber`, and the repo carries one or more + `.sbomber-manifest*.yaml` files (root or under .github/). SSDLC + identification lives in `ssdlc_params:` blocks per artifact inside the + manifest; the secscan client is enabled via `clients.secscan` in the + manifest. Pass requires: workflow + at least one manifest with both + `clients.secscan` and per-artifact `ssdlc_params`. + +2. **Direct canonical-secscan-client**: the workflow runs the client + directly (or via cs-github-actions / starflow). Pass requires the + --ssdlc-product-name / --ssdlc-cycle CLI parameters. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "secscan-workflow" +APPLIES = "product" + +SBOMBER_RE = re.compile(r"canonical/sbomber|\./sbomber|sbomber/sbomber", re.IGNORECASE) +DIRECT_RE = re.compile(r"canonical-secscan-client|run-secscan|sbom-secscan|scan-python", re.IGNORECASE) +SECSCAN_KEY_RE = re.compile(r"^\s*secscan\s*:", re.MULTILINE) +SSDLC_KEY_RE = re.compile(r"^\s*ssdlc_params\s*:", re.MULTILINE) +SSDLC_CLI_RE = re.compile(r"ssdlc-product-name|ssdlc-cycle") + + +def find_first(pattern: re.Pattern[str], files: list[Path]) -> str: + for p in files: + try: + if pattern.search(p.read_text(errors="replace")): + return str(p) + except OSError: + continue + return "" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, "fail", + "No .github/workflows directory.", + {}, + {"kind": "judgement", "human_review": "Set up workflows and wire secscan."}, + ) + return EXIT_FAIL + + workflows = sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))) + + sbomber_workflow = find_first(SBOMBER_RE, workflows) + direct_workflow = find_first(DIRECT_RE, workflows) + + if not sbomber_workflow and not direct_workflow: + emit_check( + CHECK_ID, "fail", + "No secscan workflow found.", + {}, + {"kind": "judgement", "human_review": "Reference: canonical/sbomber composite action (Charm Tech default), canonical/cs-github-actions run-secscan, or canonical/starflow scan-python. Use the --batch instance; GitHub private runners are allow-listed."}, + ) + return EXIT_FAIL + + if sbomber_workflow: + workflow_hit = sbomber_workflow + # Find manifests up to 3 levels deep, excluding .git + manifests: list[str] = [] + for pattern in (".sbomber-manifest*.yaml", ".sbomber-manifest*.yml"): + for p in Path(".").rglob(pattern): + # Depth check: <=3 components from root + parts = p.parts + if len(parts) > 3: + continue + if any(part == ".git" for part in parts): + continue + manifests.append(str(p)) + + if not manifests: + emit_check( + CHECK_ID, "fail", + "sbomber workflow present but no .sbomber-manifest*.yaml found at repo root or under .github/.", + {"workflow": workflow_hit}, + {"kind": "judgement", "human_review": "Add a .sbomber-manifest-.yaml describing artifacts, with clients.secscan enabled and ssdlc_params per artifact. See canonical/sbomber/examples/all/manifest.yaml."}, + ) + return EXIT_FAIL + + problems: list[str] = [] + has_secscan = False + has_ssdlc = False + for m in manifests: + try: + text = Path(m).read_text(errors="replace") + except OSError: + continue + if SECSCAN_KEY_RE.search(text): + has_secscan = True + if SSDLC_KEY_RE.search(text): + has_ssdlc = True + + if not has_secscan: + problems.append("no manifest declares clients.secscan") + if not has_ssdlc: + problems.append("no manifest carries per-artifact ssdlc_params") + + evidence = { + "workflow": workflow_hit, + "driver": "sbomber", + "manifests": manifests, + } + + if not problems: + emit_check( + CHECK_ID, "pass", + "sbomber workflow present; manifest enables secscan client and carries ssdlc_params.", + evidence, + ) + return EXIT_PASS + + joined = "; ".join(problems) + emit_check( + CHECK_ID, "fail", + f"sbomber workflow present but manifest incomplete: {joined}.", + evidence, + {"kind": "judgement", "human_review": "In the .sbomber-manifest*.yaml, ensure clients.secscan is enabled and every artifact declares ssdlc_params (name/version/channel) — these are what the SSDLC scan registry indexes."}, + ) + return EXIT_FAIL + + # Direct client path. + workflow_hit = direct_workflow + try: + text = Path(workflow_hit).read_text(errors="replace") + except OSError: + text = "" + if SSDLC_CLI_RE.search(text): + emit_check( + CHECK_ID, "pass", + "secscan workflow present with SSDLC identification parameters.", + {"workflow": workflow_hit, "driver": "canonical-secscan-client"}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "secscan workflow present but --ssdlc-* identification parameters missing.", + {"workflow": workflow_hit, "driver": "canonical-secscan-client"}, + {"kind": "judgement", "human_review": "Pass --ssdlc-product-name, --ssdlc-cycle, --ssdlc-product-channel, --ssdlc-product-version so results land in the long-term SSDLC scan registry. (Or migrate to canonical/sbomber and move identification into the manifest ssdlc_params blocks.)"}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/security-md.py b/skills/engineering/charm-tech-baseline/scripts/checks/security-md.py new file mode 100755 index 0000000..7443ea0 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/security-md.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Check: SECURITY.md exists and references the Ubuntu disclosure policy. +Tier coverage: all (product, canonical, personal). + +Mandate: SEC0025 §General Requirements + SEC0026 (Canonical-internal); +best practice for personal-tier. + +Pass: SECURITY.md present AND links to ubuntu.com/security/disclosure-policy + OR to security@ubuntu.com / security@canonical.com +Fail: SECURITY.md missing, OR present but no disclosure-policy link/contact +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "security-md" +APPLIES = "product,canonical,personal" + +PATTERN = re.compile(r"ubuntu\.com/security/disclosure-policy|security@(ubuntu|canonical)\.com|security/advisories", re.IGNORECASE) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + p = Path("SECURITY.md") + if not p.is_file(): + emit_check( + CHECK_ID, "fail", + "SECURITY.md is missing.", + {}, + {"kind": "mechanical", "script": "scripts/fixes/add-security-md.py", "human_review": "Customise the disclosure contact and supported-versions table."}, + ) + return EXIT_FAIL + + text = p.read_text(errors="replace") + if PATTERN.search(text): + lines = text.count("\n") + emit_check( + CHECK_ID, "pass", + "SECURITY.md present and references the Ubuntu disclosure policy.", + {"path": "SECURITY.md", "lines": lines}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "SECURITY.md present but does not reference the Ubuntu disclosure policy or a security contact.", + {"path": "SECURITY.md"}, + {"kind": "judgement", "human_review": "Add a Reporting section linking https://ubuntu.com/security/disclosure-policy and the project security contact."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/threat-model-drive.py b/skills/engineering/charm-tech-baseline/scripts/checks/threat-model-drive.py new file mode 100755 index 0000000..b48b72e --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/threat-model-drive.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Check: Threat model — informational only. +Tier coverage: product only. + +Threat models live in the central SSDLC Artifacts Drive. This check +emits an informational note prompting the agent to confirm the +Drive sheet is current for the cycle. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_NA, EXIT_PASS, + emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "threat-model-drive" +APPLIES = "product" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + emit_check( + CHECK_ID, "unknown", + "Cannot verify from the repo — threat models live in the SSDLC Artifacts Drive. Confirm a refreshed model exists for this cycle.", + {}, + {"kind": "judgement", "human_review": "SEC0028: refresh every release cycle; demonstrate no unacceptable residual risk; any accepted risk needs a Risk Acceptance Form. Charm SDK consolidated sheet covers ops/ops-scenario/ops-tracing/jubilant/concierge; pebble has its own sheet."}, + ) + return EXIT_PASS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/tiobe-config.py b/skills/engineering/charm-tech-baseline/scripts/checks/tiobe-config.py new file mode 100755 index 0000000..cbf815c --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/tiobe-config.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Check: TIOBE TICS workflow present, wired with the auth token, and the +language-specific linters TICS needs are declared as project dependencies. +Tier coverage: product only. + +Mandate: SEC0024 (Static Code Analysis). TIOBE TICS is the required +SCA tool for SSDLC satisfaction; additional scanners are encouraged +but do not substitute. + +Pass: A workflow under .github/workflows/ invokes tiobe/tics-github-action, + references `secrets.TICSAUTHTOKEN`, and the per-language linters + (Python: flake8 + pylint; Go: staticcheck) are visible somewhere + in the repo (workflow install step, pyproject.toml dep group, + Makefile, or go.mod tooling). +Fail: Any of the above missing. + +Notes: This script does NOT verify the TQI target spreadsheet entry, the +Coverage XML artefact path, or the actual TICS dashboard score (all live +outside the repo). The `tqi-security-target` check covers the spreadsheet +entry; coverage-XML is left as a per-repo judgement. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "tiobe-config" +APPLIES = "product" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check( + CHECK_ID, "fail", + "No .github/workflows/ directory; cannot host TIOBE TICS workflow.", + {}, + {"kind": "judgement", "human_review": "Add a tiobe.yaml workflow per https://canonical-tiobe-docs.canonical.com/ — needs the self-hosted tiobe runner and viewer config selection."}, + ) + return EXIT_FAIL + + hits: list[str] = [] + for p in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + if "tiobe/tics-github-action" in p.read_text(errors="replace"): + hits.append(str(p)) + except OSError: + continue + + if not hits: + emit_check( + CHECK_ID, "fail", + "No TIOBE TICS workflow found under .github/workflows/.", + {}, + {"kind": "judgement", "human_review": "Add a tiobe.yaml workflow using the self-hosted tiobe runner and the appropriate viewer config (GoProjects for Go; default for Python)."}, + ) + return EXIT_FAIL + + workflow = hits[0] + problems: list[str] = [] + + workflow_text = Path(workflow).read_text(errors="replace") + if not re.search(r"secrets\.TICSAUTHTOKEN", workflow_text): + problems.append("workflow does not reference secrets.TICSAUTHTOKEN") + + # Determine language. + language = "unknown" + if Path("pyproject.toml").is_file() or any(Path(".").glob("*.py")) or Path("requirements.txt").is_file() or Path("setup.cfg").is_file(): + language = "python" + elif Path("go.mod").is_file(): + language = "go" + + # Aggregate content from candidate files. + def gather() -> str: + parts = [workflow_text] + for f in ("pyproject.toml", "requirements.txt", "requirements-dev.txt", "setup.cfg", "Makefile", "go.mod", "tools.go"): + p = Path(f) + if p.is_file(): + try: + parts.append(p.read_text(errors="replace")) + except OSError: + pass + return "\n".join(parts) + + haystack = gather() + + def linter_hit(pattern: str) -> bool: + return re.search(pattern, haystack, re.IGNORECASE) is not None + + if language == "python": + if not linter_hit(r"(^|[^a-z])flake8([^a-z]|$)"): + problems.append("flake8 not declared in workflow/pyproject/requirements/Makefile") + if not linter_hit(r"(^|[^a-z])pylint([^a-z]|$)"): + problems.append("pylint not declared in workflow/pyproject/requirements/Makefile") + elif language == "go": + if not linter_hit(r"staticcheck"): + problems.append("staticcheck not declared in workflow/Makefile/go.mod/tools.go") + + evidence = {"workflow": workflow, "language": language} + + if not problems: + emit_check( + CHECK_ID, "pass", + "TIOBE TICS workflow present, TICSAUTHTOKEN wired, and language linters declared.", + evidence, + ) + return EXIT_PASS + + joined = "; ".join(problems) + emit_check( + CHECK_ID, "fail", + f"TIOBE TICS workflow present but incomplete: {joined}.", + evidence, + {"kind": "judgement", "human_review": "Add secrets.TICSAUTHTOKEN to the workflow env (TICS publishes nothing without it). For Python repos, declare flake8 and pylint in a [dependency-groups] block (or install them in the workflow). For Go repos, add staticcheck via a tools.go entry, Makefile target, or workflow install step. Also confirm a Cobertura coverage artefact is produced before the TICS step runs — that is repo-specific and not auto-verified here."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/tqi-security-target.py b/skills/engineering/charm-tech-baseline/scripts/checks/tqi-security-target.py new file mode 100755 index 0000000..8d94fc1 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/tqi-security-target.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Check: TQI security target — informational only. +Tier coverage: product only. + +The TQI target lives in the central *TiCS Targets 26.10* spreadsheet, +not the repo. This check just emits an informational note prompting +the agent to verify the target is recorded for the cycle. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_NA, EXIT_PASS, + emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "tqi-security-target" +APPLIES = "product" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + emit_check( + CHECK_ID, "unknown", + "Cannot verify from the repo — the TQI security target lives in the *TiCS Targets 26.10* spreadsheet. Confirm a target is recorded for this product.", + {}, + {"kind": "judgement", "human_review": "Set/verify the per-repo Security metric (TQI) target in *TiCS Targets 26.10* by 30 June."}, + ) + return EXIT_PASS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/trusted-publishing.py b/skills/engineering/charm-tech-baseline/scripts/checks/trusted-publishing.py new file mode 100755 index 0000000..caf07a8 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/trusted-publishing.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Check: every PyPI publish path uses Trusted Publishing (OIDC), not a long-lived +API token. Detects: + - `pypa/gh-action-pypi-publish` invocations — pass requires NO `password:` + or `username:` input, and the surrounding job (or workflow) must declare + `id-token: write`. + - `twine upload` invocations — fail; twine is the long-lived-token path. + +Emits `na` when no PyPI publish path is present (Python project that doesn't +publish, or non-Python repo). Tier coverage: all tiers — anyone publishing +to PyPI from GitHub Actions should use Trusted Publishing. + +Reference: BASELINE.md "All Python-publishing repos already use Trusted +Publishing (OIDC id-token: write + pypa/gh-action-pypi-publish)". +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "trusted-publishing" +APPLIES = "product,canonical,personal" + +TOKEN_INPUT_RE = re.compile(r"^[ \t]*(password|username):", re.MULTILINE) +ID_TOKEN_WRITE_RE = re.compile(r"^[ \t]*id-token:[ \t]*write\b", re.MULTILINE) +TWINE_RE = re.compile(r"(^|[ \t])twine[ \t]+upload\b", re.MULTILINE) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check(CHECK_ID, "na", "No .github/workflows directory; no publish workflow to audit.") + return EXIT_NA + + workflows = sorted( + [str(p) for p in wf_dir.iterdir() if p.is_file() and p.suffix in (".yml", ".yaml")] + ) + if not workflows: + emit_check(CHECK_ID, "na", "No workflows under .github/workflows.") + return EXIT_NA + + publish_files: list[str] = [] + twine_files: list[str] = [] + bad_token_files: list[str] = [] + missing_id_token_files: list[str] = [] + + for wf in workflows: + try: + text = Path(wf).read_text(errors="replace") + except OSError: + continue + if "pypa/gh-action-pypi-publish" in text: + publish_files.append(wf) + if TOKEN_INPUT_RE.search(text): + bad_token_files.append(wf) + if not ID_TOKEN_WRITE_RE.search(text): + missing_id_token_files.append(wf) + if TWINE_RE.search(text): + twine_files.append(wf) + + if not publish_files and not twine_files: + emit_check(CHECK_ID, "na", "No PyPI publish workflow detected (no pypa/gh-action-pypi-publish or twine upload).") + return EXIT_NA + + evidence = { + "publish_workflows": publish_files, + "twine_workflows": twine_files, + "missing_id_token": missing_id_token_files, + "token_inputs": bad_token_files, + } + + problems: list[str] = [] + if twine_files: + problems.append("twine upload detected (long-lived API token path)") + if bad_token_files: + problems.append("pypa/gh-action-pypi-publish invoked with password/username input") + if missing_id_token_files: + problems.append("publish workflow missing id-token: write permission") + + if not problems: + emit_check( + CHECK_ID, "pass", + "PyPI publishing uses Trusted Publishing (OIDC; id-token: write + pypa/gh-action-pypi-publish, no token input).", + evidence, + ) + return EXIT_PASS + + joined = "; ".join(problems) + emit_check( + CHECK_ID, "fail", + f"PyPI publishing is not fully on Trusted Publishing: {joined}.", + evidence, + {"kind": "judgement", "human_review": ( + "Convert the publish workflow to Trusted Publishing: drop " + "password/username inputs, add permissions: { id-token: write } " + "at the job level, configure the PyPI project/environment as a " + "Trusted Publisher, and revoke any leftover API tokens. " + "Templates (fill in REPLACE_WITH_* markers before use): " + "personal|canonical → assets/trusted-publishing.yaml.template " + "(inline CycloneDX SBOM + dual attestation); " + "product → assets/trusted-publishing-product.yaml.template + " + "assets/sbom-secscan.yaml.template + " + "assets/sbomber-manifest-{sdist,wheel}.yaml.template. " + "Before committing, modernise pinned action versions: for every " + "third-party action, look up the latest release on GitHub, pin " + "it by commit SHA, and update the `# vX.Y.Z` comment. Environment " + "name is `publish-pypi` (fleet convention). The result must pass " + "zizmor with no findings." + )}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/uv-exclude-newer.py b/skills/engineering/charm-tech-baseline/scripts/checks/uv-exclude-newer.py new file mode 100755 index 0000000..b803c88 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/uv-exclude-newer.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Check: pyproject.toml sets `[tool.uv].exclude-newer` to a rolling +quarantine of at least 7 days. + +Rationale (Canonical Security "How-To: Secure a repo" — Minimum +release age section): a package-manager-level cooldown protects +every dep-resolution path (manual `uv add`, `uv lock` regens, uvx +bootstraps, CI re-resolves) that Dependabot cooldown alone doesn't +cover — Dependabot cooldown only affects PRs Dependabot itself opens. + +uv's `exclude-newer` accepts three formats per the docs: + - RFC 3339 timestamps (absolute snapshot; e.g. 2026-01-01T00:00:00Z) + - Friendly durations (rolling window; e.g. "7 days", "1 week") + - ISO 8601 durations (rolling window; e.g. "P7D", "P30D") + +Prefer a rolling window ("7 days" / "P7D"). Absolute timestamps also +accepted but flagged in evidence: they freeze resolution to a moment +and drift silently as they age. + +Tier coverage: all tiers. +`na` when there's no `pyproject.toml`, no `[tool.uv]`, or (for tiers +where uv isn't in use) no `uv.lock`. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, EXIT_UNKNOWN, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "uv-exclude-newer" +APPLIES = "product,canonical,personal" +MIN_DAYS = 7 + + +def parse_days(v: str): + """Classify the value: + RFC3339 timestamp: contains 'T' and ends with 'Z' or timezone offset. + ISO 8601 duration: matches /^P(?:\\d+[YMWD])+(?:T(?:\\d+[HMS])+)?$/ or PT... + Friendly duration: matches a number + unit word (hours/days/weeks/etc.) + """ + v = v.strip() + # RFC 3339 timestamp + if re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}', v): + return ("snapshot", None) + # ISO 8601 duration + m = re.match(r'^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$', v) + if m: + y, mo, w, d, h, mi, s = (int(x) if x else 0 for x in m.groups()) + # Approx: month=30d, year=365d. + days = y*365 + mo*30 + w*7 + d + h/24 + mi/1440 + s/86400 + return ("iso8601", days) + # Friendly duration: parse "N unit" tokens; sum in days. + unit_days = { + "second": 1/86400, "seconds": 1/86400, "sec": 1/86400, "s": 1/86400, + "minute": 1/1440, "minutes": 1/1440, "min": 1/1440, "m": 1/1440, + "hour": 1/24, "hours": 1/24, "hr": 1/24, "hrs": 1/24, "h": 1/24, + "day": 1, "days": 1, "d": 1, + "week": 7, "weeks": 7, "w": 7, + "month": 30, "months": 30, "mon": 30, "mo": 30, + "year": 365, "years": 365, "yr": 365, "y": 365, + } + total = 0.0 + matched = False + for num, unit in re.findall(r'(\d+(?:\.\d+)?)\s*([A-Za-z]+)', v): + u = unit.lower().rstrip('.') + if u not in unit_days: + return ("unknown", None) + total += float(num) * unit_days[u] + matched = True + if matched: + return ("friendly", total) + return ("unknown", None) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + if not Path("pyproject.toml").is_file(): + emit_check(CHECK_ID, "na", "No pyproject.toml — not a uv project.") + return EXIT_NA + + # Parse [tool.uv] and inspect exclude-newer. Needs python3+tomllib + # (stdlib in 3.11+) — fall back to a text-only presence check when the + # parser isn't available. + try: + import tomllib + have_parser = True + except ImportError: + have_parser = False + + if not have_parser: + # Fallback: presence check on `exclude-newer` inside `[tool.uv]`. + # This is intentionally cheap; agents on hosts without Python 3.11+ + # can still get a signal. + text = Path("pyproject.toml").read_text(errors="replace") + if re.search(r'^\s*exclude-newer\s*=', text, re.MULTILINE) and \ + re.search(r'^\[tool\.uv\]', text, re.MULTILINE): + emit_check( + CHECK_ID, "pass", + "exclude-newer present in pyproject.toml (value not validated — python3+tomllib unavailable).", + {"parser": False, "exclude_newer_present": True}, + ) + return EXIT_PASS + emit_check( + CHECK_ID, "fail", + "No exclude-newer found under [tool.uv] in pyproject.toml (unvalidated fallback).", + {"parser": False, "exclude_newer_present": False}, + {"kind": "judgement", "human_review": "Add `exclude-newer = \"7 days\"` under [tool.uv] in pyproject.toml. Prefer a friendly-duration string (rolling window) over an RFC 3339 timestamp (absolute snapshot). See Canonical Security \"How-To: Secure a repo\" — Minimum release age."}, + ) + return EXIT_FAIL + + try: + with open("pyproject.toml", "rb") as f: + doc = tomllib.load(f) + except Exception as e: + emit_check( + CHECK_ID, "unknown", + f"Could not parse pyproject.toml: {e}", + {"parser": True}, + ) + return EXIT_UNKNOWN + + tool_uv = (doc.get("tool") or {}).get("uv") + if tool_uv is None: + # A uv.lock in the working tree means the project uses uv even + # though pyproject.toml doesn't declare [tool.uv] yet — that's + # a fail (add the section), not na. + if Path("uv.lock").is_file(): + emit_check( + CHECK_ID, "fail", + f"uv.lock present but pyproject.toml has no [tool.uv] section. Add [tool.uv] with exclude-newer = \"{MIN_DAYS} days\".", + {"parser": True, "tool_uv": False, "uv_lock_present": True}, + {"kind": "judgement", "human_review": "Add [tool.uv] to pyproject.toml with `exclude-newer = \"7 days\"`. This gives every uv resolution path (manual uv add, uv lock regens, uvx bootstraps, CI re-resolves) a rolling 7-day quarantine on fresh releases — complementing the Dependabot cooldown that only covers Dependabot-authored PRs. See Canonical Security \"How-To: Secure a repo\" — Minimum release age."}, + ) + return EXIT_FAIL + emit_check( + CHECK_ID, "na", + "pyproject.toml has no [tool.uv] section and no uv.lock — not a uv-configured project.", + {"parser": True, "tool_uv": False, "uv_lock_present": False}, + ) + return EXIT_NA + + if "exclude-newer" not in tool_uv: + emit_check( + CHECK_ID, "fail", + f"[tool.uv] present but exclude-newer not set. Add exclude-newer = \"{MIN_DAYS} days\" to give every uv resolution path (manual uv add, uv lock, uvx, CI re-resolves) a rolling {MIN_DAYS}-day quarantine on fresh releases.", + {"parser": True, "tool_uv": True, "exclude_newer_present": False}, + {"kind": "judgement", "human_review": "Add `exclude-newer = \"7 days\"` under [tool.uv] in pyproject.toml. Prefer a friendly-duration string (rolling window) over an RFC 3339 timestamp (absolute snapshot). See Canonical Security \"How-To: Secure a repo\" — Minimum release age."}, + ) + return EXIT_FAIL + + value = tool_uv["exclude-newer"] + if not isinstance(value, str): + emit_check( + CHECK_ID, "fail", + f"[tool.uv].exclude-newer is not a string: {type(value).__name__} {value!r}", + {"parser": True}, + {"kind": "judgement", "human_review": "Set exclude-newer to a string, e.g. \"7 days\"."}, + ) + return EXIT_FAIL + + kind, days = parse_days(value) + + evidence = { + "parser": True, + "exclude_newer_kind": kind, + "exclude_newer_value": value, + "exclude_newer_days": days, + } + + if kind == "snapshot": + emit_check( + CHECK_ID, "pass", + f"exclude-newer set to an RFC 3339 timestamp (absolute snapshot). Accepted but note: this freezes resolution to a moment and drifts silently as time passes; prefer a rolling friendly-duration like \"{MIN_DAYS} days\".", + evidence, + ) + return EXIT_PASS + + if kind in ("iso8601", "friendly"): + days_int = int(days) if days is not None else 0 + if days_int >= MIN_DAYS: + emit_check( + CHECK_ID, "pass", + f"exclude-newer = '{value}' ({kind}, ≈{days} days). Rolling ≥{MIN_DAYS}-day quarantine.", + evidence, + ) + return EXIT_PASS + emit_check( + CHECK_ID, "fail", + f"exclude-newer = '{value}' ({kind}, ≈{days} days) is below the {MIN_DAYS}-day baseline.", + evidence, + {"kind": "judgement", "human_review": "Widen exclude-newer to at least \"7 days\" to match the Charm Tech baseline and the existing Dependabot cooldown."}, + ) + return EXIT_FAIL + + # unknown + emit_check( + CHECK_ID, "fail", + f"exclude-newer = '{value}' did not parse as an RFC 3339 timestamp, ISO 8601 duration, or friendly duration.", + evidence, + {"kind": "judgement", "human_review": "Set exclude-newer to a friendly duration like \"7 days\" per the uv docs."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/vulnerability-response-plan.py b/skills/engineering/charm-tech-baseline/scripts/checks/vulnerability-response-plan.py new file mode 100755 index 0000000..29e4404 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/vulnerability-response-plan.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Check: Vulnerability Response plan — informational only. +Tier coverage: product, canonical. + +Lives in the SSDLC Artifacts Drive (per-product folder), not the repo. +Must be reviewed every 6 months. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_NA, EXIT_PASS, + emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "vulnerability-response-plan" +APPLIES = "product,canonical" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + emit_check( + CHECK_ID, "unknown", + "Cannot verify from the repo — Vulnerability Response plan lives in the SSDLC Artifacts Drive (per-product folder).", + {}, + {"kind": "judgement", "human_review": "SEC0026: confirm the plan has been authored and reviewed in the last 6 months. For downstream/vendored components, ensure Know-Your-Upstream is recorded (security-maintained releases, notification channels, embargo posture)."}, + ) + return EXIT_PASS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/workflow-secrets.py b/skills/engineering/charm-tech-baseline/scripts/checks/workflow-secrets.py new file mode 100755 index 0000000..1edf2fb --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/workflow-secrets.py @@ -0,0 +1,165 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyyaml"] +# /// +"""Check: workflow secret-handling hygiene. + +Follows the Canonical Security "Repository security" page (Secrets section) +— a repo-level secret-handling audit that flags patterns known to leak +secrets or over-scope them. Fleet audit 2026-07-02 (recorded in +roadmap/26.10/repo-setup/security-docs-gap.md rows #32-#36) turned up one +real hit across all 9 Charm Tech in-scope repos; this check turns that +audit into a reusable per-repo verification. + +Detects, in .github/workflows/*.y*ml: + * workflow-level `env:` blocks that reference `${{ secrets.* }}` + (over-scoped: every job/step in the workflow sees the secret) + * job-level `env:` blocks that reference `${{ secrets.* }}` + (over-scoped: every step in the job sees the secret; use step-level + env: instead) + * `run:` lines that `echo` / `printf` / `cat` a secret expression + (log-masking is not guaranteed for every transformation — the + reference page warns against this explicitly) + * `secrets: inherit` in reusable workflow calls (pass named secrets + instead so the callee's secret surface is auditable) + +Tier coverage: product, canonical. (Personal-tier: advisory — secret +handling still matters, but personal repos may lack even a workflow to +scan.) +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +import yaml + +CHECK_ID = "workflow-secrets" +APPLIES = "product,canonical,personal" + +SECRET_RE = re.compile(r"\$\{\{\s*secrets\.") +ECHO_RE = re.compile(r"^[ \t]*(-[ \t]+)?run:[ \t]*(echo|printf|cat)\b.*\$\{\{[ \t]*secrets\.") +INHERIT_RE = re.compile(r"^[ \t]*secrets:[ \t]*inherit\b") + + +def walk_env(env, scope: str, findings: list[tuple[str, str]], path_parts: list[str]) -> None: + if not isinstance(env, dict): + return + for k, v in env.items(): + if isinstance(v, str) and SECRET_RE.search(v): + findings.append((scope, ".".join(path_parts + [str(k)]))) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + wf_dir = Path(".github/workflows") + if not wf_dir.is_dir(): + emit_check(CHECK_ID, "na", "No .github/workflows/ directory.") + return EXIT_NA + + workflows = sorted( + [p for p in wf_dir.iterdir() if p.is_file() and p.suffix in (".yml", ".yaml")] + ) + if not workflows: + emit_check(CHECK_ID, "na", "No workflow files under .github/workflows/.") + return EXIT_NA + + have_parser = True # PyYAML available via PEP 723 + + echo_hits: list[str] = [] + inherit_hits: list[str] = [] + for wf in workflows: + try: + text = wf.read_text(errors="replace") + except OSError: + continue + for i, line in enumerate(text.splitlines(), start=1): + if ECHO_RE.match(line): + echo_hits.append(f"{wf}:{i}:{line}") + if INHERIT_RE.match(line): + inherit_hits.append(f"{wf}:{i}:{line}") + + env_hits: list[tuple[str, str, str]] = [] + for wf in workflows: + try: + with open(wf) as f: + doc = yaml.safe_load(f) + except Exception: + continue + if not isinstance(doc, dict): + continue + findings: list[tuple[str, str]] = [] + walk_env(doc.get("env"), "workflow", findings, []) + jobs = doc.get("jobs") or {} + if isinstance(jobs, dict): + for jname, job in jobs.items(): + if not isinstance(job, dict): + continue + walk_env(job.get("env"), "job", findings, [f"jobs.{jname}"]) + for scope, key in findings: + env_hits.append((str(wf), scope, key)) + + n_workflow = sum(1 for _, s, _ in env_hits if s == "workflow") + n_job = sum(1 for _, s, _ in env_hits if s == "job") + n_echo = len(echo_hits) + n_inherit = len(inherit_hits) + total = n_workflow + n_job + n_echo + n_inherit + + evidence = { + "workflows_scanned": len(workflows), + "parser": have_parser, + "hits": { + "workflow_env": n_workflow, + "job_env": n_job, + "echo_secret": n_echo, + "secrets_inherit": n_inherit, + }, + } + + if total == 0: + emit_check( + CHECK_ID, "pass", + f"No workflow-/job-level env: secrets, echo-secret, or secrets: inherit hits across {len(workflows)} workflow(s).", + evidence, + ) + return EXIT_PASS + + summary = ( + f"Secret-handling hits: {n_workflow} workflow-level env, {n_job} job-level env, " + f"{n_echo} echo-secret, {n_inherit} secrets: inherit." + ) + details = "" + if env_hits: + details += "env: scope hits:\n" + "\n".join(f"{w}\t{s}\t{k}" for w, s, k in env_hits) + "\n" + if echo_hits: + details += "echo/printf/cat hits (file:line:content):\n" + "\n".join(echo_hits) + "\n" + if inherit_hits: + details += "secrets: inherit hits:\n" + "\n".join(inherit_hits) + "\n" + + remediation = { + "kind": "judgement", + "human_review": "Move secrets to step-level env: (never workflow- or job-level). Never echo/printf/cat a secret expression — pass via env: and reference $VAR instead. In reusable-workflow calls, pass named secrets rather than secrets: inherit.", + } + + emit_check(CHECK_ID, "fail", summary, evidence, remediation) + if details: + sys.stderr.write(f"\n# workflow-secrets detail:\n{details}") + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/yaml-extension.py b/skills/engineering/charm-tech-baseline/scripts/checks/yaml-extension.py new file mode 100755 index 0000000..4f5c915 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/yaml-extension.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Check: YAML files under .github/ use the .yaml extension, not .yml. +Tier coverage: product, canonical, personal. + +Convention: Charm Tech (and the broader Canonical convention) prefers +the explicit `.yaml` spelling — matching the official YAML spec and the +pattern already used by almost every Charm Tech-authored workflow this cycle. +Mixed extensions inside one repo also defeat tooling globs that only +match one form. + +Scope: anything under .github/ — workflows, dependabot, zizmor, +issue templates, etc. Anything outside .github/ (Snapcraft snapcraft.yaml, +Rockcraft rockcraft.yaml, etc.) is out of scope; those names are fixed +by upstream tooling. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "yaml-extension" +APPLIES = "product,canonical,personal" + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + gh = Path(".github") + if not gh.is_dir(): + emit_check(CHECK_ID, "na", "No .github/ directory; nothing to check.") + return EXIT_NA + + offenders = sorted(str(p) for p in gh.rglob("*.yml") if p.is_file()) + + if not offenders: + emit_check( + CHECK_ID, "pass", + "All YAML files under .github/ use the .yaml extension.", + {}, + ) + return EXIT_PASS + + count = len(offenders) + joined = ", ".join(offenders) + emit_check( + CHECK_ID, "fail", + f"{count} file(s) under .github/ use .yml instead of .yaml: {joined}.", + {"offenders": offenders}, + {"kind": "mechanical", "script": "scripts/fixes/rename-yml-to-yaml.py", "human_review": "git mv each .yml -> .yaml under .github/. Confirm no external reference uses the old path (workflow_call uses:, docs links, downstream consumers of action.yml)."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/checks/zizmor-config.py b/skills/engineering/charm-tech-baseline/scripts/checks/zizmor-config.py new file mode 100755 index 0000000..2c0fbe2 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/checks/zizmor-config.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Check: zizmor is invoked in CI. +Tier coverage: product, canonical. + +A .github/zizmor.yaml config file is no longer required — the pinning +policy has no allowlist exceptions, so zizmor's default unpinned-uses +rule is sufficient. If a config file exists it is not flagged, but +it's redundant. +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import ( + EXIT_FAIL, EXIT_NA, EXIT_PASS, + cd_repo_root, emit_check, parse_tier, tier_applies, +) + +CHECK_ID = "zizmor-config" +APPLIES = "product,canonical" + +PATTERN = re.compile( + r"woodruffw/zizmor|zizmor-action|uvx[ \t]+zizmor|uv[ \t]+run[ \t].*zizmor|(^|[^a-zA-Z0-9_./-])zizmor[ \t]" +) + + +def main() -> int: + tier = parse_tier() + if not tier_applies(APPLIES, tier): + emit_check(CHECK_ID, "na", f"Not applicable for tier {tier}.") + return EXIT_NA + + cd_repo_root() + + hits: list[str] = [] + wf_dir = Path(".github/workflows") + if wf_dir.is_dir(): + for p in sorted(list(wf_dir.glob("*.yml")) + list(wf_dir.glob("*.yaml"))): + try: + text = p.read_text(errors="replace") + except OSError: + continue + for line in text.splitlines(): + if PATTERN.search(line): + hits.append(str(p)) + break + + if hits: + first = hits[0] + emit_check( + CHECK_ID, "pass", + f"zizmor invoked in CI ({first}).", + {"workflow": first}, + ) + return EXIT_PASS + + emit_check( + CHECK_ID, "fail", + "No workflow invokes zizmor.", + {}, + {"kind": "judgement", "human_review": "Add a CI step that runs zizmor against .github/workflows/ (uvx zizmor, or via the project's lint dependency-group). No .github/zizmor.yaml config file is required — the default unpinned-uses rule enforces SHA-pinning without an allowlist."}, + ) + return EXIT_FAIL + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/detect-tier.py b/skills/engineering/charm-tech-baseline/scripts/detect-tier.py new file mode 100755 index 0000000..6dd2562 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/detect-tier.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Inspect the current repo's origin remote and emit one of: + + product | canonical | personal | unknown + +Detection rules (in order): + 1. URL matches https://github.com/canonical/ -> canonical or product + 2. URL matches https://github.com//: + a. If the repo is a fork of canonical/ (detected via + `gh repo view --json isFork,parent`, or an `upstream` remote + pointing at canonical/) -> canonical or product + b. Otherwise -> personal + 3. No remote / no clear org -> unknown + +The fork lookup matters because Charm Tech engineers routinely work +from a personal fork of a canonical/* repo; the baseline that applies +is the upstream repo's, not the fork owner's. + +Product-tier classification within canonical/ is driven by a small +allowlist below (Charm Tech products as of 2026-06 — operator, pebble, +jubilant, concierge, charmlibs). All other canonical/* repos are +'canonical' tier (cross-cutting requirements only). + +Override: pass an argument to force a tier (useful when auditing a +repo before transfer to the canonical org). + +Exit 0 always; the tier name is printed on stdout. +""" + +from __future__ import annotations + +import shutil +import sys + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) +from lib.common import origin_url, run # noqa: E402 + + +PRODUCT_REPOS = {"operator", "pebble", "jubilant", "concierge", "charmlibs"} + + +def main() -> int: + if len(sys.argv) >= 2: + arg = sys.argv[1] + if arg in ("product", "canonical", "personal"): + print(arg) + return 0 + print("unknown", file=sys.stderr) + return 1 + + url = origin_url() + if not url: + print("unknown") + return 0 + + prefix = "https://github.com/" + if not url.startswith(prefix): + # Some other forwarding host; don't guess. + print("unknown") + return 0 + + path = url[len(prefix):] + parts = path.split("/", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + print("unknown") + return 0 + org, repo = parts + + # If origin is not under canonical/, the repo may still be a fork of + # a canonical/* repo — in which case the upstream's baseline applies. + if org != "canonical": + parent_slug = "" + if shutil.which("gh"): + result = run([ + "gh", "repo", "view", f"{org}/{repo}", + "--json", "isFork,parent", + "--jq", + r'select(.isFork) | .parent' + r' | select(.owner.login == "canonical")' + r' | "\(.owner.login)/\(.name)"', + ]) + parent_slug = result.stdout.strip() + if not parent_slug: + upstream = run(["git", "config", "--get", "remote.upstream.url"]).stdout.strip() + if upstream.startswith("git@github.com:"): + upstream = "https://github.com/" + upstream[len("git@github.com:"):] + if upstream.endswith(".git"): + upstream = upstream[:-4] + if upstream.startswith(prefix): + upstream_path = upstream[len(prefix):] + if upstream_path.startswith("canonical/"): + parent_slug = upstream_path + if parent_slug: + org = "canonical" + repo = parent_slug[len("canonical/"):] + + if org == "canonical": + print("product" if repo in PRODUCT_REPOS else "canonical") + else: + print("personal") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/add-agents-md.py b/skills/engineering/charm-tech-baseline/scripts/fixes/add-agents-md.py new file mode 100755 index 0000000..a20323d --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/add-agents-md.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Fix: copy the AGENTS.md template into the repo root. +Agent must fill in {{...}} placeholders before committing — the +template is intentionally a skeleton, not a working file. +""" +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + import os + os.chdir(repo_root()) + except OSError: + return 3 + + if Path("AGENTS.md").exists(): + sys.stderr.write("AGENTS.md already exists; refusing to overwrite.\n") + return 1 + + template = SCRIPT_DIR.parent.parent / "assets" / "AGENTS.md.template" + if not template.is_file(): + sys.stderr.write("Template missing.\n") + return 3 + + shutil.copy(template, "AGENTS.md") + sys.stdout.write("Copied AGENTS.md template. Replace {{REPO_DESCRIPTION_ONE_SENTENCE}}, {{SETUP_COMMANDS}}, {{TEST_COMMANDS}}, {{LINT_COMMANDS}}, {{DEPTH_LINK_TITLE}}, {{DEPTH_LINK}} before committing.\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/add-code-of-conduct.py b/skills/engineering/charm-tech-baseline/scripts/fixes/add-code-of-conduct.py new file mode 100755 index 0000000..edaf444 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/add-code-of-conduct.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Fix: copy the Code-of-Conduct template (link-only Ubuntu CoC) into the repo root. +Refuses to overwrite an existing file. +""" +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path("CODE_OF_CONDUCT.md").exists(): + sys.stderr.write("CODE_OF_CONDUCT.md already exists; refusing to overwrite.\n") + return 1 + + template = SCRIPT_DIR.parent.parent / "assets" / "CODE_OF_CONDUCT.md" + if not template.is_file(): + sys.stderr.write("Template missing.\n") + return 3 + + shutil.copy(template, "CODE_OF_CONDUCT.md") + sys.stdout.write("Copied CODE_OF_CONDUCT.md. No placeholders to fill in — the link-only form is complete as-is.\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/add-contributing.py b/skills/engineering/charm-tech-baseline/scripts/fixes/add-contributing.py new file mode 100755 index 0000000..f4e8882 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/add-contributing.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Fix: copy the CONTRIBUTING.md template into the repo root and rewrite the +owner/repo placeholders to match origin. The template mirrors the +dominant Charm Tech pattern (substantive standalone doc with a +`# Pull requests` section). +""" +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import origin_url, repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path("CONTRIBUTING.md").exists(): + sys.stderr.write("CONTRIBUTING.md already exists; refusing to overwrite.\n") + return 1 + + template = SCRIPT_DIR.parent.parent / "assets" / "CONTRIBUTING.md.template" + if not template.is_file(): + sys.stderr.write("Template missing.\n") + return 3 + + shutil.copy(template, "CONTRIBUTING.md") + + url = origin_url() + prefix = "https://github.com/" + if url.startswith(prefix) and len(url) > len(prefix): + slug = url[len(prefix):] + # Match shell: owner=${slug%%/*}, name=${slug##*/}. + owner = slug.split("/", 1)[0] + name = slug.rsplit("/", 1)[-1] + text = Path("CONTRIBUTING.md").read_text() + text = text.replace("REPLACE_WITH_OWNER", owner).replace("REPLACE_WITH_REPO", name) + Path("CONTRIBUTING.md").write_text(text) + sys.stdout.write(f"Rewrote owner/repo placeholders to {owner}/{name}.\n") + else: + sys.stderr.write("Could not determine origin slug; left REPLACE_WITH_OWNER/REPO placeholders in CONTRIBUTING.md — fix before committing.\n") + + sys.stdout.write("Wrote CONTRIBUTING.md. Confirm: the `# Pull requests` type list matches .github/check-conventional-pr-title.py (chore, ci, docs, feat, fix, perf, refactor, revert, test).\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/add-dependabot.py b/skills/engineering/charm-tech-baseline/scripts/fixes/add-dependabot.py new file mode 100755 index 0000000..cced4e6 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/add-dependabot.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Fix: copy the Dependabot template into .github/. +Agent must edit the package-ecosystem set to match the repo +(drop unused ecosystems, uncomment gomod / docker if applicable). +""" +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path(".github/dependabot.yml").exists() or Path(".github/dependabot.yaml").exists(): + sys.stderr.write(".github/dependabot.{yml,yaml} already exists; refusing to overwrite.\n") + return 1 + + Path(".github").mkdir(parents=True, exist_ok=True) + template = SCRIPT_DIR.parent.parent / "assets" / "dependabot.yaml.template" + if not template.is_file(): + sys.stderr.write("Template missing.\n") + return 3 + + shutil.copy(template, ".github/dependabot.yaml") + sys.stdout.write("Wrote .github/dependabot.yaml. Confirm the ecosystem set matches the repo (github-actions + uv by default; swap uv→pip or delete uv and uncomment gomod as needed), prune the `charm-tech` group to what this repo actually depends on, and confirm all dev tooling in use is covered by the `dev-tooling` group.\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/add-security-md.py b/skills/engineering/charm-tech-baseline/scripts/fixes/add-security-md.py new file mode 100755 index 0000000..ed5ff77 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/add-security-md.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Fix: copy the SECURITY.md template into the repo root. +Caller is the agent, which must then: + 1. Replace placeholder fields ({{REPO}}, {{CONTACT}}, etc.). + 2. Stage and commit; do not push without user direction. + +This script never overwrites an existing SECURITY.md. +""" +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import repo_root + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if Path("SECURITY.md").exists(): + sys.stderr.write("SECURITY.md already exists; refusing to overwrite. Remove it first if the intent is to replace.\n") + return 1 + + template = SCRIPT_DIR.parent.parent / "assets" / "SECURITY.md.template" + if not template.is_file(): + sys.stderr.write(f"Template missing at {template}\n") + return 3 + + shutil.copy(template, "SECURITY.md") + sys.stdout.write("Copied SECURITY.md template. Replace placeholders ({{REPO}}, {{CONTACT}}) before committing.\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/add-validate-pr-title.py b/skills/engineering/charm-tech-baseline/scripts/fixes/add-validate-pr-title.py new file mode 100755 index 0000000..f51845b --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/add-validate-pr-title.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Fix: install the operator-style Conventional Commits PR-title check. + +Two-file pattern (source: canonical/operator): + - .github/workflows/validate-pr-title.yaml — runs on pull_request + [opened, edited, synchronize], permissions: {}, no PR-title fetch from + the API; reads it from the event payload via the PR_TITLE env var. + - .github/check-conventional-pr-title.py — self-contained Python (stdlib + only). Allowed types: chore, ci, docs, feat, fix, perf, refactor, revert, + test. Scopes disallowed. + +Both files are staged from the asset templates. The Python script's _HELP_URL +placeholder is rewritten to point at this repo's CONTRIBUTING.md so the error +message links to the right place. The agent should still check the +CONTRIBUTING.md exists and documents these types. +""" +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import origin_url, repo_root, run + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + workflow = ".github/workflows/validate-pr-title.yaml" + script = ".github/check-conventional-pr-title.py" + + if Path(workflow).exists() or Path(".github/workflows/validate-pr-title.yml").exists(): + sys.stderr.write(f"{workflow} (or .yml variant) already exists; refusing to overwrite.\n") + return 1 + if Path(script).exists(): + sys.stderr.write(f"{script} already exists; refusing to overwrite.\n") + return 1 + + wf_template = SCRIPT_DIR.parent.parent / "assets" / "validate-pr-title.yaml.template" + py_template = SCRIPT_DIR.parent.parent / "assets" / "check-conventional-pr-title.py.template" + if not wf_template.is_file(): + sys.stderr.write(f"Workflow template missing: {wf_template}\n") + return 3 + if not py_template.is_file(): + sys.stderr.write(f"Python template missing: {py_template}\n") + return 3 + + Path(".github/workflows").mkdir(parents=True, exist_ok=True) + shutil.copy(wf_template, workflow) + shutil.copy(py_template, script) + + # Rewrite the help-URL placeholder to point at THIS repo. + url = origin_url() + prefix = "https://github.com/" + if url.startswith(prefix) and len(url) > len(prefix): + slug = url[len(prefix):] + owner = slug.split("/", 1)[0] + name = slug.rsplit("/", 1)[-1] + + r = run(["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"]) + default_branch = r.stdout.strip() if r.returncode == 0 else "" + if default_branch.startswith("origin/"): + default_branch = default_branch[len("origin/"):] + if not default_branch: + r2 = run(["git", "rev-parse", "--abbrev-ref", "HEAD"]) + default_branch = r2.stdout.strip() if r2.returncode == 0 else "main" + if not default_branch: + default_branch = "main" + + text = Path(script).read_text() + text = text.replace("REPLACE_WITH_OWNER", owner) + text = text.replace("REPLACE_WITH_REPO", name) + text = text.replace("/blob/main/", f"/blob/{default_branch}/") + Path(script).write_text(text) + sys.stdout.write( + f"Rewrote help-URL to https://github.com/{owner}/{name}/blob/{default_branch}/CONTRIBUTING.md#pull-requests\n" + ) + else: + sys.stderr.write( + f"Could not determine origin slug; left REPLACE_WITH_OWNER/REPO placeholders in {script} — fix before committing.\n" + ) + + sys.stdout.write(f"Wrote {workflow} and {script}.\n") + sys.stdout.write( + "Confirm CONTRIBUTING.md (or HACKING.md, etc.) exists in this repo and documents the allowed Conventional-Commits types; if not, add a \"Pull requests\" section listing chore/ci/docs/feat/fix/perf/refactor/revert/test before merging.\n" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/apply-repo-settings.py b/skills/engineering/charm-tech-baseline/scripts/fixes/apply-repo-settings.py new file mode 100755 index 0000000..606cdb5 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/apply-repo-settings.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Fix: patch live GitHub repo settings to match the baseline. + +Use this ONLY when the repo is not (and will not be) enrolled in +canonical-repo-automation (CRA). For CRA-enrolled repos, drift must be +fixed by re-applying CRA — direct API patches will be overwritten on the +next apply. The repo-settings check refuses to recommend this fix for +CRA-enrolled repos for that reason. + +What it sets: + - allow_squash_merge=true, allow_merge_commit=false, allow_rebase_merge=false + - delete_branch_on_merge=true + - secret_scanning + push protection + dependabot security updates enabled + - private vulnerability reporting enabled + - actions allowed_actions=selected (canonical-owned repos only) + +What it does NOT set: rulesets / branch protection (a separate fix — +different shape per-repo, needs the protected branch name, required checks, +and bypass policy decided per repo). + +Usage: scripts/fixes/apply-repo-settings.py [--dry-run] +The script prints each gh call before running; pass --dry-run to print only. +""" +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import origin_url + + +HELP_TEXT = """Fix: patch live GitHub repo settings to match the baseline. + +Use this ONLY when the repo is not (and will not be) enrolled in +canonical-repo-automation (CRA). For CRA-enrolled repos, drift must be +fixed by re-applying CRA — direct API patches will be overwritten on the +next apply. The repo-settings check refuses to recommend this fix for +CRA-enrolled repos for that reason. + +What it sets: + - allow_squash_merge=true, allow_merge_commit=false, allow_rebase_merge=false + - delete_branch_on_merge=true + - secret_scanning + push protection + dependabot security updates enabled + - private vulnerability reporting enabled + - actions allowed_actions=selected (canonical-owned repos only) + +What it does NOT set: rulesets / branch protection (a separate fix — +different shape per-repo, needs the protected branch name, required checks, +and bypass policy decided per repo). + +Usage: scripts/fixes/apply-repo-settings.py [--dry-run] +The script prints each gh call before running; pass --dry-run to print only. +""" + + +def main() -> int: + dry_run = False + for arg in sys.argv[1:]: + if arg == "--dry-run": + dry_run = True + elif arg in ("-h", "--help"): + sys.stdout.write(HELP_TEXT) + return 0 + else: + sys.stderr.write(f"Unknown argument: {arg}\n") + return 2 + + if shutil.which("gh") is None: + sys.stderr.write("gh CLI not installed.\n") + return 3 + + url = origin_url() + prefix = "https://github.com/" + if not (url.startswith(prefix) and len(url) > len(prefix)): + sys.stderr.write(f"Could not parse owner/repo from origin URL: {url}\n") + return 3 + slug = url[len(prefix):] + owner = slug.split("/", 1)[0] + + def run_cmd(cmd: list[str]) -> None: + sys.stdout.write("+ " + " ".join(cmd) + "\n") + sys.stdout.flush() + if not dry_run: + subprocess.run(cmd) + + # Merge + branch hygiene + security-and-analysis (one PATCH call). + run_cmd([ + "gh", "api", "-X", "PATCH", f"repos/{slug}", + "-F", "allow_squash_merge=true", + "-F", "allow_merge_commit=false", + "-F", "allow_rebase_merge=false", + "-F", "delete_branch_on_merge=true", + "-f", "security_and_analysis[secret_scanning][status]=enabled", + "-f", "security_and_analysis[secret_scanning_push_protection][status]=enabled", + "-f", "security_and_analysis[dependabot_security_updates][status]=enabled", + ]) + + # Private vulnerability reporting (separate endpoint, PUT, no body). + run_cmd(["gh", "api", "-X", "PUT", f"repos/{slug}/private-vulnerability-reporting"]) + + # Actions allowlist — canonical-owned only. Personal repos legitimately run + # `allowed_actions=all`; only flip when the repo belongs to canonical. + if owner == "canonical": + run_cmd([ + "gh", "api", "-X", "PUT", f"repos/{slug}/actions/permissions", + "-F", "enabled=true", + "-f", "allowed_actions=selected", + ]) + sys.stdout.write( + "\nNote: allowed_actions set to \"selected\". The selected-actions allowlist itself is org-scoped and lives in canonical-repo-automation; this repo will inherit whatever the org allows. If the repo needs additional vetted actions, declare them in CRA rather than per-repo.\n" + ) + + sys.stdout.write("\nDone. Re-run scripts/check.py --only=repo-settings to confirm.\n") + if owner == "canonical": + sys.stdout.write( + "Reminder: this patches live settings only. For a canonical/* repo, the durable fix is enrolment in canonical-repo-automation — these patches will drift back over time without a CRA declaration.\n" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/fixes/rename-yml-to-yaml.py b/skills/engineering/charm-tech-baseline/scripts/fixes/rename-yml-to-yaml.py new file mode 100755 index 0000000..b1fc567 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/fixes/rename-yml-to-yaml.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Fix: rename every *.yml under .github/ to *.yaml, using `git mv` so +history follows. Skips files where the .yaml twin already exists (left +for manual reconciliation — likely intentional or a stale leftover). + +Does NOT update references: `workflow_call uses:` paths, README links, +downstream consumers of a composite action.yml, etc. The human-review +note on the matching check flags this; rerun the audit + grep after. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from lib.common import repo_root, run + + +def main() -> int: + try: + os.chdir(repo_root()) + except OSError: + return 3 + + if not Path(".github").is_dir(): + sys.stdout.write("No .github/ directory; nothing to do.\n") + return 0 + + offenders = sorted(str(p) for p in Path(".github").rglob("*.yml") if p.is_file()) + + if not offenders: + sys.stdout.write("No .yml files under .github/; nothing to do.\n") + return 0 + + in_git_repo = run(["git", "rev-parse", "--is-inside-work-tree"]).returncode == 0 + + renamed = 0 + skipped = 0 + for src in offenders: + dst = src[:-len(".yml")] + ".yaml" + if Path(dst).exists(): + sys.stderr.write(f"SKIP: {src} — {dst} already exists.\n") + skipped += 1 + continue + tracked = False + if in_git_repo: + tracked = run(["git", "ls-files", "--error-unmatch", "--", src]).returncode == 0 + if tracked: + subprocess.run(["git", "mv", "--", src, dst]) + else: + shutil.move(src, dst) + sys.stdout.write(f"Renamed: {src} -> {dst}\n") + renamed += 1 + + sys.stdout.write(f"\nDone: {renamed} renamed, {skipped} skipped.\n") + sys.stdout.write("Reminder: grep the repo (and downstream consumers) for the old .yml paths in case any workflow_call / README / action ref points at them.\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/engineering/charm-tech-baseline/scripts/lib/common.py b/skills/engineering/charm-tech-baseline/scripts/lib/common.py new file mode 100644 index 0000000..32dc30b --- /dev/null +++ b/skills/engineering/charm-tech-baseline/scripts/lib/common.py @@ -0,0 +1,120 @@ +"""Shared helpers for charm-tech-baseline skill checks and fixes. + +Imported by every check / fix script. No side effects on import. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Iterable + + +# Exit codes. Every check script exits with one of these. +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_NA = 2 +EXIT_UNKNOWN = 3 + + +def repo_root() -> Path: + """Return the repo root. Falls back to CWD when not inside a git tree + (the skill can be invoked against an unpacked tarball, for example).""" + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, + ).stdout.strip() + if out: + return Path(out) + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return Path.cwd() + + +def origin_url() -> str: + """Return the origin remote URL normalised to https form, without a + trailing .git. Empty string if no origin remote.""" + try: + url = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, text=True, check=True, + ).stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "" + if url.startswith("git@github.com:"): + url = "https://github.com/" + url[len("git@github.com:"):] + if url.endswith(".git"): + url = url[:-4] + return url + + +def emit_check( + check_id: str, + status: str, + summary: str, + evidence: dict[str, Any] | None = None, + remediation: dict[str, Any] | None = None, +) -> None: + """Emit a single check result as a JSON object on one line to stdout. + + status is one of: pass, fail, na, unknown. + """ + payload = { + "id": check_id, + "status": status, + "summary": summary, + "evidence": evidence if evidence is not None else {}, + "remediation": remediation, + } + # Single-line JSON so the umbrella runner can concatenate outputs. + sys.stdout.write(json.dumps(payload, separators=(",", ":"))) + sys.stdout.write("\n") + + +def tier_applies(check_tiers: str | Iterable[str], current_tier: str) -> bool: + """True when the current tier is in the check's applicable tiers. + + check_tiers may be a comma-separated string ("product,canonical") or + any iterable of strings. + """ + if isinstance(check_tiers, str): + tiers = {t.strip() for t in check_tiers.split(",") if t.strip()} + else: + tiers = set(check_tiers) + return current_tier in tiers + + +def parse_tier(argv: list[str] | None = None) -> str: + """Extract --tier= from argv. Returns empty string if absent. + + Unknown flags are ignored (each check only cares about --tier).""" + args = argv if argv is not None else sys.argv[1:] + for arg in args: + if arg.startswith("--tier="): + return arg[len("--tier="):] + return "" + + +def run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + """Convenience wrapper around subprocess.run with text=True and + capture_output=True by default. Never raises on non-zero exit — + callers should inspect .returncode.""" + kwargs.setdefault("text", True) + kwargs.setdefault("capture_output", True) + kwargs.setdefault("check", False) + return subprocess.run(cmd, **kwargs) + + +def cd_repo_root() -> Path: + """Chdir to the repo root and return it. Exits EXIT_UNKNOWN if the + root cannot be reached (matches the shell behaviour of `cd || exit 3`).""" + root = repo_root() + try: + os.chdir(root) + except OSError: + sys.exit(EXIT_UNKNOWN) + return root diff --git a/skills/engineering/charm-tech-baseline/tests/checks/test_agents_md_content.py b/skills/engineering/charm-tech-baseline/tests/checks/test_agents_md_content.py new file mode 100644 index 0000000..bb99842 --- /dev/null +++ b/skills/engineering/charm-tech-baseline/tests/checks/test_agents_md_content.py @@ -0,0 +1,164 @@ +"""AGENTS.md content check: the five Layer 1 staleness checks.""" +from __future__ import annotations + +import textwrap + + +CLEAN = textwrap.dedent("""\ + # AGENTS.md + + See [HACKING.md](HACKING.md) for details. Tests use `gopkg.in/check.v1`. + + ## Build and test + + ```bash + true --check # lint gate + false --lxd deploy # deploys via LXD, needs juju + ``` + """) + + +def test_na_when_agents_md_missing(run_check): + r = run_check("agents-md-content", "canonical", {}) + assert r["status"] == "na" + + +def test_pass_when_content_clean(run_check): + r = run_check("agents-md-content", "canonical", { + "AGENTS.md": CLEAN, + "HACKING.md": "# Hacking\n", + }) + assert r["status"] == "pass" + assert r["evidence"]["runnable_failed"] == [] + assert r["evidence"]["missing_paths"] == [] + + +def test_module_path_not_flagged_as_missing_file(run_check): + # gopkg.in/check.v1 is a Go module path, not a local file — must not be + # reported missing just because it contains a '/'. + r = run_check("agents-md-content", "canonical", { + "AGENTS.md": CLEAN, + "HACKING.md": "# Hacking\n", + }) + assert "gopkg.in/check.v1" not in r["evidence"]["missing_paths"] + + +def test_environment_gated_command_not_executed(run_check): + # `false` would fail if run; it must be classified environment-gated + # (lxd/juju) and skipped, not executed — proven indirectly by the + # overall check still passing (see test_pass_when_content_clean). + r = run_check("agents-md-content", "canonical", { + "AGENTS.md": CLEAN, + "HACKING.md": "# Hacking\n", + }) + gated_commands = [g["command"] for g in r["evidence"]["environment_gated"]] + assert any(c.startswith("false") for c in gated_commands) + assert not any(rr["command"].startswith("false") for rr in r["evidence"]["runnable_failed"]) + + +def test_fail_when_referenced_path_missing(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + See [BOGUS.md](BOGUS.md) for details. + """) + r = run_check("agents-md-content", "canonical", {"AGENTS.md": md}) + assert r["status"] == "fail" + assert "BOGUS.md" in r["evidence"]["missing_paths"] + + +def test_fail_when_command_tool_missing(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + definitelynotarealbinary123 --check # lint + ``` + """) + r = run_check("agents-md-content", "canonical", {"AGENTS.md": md}) + assert r["status"] == "fail" + assert any(m["tool"] == "definitelynotarealbinary123" for m in r["evidence"]["missing_tools"]) + + +def test_fail_when_runnable_command_fails(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + false --check # lint gate + ``` + """) + r = run_check("agents-md-content", "canonical", {"AGENTS.md": md}) + assert r["status"] == "fail" + assert r["evidence"]["runnable_failed"] + assert r["evidence"]["runnable_failed"][0]["command"].startswith("false") + + +def test_suite_not_found_in_package_flags_finding(run_check): + # The canonical case: a gocheck suite documented against a package that + # no longer contains it (pebble's PebbleSuite/cmd-pebble staleness). + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + go test ./internals/cli -check.f MySuite # single suite + ``` + """) + r = run_check("agents-md-content", "canonical", { + "AGENTS.md": md, + "internals/cli/other_test.go": "package cli\n\nfunc TestSomethingElse() {}\n", + }) + findings = r["evidence"]["suite_findings"] + assert any(f["suite"] == "MySuite" and f["problem"] == "suite identifier not found anywhere in package" for f in findings) + + +def test_suite_found_in_package_no_finding(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + ```bash + go test ./internals/cli -check.f MySuite # single suite + ``` + """) + r = run_check("agents-md-content", "canonical", { + "AGENTS.md": md, + "internals/cli/suite_test.go": "package cli\n\ntype MySuite struct{}\n", + }) + assert r["evidence"]["suite_findings"] == [] + + +def test_scope_lint_flags_harness_content(run_check): + md = textwrap.dedent("""\ + # AGENTS.md + + Some guidance for agents. + + Co-Authored-By: Claude + """) + r = run_check("agents-md-content", "canonical", {"AGENTS.md": md}) + assert r["status"] == "fail" + assert r["evidence"]["scope_lint_findings"] + + +def test_version_pin_drift_flagged(run_check): + md = "# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n" + r = run_check("agents-md-content", "canonical", { + "AGENTS.md": md, + ".github/workflows/lint.yaml": "steps:\n - run: go install widget/cmd/widget@v2.0.0\n", + }) + assert r["status"] == "fail" + assert r["evidence"]["version_drift"] + assert r["evidence"]["version_drift"][0]["tool"] == "widget" + assert r["evidence"]["version_drift"][0]["doc_version"] == "v1.0.0" + assert r["evidence"]["version_drift"][0]["ci_versions"] == ["v2.0.0"] + + +def test_version_pin_matches_ci(run_check): + md = "# AGENTS.md\n\nPinned tool: widget/cmd/widget@v1.0.0 (see CI).\n" + r = run_check("agents-md-content", "canonical", { + "AGENTS.md": md, + ".github/workflows/lint.yaml": "steps:\n - run: go install widget/cmd/widget@v1.0.0\n", + }) + assert r["status"] == "pass" + assert r["evidence"]["version_drift"] == [] + assert r["evidence"]["version_pins_checked"][0]["tool"] == "widget" diff --git a/skills/engineering/charm-tech-baseline/tests/checks/test_dependabot.py b/skills/engineering/charm-tech-baseline/tests/checks/test_dependabot.py new file mode 100644 index 0000000..9c0bbbb --- /dev/null +++ b/skills/engineering/charm-tech-baseline/tests/checks/test_dependabot.py @@ -0,0 +1,47 @@ +"""Dependabot check: presence, ecosystems, and cooldown >= 7 days.""" +from __future__ import annotations + +import textwrap + + +PASSING = textwrap.dedent("""\ + version: 2 + updates: + - package-ecosystem: pip + directory: / + schedule: {interval: weekly} + cooldown: + default-days: 7 + - package-ecosystem: github-actions + directory: / + schedule: {interval: weekly} + cooldown: + default-days: 7 + """) + +SHORT_COOLDOWN = textwrap.dedent("""\ + version: 2 + updates: + - package-ecosystem: pip + directory: / + schedule: {interval: weekly} + cooldown: + default-days: 3 + """) + + +def test_pass_when_ecosystems_have_cooldown(run_check): + r = run_check("dependabot", "canonical", {".github/dependabot.yaml": PASSING}) + assert r["status"] == "pass" + assert r["evidence"]["ecosystems"] == 2 + + +def test_fail_when_cooldown_below_baseline(run_check): + r = run_check("dependabot", "canonical", {".github/dependabot.yaml": SHORT_COOLDOWN}) + assert r["status"] == "fail" + assert "cooldown" in r["summary"].lower() + + +def test_fail_when_config_missing(run_check): + r = run_check("dependabot", "canonical", {}) + assert r["status"] == "fail" diff --git a/skills/engineering/charm-tech-baseline/tests/conftest.py b/skills/engineering/charm-tech-baseline/tests/conftest.py new file mode 100644 index 0000000..16c6f8d --- /dev/null +++ b/skills/engineering/charm-tech-baseline/tests/conftest.py @@ -0,0 +1,38 @@ +"""Shared helpers for the charm-tech-baseline check tests. + +The tests are functional: each writes a small tree into a tmp dir and runs +the real check script as a subprocess via ``uv run --script``, matching how +``check.py`` itself invokes each check in production. No mocking. +""" +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parent.parent / "scripts" + + +@pytest.fixture +def run_check(tmp_path, monkeypatch): + """Return ``run(check_name, tier, files)`` -> parsed JSON dict. + + ``files`` is a mapping of repo-relative path -> file contents. Parent + directories are created as needed. The check runs with cwd = tmp_path. + """ + def _run(name: str, tier: str, files: dict[str, str]) -> dict: + for rel, body in files.items(): + dest = tmp_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(body) + monkeypatch.chdir(tmp_path) + proc = subprocess.run( + ["uv", "run", "--script", str(SCRIPTS / "checks" / f"{name}.py"), f"--tier={tier}"], + capture_output=True, text=True, check=False, + ) + assert proc.stdout, f"{name} produced no stdout (stderr: {proc.stderr!r})" + return json.loads(proc.stdout) + + return _run diff --git a/skills/engineering/charm-tech-baseline/tests/test_check_runner.py b/skills/engineering/charm-tech-baseline/tests/test_check_runner.py new file mode 100644 index 0000000..b15228a --- /dev/null +++ b/skills/engineering/charm-tech-baseline/tests/test_check_runner.py @@ -0,0 +1,26 @@ +"""check.py: one smoke test that --only dispatches and shapes a report.""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +RUNNER = Path(__file__).resolve().parent.parent / "scripts" / "check.py" + + +def test_only_dispatches_selected_check(tmp_path): + (tmp_path / ".github").mkdir() + (tmp_path / ".github" / "dependabot.yaml").write_text( + "version: 2\nupdates:\n - package-ecosystem: pip\n directory: /\n" + " schedule: {interval: weekly}\n cooldown: {default-days: 7}\n" + ) + proc = subprocess.run( + [sys.executable, str(RUNNER), "--tier=canonical", "--only=dependabot"], + capture_output=True, text=True, check=True, cwd=tmp_path, + ) + report = json.loads(proc.stdout) + assert report["tier"] == "canonical" + assert report["tier_source"] == "override" + assert [c["id"] for c in report["checks"]] == ["dependabot"] + assert report["checks"][0]["status"] == "pass" diff --git a/skills/engineering/charm-tech-baseline/tests/test_detect_tier.py b/skills/engineering/charm-tech-baseline/tests/test_detect_tier.py new file mode 100644 index 0000000..413616f --- /dev/null +++ b/skills/engineering/charm-tech-baseline/tests/test_detect_tier.py @@ -0,0 +1,43 @@ +"""detect-tier.py: override arg is pure; git-driven paths use a real init.""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "detect-tier.py" + + +def _run(*args, cwd=None): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, text=True, check=False, cwd=cwd, + ) + + +def test_override_product(): + assert _run("product").stdout.strip() == "product" + + +def test_override_rejects_garbage(): + proc = _run("something-else") + assert proc.returncode != 0 + assert proc.stderr.strip() == "unknown" + + +def test_canonical_product_repo_from_origin(tmp_path): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/canonical/operator"], + cwd=tmp_path, check=True, + ) + assert _run(cwd=tmp_path).stdout.strip() == "product" + + +def test_canonical_non_product_repo(tmp_path): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/canonical/lxd"], + cwd=tmp_path, check=True, + ) + assert _run(cwd=tmp_path).stdout.strip() == "canonical"