diff --git a/lint/capability_fields_analyser.go b/lint/capability_fields_analyser.go new file mode 100644 index 00000000..ef9d6378 --- /dev/null +++ b/lint/capability_fields_analyser.go @@ -0,0 +1,140 @@ +/* + * Cadence-lint - The Cadence linter + * + * Copyright 2019-2023 Dapper Labs, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lint + +import ( + "github.com/onflow/cadence/runtime/ast" + "github.com/onflow/cadence/runtime/common" + "github.com/onflow/cadence/tools/analysis" +) + +func DetectCapabilityType(typeToCheck ast.Type, compositesWithPubCapabilities map[string]struct{}) bool { + const capabilityTypeName = "Capability" + switch downcastedType := typeToCheck.(type) { + case *ast.NominalType: + _, found := compositesWithPubCapabilities[downcastedType.Identifier.Identifier] + return found || downcastedType.Identifier.Identifier == capabilityTypeName + case *ast.OptionalType: + return DetectCapabilityType(downcastedType.Type, compositesWithPubCapabilities) + case *ast.VariableSizedType: + return DetectCapabilityType(downcastedType.Type, compositesWithPubCapabilities) + case *ast.ConstantSizedType: + return DetectCapabilityType(downcastedType.Type, compositesWithPubCapabilities) + case *ast.DictionaryType: + return DetectCapabilityType(downcastedType.KeyType, compositesWithPubCapabilities) || DetectCapabilityType(downcastedType.ValueType, compositesWithPubCapabilities) + case *ast.FunctionType: + return false + case *ast.ReferenceType: + return DetectCapabilityType(downcastedType.Type, compositesWithPubCapabilities) + case *ast.RestrictedType: + return false + case *ast.InstantiationType: + return DetectCapabilityType(downcastedType.Type, compositesWithPubCapabilities) + default: + panic("Unknown type") + } +} + +func CollectCompositesWithPublicCapabilities(inspector *ast.Inspector) (map[string]struct{}, map[ast.Identifier]struct{}) { + compositesWithPubCapabilities := make(map[string]struct{}) + fieldsInComposite := make(map[ast.Identifier]struct{}) + inspector.Preorder( + []ast.Element{(*ast.CompositeDeclaration)(nil)}, + func(element ast.Element) { + switch declaration := element.(type) { + case *ast.CompositeDeclaration: + { + for _, d := range declaration.Members.Declarations() { + field, ok := d.(*ast.FieldDeclaration) + if !ok || field.Access != ast.AccessPublic { + return + } + if DetectCapabilityType(field.TypeAnnotation.Type, compositesWithPubCapabilities) { + if declaration.CompositeKind != common.CompositeKindContract { + // public capability fields in contracts are not included in this set as later + // on it is used to exclude false positive. And while a struct with a public capability + // can be a false positive a contract with a public capability is always an anti pattern. + fieldsInComposite[field.Identifier] = struct{}{} + } + + compositesWithPubCapabilities[declaration.Identifier.Identifier] = struct{}{} + } + } + } + } + }, + ) + return compositesWithPubCapabilities, fieldsInComposite +} + +var CapabilityFieldAnalyzer = (func() *analysis.Analyzer { + + elementFilter := []ast.Element{ + (*ast.FieldDeclaration)(nil), + } + + return &analysis.Analyzer{ + Description: "Detects public fields with Capability type", + Requires: []*analysis.Analyzer{ + analysis.InspectorAnalyzer, + }, + Run: func(pass *analysis.Pass) interface{} { + inspector := pass.ResultOf[analysis.InspectorAnalyzer].(*ast.Inspector) + location := pass.Program.Location + report := pass.Report + structTypesPublicCapability, fieldsInStruct := CollectCompositesWithPublicCapabilities(inspector) + + inspector.Preorder( + elementFilter, + func(element ast.Element) { + field, ok := element.(*ast.FieldDeclaration) + if !ok { + return + } + _, found := fieldsInStruct[field.Identifier] + if found { + return + } + if field.Access == ast.AccessPublic && DetectCapabilityType(field.TypeAnnotation.Type, structTypesPublicCapability) { + report( + analysis.Diagnostic{ + Location: location, + Range: ast.NewRangeFromPositioned(nil, element), + Category: UpdateCategory, + Message: "It is an anti-pattern to have public Capability fields.", + SecondaryMessage: "Consider restricting access.", + }, + ) + + } + + }, + ) + + return nil + }, + } +})() + +func init() { + RegisterAnalyzer( + "public-capability-field", + CapabilityFieldAnalyzer, + ) +} diff --git a/lint/capability_fields_analyser_test.go b/lint/capability_fields_analyser_test.go new file mode 100644 index 00000000..e17392b5 --- /dev/null +++ b/lint/capability_fields_analyser_test.go @@ -0,0 +1,404 @@ +/* + * Cadence-lint - The Cadence linter + * + * Copyright 2019-2023 Dapper Labs, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package lint_test + +import ( + "testing" + + "github.com/onflow/cadence/runtime/ast" + "github.com/onflow/cadence/tools/analysis" + "github.com/stretchr/testify/require" + + "github.com/onflow/cadence-tools/lint" +) + +func TestCapabilityFieldInContract(t *testing.T) { + + t.Parallel() + + t.Run("public capability field", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract ExposingCapability { + pub let my_capability : Capability? + init() { + self.my_capability = nil + } + } + `, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic{ + { + Range: ast.Range{ + StartPos: ast.Position{Offset: 44, Line: 3, Column: 5}, + EndPos: ast.Position{Offset: 78, Line: 3, Column: 39}, + }, + Location: testLocation, + Category: lint.UpdateCategory, + Message: "It is an anti-pattern to have public Capability fields.", + SecondaryMessage: "Consider restricting access.", + }, + }, + diagnostics, + ) + }) + + t.Run("private capability field", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract ExposingCapability { + priv let my_capability : Capability? + pub let data: Int + init() { + self.my_capability = nil + self.data = 42 + } + } + `, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic(nil), + diagnostics, + ) + }) +} + +func TestPublicDictionaryFieldWithCapabilityValueType(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract ExposingCapability { + pub let my_capability : {String: Capability?} + init() { + self.my_capability = {"key": nil} + } + } + `, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic{ + { + Range: ast.Range{ + StartPos: ast.Position{Offset: 48, Line: 3, Column: 7}, + EndPos: ast.Position{Offset: 92, Line: 3, Column: 51}, + }, + Location: testLocation, + Category: lint.UpdateCategory, + Message: "It is an anti-pattern to have public Capability fields.", + SecondaryMessage: "Consider restricting access.", + }, + }, + diagnostics, + ) +} + +func TestPublicArrayFieldWithConcreteCapabilityType(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract ExposingCapability { + pub resource interface MySecretStuffInterface {} + pub let array_capability : [Capability<&{MySecretStuffInterface}>] + init() { + self.array_capability = [self.account.link<&{MySecretStuffInterface}>(/public/Stuff, target: /storage/Stuff)!] + } + } + `, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic{ + { + Range: ast.Range{ + StartPos: ast.Position{Offset: 104, Line: 4, Column: 7}, + EndPos: ast.Position{Offset: 169, Line: 4, Column: 72}, + }, + Location: testLocation, + Category: lint.UpdateCategory, + Message: "It is an anti-pattern to have public Capability fields.", + SecondaryMessage: "Consider restricting access.", + }, + }, + diagnostics, + ) +} + +func TestImplicitCapabilityLeakViaArray(t *testing.T) { + + t.Parallel() + + t.Run("public leaking capability in an array", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract MyContract { + pub let myCapArray: [Capability] + init() { + self.myCapArray = [] + } + } + `, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic{ + { + Range: ast.Range{ + StartPos: ast.Position{Offset: 38, Line: 3, Column: 6}, + EndPos: ast.Position{Offset: 69, Line: 3, Column: 37}, + }, + Location: testLocation, + Category: lint.UpdateCategory, + Message: "It is an anti-pattern to have public Capability fields.", + SecondaryMessage: "Consider restricting access.", + }, + }, + diagnostics, + ) + }) + + t.Run("private nonleaking capability in an array", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract MyContract { + priv let myCapArray: [Capability] + + init() { + self.myCapArray = [] + } + } + `, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic(nil), + diagnostics, + ) + }) +} + +func TestImplicitCapabilityLeakViaStruct(t *testing.T) { + t.Parallel() + + t.Run("leak via struct field", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract MyContract { + pub resource Counter { + priv var count: Int + + init(count: Int) { + self.count = count + } + } + + pub struct ContractData { + pub var owner: Capability + init(cap: Capability) { + self.owner = cap + } + } + + pub struct ContractDataIgnored { + pub var owner: Capability + init(cap: Capability) { + self.owner = cap + } + } + + pub var contractData: ContractData + + init(){ + self.contractData = ContractData(cap: + self.account.getCapability<&Counter>(/public/counter) + ) + } + }`, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic{ + { + Range: ast.Range{ + StartPos: ast.Position{Offset: 484, Line: 25, Column: 6}, + EndPos: ast.Position{Offset: 517, Line: 25, Column: 39}, + }, + Location: testLocation, + Category: lint.UpdateCategory, + Message: "It is an anti-pattern to have public Capability fields.", + SecondaryMessage: "Consider restricting access.", + }, + }, + diagnostics, + ) + }) + + t.Run("valid", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub contract MyContract { + pub resource Counter { + priv var count: Int + + init(count: Int) { + self.count = count + } + } + + pub struct ContractData { + pub var owner: Capability + init(cap: Capability) { + self.owner = cap + } + } + + priv var contractData: ContractData + + init(){ + self.contractData = ContractData(cap: + self.account.getCapability<&Counter>(/public/counter) + ) + } + } + `, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic(nil), + diagnostics, + ) + }) +} + +func TestImplicitCapabilityLeakViaResource(t *testing.T) { + t.Parallel() + + t.Run("leak via resource field", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub resource MyResource { + pub let my_capability : Capability? + init() { + self.my_capability = nil + } + } + pub contract MyContract { + + // Declare a public field using the MyResource resource + pub var myResource: @MyResource? + + // Initialize the contract + init() { + self.myResource <- nil + } + }`, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic{ + { + Range: ast.Range{ + StartPos: ast.Position{Offset: 209, Line: 11, Column: 3}, + EndPos: ast.Position{Offset: 240, Line: 11, Column: 34}, + }, + Location: testLocation, + Category: lint.UpdateCategory, + Message: "It is an anti-pattern to have public Capability fields.", + SecondaryMessage: "Consider restricting access.", + }, + }, + diagnostics, + ) + }) + t.Run("valid", func(t *testing.T) { + + t.Parallel() + + diagnostics := testAnalyzers(t, + ` + pub resource MyResource { + pub let my_capability : Capability? + init() { + self.my_capability = nil + } + } + pub contract MyContract { + + priv var myResource: @MyResource? + + init() { + self.myResource <- nil + } + }`, + lint.CapabilityFieldAnalyzer, + ) + + require.Equal( + t, + []analysis.Diagnostic(nil), + diagnostics, + ) + }) +}