diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b47857e..d51d7c9 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -3,23 +3,42 @@ name: validate on: push: pull_request: + # Vigil's language moves; re-check the library against it every week so a + # Vigil change that breaks a file shows up here, not on someone's import. + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + vigil_ref: + description: Vigil branch or tag to validate against + default: main jobs: - validate-tasks: + validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + path: content + # Validate with Vigil's own parsers: the same code the Community page + # runs, so this repo can never drift from what the server accepts. + - uses: actions/checkout@v4 + with: + repository: Susquehanna-Syntax/Vigil + ref: ${{ github.event.inputs.vigil_ref || 'main' }} + path: vigil - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install pyyaml - # Each validator runs unconditionally (if: always()) and as its own - # step — never chained with && — so a failure in one content type - # cannot mask a failure in another. The job fails if any step fails. - - run: python validate_task.py --all - - run: python validate_baseline.py --all - if: always() - - run: python validate_automation.py --all - if: always() - - run: python3 -m unittest discover -s tests - if: always() + cache: pip + cache-dependency-path: vigil/server/requirements.txt + - run: pip install -r vigil/server/requirements.txt + - name: validate_community --strict + working-directory: vigil/server + env: + USE_SQLITE: "1" + VIGIL_PUBLIC_URL: http://localhost + DJANGO_SECRET_KEY: ci-only + run: | + export VIGIL_SIGNING_KEY_SEED="$(python -c 'import os,base64;print(base64.b64encode(os.urandom(32)).decode())')" + python manage.py validate_community "$GITHUB_WORKSPACE/content" --strict diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6dbeed5..a34a2fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,162 +1,60 @@ # Contributing -A file in this repository becomes something an operator can deploy from the -Vigil UI on every installation that can reach this repository, so the bar is: -it must be valid, it must be safe to run on a machine that is not yours, and -it must be attributable. This page is the path from "I have a fix" to "it is -merged" for all three content types — tasks, baselines, and automations. +A file merged here becomes something every Vigil installation can fork and run on +machines that aren't yours. So it must be **valid**, **safe to run somewhere else**, +and **attributable**. ## The submission path -For a **task**, the fastest route starts in the Vigil task editor, not in a -text file. Write the task in the editor as you would for your own hosts, save -it so the server has validated the actions and parameters, and click -**Submit to Community**. The editor opens GitHub's new-file form pointed at -`tasks/` in this repository, pre-filled with your task's YAML. It injects the -`author` and `created` fields from your Vigil account and the current date if -they are not already declared, so you do not have to think about them. - -**Baselines** and **automations** are filed by hand today — clone the -repository, add a file under `baselines/` or `automations/`, and commit — but -you then owe the same two fields yourself. The submission rules below are the -same for all three types. - -## The filename - -The file is named after the content's `name`, slugified: - -- lowercase; -- every run of non-alphanumeric characters (spaces, punctuation, the like) - collapses to a single hyphen; -- no leading or trailing hyphen; -- at most 60 characters, with a `.yaml` extension. - -So the task `Remove PyCharm Process Lock` becomes -`remove-pycharm-process-lock.yaml`, and the baseline `New Server Build` -becomes `new-server-build.yaml`. Keep the filename and the `name` field in -agreement — they should read the same, only differing in case and punctuation. - -## The slug reference rule - -Baselines and automations do not copy the things they run; they reference -them **by slug** — the filename of the target file, without its extension. - -- A baseline step names its task by the task's file stem: a step with - `task: restart-nginx` refers to `tasks/restart-nginx.yaml`. -- An automation names its target the same way: `action_kind: task` refers to - a file under `tasks/`, `action_kind: baseline` to a file under - `baselines/`. - -Every reference must resolve to a file that exists **in this repository**. -The validators check this against the filesystem on every push, so a -reference to a task that is not in the repo fails CI before review. This is -deliberate and not negotiable: a baseline whose step points at a task that is -not there would import as a baseline that silently does nothing — worse than -a rejected file — and a reference to content in another repository would -break the moment the two repos drift. - -If you submit a baseline that references a new task in the same pull request, -both files must land together; the validator resolves references against the -tree the pull request produces. - -### And the `uid` beside it - -A reference may also carry the target's `uid`, and Vigil resolves by that -first. The slug stays because it is what a reviewer reading the diff can -follow; the uid is what actually resolves on the far side. - -The distinction matters because a slug is a filename. It changes when a file -is renamed, it collides when two contributors pick the same name, and this -repo does not force a filename to equal the slugified `name` inside it — -`tasks/docker-prune-and-restart-unhealthy.yaml` is called "Docker Prune and -Restart Unhealthy Container". A uid is none of those things, which is what -lets a baseline keep pointing at the right task after somebody renames their -local copy of it. - -When a reference carries both, they must agree. The validator rejects a step -whose uid belongs to a different file than its slug does: Vigil trusts the -uid, so such a file would import something other than what the diff showed. - -Every file in this repo carries a `uid`, and Vigil's *Submit to Community* -writes one into anything you share. **Never reuse or edit one** — it is the -file's identity, not its version. Change it and every installation treats the -file as new content and forks a second copy alongside the one people already -have. Copy a file to start another and leave the `uid` in place, and the two -now claim to be the same thing; delete the line and let Vigil mint a new one. - -### No host references in community automations - -A community automation can target `event_host` (the host that raised the -event), `tags` (hosts carrying one of `target_tags`), or `all` (every managed -host). It cannot target a single host by id — the server's model supports -that, but a host id is a primary key on a particular server and means nothing -in another installation, so it is not portable. The validator rejects -`target: host` with a message pointing at `target: tags` instead. If you need -"run this on the machines that are my web servers," the answer is a tag, and -`target: tags` is how you name it. - -## What the validators check - -A pull request must pass the validator for its content type before it is -reviewed; the repository's CI runs all three — plus the unit tests — on every -push and pull request, and will not merge a red file. Run them yourself before -you push: - -```sh -pip install pyyaml -python validate_task.py --all -python validate_baseline.py --all -python validate_automation.py --all -``` - -The shared rules, enforced identically by all three: - -- a non-empty `name` (at most 120 characters); -- a real author — `author` must be a name, not a placeholder. `Admin`, - `TODO`, `Unknown`, and the empty string are rejected. Community files are - attributable; when a file causes trouble on a host, the author field is how - you find out who to talk to; -- `created`, when present, must be an ISO date. - -The task-specific rules (non-empty list of actions, every action a known type -from `references/actions.json` with its required parameters) and the -baseline- and automation-specific rules (slug references, the high-risk -opt-in, the cron character set, the target rules) are documented in -`schemas/baseline.md` and `schemas/automation.md`. - -The task validator enforces one more community rule the server does not check: - -- **No hardcoded home directories.** A string anywhere in the task's - parameters that points under `/home//`, `/Users//`, or - `C:\Users\\` is rejected. A community task runs on someone else's - box, where your home path does not exist. If the task genuinely needs a - machine-specific path, declare it as an `inputs` entry and reference it - with `{{ inputs. }}`; the operator supplies the value when they deploy. - -## What makes a good task - -Beyond passing the validator, reviewers judge whether the task is worth -sharing: - -- **Scoped actions over shell commands.** If a registry action does the job - (`delete_path`, `restart_service`, `install_package`), use it. A `rm` - inside `run_command` is a red flag; a scoped action is reviewable, has a - declared risk tier, and does exactly what its name says. -- **The risk tier is honest.** `risk` is a floor the server raises to match - the actions, not a label you can lower. Declaring a destructive task `low` - to make it feel safe will not work — the effective risk is the maximum of - what you declare and what the actions warrant. -- **The description tells an operator why.** One or two sentences on what - breaks, what the task does, and anything they must know before they click - Deploy. `relevance` is a short tag for what kind of machine this is for. +**Tasks.** Start in Vigil's task editor. Write and save the task (the server validates +it), then click **Submit to Community**. That opens GitHub's new-file form for `tasks/`, +pre-filled with the YAML plus `author`, `created` and a fresh `uid`. + +**Playbooks and automations.** Open one in Vigil and click **Edit as YAML**. It shows +exactly the document this repo stores, ready to copy. Or write them by hand under `playbooks/` or `automations/`. Then open a pull +request. If a playbook needs a new task, add both in the same pull request. + +## The rules CI enforces + +CI runs Vigil's `manage.py validate_community --strict` (see the README), so these are +Vigil's real rules, not an approximation: + +- **It parses.** It uses the same parser as the Community page and the task editor. Unknown + actions, missing required params, bad `relevant:` trees and step references to steps that + don't exist all fail. +- **No deprecated syntax.** Inputs are `${{ inputs.x }}`; the old `{{ inputs.x }}` fails. Use + `relevant:` to say where a task applies. The free-text `relevance:` field fails. +- **File name = slugified `name`.** `Keep Nginx Running` is `keep-nginx-running.yaml`. +- **Every file has a `uid`, and uids are unique.** Never edit or reuse one. It is the file's + identity: change it and every installation forks a second copy. To start a new file from an + old one, delete the `uid:` line and let Vigil mint one, or use `python -c "import uuid;print(uuid.uuid4())"`. +- **References resolve.** Every playbook step and automation names a file in this repo, and + its `uid` matches that file's. +- **Events exist.** An event automation listens for one of `alert_sent`, `alert_refired`, + `host_approved`, `host_rejected`, `task_completed` or `insight_created`. +- **No hardcoded home directories.** A path under `/home//`, `/Users//` or + `C:\Users\\` doesn't exist on someone else's machine. Make it an input. +- **A real author.** Not `Admin`, `TODO` or `Unknown`. +- **Flat directories.** Vigil can't see subdirectories. + +## What makes good community content + +- **Scope itself.** A task that only makes sense on some hosts says so with `relevant:` + (a `hunt_service`, `hunt_package` or `hunt_file` probe), so it reports *not applicable* + elsewhere instead of failing. `platforms:` is only a label. `patch-windows-security` + probes for the `wuauserv` service, which is what keeps it off Linux. +- **Check before changing.** Read the state, then act only if needed (`if:` on a step's + outputs), and say which path each host took with `outcome:`. See `keep-nginx-running`. +- **Scoped actions over scripts.** `restart_service`, `delete_path` and `install_package` are + reviewable and carry an honest risk tier. An inline `execute_script` must be hash-approved + on every managed host, so it rarely suits community content. +- **An honest risk.** `risk:` is a floor. The server raises it to match the actions, so + declaring a destructive task `low` doesn't work. +- **A description that says why.** Say what breaks, what the task does, and what to know + before deploying it. ## What reviewers look for -A pull request is approved when the validator is green, the file sits flat in -its directory with a slugified name, the author is a person, every slug -reference resolves inside the repo, and the content is a scoped, honest fix -rather than a repurposed shell command. If your file trips the -home-directory rule, the fix is almost always the same: move the path into -`inputs`. If it trips the author rule, put a name in. If it trips a slug -reference, add the file it refers to in the same pull request — or fix the -stem. +CI is green. The content is a scoped, honest fix, not a repurposed shell command. It +behaves sensibly on hosts it doesn't apply to. The description lets an operator who has +never seen it decide whether to run it. diff --git a/README.md b/README.md index 9472458..57e9633 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,84 @@ # Vigil Approved Scripts Community content library for [Vigil](https://github.com/Susquehanna-Syntax/Vigil), -the self-hosted host management server. Every file in this repository is a -validated YAML document that an operator can deploy from the Vigil UI to any -connected host: a **task** that does one thing, a **baseline** that does a -sequence of things, or an **automation** that does one of those automatically. -The Vigil server fetches this repository and lists the content in its -Community tab, so anything merged here is immediately available to every Vigil -installation that can reach it. - -## The three content types - -| Type | Directory | What it is | Validator | -|--------------|------------------|---------------------------------------------------------|------------------------------| -| Task | `tasks/` | One named set of actions, run by hand or as a baseline step. | `validate_task.py` | -| Baseline | `baselines/` | A named, ordered sequence of tasks that runs on enrollment or on demand. | `validate_baseline.py` | -| Automation | `automations/` | A trigger (event or schedule) bound to a task or baseline, run on a set of hosts. | `validate_automation.py` | - -Each type lives in its own directory, **flat**, and each has its own schema -document in `schemas/`: - -- `tasks/` — a task declares a name, an author, and one or more actions drawn - from the action reference. The task schema is documented in the Vigil - wiki's Task YAML Spec. -- `baselines/` — a baseline names its tasks by slug and runs them in `order`. - See `schemas/baseline.md`. -- `automations/` — an automation binds a trigger to a task or baseline and a - target to a set of hosts. See `schemas/automation.md`. - -The server lists each directory through the GitHub contents API and does not -recurse, so a file placed in a subdirectory such as `baselines/linux/` is -invisible to the Community tab. There are no subdirectories, and there should -never be — if a file belongs in one of these directories, it belongs directly -in it. +the self-hosted host management server. Every file here is a YAML document an +operator can fork into their own Vigil from the **Community** page and deploy to +any connected host: + +| Type | Directory | What it is | Schema | +|----------------|----------------|----------------------------------------------------------------------------|----------------------------| +| **Task** | `tasks/` | One named set of actions. It can decide where it applies (`relevant:`) and branch (`if:`). | `schemas/task.md` | +| **Playbook** | `playbooks/` | Tasks run in order on each host, and they can branch on how earlier tasks went. | `schemas/playbook.md` | +| **Automation** | `automations/` | A trigger (an event or a schedule) that runs a task or a playbook on a set of hosts. | `schemas/automation.md` | + +The server lists each directory **flat** through the GitHub contents API. A file in +a subdirectory such as `playbooks/linux/` is invisible to it, so there are no +subdirectories. + +## What's in here + +**Tasks.** These cover hunts (`find-exposed-redis`), a detection task (`xz-backdoor-cve-2024-3094`), +self-scoping fixes (`keep-nginx-running`, `truncate-container-logs`), patching for Linux and Windows, +app management (`keep-firefox-current`), tagging (`tag-container-hosts`), and a few classics +(`install-nginx`, `clear-temp-files`). + +**Playbooks.** +- `standard-linux-server-build` is a fresh-host build. +- `container-host-maintenance` keeps container hosts tidy. +- `keep-the-web-tier-healthy` falls back to a recovery step when nginx won't come back. +- `onboard-a-container-host` branches on whether the host runs containers. +- `patch-tuesday` runs on a mixed Windows and Linux fleet. + +**Automations.** +- `prune-docker-on-low-disk` runs on a disk alert. +- `restart-nginx-when-it-alerts` runs on an nginx alert. +- `tag-new-container-hosts` runs when a host is approved. +- `weekly-container-maintenance` and `monthly-patch-run` run on a schedule. + +Together they form a working set. `tag-new-container-hosts` onboards every approved host. That tags +container hosts `docker`, and `weekly-container-maintenance` and `prune-docker-on-low-disk` +then pick them up by that tag. ## How content references other content -A baseline does not copy its tasks; it references them **by slug** — the -filename of the target file without its extension. A step with -`task: remove-pycharm-process-lock` refers to -`tasks/remove-pycharm-process-lock.yaml`. The same rule applies to automations: -an automation that runs a baseline names it by the stem of the file in -`baselines/`. Every reference must resolve to a file that exists in this -repository; the validators resolve each slug against the filesystem and reject -a file that points at nothing. A baseline whose step names a task that is not -there would import as a baseline that silently does nothing, which is worse -than a rejected file. +A playbook step or an automation names its target **by slug**, the file name without +`.yaml`, and also by the target's `uid`: -Host references are a different matter. A community automation can target -`event_host` (the host that raised the event), `tags` (hosts carrying one of -`target_tags`), or `all` (every managed host) — but it cannot target a single -host by id. Host ids are not portable across installations, so the community -schema does not support `target: host` and the validator rejects it. If you -need "the machines that are my web servers," that is a tag. +```yaml +steps: + - id: logs + task: truncate-container-logs # tasks/truncate-container-logs.yaml + uid: 04954106-4ce1-48d7-a232-408624b49405 # that file's uid +``` -## Validating locally +Vigil resolves by the uid first. The slug is what a reviewer can follow in a diff. Both +must point at the same file in **this** repository. A file's name must be its `name`, +slugified (lowercase, with each run of other characters turned into one hyphen), because that +is how Vigil finds a forked task again. -Each type has its own offline validator. They share the same interface: +Community automations target `event_host`, `tags` or `all`, never one host by id, +since host ids mean nothing on another installation. -```sh -pip install pyyaml -python validate_task.py --all # every file under tasks/ -python validate_baseline.py --all # every file under baselines/ -python validate_automation.py --all # every file under automations/ -python validate_task.py tasks/foo.yaml # a single file, or several -``` +## Validation: Vigil's own parsers -Each validator exits non-zero with one `: ` line per problem to -stderr and a `N passed, M failed` summary. CI runs all three, plus the unit -tests, on every push and pull request, so a file that passes locally will pass -there. The shared rules (field limits, the placeholder-author rule, the -ISO-date rule, the CLI shell, non-recursive discovery) live in `common.py`, -imported by all three validators, so the rules are enforced identically. +There's no separate validator to fall out of date. CI checks out Vigil and runs: -`references/actions.json` is the machine-readable action reference: every -action type the Vigil server knows, with its label, risk tier, and required -and optional parameters. It is generated from the server's `ACTION_REGISTRY` -by `manage.py dump_actions` in the Vigil repository. Because it is generated, -it can lag a Vigil release: right after a Vigil update that adds or changes an -action, the file here may be stale until it is regenerated and committed. If a -valid task fails because of an action this file does not know about, the -reference is probably behind the server — check the Vigil release notes before -changing your task. +```sh +python manage.py validate_community --strict +``` -## What a task file looks like +That parses every file with the **same code the Community page uses**, then checks: +every slug and uid resolves, file names match names, uids are unique, events exist, +there are no subdirectories and no hardcoded home directories. `--strict` also fails +deprecated syntax (`{{ inputs.x }}`, free-text `relevance:`). -A task declares a name, who wrote it, when, and one or more actions drawn from -the action reference: +To run it locally against a Vigil checkout: -```yaml -name: Remove PyCharm Process Lock -author: Connor Haggerty -created: 2026-07-05 -description: Removes a stale PyCharm lock file. -relevance: "Machine Effected" -risk: high - -actions: - - id: remove lock - type: delete_path - params: - path: "{{ inputs.lock_path }}" +```sh +cd ../Vigil/server +USE_SQLITE=1 VIGIL_PUBLIC_URL=http://localhost DJANGO_SECRET_KEY=dev \ +VIGIL_SIGNING_KEY_SEED=$(python -c "import os,base64;print(base64.b64encode(os.urandom(32)).decode())") \ + python manage.py validate_community ../../Vigil-Approved-Scripts --strict ``` -Actions must name only types found in `references/actions.json`, and each must -carry that type's required parameters. Because a community task runs on -anyone's machine, machine-specific paths are never hardcoded: a task that -needs a path declares it as an `inputs` entry and references it with -`{{ inputs. }}`, so the operator supplies the value at deploy time. See -`CONTRIBUTING.md` for the full submission process and `schemas/` for the -baseline and automation schemas. +Needs Vigil 2026.14.1 or newer. See `CONTRIBUTING.md` to submit something. diff --git a/automations/monthly-patch-run.yaml b/automations/monthly-patch-run.yaml new file mode 100644 index 0000000..f4ed646 --- /dev/null +++ b/automations/monthly-patch-run.yaml @@ -0,0 +1,27 @@ +name: Monthly Patch Run +uid: bfd42cef-90b1-4019-b0ba-d2a2ddfe545d +author: Connor Haggerty +created: 2026-10-07 +description: >- + Runs the Patch Tuesday playbook at 03:00 on the 15th of every month, on + every host tagged "patch-monthly". Microsoft's Patch Tuesday always falls + between the 8th and the 14th, so the 15th is the first day every month's + updates are guaranteed to be out. Cron cannot say "the day after the + second Tuesday", which is why it is a fixed date. +enabled: true + +trigger: schedule +cron: + minute: "0" + hour: "3" + dom: "15" + month: "*" + dow: "*" + +action_kind: playbook +playbook: patch-tuesday +action_uid: 7135e2c8-8bef-4808-911d-eb7d311fdd2e + +target: tags +target_tags: + - patch-monthly diff --git a/automations/prune-docker-on-low-disk.yaml b/automations/prune-docker-on-low-disk.yaml index 2318c83..ad0ee24 100644 --- a/automations/prune-docker-on-low-disk.yaml +++ b/automations/prune-docker-on-low-disk.yaml @@ -3,18 +3,26 @@ uid: 62bac7e3-7ae6-5e5b-bcd3-01348338d99f author: Connor Haggerty created: 2026-08-26 description: >- - When a disk alert at warning severity or higher fires, runs the Container - Host Maintenance baseline on the host that raised it: truncates container - logs and clears stale temp files to free space. Reach for it on Docker - hosts where logs and temp files are what fill the disk. + When a disk alert at warning or above fires on a host tagged "docker", + runs Container Host Maintenance on that host: truncates container logs and + clears stale temp files to free space. On container hosts, logs and temp + files are usually what fills the disk. enabled: true trigger: event -event: alert_fired +event: alert_sent min_severity: warning +condition_logic: all +conditions: + - field: rule + op: contains + value: disk + - field: host_tags + op: contains + value: docker -action_kind: baseline -baseline: container-host-maintenance +action_kind: playbook +playbook: container-host-maintenance action_uid: 29d90af5-69fc-5c7b-a4d2-734741abb534 target: event_host diff --git a/automations/restart-nginx-when-it-alerts.yaml b/automations/restart-nginx-when-it-alerts.yaml new file mode 100644 index 0000000..310c5ed --- /dev/null +++ b/automations/restart-nginx-when-it-alerts.yaml @@ -0,0 +1,28 @@ +name: Restart Nginx When It Alerts +uid: 8ed221ec-98d8-432e-b766-4205d1ba73aa +author: Connor Haggerty +created: 2026-10-07 +description: >- + When an alert whose rule or message mentions nginx fires, runs Keep Nginx + Running on the host that raised it. The task only + restarts nginx if it is actually down, so a noisy alert costs a status + check, not a restart. +enabled: true + +trigger: event +event: alert_sent +min_severity: warning +condition_logic: any +conditions: + - field: rule + op: contains + value: nginx + - field: message + op: contains + value: nginx + +action_kind: task +task: keep-nginx-running +action_uid: d6772f24-63d4-4616-a313-8eb2de343916 + +target: event_host diff --git a/automations/tag-new-container-hosts.yaml b/automations/tag-new-container-hosts.yaml new file mode 100644 index 0000000..d56f22b --- /dev/null +++ b/automations/tag-new-container-hosts.yaml @@ -0,0 +1,20 @@ +name: Tag New Container Hosts +uid: 3169fcce-332f-4da3-8e46-62f9ceab86d3 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Every time you approve a new host, runs Onboard a Container Host on it. + Container hosts get the "docker" tag, truncated logs and a first Trivy + scan; everything else ends as "Not a container host". Tag-targeted + automations such as Weekly Container Maintenance then find new hosts with + no one having to remember them. +enabled: true + +trigger: event +event: host_approved + +action_kind: playbook +playbook: onboard-a-container-host +action_uid: b00d9292-a880-436f-84c9-63d53e8d9846 + +target: event_host diff --git a/automations/weekly-container-maintenance.yaml b/automations/weekly-container-maintenance.yaml index 8abf427..6481e97 100644 --- a/automations/weekly-container-maintenance.yaml +++ b/automations/weekly-container-maintenance.yaml @@ -3,10 +3,10 @@ uid: dd4d0d19-ae9e-5193-ad95-4fac119997e3 author: Connor Haggerty created: 2026-08-26 description: >- - Runs the Container Host Maintenance baseline on every host tagged "docker" - at 04:00 on Sundays: truncates container logs and clears stale temp files. - A quiet, low-traffic time slot keeps the weekly sweep out of the way of - production load. + Runs Container Host Maintenance on every host tagged "docker" at 04:00 on + Sundays: truncates container logs and clears stale temp files. A quiet + slot keeps the weekly sweep out of production traffic. Pair it with the + "Tag New Container Hosts" automation so new hosts join automatically. enabled: true trigger: schedule @@ -17,8 +17,8 @@ cron: month: "*" dow: "0" -action_kind: baseline -baseline: container-host-maintenance +action_kind: playbook +playbook: container-host-maintenance action_uid: 29d90af5-69fc-5c7b-a4d2-734741abb534 target: tags diff --git a/baselines/container-host-maintenance.yaml b/baselines/container-host-maintenance.yaml deleted file mode 100644 index e71f903..0000000 --- a/baselines/container-host-maintenance.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: Container Host Maintenance -uid: 29d90af5-69fc-5c7b-a4d2-734741abb534 -author: Connor Haggerty -created: 2026-08-26 -description: >- - Recurring maintenance for a Docker host. Truncates Docker container logs - (the logs of every running container), then clears stale temp files. Safe - to run unattended; it reclaims disk without stopping any container. -target_tags: - - docker - -steps: - - task: docker-prune-and-restart-unhealthy - uid: 342135b3-b6ad-5b79-b24d-59c8d9d5b31b - order: 1 - - task: clear-temp-files - uid: c6c2e640-2958-54eb-a64f-f6be1870c131 - order: 2 diff --git a/baselines/standard-linux-server-build.yaml b/baselines/standard-linux-server-build.yaml deleted file mode 100644 index dfcbd91..0000000 --- a/baselines/standard-linux-server-build.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: Standard Linux Server Build -uid: f4ccbca4-140d-52c8-8811-7245db2ca032 -author: Connor Haggerty -created: 2026-08-26 -description: >- - Post-provision build for a fresh Linux host. Installs the nginx web server, - restarts it and confirms it is running, then clears stale temp files so the - host starts with a clean /tmp. Run it on a host right after it is - provisioned to bring it to a known-good starting state. -target_tags: - - linux - - server - -steps: - - task: install-nginx - uid: 1cf8ab45-1b5f-52e8-8bc8-fe9ef29a488c - order: 1 - - task: rotate-and-verify-nginx - uid: 28100e17-bc96-5bfb-876b-d851e248458a - order: 2 - - task: clear-temp-files - uid: c6c2e640-2958-54eb-a64f-f6be1870c131 - order: 3 diff --git a/common.py b/common.py deleted file mode 100644 index 6c56325..0000000 --- a/common.py +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env python3 -"""Shared helpers for the community-repo validators. - -``validate_task.py``, ``validate_baseline.py`` and ``validate_automation.py`` -all import from here so that the shared rules (field limits, the -placeholder-author rule, the ISO-date rule, the CLI shell, non-recursive -directory discovery) live in exactly one module. Each validator keeps its -own content-specific rules in its own file. - -Uses only the standard library plus PyYAML, like the validators themselves. -""" - -from __future__ import annotations - -import argparse -import datetime -import re -import sys -from pathlib import Path - -import yaml - -_HERE = Path(__file__).resolve().parent -_MAX_NAME = 120 -_MAX_DESCRIPTION = 2000 -_MAX_RELEVANCE = 255 -_MAX_AUTHOR = 80 -_MAX_ACTIONS = 32 -_MAX_ACTION_ID = 60 -_VALID_RISK = ("low", "standard", "high") -_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") -# Community files are attributable — these are not authors. -_PLACEHOLDER_AUTHORS = {"admin", "todo", "unknown"} - -# One cron field: digits and the characters cron itself understands -# (wildcards, lists, ranges, steps). Anything else — shell metacharacters, -# spaces, backticks, parentheses — is not a schedule and is rejected. -_CRON_FIELD = re.compile(r"^[\d*,\-/]+$") -_MAX_CRON_FIELD = 64 -# The five classic cron fields, in order. -CRON_FIELDS = ("minute", "hour", "dom", "month", "dow") - - -def is_str(value) -> bool: - return isinstance(value, str) - - -def load_yaml(text: str): - """Parse YAML source. Returns (document, error); on failure the - document is None and the error is a string.""" - try: - return yaml.safe_load(text), None - except (yaml.YAMLError, ValueError) as exc: - return None, f"invalid YAML: {' '.join(str(exc).split())}" - - -def check_scalar(raw: dict, field: str, limit: int, required: bool = False, - errors: list[str] | None = None) -> None: - """Check an optional (or required) string field against a length limit.""" - if errors is None: - errors = [] - value = raw.get(field) - if value is None: - if required: - errors.append(f"'{field}' is required") - elif not is_str(value): - errors.append(f"'{field}' must be a string") - elif required and not value.strip(): - errors.append(f"'{field}' must be non-empty") - elif len(value) > limit: - errors.append(f"'{field}' is too long (max {limit})") - - -def check_author(raw: dict, errors: list[str]) -> None: - """The placeholder-author rule shared by all three content types: - `author` must be a real name, not Admin/TODO/Unknown/empty.""" - author = raw.get("author") - if is_str(author): - if len(author) > _MAX_AUTHOR: - errors.append(f"'author' is too long (max {_MAX_AUTHOR})") - stripped = author.strip() - if not stripped: - errors.append( - "'author' must not be empty — community files are attributable") - elif stripped.lower() in _PLACEHOLDER_AUTHORS: - errors.append( - f"'author' {stripped!r} is a placeholder — put a real name") - - -def check_created(raw: dict, errors: list[str]) -> None: - """`created` is an optional ISO date; PyYAML parses a bare YYYY-MM-DD - into a datetime.date, so both a date object and an ISO string are - accepted.""" - created = raw.get("created") - if (created is not None - and (isinstance(created, datetime.datetime) - or not isinstance(created, (str, datetime.date)))): - errors.append("'created' must be a date (YYYY-MM-DD)") - elif isinstance(created, str) and not _ISO_DATE.match(created): - errors.append(f"'created' {created!r} must be an ISO-8601 date (YYYY-MM-DD)") - - -def check_uid(raw: dict, errors: list[str], field: str = "uid", - where: str = "") -> None: - """Validate a content uid: a UUID identifying this item everywhere. - - A slug is a filename, so it changes when a file is renamed and collides - when two contributors pick the same name. A uid is neither, which is what - lets a baseline keep pointing at the right task after someone renames - their copy, and lets two unrelated tasks share a name. - - Optional: everything merged before uids existed has none, and Vigil falls - back to slug matching for those. New content should carry one — Vigil's - "Submit to Community" writes it for you. - """ - import uuid as _uuid - - value = raw.get(field) - if value in (None, ""): - return - label = f"{where}'{field}'" if where else f"'{field}'" - if not isinstance(value, str): - errors.append(f"{label} must be a UUID string") - return - try: - _uuid.UUID(value.strip()) - except (ValueError, AttributeError): - errors.append(f"{label} is not a valid UUID: {value!r}") - - -def check_string_list(raw: dict, field: str, errors: list[str], - required: bool = False, non_empty: bool = False, - max_len: int | None = None) -> None: - """Check an optional (or required) list-of-strings field.""" - if errors is None: - errors = [] - value = raw.get(field) - if value is None: - if required: - errors.append(f"'{field}' is required") - return - if not isinstance(value, list): - errors.append(f"'{field}' must be a list") - return - if non_empty and not value: - errors.append(f"'{field}' must be a non-empty list") - if max_len is not None and len(value) > max_len: - errors.append(f"too many entries in '{field}' (max {max_len})") - for index, item in enumerate(value): - if not is_str(item) or not item.strip(): - errors.append(f"'{field}'[{index}] must be a non-empty string") - - -def check_boolean(raw: dict, field: str, errors: list[str]) -> None: - value = raw.get(field) - if value is not None and not isinstance(value, bool): - errors.append(f"'{field}' must be a boolean") - - -def load_task_risk(path: Path) -> str: - """Read the `risk` field of a task file, defaulting to `standard` — - the same default the server applies when a task omits it.""" - raw, error = load_yaml(path.read_text(encoding="utf-8")) - if error is not None or not isinstance(raw, dict): - return "standard" - risk = raw.get("risk") - if is_str(risk) and risk.strip().lower() in _VALID_RISK: - return risk.strip().lower() - return "standard" - - -def load_content_uid(path: Path) -> str: - """Read the ``uid`` out of a content file, or "" when it has none. - - Used to check that a reference's uid and its slug name the same file. - A parse failure here is not this file's error to report — the validator - for that content type will report it — so it degrades to "no uid". - """ - try: - raw, error = load_yaml(path.read_text(encoding="utf-8")) - except OSError: - return "" - if error is not None or not isinstance(raw, dict): - return "" - value = raw.get("uid") - return value.strip() if is_str(value) else "" - - -def resolve_slug(root: Path, slug) -> Path | None: - """Resolve a slug to a content file in *root*. The slug is the filename - stem without its extension: `foo` matches `foo.yaml` or `foo.yml`. - Returns the resolved path, or None when the file is absent.""" - if not is_str(slug) or not slug.strip(): - return None - stem = slug.strip() - for suffix in (".yaml", ".yml"): - candidate = root / f"{stem}{suffix}" - if candidate.is_file(): - return candidate - return None - - -def list_content_files(content_dir: Path): - """Discover content files the way the Vigil server does: flat, - non-recursive. Returns (files, nested) where *files* is the sorted list - of .yaml/.yml files directly in *content_dir* and *nested* is the sorted - list of such files in subdirectories — which the server never sees and - which are reported as errors by the caller. - - Deliberately NOT rglob for discovery: a file in a subdirectory would pass - CI and then never appear in the UI, which is worse than rejecting it. - """ - files = sorted( - p for p in content_dir.glob("*") - if p.is_file() and p.suffix in (".yaml", ".yml")) - nested = sorted( - p for p in content_dir.rglob("*") - if p.is_file() and p.suffix in (".yaml", ".yml") - and p.parent != content_dir) - return files, nested - - -def nested_files_message(path: Path, kind: str) -> str: - return (f"{path}: {kind} files must sit directly in their directory — the " - f"Vigil server does not list subdirectories, so this file would " - f"never appear in the Community tab") - - -def run_validator(label: str, description: str, content_dir: Path, - kind: str, validate_file, argv=None) -> int: - """The shared CLI shell for the three validators. - - *validate_file* is a callable taking one path and returning a list of - error strings. Returns the process exit code. - """ - parser = argparse.ArgumentParser(description=f"{label}: {description}") - parser.add_argument("files", nargs="*", help=f"{kind} files to validate") - parser.add_argument("--all", action="store_true", - help=f"validate every .yaml/.yml file under {kind}/") - args = parser.parse_args(argv) - - nested = [] - if args.all: - if not content_dir.is_dir(): - print(f"{kind} directory not found: {content_dir}", file=sys.stderr) - return 2 - files, nested = list_content_files(content_dir) - for path in nested: - print(nested_files_message(path, kind), file=sys.stderr) - elif args.files: - files = [Path(f) for f in args.files] - else: - parser.print_usage(sys.stderr) - return 2 - - passed = failed = 0 - failed += len(nested) - for path in files: - errors = validate_file(path) - if errors: - for message in errors: - print(f"{path}: {' '.join(str(message).split())}", file=sys.stderr) - failed += 1 - else: - print(f"{path}: OK") - passed += 1 - - print(f"{passed} passed, {failed} failed") - return 1 if failed else 0 diff --git a/baselines/.gitkeep b/playbooks/.gitkeep similarity index 100% rename from baselines/.gitkeep rename to playbooks/.gitkeep diff --git a/playbooks/container-host-maintenance.yaml b/playbooks/container-host-maintenance.yaml new file mode 100644 index 0000000..f57f85e --- /dev/null +++ b/playbooks/container-host-maintenance.yaml @@ -0,0 +1,25 @@ +name: Container Host Maintenance +uid: 29d90af5-69fc-5c7b-a4d2-734741abb534 +author: Connor Haggerty +created: 2026-08-26 +description: >- + Recurring maintenance for a Docker or Podman host. Truncates every + container's log, then clears stale temp files. Safe to run unattended: it + frees disk without stopping a single container. Hosts with no container + engine skip the log step and still get their temp files cleared. +target_tags: + - docker + +steps: + - id: logs + task: truncate-container-logs + uid: 04954106-4ce1-48d7-a232-408624b49405 + order: 1 + on_not_applicable: skip + color: sky + - id: tmp + task: clear-temp-files + uid: c6c2e640-2958-54eb-a64f-f6be1870c131 + order: 2 + color: lavender + outcome: Maintained diff --git a/playbooks/keep-the-web-tier-healthy.yaml b/playbooks/keep-the-web-tier-healthy.yaml new file mode 100644 index 0000000..874e701 --- /dev/null +++ b/playbooks/keep-the-web-tier-healthy.yaml @@ -0,0 +1,33 @@ +name: Keep the Web Tier Healthy +uid: 6cfb6c63-29d1-4b3d-ad40-434965139569 +author: Connor Haggerty +created: 2026-10-07 +description: >- + A branching playbook. It makes sure nginx is running and, if bringing it + back fails, falls back to a full restart-and-verify so the run proves the + service is really up. Hosts without nginx skip straight to the temp-file + sweep. Open it in Playbooks → Edit to see the two lanes on the canvas. +target_tags: + - web + +steps: + - id: ensure + task: keep-nginx-running + uid: d6772f24-63d4-4616-a313-8eb2de343916 + order: 1 + on_not_applicable: skip + on_failure: continue + color: sky + - if: steps.ensure.status == "failed" + then: + - id: recover + task: rotate-and-verify-nginx + uid: 28100e17-bc96-5bfb-876b-d851e248458a + order: 2 + color: rose + outcome: Needed recovery + - id: tidy + task: clear-temp-files + uid: c6c2e640-2958-54eb-a64f-f6be1870c131 + order: 3 + color: lavender diff --git a/playbooks/onboard-a-container-host.yaml b/playbooks/onboard-a-container-host.yaml new file mode 100644 index 0000000..494f9b9 --- /dev/null +++ b/playbooks/onboard-a-container-host.yaml @@ -0,0 +1,40 @@ +name: Onboard a Container Host +uid: b00d9292-a880-436f-84c9-63d53e8d9846 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Run once when a host joins. If it runs Docker or Podman, it is tagged + "docker" (so the container maintenance schedule picks it up), its + container logs are truncated, and it gets a Trivy scan when Trivy is + installed. Hosts without a container engine get their temp files cleared + and end as "Not a container host". +target_tags: [] + +steps: + - id: tag + task: tag-container-hosts + uid: 3cf68a9e-53f7-4b2c-a27d-88427ffe84e8 + order: 1 + on_not_applicable: skip + color: sky + - if: steps.tag.status == "ok" + then: + - id: logs + task: truncate-container-logs + uid: 04954106-4ce1-48d7-a232-408624b49405 + order: 2 + color: mint + - id: scan + task: scan-with-trivy + uid: 133df278-5d69-4f1f-90da-0252fe3cfe21 + order: 3 + on_not_applicable: skip + color: lavender + outcome: Onboarded + else: + - id: skip + task: clear-temp-files + uid: c6c2e640-2958-54eb-a64f-f6be1870c131 + order: 4 + color: peach + outcome: Not a container host diff --git a/playbooks/patch-tuesday.yaml b/playbooks/patch-tuesday.yaml new file mode 100644 index 0000000..50dd08e --- /dev/null +++ b/playbooks/patch-tuesday.yaml @@ -0,0 +1,34 @@ +name: Patch Tuesday +uid: 7135e2c8-8bef-4808-911d-eb7d311fdd2e +author: Connor Haggerty +created: 2026-10-07 +description: >- + One playbook for a mixed fleet's monthly patch run. Windows hosts install + missing security and critical updates and reboot (deferrable) when an + update needs it; Linux hosts install their security updates. Each task + only applies to its own platform, so on any one host the other is + skipped. Afterwards the xz backdoor check runs, closing its finding + wherever the update fixed it. +target_tags: [] + +steps: + - id: windows + task: patch-windows-security + uid: 1eee4aad-4d9d-48c4-b831-b5ad94428b02 + order: 1 + on_not_applicable: skip + on_failure: continue + color: sky + - id: linux + task: patch-linux-security + uid: d3759bd8-a5c0-492e-b460-765ef089c87a + order: 2 + on_not_applicable: skip + on_failure: continue + color: mint + - id: xz + task: xz-backdoor-cve-2024-3094 + uid: 1257a8f9-c5f3-421e-bb3e-aabf05701268 + order: 3 + on_not_applicable: skip + color: rose diff --git a/playbooks/standard-linux-server-build.yaml b/playbooks/standard-linux-server-build.yaml new file mode 100644 index 0000000..04b76d5 --- /dev/null +++ b/playbooks/standard-linux-server-build.yaml @@ -0,0 +1,31 @@ +name: Standard Linux Server Build +uid: f4ccbca4-140d-52c8-8811-7245db2ca032 +author: Connor Haggerty +created: 2026-08-26 +description: >- + Post-provision build for a fresh Linux web host. Installs nginx (skipped + where it is already there), restarts it and proves it is running, then + clears stale temp files so the host starts from a clean /tmp. Run it on a + host right after it is provisioned, or attach it to auto-enrolment. +target_tags: + - linux + - server + +steps: + - id: install + task: install-nginx + uid: 1cf8ab45-1b5f-52e8-8bc8-fe9ef29a488c + order: 1 + on_not_applicable: skip + color: sky + - id: verify + task: rotate-and-verify-nginx + uid: 28100e17-bc96-5bfb-876b-d851e248458a + order: 2 + color: mint + - id: tidy + task: clear-temp-files + uid: c6c2e640-2958-54eb-a64f-f6be1870c131 + order: 3 + color: lavender + outcome: Built diff --git a/references/actions.json b/references/actions.json deleted file mode 100644 index 4dae6b7..0000000 --- a/references/actions.json +++ /dev/null @@ -1,485 +0,0 @@ -{ - "actions": { - "add_firewall_rule": { - "label": "Add firewall rule", - "optional": [ - "action", - "source", - "interface" - ], - "required": [ - "port", - "protocol" - ], - "risk": "high" - }, - "add_tag": { - "label": "Add host tag", - "optional": [], - "required": [ - "tags" - ], - "risk": "low" - }, - "add_user_to_group": { - "label": "Add user to group", - "optional": [], - "required": [ - "username", - "group" - ], - "risk": "standard" - }, - "baseline": { - "label": "Run baseline", - "optional": [], - "required": [ - "name" - ], - "risk": "standard" - }, - "check_docker_updates": { - "label": "Check Docker images for updates now", - "optional": [], - "required": [], - "risk": "low" - }, - "check_service": { - "label": "Check service status", - "optional": [ - "expect" - ], - "required": [ - "service_name" - ], - "risk": "low" - }, - "clear_docker_logs": { - "label": "Truncate Docker logs", - "optional": [ - "container_name" - ], - "required": [], - "risk": "low" - }, - "clear_temp_files": { - "label": "Clear /tmp", - "optional": [ - "older_than_days" - ], - "required": [], - "risk": "low" - }, - "copy_file": { - "label": "Copy file", - "optional": [], - "required": [ - "src", - "dest" - ], - "risk": "standard" - }, - "create_cron_job": { - "label": "Create cron job", - "optional": [ - "user" - ], - "required": [ - "schedule", - "command" - ], - "risk": "standard" - }, - "create_directory": { - "label": "Create directory", - "optional": [ - "owner", - "group", - "mode" - ], - "required": [ - "path" - ], - "risk": "low" - }, - "create_user": { - "label": "Create user", - "optional": [ - "groups", - "shell" - ], - "required": [ - "username" - ], - "risk": "high" - }, - "delete_cron_job": { - "label": "Delete cron job", - "optional": [ - "user" - ], - "required": [ - "pattern" - ], - "risk": "standard" - }, - "delete_path": { - "label": "Delete path", - "optional": [ - "recursive" - ], - "required": [ - "path" - ], - "risk": "high" - }, - "delete_user": { - "label": "Delete user", - "optional": [ - "remove_home" - ], - "required": [ - "username" - ], - "risk": "high" - }, - "disable_firewall": { - "label": "Disable the firewall", - "optional": [], - "required": [], - "risk": "high" - }, - "disable_service": { - "label": "Disable service", - "optional": [], - "required": [ - "service_name" - ], - "risk": "standard" - }, - "docker_compose_down": { - "label": "Docker Compose down", - "optional": [], - "required": [ - "compose_file" - ], - "risk": "standard" - }, - "docker_compose_up": { - "label": "Docker Compose up", - "optional": [ - "services" - ], - "required": [ - "compose_file" - ], - "risk": "standard" - }, - "enable_firewall": { - "label": "Enable the firewall", - "optional": [], - "required": [], - "risk": "high" - }, - "enable_service": { - "label": "Enable service", - "optional": [], - "required": [ - "service_name" - ], - "risk": "low" - }, - "execute_script": { - "label": "Execute allowlisted script", - "optional": [], - "required": [ - "script_name" - ], - "risk": "high" - }, - "install_package": { - "label": "Install package", - "optional": [], - "required": [ - "package_name" - ], - "risk": "standard" - }, - "list_firewall_rules": { - "label": "List firewall rules", - "optional": [], - "required": [], - "risk": "low" - }, - "move_file": { - "label": "Move file", - "optional": [], - "required": [ - "src", - "dest" - ], - "risk": "standard" - }, - "pull_image": { - "label": "Pull container image", - "optional": [], - "required": [ - "image" - ], - "risk": "low" - }, - "reboot": { - "label": "Reboot host", - "optional": [ - "delay_seconds" - ], - "required": [], - "risk": "high" - }, - "recreate_container": { - "label": "Recreate container (apply new image)", - "optional": [ - "image" - ], - "required": [ - "container_name" - ], - "risk": "standard" - }, - "reload_service": { - "label": "Reload service", - "optional": [], - "required": [ - "service_name" - ], - "risk": "standard" - }, - "remove_container": { - "label": "Remove container", - "optional": [], - "required": [ - "container_name" - ], - "risk": "high" - }, - "remove_firewall_rule": { - "label": "Remove firewall rule", - "optional": [ - "action", - "source", - "name", - "rule_id" - ], - "required": [ - "port", - "protocol" - ], - "risk": "high" - }, - "remove_package": { - "label": "Remove package", - "optional": [], - "required": [ - "package_name" - ], - "risk": "standard" - }, - "remove_tag": { - "label": "Remove host tag", - "optional": [], - "required": [ - "tags" - ], - "risk": "low" - }, - "reprovision_cleanup": { - "label": "Remove staged installer files", - "optional": [], - "required": [ - "job_id" - ], - "risk": "high" - }, - "reprovision_commit": { - "label": "Boot into OS installer (WIPES DISK)", - "optional": [], - "required": [ - "job_id", - "cmdline" - ], - "risk": "high" - }, - "reprovision_preflight": { - "label": "Check rebuild readiness", - "optional": [ - "disk_target", - "os_family" - ], - "required": [], - "risk": "low" - }, - "reprovision_stage": { - "label": "Stage OS installer", - "optional": [], - "required": [ - "job_id", - "kernel_url", - "initrd_url", - "kernel_sha256", - "initrd_sha256" - ], - "risk": "high" - }, - "request_nessus_scan": { - "label": "Request Nessus vulnerability scan", - "optional": [], - "required": [], - "risk": "low" - }, - "request_network_scan": { - "label": "Request network vulnerability scan", - "optional": [ - "engine" - ], - "required": [], - "risk": "low" - }, - "restart_container": { - "label": "Restart container", - "optional": [], - "required": [ - "container_name" - ], - "risk": "standard" - }, - "restart_service": { - "label": "Restart service", - "optional": [], - "required": [ - "service_name" - ], - "risk": "standard" - }, - "run_command": { - "label": "Run shell command", - "optional": [ - "timeout" - ], - "required": [ - "command" - ], - "risk": "high" - }, - "run_package_updates": { - "label": "Run system updates", - "optional": [ - "security_only" - ], - "required": [], - "risk": "standard" - }, - "run_trivy_scan": { - "label": "Run Trivy vulnerability scan", - "optional": [ - "scope" - ], - "required": [], - "risk": "low" - }, - "set_firewall_policy": { - "label": "Set firewall default policy", - "optional": [], - "required": [ - "direction", - "policy" - ], - "risk": "high" - }, - "set_hostname": { - "label": "Set hostname", - "optional": [], - "required": [ - "hostname" - ], - "risk": "standard" - }, - "set_permissions": { - "label": "Set permissions", - "optional": [ - "owner", - "group", - "mode" - ], - "required": [ - "path" - ], - "risk": "standard" - }, - "start_container": { - "label": "Start container", - "optional": [], - "required": [ - "container_name" - ], - "risk": "low" - }, - "start_service": { - "label": "Start service", - "optional": [], - "required": [ - "service_name" - ], - "risk": "standard" - }, - "stop_container": { - "label": "Stop container", - "optional": [], - "required": [ - "container_name" - ], - "risk": "standard" - }, - "stop_service": { - "label": "Stop service", - "optional": [], - "required": [ - "service_name" - ], - "risk": "standard" - }, - "trivy_db_update": { - "label": "Update Trivy vulnerability database", - "optional": [], - "required": [], - "risk": "low" - }, - "update_agent": { - "label": "Update Vigil agent", - "optional": [ - "platform" - ], - "required": [], - "risk": "standard" - }, - "update_package": { - "label": "Update package", - "optional": [], - "required": [ - "package_name" - ], - "risk": "standard" - }, - "write_file": { - "label": "Write file", - "optional": [ - "mode" - ], - "required": [ - "path", - "content" - ], - "risk": "high" - } - }, - "vigil_version": "2026.9.0" -} diff --git a/schemas/automation.md b/schemas/automation.md index f863cff..eb43a2a 100644 --- a/schemas/automation.md +++ b/schemas/automation.md @@ -1,7 +1,7 @@ # Automations An **automation** binds a trigger to an action: when something happens (or -when the clock says so), a task or a baseline runs on a set of hosts without +when the clock says so), a task or a playbook runs on a set of hosts without anyone clicking anything. It is the "when X, do Y" of the community repo. Automations live **flat inside `automations/`**, the same way tasks live flat @@ -19,9 +19,11 @@ Fires when a lifecycle event happens — an alert, a host approval, and so on. | Field | Type | Required | Notes | |----------------|--------|----------|--------------------------------------------------------------| -| `event` | string | yes | The name of the event to watch. | +| `event` | string | yes | One of `alert_sent`, `alert_refired`, `host_approved`, `host_rejected`, `task_completed`, `insight_created`. | | `min_severity` | string | no | One of `info`, `warning`, `critical`. Alert events only: fire only when the alert is at least this severe. | | `event_tags` | list | no | Strings. Alert events only: fire only for events on hosts carrying one of these tags. | +| `conditions` | list | no | Up to 20 `{field, op, value}` filters. Fields: `text`, `severity`, `rule`, `message`, `metric_value`, `flap_count`, `host`, `host_tags`, `event`. Ops include `equals`, `contains`, `starts_with`, `regex`, `gt`/`gte`/`lt`/`lte`, `in`, `is_set`. | +| `condition_logic` | string | no | `all` (default) or `any`: how `conditions` combine. | ### `trigger: schedule` @@ -50,12 +52,12 @@ The action is one of two kinds, chosen by `action_kind`: | Field | Type | Required | Notes | |---------------|--------|----------|--------------------------------------------------------------| -| `action_kind` | string | yes | `task` or `baseline`. | -| `task` | string | when `action_kind: task` | The slug of a file in `tasks/`. Must resolve. When `action_kind` is `baseline`, this key is forbidden. | -| `baseline` | string | when `action_kind: baseline` | The slug of a file in `baselines/`. Must resolve. When `action_kind` is `task`, this key is forbidden. | +| `action_kind` | string | yes | `task` or `playbook`. | +| `task` | string | when `action_kind: task` | The slug of a file in `tasks/`. Must resolve. When `action_kind` is `playbook`, this key is forbidden. | +| `playbook` | string | when `action_kind: playbook` | The slug of a file in `playbooks/`. Must resolve. When `action_kind` is `task`, this key is forbidden. | | `params_override` | mapping | no | Input overrides merged over the action's parameters at dispatch. Keys are the action's zero-based index as a string; values are mappings of parameter name to value. | -Exactly one of `task` or `baseline` must be present, and it must match +Exactly one of `task` or `playbook` must be present, and it must match `action_kind`. Supplying both, or supplying the one that does not match `action_kind`, is an error. @@ -109,7 +111,7 @@ description: >- enabled: true trigger: event -event: alert +event: alert_sent min_severity: critical event_tags: - web @@ -120,20 +122,20 @@ task: clean-stale-files target: event_host ``` -A scheduled variant, running a baseline nightly at 2am on the web hosts: +A scheduled variant, running a playbook nightly at 2am on the web hosts: ```yaml -name: Nightly Web Baseline +name: Nightly Web Playbook author: Connor Haggerty -description: Runs the standard web-server baseline every night at 02:00. +description: Runs the standard web-server playbook every night at 02:00. trigger: schedule cron: minute: "0" hour: "2" -action_kind: baseline -baseline: new-server-build +action_kind: playbook +playbook: new-server-build target: tags target_tags: @@ -157,7 +159,7 @@ follow them, but Vigil resolves by `uid` first. That matters because a slug is a filename: it changes when the file is renamed, it collides when two people pick the same name, and this repo does not force a filename to equal the slugified `name` inside it. A uid is none of those things. It is what lets a -baseline keep pointing at the right task after somebody renames their local +playbook keep pointing at the right task after somebody renames their local copy, and what lets two unrelated tasks both be called "Cleanup". | Field | Type | Required | Notes | @@ -174,4 +176,4 @@ another and leaving the `uid` in place is the same mistake in reverse — the two files then claim to be the same thing. Delete the line and let Vigil mint a fresh one. -An automation may also carry `action_uid`, the uid of the task or baseline it runs, alongside the `task` or `baseline` slug. +An automation may also carry `action_uid`, the uid of the task or playbook it runs, alongside the `task` or `playbook` slug. diff --git a/schemas/baseline.md b/schemas/baseline.md deleted file mode 100644 index 4d768da..0000000 --- a/schemas/baseline.md +++ /dev/null @@ -1,122 +0,0 @@ -# Baselines - -A **baseline** is a named, ordered sequence of tasks from this repository. -Vigil runs the whole sequence for you: a baseline is a task you do not click -every time. It is the "set of things I want done on every fresh machine" — -install the packages, apply the tweaks, run the checks — and it can be run -on demand from the UI, or automatically when hosts enroll. - -Baselines live **flat inside `baselines/`**, the same way tasks live flat in -`tasks/`. The server lists the directory without recursing, so a file in a -subdirectory would never appear. Name the file after the baseline's `name` -with the same slug rules as tasks: lowercase, non-alphanumerics collapsed to -a single hyphen, at most 60 characters, `.yaml` extension. - -## How a baseline names its tasks - -A baseline does not copy tasks. Each step in `steps` names a task **by -slug** — the filename of a file in `tasks/` without its extension. A step -with `task: restart-nginx` refers to `tasks/restart-nginx.yaml`. The file -must exist in this repository; the validator resolves every slug against -`tasks/` and rejects a baseline that points at a file that is not there. -A baseline whose step names nothing would import as a baseline that silently -does nothing, which is worse than a rejected file, so the reference must -resolve. - -The same slug rule applies in reverse from automations: an automation that -runs a baseline names it by the stem of the file in `baselines/`. - -## Fields - -| Field | Type | Required | Notes | -|-------------------|--------|----------|--------------------------------------------------------------| -| `name` | string | yes | At most 120 characters. | -| `description` | string | no | At most 2000 characters. | -| `author` | string | yes | At most 80 characters. Must be a real name, not a placeholder — `Admin`, `TODO`, `Unknown` and the empty string are rejected, the same rule as tasks. | -| `created` | date | no | ISO date, `YYYY-MM-DD`. | -| `target_tags` | list | no | At most 32 strings. When set, only hosts carrying at least one of these tags receive the baseline at enrollment. When omitted, every approved host does. | -| `allow_high_risk` | bool | no | Defaults to `false`. See the high-risk rule below. | -| `steps` | list | yes | Non-empty, at most 32 entries. Each entry is a step (below). | - -Each step is a mapping with: - -| Field | Type | Required | Notes | -|-------------------|---------|----------|------------------------------------------------------------------------| -| `task` | string | yes | The slug of a file in `tasks/`. Must resolve to an existing file. | -| `order` | integer | no | Defaults to the step's position in the list, 1-based. `order` values must be **unique within the baseline**, mirroring the server's unique constraint on `(baseline, order)`. | -| `params_override` | mapping | no | Per-step input overrides, merged over the task's own action parameters when the baseline runs. Keys are the action's zero-based index as a string; values are mappings of parameter name to value. | - -## The high-risk rule - -If any step's task declares `risk: high`, the baseline must set -`allow_high_risk: true`. This mirrors the server's gate exactly: on the -server, turning that flag on is itself a 2FA-confirmed act, because a -baseline dispatches with nobody watching. The validator checks it by loading -each referenced task and reading its `risk` field — a task that omits -`risk` is treated as `standard`, the same default the server applies. - -## Example - -```yaml -name: New Server Build -author: Connor Haggerty -created: 2026-08-20 -description: >- - Brings a fresh Ubuntu host to the standard state: updates the package - cache, installs the baseline tooling, and runs the health check. -target_tags: - - web -allow_high_risk: false - -steps: - - task: refresh-package-cache - order: 1 - - task: install-common-tools - order: 2 - - task: run-health-check - order: 3 - params_override: - "0": - extra_checks: ["disk"] -``` - -The `params_override` on the last step passes `extra_checks: ["disk"]` into -action `0` of `run-health-check` on top of whatever that task declares. -Because none of the three tasks is `risk: high`, `allow_high_risk` may stay -`false`. - -Validate locally before you open a pull request: - -```sh -python validate_baseline.py --all -python validate_baseline.py baselines/new-server-build.yaml -``` - -## Identity: the `uid` field - -Every content file may carry a `uid` — a UUID that identifies it on every -Vigil installation, forever. - -References between files still name a **slug** so a human reading the diff can -follow them, but Vigil resolves by `uid` first. That matters because a slug is -a filename: it changes when the file is renamed, it collides when two people -pick the same name, and this repo does not force a filename to equal the -slugified `name` inside it. A uid is none of those things. It is what lets a -baseline keep pointing at the right task after somebody renames their local -copy, and what lets two unrelated tasks both be called "Cleanup". - -| Field | Type | Required | Notes | -|---|---|---|---| -| `uid` | string | no | A UUID. Vigil's *Submit to Community* writes one for you. | - -`uid` is optional because everything merged before it existed has none, and -Vigil falls back to slug matching for those. New content should carry one. - -**Do not reuse or edit a `uid`.** It is the file's identity, not its version: -changing it makes every installation treat the file as new content and fork a -second copy alongside the one people already have. Copying one file to start -another and leaving the `uid` in place is the same mistake in reverse — the -two files then claim to be the same thing. Delete the line and let Vigil mint -a fresh one. - -A step may also carry `uid`, the uid of the task its `task` slug names. When both are present they must agree; the validator rejects a step whose uid belongs to a different file than its slug does, because Vigil trusts the uid and would import something other than what the diff showed. diff --git a/schemas/playbook.md b/schemas/playbook.md new file mode 100644 index 0000000..b906077 --- /dev/null +++ b/schemas/playbook.md @@ -0,0 +1,33 @@ +# Playbooks + +A playbook runs tasks from this repository in order on each host, and can branch on +how earlier tasks went. Vigil decides each branch on the server once the tasks before +it finish on that host. + +| Field | Required | Notes | +|-------------------|----------|-------| +| `name`, `uid`, `author`, `created`, `description` | as for tasks | | +| `target_tags` | no | Tags the playbook is meant for. | +| `allow_high_risk` | no | `true` to allow high-risk tasks as steps. Defaults to `false`. | +| `steps` | yes | 1–32 steps, nested at most 3 levels deep. | + +A **step**: + +| Key | Notes | +|---------------------|-------| +| `task` | The task's slug (its file name without `.yaml`). | +| `uid` | The task's uid. It must match the file `task` names. | +| `id` | Optional. Names the step so a later `if:` can read it. Quote `yes`/`no`/`on`/`off`. | +| `order` | Optional positive integer, unique within the playbook. | +| `on_not_applicable` | `stop` (default) or `skip`. | +| `on_failure` | `stop` (default) or `continue`, which makes the failure *handled* so a later `if:` can recover. | +| `outcome` | Up to 40 characters, naming what reaching this step means. | +| `color` | `rose`, `peach`, `lemon`, `mint`, `sky` or `lavender`, for the flow canvas. | +| `params_override` | A mapping of input values for that task. | + +A **branch** is `if:` (reading `steps..status`, which is `ok` / `failed` / +`not_applicable` / `skipped`, or `steps..result.`), then `then:` and an +optional `else:`, each a list of steps or branches. A condition may only name steps +that come before it. + +See `keep-the-web-tier-healthy.yaml` and `onboard-a-container-host.yaml`. diff --git a/schemas/task.md b/schemas/task.md new file mode 100644 index 0000000..0cfba49 --- /dev/null +++ b/schemas/task.md @@ -0,0 +1,45 @@ +# Tasks + +A task is Vigil's task YAML, the same document the task editor saves, plus `uid`, +`author` and `created`. The full language (every action, its params and its outputs) +is in the Vigil wiki under **Task YAML Spec**, **Relevance and branches**, **Hunts** and +**Detection tasks**. This page is the short version. + +| Field | Required | Notes | +|---------------|----------|-------| +| `name` | yes | At most 120 characters. The file is named after it, slugified. | +| `uid` | yes | A UUID. It's the file's identity, so never change or reuse it. | +| `author` | yes | A real name. | +| `created` | no | An ISO date. | +| `description` | no | What breaks, what the task does, what to know before deploying. | +| `risk` | no | `low`, `standard` or `high`. A floor; the server raises it to match the actions. | +| `platforms` | no | `linux`, `windows`, `darwin`. **A label only**: use `relevant:` to keep a task off a platform. | +| `inputs` | no | Values the operator supplies at deploy time, referenced as `${{ inputs. }}`. | +| `relevant` | no | `all:` / `any:` / `not:` tree of `hunt_*` probes. No match means the host reports *not applicable*. | +| `severity`, `cves`, `boost` | no | Make it a **detection task**: a match becomes a finding on the Vulnerabilities page. | +| `actions` | yes | Steps (`id`, `type`, `params`, optional `when:`, `outcome:`) or branches (`if:` / `then:` / `else:`). | + +A step can read earlier steps in `when:` and `if:` through `steps..status` and +`steps..result.`, plus `inputs.*` and `agent.os`, `agent.arch` and +`agent.pkg_manager`. + +```yaml +name: Keep Nginx Running +uid: d6772f24-63d4-4616-a313-8eb2de343916 +author: Connor Haggerty +risk: standard +relevant: + all: + - hunt_package: {name: nginx} +actions: + - id: check + type: check_service + params: {service_name: nginx} + outcome: Already running + - if: steps.check.result.active == False + then: + - id: restart + type: restart_service + params: {service_name: nginx} + outcome: Restarted +``` diff --git a/tasks/clear-temp-files.yaml b/tasks/clear-temp-files.yaml index 0499e9f..76f6a64 100644 --- a/tasks/clear-temp-files.yaml +++ b/tasks/clear-temp-files.yaml @@ -3,26 +3,23 @@ uid: c6c2e640-2958-54eb-a64f-f6be1870c131 author: Connor Haggerty created: 2026-08-26 description: >- - Clears stale files out of /tmp. Use it on hosts that accumulate scratch - files and need space back. Files newer than the age you supply (7 days by - default) are left alone. -relevance: "Linux hosts that accumulate temp files" + Clears stale files out of the temp directory. Use it on hosts that + accumulate scratch files and need space back. Files newer than the age you + supply (7 days by default) are left alone. The run reports how many files + were removed and how many were skipped because they were in use. risk: low -platforms: - - linux - inputs: - id: older_than_days type: number label: File age (days) - description: >- - Only remove files older than this many days. + description: Only remove files older than this many days. default: 7 required: false actions: - - id: clear temp files + - id: clear type: clear_temp_files params: - older_than_days: "{{ inputs.older_than_days }}" + older_than_days: "${{ inputs.older_than_days }}" + outcome: Temp files cleared diff --git a/tasks/docker-prune-and-restart-unhealthy.yaml b/tasks/docker-prune-and-restart-unhealthy.yaml deleted file mode 100644 index ba335a2..0000000 --- a/tasks/docker-prune-and-restart-unhealthy.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Docker Prune and Restart Unhealthy Container -uid: 342135b3-b6ad-5b79-b24d-59c8d9d5b31b -author: Connor Haggerty -created: 2026-08-26 -description: >- - Reclaims disk on a container host. Truncates the container's log file - (the log path Docker reports for it) and then restarts the container so it - comes back clean. Use it when a container's log has grown large or the - container is wedged and a restart is the expected fix. Supply the container - name at deploy time — it is not hardcoded. -relevance: "Disk usage on Docker hosts" -risk: standard - -platforms: - - linux - -inputs: - - id: container_name - type: text - label: Container name - description: >- - Name of the container whose log to truncate and that to restart. - required: true - -actions: - - id: truncate docker log - type: clear_docker_logs - params: - container_name: "{{ inputs.container_name }}" - - id: restart container - type: restart_container - params: - container_name: "{{ inputs.container_name }}" diff --git a/tasks/find-exposed-redis.yaml b/tasks/find-exposed-redis.yaml new file mode 100644 index 0000000..57e2154 --- /dev/null +++ b/tasks/find-exposed-redis.yaml @@ -0,0 +1,18 @@ +name: Find Exposed Redis +uid: b2e65ea8-ba8a-49ab-9cf2-b7abd2345864 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Hunts for anything listening on Redis's default port, 6379. Redis ships + without authentication, so a listener reachable from the network is a + common way in. The hunt is read-only: it reports each listener with its + process, and changes nothing. Use the matches to decide which hosts need + Redis bound to localhost or put behind a password. +risk: low + +actions: + - id: listeners + type: hunt_port + params: + port: 6379 + protocol: tcp diff --git a/tasks/install-nginx.yaml b/tasks/install-nginx.yaml index 085bc43..b922a40 100644 --- a/tasks/install-nginx.yaml +++ b/tasks/install-nginx.yaml @@ -3,18 +3,23 @@ uid: 1cf8ab45-1b5f-52e8-8bc8-fe9ef29a488c author: Connor Haggerty created: 2026-08-26 description: >- - Installs the nginx web server with the system package manager. Use it on a - fresh host that should serve web traffic. The service is started and - enabled by the package itself; run Rotate and Verify Service afterwards if - you want to prove it is up. -relevance: "Fresh Linux hosts that need a web server" + Installs the nginx web server with the host's own package manager (apt, + dnf, yum, pacman or apk). Use it on a fresh host that should serve web + traffic. The package starts and enables the service itself; run Rotate and + Verify Nginx afterwards to prove it is up. A host that already has nginx + reports not applicable and nothing is touched. risk: standard - platforms: - linux +relevant: + not: + - hunt_package: + name: nginx + actions: - - id: install nginx + - id: install type: install_package params: package_name: nginx + outcome: Installed diff --git a/tasks/keep-firefox-current.yaml b/tasks/keep-firefox-current.yaml new file mode 100644 index 0000000..007b7f6 --- /dev/null +++ b/tasks/keep-firefox-current.yaml @@ -0,0 +1,18 @@ +name: Keep Firefox Current +uid: 526dcc01-aff2-4a4a-af2e-9526a12e9f4a +author: Connor Haggerty +created: 2026-10-07 +description: >- + Makes sure Firefox is installed and on its latest version, through + whichever package manager the host uses (winget, Chocolatey, apt, dnf, + flatpak…). The step reports whether anything changed and the version + before and after, so the run detail shows which hosts were behind. +risk: standard + +actions: + - id: ensure + type: app_ensure + params: + app: firefox + state: latest + outcome: Firefox current diff --git a/tasks/keep-nginx-running.yaml b/tasks/keep-nginx-running.yaml new file mode 100644 index 0000000..1b7f566 --- /dev/null +++ b/tasks/keep-nginx-running.yaml @@ -0,0 +1,38 @@ +name: Keep Nginx Running +uid: d6772f24-63d4-4616-a313-8eb2de343916 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Checks nginx and restarts it only if it is down. Hosts without nginx + report not applicable, so it is safe to target a whole fleet. The run's + outcome says which path each host took: "Already running" or "Restarted". + A host's outcome is the last step with an outcome that finished, so a + restarted host ends as "Restarted". A worked example of relevant:, if:, + step outputs and outcome: in one task. +risk: standard +platforms: + - linux + +relevant: + all: + - hunt_package: + name: nginx + +actions: + - id: check + type: check_service + params: + service_name: nginx + outcome: Already running + - if: steps.check.result.active == False + then: + - id: restart + type: restart_service + params: + service_name: nginx + - id: verify + type: check_service + params: + service_name: nginx + expect: running + outcome: Restarted diff --git a/tasks/patch-linux-security.yaml b/tasks/patch-linux-security.yaml new file mode 100644 index 0000000..885bde0 --- /dev/null +++ b/tasks/patch-linux-security.yaml @@ -0,0 +1,27 @@ +name: Patch Linux Security +uid: d3759bd8-a5c0-492e-b460-765ef089c87a +author: Connor Haggerty +created: 2026-10-07 +description: >- + Installs the security updates the host's package manager offers, and + nothing else. On apt this is the -security pocket; dnf and yum use + --security. Pair it with a schedule automation, or use a patch policy + when you want approvals, deferrals and maintenance windows. +risk: standard +platforms: + - linux + +# platforms: is a label; this probe is what makes a Windows host report not +# applicable. Every systemd-era Linux has /etc/os-release. +relevant: + all: + - hunt_file: + name: os-release + paths: /etc + +actions: + - id: patch + type: run_package_updates + params: + security_only: true + outcome: Patched diff --git a/tasks/patch-windows-security.yaml b/tasks/patch-windows-security.yaml new file mode 100644 index 0000000..29e8f13 --- /dev/null +++ b/tasks/patch-windows-security.yaml @@ -0,0 +1,43 @@ +name: Patch Windows Security +uid: 1eee4aad-4d9d-48c4-b831-b5ad94428b02 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Scans for missing Windows security and critical updates, installs them + only if there are any, and schedules a reboot the logged-in user can defer + when an update needs one. Hosts with nothing missing finish as "Up to + date" and never reach the install step. +risk: standard +platforms: + - windows + +# platforms: is a label; this probe is what makes a Linux host report not +# applicable. Every Windows install has the Windows Update service. +relevant: + all: + - hunt_service: + name: wuauserv + +actions: + - id: scan + type: windows_update_scan + params: + classifications: Security Updates,Critical Updates + outcome: Up to date + - if: steps.scan.result.count > 0 + then: + - id: install + type: windows_update_install + params: + classifications: Security Updates,Critical Updates + outcome: Patched + - id: reboot + type: reboot + when: steps.install.result.reboot_required == True + params: + delay_seconds: 900 + notify: true + notify_message: Security updates were installed. This PC restarts in 15 minutes. + defer_limit: 2 + defer_minutes: 60 + outcome: Patched, rebooting diff --git a/tasks/remove-pycharm-process-lock.yaml b/tasks/remove-pycharm-process-lock.yaml index e3fd841..0c278fe 100644 --- a/tasks/remove-pycharm-process-lock.yaml +++ b/tasks/remove-pycharm-process-lock.yaml @@ -2,12 +2,11 @@ name: Remove PyCharm Process Lock uid: f843c56d-d19f-5143-9d2c-ec742938ca95 author: Connor Haggerty created: 2026-07-05 -description: | - Removes the stale PyCharm .lock file that can keep PyCharm from starting - after an unclean shutdown. Supply the path to the PyCharm config directory - (the folder that contains .lock) at deploy time — it varies by user, - version and platform. -relevance: "Machine Effected" +description: >- + Removes the stale PyCharm .lock file that keeps PyCharm from starting after + an unclean shutdown. Supply the full path to the .lock file at deploy time: + it sits in the PyCharm config directory, which varies by user, version and + platform. risk: high inputs: @@ -15,12 +14,13 @@ inputs: type: text label: PyCharm .lock file path description: >- - Full path to the .lock file, e.g. the .lock inside the PyCharm - config directory. + Full path to the .lock file, e.g. ~/.config/JetBrains/PyCharm2026.2/.lock + on Linux or %APPDATA%\JetBrains\PyCharm2026.2\.lock on Windows. required: true actions: - - id: remove lock + - id: remove-lock type: delete_path params: - path: "{{ inputs.lock_path }}" + path: "${{ inputs.lock_path }}" + outcome: Lock removed diff --git a/tasks/rotate-and-verify-nginx.yaml b/tasks/rotate-and-verify-nginx.yaml index 4f0ef5d..624370f 100644 --- a/tasks/rotate-and-verify-nginx.yaml +++ b/tasks/rotate-and-verify-nginx.yaml @@ -3,36 +3,31 @@ uid: 28100e17-bc96-5bfb-876b-d851e248458a author: Connor Haggerty created: 2026-08-26 description: >- - Restarts a service and confirms it came back. Defaults to nginx: restarts - it, then checks the service and requires it to report as running. Use it - after changing nginx configuration to bounce the service and prove the new - config loaded. Supply a different service name at deploy time if yours is - not named "nginx". -relevance: "Web servers running nginx" + Restarts a service and proves it came back. Defaults to nginx: restarts + it, then checks the service and requires it to be running, so the run + fails if the new configuration did not load. Use it after changing nginx + configuration. Supply a different service name at deploy time if yours is + not called "nginx". risk: standard - platforms: - linux -success_criteria: - exit_code: 0 - inputs: - id: service_name type: text label: Service name - description: >- - systemd service name to restart and verify. + description: systemd service to restart and verify. default: nginx required: true actions: - - id: restart nginx + - id: restart type: restart_service params: - service_name: "{{ inputs.service_name }}" - - id: verify nginx is active + service_name: "${{ inputs.service_name }}" + - id: verify type: check_service params: - service_name: "{{ inputs.service_name }}" + service_name: "${{ inputs.service_name }}" expect: running + outcome: Restarted and running diff --git a/tasks/scan-with-trivy.yaml b/tasks/scan-with-trivy.yaml new file mode 100644 index 0000000..bd27d39 --- /dev/null +++ b/tasks/scan-with-trivy.yaml @@ -0,0 +1,24 @@ +name: Scan With Trivy +uid: 133df278-5d69-4f1f-90da-0252fe3cfe21 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Runs a Trivy vulnerability scan of the host's filesystem and sends + the findings to the Vulnerabilities page. Hosts without Trivy report not + applicable: deploy Vigil's built-in "Install Trivy" template to them + first. +risk: low + +relevant: + any: + - hunt_file: + name: trivy + paths: /usr/bin,/usr/local/bin + - hunt_file: + name: trivy.exe + scope: targeted + +actions: + - id: scan + type: run_trivy_scan + outcome: Scanned diff --git a/tasks/tag-container-hosts.yaml b/tasks/tag-container-hosts.yaml new file mode 100644 index 0000000..2301589 --- /dev/null +++ b/tasks/tag-container-hosts.yaml @@ -0,0 +1,25 @@ +name: Tag Container Hosts +uid: 3cf68a9e-53f7-4b2c-a27d-88427ffe84e8 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Adds the "docker" tag to every host that runs Docker or Podman, so + container automations and playbooks can target hosts by tag instead of by + name. Hosts without a container engine report not applicable and keep + their tags. Run it once across the fleet, or from the "Tag New Container + Hosts" automation as each host is approved. +risk: low + +relevant: + any: + - hunt_service: + name: docker + - hunt_service: + name: podman + +actions: + - id: tag + type: add_tag + params: + tags: docker + outcome: Tagged docker diff --git a/tasks/truncate-container-logs.yaml b/tasks/truncate-container-logs.yaml new file mode 100644 index 0000000..484ece0 --- /dev/null +++ b/tasks/truncate-container-logs.yaml @@ -0,0 +1,24 @@ +name: Truncate Container Logs +uid: 04954106-4ce1-48d7-a232-408624b49405 +author: Connor Haggerty +created: 2026-10-07 +description: >- + Truncates the log file of every container on the host, without stopping + any of them. Docker's default json-file logs grow without limit, and on a + busy host they are often what fills the disk. Safe to run unattended. Hosts + with no container engine report not applicable. +risk: low +platforms: + - linux + +relevant: + any: + - hunt_service: + name: docker + - hunt_service: + name: podman + +actions: + - id: truncate + type: clear_docker_logs + outcome: Logs truncated diff --git a/tasks/truncate-log-and-restart-container.yaml b/tasks/truncate-log-and-restart-container.yaml new file mode 100644 index 0000000..efe1407 --- /dev/null +++ b/tasks/truncate-log-and-restart-container.yaml @@ -0,0 +1,31 @@ +name: Truncate Log and Restart Container +uid: 342135b3-b6ad-5b79-b24d-59c8d9d5b31b +author: Connor Haggerty +created: 2026-08-26 +description: >- + Gets one wedged container back on its feet. Truncates the container's log + file (the path Docker reports for it), restarts the container, and checks + it came back. Use it when a container's log has grown huge or the + container has stopped answering and a restart is the expected fix. Supply + the container name at deploy time. +risk: standard +platforms: + - linux + +inputs: + - id: container_name + type: text + label: Container name + description: The container to truncate and restart. + required: true + +actions: + - id: truncate + type: clear_docker_logs + params: + container_name: "${{ inputs.container_name }}" + - id: restart + type: restart_container + params: + container_name: "${{ inputs.container_name }}" + outcome: Restarted diff --git a/tasks/xz-backdoor-cve-2024-3094.yaml b/tasks/xz-backdoor-cve-2024-3094.yaml new file mode 100644 index 0000000..1adb000 --- /dev/null +++ b/tasks/xz-backdoor-cve-2024-3094.yaml @@ -0,0 +1,38 @@ +name: xz Backdoor CVE-2024-3094 +uid: 1257a8f9-c5f3-421e-bb3e-aabf05701268 +author: Connor Haggerty +created: 2026-10-07 +description: >- + A detection task for the xz/liblzma backdoor. Only xz 5.6.0 and 5.6.1 + carry it, so the relevant: block matches exactly those versions; every + other host reports not applicable and any finding there closes. Where it + matches, an sshd process boosts the confidence, because sshd is how the + backdoor is reached. The fix runs the distro's package updates, which + replace the backdoored release with the distro's patched one. +risk: standard +severity: critical +cves: + - CVE-2024-3094 +platforms: + - linux + +relevant: + any: + - hunt_package: + name: xz-utils + version_gte: "5.6.0" + version_lt: "5.6.2" + - hunt_package: + name: xz + version_gte: "5.6.0" + version_lt: "5.6.2" +boost: + - hunt_process: + name: sshd + +actions: + - id: update + type: run_package_updates + params: + security_only: false + outcome: Updated diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_seed_content.py b/tests/test_seed_content.py deleted file mode 100644 index a445f6a..0000000 --- a/tests/test_seed_content.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Regression test over the real seed content in the repo. - -Unlike the other validator tests (which use temp-directory fixtures), this -test walks the actual tasks/, baselines/ and automations/ directories and -asserts the matching validator reports success for every real file — so a -future seed cannot be merged broken, and it is the test the CI job leans on. -""" - -import sys -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from common import list_content_files # noqa: E402 -from validate_automation import validate_automation_file # noqa: E402 -from validate_baseline import validate_baseline_file # noqa: E402 -from validate_task import validate_task_file # noqa: E402 - -_HERE = Path(__file__).resolve().parent.parent - -# kind -> (validator, minimum number of real seed files expected) -_SEED_KINDS = { - "tasks": (validate_task_file, 3), - "baselines": (validate_baseline_file, 2), - "automations": (validate_automation_file, 2), -} - - -class TestEverySeedValidates(unittest.TestCase): - def test_every_seed_validates(self): - failures = [] - for kind, (validate, minimum) in _SEED_KINDS.items(): - files, nested = list_content_files(_HERE / kind) - if len(files) < minimum: - failures.append( - f"{kind}/: expected at least {minimum} seed files, " - f"found {len(files)}") - for path in nested: - failures.append( - f"{path}: {kind} files must sit directly in {kind}/ — " - f"the Vigil server does not list subdirectories") - for path in files: - errors = validate(path) - for message in errors: - failures.append(f"{path}: {message}") - self.assertEqual(failures, [], "\n".join(failures)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_uid.py b/tests/test_uid.py deleted file mode 100644 index 766603a..0000000 --- a/tests/test_uid.py +++ /dev/null @@ -1,116 +0,0 @@ -"""uids: the identity a piece of content keeps across every installation. - -A slug is a filename. It changes when the file is renamed and it collides when -two contributors pick the same name — and this repo does not force a filename -to match the slugified `name` inside it, so a slug is not even a reliable -handle on the content it names. A uid is neither of those things. - -Optional throughout: everything merged before uids existed has none, and the -readers fall back to slug matching for those. -""" - -import unittest -from pathlib import Path - -from validate_automation import validate_automation_text -from validate_baseline import validate_baseline_text -from validate_task import load_actions_reference, validate_task_text - -ROOT = Path(__file__).resolve().parent.parent -TASKS = ROOT / "tasks" - -GOOD = "c6c2e640-2958-54eb-a64f-f6be1870c131" -ACTIONS = load_actions_reference() - - -def _task(uid_line=""): - return f"name: T\n{uid_line}author: Connor Haggerty\nrisk: low\nactions:\n - type: clear_temp_files\n" - - -class TaskUidTests(unittest.TestCase): - def test_a_task_may_omit_its_uid(self): - self.assertEqual(validate_task_text(_task(), ACTIONS), []) - - def test_a_valid_uid_is_accepted(self): - self.assertEqual(validate_task_text(_task(f"uid: {GOOD}\n"), ACTIONS), []) - - def test_a_malformed_uid_is_rejected(self): - for bad in ("not-a-uuid", "1234", " "): - with self.subTest(bad=bad): - errors = validate_task_text(_task(f"uid: '{bad}'\n"), ACTIONS) - self.assertTrue(any("uid" in e for e in errors), errors) - - def test_an_empty_uid_reads_as_absent(self): - """`uid:` with nothing after it is an omission. Whitespace is not — - somebody typed something, and silently ignoring it would hide a typo - in the one field whose whole job is to be exact.""" - self.assertEqual(validate_task_text(_task("uid: \n"), ACTIONS), []) - - def test_a_non_string_uid_is_rejected(self): - self.assertTrue(any("uid" in e for e in validate_task_text(_task("uid: 12345\n"), ACTIONS))) - - -class BaselineUidTests(unittest.TestCase): - def _baseline(self, step_uid=""): - extra = f" uid: {step_uid}\n" if step_uid else "" - return ("name: B\nauthor: Connor Haggerty\nsteps:\n" - f" - task: clear-temp-files\n{extra} order: 1\n") - - def test_a_step_may_omit_its_uid(self): - self.assertEqual(validate_baseline_text(self._baseline(), TASKS), []) - - def test_a_step_uid_matching_its_slug_is_accepted(self): - self.assertEqual( - validate_baseline_text(self._baseline(GOOD), TASKS), []) - - def test_a_step_uid_that_contradicts_its_slug_is_rejected(self): - """The reference would import a different task than the one a reviewer - read here, because Vigil trusts the uid.""" - errors = validate_baseline_text( - self._baseline("11111111-1111-4111-8111-111111111111"), TASKS) - self.assertTrue(any("does not match" in e for e in errors), errors) - - def test_a_malformed_step_uid_is_rejected(self): - errors = validate_baseline_text(self._baseline("nope"), TASKS) - self.assertTrue(any("uid" in e for e in errors), errors) - - -class AutomationUidTests(unittest.TestCase): - def _automation(self, action_uid=""): - extra = f"action_uid: {action_uid}\n" if action_uid else "" - return ("name: A\nauthor: Connor Haggerty\ntrigger: event\n" - "event: alert_fired\naction_kind: task\n" - f"task: clear-temp-files\n{extra}target: event_host\n") - - def test_an_automation_may_omit_its_action_uid(self): - self.assertEqual(validate_automation_text(self._automation()), []) - - def test_a_valid_action_uid_is_accepted(self): - self.assertEqual(validate_automation_text(self._automation(GOOD)), []) - - def test_a_malformed_action_uid_is_rejected(self): - errors = validate_automation_text(self._automation("nope")) - self.assertTrue(any("action_uid" in e for e in errors), errors) - - -class CatalogUidTests(unittest.TestCase): - """Every file that ships in this repo carries a uid, and they are unique.""" - - def _uids(self): - import yaml - - found = {} - for kind in ("tasks", "baselines", "automations"): - for path in sorted((ROOT / kind).glob("*.yaml")): - raw = yaml.safe_load(path.read_text()) or {} - found[f"{kind}/{path.stem}"] = raw.get("uid") - return found - - def test_every_file_has_one(self): - missing = [k for k, v in self._uids().items() if not v] - self.assertEqual(missing, [], f"content without a uid: {missing}") - - def test_they_are_unique(self): - values = [v for v in self._uids().values() if v] - self.assertEqual(len(values), len(set(values)), - "two files share a uid, which defeats the point") diff --git a/tests/test_validate_automation.py b/tests/test_validate_automation.py deleted file mode 100644 index e31df39..0000000 --- a/tests/test_validate_automation.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Tests for validate_automation.py — plain unittest, no Django. - -Fixture YAML files are written into a tempfile.TemporaryDirectory() inside -each test rather than committed, so the `--all` runs in CI never pick them -up as real content. -""" - -import sys -import tempfile -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from validate_automation import validate_automation_text # noqa: E402 - -STANDARD_TASK = """\ -name: Clean Stale Files -author: Connor Haggerty -created: 2026-07-05 -description: Removes stale temp files. -risk: standard - -actions: - - type: delete_path - params: - path: "/tmp/stale" -""" - -BASELINE_FILE = """\ -name: Nightly Cleanup -author: Connor Haggerty -created: 2026-07-05 -description: Cleans up nightly. - -steps: - - task: clean-stale-files -""" - - -def automation_text(trigger: str, action: str, target: str = "", - extra: str = "") -> str: - return ( - "name: Disk Alert Cleanup\n" - "author: Connor Haggerty\n" - "created: 2026-07-05\n" - "description: Runs cleanup when a disk alert fires.\n" - + extra - + trigger - + action - + target - ) - - -EVENT_TRIGGER = "trigger: event\nevent: alert\n" -TASK_ACTION = "action_kind: task\ntask: clean-stale-files\n" -BASELINE_ACTION = "action_kind: baseline\nbaseline: nightly-cleanup\n" - - -class AutomationFixtureCase(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.addCleanup(self._tmp.cleanup) - self.root = Path(self._tmp.name) - tasks_dir = self.root / "tasks" - baselines_dir = self.root / "baselines" - tasks_dir.mkdir() - baselines_dir.mkdir() - (tasks_dir / "clean-stale-files.yaml").write_text(STANDARD_TASK, - encoding="utf-8") - (baselines_dir / "nightly-cleanup.yaml").write_text(BASELINE_FILE, - encoding="utf-8") - self.kwargs = {"tasks_dir": tasks_dir, - "baselines_dir": baselines_dir} - - def validate(self, text: str) -> list[str]: - return validate_automation_text(text, **self.kwargs) - - -class TestAutomationRejectsTargetHost(AutomationFixtureCase): - def test_automation_rejects_target_host(self): - errors = self.validate( - automation_text(EVENT_TRIGGER, TASK_ACTION, - target="target: host\n")) - self.assertTrue( - any("portable" in e and "target: tags" in e for e in errors), - f"expected the portability message, got: {errors}") - - -class TestAutomationRejectsMismatchedActionKind(AutomationFixtureCase): - def test_automation_rejects_mismatched_action_kind(self): - errors = self.validate( - automation_text(EVENT_TRIGGER, - "action_kind: task\nbaseline: nightly-cleanup\n")) - self.assertTrue( - any("action_kind is 'task'" in e and "baseline" in e - for e in errors), - f"expected the mismatch error, got: {errors}") - - -class TestAutomationRejectsCronInjection(AutomationFixtureCase): - def test_automation_rejects_cron_injection(self): - for bad in ('"0; rm -rf /"', '"* $(whoami)"', '"0 && curl evil"', - '"`id`"'): - with self.subTest(bad=bad): - errors = self.validate( - automation_text( - 'trigger: schedule\ncron:\n minute: ' + bad + "\n", - TASK_ACTION)) - self.assertTrue( - any("cron.minute" in e for e in errors), - f"expected the cron field to be rejected, got: {errors}") - - -class TestAutomationAcceptsValidCron(AutomationFixtureCase): - def test_automation_accepts_valid_cron(self): - for good in ('"*/15"', '"0"', '"1,3,5"', '"1-5"', '"*"'): - with self.subTest(good=good): - errors = self.validate( - automation_text( - 'trigger: schedule\ncron:\n minute: ' + good + "\n", - TASK_ACTION)) - self.assertEqual( - errors, [], f"unexpected errors: {errors}") - - -class TestAutomationAcceptsValidEvent(AutomationFixtureCase): - def test_automation_accepts_valid_event_automation(self): - errors = self.validate( - automation_text(EVENT_TRIGGER, TASK_ACTION)) - self.assertEqual(errors, [], f"unexpected errors: {errors}") - - -class TestAutomationTargetTagsRule(AutomationFixtureCase): - def test_automation_target_tags_required_for_tags_target(self): - errors = self.validate( - automation_text(EVENT_TRIGGER, TASK_ACTION, - target="target: tags\n")) - self.assertTrue( - any("target_tags" in e and "non-empty" in e for e in errors), - f"expected the target_tags error, got: {errors}") - - def test_automation_target_tags_forbidden_outside_tags_target(self): - errors = self.validate( - automation_text(EVENT_TRIGGER, TASK_ACTION, - target="target: all\ntarget_tags:\n - web\n")) - self.assertTrue( - any("target_tags" in e and "forbidden" in e for e in errors), - f"expected the forbidden error, got: {errors}") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_validate_baseline.py b/tests/test_validate_baseline.py deleted file mode 100644 index 7e99800..0000000 --- a/tests/test_validate_baseline.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Tests for validate_baseline.py — plain unittest, no Django. - -Fixture YAML files are written into a tempfile.TemporaryDirectory() inside -each test rather than committed, so the `--all` runs in CI never pick them -up as real content. -""" - -import sys -import tempfile -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from validate_baseline import validate_baseline_text # noqa: E402 - -STANDARD_TASK = """\ -name: Clean Stale Files -author: Connor Haggerty -created: 2026-07-05 -description: Removes stale temp files. -risk: standard - -actions: - - type: delete_path - params: - path: "/tmp/stale" -""" - -HIGH_RISK_TASK = """\ -name: Delete Old Logs -author: Connor Haggerty -created: 2026-07-05 -description: Deletes old log files. -risk: high - -actions: - - type: delete_path - params: - path: "/var/log/old" -""" - - -def baseline_text(steps_yaml: str, extra: str = "") -> str: - return ( - "name: Nightly Cleanup\n" - "author: Connor Haggerty\n" - "created: 2026-07-05\n" - "description: Cleans up nightly.\n" - + extra - + "steps:\n" - + steps_yaml - ) - - -class BaselineFixtureCase(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.addCleanup(self._tmp.cleanup) - self.root = Path(self._tmp.name) - tasks_dir = self.root / "tasks" - tasks_dir.mkdir() - (tasks_dir / "clean-stale-files.yaml").write_text(STANDARD_TASK, - encoding="utf-8") - (tasks_dir / "delete-old-logs.yaml").write_text(HIGH_RISK_TASK, - encoding="utf-8") - self.tasks_dir = tasks_dir - - def validate(self, text: str) -> list[str]: - return validate_baseline_text(text, tasks_dir=self.tasks_dir) - - -class TestBaselineAcceptsMinimalValid(BaselineFixtureCase): - def test_baseline_accepts_minimal_valid(self): - errors = self.validate(baseline_text( - " - task: clean-stale-files\n")) - self.assertEqual(errors, [], f"unexpected errors: {errors}") - - -class TestBaselineRejectsMissingTaskSlug(BaselineFixtureCase): - def test_baseline_rejects_missing_task_slug(self): - errors = self.validate(baseline_text( - " - task: does-not-exist\n")) - self.assertTrue( - any("does-not-exist" in e for e in errors), - f"expected the missing slug to be named, got: {errors}") - - -class TestBaselineRejectsDuplicateOrder(BaselineFixtureCase): - def test_baseline_rejects_duplicate_order(self): - errors = self.validate(baseline_text( - " - task: clean-stale-files\n" - " order: 1\n" - " - task: clean-stale-files\n" - " order: 1\n")) - self.assertTrue( - any("duplicate order 1" in e for e in errors), - f"expected a duplicate-order error, got: {errors}") - - -class TestBaselineHighRiskRequiresOptin(BaselineFixtureCase): - def test_baseline_high_risk_requires_optin(self): - steps = " - task: delete-old-logs\n" - errors = self.validate(baseline_text(steps)) - self.assertTrue( - any("delete-old-logs" in e and "allow_high_risk" in e - for e in errors), - f"expected the high-risk error naming the slug, got: {errors}") - - errors = self.validate(baseline_text( - steps, extra="allow_high_risk: true\n")) - self.assertEqual(errors, [], f"unexpected errors: {errors}") - - -if __name__ == "__main__": - unittest.main() diff --git a/validate_automation.py b/validate_automation.py deleted file mode 100644 index c99ac56..0000000 --- a/validate_automation.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -"""Offline validator for Vigil community automation files. - -Usage: - python validate_automation.py automations/foo.yaml [more.yaml ...] - python validate_automation.py --all - -Checks each file against the automation schema (schemas/automation.md), plus -the cross-file rules the server enforces at import time: - - * `action_kind: task` requires a `task` slug resolving under tasks/ and - forbids a `baseline` key; `action_kind: baseline` is the mirror image — - supplying both, or the one that does not match action_kind, is an error; - * `target: host` is not supported: host references are not portable, so the - validator rejects it and points at `target: tags`; - * `target_tags` is required and non-empty exactly when `target: tags`; - * cron fields are strings of at most 64 characters matching ^[\\d*,\\-/]+$ — - these strings reach a scheduler, so anything that is not a schedule - (shell metacharacters, spaces, backticks) is rejected. - -Uses only the standard library plus PyYAML. Shared rules (field limits, the -placeholder-author rule, the ISO-date rule, the CLI shell, non-recursive -discovery) live in common.py. Exits 0 when every file passes, 1 otherwise; -every failure prints one line to stderr as ": ". -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -from common import ( - check_uid, - _HERE, - _MAX_CRON_FIELD, - CRON_FIELDS, - _MAX_DESCRIPTION, - _MAX_NAME, - _CRON_FIELD, - check_author, - check_boolean, - check_created, - check_scalar, - check_string_list, - is_str, - load_yaml, - resolve_slug, - run_validator, -) - -_AUTOMATIONS_DIR = _HERE / "automations" -_TASKS_DIR = _HERE / "tasks" -_BASELINES_DIR = _HERE / "baselines" -_MIN_SEVERITIES = ("info", "warning", "critical") -_ACTION_KINDS = ("task", "baseline") -_TARGETS = ("event_host", "tags", "all") - - -def _check_cron(raw: dict, errors: list[str]) -> None: - """The schedule trigger's `cron` mapping: five string fields, each - matching the cron-field character set.""" - cron = raw.get("cron") - if not isinstance(cron, dict): - errors.append("'cron' must be a mapping with minute, hour, dom, month, dow") - return - for field in CRON_FIELDS: - value = cron.get(field, "*" if field != "minute" else "0") - if value is None: - continue - if not is_str(value): - errors.append(f"'cron.{field}' must be a string") - elif len(value) > _MAX_CRON_FIELD: - errors.append(f"'cron.{field}' is too long (max {_MAX_CRON_FIELD})") - elif not _CRON_FIELD.match(value): - errors.append( - f"'cron.{field}' {value!r} contains characters that are not a " - f"schedule — only digits, *, comma, dash and slash are allowed") - for key in cron: - if key not in CRON_FIELDS: - errors.append(f"unknown 'cron' field {key!r}") - - -def _check_trigger(raw: dict, errors: list[str]) -> None: - trigger = raw.get("trigger") - if not is_str(trigger) or trigger not in ("event", "schedule"): - errors.append("'trigger' must be 'event' or 'schedule'") - return - if trigger == "event": - event = raw.get("event") - if not is_str(event) or not event.strip(): - errors.append("'event' is required when trigger is event") - severity = raw.get("min_severity") - if severity is not None: - if not is_str(severity) or severity not in _MIN_SEVERITIES: - errors.append( - f"'min_severity' must be one of: {', '.join(_MIN_SEVERITIES)}") - check_string_list(raw, "event_tags", errors) - else: - _check_cron(raw, errors) - - -def _check_action(raw: dict, errors: list[str], - tasks_dir: Path, baselines_dir: Path) -> None: - action_kind = raw.get("action_kind") - if not is_str(action_kind) or action_kind not in _ACTION_KINDS: - errors.append(f"'action_kind' must be one of: {', '.join(_ACTION_KINDS)}") - return - - keys = {key for key in ("task", "baseline") if raw.get(key) is not None} - if len(keys) > 1: - errors.append( - "supply exactly one of 'task' or 'baseline', matching 'action_kind'") - return - expected = action_kind - if not keys: - errors.append( - f"'{expected}' is required when action_kind is {action_kind!r}") - else: - supplied = keys.pop() - if supplied != expected: - errors.append( - f"action_kind is {action_kind!r} but {supplied!r} was supplied — " - f"supply a {expected!r} slug instead") - return - slug = raw.get(expected) - if not is_str(slug) or not slug.strip(): - errors.append(f"'{expected}' must be a non-empty slug") - return - root = tasks_dir if expected == "task" else baselines_dir - if resolve_slug(root, slug) is None: - errors.append( - f"'{expected}' slug {slug.strip()!r} does not resolve to a file " - f"in {expected}s/") - - override = raw.get("params_override") - if override is not None: - if not isinstance(override, dict): - errors.append("'params_override' must be a mapping") - else: - for key, value in override.items(): - if not isinstance(value, dict): - errors.append( - f"'params_override' value for {key!r} must be a mapping") - - -def _check_target(raw: dict, errors: list[str]) -> None: - target = raw.get("target") - if target is None: - target = "event_host" - if target == "host": - errors.append( - "target: host is not supported in community automations — host " - "references are not portable across installations; use " - "target: tags instead") - return - if not is_str(target) or target not in _TARGETS: - errors.append( - f"'target' must be one of: {', '.join(_TARGETS)} " - f"('host' is not supported in community automations)") - return - tags = raw.get("target_tags") - if target == "tags": - if not isinstance(tags, list) or not tags: - errors.append( - "'target_tags' is required and must be non-empty when " - "target is tags") - else: - for index, item in enumerate(tags): - if not is_str(item) or not item.strip(): - errors.append( - f"'target_tags'[{index}] must be a non-empty string") - elif tags is not None: - errors.append("'target_tags' is forbidden unless target is tags") - - -def validate_automation_text(text: str, - tasks_dir: Path | None = None, - baselines_dir: Path | None = None) -> list[str]: - """Validate automation YAML source. - - Returns a list of error strings; an empty list means the file is valid. - """ - raw, error = load_yaml(text) - if error is not None: - return [error] - if not isinstance(raw, dict): - return ["top-level YAML must be a mapping"] - - if tasks_dir is None: - tasks_dir = _TASKS_DIR - if baselines_dir is None: - baselines_dir = _BASELINES_DIR - - errors: list[str] = [] - check_scalar(raw, "name", _MAX_NAME, required=True, errors=errors) - check_scalar(raw, "description", _MAX_DESCRIPTION, errors=errors) - check_uid(raw, errors) - check_uid(raw, errors, field="action_uid") - check_author(raw, errors) - check_created(raw, errors) - check_boolean(raw, "enabled", errors) - _check_trigger(raw, errors) - _check_action(raw, errors, tasks_dir, baselines_dir) - _check_target(raw, errors) - return errors - - -def validate_automation_file(path, tasks_dir: Path | None = None, - baselines_dir: Path | None = None) -> list[str]: - """Validate one automation file; returns a list of error strings.""" - path = Path(path) - if not path.is_file(): - return [f"cannot read {path}"] - return validate_automation_text(path.read_text(encoding="utf-8"), - tasks_dir, baselines_dir) - - -def main(argv=None) -> int: - return run_validator( - label="Validate Vigil community automation files.", - description="checks automation files against the automation schema", - content_dir=_AUTOMATIONS_DIR, - kind="automation", - validate_file=lambda p: validate_automation_file(p), - argv=argv, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/validate_baseline.py b/validate_baseline.py deleted file mode 100644 index f5bd66c..0000000 --- a/validate_baseline.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -"""Offline validator for Vigil community baseline files. - -Usage: - python validate_baseline.py baselines/foo.yaml [more.yaml ...] - python validate_baseline.py --all - -Checks each file against the baseline schema (schemas/baseline.md), plus the -cross-file rules the server enforces at import time: - - * every step's `task` slug must resolve to an existing file in tasks/ — - a baseline whose step points at nothing would import as a baseline that - silently does nothing, so the reference must resolve; - * `order` values (after defaulting to list position) must be unique, - mirroring the server's unique constraint on (baseline, order); - * a step whose task declares `risk: high` requires the baseline to set - `allow_high_risk: true`, mirroring the server's gate. - -Uses only the standard library plus PyYAML. Shared rules (field limits, the -placeholder-author rule, the ISO-date rule, the CLI shell, non-recursive -discovery) live in common.py. Exits 0 when every file passes, 1 otherwise; -every failure prints one line to stderr as ": ". -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -from common import ( - check_uid, - load_content_uid, - _HERE, - _MAX_DESCRIPTION, - _MAX_NAME, - check_author, - check_boolean, - check_created, - check_scalar, - check_string_list, - is_str, - load_yaml, - load_task_risk, - resolve_slug, - run_validator, -) - -_BASELINES_DIR = _HERE / "baselines" -_TASKS_DIR = _HERE / "tasks" -_MAX_TARGET_TAGS = 32 -_MAX_STEPS = 32 - - -def validate_baseline_text(text: str, tasks_dir: Path | None = None) -> list[str]: - """Validate baseline YAML source. - - Returns a list of error strings; an empty list means the file is valid. - """ - raw, error = load_yaml(text) - if error is not None: - return [error] - if not isinstance(raw, dict): - return ["top-level YAML must be a mapping"] - - if tasks_dir is None: - tasks_dir = _TASKS_DIR - - errors: list[str] = [] - check_scalar(raw, "name", _MAX_NAME, required=True, errors=errors) - check_scalar(raw, "description", _MAX_DESCRIPTION, errors=errors) - check_uid(raw, errors) - check_author(raw, errors) - check_created(raw, errors) - check_string_list(raw, "target_tags", errors, max_len=_MAX_TARGET_TAGS) - check_boolean(raw, "allow_high_risk", errors) - - steps = raw.get("steps") - if not isinstance(steps, list) or not steps: - errors.append("'steps' must be a non-empty list") - elif len(steps) > _MAX_STEPS: - errors.append(f"too many steps (max {_MAX_STEPS})") - else: - seen_orders: dict = {} - for index, step in enumerate(steps): - where = f"step #{index + 1}" - if not isinstance(step, dict): - errors.append(f"{where} must be a mapping") - continue - - check_uid(step, errors, where=f"{where} ") - - task_slug = step.get("task") - if not is_str(task_slug) or not task_slug.strip(): - errors.append(f"{where} missing 'task' slug") - continue - - # A step's uid must agree with the file its slug resolves to. - # Disagreeing is not a preference between two identifiers, it is a - # file claiming to reference something it does not — and Vigil - # trusts the uid, so it would import a different task than the one - # a reviewer read here. - step_uid = (step.get("uid") or "").strip() - if step_uid: - target_for_uid = resolve_slug(tasks_dir, task_slug) - if target_for_uid is not None: - actual = load_content_uid(target_for_uid) - if actual and actual != step_uid: - errors.append( - f"{where}: uid {step_uid!r} does not match " - f"{task_slug.strip()!r}, whose uid is {actual!r}") - - target = resolve_slug(tasks_dir, task_slug) - if target is None: - errors.append( - f"{where}: task slug {task_slug.strip()!r} does not resolve " - f"to a file in tasks/") - elif load_task_risk(target) == "high": - allow = raw.get("allow_high_risk") - if allow is not True: - errors.append( - f"{where}: task {task_slug.strip()!r} is risk: high — " - f"the baseline must set allow_high_risk: true") - - order = step.get("order") - if order is None: - order = index + 1 - elif not isinstance(order, int) or isinstance(order, bool) \ - or order < 1: - errors.append( - f"{where}: 'order' must be a positive integer") - order = None - if order is not None: - if order in seen_orders: - errors.append( - f"{where}: duplicate order {order} (order values must " - f"be unique within a baseline)") - seen_orders[order] = index + 1 - - override = step.get("params_override") - if override is not None: - if not isinstance(override, dict): - errors.append(f"{where}: 'params_override' must be a mapping") - else: - for key, value in override.items(): - if not isinstance(value, dict): - errors.append( - f"{where}: 'params_override' value for " - f"{key!r} must be a mapping") - - return errors - - -def validate_baseline_file(path, tasks_dir: Path | None = None) -> list[str]: - """Validate one baseline file; returns a list of error strings.""" - path = Path(path) - if not path.is_file(): - return [f"cannot read {path}"] - if tasks_dir is None: - tasks_dir = _TASKS_DIR - return validate_baseline_text(path.read_text(encoding="utf-8"), tasks_dir) - - -def main(argv=None) -> int: - return run_validator( - label="Validate Vigil community baseline files.", - description="checks baseline files against the baseline schema", - content_dir=_BASELINES_DIR, - kind="baseline", - validate_file=lambda p: validate_baseline_file(p), - argv=argv, - ) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/validate_task.py b/validate_task.py deleted file mode 100644 index 6e4998b..0000000 --- a/validate_task.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -"""Offline validator for Vigil community task files. - -Usage: - python validate_task.py tasks/foo.yaml [more.yaml ...] - python validate_task.py --all - -Checks each file against the subset of the Vigil task schema that a YAML file -is checked against server-side (see the Vigil server's parse_and_validate), -plus two community-repo-specific rules the server does not enforce: - - * no hardcoded home-directory paths under /home/, /Users/ or C:\\Users\\ — - a community task must work for anyone, so machine-specific paths are - declared as inputs and supplied at deploy time; - * author must be a real name, not a placeholder (Admin, TODO, Unknown). - -Uses only the standard library plus PyYAML. The action reference is loaded -from references/actions.json beside this script, so it can run from any -working directory in a bare checkout. Exits 0 when every file passes, 1 -otherwise; every failure prints one line to stderr as ": ". - -Shared rules (field limits, the placeholder-author rule, the ISO-date rule, -the CLI shell, non-recursive discovery) live in common.py, imported here and -by the baseline and automation validators. -""" - -from __future__ import annotations - -import json -import re -import sys -from pathlib import Path - -from common import ( - check_uid, - _HERE, - _MAX_ACTION_ID, - _MAX_ACTIONS, - _MAX_DESCRIPTION, - _MAX_NAME, - _MAX_RELEVANCE, - _VALID_RISK, - check_author, - check_created, - check_scalar, - is_str, - load_yaml, - run_validator, -) - -_ACTIONS_REF = _HERE / "references" / "actions.json" -_TASKS_DIR = _HERE / "tasks" - -# A path that hardcodes one person's home directory: /home//, -# /Users//, or C:\Users\\ (case-insensitive on the Windows form). -_HOME_PATH = re.compile( - r"/home/[^/\s]+/" - r"|/Users/[^/\s]+/" - r"|(?i:c:[\\/]users[\\/][^\\/\s]+[\\/])" -) - - -def load_actions_reference(path=None) -> dict: - """Load references/actions.json (relative to this script, not the CWD).""" - ref_path = Path(path) if path is not None else _ACTIONS_REF - with open(ref_path, encoding="utf-8") as fh: - payload = json.load(fh) - if not isinstance(payload, dict) or not isinstance(payload.get("actions"), dict): - raise ValueError(f"{ref_path} is not a valid action reference") - return payload["actions"] - - -def _string_values(value, path="params"): - """Yield (dotted_path, string) for every string value under params.""" - if isinstance(value, str): - yield path, value - elif isinstance(value, dict): - for key, item in value.items(): - yield from _string_values(item, f"{path}.{key}") - elif isinstance(value, list): - for index, item in enumerate(value): - yield from _string_values(item, f"{path}[{index}]") - - -def validate_task_text(text: str, actions: dict) -> list[str]: - """Validate task YAML source against the action reference. - - Returns a list of error strings; an empty list means the file is valid. - """ - raw, error = load_yaml(text) - if error is not None: - return [error] - if not isinstance(raw, dict): - return ["top-level YAML must be a mapping"] - - errors: list[str] = [] - check_scalar(raw, "name", _MAX_NAME, required=True, errors=errors) - for field, limit in (("description", _MAX_DESCRIPTION), - ("relevance", _MAX_RELEVANCE)): - check_scalar(raw, field, limit, errors=errors) - check_uid(raw, errors) - check_author(raw, errors) - check_created(raw, errors) - risk = raw.get("risk") - if risk is not None: - if not is_str(risk): - errors.append("'risk' must be a string") - elif risk.strip().lower() not in _VALID_RISK: - errors.append(f"'risk' must be one of: {', '.join(_VALID_RISK)}") - - actions_raw = raw.get("actions") - if not isinstance(actions_raw, list) or not actions_raw: - errors.append("'actions' must be a non-empty list") - elif len(actions_raw) > _MAX_ACTIONS: - errors.append(f"too many actions (max {_MAX_ACTIONS})") - else: - seen_ids: set = set() - for index, entry in enumerate(actions_raw): - if not isinstance(entry, dict): - errors.append(f"action #{index + 1} must be a mapping") - continue - - action_type = entry.get("type") - if not is_str(action_type) or not action_type.strip(): - errors.append(f"action #{index + 1} missing 'type'") - continue - where = f"action #{index + 1} ({action_type})" - if action_type not in actions: - errors.append(f"{where}: unknown type {action_type!r}") - continue - - action_id = entry.get("id") or f"step{index + 1}" - if not is_str(action_id): - errors.append(f"{where}: 'id' must be a string") - action_id = f"step{index + 1}" - if len(action_id) > _MAX_ACTION_ID: - errors.append(f"{where}: 'id' is too long (max {_MAX_ACTION_ID})") - if action_id in seen_ids: - errors.append(f"{where}: duplicate action id {action_id!r}") - seen_ids.add(action_id) - - params = entry.get("params") - if params is None: - params = {} - if not isinstance(params, dict): - errors.append(f"{where}: 'params' must be a mapping") - params = {} - - for required in actions[action_type]["required"]: - if required not in params: - errors.append( - f"{where} missing required param {required!r}") - break - - for where_param, value in _string_values(params): - match = _HOME_PATH.search(value) - if match: - errors.append( - f"{where}: {where_param} hardcodes a home directory " - f"({match.group(0)}...) — declare it as an inputs entry " - f"so the operator supplies the path") - - return errors - - -def validate_task_file(path, actions: dict | None = None) -> list[str]: - """Validate one task file; returns a list of error strings.""" - path = Path(path) - if not path.is_file(): - return [f"cannot read {path}"] - if actions is None: - actions = load_actions_reference() - return validate_task_text(path.read_text(encoding="utf-8"), actions) - - -def main(argv=None) -> int: - return run_validator( - label="Validate Vigil community task files.", - description="checks task files against the task schema", - content_dir=_TASKS_DIR, - kind="task", - validate_file=lambda p: validate_task_file(p), - argv=argv, - ) - - -if __name__ == "__main__": - sys.exit(main())