diff --git a/.claude/skills/disa-stig-quarterly-update/SKILL.md b/.claude/skills/disa-stig-quarterly-update/SKILL.md new file mode 100644 index 000000000000..79235bd967ba --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/SKILL.md @@ -0,0 +1,211 @@ +--- +name: disa-stig-quarterly-update +description: Assess, implement, and describe a DISA STIG quarterly benchmark update for a product in this repo. Use when a new DISA STIG release drops for a product already covered here, when diffing two xccdf-manual.xml versions with compare_ds.py, when classifying STIG diff changes (prose vs OVAL vs new rule vs removal), or when writing a DISA STIG update PR description. Supports explicit test runs against a pre-update baseline and a separate target checkout. +--- + +# DISA STIG quarterly update + +This skill produces a reviewable update package for each RHEL product in scope. The MVP expects the +user to download the DISA manual XML files. The model runs the comparison tools, retains every +intermediate artifact, delegates rule discovery, mapping, rule creation, variable resolution, +testing, and product builds to the owning skills, implements classified changes, and writes +Markdown PR drafts. A normal run may create a branch and push it; a test run never commits, pushes, +or opens a PR. + +Three phases: **assess** the new release, **implement** the changes, **describe** them in the +PR. Each phase has its own reference doc; read only the one you're on. + +- `reference/01-assess.md` - locate inputs, diff with `compare_ds.py`, build review artifacts +- `reference/02-classify-diffs.md` - prose vs OVAL vs new-rule vs removal vs control-file +- `reference/03-implement.md` - commits, branches, PR, shared-file cross-PR pattern +- `reference/04-pr-description.md` - PR description template and style +- `reference/xccdf-format.md` - XCCDF XML structure, DISA-field-to-CaC mapping +- `reference/architectural-facts.md` - `audit_watches_style`, control-file variables, + `policy/stig` overrides, `file_permissions` thresholds, one-reference-file-per-product build + constraint +- `reference/observations.md` - daemons resetting file modes on reboot, SELinux inotify denials, + Contest ansible/bash asymmetry, container guards + +## MVP result layout + +Keep a separate work package for each product. Do not delete a work package when a later step +fails; partial results are useful for review and debugging. + +```text +/ + rhel8-v2r7-to-v2r8/ + compare_ds/ + stdout.txt + diffs/ + html_diffs/ + csv/ + review.csv + assessment/ + diff_report.md + action_table.md + pr/ + description.md + verification/ + build/ + tests/ + rhel9-v2r8-to-v2r9/ + ... +``` + +The normalized CSV is the primary human-review result for the MVP. It uses these review columns: + +```text +Requirement,HTML diff URL,STDOUT from compare_ds.py,Changes,Action Required,notes,Assignee,Status,Link,Pull request,model-proposed-changes +``` + +## Hard rules + +- **Never infer inputs or scope.** Before starting any phase, ask the user to provide the work + root, product list, pre-update baseline repository, target repository, old and new manual XML + paths for each product, the requested phase, and the run mode. Ask for the HTML review base URL + when assessment artifacts are requested. Do not derive these values from repository files, + existing work folders, branch names, profile versions, or duplicate files. + If any required value is missing, stop and ask for it. +- **Use the explicit baseline for analysis.** During a test run, inspect controls, rules, policies, + remediations, and tests in the supplied pre-update baseline repository. Do not use the target + repository's already-updated files to conclude that a change is unnecessary. Use the target + repository only for the new reference input and the implementation under test. +- **Test runs do not publish changes.** A test run must not create commits, push branches, open PRs, + or modify the baseline repository. It may modify the supplied target repository and the retained + work package. +- **Diffs are copied verbatim, never from memory.** Every diff embedded in a report, analysis, + or comment must be the exact text `compare_ds.py` produced. If a diff looks wrong, re-run + `compare_ds.py`; don't hand-patch or reconstruct it from a prior read. +- **OVAL and Ansible/Bash remediations are the priority; OCIL is not.** Red Hat does not update + OCIL entries. +- **No internal issue IDs in this repo.** Never write an internal tracker ID into a commit, PR, + or comment here - this is a public upstream repository. +- **Symbolic file modes, not octal**, in Ansible `mode:` and Bash `chmod` (`u=rw,g=r,o=r`, not + `0644`). +- **STIG ID -> rule mapping lives only in the control file** for RHEL-family products + (`products//controls/*.yml`). Never add `stigid@:` to `rule.yml` there - + that's for Oracle Linux and SLE. +- **One `*-xccdf-manual.xml` per product** in `shared/references/`. Swap it with `git rm` + + `git add` as the final implementation commit, not two files coexisting. +- **No pending-work sections in PR descriptions.** Describe only what the PR implements. +- **Do not open GitHub PRs automatically.** Push the branches and write `pr/description.md`; + opening the PR is a separate user action. +- **Delegation is mandatory.** For every changed or added STIG requirement, invoke the owning + skill before implementing it: `find-rule` or `map-requirement`/`map-controls` for mapping, + `create-rule` for confirmed new rules, `resolve-rule-variables` for variable-backed values, + `create-test-scenarios` for missing coverage, `test-rule` for rule tests, `build-product` after + relevant content changes, and `run-tests` for validation. Do not reproduce those workflows + locally. +- **Build after relevant content changes.** Invoke `build-product` after changes to `rule.yml`, + OVAL, Bash, Ansible, templates, variables, controls, profiles, or reference files that affect + the product. Use the full product build for final validation; a datastream-only build is + acceptable for intermediate checks. +- **Retain verification results.** Store each build and test command, exit status, output, + warnings, produced artifact list, and summary under the product work package. Never discard + failed results. +- **Build iteratively.** After each related implementation group, invoke `build-product + --datastream-only rhel9` and retain the result before continuing. Run a full `build-product rhel9` + after the implementation groups and again after the final reference/profile update. + +## Assessment artifacts + +The MVP keeps the existing spreadsheet and HTML-review workflow, but makes every input and output +reproducible in the work package. The CSV is the spreadsheet upload. The HTML files are retained +for publication on the review host. Markdown is retained as the detailed local assessment. + +`scripts/build_diff_report.py` turns raw `compare_ds.py --disa-content --rule-diffs` output into +one Markdown report per product, with every raw diff embedded verbatim in a collapsible section: + +```bash +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_diff_report.py \ + \ + --product rhel9 --from-version v2r8 --to-version v2r9 +``` + +Generate the retained HTML and normalized CSV beside it: + +```bash +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_html_diffs.py \ + +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_review_csv.py \ + \ + --html-base-url \ + --review-data +``` + +Before running the CSV builder, create `review-data.json` with one object per changed STIG ID. Each +object must contain non-empty `Changes`, `Action Required`, `notes`, `Status`, and +`model-proposed-changes` fields. The builder fails if an ID or required field is missing, so the +uploaded CSV cannot silently contain blank proposals. Use `No change` when the raw diff does not +require a CaC change, and describe follow-up work explicitly when it is outside the current update. + +Output shape (one section per STIG ID): + +```markdown +## RHEL-09-255120 + +CaC rule: `TODO` +Classification: `TODO` + +
+Diff: RHEL-09-255120 + +```diff +--- RHEL-09-255120 ++++ RHEL-09-255120 +... +``` + +
+ +Action: TODO +``` + +Fill the JSON review data and the report's `CaC rule:`, `Classification:`, and `Action:` by reading +each embedded diff. In `model-proposed-changes`, state the concrete CaC change proposed from the +raw diff and current implementation, or `No change`. This is a model proposal, not human approval. +Never edit the diff text itself; if a diff looks wrong, rerun `compare_ds.py` and regenerate all +derived artifacts. Retain and commit the completed assessment artifacts to the product branch +without waiting for a separate action-table approval. The report, JSON review data, and CSV remain +the record of the model's classification and proposed changes. + +## Delegate to neighboring skills, don't duplicate them + +This skill owns the DISA-specific parts: obtaining and diffing STIG releases, classifying the +changes, and writing the STIG-specific parts of the PR description. For everything else, invoke +the skill that already owns it. + +For each actionable STIG entry: + +1. Invoke `find-rule` or `map-requirement`/`map-controls` to identify the existing rule or mapping. +2. Invoke `resolve-rule-variables` before hardcoding any changed XCCDF value. +3. Invoke `create-rule` only after confirming that no existing rule covers a genuine new rule. +4. Invoke `create-test-scenarios` when the changed or new rule lacks required scenarios. +5. Invoke `test-rule` for changed or new rule behavior. +6. Invoke `build-product` after each relevant content change and retain the result. +7. Invoke `run-tests` after the product build and retain the result. + +A delegated skill may stop for an author decision required by its own workflow. This skill does +not add a separate action-table approval gate. + +- **`find-rule`** / **`map-requirement`** / **`map-controls`** - check whether an existing rule + already covers a STIG ID before treating it as a new rule. +- **`create-rule`** - once a STIG ID is confirmed to need a genuinely new rule. +- **`resolve-rule-variables`** - look up which XCCDF variable a rule exposes and pick a value key, + instead of hardcoding a changed value. +- **`create-test-scenarios`** / **`test-rule`** / **`run-tests`** - test coverage and validation + for a changed or new rule. +- **`build-product`** - mandatory after relevant rule, remediation, template, variable, control, + profile, or reference changes; retain every result. +- **`draft-pr`** - optional follow-up after this skill writes the Markdown PR draft. Do not invoke + it automatically; this MVP pushes branches but does not open GitHub PRs. + +## Testing this skill cheaply + +Don't spend a full quarterly release cycle to sanity-check the pipeline. `test-fixtures/` has a +pair of one-rule benchmark files - built from a real rule lifted out of +`shared/references/disa-stig-rhel9-v2r9-xccdf-manual.xml` - that differ by exactly one character +(a hyphen removed from one rule's title), plus the real, regeneratable `compare_ds.py` output for +that pair. This exercises the assessment pipeline, including the Markdown report and normalized +CSV, in under a second and without touching a real release. HTML generation additionally requires +the external `diff2html` command. See `test-fixtures/README.md`. diff --git a/.claude/skills/disa-stig-quarterly-update/reference/01-assess.md b/.claude/skills/disa-stig-quarterly-update/reference/01-assess.md new file mode 100644 index 000000000000..502d8d1318b2 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/reference/01-assess.md @@ -0,0 +1,127 @@ +# Phase 1: assess a new DISA STIG release + +## 1. Request the assessment inputs + +Before inspecting files, ask the user to provide all of the following explicitly: + +- Work root for the retained product packages. +- Products and old/new release versions in scope. +- Pre-update baseline repository to inspect for the existing implementation. +- Target repository that contains the new reference and will receive implementation changes. +- Exactly one old and one new manual XML path for every product. +- File-server directory URL for the generated HTML links. + +Never infer any of these values from repository references, profile metadata, existing work +folders, branch names, or duplicate files. During a test run, use only the supplied baseline for +implementation analysis and never modify it. If a value is missing, stop and ask the user. + +## 2. Validate the manually downloaded inputs + +The user downloads the DISA manual XML files before invoking this skill. Validate only the paths +the user provided. Do not use the SCAP XML. + +Require exactly one old and one new manual file for every product in scope. If discovery finds +zero or multiple candidates for a provided path, stop and report the candidates instead of +guessing. + +Keep the discovered source paths in the product work package so the comparison can be rerun. + +## 3. Note the current and target versions + +Check the current version in `products//profiles/stig.profile` (`metadata.version`) +against the latest release on https://www.cyber.mil/stigs/downloads. Do this for every product +in scope for the update (e.g. rhel8, rhel9, rhel10 are updated together when their release +windows overlap). + +## 4. Obtain the new STIG files + +The user owns downloading the files. Use the `*-xccdf-manual.xml` file, not +`*-xccdf-scap.xml`: + +- `*-xccdf-manual.xml` - complete requirements with human-readable procedures. Source of truth. +- `*-xccdf-scap.xml` - automated subset DISA ships with OVAL checks, not always published. + Used later for the Contest `disa-alignment` test, not for diffing. + +## 5. Diff the previous and new manual XML with `compare_ds.py` + +```bash +# Create the retained per-product comparison directory before running the tool. +mkdir -p /--to-/compare_ds/diffs +PYTHONPATH=. python3 utils/compare_ds.py \ + --disa-content --rule-diffs \ + --output-dir /--to-/compare_ds/diffs \ + /-xccdf-manual.xml \ + /-xccdf-manual.xml \ + > /--to-/compare_ds/stdout.txt 2>&1 +``` + +Keep the stdout capture - it lists rules that were added or removed outright (`"X was added in +new data stream."` / `"X is missing in new data stream."`), which the per-rule diff files don't +summarize on their own. + +This produces one unified diff file per changed STIG ID, in the `[fieldname]: value` format +described in `reference/xccdf-format.md`. A STIG ID with no behavioral or prose change produces +no diff file at all - `compare_ds.py` only emits a file when something changed. + +## 6. Build the retained review artifacts + +The CSV is the primary review result. Generate it using the normalized columns documented in the +skill. Use a file-server directory URL supplied by the user for the HTML links; do not invent a +user name or upload destination. + +```bash +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_diff_report.py \ + /--to-/compare_ds/diffs \ + /--to-/assessment/diff_report.md \ + --product --from-version --to-version +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_html_diffs.py \ + /--to-/compare_ds/diffs \ + /--to-/html_diffs +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_review_csv.py \ + /--to-/compare_ds/diffs \ + /--to-/compare_ds/stdout.txt \ + /--to-/csv/review.csv \ + --html-base-url \ + --review-data /--to-/assessment/review-data.json +``` + +The report writes one `## STIG-ID` section per changed rule, with the raw diff embedded verbatim +inside a collapsible `
` block. The HTML directory contains one uploadable file per +changed STIG ID. The CSV includes changed and added/removed STIG IDs from both the diff files and +the comparison stdout. + +Create `assessment/review-data.json` with one entry per changed STIG ID. Each entry must contain +non-empty `Changes`, `Action Required`, `notes`, `Status`, and `model-proposed-changes` values. Use +`No change` when the implementation already satisfies the requirement. The CSV builder validates +this file and fails if any STIG ID or required proposal field is missing. Also fill the report's +`CaC rule:`, `Classification:` (see `reference/02-classify-diffs.md`), and `Action:` fields in the +Markdown report. Never edit the diff text itself - if a diff looks wrong, rerun `compare_ds.py` and +regenerate the derived artifacts. + +Retain all artifacts even when assessment or implementation stops. Commit the completed review +package to the product branch without waiting for a separate action-table approval. The report and +CSV remain the record of the model's classification and proposed changes. + +Record the command, exit status, output, and generated file list for each report, HTML, and CSV +command under the product work package. A failed command must produce a retained failure record +before the phase stops. + +## 7. How to assess each changed rule + +For every STIG ID in the report, in priority order: + +1. **Read `[fixtext]` and `[check]` first.** These are the source of truth for what the rule + requires: actual command changes, audit rule format changes, changed values (permissions, + sysctl params, timeouts), new/removed commands. See `reference/02-classify-diffs.md` for how + to tell a real command change from reformatted example output. +2. **Check for a `policy/stig/.yml` override**: + ```bash + find linux_os/guide -path "*//policy/stig/.yml" + ``` + If it exists and mirrors DISA's changed `vuldiscussion`/`fixtext`/`checktext`, update it to + match. If it doesn't exist, a `[description]`-only change needs no action - this project + writes its own description/rationale independently of DISA's wording. +3. **`[title]` changes**: update `title:` only if CaC's title mirrors DISA's wording verbatim. + +If a STIG ID has no existing CaC rule at all, use `find-rule` or `map-requirement` to check +whether an existing rule already covers it before treating it as a new-rule case. diff --git a/.claude/skills/disa-stig-quarterly-update/reference/02-classify-diffs.md b/.claude/skills/disa-stig-quarterly-update/reference/02-classify-diffs.md new file mode 100644 index 000000000000..221d8f239eda --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/reference/02-classify-diffs.md @@ -0,0 +1,97 @@ +# Phase 1: classifying a STIG diff + +Every changed STIG ID gets one classification. Use these labels in the diff report's +`Classification:` field: + +- `prose` - documentation-only (capitalization, punctuation, rephrasing with no meaning change). + No rule update. +- `oval` - the check or fixtext logic changed; the OVAL check and/or remediations need updating. +- `new-rule` - DISA added a requirement with no existing CaC rule covering it. +- `removal` - DISA removed the requirement; drop or mark `status: not applicable` in the + control file. +- `control-file` - the fix is in the control file only (a title correction, an `xccdf_value` + assignment, adding/removing a rule from the selection) with no rule.yml or OVAL change. +- `no-action` - CaC's existing implementation already satisfies the new wording; nothing to do. +- `ocil` - DISA changed manual/OCIL-only text. Red Hat does not maintain OCIL entries; skip. + +## Always needs action (`oval`, `new-rule`, `removal`, or `control-file`) + +- The actual command changed in `[fixtext]` or `[check]` - not example output, the command itself. +- Audit rule format changed: `-w /path -p wa` -> `-a always,exit -F arch=... -F path=... -F perm=...`. +- A sysctl parameter, file path, or permission value changed. +- `[severity]` changed. +- A rule was added (all `+` lines) or removed (all `-` lines) between versions. +- A SELinux filter was added to a check or fixtext (`subj_type=...`). +- A deprecated command was replaced with a newer equivalent (e.g. `awk /etc/passwd` -> + `getent passwd`) - even if the intent is unchanged, the old approach may now miss cases (LDAP/NIS + users, for example) that CaC's remediation should also handle. + +## Skip (`prose` or `no-action`) + +- Capitalization or punctuation only: `"IPsec"` vs `"ipsec"`, a hyphen added or removed, a comma + or trailing period. +- Blank lines added or removed inside `[check]` or `[fixtext]`. +- Example output reformatted (indentation, spacing of command output) with the command itself + unchanged. +- Prose rephrased with no change in meaning. +- Inclusive-language updates: "whitelist" -> "allow list", "Non-privileged" -> "Nonprivileged". +- FIPS 140-2 -> FIPS 140-3 terminology only, unless it changes an actual required value. + +## Read carefully - may or may not need action + +- `[title]` changed: usually prose, but check whether it signals a technical scope change. +- `[description]` changed: usually no action (CaC writes its own rationale); read for + requirement shifts if a `policy/stig/` override exists for that rule. +- `[fixtext]` or `[check]` changed: read the actual command lines, not just the surrounding prose. + +## Distinguishing commands from example output + +`[fixtext]` and `[check]` mix real commands with the output those commands print. A diff on +example output looks identical to a diff on a real command unless you check which is which: + +- Actual commands start with `$` or `#`. +- Example output does not start with `$`/`#`, and follows a command line. + +**No action** - only the `grep` filter and expected output changed, `auditctl -l` itself is the same: +```diff +-$ sudo auditctl -l | grep /etc/cron.d +--w /etc/cron.d -p wa -k cronjobs ++$ sudo auditctl -l | grep crond_t ++-a always,exit -F arch=b64 -S execve -F subj_type=crond_t -F euid=0 -k cron_exec +``` + +**Action needed** - the actual audit rule line changed: +```diff +-[fixtext]: -w /etc/sudoers -p wa -k identity ++[fixtext]: -a always,exit -F arch=b32 -F path=/etc/sudoers -F perm=wa -k identity ++ -a always,exit -F arch=b64 -F path=/etc/sudoers -F perm=wa -k identity +``` + +## Only implement what's in the diff + +Three traps come up repeatedly: + +- Don't add ownership (`chown`) enforcement unless the diff explicitly adds one. "For + completeness" is a reviewer style preference, not a STIG requirement - verify against the raw + diff before acting on it. +- Don't add `stigid@:` to `rule.yml` for RHEL-family products. The STIG ID -> rule + mapping lives in the control file only (`products//controls/*.yml`); `stigid@` in + `rule.yml` is for Oracle Linux and SLE. +- Don't treat an example-output change as a command change (see above). + +## Investigating when there's no obvious mapping + +When a STIG ID has no notes and no obvious existing rule: + +1. Use `find-rule` to search for an existing rule that already implements the requirement text. +2. If nothing matches, use `map-requirement` (single ID) or `map-controls` (batch) for + cross-framework rule suggestions before concluding it's a genuinely new rule. +3. Only fall back to `create-rule` once you've confirmed no existing rule covers it. + +## Implementation flexibility + +There is real wiggle room in how strictly a STIG wording is implemented, particularly for +requirements that resist a strict, checkable definition (a `not applicable` determination doesn't +need to be airtight against DISA's exact phrasing). When a requirement is genuinely ambiguous or +unautomatable, prefer `status: pending` or `status: not applicable` in the control file with a +`notes:` explanation over forcing a rigid check that will false-positive or false-negative. diff --git a/.claude/skills/disa-stig-quarterly-update/reference/03-implement.md b/.claude/skills/disa-stig-quarterly-update/reference/03-implement.md new file mode 100644 index 000000000000..20e5502648ed --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/reference/03-implement.md @@ -0,0 +1,143 @@ +# Phase 2: implement the changes + +## Step 0: select the run mode + +For a normal run, create each product branch from the upstream default branch. For a test run, +do not create or switch branches, commit changes, push, or open a pull request. Modify only the +explicit target repository and retain all results in the work package. + +## Step 1: create the product branch for a normal run + +Create each product branch from the upstream default branch. Refuse to reuse an existing branch or +overwrite unrelated local changes. + +```bash +git fetch origin master +git switch --create disa-stig-rhel8-v2r8 origin/master +``` + +Use `disa-stig-rhel9-v2r9` for the RHEL 9 branch. If either branch already exists, stop and ask +the user whether to continue with an explicitly chosen branch. Do not delete or reset it. + +## Step 2: implement the classified changes + +Read the completed CSV and classification report. Implement `oval`, `new-rule`, `removal`, and +`control-file` actions. Do not implement `prose`, `no-action`, or `ocil` entries unless the user +explicitly requests an exception. Do not wait for a separate action-table approval gate. + +Mandatory delegation applies to rule mapping, variable resolution, rule creation, test creation, +rule testing, product builds, and validation. Invoke the owning skill for each applicable operation +and preserve the assessment and verification artifacts after every step so a failed implementation +can resume. + +## Step 3: update the reference XML files last + +```bash +git rm shared/references/-xccdf-manual.xml +git add shared/references/-xccdf-manual.xml +``` + +Only one `*-xccdf-manual.xml` per product is allowed in `shared/references/` - the build globs +on `disa-stig-${PRODUCT}-v[0-9]*r[0-9]*-xccdf-manual.xml`, and two matching files break it. Keep +the existing reference until the approved rule changes are complete because other automation may +consume it during the update. Make the replacement and profile metadata bump the final +implementation commit. + +If DISA published a new `*-xccdf-scap.xml`, swap that too (needed for the Contest +`disa-alignment` test). If DISA didn't publish one for this release, skip it - the test simply +won't run. + +Also bump the version string in `products//profiles/stig.profile` and +`stig_gui.profile` (`metadata.version`) in that final commit. + +## Step 4: commit in reviewable units for a normal run + +``` +{product}: DISA STIG {version}, {STIG-ID} - {short description} +``` + +Examples: +- `rhel9: DISA STIG v2r9, RHEL-09-255120 - update control file title` +- `rhel9: DISA STIG v2r9, RHEL-09-255130 - remove SSH Compression rule` +- `rhel8: DISA STIG v2r8, RHEL-08-030610 - update audit config file permissions` + +For prose-only changes, use "prose update" as the description: +- `rhel9: DISA STIG v2r9, RHEL-09-671015 - prose update` + +No parentheses, no before/after values in the message unless essential to disambiguate. + +Default to one STIG ID per commit. Group multiple IDs only when one shared implementation makes +the change and splitting it would leave an incomplete or misleading commit. Examples include a +single product-level variable change satisfying several STIG IDs, or one shared rule change used +by RHEL 8 and RHEL 9. Name every affected STIG ID in the commit message when grouping is needed. + +After each commit, run the narrowest relevant verification and push the branch. Never amend a +published commit; fix a problem in a new commit. + +```bash +git push --set-upstream fork disa-stig-rhel9-v2r9 +git push fork disa-stig-rhel9-v2r9 +``` + +## Before hardcoding a changed value, check for an XCCDF variable + +```bash +grep "xccdf_value" linux_os/guide///rule.yml +``` + +If the rule already exposes a variable, the fix is usually one control-file line, not a rule +edit: + +```yaml +- id: RHEL-09-611010 + levels: [medium] + rules: + - accounts_password_pam_pwquality_retry + - var_password_pam_retry=3 +``` + +Use the `resolve-rule-variables` skill to look up which variables a rule depends on and pick the +right value key. + +## Step 5: push the branch and write the PR draft for a normal run + +- Push one branch per product to the user's fork after the first commit and after subsequent + commits. +- Write the completed PR body to the product work package at `pr/description.md`. +- Do not open the GitHub PR automatically in this MVP. The user can review the Markdown and invoke + `draft-pr` separately when ready. + +## Shared files (`linux_os/guide/`) go in one PR only + +A prose fix or logic change in a shared rule under `linux_os/guide/` may satisfy the same STIG +ID across multiple products (e.g. a fix that resolves both a RHEL 8 and a RHEL 9 STIG ID). Make +that change in exactly one PR - land it in whichever product's PR is more advanced or more +directly affected - and reference that PR from the other product's PR description. Never +duplicate the change in both branches; it produces a merge conflict when both land on the base +branch. + +Example pattern: a shared `accounts_password_all_shadowed_sha512` prose fix satisfies both +`RHEL-09-671015` and `RHEL-08-010120`. Commit it once in the RHEL 9 PR; the RHEL 8 PR description +notes "prose fix for RHEL-08-010120 covered by PR #N" and links to it. + +## Verifying a change + +- `build-product` after every relevant rule, remediation, template, variable, control, profile, or + reference change. Use a datastream-only build for intermediate checks and a full product build for + final validation. +- In a test run, invoke `build-product --datastream-only ` after each related implementation + group, retain the result, and stop on a failure until the implementation is corrected. +- `test-rule` for changed or new rule behavior, and `run-tests` for final ctest validation before + pushing. +- Store every build and test command, exit status, output, warnings, artifact list, and summary in + a new directory under the product work package. Never overwrite an earlier result. +- After the final reference and profile update, invoke `build-product` again and run `run-tests` + against that final build before pushing the branch. +- Contest and CI catch anything a local build/test pass misses; if a Contest failure only + reproduces on CentOS Stream and not on RHEL, that doesn't block STIG compliance work - the STIG + applies to RHEL, and a CentOS-only failure with a known cause can be waived separately (file a + GitHub issue and open a Contest PR to waive it there). + +See `reference/observations.md` for caveats that aren't obvious from the diff or the rule source +(daemons resetting file modes on reboot, SELinux inotify denials, container guards, and the +Contest ansible-vs-bash asymmetry). diff --git a/.claude/skills/disa-stig-quarterly-update/reference/04-pr-description.md b/.claude/skills/disa-stig-quarterly-update/reference/04-pr-description.md new file mode 100644 index 000000000000..c8bbc3070c56 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/reference/04-pr-description.md @@ -0,0 +1,76 @@ +# Phase 3: PR description + +The diff report already shows WHAT changed. The PR description explains WHY DISA changed the +requirement and what behavior changes in ComplianceAsCode as a result. Don't restate file paths, +XML swaps, or version bumps - those are visible in the diff. + +## Template + +```markdown +## Description: + +Updates the DISA STIG profile to . + +### RHEL-NN-XXXXXX: short description of change + +Framing sentence stating what the section covers and why DISA changed it. + +- Active-voice bullet: "Removes X from Y", not "X was removed from Y". +- Backtick technical names: paths (`shared/references/`), modes (`0640`), variables + (`inactivity_timeout_value`), commands (`cut -d: -f1,2`). + +## Rationale: + +DISA published STIG for ; this PR brings the ComplianceAsCode content in sync. + +## Review Hints: + +[prose if one point; framing sentence + bullets if multiple] +``` + +## Style rules + +**Headings** +- Top-level sections use `##`: `## Description:`, `## Rationale:`, `## Review Hints:`. +- Each STIG ID gets a `###` heading, colon-separated, sentence case: `### RHEL-09-255130: remove + SSH compression rule`. No em dashes. Acronyms stay uppercase (OVAL, SSH, FIPS). + +**References** +- Link every PR number: `[PR #14987](https://github.com/ComplianceAsCode/content/pull/14987)`. + Never a bare `PR #N`. +- Expand abbreviated STIG ID lists: `040221, 040222` -> `RHEL-08-040221, RHEL-08-040222`. + +**Abbreviations** +- Do not expand domain abbreviations: OVAL, DISA, STIG, XCCDF, OCIL, CCI, CCE, SRG, CaC, PAM, + SELinux, NSS, NIS, LDAP, FIPS, NIST, OL, SLE are plain technical terms to this team - never + write "Defense Information Systems Agency (DISA)". +- General terms a non-team reader may not know (e.g. PR) can be expanded on first use. + +**Formatting** +- Active voice, present tense. +- No "Pending (not included)" section. Omit anything not part of this PR. +- Framing sentence before every bullet list. Single-bullet sections become prose. +- No hard-wrapping - one logical line per paragraph or bullet. +- Rationale section is one prose sentence, no bullets. +- Review Hints: prose if there's one point, framing sentence + bullets if there are several. + +## What to explain per STIG ID + +- Why DISA made the change (policy shift, FIPS version bump, new audit standard, a + vendor-support-window update, etc.) - this is almost never visible from the diff alone. +- What behavior changes as a result (e.g. "removes the group-read bit from SSH host keys"). +- The CaC rule name(s) involved. +- For a shared-file change covered in another PR: name that PR and link it explicitly (see + `reference/03-implement.md`). + +## Do not list + +- File paths or XML file swaps - visible in the diff. +- Version bumps - visible in the diff. +- Line-by-line diff content - that's what the diff report from Phase 1 is for. +- Pending or future work. + +## Private tracker identifiers + +Never include private tracker identifiers or other repository-external metadata in public PR +titles, bodies, commits, or comments. diff --git a/.claude/skills/disa-stig-quarterly-update/reference/architectural-facts.md b/.claude/skills/disa-stig-quarterly-update/reference/architectural-facts.md new file mode 100644 index 000000000000..9a95bb478ab2 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/reference/architectural-facts.md @@ -0,0 +1,79 @@ +# Architectural facts that affect almost every STIG update + +Misreading these leads to wrong or redundant changes. + +## `audit_watches_style` is a product-level variable + +`audit_watches_style` in `products//product.yml` controls whether audit rules generate +in old `-w` format or new `-a always,exit` format, for the entire product. Setting it to `modern` +satisfies every `-w` -> `-a always,exit` format change at once for rules using the +`audit_rules_watch` template - no per-rule edits needed. It's a build-time variable, not a +per-profile one, so check whether flipping it would also affect non-STIG profiles for the +product before changing it. + +## `audit_rules_watch` template has no `auid` filter support + +It cannot add `auid>=1000` or `auid!=unset` filters. If DISA adds those to a rule's audit line, +the rule needs custom OVAL and Bash/Ansible remediations, not just the `audit_watches_style` flip. + +## `file_permissions` template: `allow_stricter_permissions` + +With `allow_stricter_permissions: true`, modes stricter than `filemode` pass. Tightening +`filemode` (e.g. `0640` -> `0600`) removes previously-passing looser modes - this is the intended +effect when DISA tightens a permission requirement, not a regression. + +`file_permissions` thresholds are per-rule, not per-profile. If two profiles need different +thresholds for the same underlying file, that requires separate rule variants (e.g. a `_stig` +suffix), not a single rule with a profile-conditional value. + +## Control files can set XCCDF variables, not just select rules + +```yaml +- id: RHEL-09-611010 + levels: [medium] + rules: + - accounts_password_pam_pwquality_retry + - var_password_pam_retry=3 # sets the XCCDF variable for this profile +``` + +When a STIG changes a value (timeout, permission mode, threshold), check whether the rule +exposes an `xccdf_value()` (`grep xccdf_value rule.yml`) before hardcoding the new value anywhere +- the fix is often one control-file line. + +## STIG IDs live in the control file only, for RHEL-family products + +`products//controls/stig_.yml` is the only place the STIG ID -> rule mapping +exists. Never add `stigid@: RHEL-NN-XXXXXX` to `rule.yml` - that pattern is used only +for Oracle Linux and SLE (there are zero `stigid@rhel*` instances under `linux_os/guide/`). + +## `policy/stig/.yml` rule overrides + +Some rules have `linux_os/guide///policy/stig/.yml`, overriding prose +specifically for the STIG profile: + +```bash +find linux_os/guide -path "*//policy/stig/.yml" +``` + +If it exists and mirrors DISA's changed `vuldiscussion`/`fixtext`/`checktext`, update it. If it +doesn't exist, a `[description]`-only diff needs no action - CaC writes description/rationale +independently of DISA wording. + +## `sysctl` template OVAL checks runtime + any matching `.conf` file + +The static half of the check covers `/etc/sysctl.conf`, `/etc/sysctl.d/*.conf`, and similar glob +paths - any file in those directories with the correct value passes, regardless of filename. +DISA's fixtext may name a specific file (e.g. `99-kernel_randomize_va_space.conf`) while CaC +writes to a different one (e.g. `kernel_randomize_va_space.conf`) in the same directory - that's +a naming difference, not a functional gap, and needs no action. + +## One reference XML file per product + +The build globs `disa-stig-${PRODUCT}-v[0-9]*r[0-9]*-xccdf-manual.xml`. Two matching files break +`gen_stig_table.py`. Always `git rm` the old one in the same commit that adds the new one - see +`reference/03-implement.md`. + +## Shared rules under `linux_os/guide/` cover multiple products + +A change there can satisfy STIG IDs across several products at once. Land it in one PR only and +reference that PR from the other product's PR description - see `reference/03-implement.md`. diff --git a/.claude/skills/disa-stig-quarterly-update/reference/observations.md b/.claude/skills/disa-stig-quarterly-update/reference/observations.md new file mode 100644 index 000000000000..9bc51a2778e0 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/reference/observations.md @@ -0,0 +1,66 @@ +# Caveats that aren't obvious from the diff or the source + +## Remediations can be undone by system daemons on reboot + +Some daemons overwrite files as part of their startup sequence, resetting modes or content a +remediation just set. Canonical example: `augenrules --load` hardcodes `chmod 0640 +${DestinationFile}` whenever it rewrites `/etc/audit/audit.rules`. A remediation that sets `0600` +survives until the next `auditd` restart that touches the file, at which point `augenrules` +resets it to `0640`. + +General pattern: if a daemon or package post-install script overwrites a file on boot or service +restart, a one-shot remediation won't survive a reboot. Check whether the file is owned by a +daemon that runs on startup; if so, a persistent fix is needed - a systemd dropin, a config file +the daemon reads, or a daemon reload that accepts the new value directly. CaC does not patch +files under `/usr/bin/`, `/usr/sbin/`, `/usr/lib/`, or any other package-managed path; a bug in a +system script belongs upstream, and the CaC remediation must work around it without waiting for +that fix. + +## Contest ansible vs bash asymmetry + +Contest runs ansible once, reboots once, then scans. Bash runs once, reboots, runs again, +reboots again, then scans. The second bash pass can produce a passing scan even when the +underlying problem (a daemon rewriting the file) would fail ansible. A rule that passes in bash +Contest but fails in ansible Contest on the same remediation logic usually means the end state is +correct but doesn't survive one boot cycle without a second pass - the fix is a persistent +mechanism, not reapplying the same one-shot command. + +## SELinux and systemd units in remediations + +An `ExecStartPost=` in a systemd dropin runs in the service's own SELinux domain (e.g. `auditd_t` +for `auditd.service`), not `init_t` - a dropin that `chmod`s or `install`s a file in that domain +needs no custom SELinux policy. + +A `systemd.path` unit with `PathChanged=` is different: the watch is set up by `systemd` (as +`init_t`) calling `inotify_add_watch()`. If the watched directory carries a label like +`auditd_etc_t`, SELinux may deny the `watch` permission for `init_t` - often silently, via a +`dontaudit` rule that won't show in the audit log under normal conditions. To expose it: `semodule +-DB` to disable dontaudit rules, reproduce, then check `ausearch -m avc`. A `PathChanged=` +approach that only works with `setenforce 0` will not survive a reboot, since systemd sets up +path watches during boot while SELinux is already enforcing. + +## Container environments + +`auditd` doesn't run inside containers, and `kernel-core` is absent since containers share the +host kernel. Any remediation installing a systemd dropin or reloading `auditd` must be guarded to +skip that step in containers: `rpm -q kernel-core` in Bash, `"kernel-core" in +ansible_facts.packages` in Ansible. Without the guard, `systemctl` fails to reach a host-side +`auditd` from inside the container. A plain file-mode task (`chmod`, or the `file` Ansible module) +still runs fine in containers and needs no guard. + +## Reviewer style requests vs STIG requirements + +Reviewers sometimes ask for more than the STIG diff requires: extra ownership enforcement, +additional validation, stricter defaults, explanatory comments. Treat these as optional unless +the reviewer says they're blocking. Verify any such request against the raw STIG diff before +implementing it - "for completeness" signals a style preference, "the STIG requires" or "this +blocks merge" signals a hard requirement. Implementing unrequested changes without checking can +introduce scope creep and conflict with other PRs touching the same file. + +## CentOS Stream failures and Contest waivers + +Contest also runs against CentOS Stream in addition to RHEL. A failure that reproduces only on +CentOS Stream, with a known root cause unrelated to RHEL, does not block STIG compliance work - +the STIG applies to RHEL, and RHEL results are the deciding signal. File a GitHub issue and open a +Contest PR to waive the affected test on CentOS; CentOS results after a waiver are informational +only. diff --git a/.claude/skills/disa-stig-quarterly-update/reference/xccdf-format.md b/.claude/skills/disa-stig-quarterly-update/reference/xccdf-format.md new file mode 100644 index 000000000000..e6a3d9bee4b7 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/reference/xccdf-format.md @@ -0,0 +1,78 @@ +# DISA XCCDF format and field mapping + +## DISA publishes two files per release + +- `*-xccdf-manual.xml` - complete requirements with human-readable procedures. "Manual" refers + to the checklist documentation format, not manual remediation steps. Source of truth. +- `*-xccdf-scap.xml` - automated subset containing only rules DISA ships with an OVAL check. Not + every manual-file rule has one, and DISA doesn't always publish this file for a given release. + +## XCCDF structure + +```xml + + + + RHEL-09-211010 + RHEL 9 must be a vendor-supported release. + CCI-000366 + <VulnDiscussion>...</VulnDiscussion> + Steps a sysadmin follows to remediate... + Steps an auditor follows to verify compliance... + + + +``` + +- `Group/@id` - DISA's V-ID, unique across all STIGs, can change between releases. +- `Rule/@id` - internal XCCDF rule ID in `SV-r_rule` format. `compare_ds.py --disa-content` + matches old/new rules by the `SV-` portion, since only the release-number suffix changes + between STIG releases. +- `Rule/@severity` - `low`, `medium`, or `high`. +- `` - the STIG Rule ID (e.g. `RHEL-09-211010`). Product-scoped and stable across + releases; this is the join key with the control file. +- `` - one-line rule name. +- `<description>` - contains `<VulnDiscussion>`, explaining why the requirement exists. +- `<ident system="http://cyber.mil/cci">` - CCI, links to NIST 800-53. +- `<fixtext>` - remediation steps for a sysadmin; source of truth for what CaC should automate. +- `<check>` - verification steps for an auditor; source of truth for what the OVAL check verifies. + +## STIG Rule ID format + +`RHEL-{VERSION}-{CATEGORY}{NUMBER}`, e.g. `RHEL-08-010120`: product, RHEL version, category, +number within category. IDs ending in `0` are typically the original entry; `1`/`2`/`3` variants +are usually related rules in the same group. + +## How `compare_ds.py` produces diffs + +`compare_ds.py --disa-content --rule-diffs` parses each `<Rule>` with `join_text_elements` +(`ssg/xml.py`) into an INI-like block, then unified-diffs old vs new per STIG ID: + +``` +[severity] +[version] +[title] +[description] +[ident] (one block per CCI) +[reference] (benchmark metadata: DISA target, DPMS ID) +[fixtext] +[check] +``` + +`<fix>` elements (embedded Ansible/Bash) are skipped - DISA manual XMLs don't contain them. + +## Mapping DISA fields to ComplianceAsCode + +| DISA field | Becomes | +|---|---| +| `[fixtext]` | Ansible/Bash remediation with the same intent, written as automation | +| `[check]` | OVAL check with the same intent, written as machine-executable XML | +| `[title]` | `title:` in `rule.yml` | +| `[description]` | `description:` and `rationale:` in `rule.yml` | +| `[severity]` | `severity:` in `rule.yml` | +| `[ident]` (CCI) | `references:` in `rule.yml` | +| `[version]` (STIG Rule ID) | `id:` in the control file - not written into the rule itself | +| `[reference]` (DPMS metadata) | not used | + +`[fixtext]` and `[check]` are the source of truth. When they change, the OVAL check and +Ansible/Bash remediations must follow. diff --git a/.claude/skills/disa-stig-quarterly-update/scripts/build_diff_report.py b/.claude/skills/disa-stig-quarterly-update/scripts/build_diff_report.py new file mode 100644 index 000000000000..5013e41cfdbe --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/scripts/build_diff_report.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Turn raw `compare_ds.py --disa-content --rule-diffs` output into one markdown report. + +Each file in the diffs directory is one unified diff for one STIG ID. This script embeds +every diff verbatim inside a collapsible <details> block so the whole report is a single +git-trackable artifact - no external file host, no spreadsheet, no diff2html step. + +Usage: + build_diff_report.py <compare_ds_diffs_dir> <output.md> \\ + --product rhel9 --from-version v2r8 --to-version v2r9 + +The output is a skeleton: fill in CaC rule / Classification / Action for each STIG ID by +hand (see reference/02-classify-diffs.md). Never hand-edit the text inside a ```diff fence - +if a diff looks wrong, re-run compare_ds.py and regenerate the report instead. +""" +import argparse +from pathlib import Path + + +def build(diffs_dir: Path, product: str, from_version: str, to_version: str) -> str: + files = sorted(p for p in diffs_dir.iterdir() if p.is_file()) + if not files: + raise SystemExit(f"no diff files found in {diffs_dir}") + + lines = [ + f"# {product} STIG {from_version} -> {to_version} diff report", + "", + f"{len(files)} STIG IDs changed. Generated from `{diffs_dir}` - every diff below is " + "copied verbatim from compare_ds.py output. Do not hand-edit the fenced blocks; " + "re-run this script against fresh compare_ds.py output instead.", + "", + "---", + "", + ] + for path in files: + stig_id = path.name + diff_text = path.read_text().rstrip("\n") + lines += [ + f"## {stig_id}", + "", + "CaC rule: `TODO`", + "Classification: `TODO` <!-- prose | oval | new-rule | removal | control-file | no-action -->", + "", + "<details>", + f"<summary>Diff: {stig_id}</summary>", + "", + "```diff", + diff_text, + "```", + "", + "</details>", + "", + "Action: TODO", + "", + "---", + "", + ] + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("diffs_dir", type=Path, help="directory of per-STIG-ID diff files") + parser.add_argument("output", type=Path, help="markdown file to write") + parser.add_argument("--product", required=True, help="e.g. rhel9") + parser.add_argument("--from-version", dest="from_version", required=True, help="e.g. v2r8") + parser.add_argument("--to-version", dest="to_version", required=True, help="e.g. v2r9") + args = parser.parse_args() + + report = build(args.diffs_dir, args.product, args.from_version, args.to_version) + args.output.write_text(report) + n = len(list(args.diffs_dir.iterdir())) + print(f"wrote {args.output} ({n} STIG IDs)") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/disa-stig-quarterly-update/scripts/build_html_diffs.py b/.claude/skills/disa-stig-quarterly-update/scripts/build_html_diffs.py new file mode 100644 index 000000000000..6cc3c7e5f0ca --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/scripts/build_html_diffs.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Render retained per-STIG unified diffs as HTML with ``diff2html``.""" + +import argparse +import shutil +import subprocess +from pathlib import Path + + +def build(diffs_dir: Path, output_dir: Path) -> int: + """Render every diff file and return the number of generated HTML files.""" + diff2html = shutil.which("diff2html") + if diff2html is None: + raise SystemExit( + "diff2html is not installed; keep the raw diffs and install diff2html before " + "rendering HTML artifacts" + ) + + files = sorted(path for path in diffs_dir.iterdir() if path.is_file()) + if not files: + raise SystemExit(f"no diff files found in {diffs_dir}") + + output_dir.mkdir(parents=True, exist_ok=True) + for diff_file in files: + output_file = output_dir / f"{diff_file.name}.html" + # Use file-input mode so the HTML is rendered directly from the retained raw diff. + subprocess.run( + [ + diff2html, + "-i", + "file", + "-t", + diff_file.name, + "-F", + str(output_file), + "--", + str(diff_file), + ], + check=True, + ) + return len(files) + + +def main() -> None: + """Parse arguments and render the retained HTML diff artifacts.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("diffs_dir", type=Path, help="directory of per-STIG-ID diff files") + parser.add_argument("output_dir", type=Path, help="directory for generated HTML files") + args = parser.parse_args() + count = build(args.diffs_dir, args.output_dir) + print(f"wrote {count} HTML diffs to {args.output_dir}") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/disa-stig-quarterly-update/scripts/build_review_csv.py b/.claude/skills/disa-stig-quarterly-update/scripts/build_review_csv.py new file mode 100644 index 000000000000..a05a58a21246 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/scripts/build_review_csv.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Build the normalized spreadsheet review CSV from comparison artifacts.""" + +import argparse +import csv +import json +import re +from pathlib import Path +from typing import Dict, List, Mapping + + +HEADERS = [ + "Requirement", + "HTML diff URL", + "STDOUT from compare_ds.py", + "Changes", + "Action Required", + "notes", + "Assignee", + "Status", + "Link", + "Pull request", + "model-proposed-changes", +] + +STIG_ID = re.compile(r"\bRHEL-\d{2}-\d{6}\b") +REVIEW_FIELDS = ( + "Changes", + "Action Required", + "notes", + "Status", + "model-proposed-changes", +) + + +def read_rule_messages(stdout_file: Path) -> Dict[str, str]: + """Group every compare message by its RHEL STIG ID.""" + messages: Dict[str, List[str]] = {} + for line in stdout_file.read_text().splitlines(): + rule_id = STIG_ID.search(line) + if rule_id is None: + continue + messages.setdefault(rule_id.group(0), []).append(line.strip()) + return {rule_id: "\n".join(lines) for rule_id, lines in messages.items()} + + +def read_review_data(review_data_file: Path) -> Dict[str, Mapping[str, str]]: + """Read and validate the model's required review fields.""" + data = json.loads(review_data_file.read_text()) + if not isinstance(data, dict): + raise ValueError("review data must be a JSON object keyed by STIG ID") + + review_data: Dict[str, Mapping[str, str]] = {} + for rule_id, values in data.items(): + if STIG_ID.fullmatch(rule_id) is None or not isinstance(values, dict): + raise ValueError(f"invalid review data entry for {rule_id!r}") + missing = [field for field in REVIEW_FIELDS if not values.get(field)] + if missing: + raise ValueError(f"{rule_id} is missing required fields: {', '.join(missing)}") + review_data[rule_id] = values + return review_data + + +def build( + diffs_dir: Path, + stdout_file: Path, + output_file: Path, + html_base_url: str, + review_data_file: Path, +) -> int: + """Write one review row for every changed, added, or removed STIG ID.""" + diff_files = sorted( + path for path in diffs_dir.iterdir() if path.is_file() and STIG_ID.fullmatch(path.name) + ) + messages = read_rule_messages(stdout_file) + review_data = read_review_data(review_data_file) + rule_ids = sorted(set(messages) | {path.name for path in diff_files}) + if not rule_ids: + raise SystemExit("no changed STIG IDs found in diff files or comparison stdout") + missing_review_data = sorted(set(rule_ids) - set(review_data)) + if missing_review_data: + raise ValueError("review data is missing STIG IDs: " + ", ".join(missing_review_data)) + + diff_ids = {path.name for path in diff_files} + base_url = html_base_url.rstrip("/") + output_file.parent.mkdir(parents=True, exist_ok=True) + with output_file.open("w", newline="") as stream: + writer = csv.writer(stream) + writer.writerow(HEADERS) + for rule_id in rule_ids: + html_url = f"{base_url}/{rule_id}.html" if base_url else "" + writer.writerow( + [ + rule_id, + html_url if rule_id in diff_ids else "", + messages.get(rule_id, ""), + review_data[rule_id]["Changes"], + review_data[rule_id]["Action Required"], + review_data[rule_id]["notes"], + "", + review_data[rule_id]["Status"], + "", + "", + review_data[rule_id]["model-proposed-changes"], + ] + ) + return len(rule_ids) + + +def main() -> None: + """Parse arguments and write the normalized review CSV.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("diffs_dir", type=Path, help="directory of per-STIG-ID diff files") + parser.add_argument("stdout_file", type=Path, help="captured compare_ds.py stdout") + parser.add_argument("output", type=Path, help="review CSV to write") + parser.add_argument( + "--html-base-url", + default="", + help="directory URL used to build HTML links, without a trailing slash", + ) + parser.add_argument( + "--review-data", + type=Path, + required=True, + help="JSON file containing required per-STIG review fields", + ) + args = parser.parse_args() + count = build( + args.diffs_dir, + args.stdout_file, + args.output, + args.html_base_url, + args.review_data, + ) + print(f"wrote {args.output} ({count} STIG IDs)") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/disa-stig-quarterly-update/test-fixtures/README.md b/.claude/skills/disa-stig-quarterly-update/test-fixtures/README.md new file mode 100644 index 000000000000..718e6c4b104e --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/test-fixtures/README.md @@ -0,0 +1,59 @@ +# Test fixtures + +A cheap way to exercise the whole assess pipeline - `compare_ds.py` through +`build_diff_report.py` - without downloading a real quarterly release or diffing a +multi-thousand-rule benchmark. + +## What's here + +- `disa-stig-rhel9-test-v1-xccdf-manual.xml` / `-v2-xccdf-manual.xml` - two tiny, one-rule + XCCDF benchmarks. The rule (`RHEL-09-211010`) is lifted verbatim from the real + `shared/references/disa-stig-rhel9-v2r9-xccdf-manual.xml`. `v2` differs from `v1` by exactly + one character: the title's `vendor-supported` loses its hyphen, becoming `vendor supported` - + a punctuation-only change, i.e. a `no-action` classification per + `reference/02-classify-diffs.md`. +- `compare_ds_diffs_sample/RHEL-09-211010` - the real, unmodified output of running + `utils/compare_ds.py --disa-content --rule-diffs` against the two files above. Not + hand-authored - regenerate it any time with the command below to confirm it still matches. + +## Reproduce it + +From the repo root: + +```bash +PYTHONPATH=. python3 utils/compare_ds.py --disa-content --rule-diffs \ + --output-dir .claude/skills/disa-stig-quarterly-update/test-fixtures/compare_ds_diffs_sample \ + .claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v1-xccdf-manual.xml \ + .claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v2-xccdf-manual.xml \ + > /tmp/compare_ds_stdout.txt 2>&1 +``` + +Expect exactly one output file, `RHEL-09-211010`, with a one-line diff in `[title]`. + +## Exercise the report builder + +```bash +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_diff_report.py \ + .claude/skills/disa-stig-quarterly-update/test-fixtures/compare_ds_diffs_sample \ + /tmp/test-diff-report.md \ + --product rhel9-test --from-version v1 --to-version v2 +``` + +This is the whole Phase 1 comparison and report pipeline in about a second, with no need for a +real STIG release, a full-size XML parse, or network access. Build the normalized review CSV from +the same fixture artifacts: + +```bash +python3 .claude/skills/disa-stig-quarterly-update/scripts/build_review_csv.py \ + .claude/skills/disa-stig-quarterly-update/test-fixtures/compare_ds_diffs_sample \ + /tmp/compare_ds_stdout.txt \ + /tmp/test-review.csv \ + --html-base-url https://review.example.invalid/stig \ + --review-data .claude/skills/disa-stig-quarterly-update/test-fixtures/review-data.json +``` + +Add a second rule with a real (not punctuation-only) change to the fixture pair if you need to +test the `oval`/`new-rule`/`removal` classification paths as cheaply. + +These fixtures are test-only: they never touch `shared/references/`, aren't wired into any +product build, and don't affect real STIG IDs. diff --git a/.claude/skills/disa-stig-quarterly-update/test-fixtures/compare_ds_diffs_sample/RHEL-09-211010 b/.claude/skills/disa-stig-quarterly-update/test-fixtures/compare_ds_diffs_sample/RHEL-09-211010 new file mode 100644 index 000000000000..cbdcd01883b7 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/test-fixtures/compare_ds_diffs_sample/RHEL-09-211010 @@ -0,0 +1,33 @@ +--- RHEL-09-211010 ++++ RHEL-09-211010 +@@ -1,29 +1,29 @@ + [severity]: + high + + [version]: + RHEL-09-211010 + + [title]: +-RHEL 9 must be a vendor-supported release. ++RHEL 9 must be a vendor supported release. + + [description]: + <VulnDiscussion>An operating system release is considered "supported" if the vendor continues to provide security patches for the product. With an unsupported release, it will not be possible to resolve security issues discovered in the system software.</VulnDiscussion><FalsePositives></FalsePositives><FalseNegatives></FalseNegatives><Documentable>false</Documentable><Mitigations></Mitigations><SeverityOverrideGuidance></SeverityOverrideGuidance><PotentialImpacts></PotentialImpacts><ThirdPartyTools></ThirdPartyTools><MitigationControl></MitigationControl><Responsibility></Responsibility><IAControls></IAControls> + + [reference]: + DPMS Target Red Hat Enterprise Linux 9DISADPMS TargetRed Hat Enterprise Linux 95551 + + [ident]: + CCI-000366 + + [fixtext]: + Upgrade to a supported version of RHEL 9. + + [check]: + Verify the version or RHEL 9 is vendor supported with the following command: + + $ cat /etc/redhat-release + + Red Hat Enterprise Linux release 9.6 (Plow) + + If the installed version of RHEL 9 is not supported, this is a finding. diff --git a/.claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v1-xccdf-manual.xml b/.claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v1-xccdf-manual.xml new file mode 100644 index 000000000000..f928956128c3 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v1-xccdf-manual.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?><Benchmark xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" id="RHEL_9_STIG" xml:lang="en" xmlns="http://checklists.nist.gov/xccdf/1.1"><status date="2026-05-20">accepted</status><title>Red Hat Enterprise Linux 9 Security Technical Implementation Guide (test fixture, v1)1SRG-OS-000480-GPOS-00227<GroupDescription></GroupDescription>RHEL-09-211010RHEL 9 must be a vendor-supported release.<VulnDiscussion>An operating system release is considered "supported" if the vendor continues to provide security patches for the product. With an unsupported release, it will not be possible to resolve security issues discovered in the system software.</VulnDiscussion><FalsePositives></FalsePositives><FalseNegatives></FalseNegatives><Documentable>false</Documentable><Mitigations></Mitigations><SeverityOverrideGuidance></SeverityOverrideGuidance><PotentialImpacts></PotentialImpacts><ThirdPartyTools></ThirdPartyTools><MitigationControl></MitigationControl><Responsibility></Responsibility><IAControls></IAControls>DPMS Target Red Hat Enterprise Linux 9DISADPMS TargetRed Hat Enterprise Linux 95551CCI-000366Upgrade to a supported version of RHEL 9.Verify the version or RHEL 9 is vendor supported with the following command: + +$ cat /etc/redhat-release + +Red Hat Enterprise Linux release 9.6 (Plow) + +If the installed version of RHEL 9 is not supported, this is a finding. diff --git a/.claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v2-xccdf-manual.xml b/.claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v2-xccdf-manual.xml new file mode 100644 index 000000000000..64236401a203 --- /dev/null +++ b/.claude/skills/disa-stig-quarterly-update/test-fixtures/disa-stig-rhel9-test-v2-xccdf-manual.xml @@ -0,0 +1,7 @@ +acceptedRed Hat Enterprise Linux 9 Security Technical Implementation Guide (test fixture, v2)2SRG-OS-000480-GPOS-00227<GroupDescription></GroupDescription>RHEL-09-211010RHEL 9 must be a vendor supported release.<VulnDiscussion>An operating system release is considered "supported" if the vendor continues to provide security patches for the product. With an unsupported release, it will not be possible to resolve security issues discovered in the system software.</VulnDiscussion><FalsePositives></FalsePositives><FalseNegatives></FalseNegatives><Documentable>false</Documentable><Mitigations></Mitigations><SeverityOverrideGuidance></SeverityOverrideGuidance><PotentialImpacts></PotentialImpacts><ThirdPartyTools></ThirdPartyTools><MitigationControl></MitigationControl><Responsibility></Responsibility><IAControls></IAControls>DPMS Target Red Hat Enterprise Linux 9DISADPMS TargetRed Hat Enterprise Linux 95551CCI-000366Upgrade to a supported version of RHEL 9.Verify the version or RHEL 9 is vendor supported with the following command: + +$ cat /etc/redhat-release + +Red Hat Enterprise Linux release 9.6 (Plow) + +If the installed version of RHEL 9 is not supported, this is a finding.