diff --git a/.github/workflows/validate-framework-registry.yml b/.github/workflows/validate-framework-registry.yml new file mode 100644 index 00000000..08fa8430 --- /dev/null +++ b/.github/workflows/validate-framework-registry.yml @@ -0,0 +1,23 @@ +name: Validate framework registry + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "17 8 * * 1" + workflow_dispatch: + +jobs: + validate-framework-registry: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Validate framework registry shape + run: ruby scripts/validate_framework_registry.rb + + - name: Report stale framework references + run: ruby scripts/validate_framework_registry.rb --stale --max-age-days 365 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a778622..6fda4fe2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,6 +170,7 @@ If your contribution adds or changes framework references, update ```bash ruby scripts/validate_framework_registry.rb +ruby scripts/validate_framework_registry.rb --stale --max-age-days 365 ``` If your contribution changes CI/CD examples, update diff --git a/README.md b/README.md index 62202d74..bea4a1b0 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ Validate framework provenance, versions, owners, and review dates with: ```bash ruby scripts/validate_framework_registry.rb +ruby scripts/validate_framework_registry.rb --stale --max-age-days 365 ``` CI/CD examples for GitHub Actions, GitLab CI, Azure DevOps, Jenkins, diff --git a/docs/framework-reference-registry.md b/docs/framework-reference-registry.md index 399c5737..477c39bd 100644 --- a/docs/framework-reference-registry.md +++ b/docs/framework-reference-registry.md @@ -27,5 +27,14 @@ Validate the registry locally with: ruby scripts/validate_framework_registry.rb ``` -Future stale-framework checks should use `date_reviewed` and `owner` from this -registry instead of scraping individual skill files. +Report references whose `date_reviewed` value is older than the review policy +window with: + +```bash +ruby scripts/validate_framework_registry.rb --stale --max-age-days 365 +``` + +The scheduled `Validate framework registry` workflow runs this stale-reference +report weekly. Owners should refresh stale entries by confirming the source URL, +updating `version` when the upstream framework has changed, and setting a new +`date_reviewed`. diff --git a/scripts/validate_framework_registry.rb b/scripts/validate_framework_registry.rb index 73a7d863..dac0fdf6 100644 --- a/scripts/validate_framework_registry.rb +++ b/scripts/validate_framework_registry.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require "yaml" +require "date" ROOT = File.expand_path("..", __dir__) REGISTRY_PATH = File.join(ROOT, "data", "frameworks.yaml") @@ -9,6 +10,7 @@ REQUIRED_ENTRY_FIELDS = %w[id family name version url date_reviewed owner aliases].freeze REQUIRED_FAMILIES = %w[OWASP NIST MITRE CIS CVSS SSVC EPSS SLSA CycloneDX SPDX].freeze DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/ +DEFAULT_MAX_AGE_DAYS = 365 def rel(path) path.delete_prefix("#{ROOT}#{File::SEPARATOR}") @@ -28,6 +30,56 @@ def validate_string(value, label, errors) errors << "#{label} must be a non-empty string" unless value.is_a?(String) && !value.empty? end +def usage + warn "Usage: ruby scripts/validate_framework_registry.rb [--stale] [--max-age-days DAYS] [--as-of YYYY-MM-DD]" + exit 2 +end + +def parse_date_argument(value) + Date.iso8601(value) +rescue Date::Error + usage +end + +def parse_args(argv) + options = { + stale: false, + max_age_days: DEFAULT_MAX_AGE_DAYS, + as_of: Date.today + } + + until argv.empty? + case argv.shift + when "--stale" + options[:stale] = true + when "--max-age-days" + value = argv.shift + usage unless value&.match?(/\A\d+\z/) + options[:max_age_days] = value.to_i + when "--as-of" + value = argv.shift + usage unless value + options[:as_of] = parse_date_argument(value) + else + usage + end + end + + options +end + +def validate_staleness(entry, prefix, options, errors) + return unless options[:stale] && entry["date_reviewed"].to_s.match?(DATE_PATTERN) + + reviewed = Date.iso8601(entry["date_reviewed"]) + age_days = (options[:as_of] - reviewed).to_i + return if age_days <= options[:max_age_days] + + errors << "#{prefix}: #{entry['id']} reviewed #{age_days} days ago; owner #{entry['owner']} must refresh by #{options[:max_age_days]} days" +end + +options = parse_args(ARGV.dup) + errors = [] registry = load_registry(errors) @@ -81,6 +133,8 @@ def validate_string(value, label, errors) errors << "#{prefix}.date_reviewed must use YYYY-MM-DD" end + validate_staleness(entry, prefix, options, errors) + aliases = entry["aliases"] unless aliases.is_a?(Array) && !aliases.empty? errors << "#{prefix}.aliases must be a non-empty array" @@ -105,7 +159,8 @@ def validate_string(value, label, errors) end if errors.empty? - puts "OK: validated framework registry with #{references.size} reference(s)." + stale_suffix = options[:stale] ? " and no references older than #{options[:max_age_days]} days as of #{options[:as_of]}" : "" + puts "OK: validated framework registry with #{references.size} reference(s)#{stale_suffix}." else puts "FAIL: framework registry validation failed." errors.each { |error| puts " - #{error}" }