From 8963eee5510c65a6852456a96782fb153bec0af2 Mon Sep 17 00:00:00 2001 From: Nicolas Mazzarello Date: Wed, 4 Nov 2026 14:34:24 -0300 Subject: [PATCH 1/4] feat(code_scanning): manage CodeQL default setup Add a code_scanning plugin that reconciles a repository's CodeQL default setup with code_scanning.default_setup in settings, suborg or repo files. Only the declared fields (state, query_suite, threat_model, languages, runner_type, runner_label) are compared and sent, languages are compared regardless of order, and with state: not-configured only the state is applied. GitHub errors such as a missing Code Security license are recorded for the repository without stopping the sync. Add the section to the settings, suborgs and repos schemas and to the disable_plugins names, and document it in the README and sample settings. --- README.md | 33 ++++- docs/sample-settings/settings.yml | 10 ++ lib/plugins/code_scanning.js | 102 +++++++++++++ lib/settings.js | 3 +- schema/dereferenced/repos.json | 148 +++++++++++++++++++ schema/dereferenced/settings.json | 154 +++++++++++++++++++- schema/dereferenced/suborgs.json | 148 +++++++++++++++++++ schema/repos.json | 14 ++ schema/settings.json | 20 ++- schema/suborgs.json | 14 ++ test/unit/lib/plugins/code_scanning.test.js | 148 +++++++++++++++++++ test/unit/lib/settings.test.js | 14 ++ test/unit/schema.test.js | 15 ++ 13 files changed, 817 insertions(+), 6 deletions(-) create mode 100644 lib/plugins/code_scanning.js create mode 100644 test/unit/lib/plugins/code_scanning.test.js diff --git a/README.md b/README.md index 16d09c3d5..a085f777e 100644 --- a/README.md +++ b/README.md @@ -585,7 +585,7 @@ plugins for a given scope. Each entry is either: Valid plugin names: `repository`, `labels`, `collaborators`, `teams`, `milestones`, `branches`, `autolinks`, `validator`, `rulesets`, `environments`, `custom_properties`, `custom_repository_roles`, `variables`, `archive`, -`app_installations`. +`app_installations`, `code_scanning`. #### Strip matrix (which source layers are removed before merge) @@ -848,6 +848,37 @@ still selects them. and [`additive_plugins`](#additive-plugins-additive_plugins). In additive mode the plugin only **adds** repos to installations and never removes them. +### Code scanning default setup (`code_scanning`) + +The `code_scanning` plugin manages a repository's +[CodeQL default setup](https://docs.github.com/en/code-security/code-scanning/enabling-code-scanning/configuring-default-setup-for-code-scanning). +Like other repository settings, it can be declared in `settings.yml`, a suborg +file or a repo file, and later layers override earlier ones. + +```yml +code_scanning: + default_setup: + state: configured # or not-configured + query_suite: extended # default | extended + threat_model: remote # remote | remote_and_local + languages: # omit to let GitHub analyze the languages it detects + - csharp + - actions + # runner_type: labeled # standard | labeled + # runner_label: code-scanning +``` + +- Only the fields you declare are compared and sent; the others are left to + GitHub. The order of `languages` does not matter. +- With `state: not-configured`, only the state is compared and applied. +- Private and internal repositories need GitHub Code Security (or GitHub + Advanced Security). Without it GitHub rejects the update, and safe-settings + records the error for that repository and carries on with the others. + +The plugin can be turned off with +[`disable_plugins`](#disabling-plugins-disable_plugins). It is not an additive +plugin. + ### The Settings Files The settings files can be used to set the policies at the `org`, `suborg` or `repo` level. diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml index 8bc80967e..fd09f782f 100644 --- a/docs/sample-settings/settings.yml +++ b/docs/sample-settings/settings.yml @@ -238,6 +238,16 @@ autolinks: - key_prefix: "MYLINK-" url_template: "https://mywebsite.com/" +# CodeQL default setup +# See https://docs.github.com/en/rest/code-scanning/code-scanning#update-a-code-scanning-default-setup-configuration for available options +code_scanning: + default_setup: + state: configured + query_suite: default + languages: + - javascript-typescript + - actions + # Repository name validation validator: #pattern: '[a-zA-Z0-9_-]+_[a-zA-Z0-9_-]+.*' diff --git a/lib/plugins/code_scanning.js b/lib/plugins/code_scanning.js new file mode 100644 index 000000000..8499cc21b --- /dev/null +++ b/lib/plugins/code_scanning.js @@ -0,0 +1,102 @@ +const ErrorStash = require('./errorStash') +const NopCommand = require('../nopcommand') + +const DEFAULT_SETUP_FIELDS = ['state', 'query_suite', 'threat_model', 'languages', 'runner_type', 'runner_label'] + +module.exports = class CodeScanning extends ErrorStash { + constructor (nop, github, repo, settings, log, errors) { + super(errors) + this.github = github + this.repo = repo + this.settings = settings + this.log = log + this.nop = nop + } + + /** + * Reconcile the repository's CodeQL default setup with `code_scanning.default_setup`. + * + * @see {@link https://docs.github.com/en/rest/code-scanning/code-scanning#update-a-code-scanning-default-setup-configuration} + */ + async sync () { + this.hasChanges = false + const desired = this.settings?.default_setup + if (!desired) { + return [] + } + + let current + try { + const { data } = await this.github.rest.codeScanning.getDefaultSetup({ owner: this.repo.owner, repo: this.repo.repo }) + current = data + } catch (e) { + return this.handleError('Error getting code scanning default setup', e) + } + + const modifications = this.changes(current, desired) + if (Object.keys(modifications).length === 0) { + this.log.debug(`Code scanning default setup for ${this.repo.owner}/${this.repo.repo} already matches the config`) + return [] + } + this.hasChanges = true + + const params = { owner: this.repo.owner, repo: this.repo.repo, ...this.declaredFields(desired) } + if (this.nop) { + return [new NopCommand( + this.constructor.name, + this.repo, + this.github.rest.codeScanning.updateDefaultSetup.endpoint(params), + { msg: 'Change found', additions: {}, modifications, deletions: {} } + )] + } + + this.log.info(`Updating code scanning default setup for ${this.repo.owner}/${this.repo.repo}`) + try { + await this.github.rest.codeScanning.updateDefaultSetup(params) + } catch (e) { + return this.handleError('Error updating code scanning default setup', e) + } + return [] + } + + /** + * The declared fields that differ from GitHub. With `state: not-configured` only the state + * is compared, since GitHub keeps no other settings for a repository without default setup. + */ + changes (current, desired) { + const fields = desired.state === 'not-configured' ? ['state'] : DEFAULT_SETUP_FIELDS + const modifications = {} + for (const field of fields) { + if (desired[field] === undefined) continue + const same = field === 'languages' + ? this.sameLanguages(current[field], desired[field]) + : current[field] === desired[field] + if (!same) { + modifications[field] = desired[field] + } + } + return modifications + } + + sameLanguages (current = [], desired = []) { + const sortedCurrent = [...current].sort() + const sortedDesired = [...desired].sort() + return sortedCurrent.length === sortedDesired.length && sortedCurrent.every((lang, i) => lang === sortedDesired[i]) + } + + declaredFields (desired) { + const fields = desired.state === 'not-configured' ? ['state'] : DEFAULT_SETUP_FIELDS + return Object.fromEntries(fields.filter(field => desired[field] !== undefined).map(field => [field, desired[field]])) + } + + handleError (msg, e) { + const message = `${msg} for ${this.repo.owner}/${this.repo.repo}: ${e.status ? `${e.status} ` : ''}${e.message}` + if (this.nop) { + // As in Diffable: in a dry run the repo may not exist yet. + if (e.status === 404) return [] + return [new NopCommand(this.constructor.name, this.repo, null, message, 'ERROR')] + } + this.logError(message) + return [] + } +} diff --git a/lib/settings.js b/lib/settings.js index e78fc49e3..3ee8f7018 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -3306,7 +3306,8 @@ Settings.PLUGINS = { custom_properties: require('./plugins/custom_properties.js'), custom_repository_roles: require('./plugins/custom_repository_roles'), variables: require('./plugins/variables'), - app_installations: require('./plugins/appInstallations') + app_installations: require('./plugins/appInstallations'), + code_scanning: require('./plugins/code_scanning') } module.exports = Settings diff --git a/schema/dereferenced/repos.json b/schema/dereferenced/repos.json index 8176e4fd0..df8fe3131 100644 --- a/schema/dereferenced/repos.json +++ b/schema/dereferenced/repos.json @@ -2127,6 +2127,80 @@ } } } + }, + "code_scanning": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "type": "object", + "properties": { + "state": { + "description": "The desired state of code scanning default setup.", + "type": "string", + "enum": [ + "configured", + "not-configured" + ] + }, + "runner_type": { + "description": "Runner type to be used.", + "type": "string", + "enum": [ + "standard", + "labeled" + ] + }, + "runner_label": { + "description": "Runner label to be used if the runner type is labeled.", + "type": [ + "string", + "null" + ], + "examples": [ + "code-scanning" + ] + }, + "query_suite": { + "description": "CodeQL query suite to be used.", + "type": "string", + "enum": [ + "default", + "extended" + ] + }, + "threat_model": { + "description": "Threat model to be used for code scanning analysis. Use `remote` to analyze only network sources and `remote_and_local` to include local sources like filesystem access, command-line arguments, database reads, environment variable and standard input.", + "type": "string", + "enum": [ + "remote", + "remote_and_local" + ] + }, + "languages": { + "description": "CodeQL languages to be analyzed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "actions", + "c-cpp", + "csharp", + "go", + "java-kotlin", + "javascript-typescript", + "python", + "ruby", + "swift" + ] + } + } + }, + "additionalProperties": false + } + } } }, "$defs": { @@ -4110,6 +4184,80 @@ } } }, + "CodeScanningSettings": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "type": "object", + "properties": { + "state": { + "description": "The desired state of code scanning default setup.", + "type": "string", + "enum": [ + "configured", + "not-configured" + ] + }, + "runner_type": { + "description": "Runner type to be used.", + "type": "string", + "enum": [ + "standard", + "labeled" + ] + }, + "runner_label": { + "description": "Runner label to be used if the runner type is labeled.", + "type": [ + "string", + "null" + ], + "examples": [ + "code-scanning" + ] + }, + "query_suite": { + "description": "CodeQL query suite to be used.", + "type": "string", + "enum": [ + "default", + "extended" + ] + }, + "threat_model": { + "description": "Threat model to be used for code scanning analysis. Use `remote` to analyze only network sources and `remote_and_local` to include local sources like filesystem access, command-line arguments, database reads, environment variable and standard input.", + "type": "string", + "enum": [ + "remote", + "remote_and_local" + ] + }, + "languages": { + "description": "CodeQL languages to be analyzed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "actions", + "c-cpp", + "csharp", + "go", + "java-kotlin", + "javascript-typescript", + "python", + "ruby", + "swift" + ] + } + } + }, + "additionalProperties": false + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index 01d8fcd87..2aea0da8f 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -2164,6 +2164,80 @@ } } } + }, + "code_scanning": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "type": "object", + "properties": { + "state": { + "description": "The desired state of code scanning default setup.", + "type": "string", + "enum": [ + "configured", + "not-configured" + ] + }, + "runner_type": { + "description": "Runner type to be used.", + "type": "string", + "enum": [ + "standard", + "labeled" + ] + }, + "runner_label": { + "description": "Runner label to be used if the runner type is labeled.", + "type": [ + "string", + "null" + ], + "examples": [ + "code-scanning" + ] + }, + "query_suite": { + "description": "CodeQL query suite to be used.", + "type": "string", + "enum": [ + "default", + "extended" + ] + }, + "threat_model": { + "description": "Threat model to be used for code scanning analysis. Use `remote` to analyze only network sources and `remote_and_local` to include local sources like filesystem access, command-line arguments, database reads, environment variable and standard input.", + "type": "string", + "enum": [ + "remote", + "remote_and_local" + ] + }, + "languages": { + "description": "CodeQL languages to be analyzed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "actions", + "c-cpp", + "csharp", + "go", + "java-kotlin", + "javascript-typescript", + "python", + "ruby", + "swift" + ] + } + } + }, + "additionalProperties": false + } + } } }, "$defs": { @@ -4175,6 +4249,80 @@ } } }, + "CodeScanningSettings": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "type": "object", + "properties": { + "state": { + "description": "The desired state of code scanning default setup.", + "type": "string", + "enum": [ + "configured", + "not-configured" + ] + }, + "runner_type": { + "description": "Runner type to be used.", + "type": "string", + "enum": [ + "standard", + "labeled" + ] + }, + "runner_label": { + "description": "Runner label to be used if the runner type is labeled.", + "type": [ + "string", + "null" + ], + "examples": [ + "code-scanning" + ] + }, + "query_suite": { + "description": "CodeQL query suite to be used.", + "type": "string", + "enum": [ + "default", + "extended" + ] + }, + "threat_model": { + "description": "Threat model to be used for code scanning analysis. Use `remote` to analyze only network sources and `remote_and_local` to include local sources like filesystem access, command-line arguments, database reads, environment variable and standard input.", + "type": "string", + "enum": [ + "remote", + "remote_and_local" + ] + }, + "languages": { + "description": "CodeQL languages to be analyzed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "actions", + "c-cpp", + "csharp", + "go", + "java-kotlin", + "javascript-typescript", + "python", + "ruby", + "swift" + ] + } + } + }, + "additionalProperties": false + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", @@ -4303,7 +4451,8 @@ "custom_repository_roles", "variables", "archive", - "app_installations" + "app_installations", + "code_scanning" ] }, { @@ -4330,7 +4479,8 @@ "custom_repository_roles", "variables", "archive", - "app_installations" + "app_installations", + "code_scanning" ] }, "target": { diff --git a/schema/dereferenced/suborgs.json b/schema/dereferenced/suborgs.json index 5c80f2d6f..31bcd717e 100644 --- a/schema/dereferenced/suborgs.json +++ b/schema/dereferenced/suborgs.json @@ -3371,6 +3371,80 @@ } } } + }, + "code_scanning": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "type": "object", + "properties": { + "state": { + "description": "The desired state of code scanning default setup.", + "type": "string", + "enum": [ + "configured", + "not-configured" + ] + }, + "runner_type": { + "description": "Runner type to be used.", + "type": "string", + "enum": [ + "standard", + "labeled" + ] + }, + "runner_label": { + "description": "Runner label to be used if the runner type is labeled.", + "type": [ + "string", + "null" + ], + "examples": [ + "code-scanning" + ] + }, + "query_suite": { + "description": "CodeQL query suite to be used.", + "type": "string", + "enum": [ + "default", + "extended" + ] + }, + "threat_model": { + "description": "Threat model to be used for code scanning analysis. Use `remote` to analyze only network sources and `remote_and_local` to include local sources like filesystem access, command-line arguments, database reads, environment variable and standard input.", + "type": "string", + "enum": [ + "remote", + "remote_and_local" + ] + }, + "languages": { + "description": "CodeQL languages to be analyzed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "actions", + "c-cpp", + "csharp", + "go", + "java-kotlin", + "javascript-typescript", + "python", + "ruby", + "swift" + ] + } + } + }, + "additionalProperties": false + } + } } }, "if": { @@ -8926,6 +9000,80 @@ } } }, + "CodeScanningSettings": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "type": "object", + "properties": { + "state": { + "description": "The desired state of code scanning default setup.", + "type": "string", + "enum": [ + "configured", + "not-configured" + ] + }, + "runner_type": { + "description": "Runner type to be used.", + "type": "string", + "enum": [ + "standard", + "labeled" + ] + }, + "runner_label": { + "description": "Runner label to be used if the runner type is labeled.", + "type": [ + "string", + "null" + ], + "examples": [ + "code-scanning" + ] + }, + "query_suite": { + "description": "CodeQL query suite to be used.", + "type": "string", + "enum": [ + "default", + "extended" + ] + }, + "threat_model": { + "description": "Threat model to be used for code scanning analysis. Use `remote` to analyze only network sources and `remote_and_local` to include local sources like filesystem access, command-line arguments, database reads, environment variable and standard input.", + "type": "string", + "enum": [ + "remote", + "remote_and_local" + ] + }, + "languages": { + "description": "CodeQL languages to be analyzed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "actions", + "c-cpp", + "csharp", + "go", + "java-kotlin", + "javascript-typescript", + "python", + "ruby", + "swift" + ] + } + } + }, + "additionalProperties": false + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", diff --git a/schema/repos.json b/schema/repos.json index 9e6eec463..6980384fc 100644 --- a/schema/repos.json +++ b/schema/repos.json @@ -100,6 +100,9 @@ "items": { "$ref": "#/$defs/VariablesSettings" } + }, + "code_scanning": { + "$ref": "#/$defs/CodeScanningSettings" } }, "$defs": { @@ -405,6 +408,17 @@ } } }, + "CodeScanningSettings": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1code-scanning~1default-setup/patch/requestBody/content/application~1json/schema" + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", diff --git a/schema/settings.json b/schema/settings.json index 558cdb22f..5978778dc 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -106,6 +106,9 @@ "items": { "$ref": "#/$defs/VariablesSettings" } + }, + "code_scanning": { + "$ref": "#/$defs/CodeScanningSettings" } }, "$defs": { @@ -394,6 +397,17 @@ } } }, + "CodeScanningSettings": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1code-scanning~1default-setup/patch/requestBody/content/application~1json/schema" + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", @@ -515,7 +529,8 @@ "custom_repository_roles", "variables", "archive", - "app_installations" + "app_installations", + "code_scanning" ] }, { @@ -542,7 +557,8 @@ "custom_repository_roles", "variables", "archive", - "app_installations" + "app_installations", + "code_scanning" ] }, "target": { diff --git a/schema/suborgs.json b/schema/suborgs.json index 15c75e4de..8051e0f41 100644 --- a/schema/suborgs.json +++ b/schema/suborgs.json @@ -140,6 +140,9 @@ "items": { "$ref": "#/$defs/VariablesSettings" } + }, + "code_scanning": { + "$ref": "#/$defs/CodeScanningSettings" } }, "if": { @@ -463,6 +466,17 @@ } } }, + "CodeScanningSettings": { + "description": "Code scanning settings", + "type": "object", + "additionalProperties": false, + "properties": { + "default_setup": { + "description": "CodeQL default setup. Only the declared fields are compared and updated; with `state: not-configured`, only the state is applied.", + "$ref": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.2026-03-10.json#/paths/~1repos~1{owner}~1{repo}~1code-scanning~1default-setup/patch/requestBody/content/application~1json/schema" + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", diff --git a/test/unit/lib/plugins/code_scanning.test.js b/test/unit/lib/plugins/code_scanning.test.js new file mode 100644 index 000000000..7abec953c --- /dev/null +++ b/test/unit/lib/plugins/code_scanning.test.js @@ -0,0 +1,148 @@ +const CodeScanning = require('../../../../lib/plugins/code_scanning') + +describe('CodeScanning', () => { + const repo = { owner: 'owner', repo: 'repo' } + let github + let log + let errors + + function configure (config, nop = false) { + return new CodeScanning(nop, github, repo, config, log, errors) + } + + function currentSetup (data) { + github.rest.codeScanning.getDefaultSetup.mockResolvedValueOnce({ data }) + } + + beforeEach(() => { + log = { debug: jest.fn(), info: jest.fn(), error: jest.fn() } + errors = [] + const updateDefaultSetup = jest.fn().mockResolvedValue({ status: 202, data: {} }) + updateDefaultSetup.endpoint = jest.fn(params => ({ url: 'https://api.github.com/repos/owner/repo/code-scanning/default-setup', body: params })) + github = { + rest: { + codeScanning: { + getDefaultSetup: jest.fn(), + updateDefaultSetup + } + } + } + }) + + describe('sync', () => { + it('does nothing without a default_setup config', async () => { + const results = await configure({}).sync() + + expect(results).toEqual([]) + expect(github.rest.codeScanning.getDefaultSetup).not.toHaveBeenCalled() + }) + + it('enables default setup with the declared fields', async () => { + currentSetup({ state: 'not-configured', languages: [], query_suite: 'default' }) + const plugin = configure({ default_setup: { state: 'configured', query_suite: 'extended', languages: ['csharp', 'actions'] } }) + + await plugin.sync() + + expect(github.rest.codeScanning.updateDefaultSetup).toHaveBeenCalledWith({ + ...repo, + state: 'configured', + query_suite: 'extended', + languages: ['csharp', 'actions'] + }) + expect(plugin.hasChanges).toBe(true) + }) + + it('does not update when the declared fields match, whatever the language order', async () => { + currentSetup({ state: 'configured', languages: ['actions', 'csharp'], query_suite: 'default', threat_model: 'remote', schedule: 'weekly' }) + const plugin = configure({ default_setup: { state: 'configured', languages: ['csharp', 'actions'] } }) + + await plugin.sync() + + expect(github.rest.codeScanning.updateDefaultSetup).not.toHaveBeenCalled() + expect(plugin.hasChanges).toBe(false) + }) + + it('updates when a declared field differs', async () => { + currentSetup({ state: 'configured', languages: ['csharp'], query_suite: 'default', threat_model: 'remote' }) + + await configure({ default_setup: { state: 'configured', threat_model: 'remote_and_local' } }).sync() + + expect(github.rest.codeScanning.updateDefaultSetup).toHaveBeenCalledWith({ + ...repo, + state: 'configured', + threat_model: 'remote_and_local' + }) + }) + + it('updates when the configured languages differ', async () => { + currentSetup({ state: 'configured', languages: ['csharp'], query_suite: 'default' }) + + await configure({ default_setup: { state: 'configured', languages: ['csharp', 'actions'] } }).sync() + + expect(github.rest.codeScanning.updateDefaultSetup).toHaveBeenCalledWith({ + ...repo, + state: 'configured', + languages: ['csharp', 'actions'] + }) + }) + + it('disables default setup and sends only the state', async () => { + currentSetup({ state: 'configured', languages: ['csharp'], query_suite: 'default' }) + + await configure({ default_setup: { state: 'not-configured', query_suite: 'extended' } }).sync() + + expect(github.rest.codeScanning.updateDefaultSetup).toHaveBeenCalledWith({ ...repo, state: 'not-configured' }) + }) + + it('ignores the other fields when default setup is already disabled', async () => { + currentSetup({ state: 'not-configured', languages: [], query_suite: 'default' }) + + await configure({ default_setup: { state: 'not-configured', query_suite: 'extended' } }).sync() + + expect(github.rest.codeScanning.updateDefaultSetup).not.toHaveBeenCalled() + }) + + it('records an error when the update is rejected', async () => { + currentSetup({ state: 'not-configured', languages: [] }) + github.rest.codeScanning.updateDefaultSetup.mockRejectedValueOnce({ status: 403, message: 'Code Security must be enabled for this repository to use code scanning.' }) + + const results = await configure({ default_setup: { state: 'configured' } }).sync() + + expect(results).toEqual([]) + expect(errors).toHaveLength(1) + expect(errors[0].msg).toContain('403 Code Security must be enabled') + }) + }) + + describe('nop', () => { + it('reports the change without updating', async () => { + currentSetup({ state: 'not-configured', languages: [] }) + + const results = await configure({ default_setup: { state: 'configured', languages: ['csharp'] } }, true).sync() + + expect(github.rest.codeScanning.updateDefaultSetup).not.toHaveBeenCalled() + expect(results).toHaveLength(1) + expect(results[0].type).toBe('INFO') + expect(results[0].action.modifications).toEqual({ state: 'configured', languages: ['csharp'] }) + expect(results[0].body).toEqual({ ...repo, state: 'configured', languages: ['csharp'] }) + }) + + it('reports errors as ERROR results', async () => { + github.rest.codeScanning.getDefaultSetup.mockRejectedValueOnce({ status: 403, message: 'Forbidden' }) + + const results = await configure({ default_setup: { state: 'configured' } }, true).sync() + + expect(results).toHaveLength(1) + expect(results[0].type).toBe('ERROR') + expect(errors).toHaveLength(0) + }) + + it('ignores a missing repository', async () => { + github.rest.codeScanning.getDefaultSetup.mockRejectedValueOnce({ status: 404, message: 'Not Found' }) + + const results = await configure({ default_setup: { state: 'configured' } }, true).sync() + + expect(results).toEqual([]) + }) + }) +}) diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js index edb99a11a..f700b072b 100644 --- a/test/unit/lib/settings.test.js +++ b/test/unit/lib/settings.test.js @@ -1045,6 +1045,20 @@ repository: expect(bar).toContain('labels') }) + it('creates the code_scanning plugin and lets a repo override the org default_setup', () => { + const settings = createSettings({ + code_scanning: { default_setup: { state: 'configured', query_suite: 'extended' } } + }) + settings.subOrgConfigs = {} + settings.repoConfigs = { 'foo.yml': { code_scanning: { default_setup: { state: 'not-configured' } } } } + const pluginConfig = (repo) => { + const entry = settings.childPluginsList({ repo }).find(([, , section]) => section === 'code_scanning') + return entry && entry[1] + } + expect(pluginConfig('foo').default_setup).toEqual({ state: 'not-configured', query_suite: 'extended' }) + expect(pluginConfig('bar').default_setup).toEqual({ state: 'configured', query_suite: 'extended' }) + }) + it('18. org target:children for variables: org-level variables still run per-repo (documented nuance)', () => { // target:children strips from suborg+repo only; merged repo plugin // config still inherits the org-level variables → plugin DOES run. diff --git a/test/unit/schema.test.js b/test/unit/schema.test.js index b4f292049..7d87c571a 100644 --- a/test/unit/schema.test.js +++ b/test/unit/schema.test.js @@ -93,6 +93,21 @@ describe.each(files)('%s schema', file => { expect(validate({ rulesets: [entry] })).toBe(false) }) + it('accepts a code scanning default setup', () => { + expect(validate({ + code_scanning: { default_setup: { state: 'configured', query_suite: 'extended', threat_model: 'remote', languages: ['csharp', 'actions'] } } + })).toBe(true) + }) + + it.each([ + ['unknown state', { default_setup: { state: 'enabled' } }], + ['unknown language', { default_setup: { state: 'configured', languages: ['cobol'] } }], + ['unknown default_setup field', { default_setup: { state: 'configured', enabled: true } }], + ['unknown code_scanning field', { advanced_setup: {} }] + ])('rejects a code scanning config with %s', (_label, codeScanning) => { + expect(validate({ code_scanning: codeScanning })).toBe(false) + }) + it('accepts nullable bypass actor IDs and name/slug aliases', () => { const entry = { ...validRuleset, From 093d9e0df0460f28fe874b81068a4f19575d11de Mon Sep 17 00:00:00 2001 From: Nicolas Mazzarello Date: Wed, 4 Nov 2026 15:49:16 -0300 Subject: [PATCH 2/4] fix(code_scanning): replace inherited languages and name NOP results by section The generic deep merge concatenates arrays, so a repo's languages were appended to the org's instead of replacing them: a repo could not narrow or clear the list, and the duplicates never matched GitHub's list, so the plugin updated the repo on every sync. The highest-priority layer that declares default_setup.languages now replaces the inherited list, and languages are compared as sets. NOP results used the class name as their plugin. The dry-run drift filter lowercases it to find the config section, and 'codescanning' matched no section, so pre-existing code scanning drift was reported on unrelated settings PRs. They now use the section name, code_scanning. --- README.md | 3 ++ lib/plugins/code_scanning.js | 31 +++++++++++++++++---- lib/settings.js | 4 +++ test/unit/lib/plugins/code_scanning.test.js | 28 +++++++++++++++++++ test/unit/lib/settings.test.js | 23 +++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a085f777e..47626bb79 100644 --- a/README.md +++ b/README.md @@ -870,6 +870,9 @@ code_scanning: - Only the fields you declare are compared and sent; the others are left to GitHub. The order of `languages` does not matter. +- A `languages` list replaces the one inherited from a lower layer instead of + being merged with it, so a suborg or repo can narrow the list, or clear it + with `languages: []`. - With `state: not-configured`, only the state is compared and applied. - Private and internal repositories need GitHub Code Security (or GitHub Advanced Security). Without it GitHub rejects the update, and safe-settings diff --git a/lib/plugins/code_scanning.js b/lib/plugins/code_scanning.js index 8499cc21b..173e6ea35 100644 --- a/lib/plugins/code_scanning.js +++ b/lib/plugins/code_scanning.js @@ -1,9 +1,30 @@ const ErrorStash = require('./errorStash') const NopCommand = require('../nopcommand') +// The config section name. NOP results carry it as their plugin, since the dry-run +// drift filter looks up the section by the lowercased plugin name. +const SECTION = 'code_scanning' const DEFAULT_SETUP_FIELDS = ['state', 'query_suite', 'threat_model', 'languages', 'runner_type', 'runner_label'] module.exports = class CodeScanning extends ErrorStash { + /** + * The `default_setup.languages` of the highest-priority layer that declares them. + * The generic deep merge concatenates arrays, but a lower layer's languages should be + * replaced, so a repo can narrow or clear the list it inherits. + * + * @param {Array} sections `code_scanning` sections, lowest priority first + * @returns {string[]|undefined} + */ + static declaredLanguages (sections) { + let languages + for (const section of sections) { + if (Array.isArray(section?.default_setup?.languages)) { + languages = section.default_setup.languages + } + } + return languages + } + constructor (nop, github, repo, settings, log, errors) { super(errors) this.github = github @@ -43,7 +64,7 @@ module.exports = class CodeScanning extends ErrorStash { const params = { owner: this.repo.owner, repo: this.repo.repo, ...this.declaredFields(desired) } if (this.nop) { return [new NopCommand( - this.constructor.name, + SECTION, this.repo, this.github.rest.codeScanning.updateDefaultSetup.endpoint(params), { msg: 'Change found', additions: {}, modifications, deletions: {} } @@ -79,9 +100,9 @@ module.exports = class CodeScanning extends ErrorStash { } sameLanguages (current = [], desired = []) { - const sortedCurrent = [...current].sort() - const sortedDesired = [...desired].sort() - return sortedCurrent.length === sortedDesired.length && sortedCurrent.every((lang, i) => lang === sortedDesired[i]) + const currentSet = new Set(current) + const desiredSet = new Set(desired) + return currentSet.size === desiredSet.size && [...desiredSet].every(lang => currentSet.has(lang)) } declaredFields (desired) { @@ -94,7 +115,7 @@ module.exports = class CodeScanning extends ErrorStash { if (this.nop) { // As in Diffable: in a dry run the repo may not exist yet. if (e.status === 404) return [] - return [new NopCommand(this.constructor.name, this.repo, null, message, 'ERROR')] + return [new NopCommand(SECTION, this.repo, null, message, 'ERROR')] } this.logError(message) return [] diff --git a/lib/settings.js b/lib/settings.js index 3ee8f7018..7ae5be8ad 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -2544,8 +2544,12 @@ class Settings { if (propertyConfig !== undefined) { for (const source of [sources.org, sources.suborg, sources.repo]) delete source.custom_properties } + const codeScanningLanguages = Settings.PLUGINS.code_scanning.declaredLanguages( + [sources.org.code_scanning, sources.suborg.code_scanning, sources.repo.code_scanning] + ) const overrideConfig = this.mergeDeep.mergeDeep({}, sources.org, sources.suborg, sources.repo) if (propertyConfig !== undefined) overrideConfig.custom_properties = propertyConfig + if (codeScanningLanguages !== undefined) overrideConfig.code_scanning.default_setup.languages = codeScanningLanguages // When processing repos removed from suborg targeting, inject empty arrays // for plugin sections that were previously provided by the suborg. This diff --git a/test/unit/lib/plugins/code_scanning.test.js b/test/unit/lib/plugins/code_scanning.test.js index 7abec953c..9b331bc64 100644 --- a/test/unit/lib/plugins/code_scanning.test.js +++ b/test/unit/lib/plugins/code_scanning.test.js @@ -74,6 +74,14 @@ describe('CodeScanning', () => { }) }) + it('ignores duplicate languages when comparing', async () => { + currentSetup({ state: 'configured', languages: ['actions', 'csharp'] }) + + await configure({ default_setup: { state: 'configured', languages: ['csharp', 'actions', 'csharp'] } }).sync() + + expect(github.rest.codeScanning.updateDefaultSetup).not.toHaveBeenCalled() + }) + it('updates when the configured languages differ', async () => { currentSetup({ state: 'configured', languages: ['csharp'], query_suite: 'default' }) @@ -114,6 +122,24 @@ describe('CodeScanning', () => { }) }) + describe('declaredLanguages', () => { + it('returns the languages of the highest-priority layer that declares them', () => { + expect(CodeScanning.declaredLanguages([ + { default_setup: { languages: ['csharp', 'actions'] } }, + { default_setup: { state: 'configured' } }, + { default_setup: { languages: ['csharp'] } } + ])).toEqual(['csharp']) + }) + + it('keeps an empty list so a layer can clear the inherited languages', () => { + expect(CodeScanning.declaredLanguages([{ default_setup: { languages: ['csharp'] } }, undefined, { default_setup: { languages: [] } }])).toEqual([]) + }) + + it('returns undefined when no layer declares languages', () => { + expect(CodeScanning.declaredLanguages([undefined, {}, { default_setup: { state: 'configured' } }])).toBeUndefined() + }) + }) + describe('nop', () => { it('reports the change without updating', async () => { currentSetup({ state: 'not-configured', languages: [] }) @@ -122,6 +148,7 @@ describe('CodeScanning', () => { expect(github.rest.codeScanning.updateDefaultSetup).not.toHaveBeenCalled() expect(results).toHaveLength(1) + expect(results[0].plugin).toBe('code_scanning') expect(results[0].type).toBe('INFO') expect(results[0].action.modifications).toEqual({ state: 'configured', languages: ['csharp'] }) expect(results[0].body).toEqual({ ...repo, state: 'configured', languages: ['csharp'] }) @@ -133,6 +160,7 @@ describe('CodeScanning', () => { const results = await configure({ default_setup: { state: 'configured' } }, true).sync() expect(results).toHaveLength(1) + expect(results[0].plugin).toBe('code_scanning') expect(results[0].type).toBe('ERROR') expect(errors).toHaveLength(0) }) diff --git a/test/unit/lib/settings.test.js b/test/unit/lib/settings.test.js index f700b072b..5117bebf1 100644 --- a/test/unit/lib/settings.test.js +++ b/test/unit/lib/settings.test.js @@ -1059,6 +1059,29 @@ repository: expect(pluginConfig('bar').default_setup).toEqual({ state: 'configured', query_suite: 'extended' }) }) + it('replaces inherited code_scanning languages instead of concatenating them', () => { + const settings = createSettings({ + code_scanning: { default_setup: { state: 'configured', languages: ['csharp', 'actions'] } } + }) + settings.subOrgConfigs = { + 'team-repo': { + code_scanning: { default_setup: { languages: ['actions', 'python'] } }, + source: '.github/suborgs/team.yml' + } + } + settings.repoConfigs = { + 'narrow.yml': { code_scanning: { default_setup: { languages: ['csharp'] } } }, + 'clear.yml': { code_scanning: { default_setup: { languages: [] } } }, + 'team-repo.yml': { code_scanning: { default_setup: { query_suite: 'extended' } } } + } + const defaultSetup = (repo) => settings.childPluginsList({ repo }) + .find(([, , section]) => section === 'code_scanning')[1].default_setup + expect(defaultSetup('narrow')).toEqual({ state: 'configured', languages: ['csharp'] }) + expect(defaultSetup('clear')).toEqual({ state: 'configured', languages: [] }) + expect(defaultSetup('other')).toEqual({ state: 'configured', languages: ['csharp', 'actions'] }) + expect(defaultSetup('team-repo')).toEqual({ state: 'configured', query_suite: 'extended', languages: ['actions', 'python'] }) + }) + it('18. org target:children for variables: org-level variables still run per-repo (documented nuance)', () => { // target:children strips from suborg+repo only; merged repo plugin // config still inherits the org-level variables → plugin DOES run. From 91261afa993ab5d04f9ee4ca38abe07607072ec4 Mon Sep 17 00:00:00 2001 From: Nicolas Mazzarello Date: Wed, 4 Nov 2026 16:10:17 -0300 Subject: [PATCH 3/4] fix(code_scanning): report cleared fields as deletions Clearing languages to [] or runner_label to null was reported as a modification holding the empty value. Settings.isEmptyChange() treats that as no change, so the PR and check-run report left it out even though a real sync sends the update. Cleared fields are now deletions that hold GitHub's previous value. --- lib/plugins/code_scanning.js | 17 ++++++++++++----- test/unit/lib/plugins/code_scanning.test.js | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/plugins/code_scanning.js b/lib/plugins/code_scanning.js index 173e6ea35..bb72708eb 100644 --- a/lib/plugins/code_scanning.js +++ b/lib/plugins/code_scanning.js @@ -54,8 +54,8 @@ module.exports = class CodeScanning extends ErrorStash { return this.handleError('Error getting code scanning default setup', e) } - const modifications = this.changes(current, desired) - if (Object.keys(modifications).length === 0) { + const { modifications, deletions } = this.changes(current, desired) + if (Object.keys(modifications).length === 0 && Object.keys(deletions).length === 0) { this.log.debug(`Code scanning default setup for ${this.repo.owner}/${this.repo.repo} already matches the config`) return [] } @@ -67,7 +67,7 @@ module.exports = class CodeScanning extends ErrorStash { SECTION, this.repo, this.github.rest.codeScanning.updateDefaultSetup.endpoint(params), - { msg: 'Change found', additions: {}, modifications, deletions: {} } + { msg: 'Change found', additions: {}, modifications, deletions } )] } @@ -83,20 +83,27 @@ module.exports = class CodeScanning extends ErrorStash { /** * The declared fields that differ from GitHub. With `state: not-configured` only the state * is compared, since GitHub keeps no other settings for a repository without default setup. + * A field cleared to `[]` or `null` is a deletion holding GitHub's value: as a modification + * its empty value would make the NOP report treat the change as empty and drop it. */ changes (current, desired) { const fields = desired.state === 'not-configured' ? ['state'] : DEFAULT_SETUP_FIELDS const modifications = {} + const deletions = {} for (const field of fields) { if (desired[field] === undefined) continue const same = field === 'languages' ? this.sameLanguages(current[field], desired[field]) : current[field] === desired[field] - if (!same) { + if (same) continue + const cleared = desired[field] === null || (Array.isArray(desired[field]) && desired[field].length === 0) + if (cleared) { + deletions[field] = current[field] + } else { modifications[field] = desired[field] } } - return modifications + return { modifications, deletions } } sameLanguages (current = [], desired = []) { diff --git a/test/unit/lib/plugins/code_scanning.test.js b/test/unit/lib/plugins/code_scanning.test.js index 9b331bc64..a085b5860 100644 --- a/test/unit/lib/plugins/code_scanning.test.js +++ b/test/unit/lib/plugins/code_scanning.test.js @@ -1,4 +1,5 @@ const CodeScanning = require('../../../../lib/plugins/code_scanning') +const { isEmptyChange } = require('../../../../lib/settings') describe('CodeScanning', () => { const repo = { owner: 'owner', repo: 'repo' } @@ -154,6 +155,21 @@ describe('CodeScanning', () => { expect(results[0].body).toEqual({ ...repo, state: 'configured', languages: ['csharp'] }) }) + it.each([ + ['languages', { languages: ['csharp'] }, { languages: [] }, ['csharp']], + ['runner_label', { runner_type: 'labeled', runner_label: 'code-scanning' }, { runner_label: null }, 'code-scanning'] + ])('reports clearing %s as a deletion the report keeps', async (field, current, cleared, previous) => { + currentSetup({ state: 'configured', ...current }) + + const results = await configure({ default_setup: { state: 'configured', ...cleared } }, true).sync() + + expect(results).toHaveLength(1) + expect(results[0].action.modifications).toEqual({}) + expect(results[0].action.deletions).toEqual({ [field]: previous }) + expect(results[0].body).toEqual({ ...repo, state: 'configured', ...cleared }) + expect(isEmptyChange(results[0].action)).toBe(false) + }) + it('reports errors as ERROR results', async () => { github.rest.codeScanning.getDefaultSetup.mockRejectedValueOnce({ status: 403, message: 'Forbidden' }) From dcbd5bdf363a6a1a6c3034a1edbc849051d1a692 Mon Sep 17 00:00:00 2001 From: Nicolas Mazzarello Date: Wed, 4 Nov 2026 16:34:53 -0300 Subject: [PATCH 4/4] fix(code_scanning): keep before values for modified fields in NOP reports The NOP report reads deletions as before values and modifications as after values, as the Repository plugin reports them. Only cleared fields kept GitHub's value in deletions, so an ordinary change such as query_suite default -> extended rendered with an empty before value. Every changed field now keeps GitHub's value in deletions. --- lib/plugins/code_scanning.js | 12 ++++++----- test/unit/lib/plugins/code_scanning.test.js | 1 + test/unit/lib/settings-results.test.js | 23 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/plugins/code_scanning.js b/lib/plugins/code_scanning.js index bb72708eb..652cc6a51 100644 --- a/lib/plugins/code_scanning.js +++ b/lib/plugins/code_scanning.js @@ -83,8 +83,11 @@ module.exports = class CodeScanning extends ErrorStash { /** * The declared fields that differ from GitHub. With `state: not-configured` only the state * is compared, since GitHub keeps no other settings for a repository without default setup. - * A field cleared to `[]` or `null` is a deletion holding GitHub's value: as a modification - * its empty value would make the NOP report treat the change as empty and drop it. + * + * As in the Repository plugin, the NOP report reads `deletions` as the before values and + * `modifications` as the after values. Every changed field keeps GitHub's value in + * `deletions`. A field cleared to `[]` or `null` stays out of `modifications`, where the + * report would treat its empty value as no change, and so shows as deleted. */ changes (current, desired) { const fields = desired.state === 'not-configured' ? ['state'] : DEFAULT_SETUP_FIELDS @@ -96,10 +99,9 @@ module.exports = class CodeScanning extends ErrorStash { ? this.sameLanguages(current[field], desired[field]) : current[field] === desired[field] if (same) continue + deletions[field] = current[field] const cleared = desired[field] === null || (Array.isArray(desired[field]) && desired[field].length === 0) - if (cleared) { - deletions[field] = current[field] - } else { + if (!cleared) { modifications[field] = desired[field] } } diff --git a/test/unit/lib/plugins/code_scanning.test.js b/test/unit/lib/plugins/code_scanning.test.js index a085b5860..3bf5e15d0 100644 --- a/test/unit/lib/plugins/code_scanning.test.js +++ b/test/unit/lib/plugins/code_scanning.test.js @@ -152,6 +152,7 @@ describe('CodeScanning', () => { expect(results[0].plugin).toBe('code_scanning') expect(results[0].type).toBe('INFO') expect(results[0].action.modifications).toEqual({ state: 'configured', languages: ['csharp'] }) + expect(results[0].action.deletions).toEqual({ state: 'not-configured', languages: [] }) expect(results[0].body).toEqual({ ...repo, state: 'configured', languages: ['csharp'] }) }) diff --git a/test/unit/lib/settings-results.test.js b/test/unit/lib/settings-results.test.js index 017f86d95..cecdde01a 100644 --- a/test/unit/lib/settings-results.test.js +++ b/test/unit/lib/settings-results.test.js @@ -1,6 +1,7 @@ const Settings = require('../../../lib/settings') const Branches = require('../../../lib/plugins/branches') const Rulesets = require('../../../lib/plugins/rulesets') +const CodeScanning = require('../../../lib/plugins/code_scanning') const NopCommand = require('../../../lib/nopcommand') const env = require('../../../lib/env') @@ -496,6 +497,28 @@ describe('Settings result deduplication', () => { } }) + it('renders code_scanning before and after values for modified and cleared fields', async () => { + const updateDefaultSetup = jest.fn() + updateDefaultSetup.endpoint = params => ({ url: 'https://api.github.com/repos/test/test-repo/code-scanning/default-setup', body: params }) + const github = { + rest: { + codeScanning: { + getDefaultSetup: jest.fn().mockResolvedValue({ data: { state: 'configured', query_suite: 'default', languages: ['csharp', 'actions'] } }), + updateDefaultSetup + } + } + } + const plugin = new CodeScanning(true, github, repo, { default_setup: { state: 'configured', query_suite: 'extended', languages: [] } }, settings.log, []) + settings.results = await plugin.sync() + + await settings.handleResults() + + const { check, comments } = outputs() + for (const body of [check.output.summary, ...comments]) { + expect(body).toContain('**test-repo**\n- `code_scanning`\n - - `languages`: `csharp, actions`\n - ~ `query_suite`\n - before: `default`\n - after: `extended`\n\n') + } + }) + it('keeps warning and informational containers separate from change sections', async () => { settings.results = [ command(),