Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
ifc: fix confidentiality under-classification in releases, collaborat…
…ors, get_me

Audit for the same bug class as the repo-advisory fix (confidentiality
derived from a coarse signal that misses access-restricted items) found
three more under-classifications:

- Releases (list_releases, get_latest_release, get_release_by_tag): draft
  releases are visible only to push-access users and are not world-readable
  even on a public repo. New LabelRelease(isPrivate, hasDraft) returns public
  only for a non-draft release on a public repo; handlers compute hasDraft
  from the response (Draft flag / per-item scan).
- list_repository_collaborators: a collaborator roster requires push access
  to list, so it is never world-readable, not even on a public repo. New
  LabelCollaboratorRoster() is always PrivateTrusted (mirrors LabelTeam),
  replacing the repo-visibility-derived label.
- get_me: the result includes private_gists / total_private_repos /
  owned_private_repos, which are not part of the public profile. LabelGetMe
  is now PrivateTrusted instead of PublicTrusted.

Verified the remaining public-capable labels are sound: Actions logs are
world-readable on public repos; branches/tags are public metadata; gist,
project, search, and starred-repo labels read per-item visibility and join.

Adds ifc unit tests for the new/changed labels and a get_release_by_tag
handler regression test (draft on public repo -> private); updates the
get_me handler test to assert private.
  • Loading branch information
JoannaaKL committed Jun 11, 2026
commit fcffda3e1672090bee790e7f2ab902900af32639
4 changes: 3 additions & 1 deletion pkg/github/context_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ func Test_GetMe_IFC_FeatureFlag(t *testing.T) {
require.NoError(t, err)

assert.Equal(t, "trusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
// get_me returns the caller's private repo/gist counts, which are not
// part of the public profile, so confidentiality is private.
assert.Equal(t, "private", ifcMap["confidentiality"])
})
}

Expand Down
42 changes: 33 additions & 9 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -1806,8 +1806,21 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool {

result := utils.NewToolResultText(string(r))
// Releases are published by collaborators with push access, so
// integrity is trusted. Confidentiality follows repo visibility.
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata)
// integrity is trusted. Confidentiality follows repo visibility,
// but draft releases are visible only to push-access users and are
// not world-readable even on a public repo, so the result is only
// public when no returned release is a draft.
hasDraft := false
for _, mr := range minimalReleases {
if mr.Draft {
hasDraft = true
break
}
}
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result,
func(isPrivate bool) ifc.SecurityLabel {
return ifc.LabelRelease(isPrivate, hasDraft)
})
return result, nil, nil
},
)
Expand Down Expand Up @@ -1876,8 +1889,13 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool

result := utils.NewToolResultText(string(r))
// Releases are published by collaborators with push access, so
// integrity is trusted. Confidentiality follows repo visibility.
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata)
// integrity is trusted. The "latest release" endpoint never returns
// a draft, but the draft flag is honored defensively: a draft is
// not world-readable even on a public repo.
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result,
func(isPrivate bool) ifc.SecurityLabel {
return ifc.LabelRelease(isPrivate, release.GetDraft())
})
return result, nil, nil
},
)
Expand Down Expand Up @@ -1957,8 +1975,13 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool

result := utils.NewToolResultText(string(r))
// Releases are published by collaborators with push access, so
// integrity is trusted. Confidentiality follows repo visibility.
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata)
// integrity is trusted. A release fetched by tag may be a draft,
// which is visible only to push-access users and not world-readable
// even on a public repo, so a draft forces private confidentiality.
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result,
func(isPrivate bool) ifc.SecurityLabel {
return ifc.LabelRelease(isPrivate, release.GetDraft())
})
return result, nil, nil
},
)
Expand Down Expand Up @@ -2343,9 +2366,10 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory

callResult := MarshalledTextResult(response)
// The collaborator roster is GitHub-maintained membership data
// (trusted, not attacker-authored). Confidentiality follows repo
// visibility — collaborators of a private repo are not public.
callResult = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, callResult, ifc.LabelRepoMetadata)
// (trusted, not attacker-authored). Listing collaborators requires
// push access, so the roster is never world-readable — not even on
// a public repo — hence always private confidentiality.
callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.LabelCollaboratorRoster())
return callResult, nil, nil
},
)
Expand Down
101 changes: 101 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3838,6 +3838,107 @@ func Test_GetReleaseByTag(t *testing.T) {
}
}

// Test_GetReleaseByTag_IFC_FeatureFlag verifies the IFC label on
// get_release_by_tag. The label is only present when the ifc_labels flag is
// enabled, and confidentiality is public only for a non-draft release on a
// public repo. A draft release is visible only to push-access users, so even
// on a public repo it must be labeled private. Guards against the same
// under-classification fixed for repository security advisories.
func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) {
t.Parallel()

serverTool := GetReleaseByTag(translations.NullTranslationHelper)

makeRelease := func(draft bool) *github.RepositoryRelease {
return &github.RepositoryRelease{
ID: github.Ptr(int64(1)),
TagName: github.Ptr("v1.0.0"),
Name: github.Ptr("v1.0.0"),
Draft: github.Ptr(draft),
}
}

makeMockClient := func(isPrivate bool, release *github.RepositoryRelease) *http.Client {
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposReleasesTagsByOwnerByRepoByTag: mockResponse(t, http.StatusOK, release),
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
"name": "repo",
"private": isPrivate,
}),
})
}

reqParams := map[string]any{"owner": "owner", "repo": "repo", "tag": "v1.0.0"}

readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) {
t.Helper()
if result.Meta == nil {
return nil, false
}
label, ok := result.Meta["ifc"]
if !ok {
return nil, false
}
labelJSON, err := json.Marshal(label)
require.NoError(t, err)
var labelMap map[string]any
require.NoError(t, json.Unmarshal(labelJSON, &labelMap))
return labelMap, true
}

t.Run("feature flag disabled omits ifc label", func(t *testing.T) {
t.Parallel()
deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false)))}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Nil(t, result.Meta)
})

t.Run("public repo with published release is public", func(t *testing.T) {
t.Parallel()
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false))),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)

label, ok := readIFC(t, result)
require.True(t, ok)
assert.Equal(t, "trusted", label["integrity"])
assert.Equal(t, "public", label["confidentiality"])
})

t.Run("public repo with draft release is private", func(t *testing.T) {
t.Parallel()
// Reviewer-class scenario: a draft release on a public repo is not
// world-readable, so the label must not be public.
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(false, makeRelease(true))),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)

label, ok := readIFC(t, result)
require.True(t, ok)
assert.Equal(t, "trusted", label["integrity"])
assert.Equal(t, "private", label["confidentiality"], "draft release on public repo must be private")
})
}

func Test_looksLikeSHA(t *testing.T) {
tests := []struct {
name string
Expand Down
44 changes: 43 additions & 1 deletion pkg/ifc/ifc.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,18 @@ func PrivateUntrusted() SecurityLabel {
}
}

// LabelGetMe returns the IFC label for the authenticated user's own profile
// (get_me).
//
// Integrity is trusted: this is GitHub-maintained data about the caller's own
// account, not attacker-authored content.
//
// Confidentiality is private. The result includes fields that are NOT part of
// the user's public profile — private_gists, total_private_repos, and
// owned_private_repos — which are visible only to the authenticated user. The
// result therefore must not be treated as world-readable.
func LabelGetMe() SecurityLabel {
return PublicTrusted()
return PrivateTrusted()
}

// LabelListIssues returns the IFC label for a list_issues result.
Expand Down Expand Up @@ -128,6 +138,38 @@ func LabelRepoMetadata(isPrivate bool) SecurityLabel {
return PublicTrusted()
}

// LabelRelease returns the IFC label for repository releases (list_releases,
// get_latest_release, get_release_by_tag).
//
// Integrity is trusted: releases are published by collaborators with push
// access, not by arbitrary outsiders.
//
// Confidentiality is public only when the repository is public AND no returned
// release is a draft. Draft releases are visible only to users with push
// access — they are NOT world-readable even on a public repository — so a
// result containing one must be private. hasDraft reflects whether any release
// in the result is a draft; private repositories are always private regardless.
func LabelRelease(isPrivate bool, hasDraft bool) SecurityLabel {
if isPrivate || hasDraft {
return PrivateTrusted()
}
return PublicTrusted()
}

// LabelCollaboratorRoster returns the IFC label for a repository's collaborator
// list (list_repository_collaborators).
//
// Integrity is trusted: the roster is GitHub-maintained membership data, not
// attacker-authored content.
//
// Confidentiality is always private. Listing collaborators requires push
// access to the repository, so the roster is never world-readable — not even
// for public repositories. This mirrors LabelTeam: membership data is
// restricted regardless of the repository's own visibility.
func LabelCollaboratorRoster() SecurityLabel {
return PrivateTrusted()
}

// LabelCommitContents returns the IFC label for committed repository content
// reachable from the default branch and its history: commits, commit diffs,
// and the repository file tree.
Expand Down
50 changes: 50 additions & 0 deletions pkg/ifc/ifc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,56 @@ func TestLabelRepoMetadata(t *testing.T) {
})
}

func TestLabelGetMe(t *testing.T) {
t.Parallel()

// get_me exposes private_gists/total_private_repos/owned_private_repos,
// which are not part of the public profile, so the result is trusted but
// private — never public.
label := LabelGetMe()
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
}

func TestLabelRelease(t *testing.T) {
t.Parallel()

t.Run("public repo with no draft is trusted and public", func(t *testing.T) {
t.Parallel()
label := LabelRelease(false, false)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})

t.Run("public repo with a draft release is private", func(t *testing.T) {
t.Parallel()
// Draft releases are visible only to push-access users, so a draft on
// a public repo must not be labeled public.
label := LabelRelease(false, true)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
})

t.Run("private repo is private regardless of draft", func(t *testing.T) {
t.Parallel()
for _, hasDraft := range []bool{false, true} {
label := LabelRelease(true, hasDraft)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
}
})
}

func TestLabelCollaboratorRoster(t *testing.T) {
t.Parallel()

// A collaborator roster requires push access to list, so it is never
// world-readable — always trusted and private.
label := LabelCollaboratorRoster()
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
}

func TestLabelCommitContents(t *testing.T) {
t.Parallel()

Expand Down
Loading