Skip to content

Commit 1b9a198

Browse files
committed
✨ Added independent probe for required MFA
Signed-off-by: Eddie Knight <[email protected]>
1 parent 1703089 commit 1b9a198

File tree

11 files changed

+233
-1
lines changed

11 files changed

+233
-1
lines changed

clients/git/client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,10 @@ func (c *Client) GetDefaultBranch() (*clients.BranchRef, error) {
333333
return nil, clients.ErrUnsupportedFeature
334334
}
335335

336+
func (c *Client) GetMFARequired() (required bool, err error) {
337+
return required, clients.ErrUnsupportedFeature
338+
}
339+
336340
func (c *Client) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
337341
return nil, clients.ErrUnsupportedFeature
338342
}

clients/githubrepo/client.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,17 @@ func (client *Client) GetCreatedAt() (time.Time, error) {
204204
return client.repo.CreatedAt.Time, nil
205205
}
206206

207+
func (client *Client) GetMFARequired() (required bool, err error) {
208+
org, _, err := client.repoClient.Organizations.Get(context.Background(), client.repourl.owner)
209+
if err != nil {
210+
return
211+
}
212+
if org.TwoFactorRequirementEnabled != nil {
213+
return *org.TwoFactorRequirementEnabled, nil
214+
}
215+
return false, nil
216+
}
217+
207218
func (client *Client) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
208219
dotGithubRepo, err := MakeGithubRepo(fmt.Sprintf("%s/.github", client.repourl.owner))
209220
if err != nil {

clients/gitlabrepo/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ func (client *Client) GetCreatedAt() (time.Time, error) {
236236
return client.project.getCreatedAt()
237237
}
238238

239+
func (c *Client) GetMFARequired() (required bool, err error) {
240+
err = fmt.Errorf("GetMFARequired: %w", clients.ErrUnsupportedFeature)
241+
return
242+
}
243+
239244
func (client *Client) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
240245
return nil, fmt.Errorf("GetOrgRepoClient (GitLab): %w", clients.ErrUnsupportedFeature)
241246
}

clients/localdir/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ func (client *localDirClient) GetDefaultBranchName() (string, error) {
194194
return "", fmt.Errorf("GetDefaultBranchName: %w", clients.ErrUnsupportedFeature)
195195
}
196196

197+
func (c *localDirClient) GetMFARequired() (required bool, err error) {
198+
err = fmt.Errorf("GetMFARequired: %w", clients.ErrUnsupportedFeature)
199+
return
200+
}
201+
197202
// ListCommits implements RepoClient.ListCommits.
198203
func (client *localDirClient) ListCommits() ([]clients.Commit, error) {
199204
return nil, fmt.Errorf("ListCommits: %w", clients.ErrUnsupportedFeature)

clients/mockclients/repo_client.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/ossfuzz/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ func (c *client) GetDefaultBranch() (*clients.BranchRef, error) {
197197
return nil, fmt.Errorf("GetDefaultBranch: %w", clients.ErrUnsupportedFeature)
198198
}
199199

200+
func (c *client) GetMFARequired() (required bool, err error) {
201+
err = fmt.Errorf("GetMFARequired: %w", clients.ErrUnsupportedFeature)
202+
return
203+
}
204+
200205
// GetOrgRepoClient implements RepoClient.GetOrgRepoClient.
201206
func (c *client) GetOrgRepoClient(ctx context.Context) (clients.RepoClient, error) {
202207
return nil, fmt.Errorf("GetOrgRepoClient: %w", clients.ErrUnsupportedFeature)

clients/repo_client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type RepoClient interface {
4444
GetCreatedAt() (time.Time, error)
4545
GetDefaultBranchName() (string, error)
4646
GetDefaultBranch() (*BranchRef, error)
47+
GetMFARequired() (bool, error)
4748
GetOrgRepoClient(context.Context) (RepoClient, error)
4849
ListCommits() ([]Commit, error)
4950
ListIssues() ([]Issue, error)

probes/entries.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
"github.com/ossf/scorecard/v5/probes/hasUnverifiedBinaryArtifacts"
4545
"github.com/ossf/scorecard/v5/probes/issueActivityByProjectMember"
4646
"github.com/ossf/scorecard/v5/probes/jobLevelPermissions"
47+
"github.com/ossf/scorecard/v5/probes/orgRequiresMFA"
4748
"github.com/ossf/scorecard/v5/probes/packagedWithAutomatedWorkflow"
4849
"github.com/ossf/scorecard/v5/probes/pinsDependencies"
4950
"github.com/ossf/scorecard/v5/probes/releasesAreSigned"
@@ -173,7 +174,9 @@ var (
173174
}
174175

175176
// Probes which don't use pre-computed raw data but rather collect it themselves.
176-
Independent = []IndependentProbeImpl{}
177+
Independent = []IndependentProbeImpl{
178+
orgRequiresMFA.Run,
179+
}
177180
)
178181

179182
//nolint:gochecknoinits

probes/orgRequiresMFA/def.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright 2024 OpenSSF Scorecard Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
id: orgRequiresMFA # required
16+
lifecycle: experimental # required
17+
short: A short description of this probe
18+
motivation: >
19+
What is the motivation for this probe?
20+
implementation: >
21+
How does this probe work under-the-hood?
22+
outcome:
23+
- If MFA is found to be required, the probe returns OutcomeTrue
24+
- IF MFA is not found to be required, the probe returns OutcomeFalse
25+
- If the runtime environment does not have authentication for the target project, the probe returns OutcomeNotAvailable
26+
remediation:
27+
onOutcome: False # required
28+
effort: Low # required
29+
text:
30+
- In your project settings, require MFA for all collaborators.

probes/orgRequiresMFA/impl.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright 2024 OpenSSF Scorecard Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//nolint:stylecheck
16+
package orgRequiresMFA
17+
18+
import (
19+
"embed"
20+
"fmt"
21+
22+
"github.com/ossf/scorecard/v5/checker"
23+
"github.com/ossf/scorecard/v5/finding"
24+
"github.com/ossf/scorecard/v5/internal/probes"
25+
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
26+
)
27+
28+
//go:embed *.yml
29+
var fs embed.FS
30+
31+
const (
32+
Probe = "orgRequiresMFA"
33+
)
34+
35+
func init() {
36+
// Register independently of any checks
37+
probes.MustRegisterIndependent(Probe, Run)
38+
}
39+
40+
func Run(raw *checker.CheckRequest) (found []finding.Finding, probeName string, err error) {
41+
probeName = Probe
42+
if raw == nil {
43+
err = fmt.Errorf("raw results is nil: %w", uerror.ErrNil)
44+
return
45+
}
46+
47+
mfaRequired, err := raw.RepoClient.GetMFARequired()
48+
if err != nil {
49+
err = fmt.Errorf("getting MFA required: %w", err)
50+
return
51+
}
52+
53+
var outcome finding.Outcome
54+
if mfaRequired {
55+
outcome = finding.OutcomeTrue
56+
} else {
57+
outcome = finding.OutcomeFalse
58+
}
59+
60+
result, err := finding.NewWith(
61+
fs,
62+
Probe,
63+
"Collaborators require MFA",
64+
nil,
65+
outcome,
66+
)
67+
68+
if err != nil {
69+
err = fmt.Errorf("creating finding: %w", err)
70+
return
71+
}
72+
73+
found = append(found, *result)
74+
75+
return
76+
}

probes/orgRequiresMFA/impl_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package orgRequiresMFA
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/ossf/scorecard/v5/checker"
8+
mockrepo "github.com/ossf/scorecard/v5/clients/mockclients"
9+
"github.com/ossf/scorecard/v5/finding"
10+
"github.com/ossf/scorecard/v5/probes/internal/utils/uerror"
11+
)
12+
13+
// ConfigurableMockRepoClient mocks RepoClient with configurable return values.
14+
type ConfigurableMockRepoClient struct {
15+
mockrepo.MockRepoClient
16+
MFARequired bool
17+
ReturnError error
18+
}
19+
20+
// GetMFARequired returns the pre-set value for MFA requirement.
21+
func (m *ConfigurableMockRepoClient) GetMFARequired() (bool, error) {
22+
return m.MFARequired, m.ReturnError
23+
}
24+
25+
func (m *ConfigurableMockRepoClient) Close() error { return nil }
26+
27+
func TestProbeCodeApproved(t *testing.T) {
28+
t.Parallel()
29+
probeTests := []struct {
30+
name string
31+
rawResults *checker.CheckRequest
32+
expectedError error
33+
expectedCount int
34+
expectedOutcome finding.Outcome
35+
}{
36+
{
37+
name: "mfa check succeeded",
38+
rawResults: &checker.CheckRequest{
39+
RepoClient: &ConfigurableMockRepoClient{MFARequired: true, ReturnError: nil},
40+
},
41+
expectedError: nil,
42+
expectedCount: 1,
43+
expectedOutcome: finding.OutcomeTrue,
44+
},
45+
{
46+
name: "error retrieving MFA status",
47+
rawResults: &checker.CheckRequest{
48+
RepoClient: &ConfigurableMockRepoClient{ReturnError: uerror.ErrNil},
49+
},
50+
expectedError: uerror.ErrNil,
51+
expectedCount: 0,
52+
expectedOutcome: finding.OutcomeFalse,
53+
},
54+
{
55+
name: "nil raw results",
56+
rawResults: nil,
57+
expectedError: uerror.ErrNil,
58+
expectedCount: 0,
59+
expectedOutcome: finding.OutcomeFalse,
60+
},
61+
}
62+
63+
for _, tt := range probeTests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
found, probeName, err := Run(tt.rawResults)
66+
67+
if probeName != Probe {
68+
t.Errorf("unexpected probe name: got %v, want %v", probeName, Probe)
69+
}
70+
71+
if !errors.Is(err, tt.expectedError) {
72+
t.Errorf("unexpected error: got %v, want %v", err, tt.expectedError)
73+
}
74+
75+
if len(found) != tt.expectedCount {
76+
t.Errorf("unexpected number of findings: got %d, want %d", len(found), tt.expectedCount)
77+
}
78+
79+
if len(found) > 0 && found[0].Outcome != tt.expectedOutcome {
80+
t.Errorf("unexpected outcome: got %v, want %v", found[0].Outcome, tt.expectedOutcome)
81+
}
82+
})
83+
}
84+
}

0 commit comments

Comments
 (0)