Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[lint] Capability field analyzer #176

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
162 changes: 162 additions & 0 deletions lint/capability_fields_analyser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* 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) bool {
const capabilityTypeName = "Capability"
turbolent marked this conversation as resolved.
Show resolved Hide resolved
switch downcastedType := typeToCheck.(type) {
case *ast.NominalType:
return downcastedType.Identifier.Identifier == capabilityTypeName
case *ast.OptionalType:
return DetectCapabilityType(downcastedType.Type)
case *ast.VariableSizedType:
return DetectCapabilityType(downcastedType.Type)
case *ast.ConstantSizedType:
return DetectCapabilityType(downcastedType.Type)
case *ast.DictionaryType:
return DetectCapabilityType(downcastedType.KeyType) || DetectCapabilityType(downcastedType.ValueType)
case *ast.FunctionType:
return false
case *ast.ReferenceType:
return DetectCapabilityType(downcastedType.Type)
case *ast.RestrictedType:
return false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the inner types of the restricted type would also need to be visited recursively.
e.g:

pub contract MyContract {
	
    pub var contractData: ContractData{SomeInterface}  //  <-- A restricted type, whose inner type has a public capability field.

    pub struct ContractData: SomeInterface {
        pub var owner: Capability

        init(cap: Capability) {
            self.owner = cap
        }
    }

    pub struct interface SomeInterface { 
        pub var owner: Capability
    }

}

case *ast.InstantiationType:
return DetectCapabilityType(downcastedType.Type)
default:
panic("Unknown type")
}
}

func CollectCompositeIdentifiers(inspector *ast.Inspector) (map[string]bool, map[ast.Identifier]bool) {
compositeIdsCapabilitiesPropery := make(map[string]bool)
fieldsInStruct := make(map[ast.Identifier]bool)
inspector.Preorder(
[]ast.Element{(*ast.CompositeDeclaration)(nil)},
func(element ast.Element) {
switch declaration := element.(type) {
case *ast.CompositeDeclaration:
{
if declaration.CompositeKind != common.CompositeKindStructure {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why analyze only structures, and not e.g. resources or contracts?

Copy link
Author

@kozlovb kozlovb Aug 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. My thought process was the following ( I mean I saw the idea of looking into structs in the Nozim's patch) -
Structures can be used as types to declare fields in contracts. So, in that way we would have a map of the structs that have a potentially dangerous public capabilities that might leak. I am referring to this test -

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)
       )
   }
}

I think for contracts it doesn't make sense to have such a map. Or is it? I have my day off on Friday so can take a more in depth look.

For resources you are probably right as a resource might contain a public capability which links to the other resource. I need to check Cadence a bit as it's been a while since I looked into it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Given that a public capability fields is just as problematic in a resource or contract as it is in a struct, it might make sense to just detect it everywhere, i.e. remove the if-statement.

Copy link
Author

@kozlovb kozlovb Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea. For contracts it was a bit more tricky, I put a comment in - CollectCompositesWithPublicCapabilities.
May be should rename this function as it not only collects composites with pub capabilities but also field declarations to exclude.

I mean -

pub resource MyResource {
    pub let my_capability : Capability?
    init() {
        self.my_capability = nil
    }
}

pub contract MyContract {

priv var myResource: @MyResource?
    init() {
        self.myResource <- nil
    }
}`

this test passes cause CollectCompositesWithPublicCapabilities puts pub let my_capability : Capability?
in fieldsInComposite which allows to ignore it in inspector.Preorder. Kind of a main pass where leaks are detected.

I've added a test with resources. Pls take a look

return
}
for _, d := range declaration.Members.Declarations() {
kozlovb marked this conversation as resolved.
Show resolved Hide resolved
fd, ok := d.(*ast.FieldDeclaration)
if !ok {
return
}
if fd.Access != ast.AccessPublic {
return
}
if fd.Access == ast.AccessPublic && DetectCapabilityType(fd.TypeAnnotation.Type) {
fieldsInStruct[fd.Identifier] = true
compositeIdsCapabilitiesPropery[declaration.Identifier.Identifier] = true
}
}
}
}
},
)
return compositeIdsCapabilitiesPropery, fieldsInStruct
}

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
compositeIdsCapabilitiesPropery, fieldsInStruct := CollectCompositeIdentifiers(inspector)

inspector.Preorder(
elementFilter,
func(element ast.Element) {

field, ok := element.(*ast.FieldDeclaration)
if !ok {
return
}

nt, ok := field.TypeAnnotation.Type.(*ast.NominalType)
if ok {
hasCapability, found := compositeIdsCapabilitiesPropery[nt.Identifier.Identifier]

if found {
kozlovb marked this conversation as resolved.
Show resolved Hide resolved
if hasCapability && field.Access == ast.AccessPublic {
kozlovb marked this conversation as resolved.
Show resolved Hide resolved
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
}
}
}
_, found := fieldsInStruct[field.Identifier]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping based on the name can be a bit problematic. For e.g. what if both the contract and the struct/resource have fields with the same name? Can you maybe add a test?

Copy link
Author

@kozlovb kozlovb Sep 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, I'll take a look at it today. Probably, a more elaborated key would be required like name+position or something of a sort.

Copy link
Member

@SupunS SupunS Sep 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe instead of doing it in two iterations, perform this check in a single iteration.
i.e:

  • Visit each public field of the contract
  • Check if the type of the field has public capability fields:
    • For that, visit the type then and there, recursively.
    • Once the result is found memoize it so it can be looked up if the same type is encountered again. (in the memoized mapping, Type could be the key, and the result/boolean could be the value)
  • It might be easier to use the sema-type instead.

I haven't actually tried this at the code level, but just an idea that might be easier to implement.

Copy link
Author

@kozlovb kozlovb Sep 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, finally didn't have time on Monday, now I am aiming Friday. (I work full time Tue, Wed, Thu)

As for using sema we had this discussion with Bastian, pls take a look at my comment
#176 (comment)

As for your proposal regarding one path.
"Once the result is found memoize it so it can be looked up if the same type is encountered again. (in the memoized mapping, Type could be the key, and the result/boolean could be the value)" - I think it's a valid optimization that would allow to limit number of runs of DetectCapabilityType. Although, may be there ll be difficulties in case of name collisions that you've mentioned in your previous comment. ( the trick with key - name+location won't work here I think)

However, I needed 2 paths for a different reason.

1st path detects composites , so resources and structs with public capability type. However , it's not a bad pattern yet to have them declared like this. if this struct is only used as a private field in a contract that's fine.
2nd path well ignores pub capabilities inside of structs and resources but checks them in contracts and also takes structs/resources with pub capabilities from 1st path and checks if those are used to declare public fields.

Basically, pls take a look at this test -

`
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)
         )
    }
}
`

it was reporting a false warning before I introduced 2 paths. ContractData - is only used to declare a private field so no warning should be issued. I just couldn't come up with a solution that uses one path and works for these kind of tests.

Copy link
Member

@SupunS SupunS Sep 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may be there ll be difficulties in case of name collisions

In the algorithm I suggested, there will be no name collisions since field names are not looked up. Fields are visited in-place.

it was reporting a false warning before I introduced 2 paths. I just couldn't come up with a solution that uses one path.

Here, the approach I suggested would not report any errors. Since it first (and only) visit the field priv var contractData: ContractData, sees that field is private, so skips.
Suppose the field was public, then only it will go and check the ContractData type.

Copy link
Member

@SupunS SupunS Sep 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like I mentioned in #176 (comment), we would only need to visit contract fields, no need to visit type definitions, unless it is encountered as a type of a public field.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, how would it visit ContractData if needed ?
May be there is a way to run inspector.Preorder( ... on a specific struct but I didn't find an example.
Well, I am not saying that one path wont work but I just fail to see how to achieve it with my current knowledge.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Like I mentioned in #176 (comment), we would only need to visit contract fields, no need to visit type definitions, unless it is encountered as a type of a public field."

ah sorry I didn't understand that you proposed to ignore definitions.
I don't know how to ignore type definitions but I imagine there should be a way to filter them out together with resources. However, if I would need to analyze ContractData how I can make a path through it ?
Is there a way to run an inspector on a specific struct definition ?

Other thing, the advantage of having two paths - all structs with pub capabilities are stored in a map so there ll be no need to analyze them on each encounter.

Copy link
Member

@SupunS SupunS Sep 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pass.Program already has the ast nodes as well as type info to look up. So when you know a type (say, ContractData), it can be retrieved from the pass.Program. Here you can either retrieve the ast-type-definition (from pass.Program.Program) or the sema-type (from pass.Program.Elaboration)

Copy link
Author

@kozlovb kozlovb Sep 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thx for the suggestion. For now I don't understand how to transit from
ast-type-definition (in pass.Program.Program) to the "reduced" inspector so to be able to run

func CollectCompositesWithPublicCapabilities(inspector *ast.Inspector) (map[string]struct{}, map[ast.Identifier]struct{}) {

on an inspector that only is a result of this specific node. But I'll take a look.

Other note - anyway I think caching in the map is nice as this would allow not to re-analyze the same structs if used to declare several fields in different places of a contract.

if found {
//if its in a struct it is treated by another check
return
}

if field.Access == ast.AccessPublic && DetectCapabilityType(field.TypeAnnotation.Type) {
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,
)
}
Loading