# The exact component-and-version inventory the boundary's repositories build from — every package the dependency graph resolved, carrying the version string that is the only thing SA-22's question can be asked about — together with whether that graph is switched on across the boundary at all, and then the open advisories for which the ecosystem offers no patched version, which is the closest thing a pipeline emits to a component nobody maintains any more. The inventory is the load-bearing half and it is why this recipe exists separately from the vulnerability one: "is this component past end of support" is a question about a name and a version, and a check that cannot produce the version has not asked it.

> FedRAMP Consolidated Rules for 2026 v2026.07.14.01 · updated 2026-07-14
> Canonical page: /collect/unsupported-component-inventory-and-lifecycle-review

Recipe id: `unsupported-component-inventory-and-lifecycle-review` · cadence monthly · partial

> **Authored opinion.** pipeline overlay v0.8.0, written
> against dataset 2026.07.14.01. The upstream
> FedRAMP rules name none of these tools; this mapping is ours.

## What it proves

- KSI `KSI-SCR-MIT`
- control `sa-22`

## Collection

Kind: `api`

```sh
# configurations
gh api --paginate "/orgs/<ORG>/code-security/configurations"
# configuration-repositories
gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?status=attached,enforced&per_page=100"
# configuration-repositories-all
gh api --paginate "/orgs/<ORG>/code-security/configurations/<CONFIGURATION_ID>/repositories?per_page=100"
# sbom
gh api "/repos/<ORG>/<REPO>/dependency-graph/sbom"
# alerts-open
gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=open&per_page=100"
# alerts-dismissed
gh api --paginate "/orgs/<ORG>/dependabot/alerts?state=dismissed&per_page=100"
```

## Expected output

Report names used below, and the command each comes from: `configurations` is the first, `configuration-repositories` the second, `configuration-repositories-all` the third, `sbom` the fourth, `alerts-open` the fifth and `alerts-dismissed` the sixth. The second and third call the SAME endpoint and the difference between them is the point rather than a duplication: the second narrows `status` to `attached,enforced` so the enumeration holds only repositories the configuration actually reached, and the third leaves `status` at its documented default of `all` so the `failed` rows are visible at all. A recipe carrying only the narrowed call cannot fail a clause about `failed`, because the row it is looking for was filtered out before the report was written.

RUN COMMAND 4 ONCE PER REPOSITORY. The SBOM export is documented at `GET /repos/{owner}/{repo}/dependency-graph/sbom` and has no organization-scoped form, so the component inventory is assembled one repository at a time from the list command 2 returns; the two alert commands are organization-scoped and each alert carries its owning `repository`, so the advisory half arrives in one walk.

From `configurations`, the fields this recipe reads and their documented defaults, which are not the same for the two settings it depends on: `dependency_graph` is `enabled | disabled | not_set` and defaults to `enabled`, while `dependabot_alerts` is the same enum and defaults to `disabled`. `target_type` is `global | organization | enterprise` and `enforcement` is `enforced | unenforced`. From `configuration-repositories`, the association `status`, whose documented values are `all, attached, attaching, detached, removed, enforced, failed, updating, removed_by_enterprise` and whose documented DEFAULT is `all` — the second command narrows it to `attached,enforced` so the enumeration holds only repositories the configuration actually reached, and the third leaves it unnarrowed so the `failed` rows a clause of its own reads below are present in a report at all.

From `sbom`, an SPDX document under a required `sbom` key carrying `SPDXID`, `spdxVersion`, `creationInfo`, `name`, `dataLicense`, `documentNamespace`, a required `packages` array and a `relationships` array. Each package entry is documented with `SPDXID`, `name`, `versionInfo`, `downloadLocation`, `filesAnalyzed`, `licenseConcluded`, `licenseDeclared`, `supplier`, `copyrightText` and `externalRefs`, the last an array of `{referenceCategory, referenceLocator, referenceType}` — the purl that identifies the component precisely enough to look its support dates up. `versionInfo` is the field this recipe turns on, and the documented behaviour behind it is the opposite of the one an earlier draft of this recipe assumed. It is REQUIRED on every package entry — the schema's required list is `SPDXID, name, versionInfo, downloadLocation, filesAnalyzed` — so a component never arrives without the field, and a clause counting the entries that lack one could never fire. What the page documents instead is the real failing case: "The version of the package. If the package does not have an exact version specified, a version range is given." An unpinned component is present, carries the field, and carries a RANGE, which is the shape the clause below enumerates.

From `alerts-open` and `alerts-dismissed`, `dependency` (`package.ecosystem`, `package.name`, `manifest_path`, `scope`, `relationship`), `state` (`open | dismissed | fixed | auto_dismissed`), `dismissed_reason` (`fix_started | inaccurate | no_bandwidth | not_used | tolerable_risk`), `dismissed_comment` (documented required, string or null, capped at 280 characters), `auto_dismissed_at`, and `security_advisory` with `ghsa_id`, `cve_id`, `severity`, `classification` (`general | malware`), `cwes`, `published_at`, `updated_at`, `withdrawn_at` and a `vulnerabilities` array whose entries carry `vulnerable_version_range` and a `first_patched_version` documented "object or null". Beside `security_advisory`, and not to be confused with it, each alert carries a required `security_vulnerability` object of its own — `package`, `severity`, `vulnerable_version_range` and its own `first_patched_version`, same "object or null" — and THAT is the entry pertaining to the alerted component, where `security_advisory.vulnerabilities[]` enumerates every affected range across every ecosystem the advisory covers. The no-patch clause below reads the former for that reason. The `has` filter is documented as supporting only `patch` on the REPOSITORY form and is left unenumerated on the organization form this recipe calls, so on neither form is there a documented way to select the alerts that lack a fix; the no-patch narrowing is therefore a client-side filter over a full paginated walk, and a collector that reads one page of thirty has produced a smaller answer rather than a filtered one.

## Assertions

- {"field":"configurations[?target_type=='organization' && dependency_graph=='enabled'] | [0].id","op":"exists","controls":["sa-22"],"description":"At least one code security configuration the organization itself owns sets `dependency_graph` to `enabled`. Every other clause in this recipe is an offender count, and an organization that has never created a configuration returns an empty list for all of them: no configuration is enumerated, no repository is walked, no SBOM is exported, no alert is raised, and six `count_eq 0` clauses go green on a boundary where nothing was ever switched on. This is the positive clause that makes that state fail, and it is the same guard the three sibling recipes reading this report already carry — its absence here was the defect, not its presence there. The field is three-state and the middle value is the trap: `not_set` is neither on nor off, and a clause written against `disabled` passes an organization that never decided. It says a configuration exists and decided; it does not say which repositories it reaches, and repository-level enablement bypasses configurations entirely."}
- {"field":"configurations[?target_type=='organization' && dependency_graph!='enabled']","op":"count_eq","value":0,"controls":["sa-22"],"description":"No configuration the organization ITSELF OWNS leaves the dependency graph unenabled. The `target_type` narrowing is deliberate and matches the sibling code-scanning recipe: a provider cannot edit a global or enterprise-owned configuration, so failing it for one would be failing it for a decision it has no permission to make. This is the negative half of the clause above and is not the precondition on its own: an offender count over an empty configuration list is green, which is exactly why the existence clause precedes it. What it adds is the case where a configuration DOES exist and leaves the graph off — there are then no packages to export, `sbom` is empty rather than wrong, and every clause below would be vacuously green."}
- {"field":"configurations[?target_type=='organization' && dependabot_alerts!='enabled']","op":"count_eq","value":0,"controls":["sa-22"],"description":"No organization-owned configuration leaves Dependabot alerts unenabled. Asserted separately from the graph clause above because the two settings have DIFFERENT documented defaults — `dependency_graph` defaults to `enabled` and `dependabot_alerts` to `disabled` — so the common state is a repository whose components are resolved and whose advisories are never raised. That repository appears complete in the inventory half of this recipe and contributes nothing to the alert half, which is the quietest way this evidence can be half-true."}
- {"field":"configuration-repositories-all[?status=='failed']","op":"count_eq","value":0,"controls":["sa-22"],"description":"No repository the configuration was applied to failed to receive it. `failed` is the status that produces silence rather than an error: the repository is attached in intent, resolves no dependency graph, exports no packages and raises no alerts, so the boundary's inventory is short by exactly that repository's components and nothing in the totals says so. It reads `configuration-repositories-all` and NOT the enumeration report, and that is a correction rather than a detail — the enumeration command narrows `status` to `attached,enforced`, which filters out the very row this clause exists to count, and a clause pointed at it would be unfailable by construction. The documented default for `status` is `all`, which is what the third command relies on."}
- {"field":"sbom.sbom.packages[?contains(versionInfo,'>') || contains(versionInfo,'<') || contains(versionInfo,'^') || contains(versionInfo,'~') || contains(versionInfo,'*') || contains(versionInfo,',') || contains(versionInfo,' ')]","op":"count_eq","value":0,"controls":["sa-22"],"description":"No component in this repository's exported inventory arrives with a version RANGE instead of a pinned version. This is the clause SA-22 turns on and it took two corrections to write. The first is the path, and it is the one that mattered most: the root token is the REPORT name and the response's own top-level key is `sbom` again, because the SPDX document arrives nested under a required `sbom` key, so the clause reads `sbom.sbom.packages`. Written at `sbom.packages` it resolved to nothing, and an offender count over nothing is zero, which is green — the recipe's own centre-of-gravity clause was unfailable on every estate. The second is the field: an earlier form counted packages whose `versionInfo` was null, which could never fire, because GitHub documents `versionInfo` in the package object's required list. The documented failing case is the other one — \"If the package does not have an exact version specified, a version range is given\" — so the unpinned component is PRESENT, carries the field, and carries a range, and a null-counting clause read it as green. A range cannot be looked up against a vendor's lifecycle policy, which is the whole question SA-22 asks, so it is the failing row. State plainly what this form costs: the assertion grammar has no \"is not an exact version\" operator, so the clause enumerates the characters a range spelling uses and a range spelled some other way passes. That residue is a property of the grammar and is named here rather than hidden, which is the difference between a heuristic and an over-claim."}
- {"field":"alerts-open[?security_vulnerability.first_patched_version==`null`]","op":"count_eq","value":0,"controls":["sa-22"],"description":"No open alert names a component for which the ecosystem has published no fixed version. The alert carries its OWN required `security_vulnerability` object — `package`, `severity`, `vulnerable_version_range` and a `first_patched_version` documented \"object or null\" — and that object is the entry pertaining to the alerted component. An earlier form of this clause read `security_advisory.vulnerabilities[]` instead and counted an alert whose ANY entry lacked a patch: that array enumerates every affected range across every ecosystem the advisory covers, so an advisory patched for this component and unpatched for some other one fired it. The clause over-fired rather than under-fired, which is the safe direction and is why it is listed third of three corrections rather than first, but it was not asking the question the description claimed. Offender form over a list built to hold offenders, and evaluated client-side: the `has` filter is documented as supporting only `patch` on the repository form and is left unenumerated on the organization form this recipe calls, so there is no documented way to select the alerts WITHOUT a fix and the narrowing needs the full paginated walk the command performs. Read the result as a hint that a lifecycle review is owed on those packages, never as the finding that they are past end of support; `scan_scope` names the list that decides that and it is not in this output."}
- {"field":"alerts-dismissed[?(dismissed_reason=='no_bandwidth' || dismissed_reason=='tolerable_risk') && dismissed_comment==`null`]","op":"count_eq","value":0,"controls":["sa-22"],"description":"No alert was dismissed for lack of capacity or as tolerable risk without a written justification. These are the two documented reasons that mean the component STAYS, so this clause marks the population that was knowingly not replaced — which is the set SA-22's second limb then has to answer for. `dismissed_comment` is documented required, string or null, capped at 280 characters, so the literal-null comparison catches both an explicit null and an absent key and the failing row is present-and-false rather than dropped. What this establishes is narrow and the note says so: a typed comment records that somebody chose to keep the component, and it is evidence of neither replacement nor of an alternative support source having been arranged, which is what the control actually asks for."}

## Outside the boundary

Platform: GitHub Enterprise Cloud
The dependency resolver, the component inventory it produces and the advisory database the alerts are drawn from are all held and operated by a third party, so reading them brings the platform into the assessment as an information resource under SA-09 and CA-03 and into the external-system inventory AC-20 covers. SA-22 makes that dependency unusually pointed, because the control is ABOUT depending on components somebody else does or does not still maintain, and this evidence answers it by depending on a component somebody else maintains: what counts as a known weakness in a package is decided by an advisory database the provider does not write or version, and a re-review or a withdrawal there changes what this output says without anything in the provider's system changing. SA-22 sits in the class b, class c and class d baselines, so it reaches every reader of this product including Low systems, and the impact level a platform's Marketplace listing carries is not necessarily the one a given system needs — that is the SA-09 question, it is answered by reading the cited listing rather than by this sentence, and no value for it is written here because it is a dated fact that changes. This recipe was authored against GitHub Enterprise Cloud; github.com and the data-residency offering are different deployments on different infrastructure.

## Notes

SA-22 asks two things: that unsupported components are replaced, and that alternative sources of continued support are provided for the ones that are not — in-house support, or an external provider the organization names. This plane can see the population and cannot see either answer, which is what `partial` records. An earlier draft of this note said the second limb was documented justification and approval; that is the shape of legacy baseline supplemental guidance rather than of the control, this repo's own dataset carries no guidance for SA-22 at all, and the difference matters here because the platform DOES hold a justification artifact and holds nothing whatsoever about an alternative support arrangement. Getting that wrong would have made the dismissal clause below look like it closed a limb it does not touch.

WHY THIS IS A DIFFERENT RECIPE FROM THE DEPENDENCY-VULNERABILITY ONE AND NOT A SECOND VIEW OF IT. `dependency-vulnerability-monitoring` asks whether known-vulnerable components are found and fixed; this asks whether the provider knows what it ships and whether any of it has been abandoned. The two questions overlap in their telemetry and diverge in their failure cases, and the divergence is the point: a component with no advisories at all is clean by the first question and is precisely the profile of an unmaintained package by the second, because an advisory exists when somebody researches a package and nobody researches a dead one. That is why the inventory clause rather than the alert clauses is the centre of this recipe.

THE NO-PATCH CLAUSE IS A HINT AND IS DELIBERATELY NOT WRITTEN AS A VERDICT. An open alert whose advisory carries no `first_patched_version` on any entry is the strongest end-of-support signal the documented API emits — nobody shipped a fix — and it is still not the control's finding. It is equally the shape of an advisory published hours ago, of a dispute upstream, and of a maintainer who fixed the issue without cutting a release. It is asserted because a growing set of them is a real and readable finding about the boundary, and it is described here as a hint because reading it as "these components are past end of support" would be authoring the reconciliation `scan_scope` says nobody has done.

WHAT THE DISMISSAL CLAUSE DOES AND DOES NOT ESTABLISH. `dismissed_reason` of `no_bandwidth` or `tolerable_risk` on an alert nobody can patch is the platform's record that a component was knowingly KEPT, and that is its whole value: it enumerates the components SA-22's second limb is about, which is a real service and is not the limb itself. Nothing in this output says whether in-house support was arranged for any of them or whether an external provider was named, and no field on this endpoint could. The clause is worth asserting because a dismissal carrying an empty comment is unambiguously worse than one carrying a reason, and it is worth saying plainly that passing it establishes neither replacement nor alternative support.

THE `withdrawn_at` FIELD IS COLLECTED AND NOT ASSERTED, AND THE REASON IS THE SAME ONE THAT KEEPS THE EXTERNAL-SYSTEM NOTE SHARP. An advisory can be withdrawn after this evidence was collected, which retroactively changes what a dated report meant. Nothing in the provider's system moved. It is read so a human comparing two months of this report can tell a fixed component from a withdrawn advisory, and it is not asserted because a recipe that failed on withdrawn advisories would be failing the provider for the database's corrections.

CADENCE IS `monthly` RATHER THAN `continuous`, AND THAT IS A CLAIM ABOUT THE HUMAN STEP RATHER THAN ABOUT THE PLATFORM. The alert stream underneath is continuous and the sibling recipe on SR-06 collects it that way. What SA-22 asks for is a review of an inventory against published lifecycle dates, and that reconciliation is an act somebody performs on a schedule; exporting the SBOM is the same kind of act. A provider running it continuously has not done anything wrong, and one collecting the alerts continuously while never exporting the inventory has met the sibling recipe and not this one.

KSI-SCR-MIT is the only indicator that reaches this control, and it is claimed for the identification limb of its own statement — persistently identify, review and mitigate potential supply chain risks — with the review and mitigation limbs left to the human step `scan_scope` names.

## References

- {"title":"GitHub REST: export a software bill of materials for a repository — GET /repos/{owner}/{repo}/dependency-graph/sbom, returning an SPDX document under a required `sbom` key with required `SPDXID`, `spdxVersion`, `creationInfo`, `name`, `dataLicense`, `documentNamespace` and `packages`; each package documented with `name`, `versionInfo`, `downloadLocation`, `filesAnalyzed`, `licenseConcluded`, `licenseDeclared`, `supplier`, `copyrightText` and `externalRefs` entries of `{referenceCategory, referenceLocator, referenceType}`. Read access to the repository is what the endpoint requires, and there is no organization-scoped form","url":"https://docs.github.com/en/rest/dependency-graph/sboms"}
- {"title":"GitHub REST: Dependabot alerts — GET /orgs/{org}/dependabot/alerts and the repository form; `state` open | dismissed | fixed | auto_dismissed, `dismissed_reason` fix_started | inaccurate | no_bandwidth | not_used | tolerable_risk, `dismissed_comment` documented required, string or null, max 280 characters, and `security_advisory.vulnerabilities[]` carrying `vulnerable_version_range` and a `first_patched_version` documented \"object or null\". The `has` filter is documented as supporting only `patch` on the repository form and is left unenumerated on the organization form this recipe calls, which is why the no-patch narrowing is client-side on either. Organization owner or security manager, `security_events` scope","url":"https://docs.github.com/en/rest/dependabot/alerts"}
- {"title":"GitHub REST: code security configurations — `dependency_graph` enabled | disabled | not_set defaulting to ENABLED and `dependabot_alerts` the same enum defaulting to DISABLED, the asymmetry two clauses in this recipe are written around; `target_type` global | organization | enterprise, `enforcement` enforced | unenforced, and the repositories endpoint whose `status` accepts all | attached | attaching | detached | removed | enforced | failed | updating | removed_by_enterprise and defaults to `all`","url":"https://docs.github.com/en/rest/code-security/configurations"}
- {"title":"GitHub: dependency graph supported package ecosystems — the per-ecosystem manifest and lock files the graph recognises, and its own statement that the recommended files \"explicitly define which versions are used for all direct and all indirect dependencies\". Read for what it does NOT recommend: Gradle carries no recommended file unless dependencies arrive through automatic submission, and a project resolving from an unpinned manifest is the case where an indirect component reaches the SBOM as a version range rather than a pinned version","url":"https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/dependency-graph-supported-package-ecosystems"}
- {"title":"GitHub: exporting a software bill of materials for your repository — the SBOM as \"an inventory of a project's dependencies and associated information such as versions, package identifiers, licenses, transitive paths, and copyright information\", and its documented exclusion that \"SBOMs do not include dependents (other projects that rely on your project)\"","url":"https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/exporting-a-software-bill-of-materials-for-your-repository"}
- {"title":"FedRAMP Marketplace: GitHub Enterprise Cloud — the listing to re-read rather than quote, and the place to check the impact level against the system this recipe is being run for; SA-22 reaches class b, c and d, so the gap between a product's listed level and a reader's own is the SA-09 question this recipe's external-system note raises and does not answer","url":"https://www.fedramp.gov/marketplace/products/FR1812058188/"}
