From f18aa5c59f3e7155e0d4aa5d48081b65a7259b23 Mon Sep 17 00:00:00 2001 From: Nate Scherer <376408+natescherer@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:08:52 -0400 Subject: [PATCH] feat(resources): manage CodeQL default setup A new `code_scanning.default_setup` section enables or disables default setup and manages its query suite, threat model, languages and runners, all through one PATCH. GitHub reports a language under every alias (`javascript-typescript` reads back as three names), so both sides fold aliases before comparing. A changed query suite, threat model or added language reads back only once the CodeQL Setup run GitHub starts has finished, which the field documentation states, along with GitHub leaving a setup enabled on a repository without code idle once code arrives. Verified against a real repository. --- README.md | 6 + docs/authentication.md | 3 +- docs/configuration.md | 37 ++ docs/roadmap.md | 1 + schema/v1/settings.json | 138 +++++++ scripts/live-sandbox.sh | 10 + src/config/merge/mod.rs | 39 ++ src/config/merge/tests.rs | 31 ++ src/config/settings.rs | 9 + src/engine/registry.rs | 8 +- src/resources/code_scanning/mod.rs | 353 ++++++++++++++++++ src/resources/code_scanning/model.rs | 266 +++++++++++++ src/resources/code_scanning/tests.rs | 270 ++++++++++++++ src/resources/mod.rs | 6 + src/schema/mod.rs | 1 + tests/export.rs | 41 ++ tests/live.rs | 20 + ...ned_token_the_admin_probe_vouches_for.snap | 1 + ...octor_renders_a_healthy_classic_token.snap | 1 + ...enders_a_token_missing_the_repo_scope.snap | 21 +- ...tor__doctor_renders_the_actions_token.snap | 17 +- ...r__doctor_renders_the_unknown_verdict.snap | 19 +- tests/sync.rs | 53 +++ 23 files changed, 1320 insertions(+), 31 deletions(-) create mode 100644 src/resources/code_scanning/mod.rs create mode 100644 src/resources/code_scanning/model.rs create mode 100644 src/resources/code_scanning/tests.rs diff --git a/README.md b/README.md index d62f439..4020918 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,11 @@ pages: path: / cname: docs.example.com https_enforced: true + +code_scanning: + default_setup: + enabled: true + query_suite: extended ``` Every section is **optional**, and an absent section is **unmanaged** — nothing @@ -386,6 +391,7 @@ rather than a guess. | Actions variables, at repository and environment scope | ✅ | | Actions general settings (permissions, retention, fork PR approval) | ✅ | | GitHub Pages (source, custom domain, HTTPS) | ✅ | +| Code scanning (CodeQL default setup) | ✅ | | Inheritance from a shared repository (`extends:`) | ✅ | Declaring a `pages:` section enables Pages; it is never turned back off, because diff --git a/docs/authentication.md b/docs/authentication.md index 93277fb..187e0c6 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -193,11 +193,12 @@ actually enforces. | `actions` | Metadata: read, Administration: write, Actions policies: write † | `repo` | ✘ | | `variables` | Metadata: read, Actions: read, Variables: write, Environments: write | `repo` | ✘ | | `pages` | Metadata: read, Pages: write, Administration: write | `repo` | ✔ | +| `code_scanning` | Metadata: read, Administration: write | `repo` | ✘ | | `extends` | Contents: read | `repo` | ✘ | † GitHub's own reference does not settle this mapping — it is either absent or ambiguous there, so this is our best understanding and the minimal claim, not a guarantee. `gh settings doctor` will tell you what your token can actually do. -`repository`, `topics`, `autolinks`, `rulesets`, `environments` and `actions` require Administration: write, which cannot be granted to GITHUB_TOKEN — the workflow `permissions:` block has no key that grants it. Use a personal access token or a GitHub App token. +`repository`, `topics`, `autolinks`, `rulesets`, `environments`, `actions` and `code_scanning` require Administration: write, which cannot be granted to GITHUB_TOKEN — the workflow `permissions:` block has no key that grants it. Use a personal access token or a GitHub App token. `variables` requires Variables: write, which cannot be granted to GITHUB_TOKEN — the workflow `permissions:` block has no key that grants it. Use a personal access token or a GitHub App token. diff --git a/docs/configuration.md b/docs/configuration.md index eec801c..902a33b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -84,6 +84,43 @@ A single autolink reference. | `url_template` | string | yes | Target URL, containing the `` placeholder. | +## `code_scanning` + +Code scanning. + +Covers CodeQL default setup, which `default_setup.enabled` switches on +and off. An omitted section leaves code scanning as it is. + +| Field | Type | Required | Description | +|---|---|---|---| +| `default_setup` | object | no | CodeQL default setup, which analyses the repository on GitHub's own workflow without a workflow file. | + + +### `code_scanning.default_setup` + +CodeQL default setup, which analyses the repository on GitHub's own +workflow without a workflow file. + +GitHub applies a change to the query suite, threat model or languages +of a setup that is already enabled through a *CodeQL Setup* workflow +run, and reports the previous values until that run finishes. A plan +made in the meantime shows the change again. + +GitHub also accepts enabling it on a repository with no language CodeQL +can analyse, then reports no languages and does not start analysing +when one is added. Enable it once the repository has code, or turn it +off and on again after adding some. + +| Field | Type | Required | Description | +|---|---|---|---| +| `enabled` | boolean | yes | Whether default setup is enabled. | +| `languages` | list of string | no | Languages to analyse, for example `actions`, `python` or `javascript-typescript`. | +| `query_suite` | `default` \| `extended` | no | Which CodeQL queries to run. | +| `runner_label` | string | no | Label of the self-hosted runners to use, with `runner_type: labeled`. | +| `runner_type` | `standard` \| `labeled` | no | Which runners the analysis runs on. | +| `threat_model` | `remote` \| `remote_and_local` | no | Which sources of untrusted data CodeQL considers. | + + ## `environments` Deployment environments and their protection rules. diff --git a/docs/roadmap.md b/docs/roadmap.md index 26def93..c29905f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,6 +24,7 @@ eventually be manageable here.* | Actions general settings | ✅ | allowed actions, workflow permissions, artifact and log retention, fork PR approval | | Actions variables | ✅ | repository and environment scope; values are readable, so they diff | | Pages | ✅ | build type, source, custom domain and HTTPS; declaring the section enables the site, nothing ever disables it. The `public` flag is reported by the API but not settable through it, so it is not offered | +| Code scanning | ✅ | CodeQL default setup: query suite, threat model, languages and runners. A changed query suite, threat model or added language reads back only once the setup run GitHub starts has finished | | Custom properties | planned | | | Webhooks | planned | | | Collaborators & teams | planned | | diff --git a/schema/v1/settings.json b/schema/v1/settings.json index facf4c0..8d1d0b8 100644 --- a/schema/v1/settings.json +++ b/schema/v1/settings.json @@ -246,6 +246,24 @@ } ] }, + "CodeScanningSettings": { + "additionalProperties": false, + "description": "The `code_scanning` configuration section.\n\n```yaml\ncode_scanning:\n default_setup:\n enabled: true\n```", + "properties": { + "default_setup": { + "anyOf": [ + { + "$ref": "#/$defs/DefaultSetup" + }, + { + "type": "null" + } + ], + "description": "CodeQL default setup, which analyses the repository on GitHub's own\nworkflow without a workflow file.\n\nGitHub applies a change to the query suite, threat model or languages\nof a setup that is already enabled through a *CodeQL Setup* workflow\nrun, and reports the previous values until that run finishes. A plan\nmade in the meantime shows the change again.\n\nGitHub also accepts enabling it on a repository with no language CodeQL\ncan analyse, then reports no languages and does not start analysing\nwhen one is added. Enable it once the repository has code, or turn it\noff and on again after adding some." + } + }, + "type": "object" + }, "Conditions": { "additionalProperties": false, "description": "Which refs a ruleset applies to.", @@ -264,6 +282,70 @@ }, "type": "object" }, + "DefaultSetup": { + "additionalProperties": false, + "description": "CodeQL default setup.\n\n```yaml\ndefault_setup:\n enabled: true\n query_suite: extended\n languages: [actions, python]\n```", + "properties": { + "enabled": { + "description": "Whether default setup is enabled.\n\n`false` turns it off, and the other fields are then ignored.", + "type": "boolean" + }, + "languages": { + "description": "Languages to analyse, for example `actions`, `python` or\n`javascript-typescript`.\n\nOmit it to let GitHub analyse every supported language it detects.\n`javascript` and `typescript` both mean `javascript-typescript`; likewise\n`c` and `cpp` mean `c-cpp`, and `java` and `kotlin` mean `java-kotlin`.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query_suite": { + "anyOf": [ + { + "$ref": "#/$defs/QuerySuite" + }, + { + "type": "null" + } + ], + "description": "Which CodeQL queries to run." + }, + "runner_label": { + "description": "Label of the self-hosted runners to use, with `runner_type: labeled`.", + "type": [ + "string", + "null" + ] + }, + "runner_type": { + "anyOf": [ + { + "$ref": "#/$defs/RunnerType" + }, + { + "type": "null" + } + ], + "description": "Which runners the analysis runs on." + }, + "threat_model": { + "anyOf": [ + { + "$ref": "#/$defs/ThreatModel" + }, + { + "type": "null" + } + ], + "description": "Which sources of untrusted data CodeQL considers." + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "DeploymentBranchPolicy": { "anyOf": [ { @@ -748,6 +830,21 @@ ], "description": "A collection section that can opt into pruning." }, + "QuerySuite": { + "description": "Which CodeQL queries default setup runs.", + "oneOf": [ + { + "const": "default", + "description": "The default suite, tuned for a low false-positive rate.", + "type": "string" + }, + { + "const": "extended", + "description": "The default suite plus lower-severity and lower-precision queries.", + "type": "string" + } + ] + }, "RefNameCondition": { "additionalProperties": false, "description": "Ref name matching.", @@ -1056,6 +1153,21 @@ ], "type": "object" }, + "RunnerType": { + "description": "Which runners default setup's analysis runs on.", + "oneOf": [ + { + "const": "standard", + "description": "GitHub-hosted runners.", + "type": "string" + }, + { + "const": "labeled", + "description": "Runners carrying the label named by `runner_label`.", + "type": "string" + } + ] + }, "SecuritySettings": { "additionalProperties": false, "description": "Security and analysis features.\n\nThe API models these as `{ status: \"enabled\" | \"disabled\" }` objects rather\nthan booleans; the configuration exposes plain booleans and the resource\ntranslates.", @@ -1205,6 +1317,21 @@ } ] }, + "ThreatModel": { + "description": "Which sources of untrusted data CodeQL considers.", + "oneOf": [ + { + "const": "remote", + "description": "Remote sources only, such as network requests.", + "type": "string" + }, + { + "const": "remote_and_local", + "description": "Remote sources and local ones, such as files and environment variables.", + "type": "string" + } + ] + }, "Variable": { "additionalProperties": false, "description": "A single Actions variable.", @@ -1267,6 +1394,17 @@ ], "description": "Autolink references." }, + "code_scanning": { + "anyOf": [ + { + "$ref": "#/$defs/CodeScanningSettings" + }, + { + "type": "null" + } + ], + "description": "Code scanning.\n\nCovers CodeQL default setup, which `default_setup.enabled` switches on\nand off. An omitted section leaves code scanning as it is." + }, "environments": { "anyOf": [ { diff --git a/scripts/live-sandbox.sh b/scripts/live-sandbox.sh index f342c13..2ec7381 100755 --- a/scripts/live-sandbox.sh +++ b/scripts/live-sandbox.sh @@ -238,6 +238,16 @@ elif api "repos/$repo/pages" >/dev/null 2>&1; then fi fi +# Like the Actions settings below, CodeQL default setup has a value rather than +# an existence, so the pre-flight cannot see a run that left it enabled. +if [[ "$(api "repos/$repo/code-scanning/default-setup" --jq .state 2>/dev/null || true)" == configured ]]; then + if try "disabling CodeQL default setup" \ + gh api "repos/$repo/code-scanning/default-setup" --method PATCH \ + --field state=not-configured --silent; then + echo "disabled CodeQL default setup" + fi +fi + if try "clearing description and homepage" \ gh api "repos/$repo" --method PATCH --field description= --field homepage= --silent; then echo "cleared description and homepage" diff --git a/src/config/merge/mod.rs b/src/config/merge/mod.rs index d5d5350..17483c8 100644 --- a/src/config/merge/mod.rs +++ b/src/config/merge/mod.rs @@ -26,6 +26,7 @@ use super::source::SourceId; use super::spans::SpanIndex; use crate::config::Prunable; use crate::resources::actions::{ActionsSettings, ForkPrWorkflowsPrivateRepos, SelectedActions}; +use crate::resources::code_scanning::CodeScanningSettings; use crate::resources::labels::model::key as label_key; use crate::resources::pages::PagesSettings; use crate::resources::repository::{RepositorySettings, SecuritySettings}; @@ -59,6 +60,7 @@ pub fn merge(base: &Layer<'_>, child: &Layer<'_>) -> (Settings, Provenance) { let repository = merge_repository(&mut provenance, base, child); let pages = merge_pages(&mut provenance, base, child); let actions = merge_actions(&mut provenance, base, child); + let code_scanning = merge_code_scanning(&mut provenance, base, child); let topics = merge_section( &mut provenance, @@ -125,6 +127,7 @@ pub fn merge(base: &Layer<'_>, child: &Layer<'_>) -> (Settings, Provenance) { variables, pages, actions, + code_scanning, }; (settings, provenance) @@ -298,6 +301,42 @@ fn merge_pages( }) } +/// Merge the code scanning section. +/// +/// `default_setup` is taken whole rather than merged: `enabled` is not an +/// `Option`, so a child overriding only the query suite would otherwise +/// inherit an `enabled` it never mentioned. Same reasoning as `pages.source`. +fn merge_code_scanning( + provenance: &mut Provenance, + base: &Layer<'_>, + child: &Layer<'_>, +) -> Option { + let base_section = base.settings.code_scanning.as_ref(); + let child_section = child.settings.code_scanning.as_ref(); + + if base_section.is_none() && child_section.is_none() { + return None; + } + + let empty = CodeScanningSettings::default(); + let b = base_section.unwrap_or(&empty); + let c = child_section.unwrap_or(&empty); + + // Exhaustive, with no `..`, for the same reason as `merge_repository`. + let CodeScanningSettings { default_setup } = c; + + Some(CodeScanningSettings { + default_setup: pick( + provenance, + "code_scanning.default_setup", + base.id, + b.default_setup.as_ref(), + child.id, + default_setup.as_ref(), + ), + }) +} + /// Merge the security block, which must recurse rather than be taken whole. /// /// Taking the child's block outright would unmanage every setting it did not diff --git a/src/config/merge/tests.rs b/src/config/merge/tests.rs index 07a03b6..2b7681a 100644 --- a/src/config/merge/tests.rs +++ b/src/config/merge/tests.rs @@ -397,3 +397,34 @@ fn pages_are_unmanaged_when_neither_document_declares_them() { let (settings, _, _) = merged("labels: []\n", "topics: []\n"); assert!(settings.pages.is_none()); } + +// --- code scanning ---------------------------------------------------------- + +#[test] +fn an_inherited_default_setup_is_available_to_a_child_that_declares_none() { + let (settings, _, _) = merged( + "code_scanning:\n default_setup:\n enabled: true\n", + "labels: []\n", + ); + let setup = settings.code_scanning.unwrap().default_setup.unwrap(); + assert!(setup.enabled); +} + +#[test] +fn an_inherited_default_setup_is_replaced_whole_rather_than_field_by_field() { + // `enabled` is not an `Option`, so a child redeclaring the setup states + // all of it: here, a base's query suite does not survive the override. + let (settings, _, _) = merged( + "code_scanning:\n default_setup:\n enabled: true\n query_suite: extended\n", + "code_scanning:\n default_setup:\n enabled: true\n languages: [python]\n", + ); + let setup = settings.code_scanning.unwrap().default_setup.unwrap(); + assert_eq!(setup.query_suite, None); + assert_eq!(setup.languages, Some(vec!["python".to_string()])); +} + +#[test] +fn code_scanning_is_unmanaged_when_neither_document_declares_it() { + let (settings, _, _) = merged("labels: []\n", "topics: []\n"); + assert!(settings.code_scanning.is_none()); +} diff --git a/src/config/settings.rs b/src/config/settings.rs index 7a04573..f0853ef 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::resources::actions::ActionsSettings; use crate::resources::autolinks::Autolink; +use crate::resources::code_scanning::CodeScanningSettings; use crate::resources::environments::Environment; use crate::resources::labels::Label; use crate::resources::pages::PagesSettings; @@ -116,6 +117,13 @@ pub struct Settings { /// the default `GITHUB_TOKEN` permissions. #[serde(default, skip_serializing_if = "Option::is_none")] pub actions: Option, + + /// Code scanning. + /// + /// Covers CodeQL default setup, which `default_setup.enabled` switches on + /// and off. An omitted section leaves code scanning as it is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code_scanning: Option, } impl Settings { @@ -155,6 +163,7 @@ impl Settings { && self.variables.is_none() && self.pages.is_none() && self.actions.is_none() + && self.code_scanning.is_none() } /// Cross-section checks that no individual resource can perform. diff --git a/src/engine/registry.rs b/src/engine/registry.rs index 37b4142..7242a76 100644 --- a/src/engine/registry.rs +++ b/src/engine/registry.rs @@ -6,9 +6,9 @@ //! custom properties the same way. use crate::resources::{ - ErasedResource, ResourceId, actions::Actions, autolinks::Autolinks, environments::Environments, - labels::Labels, pages::Pages, repository::Repository, rulesets::Rulesets, topics::Topics, - variables::Variables, + ErasedResource, ResourceId, actions::Actions, autolinks::Autolinks, + code_scanning::CodeScanning, environments::Environments, labels::Labels, pages::Pages, + repository::Repository, rulesets::Rulesets, topics::Topics, variables::Variables, }; /// The ordered set of resources the engine orchestrates. @@ -31,6 +31,7 @@ impl Default for Registry { Box::new(Actions), Box::new(Variables), Box::new(Pages), + Box::new(CodeScanning), ]) } } @@ -155,6 +156,7 @@ mod tests { ResourceId::Actions, ResourceId::Variables, ResourceId::Pages, + ResourceId::CodeScanning, ] ); } diff --git a/src/resources/code_scanning/mod.rs b/src/resources/code_scanning/mod.rs new file mode 100644 index 0000000..ef2d217 --- /dev/null +++ b/src/resources/code_scanning/mod.rs @@ -0,0 +1,353 @@ +//! Code scanning: CodeQL default setup. +//! +//! A singleton resource, like `pages`, over one endpoint: +//! `GET`/`PATCH /repos/{o}/{r}/code-scanning/default-setup`. The `PATCH` carries +//! `state: configured` or `state: not-configured` alongside whichever settings +//! change, so enabling, updating and disabling are all one request. +//! +//! # Settings a disabled setup reports +//! +//! A repository that has never enabled default setup still answers the `GET`, +//! with the languages GitHub detected and the query suite and threat model it +//! would use. None of those describe anything that runs, so the diff compares +//! them only while the setup is enabled, and enabling sends every declared +//! setting rather than only the ones that differ. +//! +//! # Changes that land later +//! +//! A `PATCH` answers `202 Accepted` and starts a *CodeQL Setup* workflow run. +//! Turning the setup on or off, and removing a language, show up on the next +//! read; a new query suite, threat model or added language reads back only +//! once that run has finished, so a plan made in the meantime reports them +//! again. Verified against a real repository. + +use std::collections::BTreeSet; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::config::{Finding, Settings}; +use crate::github::{GitHubClient, GitHubClientExt, Request, Result as GitHubResult, Target}; +use crate::resources::{ + Change, FieldDiff, Op, PruneOpts, Requirement, Resource, ResourceId, ValidateCtx, +}; + +pub mod model; + +pub use model::{CodeScanningSettings, DefaultSetup, QuerySuite, RunnerType, ThreatModel}; + +/// The `code_scanning` resource. +#[derive(Debug, Default, Clone, Copy)] +pub struct CodeScanning; + +/// Default setup as the API reports it. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct DefaultSetupState { + /// `configured` or `not-configured`. + #[serde(default)] + pub state: Option, + /// Languages analysed, or detected while not configured. + #[serde(default)] + pub languages: Vec, + /// Query suite. + #[serde(default)] + pub query_suite: Option, + /// Threat model. + #[serde(default)] + pub threat_model: Option, + /// Runner type. + #[serde(default)] + pub runner_type: Option, + /// Runner label, `null` or `""` when unset. + #[serde(default)] + pub runner_label: Option, +} + +impl DefaultSetupState { + /// A normalised copy, safe to compare against a normalised counterpart. + pub fn normalized(mut self) -> Self { + fn text(value: Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase) + } + + self.state = text(self.state); + self.query_suite = text(self.query_suite); + self.threat_model = text(self.threat_model); + self.runner_type = text(self.runner_type); + self.runner_label = model::normalize_label(self.runner_label.as_deref()); + self.languages = model::normalize_languages(&self.languages) + .into_iter() + .collect(); + self + } + + /// Whether default setup is enabled. + pub fn is_configured(&self) -> bool { + self.state.as_deref() == Some("configured") + } + + /// The analysed languages, as a set. + pub fn language_set(&self) -> BTreeSet { + self.languages.iter().cloned().collect() + } +} + +/// Current state: `None` when the repository has no code scanning endpoint. +pub type Current = Option; + +/// Payload of a code scanning change: a `PATCH /code-scanning/default-setup` +/// body. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Payload { + /// The request body. + pub body: Value, +} + +#[async_trait] +impl Resource for CodeScanning { + type Desired = CodeScanningSettings; + type Current = Current; + + fn id(&self) -> ResourceId { + ResourceId::CodeScanning + } + + fn requirement(&self) -> &'static Requirement { + &Requirement::ADMINISTRATION + } + + fn depends_on(&self) -> &'static [ResourceId] { + // Code scanning on a private repository needs a GitHub Advanced Security + // licence, so a run that makes a repository public must do so first. + &[ResourceId::Repository] + } + + fn desired(&self, settings: &Settings) -> Option { + settings.code_scanning.clone() + } + + fn validate(&self, desired: &Self::Desired, ctx: &ValidateCtx<'_>) -> Vec { + model::validate(desired, ctx) + } + + async fn current( + &self, + client: &dyn GitHubClient, + target: &Target, + ) -> GitHubResult { + let current: Option = client + .send_optional(Request::get(target.endpoint("code-scanning/default-setup"))) + .await?; + Ok(current.map(DefaultSetupState::normalized)) + } + + fn diff( + &self, + desired: &Self::Desired, + current: &Self::Current, + // Unused on purpose: there is nothing to prune, only a setting to + // switch off, and that takes an explicit `enabled: false`. + _prune: &PruneOpts, + ) -> Vec { + let Some(setup) = &desired.default_setup else { + return Vec::new(); + }; + let empty = DefaultSetupState::default(); + let current = current.as_ref().unwrap_or(&empty); + + match (setup.enabled, current.is_configured()) { + (false, false) => Vec::new(), + (false, true) => vec![ + Change::new(ResourceId::CodeScanning, Op::Update, "default_setup") + .summary("disable CodeQL default setup") + .fields(vec![FieldDiff::changed("enabled", "true", "false")]) + .payload(Payload { + body: json!({ "state": "not-configured" }), + }), + ], + (true, false) => vec![self.enable(setup)], + (true, true) => self.update(setup, current).into_iter().collect(), + } + } + + async fn apply( + &self, + client: &dyn GitHubClient, + target: &Target, + change: &Change, + ) -> GitHubResult<()> { + let payload: Payload = change.decode().unwrap_or_else(|error| { + panic!("code scanning change carried an undecodable payload: {error}") + }); + + client + .execute(Request::patch( + target.endpoint("code-scanning/default-setup"), + payload.body, + )) + .await + } + + async fn export( + &self, + client: &dyn GitHubClient, + target: &Target, + ) -> GitHubResult> { + // A disabled setup exports nothing, as Pages does for a repository + // without a site: the settings GitHub reports for it describe nothing + // that runs, and an exported file should not switch it on or off. + let Some(current) = self.current(client, target).await? else { + return Ok(None); + }; + if !current.is_configured() { + return Ok(None); + } + + let settings = CodeScanningSettings { + default_setup: Some(DefaultSetup { + enabled: true, + query_suite: current.query_suite.as_deref().and_then(parse_enum), + threat_model: current.threat_model.as_deref().and_then(parse_enum), + languages: (!current.languages.is_empty()).then(|| current.languages.clone()), + runner_type: current.runner_type.as_deref().and_then(parse_enum), + runner_label: current.runner_label.clone(), + }), + }; + + Ok(Some(serde_json::to_value(settings).unwrap_or(Value::Null))) + } +} + +impl CodeScanning { + /// The change that enables a setup that is off. + fn enable(&self, setup: &DefaultSetup) -> Change { + let mut body = Map::new(); + body.insert("state".into(), json!("configured")); + let mut fields = vec![FieldDiff::changed("enabled", "false", "true")]; + + for (name, value) in declared(setup) { + fields.push(FieldDiff::added(name, value.label())); + body.insert(name.into(), value.body()); + } + + Change::new(ResourceId::CodeScanning, Op::Update, "default_setup") + .summary("enable CodeQL default setup") + .fields(fields) + .payload(Payload { + body: Value::Object(body), + }) + } + + /// The change, if any, that brings an enabled setup in line. + fn update(&self, setup: &DefaultSetup, current: &DefaultSetupState) -> Option { + let mut body = Map::new(); + let mut fields = Vec::new(); + + for (name, value) in declared(setup) { + let before = match name { + "query_suite" => Setting::Text(current.query_suite.clone()), + "threat_model" => Setting::Text(current.threat_model.clone()), + "runner_type" => Setting::Text(current.runner_type.clone()), + "runner_label" => Setting::Text(current.runner_label.clone()), + "languages" => Setting::Languages(current.language_set()), + _ => unreachable!("`declared` yields only the fields above"), + }; + if before != value { + fields.push(FieldDiff::changed(name, before.label(), value.label())); + body.insert(name.into(), value.body()); + } + } + + if body.is_empty() { + return None; + } + // Sent with every update, so the request does not depend on how GitHub + // reads a body that leaves the state out. + body.insert("state".into(), json!("configured")); + + let summary = if fields.len() == 1 { + format!("update CodeQL default setup {}", fields[0].field) + } else { + format!("update CodeQL default setup ({} fields)", fields.len()) + }; + + Some( + Change::new(ResourceId::CodeScanning, Op::Update, "default_setup") + .summary(summary) + .fields(fields) + .payload(Payload { + body: Value::Object(body), + }), + ) + } +} + +/// One comparable setting. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Setting { + /// A string-valued setting, `None` when unset. + Text(Option), + /// The analysed languages. + Languages(BTreeSet), +} + +impl Setting { + /// Rendered for a [`FieldDiff`]. + fn label(&self) -> String { + match self { + Self::Text(Some(value)) => value.clone(), + Self::Text(None) => "(none)".into(), + Self::Languages(languages) if languages.is_empty() => "(none)".into(), + Self::Languages(languages) => languages + .iter() + .map(String::as_str) + .collect::>() + .join(", "), + } + } + + /// The request body fragment. + fn body(&self) -> Value { + match self { + Self::Text(value) => json!(value), + Self::Languages(languages) => json!(languages), + } + } +} + +/// Every setting the configuration declares, normalised, in a stable order. +fn declared(setup: &DefaultSetup) -> Vec<(&'static str, Setting)> { + let text = |value: Option<&str>| value.map(|value| Setting::Text(Some(value.to_string()))); + [ + ("query_suite", text(setup.query_suite.map(|v| v.as_str()))), + ("threat_model", text(setup.threat_model.map(|v| v.as_str()))), + ( + "languages", + setup.normalized_languages().map(Setting::Languages), + ), + ("runner_type", text(setup.runner_type.map(|v| v.as_str()))), + ( + "runner_label", + setup + .runner_label + .as_deref() + .map(|label| Setting::Text(model::normalize_label(Some(label)))), + ), + ] + .into_iter() + .filter_map(|(name, value)| value.map(|value| (name, value))) + .collect() +} + +/// Parse the API's spelling of an enum back into a configuration enum. +fn parse_enum Deserialize<'de>>(value: &str) -> Option { + serde_json::from_value(Value::String(value.to_string())).ok() +} + +#[cfg(test)] +mod tests; diff --git a/src/resources/code_scanning/model.rs b/src/resources/code_scanning/model.rs new file mode 100644 index 0000000..c453ffa --- /dev/null +++ b/src/resources/code_scanning/model.rs @@ -0,0 +1,266 @@ +//! Code scanning model. +//! +//! # Normalisation +//! +//! Two traps, each of which produces a permanent diff if missed: +//! +//! * GitHub reports a configured language under every name it answers to: +//! a repository set up for `javascript-typescript` reads back as +//! `javascript`, `javascript-typescript` and `typescript`. [`normalize_language`] +//! folds each alias into the name the API accepts on write, on both sides. +//! * An unset runner label reads back as `null` before default setup was +//! first enabled and as `""` after, so [`normalize_label`] treats both as +//! unset. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::Finding; +use crate::resources::ValidateCtx; + +/// Which CodeQL queries default setup runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QuerySuite { + /// The default suite, tuned for a low false-positive rate. + Default, + /// The default suite plus lower-severity and lower-precision queries. + Extended, +} + +impl QuerySuite { + /// The value as the API spells it. + pub fn as_str(&self) -> &'static str { + match self { + Self::Default => "default", + Self::Extended => "extended", + } + } +} + +/// Which sources of untrusted data CodeQL considers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ThreatModel { + /// Remote sources only, such as network requests. + Remote, + /// Remote sources and local ones, such as files and environment variables. + RemoteAndLocal, +} + +impl ThreatModel { + /// The value as the API spells it. + pub fn as_str(&self) -> &'static str { + match self { + Self::Remote => "remote", + Self::RemoteAndLocal => "remote_and_local", + } + } +} + +/// Which runners default setup's analysis runs on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RunnerType { + /// GitHub-hosted runners. + Standard, + /// Runners carrying the label named by `runner_label`. + Labeled, +} + +impl RunnerType { + /// The value as the API spells it. + pub fn as_str(&self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Labeled => "labeled", + } + } +} + +/// CodeQL default setup. +/// +/// ```yaml +/// default_setup: +/// enabled: true +/// query_suite: extended +/// languages: [actions, python] +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DefaultSetup { + /// Whether default setup is enabled. + /// + /// `false` turns it off, and the other fields are then ignored. + pub enabled: bool, + + /// Which CodeQL queries to run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query_suite: Option, + + /// Which sources of untrusted data CodeQL considers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threat_model: Option, + + /// Languages to analyse, for example `actions`, `python` or + /// `javascript-typescript`. + /// + /// Omit it to let GitHub analyse every supported language it detects. + /// `javascript` and `typescript` both mean `javascript-typescript`; likewise + /// `c` and `cpp` mean `c-cpp`, and `java` and `kotlin` mean `java-kotlin`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub languages: Option>, + + /// Which runners the analysis runs on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runner_type: Option, + + /// Label of the self-hosted runners to use, with `runner_type: labeled`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runner_label: Option, +} + +impl DefaultSetup { + /// The declared languages, normalised; `None` when unmanaged. + pub fn normalized_languages(&self) -> Option> { + self.languages + .as_ref() + .map(|languages| normalize_languages(languages)) + } +} + +/// The `code_scanning` configuration section. +/// +/// ```yaml +/// code_scanning: +/// default_setup: +/// enabled: true +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CodeScanningSettings { + /// CodeQL default setup, which analyses the repository on GitHub's own + /// workflow without a workflow file. + /// + /// GitHub applies a change to the query suite, threat model or languages + /// of a setup that is already enabled through a *CodeQL Setup* workflow + /// run, and reports the previous values until that run finishes. A plan + /// made in the meantime shows the change again. + /// + /// GitHub also accepts enabling it on a repository with no language CodeQL + /// can analyse, then reports no languages and does not start analysing + /// when one is added. Enable it once the repository has code, or turn it + /// off and on again after adding some. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_setup: Option, +} + +/// Normalise a language to the name the API accepts on write. +pub fn normalize_language(language: &str) -> String { + let language = language.trim().to_lowercase(); + match language.as_str() { + "javascript" | "typescript" => "javascript-typescript".to_string(), + "c" | "cpp" => "c-cpp".to_string(), + "java" | "kotlin" => "java-kotlin".to_string(), + _ => language, + } +} + +/// Normalise a list of languages into a comparable set. +pub fn normalize_languages>(languages: &[S]) -> BTreeSet { + languages + .iter() + .map(|language| normalize_language(language.as_ref())) + .filter(|language| !language.is_empty()) + .collect() +} + +/// Normalise a runner label, mapping the unset spellings to `None`. +pub fn normalize_label(label: Option<&str>) -> Option { + label + .map(str::trim) + .filter(|label| !label.is_empty()) + .map(str::to_string) +} + +/// Validate the code scanning section. +pub fn validate(settings: &CodeScanningSettings, ctx: &ValidateCtx<'_>) -> Vec { + let mut findings = Vec::new(); + let Some(setup) = &settings.default_setup else { + return findings; + }; + let path = "code_scanning.default_setup"; + + if !setup.enabled { + let ignored: Vec<&str> = [ + ("query_suite", setup.query_suite.is_some()), + ("threat_model", setup.threat_model.is_some()), + ("languages", setup.languages.is_some()), + ("runner_type", setup.runner_type.is_some()), + ("runner_label", setup.runner_label.is_some()), + ] + .into_iter() + .filter_map(|(name, declared)| declared.then_some(name)) + .collect(); + + if !ignored.is_empty() { + findings.push( + Finding::warning( + "gh_settings::code_scanning::ignored_when_disabled", + format!( + "`{}` has no effect while default setup is disabled", + ignored.join("`, `") + ), + ) + .at(ctx.span(&format!("{path}.enabled"))) + .labelled("disabled here") + .help("remove the other fields, or set `enabled: true`"), + ); + } + return findings; + } + + if setup + .languages + .as_ref() + .is_some_and(|languages| normalize_languages(languages).is_empty()) + { + findings.push( + Finding::error( + "gh_settings::code_scanning::no_languages", + "`languages` is empty", + ) + .at(ctx.key_span(&format!("{path}.languages"))) + .labelled("nothing to analyse") + .help("name at least one language, or omit `languages` to analyse every one GitHub detects"), + ); + } + + // GitHub rejects both mismatches with a 422 that names neither field. + let label = normalize_label(setup.runner_label.as_deref()); + match (setup.runner_type, &label) { + (Some(RunnerType::Labeled), None) => findings.push( + Finding::error( + "gh_settings::code_scanning::missing_runner_label", + "`runner_type: labeled` needs a `runner_label`", + ) + .at(ctx.span(&format!("{path}.runner_type"))) + .labelled("no label to select runners by") + .help("set `runner_label` to the label of your self-hosted runners"), + ), + (runner_type, Some(_)) if runner_type != Some(RunnerType::Labeled) => findings.push( + Finding::error( + "gh_settings::code_scanning::unused_runner_label", + "`runner_label` needs `runner_type: labeled`", + ) + .at(ctx.key_span(&format!("{path}.runner_label"))) + .labelled("only used for labeled runners") + .help("set `runner_type: labeled`, or remove `runner_label`"), + ), + _ => {} + } + + findings +} diff --git a/src/resources/code_scanning/tests.rs b/src/resources/code_scanning/tests.rs new file mode 100644 index 0000000..49d7882 --- /dev/null +++ b/src/resources/code_scanning/tests.rs @@ -0,0 +1,270 @@ +//! Code scanning resource tests. + +use super::*; +use crate::config::SpanIndex; +use rstest::rstest; + +fn settings(yaml: &str) -> CodeScanningSettings { + serde_norway::from_str(yaml).expect("valid code_scanning section") +} + +fn state(json: Value) -> DefaultSetupState { + // Through `normalized`, as the real `current()` does. + serde_json::from_value::(json) + .expect("valid default setup state") + .normalized() +} + +/// A repository that never enabled default setup, as GitHub reports it: the +/// languages it detected, and the settings it would use. +fn never_configured() -> DefaultSetupState { + state(json!({ + "state": "not-configured", + "languages": ["actions", "javascript", "javascript-typescript", "python", "rust", "typescript"], + "query_suite": "default", + "threat_model": "remote", + "runner_type": "standard", + "runner_label": null, + })) +} + +/// A configured setup, read back after its setup run finished. +fn configured() -> DefaultSetupState { + state(json!({ + "state": "configured", + "languages": ["actions", "javascript", "javascript-typescript", "python", "typescript"], + "query_suite": "default", + "threat_model": "remote", + "runner_type": "standard", + "runner_label": "", + })) +} + +fn plan(desired: &str, current: Current) -> Vec { + CodeScanning.diff(&settings(desired), ¤t, &PruneOpts::default()) +} + +fn body(change: &Change) -> Value { + change + .decode::() + .expect("a decodable payload") + .body +} + +mod normalisation { + use super::*; + use pretty_assertions::assert_eq; + + #[rstest] + #[case("javascript", "javascript-typescript")] + #[case("typescript", "javascript-typescript")] + #[case("javascript-typescript", "javascript-typescript")] + #[case("c", "c-cpp")] + #[case("cpp", "c-cpp")] + #[case("java", "java-kotlin")] + #[case("kotlin", "java-kotlin")] + #[case(" Python ", "python")] + #[case("actions", "actions")] + fn every_alias_of_a_language_folds_into_the_name_the_api_accepts( + #[case] input: &str, + #[case] expected: &str, + ) { + assert_eq!(model::normalize_language(input), expected); + } + + #[test] + fn the_aliases_github_reports_collapse_into_one_language() { + assert_eq!( + configured().languages, + vec!["actions", "javascript-typescript", "python"] + ); + } + + #[test] + fn an_empty_runner_label_reads_as_unset() { + assert_eq!(configured().runner_label, None); + assert_eq!(never_configured().runner_label, None); + } +} + +mod diff { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn an_empty_section_produces_no_change() { + assert!(plan("{}", Some(configured())).is_empty()); + } + + #[test] + fn enabling_sends_the_configured_state() { + let changes = plan( + "default_setup:\n enabled: true\n", + Some(never_configured()), + ); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].op, Op::Update); + assert_eq!(changes[0].summary, "enable CodeQL default setup"); + assert_eq!(body(&changes[0]), json!({"state": "configured"})); + } + + #[test] + fn enabling_sends_every_declared_setting_even_where_github_already_agrees() { + // A disabled setup's settings describe nothing that runs, so matching + // them proves nothing about what the enabled setup will use. + let changes = plan( + "default_setup:\n enabled: true\n query_suite: default\n languages: [python]\n", + Some(never_configured()), + ); + assert_eq!( + body(&changes[0]), + json!({"state": "configured", "query_suite": "default", "languages": ["python"]}) + ); + } + + #[test] + fn a_repository_without_the_endpoint_is_treated_as_disabled() { + let changes = plan("default_setup:\n enabled: true\n", None); + assert_eq!(changes[0].summary, "enable CodeQL default setup"); + } + + #[test] + fn an_enabled_setup_that_matches_produces_no_change() { + assert!(plan("default_setup:\n enabled: true\n", Some(configured())).is_empty()); + } + + #[test] + fn languages_are_compared_through_their_aliases() { + // GitHub reads `javascript-typescript` back as three names; a plan that + // compared them literally would never come out empty. + let yaml = "default_setup:\n enabled: true\n languages: [typescript, python, actions]\n"; + assert!(plan(yaml, Some(configured())).is_empty()); + } + + #[test] + fn a_changed_language_list_is_sent_in_full() { + let changes = plan( + "default_setup:\n enabled: true\n languages: [actions]\n", + Some(configured()), + ); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].summary, "update CodeQL default setup languages"); + assert_eq!( + body(&changes[0]), + json!({"state": "configured", "languages": ["actions"]}) + ); + } + + #[test] + fn only_the_settings_that_differ_are_sent_to_an_enabled_setup() { + let changes = plan( + "default_setup:\n enabled: true\n query_suite: extended\n threat_model: remote\n", + Some(configured()), + ); + assert_eq!( + body(&changes[0]), + json!({"state": "configured", "query_suite": "extended"}) + ); + assert_eq!(changes[0].fields.len(), 1); + assert_eq!(changes[0].fields[0].before.as_deref(), Some("default")); + } + + #[test] + fn disabling_sends_only_the_state() { + let changes = plan("default_setup:\n enabled: false\n", Some(configured())); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].summary, "disable CodeQL default setup"); + assert_eq!(body(&changes[0]), json!({"state": "not-configured"})); + } + + #[test] + fn a_disabled_setup_that_should_stay_disabled_produces_no_change() { + assert!( + plan( + "default_setup:\n enabled: false\n", + Some(never_configured()) + ) + .is_empty() + ); + } + + #[test] + fn disabling_is_not_destructive() { + // Turning a setting off is an update the file asked for, not a prune. + let changes = plan("default_setup:\n enabled: false\n", Some(configured())); + assert!(!changes[0].op.is_destructive()); + } + + #[test] + fn applying_then_replanning_yields_nothing() { + // What GitHub reports once the setup run has finished, fed back in. + let yaml = "default_setup:\n enabled: true\n query_suite: extended\n threat_model: remote_and_local\n languages: [actions, javascript]\n"; + let after = state(json!({ + "state": "configured", + "languages": ["actions", "javascript", "javascript-typescript", "typescript"], + "query_suite": "extended", + "threat_model": "remote_and_local", + "runner_type": "standard", + "runner_label": "", + })); + assert!(plan(yaml, Some(after)).is_empty()); + } +} + +mod validation { + use super::*; + use pretty_assertions::assert_eq; + + fn codes(yaml: &str) -> Vec { + let spans = SpanIndex::default(); + let ctx = ValidateCtx::new(&spans); + CodeScanning + .validate(&settings(yaml), &ctx) + .into_iter() + .map(|finding| finding.code) + .collect() + } + + #[test] + fn settings_alongside_a_disabled_setup_are_flagged() { + assert_eq!( + codes("default_setup:\n enabled: false\n query_suite: extended\n"), + vec!["gh_settings::code_scanning::ignored_when_disabled"] + ); + } + + #[test] + fn an_empty_language_list_is_rejected() { + assert_eq!( + codes("default_setup:\n enabled: true\n languages: []\n"), + vec!["gh_settings::code_scanning::no_languages"] + ); + } + + #[test] + fn labeled_runners_need_a_label() { + assert_eq!( + codes("default_setup:\n enabled: true\n runner_type: labeled\n"), + vec!["gh_settings::code_scanning::missing_runner_label"] + ); + } + + #[test] + fn a_label_needs_labeled_runners() { + assert_eq!( + codes("default_setup:\n enabled: true\n runner_label: code-scanning\n"), + vec!["gh_settings::code_scanning::unused_runner_label"] + ); + } + + #[test] + fn a_complete_section_is_silent() { + let yaml = "default_setup:\n enabled: true\n query_suite: extended\n threat_model: remote\n languages: [python]\n runner_type: labeled\n runner_label: code-scanning\n"; + assert!(codes(yaml).is_empty()); + } + + #[test] + fn enabled_is_required() { + assert!(serde_norway::from_str::("default_setup: {}\n").is_err()); + } +} diff --git a/src/resources/mod.rs b/src/resources/mod.rs index f0e99eb..bbf838b 100644 --- a/src/resources/mod.rs +++ b/src/resources/mod.rs @@ -15,6 +15,7 @@ pub mod requirement; pub mod actions; pub mod autolinks; +pub mod code_scanning; pub mod environments; pub mod labels; pub mod pages; @@ -58,6 +59,8 @@ pub enum ResourceId { Variables, /// GitHub Pages. Pages, + /// Code scanning: CodeQL default setup. + CodeScanning, } impl ResourceId { @@ -72,6 +75,7 @@ impl ResourceId { Self::Actions, Self::Variables, Self::Pages, + Self::CodeScanning, ]; /// The identifier as it appears in `--only`, plan output and JSON. @@ -86,6 +90,7 @@ impl ResourceId { Self::Actions => "actions", Self::Variables => "variables", Self::Pages => "pages", + Self::CodeScanning => "code_scanning", } } @@ -101,6 +106,7 @@ impl ResourceId { Self::Actions => "Actions", Self::Variables => "Variables", Self::Pages => "Pages", + Self::CodeScanning => "Code scanning", } } diff --git a/src/schema/mod.rs b/src/schema/mod.rs index a7d96cc..f1b1a75 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -111,6 +111,7 @@ mod tests { "variables", "pages", "actions", + "code_scanning", ] { assert!(properties.contains_key(section), "{section} is missing"); } diff --git a/tests/export.rs b/tests/export.rs index ecdb97a..c9af60c 100644 --- a/tests/export.rs +++ b/tests/export.rs @@ -509,3 +509,44 @@ fn an_exported_actions_section_plans_nothing() { output.stdout ); } + +#[test] +fn an_enabled_codeql_default_setup_exports_and_round_trips() { + // GitHub reads `javascript-typescript` back under three names; the export + // must fold them, or the round trip would plan a change. + let runner = populated() + .get( + "repos/o/r/code-scanning/default-setup", + r#"{"state": "configured", "languages": ["javascript", "javascript-typescript", "typescript"], "query_suite": "extended", "threat_model": "remote", "runner_type": "standard", "runner_label": ""}"#, + ) + .build(); + + runner + .run(&["export", "-R", "o/r", "--force"]) + .expect_status(0); + + let written = common::read(runner.path(), ".github/settings.yml"); + assert!(written.contains("code_scanning:"), "{written}"); + assert!(written.contains("- javascript-typescript"), "{written}"); + assert!(!written.contains("- typescript"), "{written}"); + + runner.run(&["plan", "-R", "o/r"]).expect_status(0); +} + +#[test] +fn a_disabled_codeql_default_setup_exports_nothing() { + let runner = populated() + .get( + "repos/o/r/code-scanning/default-setup", + r#"{"state": "not-configured", "languages": ["python"], "query_suite": "default"}"#, + ) + .build(); + + let output = runner.run(&["export", "-R", "o/r", "--stdout"]); + output.expect_status(0); + assert!( + !output.stdout.contains("code_scanning"), + "{}", + output.stdout + ); +} diff --git a/tests/live.rs b/tests/live.rs index d26a99c..d45d09c 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -191,6 +191,26 @@ fn live_repository_security_travels_in_its_own_request() { .expect_up_to_date(); } +#[test] +#[ignore = "live: requires GH_SETTINGS_TEST_REPO"] +fn live_codeql_default_setup_enables_and_disables() { + let live = live_or_skip!(); + + // Only `enabled`: turning the setup on or off reads back at once, while a + // query suite or language change waits for the setup run GitHub starts, + // so a re-plan straight after it would race that run. Off last, so the + // sandbox ends as it began. + for enabled in [true, false] { + live.config(&only(&format!( + "code_scanning:\n default_setup:\n enabled: {enabled}\n" + ))); + live.run(&["sync", "--yes", "--only", "code_scanning"]) + .expect_status(0); + live.run(&["plan", "--only", "code_scanning"]) + .expect_up_to_date(); + } +} + #[test] #[ignore = "live: requires GH_SETTINGS_TEST_REPO"] fn live_export_round_trips_to_an_empty_plan() { diff --git a/tests/snapshots/doctor__doctor_renders_a_fine_grained_token_the_admin_probe_vouches_for.snap b/tests/snapshots/doctor__doctor_renders_a_fine_grained_token_the_admin_probe_vouches_for.snap index 75b280a..0c3abea 100644 --- a/tests/snapshots/doctor__doctor_renders_a_fine_grained_token_the_admin_probe_vouches_for.snap +++ b/tests/snapshots/doctor__doctor_renders_a_fine_grained_token_the_admin_probe_vouches_for.snap @@ -18,3 +18,4 @@ Resources ✔ actions ✔ variables ✔ pages + ✔ code_scanning diff --git a/tests/snapshots/doctor__doctor_renders_a_healthy_classic_token.snap b/tests/snapshots/doctor__doctor_renders_a_healthy_classic_token.snap index 7870d8f..e2a36ef 100644 --- a/tests/snapshots/doctor__doctor_renders_a_healthy_classic_token.snap +++ b/tests/snapshots/doctor__doctor_renders_a_healthy_classic_token.snap @@ -18,3 +18,4 @@ Resources ✔ actions ✔ variables ✔ pages + ✔ code_scanning diff --git a/tests/snapshots/doctor__doctor_renders_a_token_missing_the_repo_scope.snap b/tests/snapshots/doctor__doctor_renders_a_token_missing_the_repo_scope.snap index 12659e1..c339845 100644 --- a/tests/snapshots/doctor__doctor_renders_a_token_missing_the_repo_scope.snap +++ b/tests/snapshots/doctor__doctor_renders_a_token_missing_the_repo_scope.snap @@ -9,17 +9,18 @@ Environment Scopes gist Resources - ✘ repository missing the `repo` scope - ✘ topics missing the `repo` scope - ✘ labels missing the `repo` scope - ✘ autolinks missing the `repo` scope - ✘ rulesets missing the `repo` scope - ✘ environments missing the `repo` scope - ✘ actions missing the `repo` scope - ✘ variables missing the `repo` scope - ✘ pages missing the `repo` scope + ✘ repository missing the `repo` scope + ✘ topics missing the `repo` scope + ✘ labels missing the `repo` scope + ✘ autolinks missing the `repo` scope + ✘ rulesets missing the `repo` scope + ✘ environments missing the `repo` scope + ✘ actions missing the `repo` scope + ✘ variables missing the `repo` scope + ✘ pages missing the `repo` scope + ✘ code_scanning missing the `repo` scope - ✘ extends missing the `repo` scope + ✘ extends missing the `repo` scope → Use a personal access token or a GitHub App installation token. See https://noirbizarre.github.io/gh-settings/authentication/ diff --git a/tests/snapshots/doctor__doctor_renders_the_actions_token.snap b/tests/snapshots/doctor__doctor_renders_the_actions_token.snap index ab700d6..4a33ea3 100644 --- a/tests/snapshots/doctor__doctor_renders_the_actions_token.snap +++ b/tests/snapshots/doctor__doctor_renders_the_actions_token.snap @@ -9,17 +9,18 @@ Environment Scopes issues Resources - ✘ repository requires Administration: write, which cannot be granted to GITHUB_TOKEN - ✘ topics requires Administration: write, which cannot be granted to GITHUB_TOKEN + ✘ repository requires Administration: write, which cannot be granted to GITHUB_TOKEN + ✘ topics requires Administration: write, which cannot be granted to GITHUB_TOKEN ✔ labels - ✘ autolinks requires Administration: write, which cannot be granted to GITHUB_TOKEN - ✘ rulesets requires Administration: write, which cannot be granted to GITHUB_TOKEN - ✘ environments requires Administration: write, which cannot be granted to GITHUB_TOKEN - ✘ actions requires Administration: write, which cannot be granted to GITHUB_TOKEN - ✘ variables requires Variables: write, which cannot be granted to GITHUB_TOKEN + ✘ autolinks requires Administration: write, which cannot be granted to GITHUB_TOKEN + ✘ rulesets requires Administration: write, which cannot be granted to GITHUB_TOKEN + ✘ environments requires Administration: write, which cannot be granted to GITHUB_TOKEN + ✘ actions requires Administration: write, which cannot be granted to GITHUB_TOKEN + ✘ variables requires Variables: write, which cannot be granted to GITHUB_TOKEN ✔ pages + ✘ code_scanning requires Administration: write, which cannot be granted to GITHUB_TOKEN - ✘ extends requires Contents: read on the *other* repository, which GITHUB_TOKEN does not have + ✘ extends requires Contents: read on the *other* repository, which GITHUB_TOKEN does not have → Use a personal access token or a GitHub App installation token. See https://noirbizarre.github.io/gh-settings/authentication/ diff --git a/tests/snapshots/doctor__doctor_renders_the_unknown_verdict.snap b/tests/snapshots/doctor__doctor_renders_the_unknown_verdict.snap index 9fc2064..9d619e0 100644 --- a/tests/snapshots/doctor__doctor_renders_the_unknown_verdict.snap +++ b/tests/snapshots/doctor__doctor_renders_the_unknown_verdict.snap @@ -9,12 +9,13 @@ Environment Scopes not reported by this token type Resources - ? repository cannot determine from this token type - ? topics cannot determine from this token type - ? labels cannot determine from this token type - ? autolinks cannot determine from this token type - ? rulesets cannot determine from this token type - ? environments cannot determine from this token type - ? actions cannot determine from this token type - ? variables cannot determine from this token type - ? pages cannot determine from this token type + ? repository cannot determine from this token type + ? topics cannot determine from this token type + ? labels cannot determine from this token type + ? autolinks cannot determine from this token type + ? rulesets cannot determine from this token type + ? environments cannot determine from this token type + ? actions cannot determine from this token type + ? variables cannot determine from this token type + ? pages cannot determine from this token type + ? code_scanning cannot determine from this token type diff --git a/tests/sync.rs b/tests/sync.rs index 13fe3dd..f672ee4 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -1806,3 +1806,56 @@ fn actions_endpoints_that_do_not_apply_are_not_read_as_failures() { output.stderr ); } + +const NEVER_CONFIGURED: &str = r#"{ + "state": "not-configured", + "languages": ["actions", "python"], + "query_suite": "default", + "threat_model": "remote", + "runner_type": "standard", + "runner_label": null +}"#; + +#[test] +fn sync_enables_codeql_default_setup_with_one_patch() { + let runner = Sandbox::new() + .config("version: 1\ncode_scanning:\n default_setup:\n enabled: true\n query_suite: extended\n") + .get("repos/o/r/code-scanning/default-setup", NEVER_CONFIGURED) + .respond( + "PATCH", + "repos/o/r/code-scanning/default-setup", + Fixture { + status: 202, + ..Fixture::ok(r#"{"run_id": 1, "run_url": ""}"#) + }, + ) + .build(); + + let output = runner.run(&["sync", "-R", "o/r", "--yes"]); + output.expect_status(0); + + let writes = output.writes(); + assert_eq!(writes.len(), 1, "{writes:?}"); + assert!( + writes[0].starts_with("PATCH repos/o/r/code-scanning/default-setup"), + "{writes:?}" + ); + let body = body_of(writes[0]); + assert_eq!(body["state"], "configured", "{body}"); + assert_eq!(body["query_suite"], "extended", "{body}"); +} + +#[test] +fn plan_does_not_read_code_scanning_when_it_is_unmanaged() { + let runner = Sandbox::new() + .config("version: 1\nlabels:\n - name: bug\n color: d73a4a\n") + .get("repos/o/r/labels", LABELS) + .build(); + + let output = runner.run(&["plan", "-R", "o/r"]); + assert!( + !output.requests.iter().any(|r| r.contains("code-scanning")), + "read code scanning despite it being unmanaged: {:?}", + output.requests + ); +}