Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2f854da
ci: validate landscape.yml on pull requests
thc1006 Jul 24, 2026
8df7701
ci: validate landscape.yml with the site's own parser (js-yaml)
thc1006 Jul 24, 2026
654741e
ci: fix js-yaml prototype-pollution exposure and harden landscape val…
thc1006 Jul 24, 2026
11b29c8
Harden landscape validation: js-yaml 4.3.0, reject aliases, cap size
thc1006 Jul 24, 2026
d01977b
Bound parsed size and disable merge keys in the landscape validator
thc1006 Jul 25, 2026
c08ae52
Align the browser parser with the validator and bound render and erro…
thc1006 Jul 25, 2026
04c104e
Reject non-string and oversized item fields before building diagnostics
thc1006 Jul 25, 2026
1250d4e
Align the validator's stated guarantees with its behavior
thc1006 Jul 25, 2026
9715ad4
Highlight search results with the same normalized query the filter used
thc1006 Jul 25, 2026
e3cf54e
Bound the parsed object graph, harden the highlighter, and fix docs
thc1006 Jul 25, 2026
b3d7181
Bound highlight nodes per render, correct the graph-budget claims, te…
thc1006 Jul 25, 2026
7841520
Resolve the CLI default path relative to the script, tighten parity t…
thc1006 Jul 26, 2026
4166336
Reject symlinks in the Pages artifact before upload
thc1006 Jul 26, 2026
a7096d1
Match filter and highlight on one regex, reject source symlinks robustly
thc1006 Jul 26, 2026
4c3c35c
Fold Unicode case in search, check JS syntax and symlinks in PR CI
thc1006 Jul 27, 2026
2b4bebc
Test that the browser search regexes stay Unicode-aware
thc1006 Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Setup Pages
uses: actions/configure-pages@v5
Expand All @@ -36,7 +39,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22

# - name: Install landscape2
# run: |
Expand All @@ -53,6 +56,22 @@ jobs:
# --output-dir out
# cd ..

# Validate the landscape data before assembling, so a bad file cannot be
# deployed even on a direct push to main.
- name: Install landscape validator
working-directory: scripts
run: npm ci --ignore-scripts

- name: Run validator tests
working-directory: scripts
run: node --test

- name: Validate production landscape
run: node scripts/validate-landscape.mjs landscape/landscape.yml

- name: Reject symlinks in the deployed sources
run: bash scripts/check-no-symlinks.sh

- name: Assemble Portal Distribution
run: |
mkdir -p dist/landscape/static
Expand All @@ -73,6 +92,18 @@ jobs:
sed -i "s|BUILD_TIMESTAMP|$CURRENT_TIME|g" dist/index.html


- name: Reject symlinks in the Pages artifact
run: |
# upload-pages-artifact packs the tree with `tar --dereference`, so a symlink copied
# into dist would be published as the bytes of whatever it points at (for example a
# runner-local file). Fail the deploy if any symlink is present rather than leak it.
links="$(find dist -type l -print)"
if [ -n "$links" ]; then
echo "::error::Symlinks are not allowed in the Pages artifact"
printf '%s\n' "$links"
exit 1
fi

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
Expand Down
49 changes: 49 additions & 0 deletions .github/workflows/validate-landscape.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Validate Landscape

# Runs on every pull request (no path filter) so it stays reliable as a required
# check, and on push to main as a second line of defence for direct pushes.
on:
pull_request:
push:
branches: ["main"]

permissions:
contents: read

concurrency:
group: validate-landscape-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: scripts/package-lock.json

- name: Install dependencies
run: npm ci --ignore-scripts
working-directory: scripts

- name: Run validator tests
run: node --test
working-directory: scripts

- name: Validate landscape.yml
run: node scripts/validate-landscape.mjs landscape/landscape.yml

- name: Check browser JavaScript syntax
run: node --check landscape/static/app.js

- name: Reject symlinks in the deployed sources
run: bash scripts/check-no-symlinks.sh
36 changes: 27 additions & 9 deletions docs/data-schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,20 @@ Each entry in the master taxonomy array is a JavaScript object representing a co
The landscape configuration follows a hierarchical CNCF-style structure. Each root category node contains subcategories, which contain individual items:

```yaml
- category: Security Guardrails & Firewalls
subcategories:
- subcategory: Prompt & Runtime Guardrails
items:
- name: Google Cloud Model Armor
homepage_url: https://cloud.google.com/security/products/model-armor
repo_url: https://github.com/... (optional)
description: Enterprise security service providing prompt injection defense...
project: member
landscape:
- category: Security Guardrails & Firewalls
subcategories:
- subcategory: Prompt & Runtime Guardrails
items:
- name: Google Cloud Model Armor
homepage_url: https://cloud.google.com/security/products/model-armor
repo_url: https://github.com/example/project # optional
description: Enterprise security service providing prompt injection defense...
project: member
```

The file has a single top-level `landscape:` key holding the list of categories.

### Landscape Item Field Specifications
* **`name`** *(String, Required):* The official name of the tool, framework, protocol, or standard.
* **`homepage_url`** *(String, Required):* The landing page URL of the project.
Expand All @@ -75,5 +78,20 @@ The landscape configuration follows a hierarchical CNCF-style structure. Each ro
* `incubating` - Active AAIF work-in-progress standards/projects
* `member` - Member-contributed tools/projects
* `external` - Non-member open-source tools/frameworks
* **`logo`** *(String, Optional):* Path to the item's logo asset.

### Landscape Structural Rules
* Each **`category`** requires a non-empty `category` name and a non-empty `subcategories` list; each **`subcategory`** requires a non-empty `subcategory` name and a non-empty `items` list.
* Category names and item names must each be unique across the whole landscape; subcategory names must be unique within their category. Names are compared case- and Unicode-normalization-insensitively.

### Landscape Validation Limits
`scripts/validate-landscape.mjs` runs in CI with the same js-yaml parser and options the site loads with, and enforces the following so a malformed or hostile file cannot break the rendered map or the validator itself:

* **Parsing:** the file is parsed with `FAILSAFE_SCHEMA`, so every scalar is a string — a bare `123` or `2026-01-01` is read as text, matching the browser. Reused object or array nodes (YAML aliases or cycles) and merge (`<<`) keys are rejected; a scalar alias is allowed but stays within the per-field and total limits below. Nesting depth and file size (512 KB) are bounded; the size cap is what limits how much the parser materializes.
* **Field lengths:** `name` ≤ 200, `description` ≤ 2000, `project` ≤ 50, `homepage_url` / `repo_url` ≤ 2048, `logo` ≤ 300, and `category` / `subcategory` names ≤ 120 characters.
* **Cardinality:** at most 500 items across the whole landscape, and at most 5000 objects or arrays and 20,000 references in the whole document (a budget that stops the validation walk early on a hostile file; it does not change what the parser already materialized).
* **URLs:** `homepage_url` and `repo_url` must be `https://`, contain no whitespace, and carry no embedded credentials.
* **Fields:** only the fields documented above are allowed at each level; any other key is rejected.
* **Characters:** display names and item descriptions must not contain control or format characters (for example zero-width or bidirectional-override characters).

---
132 changes: 91 additions & 41 deletions landscape/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,57 @@ document.addEventListener('DOMContentLoaded', () => {
const categoryBar = document.getElementById('category-bar');
const resultCount = document.getElementById('result-count');

// Helper to append highlighted query substrings using pure DOM methods
function appendHighlightedText(parentElement, text, query) {
if (!query) {
// Escape a user query so it is matched as a literal (not a pattern) in a RegExp. Only the regex
// syntax characters are escaped, not `-` (which is literal outside a character class), so the
// result is valid under the `u` flag. Filtering and highlighting build their regexes from this
// with `iu`/`giu`, so both apply Unicode simple case-folding (a Kelvin sign matches `k`, a
// capital sharp-s matches `ß`) and stay in agreement. Locale-specific folds such as Turkish
// dotted-I are not covered by simple case-folding and are not matched.
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Helper to append highlighted query substrings using pure DOM methods. `highlight` is a
// per-render context { regex, budget } shared across every field, so the regex is compiled
// once and the total number of <mark> nodes for the whole render is bounded, not just the
// count per field.
function appendHighlightedText(parentElement, text, highlight) {
if (!highlight || highlight.budget.remaining <= 0) {
parentElement.textContent = text;
return;
}
try {
const escapedQuery = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);

parts.forEach(part => {
if (part.toLowerCase() === query.toLowerCase()) {
const mark = document.createElement('mark');
mark.className = 'match-highlight';
mark.textContent = part;
parentElement.appendChild(mark);
} else if (part) {
parentElement.appendChild(document.createTextNode(part));
const { regex, budget } = highlight;
regex.lastIndex = 0;

// Walk matches with exec() and stop after MAX_MATCHES_PER_FIELD (or once the shared
// render budget runs out), rather than splitting the whole string into a fragment array
// first. Only the matched slices and the surrounding gaps become nodes; the remainder is
// appended as a single text node so the field still renders in full.
const MAX_MATCHES_PER_FIELD = 100;
let cursor = 0;
let count = 0;
let match;
while (count < MAX_MATCHES_PER_FIELD && budget.remaining > 0 && (match = regex.exec(text)) !== null) {
// A zero-length match cannot advance lastIndex on its own and would loop forever.
if (match.index === regex.lastIndex) {
regex.lastIndex += 1;
continue;
}
});
if (match.index > cursor) {
parentElement.appendChild(document.createTextNode(text.slice(cursor, match.index)));
}
const mark = document.createElement('mark');
mark.className = 'match-highlight';
mark.textContent = match[0];
parentElement.appendChild(mark);
cursor = match.index + match[0].length;
count += 1;
budget.remaining -= 1;
}
if (cursor < text.length) {
parentElement.appendChild(document.createTextNode(text.slice(cursor)));
}
} catch (e) {
parentElement.textContent = text;
}
Expand Down Expand Up @@ -71,8 +101,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
}

// Render Landscape Grid
function renderLandscape() {
// Render Landscape Grid. `query` is the trimmed search string from runFilteringPipeline;
// highlighting builds its regex from the same string (and the same escaping) the filter used,
// so it is passed in rather than re-read from state here.
function renderLandscape(query) {
landscapeGrid.replaceChildren();

let totalItems = 0;
Expand All @@ -91,15 +123,20 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}

const query = state.currentSearch;
// Compile the search regex once for the whole render and share a total <mark> budget across
// every field, so the number of highlight nodes is bounded per render, not just per field.
// A whitespace-only query has already been normalized to empty by the caller.
const highlight = query
? { regex: new RegExp(escapeRegExp(query), 'giu'), budget: { remaining: 2_000 } }
: null;

state.filteredCategories.forEach(catObj => {
const catGroup = document.createElement('section');
catGroup.className = 'landscape-category-group';

const catTitle = document.createElement('h2');
catTitle.className = 'landscape-category-title';
appendHighlightedText(catTitle, catObj.category, query);
appendHighlightedText(catTitle, catObj.category, highlight);
catGroup.appendChild(catTitle);

catObj.subcategories.forEach(subcatObj => {
Expand All @@ -110,7 +147,7 @@ document.addEventListener('DOMContentLoaded', () => {

const subTitle = document.createElement('h3');
subTitle.className = 'subcat-title';
appendHighlightedText(subTitle, subcatObj.subcategory, query);
appendHighlightedText(subTitle, subcatObj.subcategory, highlight);
subGroup.appendChild(subTitle);

const itemsGrid = document.createElement('div');
Expand All @@ -127,7 +164,7 @@ document.addEventListener('DOMContentLoaded', () => {

const cardTitle = document.createElement('h4');
cardTitle.className = 'card-title';
appendHighlightedText(cardTitle, item.name, query);
appendHighlightedText(cardTitle, item.name, highlight);
cardHeader.appendChild(cardTitle);

const tierBadge = document.createElement('span');
Expand All @@ -139,7 +176,7 @@ document.addEventListener('DOMContentLoaded', () => {

const cardDesc = document.createElement('p');
cardDesc.className = 'card-desc';
appendHighlightedText(cardDesc, item.description || '', query);
appendHighlightedText(cardDesc, item.description || '', highlight);
card.appendChild(cardDesc);

const cardLinks = document.createElement('div');
Expand All @@ -161,7 +198,7 @@ document.addEventListener('DOMContentLoaded', () => {
repoLink.setAttribute('href', item.repo_url);
repoLink.setAttribute('target', '_blank');
repoLink.setAttribute('rel', 'noopener noreferrer');
repoLink.textContent = 'GitHub ↗';
repoLink.textContent = 'Repository ↗';
cardLinks.appendChild(repoLink);
}

Expand All @@ -186,7 +223,11 @@ document.addEventListener('DOMContentLoaded', () => {
function runFilteringPipeline() {
if (!state.rawLandscape || !state.rawLandscape.landscape) return;

const query = state.currentSearch.toLowerCase().trim();
const rawQuery = state.currentSearch.trim();
// Filter and highlight share one escaped regex (Unicode case-insensitive, `iu`) so the same
// matching decides both. A `.test()` regex without the global flag is stateless, so it is
// safely reused across every field.
const filterRegex = rawQuery ? new RegExp(escapeRegExp(rawQuery), 'iu') : null;

// Filter Categories and Subcategories
state.filteredCategories = state.rawLandscape.landscape.map(catObj => {
Expand All @@ -198,15 +239,13 @@ document.addEventListener('DOMContentLoaded', () => {
// Filter Subcategories and Items
const filteredSubcats = catObj.subcategories.map(subcatObj => {
const filteredItems = subcatObj.items.filter(item => {
if (!query) return true;

const matchName = (item.name || '').toLowerCase().includes(query);
const matchDesc = (item.description || '').toLowerCase().includes(query);
const matchTier = (item.project || '').toLowerCase().includes(query);
const matchHome = (item.homepage_url || '').toLowerCase().includes(query);
const matchRepo = (item.repo_url || '').toLowerCase().includes(query);
if (!filterRegex) return true;

return matchName || matchDesc || matchTier || matchHome || matchRepo;
return filterRegex.test(item.name || '') ||
filterRegex.test(item.description || '') ||
filterRegex.test(item.project || '') ||
filterRegex.test(item.homepage_url || '') ||
filterRegex.test(item.repo_url || '');
});

if (filteredItems.length === 0) return null;
Expand All @@ -225,7 +264,11 @@ document.addEventListener('DOMContentLoaded', () => {
};
}).filter(Boolean);

renderLandscape();
// Highlight the rendered text with the same query and escaping the filter used. The filter
// also searches the tier and URLs, which are not rendered as highlightable text, so a match
// there filters the card in without a visible mark. A whitespace-only query trims to empty,
// filtering nothing out and highlighting nothing.
renderLandscape(rawQuery);
}

// Fetch landscape.yml and Initialize
Expand All @@ -235,16 +278,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (!response.ok) throw new Error('Failed to fetch landscape.yml');
const yamlText = await response.text();

state.rawLandscape = jsyaml.load(yamlText);
// Parse with the same options as the CI validator (scripts/validate-landscape.mjs):
// FAILSAFE_SCHEMA keeps every scalar a string, so a value like `name: 789` cannot
// arrive here as a number or Date and then throw in the search .toLowerCase() calls,
// and maxDepth bounds nesting. These options must stay in sync with the validator.
state.rawLandscape = jsyaml.load(yamlText, { schema: jsyaml.FAILSAFE_SCHEMA, maxDepth: 10 });
initCategoryBar();
runFilteringPipeline();
} catch (error) {
landscapeGrid.innerHTML = `
<div class="empty-state">
<p>Error loading landscape configuration.</p>
<span>Please ensure landscape.yml exists and is valid YAML. (${error.message})</span>
</div>
`;
landscapeGrid.replaceChildren();
const errorState = document.createElement('div');
errorState.className = 'empty-state';
const errorTitle = document.createElement('p');
errorTitle.textContent = 'Error loading landscape configuration.';
const errorDetail = document.createElement('span');
errorDetail.textContent = `Please ensure landscape.yml exists and is valid YAML. (${error.message})`;
errorState.append(errorTitle, errorDetail);
landscapeGrid.appendChild(errorState);
resultCount.textContent = 'Error loading data';
}
}
Expand Down
Loading