Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions workspaces/orchestrator/.changeset/five-meals-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': patch
---

- Update dependecy @urql/core to fix CVE-2026-3118
- Reworks the filter and query builder code to use query variables
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"@backstage/plugin-scaffolder-backend": "^2.2.0",
"@backstage/plugin-scaffolder-node": "^0.11.0",
"@red-hat-developer-hub/backstage-plugin-orchestrator-common": "workspace:^",
"@urql/core": "^4.1.4",
"@urql/core": "^6.0.1",
"ajv-formats": "^2.1.1",
"cloudevents": "^8.0.0",
"express": "^4.21.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
TypeName,
} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common';

import { randomBytes } from 'node:crypto';

import { FilterClause, FilterClauseVariable } from '../types/filterClause';

type ProcessType = 'ProcessDefinition' | 'ProcessInstance';

const supportedOperators = [
Expand Down Expand Up @@ -73,44 +77,88 @@ function handleLogicalFilter(
introspection: IntrospectionField[],
type: ProcessType,
filter: LogicalFilter,
): string {
if (!filter.operator) return '';
): FilterClause {
if (!filter.operator) return {} as FilterClause;

const subClauses = filter.filters.map(f =>
buildFilterCondition(introspection, type, f),
);

return `${filter.operator.toLowerCase()}: {${subClauses.join(', ')}}`;
const filterClause: FilterClause = {
clause: `${filter.operator.toLowerCase()}: {${subClauses.map(cl => cl.clause).join(', ')}}`,
clauseVariable: subClauses.flatMap(cl => cl.clauseVariable),
};
return filterClause;
}

function handleNestedFilter(
introspection: IntrospectionField[],
type: ProcessType,
filter: NestedFilter,
): string {
): FilterClause {
const subClauses = buildFilterCondition(
introspection,
type,
filter.nested,
true,
);

return `${filter.field}: {${subClauses}}`;
const filterClause: FilterClause = {
clauseVariable: subClauses.clauseVariable,
clause: `${filter.field}: {${subClauses.clause}}`,
};

return filterClause;
}

function handleBetweenOperator(filter: FieldFilter): string {
function handleBetweenOperator(filter: FieldFilter): FilterClause {
if (!Array.isArray(filter.value) || filter.value.length !== 2) {
throw new Error('Between operator requires an array of two elements');
}
return `${filter.field}: {${getGraphQLOperator(
const filterClauseVariableArray: FilterClauseVariable[] = [];
const clauseVariableName1 = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const filterClauseVariable1: FilterClauseVariable = {
clauseVariableName: clauseVariableName1,
formattedValue: filter.value[0],
clauseVariableType: 'String',
};

const clauseVariableName2 = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const filterClauseVariable2: FilterClauseVariable = {
clauseVariableName: clauseVariableName2,
formattedValue: filter.value[1],
clauseVariableType: 'String',
};

const clause = `${filter.field}: {${getGraphQLOperator(
FieldFilterOperatorEnum.Between,
)}: {from: "${filter.value[0]}", to: "${filter.value[1]}"}}`;
)}: {from: $${clauseVariableName1}, to: $${clauseVariableName2}}}`;
filterClauseVariableArray.push(filterClauseVariable1, filterClauseVariable2);
const filterClause: FilterClause = {
clause: clause,
clauseVariable: filterClauseVariableArray,
};

return filterClause;
}

function handleIsNullOperator(filter: FieldFilter): string {
return `${filter.field}: {${getGraphQLOperator(
FieldFilterOperatorEnum.IsNull,
)}: ${convertToBoolean(filter.value)}}`;
function handleIsNullOperator(filter: FieldFilter): FilterClause {
const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const clause = `${filter.field}: {${getGraphQLOperator(FieldFilterOperatorEnum.IsNull)}: $${clauseVariableName}}`;

const filterClauseVariable: FilterClauseVariable = {
clauseVariableName: clauseVariableName,
formattedValue: convertToBoolean(filter.value),
clauseVariableType: 'Boolean',
};
const filterClauseVariableArray: FilterClauseVariable[] = [];
filterClauseVariableArray.push(filterClauseVariable);
const clauseObject: FilterClause = {
clauseVariable: filterClauseVariableArray,
clause,
};

return clauseObject;
}

function isEnumFilter(
Expand All @@ -136,32 +184,58 @@ function handleBinaryOperator(
binaryFilter: FieldFilter,
fieldDef: IntrospectionField | undefined,
type: 'ProcessDefinition' | 'ProcessInstance',
): string {
): FilterClause {
if (isEnumFilter(binaryFilter.field, type)) {
if (!isValidEnumOperator(binaryFilter.operator)) {
throw new Error(
`Invalid operator ${binaryFilter.operator} for enum field ${binaryFilter.field} filter`,
);
}
}
const formattedValue = Array.isArray(binaryFilter.value)
? `[${binaryFilter.value
.map(v => formatValue(binaryFilter.field, v, fieldDef, type))
.join(', ')}]`
: formatValue(binaryFilter.field, binaryFilter.value, fieldDef, type);
return `${binaryFilter.field}: {${getGraphQLOperator(
binaryFilter.operator,
)}: ${formattedValue}}`;
let formattedValue: any;
let paramType: string;
if (Array.isArray(binaryFilter.value)) {
formattedValue = binaryFilter.value.map(v =>
formatValue(binaryFilter.field, v, fieldDef, type),
);
paramType = isEnumFilter(binaryFilter.field, type)
? '[ProcessInstanceState!]'
: '[String!]';
} else {
formattedValue = formatValue(
binaryFilter.field,
binaryFilter.value,
fieldDef,
type,
);
paramType = 'String';
}

const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const clause = `${binaryFilter.field}: {${getGraphQLOperator(binaryFilter.operator)}: $${clauseVariableName}}`;
const filterClauseVariable: FilterClauseVariable = {
Comment on lines +195 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Enum variable type mismatch 🐞 Bug ≡ Correctness

handleBinaryOperator sets the GraphQL variable type for scalar enum filters (e.g. state EQ ...) to
String while using [ProcessInstanceState!] for array enum filters. This inconsistency can cause
GraphQL validation errors if the schema expects ProcessInstanceState for equal on state.
Agent Prompt
### Issue description
Enum filters for `state` use inconsistent GraphQL variable types: array values use `[ProcessInstanceState!]` but scalar values use `String`. This can break queries depending on the GraphQL schema.

### Issue Context
`isEnumFilter()` identifies `state` as enum-like, and the code already hardcodes `ProcessInstanceState` for the list case.

### Fix approach
Update the scalar branch in `handleBinaryOperator()` so that when `isEnumFilter(binaryFilter.field, type)` is true, `paramType` is the matching enum type (e.g., `ProcessInstanceState`), not `String`.

Optionally (more robust): derive the correct GraphQL input type name from introspection rather than hardcoding `ProcessInstanceState`.

### Fix Focus Areas
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[164-229]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

clauseVariableName: clauseVariableName,
formattedValue: formattedValue,
clauseVariableType: paramType,
};
const filterClauseVariableArray: FilterClauseVariable[] = [];
filterClauseVariableArray.push(filterClauseVariable);
const clauseObject: FilterClause = {
clauseVariable: filterClauseVariableArray,
clause,
};

return clauseObject;
}

export function buildFilterCondition(
introspection: IntrospectionField[],
type: ProcessType,
filters?: Filter,
isNested?: boolean,
): string {
): FilterClause {
if (!filters) {
return '';
return {} as FilterClause;
}
Comment on lines +236 to 239

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Empty filterclause is truthy 🐞 Bug ≡ Correctness

buildFilterCondition returns {} cast as FilterClause when filters is missing, producing a
truthy object with undefined clause/clauseVariable. Callers like DataIndexService treat this as
present and can build GraphQL where strings containing {undefined} and/or pass malformed filter
variables downstream.
Agent Prompt
### Issue description
`buildFilterCondition()` (and `handleLogicalFilter()` when `operator` is missing) returns `{} as FilterClause`. This creates a truthy value with missing fields, which then gets treated as a real filter and can render `where` fragments like `{undefined}`.

### Issue Context
Callers (e.g., `DataIndexService`) use `if (filterCondition)` to decide whether to include the filter, and then interpolate `filterCondition.clause`.

### Fix approach
Prefer one of these (choose consistently across the codebase):
1) Change return type to `FilterClause | undefined` and return `undefined` for “no filter / invalid filter”, OR
2) Return a structurally valid empty clause: `{ clause: '', clauseVariable: [] }` and update call sites to check `filterCondition.clause` (non-empty) rather than object truthiness.

### Fix Focus Areas
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[76-92]
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[231-247]
- workspaces/orchestrator/plugins/orchestrator-backend/src/service/DataIndexService.ts[222-277]
- workspaces/orchestrator/plugins/orchestrator-backend/src/service/DataIndexService.ts[279-323]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if (isNestedFilter(filters)) {
Expand Down Expand Up @@ -255,7 +329,7 @@ function formatValue(
type: ProcessType,
): string {
if (!fieldDef) {
return `"${fieldValue}"`;
return `${fieldValue}`;
}

if (!isFieldFilterSupported) {
Expand All @@ -270,7 +344,7 @@ function formatValue(
fieldDef.type.name === TypeName.Id ||
fieldDef.type.name === TypeName.Date
) {
return `"${fieldValue}"`;
return `${fieldValue}`;
}
throw new Error(
`Failed to format value for ${fieldName} ${fieldValue} with type ${fieldDef.type.name}`,
Expand Down Expand Up @@ -301,3 +375,9 @@ function getGraphQLOperator(operator: FieldFilterOperatorEnum): string {
throw new Error(`Operation "${operator}" not supported`);
}
}

// Function for getting 4 random digits to append to the clause variable name.
// Not used for any secrets or anything
function nonSecureRandomAlphaNumeric() {
return randomBytes(8).toString('hex');
}
Loading
Loading