Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion actions/setup/js/add_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const MAX_LABELS = 10;
async function main(config = {}) {
// Extract configuration
const allowedLabels = config.allowed || [];
const blockedPatterns = config.blocked || [];
const maxCount = config.max || 10;
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);

Expand All @@ -38,6 +39,9 @@ async function main(config = {}) {
if (allowedLabels.length > 0) {
core.info(`Allowed labels: ${allowedLabels.join(", ")}`);
}
if (blockedPatterns.length > 0) {
core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
}
core.info(`Default target repo: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Allowed repos: ${[...allowedRepos].join(", ")}`);
Expand Down Expand Up @@ -105,7 +109,7 @@ async function main(config = {}) {
}

// Use validation helper to sanitize and validate labels
const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount);
const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount, blockedPatterns);
if (!labelsResult.valid) {
// If no valid labels, log info and return gracefully
if (labelsResult.error?.includes("No valid labels")) {
Expand Down
6 changes: 5 additions & 1 deletion actions/setup/js/remove_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_help
async function main(config = {}) {
// Extract configuration
const allowedLabels = config.allowed || [];
const blockedPatterns = config.blocked || [];
const maxCount = config.max || 10;
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);

Expand All @@ -30,6 +31,9 @@ async function main(config = {}) {
if (allowedLabels.length > 0) {
core.info(`Allowed labels to remove: ${allowedLabels.join(", ")}`);
}
if (blockedPatterns.length > 0) {
core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
}
core.info(`Default target repo: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
Expand Down Expand Up @@ -100,7 +104,7 @@ async function main(config = {}) {
}

// Use validation helper to sanitize and validate labels
const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount);
const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount, blockedPatterns);
if (!labelsResult.valid) {
// If no valid labels, log info and return gracefully
if (labelsResult.error?.includes("No valid labels")) {
Expand Down
24 changes: 21 additions & 3 deletions actions/setup/js/safe_output_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ function validateBody(body, fieldName = "body", required = false) {
* @param {any} labels - The labels to validate
* @param {string[]|undefined} allowedLabels - Optional list of allowed labels
* @param {number} maxCount - Maximum number of labels allowed
* @param {string[]|undefined} blockedPatterns - Optional list of blocked label patterns (supports glob patterns like "~*", "*[bot]")
* @returns {{valid: boolean, value?: string[], error?: string}} Validation result
*/
function validateLabels(labels, allowedLabels = undefined, maxCount = 3) {
function validateLabels(labels, allowedLabels = undefined, maxCount = 3, blockedPatterns = undefined) {
if (!labels || !Array.isArray(labels)) {
return { valid: false, error: "labels must be an array" };
}
Expand All @@ -98,10 +99,27 @@ function validateLabels(labels, allowedLabels = undefined, maxCount = 3) {
}
}

// Filter labels based on allowed list if provided
// Filter out blocked labels first (security boundary)
let validLabels = labels;
if (blockedPatterns && blockedPatterns.length > 0) {
const { matchesSimpleGlob } = require("./glob_pattern_helpers.cjs");
const blockedLabels = [];
validLabels = labels.filter(label => {
const labelStr = String(label).trim();
const isBlocked = blockedPatterns.some(pattern => matchesSimpleGlob(labelStr, pattern));
if (isBlocked) {
blockedLabels.push(labelStr);
}
return !isBlocked;
});
Comment on lines +107 to +114
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

Blocked-pattern matching is currently performed on labelStr = String(label).trim() before sanitizeLabelContent/Unicode hardening. Since sanitizeLabelContent removes zero-width chars and normalizes text, a label like ~\u200bstale could evade the ~* blocked pattern check and then be normalized to ~stale later, defeating the security boundary. Consider normalizing/sanitizing the label string (at least hardenUnicodeText, or reuse sanitizeLabelContent) before evaluating blocked patterns (and ideally use the same normalized value for subsequent allowlist checks).

Copilot uses AI. Check for mistakes.
if (blockedLabels.length > 0) {
core.info(`Filtered out ${blockedLabels.length} blocked labels: ${blockedLabels.join(", ")}`);
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

validateLabels logs blocked labels by joining raw, untrusted label strings. Since labels come from agent output, this can inject newlines/control chars into logs and make traces hard to read. Consider logging a sanitized/escaped representation instead (e.g., JSON.stringify(blockedLabels) and/or sanitizing each label before logging).

Suggested change
core.info(`Filtered out ${blockedLabels.length} blocked labels: ${blockedLabels.join(", ")}`);
core.info(`Filtered out ${blockedLabels.length} blocked labels: ${JSON.stringify(blockedLabels)}`);

Copilot uses AI. Check for mistakes.
}
}

// Filter labels based on allowed list if provided
if (allowedLabels && allowedLabels.length > 0) {
validLabels = labels.filter(label => allowedLabels.includes(label));
validLabels = validLabels.filter(label => allowedLabels.includes(label));
}

// Sanitize and deduplicate labels
Expand Down
79 changes: 79 additions & 0 deletions actions/setup/js/safe_output_validator.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,85 @@ describe("safe_output_validator.cjs", () => {
expect(result.valid).toBe(false);
expect(result.error).toContain("No valid labels found");
});

it("should filter out labels matching blocked patterns", () => {
const result = validator.validateLabels(["bug", "~stale", "~archived", "enhancement"], undefined, 10, ["~*"]);

expect(result.valid).toBe(true);
expect(result.value).toHaveLength(2);
expect(result.value).toContain("bug");
expect(result.value).toContain("enhancement");
expect(result.value).not.toContain("~stale");
expect(result.value).not.toContain("~archived");
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Filtered out 2 blocked labels"));
});

it("should apply blocked filter before allowed filter", () => {
const result = validator.validateLabels(
["bug", "~stale", "enhancement", "custom"],
["bug", "~stale", "enhancement"], // ~stale is in allowed list
10,
["~*"] // but blocked by pattern
);

expect(result.valid).toBe(true);
expect(result.value).toHaveLength(2);
expect(result.value).toContain("bug");
expect(result.value).toContain("enhancement");
expect(result.value).not.toContain("~stale"); // blocked despite being in allowed list
expect(result.value).not.toContain("custom");
});

it("should handle exact match blocking", () => {
const result = validator.validateLabels(
["bug", "stale", "enhancement"],
undefined,
10,
["stale"] // exact match, no pattern
);

expect(result.valid).toBe(true);
expect(result.value).toHaveLength(2);
expect(result.value).toContain("bug");
expect(result.value).toContain("enhancement");
expect(result.value).not.toContain("stale");
});

it("should handle empty blocked patterns", () => {
const result = validator.validateLabels(["bug", "~stale"], undefined, 10, []);

expect(result.valid).toBe(true);
expect(result.value).toHaveLength(2);
expect(result.value).toContain("bug");
expect(result.value).toContain("~stale");
});

it("should handle undefined blocked patterns", () => {
const result = validator.validateLabels(["bug", "~stale"], undefined, 10, undefined);

expect(result.valid).toBe(true);
expect(result.value).toHaveLength(2);
expect(result.value).toContain("bug");
expect(result.value).toContain("~stale");
});

it("should reject when all labels are blocked", () => {
const result = validator.validateLabels(["~stale", "~archived"], undefined, 10, ["~*"]);

expect(result.valid).toBe(false);
expect(result.error).toContain("No valid labels found");
});

it("should handle bot pattern blocking", () => {
const result = validator.validateLabels(["bug", "dependabot[bot]", "github-actions[bot]", "enhancement"], undefined, 10, ["*[bot]"]);

expect(result.valid).toBe(true);
expect(result.value).toHaveLength(2);
expect(result.value).toContain("bug");
expect(result.value).toContain("enhancement");
expect(result.value).not.toContain("dependabot[bot]");
expect(result.value).not.toContain("github-actions[bot]");
});
});

describe("validateMaxCount", () => {
Expand Down
18 changes: 18 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5232,6 +5232,15 @@
"minItems": 1,
"maxItems": 50
},
"blocked": {
"type": "array",
"description": "Optional list of blocked label patterns (supports glob patterns like '~*', '*[bot]'). Labels matching these patterns will be rejected. Applied before allowed list filtering for security.",
"items": {
"type": "string"
},
"minItems": 1,
"maxItems": 50
},
"max": {
"type": "integer",
"description": "Optional maximum number of labels to add (default: 3)",
Expand Down Expand Up @@ -5281,6 +5290,15 @@
"minItems": 1,
"maxItems": 50
},
"blocked": {
"type": "array",
"description": "Optional list of blocked label patterns (supports glob patterns like '~*', '*[bot]'). Labels matching these patterns will be rejected. Applied before allowed list filtering for security.",
"items": {
"type": "string"
},
"minItems": 1,
"maxItems": 50
Comment on lines +5293 to +5300
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

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

Same issue as add-labels: blocked.minItems is 1, so blocked: [] is schema-invalid even though the PR description/runtime treat empty as “no restrictions”. Consider minItems: 0 (or update the documented behavior to require omitting the field).

This issue also appears on line 5235 of the same file.

Copilot uses AI. Check for mistakes.
},
"max": {
"type": "integer",
"description": "Optional maximum number of labels to remove (default: 3)",
Expand Down
4 changes: 3 additions & 1 deletion pkg/workflow/add_labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type AddLabelsConfig struct {
BaseSafeOutputConfig `yaml:",inline"`
SafeOutputTargetConfig `yaml:",inline"`
Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed labels. Labels will be created if they don't already exist in the repository. If omitted, any labels are allowed (including creating new ones).
Blocked []string `yaml:"blocked,omitempty"` // Optional list of blocked label patterns (supports glob patterns like "~*", "*[bot]"). Labels matching these patterns will be rejected.
}

// parseAddLabelsConfig handles add-labels configuration
Expand All @@ -33,7 +34,7 @@ func (c *Compiler) parseAddLabelsConfig(outputMap map[string]any) *AddLabelsConf
return &AddLabelsConfig{}
}

addLabelsLog.Printf("Parsed configuration: allowed_count=%d, target=%s", len(config.Allowed), config.Target)
addLabelsLog.Printf("Parsed configuration: allowed_count=%d, blocked_count=%d, target=%s", len(config.Allowed), len(config.Blocked), config.Target)

return &config
}
Expand All @@ -52,6 +53,7 @@ func (c *Compiler) buildAddLabelsJob(data *WorkflowData, mainJobName string) (*J
listJobConfig := ListJobConfig{
SafeOutputTargetConfig: cfg.SafeOutputTargetConfig,
Allowed: cfg.Allowed,
Blocked: cfg.Blocked,
}

// Use shared builder for list-based safe-output jobs
Expand Down
Loading
Loading