Grundschutz++ as code, part 2: Applicability as data
Every ISO 27001 ISMS has a Statement of Applicability. It defines what gets checked. In part 1 it was a table in Markdown, and a script turned it into a checklist. Now it is an OSCAL profile: generated from the same table, checked against the BSI catalogue and readable by any tool.
Part 2 of the series “Grundschutz++ as code”. The overview of all parts is further down.
01 — Why the Statement of Applicability comes first
Every further step of the series needs an answer to the same question: which requirements apply at all? A component describes which requirements it covers. A measurement checks whether a requirement is met. A security plan brings both together. All three refer to a selection from the catalogue.
That selection already exists. ISO 27001 requires it in clause 6.1.3 d as the Statement of Applicability. Through the library's mapping it becomes a list of Grundschutz++ requirements, as shown in part 1. As long as that list exists only as text, every tool has to guess it again. That is why it is the first document that should become OSCAL.
02 — What an OSCAL profile is
A catalogue contains every requirement. A profile selects from it and tailors. It points at the catalogue, names the requirements that apply, and can set parameters or add text. The result can be resolved into a catalogue again that contains only the selection.
The BSI uses exactly this mechanism itself. The Grundschutz++ user catalogue is a resolved profile over several source catalogues of the library (Kernel-G0, methodology, part of risk management). Your own profile simply sits one level further down. Shortened, it looks like this:
{
"profile": {
"uuid": "…",
"metadata": {
"title": "Grundschutz++ tailoring of Example GmbH",
"last-modified": "2026-09-27T00:00:00Z",
"version": "2026-09-27",
"oscal-version": "1.1.3"
},
"imports": [{
"href": "https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/367d775…/control_layer/Grundschutz%2B%2B/Grundschutz%2B%2B-resolved_catalog.json",
"include-controls": [{
"with-child-controls": "no",
"with-ids": ["DET.2.1", "DET.3.1", "KONF.3.2", "…"]
}]
}],
"merge": { "as-is": true }
}
}
Two things stand out. The catalogue is imported at a fixed commit, not at the current version. And the profile names IDs, not texts. The BSI texts stay in the catalogue; the profile only points at them.
03 — From document to profile
Copying the Statement of Applicability into JSON by hand would repeat the mistake from part 1 in a new form. There would be two copies, and one of them would soon be wrong. So the profile is generated, in two halves.
- A reader for your own documentation: it knows the format of your own tables and turns them into a small tailoring file. It is specific, so it stays in your own repository.
- A generator for the profile: it knows only the tailoring file and OSCAL. It is generic and public in grundschutz-worksheets.
The tailoring file is deliberately plain:
{
"title": "Grundschutz++ tailoring of Example GmbH",
"version": "2026-09-27",
"last-modified": "2026-09-27T00:00:00Z",
"catalog": {
"path": "control_layer/Grundschutz++/Grundschutz++-resolved_catalog.json",
"commit": "367d775010abee641b258926bb482fcd05270059"
},
"applicable": ["DET.2.1", "KONF.3.2"],
"not-applicable": [{ "id": "ARCH.6.1", "reason": "no own WAN links" }]
}
git clone github.com/ohartwig/grundschutz-worksheets
cd grundschutz-worksheets
npm ci # only for validate.mjs
C=367d775010abee641b258926bb482fcd05270059
CAT=control_layer/Grundschutz++/Grundschutz++-resolved_catalog.json
node profile.mjs tailoring.json > profile.json
node validate.mjs profile.json
node worksheet.mjs $CAT --profile profile.json --commit $C
The last line closes the loop. The worksheet from part 1 now reads the profile instead of a list of Annex A controls. The output is exactly the requirements that apply. When I wrote this, it was 257 of 280 in my case.
Nothing drops out silently
- Every ID is checked against the catalogue at the fixed commit. An unknown ID is an error.
- An ID listed twice, or both applicable and not applicable, is an error.
- A table row the reader does not understand stops the run. Otherwise it would simply be missing from the profile.
The last point paid off on the very first run. Two rows carried a note in the level column that the first pattern did not know. The result was 278 requirements instead of 280, without any warning.
04 — The gap in the format: not applicable, because
A Statement of Applicability does not only say what applies. It also says what does not, and why. That is exactly what an OSCAL profile has no field for. It can include or exclude requirements, but it cannot give a reason.
There are three ways out:
- Exclude without a reason: formally clean, but the reason is lost. For ISO 27001 the reason is the actual content.
- Free text in the metadata: readable for people, not for tools.
- One back-matter resource per exclusion: OSCAL allows resources in the back matter of a document. Each carries a title, the reason and an ID, marked with a namespace of its own.
I chose the third:
"back-matter": {
"resources": [{
"uuid": "…",
"title": "Not applicable: ARCH.6.1",
"description": "no own WAN links",
"props": [
{ "name": "control-id", "ns": "https://github.com/ohartwig/grundschutz-worksheets/ns", "value": "ARCH.6.1" },
{ "name": "tailoring", "ns": "https://github.com/ohartwig/grundschutz-worksheets/ns", "value": "not-applicable" }
]
}]
}
OSCAL actually expects this statement in the security plan. That only arrives in part 5. Until then the reason lives in the profile, machine-readable and valid against the schema.
Applying is not meeting
A requirement that applies but is still open stays in the profile. Whether something applies and whether it is met are two questions. The profile answers the first. Measurements answer the second, and they come in part 4. Mix both into one file and you get a Statement of Applicability that changes with every finding.
05 — Generating deterministically
A generated document can only be reviewed if the same input gives the same bytes. Otherwise every merge request shows a diff across the whole file, and nobody reads it.
- IDs from the content: OSCAL requires a UUID for every document and every resource. The generator derives them from the content instead of rolling dice.
- Date from the source: the date in the profile does not come from the clock but from the documentation itself. In my case it is the edit date of the table pages, which is refreshed on every change anyway.
- Fixed order: IDs are sorted, and numerically. KONF.10 comes after KONF.9.
The test is simple. Set one requirement to “not applicable”, and the diff shows exactly that: the ID leaves the selection, one back-matter entry appears, two counters change. Nothing else.
The pipeline checks this on every change. It regenerates the profile and compares it with the committed version. If they differ, the merge request is red. A changed table without a new profile does not get through, and neither does a hand edit to the JSON.
06 — Validating against the schema
NIST publishes a JSON schema for every OSCAL version. The tool validate.mjs checks every file against the schema of the version the file itself declares. That is necessary because the BSI library mixes several versions: catalogues and profiles in 1.1.3, the mapping and newer component definitions in 1.2.2, older component definitions still in 1.1.2. Not every file in the library passes. Two component definitions and the ISO mapping itself deviate from the schema of their version.
- Pinned schemas: the schemas ship unchanged from the NIST releases in the repository and are checked against a SHA-256 checksum.
- Unknown is an error: a file with a version that has no schema on record fails. It is not skipped either.
- Broken examples: the tests contain deliberately invalid files. They have to fail. A validator that never goes red checks nothing.
A practical note for anyone running CI runners on IPv6 only: github.com and its release downloads have no IPv6 address, raw.githubusercontent.com does. The check in my own repository therefore fetches only the dependency-free generator from raw.githubusercontent.com. Schema validation runs when the profile is generated and in the public repository's CI.
07 — What the profile now carries
The profile is small and unspectacular. That is exactly what makes it useful. Every further step can refer to it instead of determining the selection again.
- Worksheets: exactly for your own scope, without the detour through the mapping.
- Components (part 3): which requirements from the profile does an image built by the pipeline cover?
- Measurements (part 4): which requirements from the profile did the nightly check evidence today?
- Security plan (part 5): profile, components and measurements brought together, with the reasons where OSCAL expects them.
08 — The series at a glance
- Grundschutz++ against my own documentation: the method, the catalogue as a data model, the comparison with IaC and GitOps.
- Applicability as data: the Statement of Applicability as an OSCAL profile. This post.
- Containers that say which requirements they meet: a signed component definition attached to every image the pipeline builds.
- Measurement as evidence: nightly checks write their results as OSCAL. Plus the second level, which finds in the running system what no repository knows about.
- The ISMS as a build product: a security plan that is assembled rather than written, with an honest account of the effort.
A part appears only once the step it describes is running and measured. The code for it lands in grundschutz-worksheets first. The titles are linked here as soon as each post is online.
Frequently asked questions
Does this work without ISO 27001?+
Yes. The tailoring file only needs IDs and reasons. The generator does not care where the selection comes from. With ISO 27001, the library's mapping is a good starting point.
What happens when the BSI changes the catalogue?+
Nothing, as long as the profile points at a fixed commit. Whoever raises the commit generates the profile again. IDs that no longer exist in the new catalogue stop the generator. That way every change is noticed.
Does the profile replace the Statement of Applicability?+
No. The Statement of Applicability stays the document people read and approve. The profile is generated from it and never edited by hand. When the statement changes, the profile is generated again.
Do I need OSCAL to use Grundschutz++?+
No. The requirements can also be read as a worksheet. OSCAL pays off once several tools need the same selection: worksheets, components, measurements and a security plan.
Conclusion
The Statement of Applicability is the smallest OSCAL document with the biggest effect. It defines what everything else refers to. As a profile it is generated from your own documentation, checked against the catalogue and the schema, and compared again on every change. The format's gap for exclusions with a reason can be bridged cleanly until the security plan takes them over.
Next up, part 3: containers that say which requirements they meet.
I make your Statement of Applicability machine-readable. Generated from your documentation, checked against Grundschutz++.
Matching your Statement of Applicability against the state-of-the-art library, an OSCAL profile for your scope, and a pipeline that regenerates and checks it on every change.
Platform operations instead of paper consulting: on request I build the next steps too, from the component definition to the security plan.
About the author

Kai Ole Hartwig
Programming since 2002 – self-taught, set up my own business with KO-Web in 2012. Over 100 projects, with a focus on security, performance, automation and quality. Today freelance: DevSecOps consulting, training and software development.
