diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c685a..0ba9e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ publishing to npm. - Lockfile-pinned offline GitHub Actions schema/semantic validation, immutable action policy, negative fixtures, and validator provenance/update guidance. +- Additive per-control security capability/enabled results with sanitized recovery, + deterministic enabled/disabled/omitted/403/404/partial fixtures, and preserved legacy + `state` booleans/`ok` semantics. ### Changed @@ -29,6 +32,8 @@ - All mutating dry runs now avoid both credential resolution and HTTP, including PR linking/merge and branch deletion plans. - Coverage gates now require at least 80% lines and 70% branches. +- The workflow validator stays directly executable with Node while remaining importable + by the Windows Vitest suite. ## 0.6.0 - 2026-07-01 diff --git a/README.md b/README.md index c1a74eb..512919e 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,9 @@ This package now covers the smallest high-value GitHub admin workflow end to end retain their requested limit and return `truncated` when more results exist - internal GitHub requests use a 10-second default timeout, retry bounded read-only transient failures, and surface sanitized structured recovery metadata +- security verification keeps legacy `state` booleans and `ok` while adding per-control + capability, enabled-state, sanitized reason/status, and bounded recovery results; masked + 404 responses remain `unknown` rather than being treated as proof of unsupported state ## PR discipline workflow @@ -246,6 +249,7 @@ github_delete_comment({ - [Examples and integration recipes](docs/INTEGRATIONS.md) - [Performance baseline](docs/PERFORMANCE.md) - [Offline GitHub Actions validation and pin review](docs/WORKFLOW_VALIDATION.md) +- [Security-control capability taxonomy and legacy compatibility](docs/SECURITY_CAPABILITY_RESULTS.md) - [Release and changelog workflow](docs/RELEASING.md) - [Roadmap](docs/ROADMAP.md) and [backlog governance](docs/BACKLOG.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 17a28f6..679cd19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,6 +74,9 @@ When adding or changing a tool: is a manual integration check and must use a disposable repository. - Register new tools through `src/tools.ts` and confirm public exports through `src/index.ts` when programmatic access is intended. +- Treat security capability diagnostics as an additive contract: retain legacy booleans + and `ok`, emit only sanitized closed reasons/status, and keep masked 404 capability + unknown as recorded in [`SECURITY_CAPABILITY_RESULTS.md`](SECURITY_CAPABILITY_RESULTS.md). ## Validation boundary diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index d0c172e..92bcf36 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -40,6 +40,10 @@ The package intentionally emits structured results rather than application logs | Request timed out | GitHub did not answer within the bounded timeout | Verify GitHub Status and network access, then repeat a read or run a fresh dry run before retrying a mutation | | Rate limit exhausted | Structured error has `rateLimit.remaining: 0` | Wait until `rateLimit.resetAt` or the bounded `retryAfterSeconds` signal; do not rotate credentials merely to evade policy | | Pagination reports `truncated` | Maximum pages/items or a repeated Link target stopped traversal | Narrow the query or inspect the truncation reason; correctness-sensitive mutations refuse to treat truncated verification as complete | +| Security control is `disabled` | GitHub explicitly reported an available control as off | Review repository policy, then use `github_configure_security` only if the control is required | +| Security control is `unavailable` | The repository response omitted the control | Review plan, organization policy, and caller visibility; omission is not proof about every plan or policy | +| Security control is `forbidden` | GitHub returned 403 for that read | Grant only the minimum repository administration or security-manager permission needed, then retry | +| Security control is `unknown` after 404 | Repository/control was not found or GitHub masked an authorization failure | Confirm `owner/name` and caller access; do not infer unsupported or disabled state from 404 | ## Debugging and observability boundaries @@ -52,4 +56,8 @@ The package intentionally emits structured results rather than application logs `GitHubApiError` exposes `status`, `requestId`, `documentationUrl`, `rateLimit` (`limit`, `remaining`, `resetAt`, `retryAfterSeconds`), `retryable`, `method`, and a sanitized `path`. Response text is reduced to GitHub's message/documentation fields, authorization-shaped values are redacted, and surfaced text is capped. The package never logs errors automatically; callers decide where sanitized structured details may be recorded. +Security verification exposes only closed reason/status values, numeric 403/404 status, +and static recovery text for expected capability failures. It does not copy raw GitHub +failure messages into the result. See [the capability-result decision](SECURITY_CAPABILITY_RESULTS.md). + Escalate repeated or ambiguous failures with the tool name, sanitized status/message, target resource type, and exact recovery steps already attempted. diff --git a/docs/SECURITY_CAPABILITY_RESULTS.md b/docs/SECURITY_CAPABILITY_RESULTS.md new file mode 100644 index 0000000..0ea6d3d --- /dev/null +++ b/docs/SECURITY_CAPABILITY_RESULTS.md @@ -0,0 +1,52 @@ +# Security-control capability results + +## Decision + +Security verification reports a per-control result in `controls` while retaining the +legacy `state` booleans and top-level `ok`. The additive result separates whether a +control is observable from whether it is enabled: + +| `status` | `capability` | `enabled` | Meaning | +| --- | --- | --- | --- | +| `enabled` | `available` | `true` | GitHub reported the control enabled. | +| `disabled` | `available` | `false` | GitHub reported the control disabled. | +| `unavailable` | `unavailable` | `null` | The repository response omitted this control; it is unavailable to this read, not proven absent from every plan or policy. | +| `forbidden` | `unknown` | `null` | GitHub returned 403, so the caller cannot determine capability or state. | +| `unknown` | `unknown` | `null` | GitHub returned a masked/not-found 404 or an unexpected response shape. | + +Each result also has a closed `reason` value. Expected 403/404 failures expose only a +numeric `httpStatus` and a static recovery action; raw GitHub response messages are not +copied into verification output. + +## Conservative 404 treatment + +GitHub can mask authorization failures as 404. Therefore `not_found_or_masked` never +means "unsupported" or "disabled" in the additive result. Confirm the repository +reference and caller access before drawing a plan, policy, or feature-availability +conclusion. Live read-only evidence must use the same qualification. + +## Legacy compatibility + +`state` remains the six-boolean object returned to existing callers. `ok` remains the +logical AND of those booleans; it is not redefined around the new taxonomy. Existing +successful endpoint responses remain enabled unless they explicitly contain +`enabled: false`, omitted analysis fields remain `false`, and endpoint 404s remain +`false`. A handled 403 has no earlier successful-result equivalent and is represented +conservatively as `false` in `state` while `controls` records `forbidden`/`unknown`. + +New callers should use `controls` for diagnostics and use `state`/`ok` only when they +need the compatibility contract. + +## Recovery + +Recovery text is deliberately bounded and static: + +1. For `disabled`, review repository policy and enable the control only if required. +2. For `unavailable`, review repository plan, organization policy, and caller visibility. +3. For `forbidden`, grant only the minimum repository administration or + security-manager permission needed for the read, then retry. +4. For `not_found_or_masked`, confirm `owner/name` and caller access without assuming + the feature is unsupported. + +Verification is read-only. Use `github_configure_security` separately and deliberately +when a policy-approved state change is required. diff --git a/scripts/verify-workflows.mjs b/scripts/verify-workflows.mjs index 5375b09..e66956b 100644 --- a/scripts/verify-workflows.mjs +++ b/scripts/verify-workflows.mjs @@ -1,4 +1,3 @@ -#!/usr/bin/env node import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/src/security.ts b/src/security.ts index b51baea..57d90d3 100644 --- a/src/security.ts +++ b/src/security.ts @@ -1,4 +1,4 @@ -import { getGitHubClient } from "./http.js"; +import { getGitHubClient, type GitHubClient } from "./http.js"; import type { GitHubSecurityControlsInput, GitHubVerifySecurityControlsInput } from "./types.js"; import { parseRepo } from "./repo-ref.js"; @@ -11,6 +11,29 @@ export interface RepositorySecurityState { dependabotSecurityUpdates: boolean; } +export type RepositorySecurityCapability = "available" | "unavailable" | "unknown"; +export type RepositorySecurityControlStatus = "enabled" | "disabled" | "unavailable" | "forbidden" | "unknown"; +export type RepositorySecurityControlReason = "reported" | "omitted" | "forbidden" | "not_found_or_masked" | "unexpected_response"; + +export interface RepositorySecurityControlResult { + capability: RepositorySecurityCapability; + enabled: boolean | null; + status: RepositorySecurityControlStatus; + reason: RepositorySecurityControlReason; + httpStatus?: 403 | 404; + recovery?: string; +} + +export type RepositorySecurityControlResults = { + [K in keyof RepositorySecurityState]: RepositorySecurityControlResult; +}; + +const DISABLED_RECOVERY = "Review repository policy, then enable this control in GitHub settings only if required."; +const UNAVAILABLE_RECOVERY = "Review the repository plan, organization policy, and caller visibility before attempting to enable this control."; +const FORBIDDEN_RECOVERY = "Verify the caller has the minimum required repository administration or security-manager permission, then retry the read."; +const MASKED_NOT_FOUND_RECOVERY = "Confirm the repository reference and caller access; GitHub may mask authorization failures as 404, so do not infer that the control is unsupported."; +const UNEXPECTED_RECOVERY = "Retry the read and compare GitHub's response with the documented security-control shape before changing the control."; + export async function configureRepositorySecurity(input: GitHubSecurityControlsInput) { const requested = normalizeRequested(input); if (input.dryRun) { @@ -44,28 +67,50 @@ export async function configureRepositorySecurity(input: GitHubSecurityControlsI ); } const verification = await verifyRepositorySecurity({ repo: input.repo }); - const matches = Object.entries(requested).every(([key, value]) => value === undefined || verification.state[key as keyof RepositorySecurityState] === value); - return { updated: true, verified: matches, dryRun: false, repo: input.repo, requested, state: verification.state }; + const matches = Object.entries(requested).every(([key, value]) => { + if (value === undefined) return true; + const control = verification.controls[key as keyof RepositorySecurityState]; + return control.capability === "available" && control.enabled === value; + }); + return { + updated: true, + verified: matches, + dryRun: false, + repo: input.repo, + requested, + state: verification.state, + controls: verification.controls, + recovery: verification.recovery, + }; } export async function verifyRepositorySecurity(input: GitHubVerifySecurityControlsInput) { const ref = parseRepo(input.repo); const client = await getGitHubClient(); - const repo = await client.request(`/repos/${ref.owner}/${ref.name}`) as any; - const [privateReporting, automatedFixes] = await Promise.all([ - readEnabledEndpoint(client, `/repos/${ref.owner}/${ref.name}/private-vulnerability-reporting`), - readEnabledEndpoint(client, `/repos/${ref.owner}/${ref.name}/automated-security-fixes`), + const basePath = `/repos/${ref.owner}/${ref.name}`; + const [repositoryAnalysis, privateReporting, automatedFixes] = await Promise.all([ + readRepositoryAnalysis(client, basePath), + readEnabledEndpoint(client, `${basePath}/private-vulnerability-reporting`), + readEnabledEndpoint(client, `${basePath}/automated-security-fixes`), ]); - const analysis = repo.security_and_analysis ?? {}; - const state: RepositorySecurityState = { + const controls: RepositorySecurityControlResults = { privateVulnerabilityReporting: privateReporting, - secretScanning: enabled(analysis.secret_scanning), - pushProtection: enabled(analysis.secret_scanning_push_protection), - nonProviderPatterns: enabled(analysis.secret_scanning_non_provider_patterns), - validityChecks: enabled(analysis.secret_scanning_validity_checks), + secretScanning: readAnalysisControl(repositoryAnalysis, "secret_scanning"), + pushProtection: readAnalysisControl(repositoryAnalysis, "secret_scanning_push_protection"), + nonProviderPatterns: readAnalysisControl(repositoryAnalysis, "secret_scanning_non_provider_patterns"), + validityChecks: readAnalysisControl(repositoryAnalysis, "secret_scanning_validity_checks"), dependabotSecurityUpdates: automatedFixes, }; - return { ok: Object.values(state).every(Boolean), repo: input.repo, state }; + const state: RepositorySecurityState = { + privateVulnerabilityReporting: legacyEnabled(controls.privateVulnerabilityReporting), + secretScanning: legacyEnabled(controls.secretScanning), + pushProtection: legacyEnabled(controls.pushProtection), + nonProviderPatterns: legacyEnabled(controls.nonProviderPatterns), + validityChecks: legacyEnabled(controls.validityChecks), + dependabotSecurityUpdates: legacyEnabled(controls.dependabotSecurityUpdates), + }; + const recovery = [...new Set(Object.values(controls).flatMap((control) => control.recovery ? [control.recovery] : []))]; + return { ok: Object.values(state).every(Boolean), repo: input.repo, state, controls, recovery }; } function normalizeRequested(input: GitHubSecurityControlsInput): Partial { @@ -79,16 +124,103 @@ function normalizeRequested(input: GitHubSecurityControlsInput): Partial } + | { kind: "failure"; result: RepositorySecurityControlResult }; + +async function readRepositoryAnalysis(client: GitHubClient, path: string): Promise { + try { + const repository = await client.request(path) as { security_and_analysis?: unknown }; + const analysis = isRecord(repository.security_and_analysis) ? repository.security_and_analysis : {}; + return { kind: "reported", analysis }; + } catch (error) { + const result = expectedFailureResult(error); + if (result) return { kind: "failure", result }; + throw error; + } } -async function readEnabledEndpoint(client: Awaited>, path: string): Promise { +function readAnalysisControl(source: RepositoryAnalysisRead, key: string): RepositorySecurityControlResult { + if (source.kind === "failure") return { ...source.result }; + const value = source.analysis[key]; + if (value === undefined) { + return { + capability: "unavailable", + enabled: null, + status: "unavailable", + reason: "omitted", + recovery: UNAVAILABLE_RECOVERY, + }; + } + if (isRecord(value) && (value.status === "enabled" || value.status === "disabled")) { + return reportedResult(value.status === "enabled"); + } + return { + capability: "unknown", + enabled: null, + status: "unknown", + reason: "unexpected_response", + recovery: UNEXPECTED_RECOVERY, + }; +} + +async function readEnabledEndpoint(client: GitHubClient, path: string): Promise { try { - const result = await client.request(path) as any; - return result?.enabled !== false; + const result = await client.request(path) as { enabled?: unknown } | undefined; + // Preserve the legacy endpoint rule: any successful response is enabled unless it explicitly reports false. + return reportedResult(result?.enabled !== false); } catch (error) { - if (typeof error === "object" && error !== null && "status" in error && (error as { status?: number }).status === 404) return false; + const result = expectedFailureResult(error); + if (result) return result; throw error; } } + +function reportedResult(enabled: boolean): RepositorySecurityControlResult { + return { + capability: "available", + enabled, + status: enabled ? "enabled" : "disabled", + reason: "reported", + ...(enabled ? {} : { recovery: DISABLED_RECOVERY }), + }; +} + +function expectedFailureResult(error: unknown): RepositorySecurityControlResult | undefined { + const status = errorStatus(error); + if (status === 403) { + return { + capability: "unknown", + enabled: null, + status: "forbidden", + reason: "forbidden", + httpStatus: 403, + recovery: FORBIDDEN_RECOVERY, + }; + } + if (status === 404) { + return { + capability: "unknown", + enabled: null, + status: "unknown", + reason: "not_found_or_masked", + httpStatus: 404, + recovery: MASKED_NOT_FOUND_RECOVERY, + }; + } + return undefined; +} + +function errorStatus(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "status" in error + ? (error as { status?: number }).status + : undefined; +} + +function legacyEnabled(result: RepositorySecurityControlResult): boolean { + return result.enabled === true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/tool-domains/security.ts b/src/tool-domains/security.ts index 191779e..8a34ecf 100644 --- a/src/tool-domains/security.ts +++ b/src/tool-domains/security.ts @@ -21,8 +21,13 @@ export function registerSecurityTools(pi: ExtensionAPI): void { }, { additionalProperties: false }), async execute(_id, params) { const result = await configureRepositorySecurity(params); + const text = result.dryRun + ? `Dry run: would configure security for ${params.repo}` + : result.verified + ? `Repository security configured for ${params.repo}` + : `Repository security configuration could not be fully verified for ${params.repo}.${formatRecovery("recovery" in result ? result.recovery : undefined)}`; return { - content: [{ type: "text", text: result.dryRun ? `Dry run: would configure security for ${params.repo}` : `Repository security configured for ${params.repo}` }], + content: [{ type: "text", text }], details: result, }; }, @@ -35,9 +40,19 @@ export function registerSecurityTools(pi: ExtensionAPI): void { async execute(_id, params) { const result = await verifyRepositorySecurity(params); return { - content: [{ type: "text", text: result.ok ? `Repository security verified for ${params.repo}` : `Repository security gaps found for ${params.repo}` }], + content: [{ + type: "text", + text: result.ok + ? `Repository security verified for ${params.repo}` + : `Repository security gaps found for ${params.repo}.${formatRecovery(result.recovery)}`, + }], details: result, }; }, }); } + +function formatRecovery(recovery: readonly string[] | undefined): string { + if (!recovery?.length) return " Recovery: review the per-control result before changing repository settings."; + return ` Recovery: ${recovery.join(" ")}`; +} diff --git a/tests/fixtures/security/disabled.json b/tests/fixtures/security/disabled.json new file mode 100644 index 0000000..a5ca991 --- /dev/null +++ b/tests/fixtures/security/disabled.json @@ -0,0 +1,16 @@ +{ + "name": "disabled", + "repository": { + "status": 200, + "body": { + "security_and_analysis": { + "secret_scanning": { "status": "disabled" }, + "secret_scanning_push_protection": { "status": "disabled" }, + "secret_scanning_non_provider_patterns": { "status": "disabled" }, + "secret_scanning_validity_checks": { "status": "disabled" } + } + } + }, + "privateVulnerabilityReporting": { "status": 200, "body": { "enabled": false } }, + "dependabotSecurityUpdates": { "status": 200, "body": { "enabled": false } } +} diff --git a/tests/fixtures/security/enabled.json b/tests/fixtures/security/enabled.json new file mode 100644 index 0000000..c50db59 --- /dev/null +++ b/tests/fixtures/security/enabled.json @@ -0,0 +1,16 @@ +{ + "name": "enabled", + "repository": { + "status": 200, + "body": { + "security_and_analysis": { + "secret_scanning": { "status": "enabled" }, + "secret_scanning_push_protection": { "status": "enabled" }, + "secret_scanning_non_provider_patterns": { "status": "enabled" }, + "secret_scanning_validity_checks": { "status": "enabled" } + } + } + }, + "privateVulnerabilityReporting": { "status": 200, "body": { "enabled": true } }, + "dependabotSecurityUpdates": { "status": 200, "body": { "enabled": true } } +} diff --git a/tests/fixtures/security/forbidden-403.json b/tests/fixtures/security/forbidden-403.json new file mode 100644 index 0000000..06c99b8 --- /dev/null +++ b/tests/fixtures/security/forbidden-403.json @@ -0,0 +1,6 @@ +{ + "name": "forbidden-403", + "repository": { "status": 403, "message": "administrator-only diagnostic detail" }, + "privateVulnerabilityReporting": { "status": 403, "message": "private-reporting diagnostic detail" }, + "dependabotSecurityUpdates": { "status": 403, "message": "automated-fixes diagnostic detail" } +} diff --git a/tests/fixtures/security/masked-404.json b/tests/fixtures/security/masked-404.json new file mode 100644 index 0000000..aae514e --- /dev/null +++ b/tests/fixtures/security/masked-404.json @@ -0,0 +1,6 @@ +{ + "name": "masked-404", + "repository": { "status": 404, "message": "repository diagnostic detail" }, + "privateVulnerabilityReporting": { "status": 404, "message": "private-reporting diagnostic detail" }, + "dependabotSecurityUpdates": { "status": 404, "message": "automated-fixes diagnostic detail" } +} diff --git a/tests/fixtures/security/omitted.json b/tests/fixtures/security/omitted.json new file mode 100644 index 0000000..7a3a53e --- /dev/null +++ b/tests/fixtures/security/omitted.json @@ -0,0 +1,9 @@ +{ + "name": "omitted", + "repository": { + "status": 200, + "body": { "security_and_analysis": {} } + }, + "privateVulnerabilityReporting": { "status": 200, "body": { "enabled": true } }, + "dependabotSecurityUpdates": { "status": 200, "body": { "enabled": true } } +} diff --git a/tests/fixtures/security/partial.json b/tests/fixtures/security/partial.json new file mode 100644 index 0000000..9012e0e --- /dev/null +++ b/tests/fixtures/security/partial.json @@ -0,0 +1,15 @@ +{ + "name": "partial", + "repository": { + "status": 200, + "body": { + "security_and_analysis": { + "secret_scanning": { "status": "enabled" }, + "secret_scanning_push_protection": { "status": "disabled" }, + "secret_scanning_validity_checks": { "status": "enabled" } + } + } + }, + "privateVulnerabilityReporting": { "status": 403, "message": "private-reporting diagnostic detail" }, + "dependabotSecurityUpdates": { "status": 200, "body": { "enabled": false } } +} diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..5de696b --- /dev/null +++ b/tests/security.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + configureRepositorySecurity, + GitHubApiError, + setGitHubClientFactoryForTests, + type GitHubClient, + verifyRepositorySecurity, +} from "../src/index.js"; +import { registerGitHubAdminTools } from "../src/tools.js"; +import enabledFixture from "./fixtures/security/enabled.json"; +import disabledFixture from "./fixtures/security/disabled.json"; +import omittedFixture from "./fixtures/security/omitted.json"; +import forbiddenFixture from "./fixtures/security/forbidden-403.json"; +import maskedNotFoundFixture from "./fixtures/security/masked-404.json"; +import partialFixture from "./fixtures/security/partial.json"; + +type FixtureResponse = { status: number; body?: unknown; message?: string }; +type SecurityFixture = { + name: string; + repository: FixtureResponse; + privateVulnerabilityReporting: FixtureResponse; + dependabotSecurityUpdates: FixtureResponse; +}; + +const controls = [ + "privateVulnerabilityReporting", + "secretScanning", + "pushProtection", + "nonProviderPatterns", + "validityChecks", + "dependabotSecurityUpdates", +] as const; + +function installFixture(fixture: SecurityFixture): GitHubClient { + const client = { + request: vi.fn(async (path: string, method = "GET") => { + if (method !== "GET") return {}; + const response = path.endsWith("/private-vulnerability-reporting") + ? fixture.privateVulnerabilityReporting + : path.endsWith("/automated-security-fixes") + ? fixture.dependabotSecurityUpdates + : fixture.repository; + if (response.status >= 400) { + throw new GitHubApiError({ + message: response.message ?? "fixture diagnostic detail", + method, + path, + status: response.status, + }); + } + return response.body ?? {}; + }), + requestWithMeta: vi.fn(async () => { throw new Error("not used"); }), + } as unknown as GitHubClient; + setGitHubClientFactoryForTests(() => client); + return client; +} + +function collectTools() { + const tools: any[] = []; + registerGitHubAdminTools({ registerTool: (tool: any) => tools.push(tool) } as any); + return new Map(tools.map((tool) => [tool.name, tool])); +} + +afterEach(() => setGitHubClientFactoryForTests()); + +describe("repository security capability results", () => { + it("reports enabled controls while preserving true legacy state and ok", async () => { + installFixture(enabledFixture as SecurityFixture); + const result = await verifyRepositorySecurity({ repo: "o/r" }); + + expect(result.ok).toBe(true); + expect(Object.values(result.state)).toEqual(Array(6).fill(true)); + for (const control of controls) { + expect(result.controls[control]).toMatchObject({ + capability: "available", + enabled: true, + status: "enabled", + reason: "reported", + }); + } + expect(result.recovery).toEqual([]); + }); + + it("distinguishes explicitly disabled controls from capability", async () => { + installFixture(disabledFixture as SecurityFixture); + const result = await verifyRepositorySecurity({ repo: "o/r" }); + + expect(result.ok).toBe(false); + expect(Object.values(result.state)).toEqual(Array(6).fill(false)); + for (const control of controls) { + expect(result.controls[control]).toMatchObject({ + capability: "available", + enabled: false, + status: "disabled", + reason: "reported", + }); + } + expect(result.recovery).toHaveLength(1); + }); + + it("reports omitted analysis controls as unavailable to the read", async () => { + installFixture(omittedFixture as SecurityFixture); + const result = await verifyRepositorySecurity({ repo: "o/r" }); + + expect(result.state).toEqual({ + privateVulnerabilityReporting: true, + secretScanning: false, + pushProtection: false, + nonProviderPatterns: false, + validityChecks: false, + dependabotSecurityUpdates: true, + }); + for (const control of ["secretScanning", "pushProtection", "nonProviderPatterns", "validityChecks"] as const) { + expect(result.controls[control]).toMatchObject({ + capability: "unavailable", + enabled: null, + status: "unavailable", + reason: "omitted", + }); + } + }); + + it("returns sanitized forbidden results instead of failing the whole read", async () => { + installFixture(forbiddenFixture as SecurityFixture); + const result = await verifyRepositorySecurity({ repo: "o/r" }); + + expect(result.ok).toBe(false); + for (const control of controls) { + expect(result.controls[control]).toMatchObject({ + capability: "unknown", + enabled: null, + status: "forbidden", + reason: "forbidden", + httpStatus: 403, + }); + } + expect(JSON.stringify(result)).not.toContain("diagnostic detail"); + }); + + it("treats 404 as masked or not found without claiming unsupported or disabled", async () => { + installFixture(maskedNotFoundFixture as SecurityFixture); + const result = await verifyRepositorySecurity({ repo: "o/r" }); + + expect(result.ok).toBe(false); + expect(Object.values(result.state)).toEqual(Array(6).fill(false)); + for (const control of controls) { + expect(result.controls[control]).toMatchObject({ + capability: "unknown", + enabled: null, + status: "unknown", + reason: "not_found_or_masked", + httpStatus: 404, + }); + } + expect(JSON.stringify(result)).not.toContain("diagnostic detail"); + }); + + it("keeps distinct per-control evidence in a partial response", async () => { + installFixture(partialFixture as SecurityFixture); + const result = await verifyRepositorySecurity({ repo: "o/r" }); + + expect(result.ok).toBe(false); + expect(Object.fromEntries(controls.map((control) => [control, result.controls[control].status]))).toEqual({ + privateVulnerabilityReporting: "forbidden", + secretScanning: "enabled", + pushProtection: "disabled", + nonProviderPatterns: "unavailable", + validityChecks: "enabled", + dependabotSecurityUpdates: "disabled", + }); + expect(result.recovery).toHaveLength(3); + }); + + it("preserves the legacy successful-endpoint default", async () => { + const fixture = structuredClone(enabledFixture) as SecurityFixture; + fixture.privateVulnerabilityReporting.body = {}; + fixture.dependabotSecurityUpdates.body = {}; + installFixture(fixture); + + const result = await verifyRepositorySecurity({ repo: "o/r" }); + expect(result.state.privateVulnerabilityReporting).toBe(true); + expect(result.state.dependabotSecurityUpdates).toBe(true); + expect(result.controls.privateVulnerabilityReporting.enabled).toBe(true); + }); + + it("does not claim a requested disabled state was verified from unknown capability", async () => { + installFixture(maskedNotFoundFixture as SecurityFixture); + const result = await configureRepositorySecurity({ repo: "o/r", secretScanning: false }); + if (result.dryRun) throw new Error("expected live verification result"); + + expect(result.state!.secretScanning).toBe(false); + expect(result.controls!.secretScanning).toMatchObject({ capability: "unknown", enabled: null }); + expect(result.verified).toBe(false); + }); + + it("emits bounded recovery text without raw API diagnostics", async () => { + installFixture(forbiddenFixture as SecurityFixture); + const tool = collectTools().get("github_verify_security"); + const result = await tool.execute("id", { repo: "o/r" }); + const text = result.content[0].text as string; + + expect(text).toContain("Recovery:"); + expect(text).toContain("minimum required"); + expect(text).not.toContain("diagnostic detail"); + expect(JSON.stringify(result.details)).not.toContain("diagnostic detail"); + }); +});