diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 380195d98d6..1b07d370eed 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1082,7 +1082,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1110,34 +1110,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1288,6 +1289,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1337,7 +1430,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1373,7 +1466,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1383,7 +1476,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1405,7 +1498,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1413,16 +1506,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1452,7 +1545,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1473,7 +1566,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1484,7 +1577,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1499,7 +1592,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1568,7 +1661,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1588,7 +1683,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1617,6 +1712,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 969d2e4b79a..7d5d09b789c 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1213,7 +1213,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1241,34 +1241,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1419,6 +1420,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1468,7 +1561,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1504,7 +1597,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1514,7 +1607,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1536,7 +1629,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1544,16 +1637,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1583,7 +1676,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1604,7 +1697,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1615,7 +1708,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1630,7 +1723,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1699,7 +1792,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1719,7 +1814,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1748,6 +1843,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/cli/workflows/test-all.lock.yml b/pkg/cli/workflows/test-all.lock.yml index d9e1a63ffd2..8b2328faa58 100644 --- a/pkg/cli/workflows/test-all.lock.yml +++ b/pkg/cli/workflows/test-all.lock.yml @@ -1517,7 +1517,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1545,34 +1545,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1723,6 +1724,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1772,7 +1865,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1808,7 +1901,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1818,7 +1911,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1840,7 +1933,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1848,16 +1941,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1887,7 +1980,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1908,7 +2001,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1919,7 +2012,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1934,7 +2027,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -2003,7 +2096,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -2023,7 +2118,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -2052,6 +2147,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/cli/workflows/test-claude-max-patch-size.lock.yml b/pkg/cli/workflows/test-claude-max-patch-size.lock.yml index f26eb269b6e..df8dfd395c6 100644 --- a/pkg/cli/workflows/test-claude-max-patch-size.lock.yml +++ b/pkg/cli/workflows/test-claude-max-patch-size.lock.yml @@ -1010,7 +1010,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1038,34 +1038,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1216,6 +1217,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1265,7 +1358,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1301,7 +1394,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1311,7 +1404,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1333,7 +1426,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1341,16 +1434,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1380,7 +1473,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1401,7 +1494,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1412,7 +1505,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1427,7 +1520,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1496,7 +1589,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1516,7 +1611,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1545,6 +1640,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/cli/workflows/test-claude-missing-tool.lock.yml b/pkg/cli/workflows/test-claude-missing-tool.lock.yml index d7d20a90a49..cc1dfc91fb4 100644 --- a/pkg/cli/workflows/test-claude-missing-tool.lock.yml +++ b/pkg/cli/workflows/test-claude-missing-tool.lock.yml @@ -1069,7 +1069,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1097,34 +1097,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1275,6 +1276,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1324,7 +1417,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1360,7 +1453,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1370,7 +1463,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1392,7 +1485,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1400,16 +1493,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1439,7 +1532,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1460,7 +1553,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1471,7 +1564,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1486,7 +1579,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1555,7 +1648,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1575,7 +1670,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1604,6 +1699,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml index 7809fb6df0d..5bc372cd0ad 100644 --- a/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-claude-patch-size-exceeded.lock.yml @@ -1012,7 +1012,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1040,34 +1040,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1218,6 +1219,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1267,7 +1360,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1303,7 +1396,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1313,7 +1406,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1335,7 +1428,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1343,16 +1436,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1382,7 +1475,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1403,7 +1496,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1414,7 +1507,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1429,7 +1522,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1498,7 +1591,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1518,7 +1613,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1547,6 +1642,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/cli/workflows/test-playwright-accessibility-contrast.lock.yml b/pkg/cli/workflows/test-playwright-accessibility-contrast.lock.yml index 481e6a5ba47..49cb3d6de26 100644 --- a/pkg/cli/workflows/test-playwright-accessibility-contrast.lock.yml +++ b/pkg/cli/workflows/test-playwright-accessibility-contrast.lock.yml @@ -1027,7 +1027,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1055,34 +1055,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1233,6 +1234,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1282,7 +1375,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1318,7 +1411,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1328,7 +1421,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1350,7 +1443,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1358,16 +1451,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1397,7 +1490,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1418,7 +1511,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1429,7 +1522,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1444,7 +1537,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1513,7 +1606,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1533,7 +1628,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1562,6 +1657,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/cli/workflows/test-playwright-screenshots.lock.yml b/pkg/cli/workflows/test-playwright-screenshots.lock.yml index d886d8e75ae..b124859889a 100644 --- a/pkg/cli/workflows/test-playwright-screenshots.lock.yml +++ b/pkg/cli/workflows/test-playwright-screenshots.lock.yml @@ -1175,7 +1175,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1203,34 +1203,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1381,6 +1382,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1430,7 +1523,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1466,7 +1559,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1476,7 +1569,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1498,7 +1591,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1506,16 +1599,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1545,7 +1638,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1566,7 +1659,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1577,7 +1670,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1592,7 +1685,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1661,7 +1754,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1681,7 +1776,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1710,6 +1805,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/cli/workflows/test-safe-jobs.lock.yml b/pkg/cli/workflows/test-safe-jobs.lock.yml index 7f275d351b3..9594ade54c5 100644 --- a/pkg/cli/workflows/test-safe-jobs.lock.yml +++ b/pkg/cli/workflows/test-safe-jobs.lock.yml @@ -1213,7 +1213,7 @@ jobs: return sanitized.trim(); function sanitizeUrlDomains(s) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); + const urlAfterProtocol = match.slice(8); const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); const isAllowed = allowedDomains.some(allowedDomain => { const normalizedAllowed = allowedDomain.toLowerCase(); @@ -1241,34 +1241,35 @@ jobs: } } function getMaxAllowedForType(itemType, config) { - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } switch (itemType) { case "create-issue": - return 1; + return 1; case "add-comment": - return 1; + return 1; case "create-pull-request": - return 1; + return 1; case "create-pull-request-review-comment": - return 1; + return 1; case "add-labels": - return 5; + return 5; case "update-issue": - return 1; + return 1; case "push-to-pull-request-branch": - return 1; + return 1; case "create-discussion": - return 1; + return 1; case "missing-tool": - return 1000; + return 1000; case "create-code-scanning-alert": - return 1000; + return 1000; case "upload-asset": - return 10; + return 10; default: - return 1; + return 1; } } function repairJson(jsonStr) { @@ -1419,6 +1420,98 @@ jobs: } return { isValid: true }; } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } function parseJsonWithRepair(jsonStr) { try { return JSON.parse(jsonStr); @@ -1468,7 +1561,7 @@ jobs: const errors = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); - if (line === "") continue; + if (line === "") continue; try { const item = parseJsonWithRepair(line); if (item === undefined) { @@ -1504,7 +1597,7 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-comment": @@ -1514,7 +1607,7 @@ jobs: } const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } item.body = sanitizeContent(item.body); @@ -1536,7 +1629,7 @@ jobs: item.body = sanitizeContent(item.body); item.branch = sanitizeContent(item.branch); if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map( label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); } break; case "add-labels": @@ -1544,16 +1637,16 @@ jobs: errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some( label => typeof label !== "string")) { + if (item.labels.some(label => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } - item.labels = item.labels.map( label => sanitizeContent(label)); + item.labels = item.labels.map(label => sanitizeContent(label)); break; case "update-issue": const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; @@ -1583,7 +1676,7 @@ jobs: } const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -1604,7 +1697,7 @@ jobs: i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -1615,7 +1708,7 @@ jobs: } const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } const lineNumber = lineValidation.normalizedValue; @@ -1630,7 +1723,7 @@ jobs: i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -1699,7 +1792,9 @@ jobs: } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -1719,7 +1814,7 @@ jobs: } const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } if (item.ruleIdSuffix !== undefined) { @@ -1748,6 +1843,15 @@ jobs: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } break; } core.info(`Line ${i + 1}: Valid ${itemType} item`); diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index 41b58cb3d38..624352d6b8f 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -27,7 +27,7 @@ var createCodeScanningAlertScript string //go:embed js/compute_text.cjs var computeTextScript string -//go:embed js/collect_ndjson_output.cjs +//go:embed js/collect_ndjson_output.js var collectJSONLOutputScript string //go:embed js/add_labels.js diff --git a/pkg/workflow/js/collect_ndjson_output.js b/pkg/workflow/js/collect_ndjson_output.js new file mode 100644 index 00000000000..2e0b1b1da63 --- /dev/null +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -0,0 +1,722 @@ +async function main() { + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + core.setOutput("output", ""); + return; + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } +} +await main(); diff --git a/pkg/workflow/js/collect_ndjson_output.test.cjs b/pkg/workflow/js/collect_ndjson_output.test.cjs index 8a029642ffe..f67ecb42cd1 100644 --- a/pkg/workflow/js/collect_ndjson_output.test.cjs +++ b/pkg/workflow/js/collect_ndjson_output.test.cjs @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; import path from "path"; -describe("collect_ndjson_output.cjs", () => { +describe("collect_ndjson_output.js", () => { let mockCore; let collectScript; @@ -61,7 +61,7 @@ describe("collect_ndjson_output.cjs", () => { global.core = mockCore; // Read the script file - const scriptPath = path.join(__dirname, "collect_ndjson_output.cjs"); + const scriptPath = path.join(__dirname, "collect_ndjson_output.js"); collectScript = fs.readFileSync(scriptPath, "utf8"); // Make fs available globally for the evaluated script diff --git a/pkg/workflow/js/collect_ndjson_output.cjs b/pkg/workflow/js/collect_ndjson_output.ts similarity index 80% rename from pkg/workflow/js/collect_ndjson_output.cjs rename to pkg/workflow/js/collect_ndjson_output.ts index 7992fbe6950..a4b199975bb 100644 --- a/pkg/workflow/js/collect_ndjson_output.cjs +++ b/pkg/workflow/js/collect_ndjson_output.ts @@ -1,3 +1,23 @@ +import type { SafeOutputItem, SafeOutputItems } from "./types/safe-outputs"; +import type { + SafeOutputConfigs, + SafeOutputConfig, + SpecificSafeOutputConfig, + CreateIssueConfig, + CreateDiscussionConfig, + AddCommentConfig, + CreatePullRequestConfig, + CreatePullRequestReviewCommentConfig, + CreateCodeScanningAlertConfig, + AddLabelsConfig, + UpdateIssueConfig, + PushToPullRequestBranchConfig, + UploadAssetConfig, + MissingToolConfig, + SafeJobInput, + SafeJobConfig, +} from "./types/safe-outputs-config"; + async function main() { const fs = require("fs"); @@ -6,7 +26,7 @@ async function main() { * @param {string} content - The content to sanitize * @returns {string} The sanitized content */ - function sanitizeContent(content) { + function sanitizeContent(content: string) { if (!content || typeof content !== "string") { return ""; } @@ -68,7 +88,7 @@ async function main() { * @param {string} s - The string to process * @returns {string} The string with unknown domains redacted */ - function sanitizeUrlDomains(s) { + function sanitizeUrlDomains(s: string) { return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { // Extract just the URL part after https:// const urlAfterProtocol = match.slice(8); // Remove 'https://' @@ -91,7 +111,7 @@ async function main() { * @param {string} s - The string to process * @returns {string} The string with non-https protocols redacted */ - function sanitizeUrlProtocols(s) { + function sanitizeUrlProtocols(s: string) { // Match protocol:// patterns (URLs) and standalone protocol: patterns that look like URLs // Avoid matching command line flags like -v:10 or z3 -memory:high return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { @@ -105,7 +125,7 @@ async function main() { * @param {string} s - The string to process * @returns {string} The string with neutralized mentions */ - function neutralizeMentions(s) { + function neutralizeMentions(s: string) { // Replace @name or @org/team outside code with `@name` return s.replace( /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, @@ -118,7 +138,7 @@ async function main() { * @param {string} s - The string to process * @returns {string} The string with XML comments removed */ - function removeXmlComments(s) { + function removeXmlComments(s: string) { // Remove XML/HTML comments including malformed ones that might be used to hide content // Matches: and and variations return s.replace(//g, "").replace(//g, ""); @@ -129,7 +149,7 @@ async function main() { * @param {string} s - The string to process * @returns {string} The string with neutralized bot triggers */ - function neutralizeBotTriggers(s) { + function neutralizeBotTriggers(s: string) { // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); } @@ -138,13 +158,14 @@ async function main() { /** * Gets the maximum allowed count for a given output type * @param {string} itemType - The output item type - * @param {any} config - The safe-outputs configuration + * @param {SafeOutputConfigs} config - The safe-outputs configuration * @returns {number} The maximum allowed count */ - function getMaxAllowedForType(itemType, config) { + function getMaxAllowedForType(itemType: string, config: SafeOutputConfigs): number { // Check if max is explicitly specified in config - if (config && config[itemType] && typeof config[itemType] === "object" && config[itemType].max) { - return config[itemType].max; + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; } // Use default limits for plural-supported types @@ -181,15 +202,14 @@ async function main() { * @param {string} jsonStr - The potentially malformed JSON string * @returns {string} The repaired JSON string */ - function repairJson(jsonStr) { + function repairJson(jsonStr: string): string { let repaired = jsonStr.trim(); // remove invalid control characters like // U+0014 (DC4) — represented here as "\u0014" // Escape control characters not allowed in JSON strings (U+0000 through U+001F) // Preserve common JSON escapes for \b, \f, \n, \r, \t and use \uXXXX for the rest. - /** @type {Record} */ - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + const _ctrl: Record = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { const c = ch.charCodeAt(0); return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); @@ -249,7 +269,7 @@ async function main() { * @param {number} lineNum - The line number for error reporting * @returns {{isValid: boolean, error?: string, normalizedValue?: number}} Validation result */ - function validatePositiveInteger(value, fieldName, lineNum) { + function validatePositiveInteger(value: unknown, fieldName: string, lineNum: number) { if (value === undefined || value === null) { // Match the original error format for create-code-scanning-alert if (fieldName.includes("create-code-scanning-alert 'line'")) { @@ -321,7 +341,7 @@ async function main() { * @param {number} lineNum - The line number for error reporting * @returns {{isValid: boolean, error?: string, normalizedValue?: number}} Validation result */ - function validateOptionalPositiveInteger(value, fieldName, lineNum) { + function validateOptionalPositiveInteger(value: unknown, fieldName: string, lineNum: number) { if (value === undefined) { return { isValid: true }; } @@ -377,7 +397,7 @@ async function main() { * @param {number} lineNum - The line number for error reporting * @returns {{isValid: boolean, error?: string}} Validation result */ - function validateIssueOrPRNumber(value, fieldName, lineNum) { + function validateIssueOrPRNumber(value: unknown, fieldName: string, lineNum: number) { if (value === undefined) { return { isValid: true }; } @@ -392,12 +412,141 @@ async function main() { return { isValid: true }; } + /** + * Validates and sanitizes a field value based on SafeJobInput schema + * @param {any} value - The value to validate + * @param {string} fieldName - The name of the field + * @param {SafeJobInput} inputSchema - The input schema to validate against + * @param {number} lineNum - The line number for error reporting + * @returns {{isValid: boolean, error?: string, normalizedValue?: any}} Validation result + */ + function validateFieldWithInputSchema(value: any, fieldName: string, inputSchema: SafeJobInput, lineNum: number) { + // If field is required and value is missing + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + + // If value is undefined and field is not required, use default or return valid + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + + // Validate type + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + // Apply sanitization for string types + normalizedValue = sanitizeContent(value); + break; + + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + // Apply sanitization for string-based choice types + normalizedValue = sanitizeContent(value); + break; + + default: + // For unknown types, treat as string and sanitize + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } + + return { + isValid: true, + normalizedValue, + }; + } + + /** + * Validates an item using SafeJobConfig inputs schema + * @param {any} item - The item to validate + * @param {SafeJobConfig} jobConfig - The safe job configuration + * @param {number} lineNum - The line number for error reporting + * @returns {{isValid: boolean, errors: string[], normalizedItem: any}} Validation result + */ + function validateItemWithSafeJobConfig(item: any, jobConfig: SafeJobConfig, lineNum: number) { + const errors: string[] = []; + const normalizedItem = { ...item }; + + if (!jobConfig.inputs) { + // No input schema defined, return item as-is + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + + // Validate each field defined in the inputs schema + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + /** * Attempts to parse JSON with repair fallback * @param {string} jsonStr - The JSON string to parse - * @returns {Object|undefined} The parsed JSON object, or undefined if parsing fails + * @returns {any|undefined} The parsed JSON object, or undefined if parsing fails */ - function parseJsonWithRepair(jsonStr) { + function parseJsonWithRepair(jsonStr: string): any | undefined { try { // First, try normal JSON.parse return JSON.parse(jsonStr); @@ -441,11 +590,10 @@ async function main() { core.info(`Raw output content length: ${outputContent.length}`); // Parse the safe-outputs configuration - /** @type {any} */ - let expectedOutputTypes = {}; + let expectedOutputTypes: SafeOutputConfigs = {}; if (safeOutputsConfig) { try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); + expectedOutputTypes = JSON.parse(safeOutputsConfig) as SafeOutputConfigs; core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -455,15 +603,14 @@ async function main() { // Parse JSONL content const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; + const parsedItems: SafeOutputItem[] = []; + const errors: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line === "") continue; // Skip empty lines try { - /** @type {any} */ - const item = parseJsonWithRepair(line); + const item = parseJsonWithRepair(line) as any; // If item is undefined (failed to parse), add error and process next line if (item === undefined) { @@ -509,7 +656,7 @@ async function main() { item.body = sanitizeContent(item.body); // Sanitize labels if present if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(/** @param {any} label */ label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map((label: any) => (typeof label === "string" ? sanitizeContent(label) : label)); } break; @@ -521,7 +668,7 @@ async function main() { // Validate optional issue_number field const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); if (!issueNumValidation.isValid) { - errors.push(issueNumValidation.error); + if (issueNumValidation.error) errors.push(issueNumValidation.error); continue; } // Sanitize text content @@ -547,7 +694,7 @@ async function main() { item.branch = sanitizeContent(item.branch); // Sanitize labels if present if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(/** @param {any} label */ label => (typeof label === "string" ? sanitizeContent(label) : label)); + item.labels = item.labels.map((label: any) => (typeof label === "string" ? sanitizeContent(label) : label)); } break; @@ -556,18 +703,18 @@ async function main() { errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); continue; } - if (item.labels.some(/** @param {any} label */ label => typeof label !== "string")) { + if (item.labels.some((label: any) => typeof label !== "string")) { errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); continue; } // Validate optional issue_number field const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); if (!labelsIssueNumValidation.isValid) { - errors.push(labelsIssueNumValidation.error); + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); continue; } // Sanitize label strings - item.labels = item.labels.map(/** @param {any} label */ label => sanitizeContent(label)); + item.labels = item.labels.map((label: any) => sanitizeContent(label)); break; case "update-issue": @@ -603,7 +750,7 @@ async function main() { // Validate issue_number if provided (for target "*") const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); if (!updateIssueNumValidation.isValid) { - errors.push(updateIssueNumValidation.error); + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); continue; } break; @@ -629,7 +776,7 @@ async function main() { i + 1 ); if (!pushPRNumValidation.isValid) { - errors.push(pushPRNumValidation.error); + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); continue; } break; @@ -642,7 +789,7 @@ async function main() { // Validate required line field const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); if (!lineValidation.isValid) { - errors.push(lineValidation.error); + if (lineValidation.error) errors.push(lineValidation.error); continue; } // lineValidation.normalizedValue is guaranteed to be defined when isValid is true @@ -661,7 +808,7 @@ async function main() { i + 1 ); if (!startLineValidation.isValid) { - errors.push(startLineValidation.error); + if (startLineValidation.error) errors.push(startLineValidation.error); continue; } if ( @@ -740,7 +887,9 @@ async function main() { } const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); if (!alertLineValidation.isValid) { - errors.push(alertLineValidation.error); + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } continue; } if (!item.severity || typeof item.severity !== "string") { @@ -764,7 +913,7 @@ async function main() { // Validate optional column field const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); if (!columnValidation.isValid) { - errors.push(columnValidation.error); + if (columnValidation.error) errors.push(columnValidation.error); continue; } @@ -798,7 +947,21 @@ async function main() { errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; } - // TODO: validate inputs and apply sanitizations based on the inputs schema + + // Check if this is a SafeJobConfig with inputs schema + const safeJobConfig = jobOutputType as SafeJobConfig; + if (safeJobConfig && safeJobConfig.inputs) { + // Use SafeJobConfig inputs schema to validate and sanitize fields + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + + // Update item with normalized/sanitized values + Object.assign(item, validation.normalizedItem); + } break; } diff --git a/pkg/workflow/js/tsconfig.build.json b/pkg/workflow/js/tsconfig.build.json index 403373b42f4..7689ab9ddaf 100644 --- a/pkg/workflow/js/tsconfig.build.json +++ b/pkg/workflow/js/tsconfig.build.json @@ -35,6 +35,7 @@ "add_labels.ts", "create_discussion.ts", "create_issue.ts", + "collect_ndjson_output.ts", "types/*.d.ts" ], "exclude": [ diff --git a/pkg/workflow/js/tsconfig.json b/pkg/workflow/js/tsconfig.json index cae84012fdc..bba423bef43 100644 --- a/pkg/workflow/js/tsconfig.json +++ b/pkg/workflow/js/tsconfig.json @@ -37,7 +37,6 @@ "add_reaction_and_edit_comment.cjs", "check_permissions.cjs", "check_team_member.cjs", - "collect_ndjson_output.cjs", "compute_text.cjs", "create_code_scanning_alert.cjs", "add_comment.cjs", @@ -45,6 +44,7 @@ "create_issue.ts", "create_pr_review_comment.cjs", "create_pull_request.cjs", + "collect_ndjson_output.ts", "missing_tool.cjs", "parse_claude_log.cjs", "parse_codex_log.cjs", diff --git a/pkg/workflow/js/types/safe-outputs-config.d.ts b/pkg/workflow/js/types/safe-outputs-config.d.ts new file mode 100644 index 00000000000..b3afb496a28 --- /dev/null +++ b/pkg/workflow/js/types/safe-outputs-config.d.ts @@ -0,0 +1,180 @@ +// Base interface for all safe output configurations +interface SafeOutputConfig { + type: string; + max?: number; +} + +// === Specific Safe Output Configuration Interfaces === + +/** + * Configuration for creating GitHub issues + */ +interface CreateIssueConfig extends SafeOutputConfig { + "title-prefix"?: string; + labels?: string[]; + max?: number; + "github-token"?: string; +} + +/** + * Configuration for creating GitHub discussions + */ +interface CreateDiscussionConfig extends SafeOutputConfig { + "title-prefix"?: string; + "category-id"?: string; + max?: number; + "github-token"?: string; +} + +/** + * Configuration for adding comments to issues or PRs + */ +interface AddCommentConfig extends SafeOutputConfig { + max?: number; + target?: string; + "github-token"?: string; +} + +/** + * Configuration for creating pull requests + */ +interface CreatePullRequestConfig extends SafeOutputConfig { + "title-prefix"?: string; + labels?: string[]; + draft?: boolean; + max?: number; + "if-no-changes"?: string; + "github-token"?: string; +} + +/** + * Configuration for creating pull request review comments + */ +interface CreatePullRequestReviewCommentConfig extends SafeOutputConfig { + max?: number; + side?: string; + "github-token"?: string; +} + +/** + * Configuration for creating code scanning alerts + */ +interface CreateCodeScanningAlertConfig extends SafeOutputConfig { + max?: number; + driver?: string; + "github-token"?: string; +} + +/** + * Configuration for adding labels to issues or PRs + */ +interface AddLabelsConfig extends SafeOutputConfig { + allowed?: string[]; + max?: number; + "github-token"?: string; +} + +/** + * Configuration for updating issues + */ +interface UpdateIssueConfig extends SafeOutputConfig { + status?: boolean; + target?: string; + title?: boolean; + body?: boolean; + max?: number; + "github-token"?: string; +} + +/** + * Configuration for pushing to pull request branches + */ +interface PushToPullRequestBranchConfig extends SafeOutputConfig { + target?: string; + "if-no-changes"?: string; + "github-token"?: string; +} + +/** + * Configuration for uploading assets + */ +interface UploadAssetConfig extends SafeOutputConfig { + branch?: string; + "max-size"?: number; + "allowed-exts"?: string[]; + "github-token"?: string; +} + +/** + * Configuration for reporting missing tools + */ +interface MissingToolConfig extends SafeOutputConfig { + max?: number; + "github-token"?: string; +} + +// === Safe Job Configuration Interfaces === + +/** + * Safe job input parameter configuration + */ +interface SafeJobInput { + description?: string; + required?: boolean; + default?: string; + type?: string; + options?: string[]; +} + +/** + * Safe job configuration item + */ +interface SafeJobConfig { + name?: string; + "runs-on"?: any; + if?: string; + needs?: string[]; + steps?: any[]; + env?: Record; + permissions?: Record; + inputs?: Record; + "github-token"?: string; + output?: string; +} + +// Union type of all specific safe output configurations +type SpecificSafeOutputConfig = + | CreateIssueConfig + | CreateDiscussionConfig + | AddCommentConfig + | CreatePullRequestConfig + | CreatePullRequestReviewCommentConfig + | CreateCodeScanningAlertConfig + | AddLabelsConfig + | UpdateIssueConfig + | PushToPullRequestBranchConfig + | UploadAssetConfig + | MissingToolConfig; + +type SafeOutputConfigs = Record; + +export { + SafeOutputConfig, + SafeOutputConfigs, + // Specific configuration types + CreateIssueConfig, + CreateDiscussionConfig, + AddCommentConfig, + CreatePullRequestConfig, + CreatePullRequestReviewCommentConfig, + CreateCodeScanningAlertConfig, + AddLabelsConfig, + UpdateIssueConfig, + PushToPullRequestBranchConfig, + UploadAssetConfig, + MissingToolConfig, + SpecificSafeOutputConfig, + // Safe job configuration types + SafeJobInput, + SafeJobConfig, +};