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

RFC: Create SecretBuffer and use it to help keep LibGit2's secrets #27565

Merged
merged 19 commits into from
Jun 23, 2018

Conversation

mbauman
Copy link
Member

@mbauman mbauman commented Jun 13, 2018

This PR is building upon @omus' #24738, transitioned to using an IO-like API instead of an AbstractString one. It definitely makes some things more awkward, but on the plus side, it also has made me vet and correct many places where we were still transitioning the secrets through Strings in LibGit2.

It has the major downside that the returned object from Base.prompt changes drastically depending upon the password keyword. Edit: Base.prompt and Base.getpass are now detangled.

I initially tried following the approach in #24738, wherein all the credential information (including usernames and key paths) is considered secret. This proved to be horribly cumbersome and a huge dungeon of kludges and work-arounds. This PR now only considers the passwords themselves to be secret — and that works quite well.

@ararslan ararslan requested a review from omus June 13, 2018 23:37
@ararslan ararslan added libgit2 The libgit2 library or the LibGit2 stdlib module security System security concerns and vulnerabilities labels Jun 13, 2018
@Keno Keno added this to the 0.7 milestone Jun 13, 2018
Copy link
Member

@omus omus left a comment

Choose a reason for hiding this comment

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

Fantastic work! You found some good solutions to some issues I was struggling with. The IO based SecureBuffer design worked out really well. I'll do some further testing yet.

@@ -1180,7 +1180,7 @@ function objtype(obj_type::Consts.OBJECT)
end
end

import Base.securezero!
import Base.Base.shred!
Copy link
Member

Choose a reason for hiding this comment

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

Find replace issue?

while !eof(io) && (c = read(io, UInt8)) != UInt8('\n')
write(value, c)
end
seek(value, 0)
Copy link
Member

Choose a reason for hiding this comment

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

Personal preference but I prefer seekstart

result = let
$code
result = open($input_code) do fp
eval(deserialize(fp))
Copy link
Member

Choose a reason for hiding this comment

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

Why is this change now necessary?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure if it's still necessary, but it definitely was at one point and it seems like a good improvement. Before we were trusting that the string representation of a expression was round-trippable. This isn't just Exprs, it also includes embedded credential objects spliced directly into the AST. If their stringification isn't round-trippable, this fails.

@@ -318,27 +323,26 @@ end
url = LibGit2.git_url(
scheme="https",
username="user",
password="pass",
# password="pass",
Copy link
Member

Choose a reason for hiding this comment

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

We should just remove these lines

@@ -0,0 +1,45 @@
## Secure strings ##
Copy link
Member

Choose a reason for hiding this comment

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

This test file is out of date now. I appear to have forgotten to add this into the test suite...

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh man, I totally missed this — thanks!

Copy link
Member

Choose a reason for hiding this comment

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

Here's a modernized version of this test suite:

using Base: SecretBuffer, SecretBuffer!, shred!, isshredded
using Base.Test

@testset "SecretBuffer" begin
    @testset "original unmodified" begin
        str = "foobar"
        secret = SecretBuffer(str)

        @test read(secret, String) == str
        seekstart(secret)

        @test shred!(secret) === secret
        @test read(secret, String) != ""
        @test str != "foobar"
    end

    @testset "finalizer" begin
        v = UInt8[1, 2]
        secret_a = SecretBuffer!(v)
        secret_b = secret_a

        secret_a = nothing
        GC.gc()

        @test all(iszero, v)
        @test !isshredded(secret_b)
    end
end

@@ -2424,7 +2525,10 @@ mktempdir() do dir
@test err == exhausted_error
@test auth_attempts == 3
@test p.explicit == invalid_cred
@test p.credential != invalid_cred
# @test p.credential != invalid_cred # TODO!!!
Copy link
Member

Choose a reason for hiding this comment

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

I'll try to look into this

Copy link
Member

Choose a reason for hiding this comment

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

These tests can be re-enabled if you set the non-secret fields of UserPasswordCredential and SSHCredential to "" in their respective shred! methods.

GitCredential(cred::UserPasswordCredential, url::AbstractString) = parse(GitCredential, url)

Base.:(==)(c1::GitCredential, c2::GitCredential) = (c1.protocol, c1.host, c1.path, c1.username, c1.password, c1.use_http_path) ==
(c2.protocol, c2.host, c2.path, c2.username, c2.password, c2.use_http_path)
Copy link
Member

Choose a reason for hiding this comment

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

My personal preference is to use == and && over multiple lines


Initialize a new `SecretBuffer` with `data` and securely zero the original source argument.
"""
SecretBuffer!(s::Cstring) = SecretBuffer!(convert(Ptr{UInt8}, s))
Copy link
Member

Choose a reason for hiding this comment

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

Great use of !. Highly approve!

@omus
Copy link
Member

omus commented Jun 14, 2018

I initially tried following the approach in #24738, wherein all the credential information (including usernames and key paths) is considered secret. This proved to be horribly cumbersome and a huge dungeon of kludges and work-arounds. This PR now only considers the passwords themselves to be secret — and that works quite well.

I totally agree. I have seen these dungeons you speak of and dare not speak of them myself.

@omus
Copy link
Member

omus commented Jun 14, 2018

CI failing due to:

Some tests did not pass: 74 passed, 0 failed, 2 errored, 0 broken.OldPkg/pkg: Error During Test at /tmp/julia/share/julia/stdlib/v0.7/OldPkg/test/pkg.jl:303
  Test threw exception MethodError(Base.securezero!, (LibGit2.CachedCredentials(Dict{String,LibGit2.AbstractCredential}()),), 0x00006b7f)
  Expression: OldPkg.update()
  MethodError: no method matching securezero!(::LibGit2.CachedCredentials)
  Closest candidates are:
    securezero!(!Matched::Cstring) at util.jl:444
    securezero!(!Matched::AbstractArray{#s620,N} where N where #s620<:Number) at util.jl:443


An IOBuffer-like object where the contents will be securely wiped when garbage collected. However, it is
considered best practice to wipe the buffer using `Base.shred!(::SecretBuffer)` as soon as the
secure data are no longer required. Avoid initializing with converting to strings as they are
Copy link
Member

Choose a reason for hiding this comment

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

"Avoid initializing with converting..." reads poorly. Perhaps first mention that they can be initialized, then add the note to avoid String second?

Copy link
Member Author

Choose a reason for hiding this comment

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

Missed an "and" there — I was intending: "Avoid initializing with and converting to". Good point about swapping ordering here.

convert(::Type{SecretBuffer}, s::AbstractString) = SecretBuffer(String(s))
SecretBuffer(str::AbstractString) = SecretBuffer(String(str))
function SecretBuffer(str::String)
buf = unsafe_wrap(Vector{UInt8}, str)
Copy link
Member

Choose a reason for hiding this comment

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

Use codepoints

Initialize a new `SecretBuffer` with `data` and securely zero the original source argument.
"""
SecretBuffer!(s::Cstring) = SecretBuffer!(convert(Ptr{UInt8}, s))
function SecretBuffer!(p::Ptr{UInt8})
Copy link
Member

Choose a reason for hiding this comment

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

should be called unsafe_SecretBuffer!, calling our normal naming convention, and should take a length argument (with a default value of calling :strlen)

Copy link
Member Author

Choose a reason for hiding this comment

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

Ugh, that's ugly. But I see your point.

Copy link
Member

Choose a reason for hiding this comment

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

Definitely ugly

function SecretBuffer!(d::Vector{UInt8})
s = SecretBuffer(sizehint=length(d))
for i in 1:len
write(s, unsafe_load(p, i))
Copy link
Member

Choose a reason for hiding this comment

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

p isn't defined. missing test coverage?

end

function unsafe_convert(::Type{Ptr{UInt8}}, s::SecretBuffer)
# Add a hidden nul byte just past the end of the valid region
Copy link
Member

Choose a reason for hiding this comment

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

That's usually only applicable for conversion to ::Type{Cstring} (and also should check that there are no embedded \nul values)

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, yes, that'd be much better — I'll change this to a conversion to Cstring and fixup the accompanying ccalls.

cred.username !== nothing && securezero!(cred.username)
cred.password !== nothing && securezero!(cred.password)
function Base.shred!(cred::GitCredential)
cred.protocol !== nothing && (cred.protocol = nothing)
Copy link
Member

Choose a reason for hiding this comment

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

the check for nothing is unnecessary

a.host = b.host
a.path = b.path
a.username = b.username
a.password = b.password
Copy link
Member

Choose a reason for hiding this comment

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

Seems like this still should be copy(b.password)

if key == "url"
# Any components which are missing from the URL will be set to empty
# https://git-scm.com/docs/git-credential#git-credential-codeurlcode
Base.shred!(cred)
Copy link
Member

Choose a reason for hiding this comment

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

Seems like this can be part of the copy! definition

Copy link
Member

Choose a reason for hiding this comment

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

In this case yes but the copy! method could be used for duplication where you do not want to destroy the source.

Copy link
Member

Choose a reason for hiding this comment

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

Isn't cred the destination?

Copy link
Member

Choose a reason for hiding this comment

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

Yes it is. I had some wires crossed. Your suggestion is good 👍

Copy link
Member

Choose a reason for hiding this comment

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

I had to go check myself too. That's perhaps a good point too though, that we should destroy the source argument? Maybe take!(dest, src) for that, or just do it manually.

c = new(user, pass)
finalizer(securezero!, c)
return c
user::AbstractString
Copy link
Member

Choose a reason for hiding this comment

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

keep String

c = new(user, pass, prvkey, pubkey)
finalizer(securezero!, c)
return c
user::AbstractString
Copy link
Member

Choose a reason for hiding this comment

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

keep as String

end

function final_shred!(s::SecretBuffer)
if !isshredded(s)
Copy link
Member

Choose a reason for hiding this comment

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

it would likely be cheaper and faster to call shred! unconditionally

Copy link
Member

Choose a reason for hiding this comment

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

Agreed. Originally this code issued a warning to alert developers that they weren't explicitly calling shred!


An IOBuffer-like object where the contents will be securely wiped when garbage collected. However, it is
considered best practice to wipe the buffer using `Base.shred!(::SecretBuffer)` as soon as the
secure data are no longer required. Avoid initializing with converting to strings as they are
Copy link
Member

Choose a reason for hiding this comment

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

"secure data is no longer required"

Copy link
Member Author

Choose a reason for hiding this comment

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

Not a typo :trollface:

end
function SecretBuffer!(d::Vector{UInt8})
s = SecretBuffer(sizehint=length(d))
for i in 1:len
Copy link
Member

Choose a reason for hiding this comment

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

len not defined

key, value = split(readline(io), '=')

key = readuntil(io, '=')
if key == "password"
Copy link
Member

Choose a reason for hiding this comment

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

We may also want to do this if key == "url" since that URL could contain a password

Copy link
Member Author

Choose a reason for hiding this comment

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

We still parse that URL with a regex which stashes things into Strings. I decided to just consider URLs insecure, but perhaps we should re-write that, too.

Copy link
Member

Choose a reason for hiding this comment

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

That approach makes sense. I think we should add a comment which calls the potential security issue and leave it at that. The "url" key doesn't seem commonly used so it isn't a big concern for me.

Copy link
Member Author

Choose a reason for hiding this comment

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

Any ideas as to where this would most make sense to document? Another alternative would just be to completely not support passwords in URLs. We'd just throw an error if m[:password] exists. I kinda like that.

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 it makes sense to open an issue about this and reference the issue below if key == "url". I believe we can address the issue later by rewriting the URL parsing and reading in the password with a SecretBuffer. We can try to tackle this now but I don't want to add extra work for you.

Raising an error will still result in the password information being stored in a string which is no better than what we currently have.

@mbauman
Copy link
Member Author

mbauman commented Jun 14, 2018

Thanks for the very thorough reviews Jameson and Curtis!

@mbauman
Copy link
Member Author

mbauman commented Jun 15, 2018

I somewhat cannot believe that this passes tests on Windows as I've done all the win-specific changes blindly. Is there reasonable test coverage that I can trust that? Or should I hunt down a windows VM to do some extra interactive testing myself?

@Keno
Copy link
Member

Keno commented Jun 16, 2018

I usually call things good enough if tests pass, but ping me on slack for access to a Windows test VM if you want.

Copy link
Member

@staticfloat staticfloat left a comment

Choose a reason for hiding this comment

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

You're a beast. Looks great to me.


convert(::Type{SecretBuffer}, s::AbstractString) = SecretBuffer(String(s))
SecretBuffer(str::AbstractString) = SecretBuffer(String(str))
function SecretBuffer(str::String)
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 it bears creating a docstring for the ::String constructor that mentions that the string will not be zeroed out.

"""
SecretBuffer!(data::Vector{UInt8})

Initialize a new `SecretBuffer` with `data` and securely zero the original source argument.
Copy link
Member

@staticfloat staticfloat Jun 16, 2018

Choose a reason for hiding this comment

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

The way this is phrased makes it sound like data and "the original source argument" are two different things? Perhaps rephrase to something like "Initialize a new SecretBuffer from data, securely zeroing it afterwards."

a.host = b.host
a.path = b.path
a.username = b.username
a.password = b.password == nothing ? nothing : copy(b.password)
Copy link
Member

Choose a reason for hiding this comment

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

It seems like copy(nothing) should just return nothing. :P

show(io::IO, s::SecretBuffer) = print(io, "SecretBuffer(\"*******\")")

hash(s::SecretBuffer, h::UInt) = hash(SecretBuffer, hash((s.data, s.size, s.ptr), h))
==(s1::SecretBuffer, s2::SecretBuffer) = (view(s1.data, 1:s1.size), s1.ptr) == (view(s2.data, 1:s2.size), s2.ptr)
Copy link
Contributor

Choose a reason for hiding this comment

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

I know that secretbuffer is meant for uninitialized memory disclosure scenarios and the following does not matter for use in libgit2. Nevertheless, I think it would be good practice to make comparison constant time for something called secretbuffer. Somebody will eventually misuse it to compare a secret to an attacker-supplied string and leak the secret via timing side-channel.

Copy link
Contributor

Choose a reason for hiding this comment

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

A simple sample implementation could be (no early return, no branching, no conditional moves, @noinline to prevent the compiler from being clever):

@noinline function _bufcmp(data1::Vector{UInt8}, data2::Vector{UInt8}, sz::Int)
    res = UInt8(0)
    for i = 1:sz
        res |= xor(data1[i], data2[i])
    end
    return res
end

==(s1::SecretBuffer, s2::SecretBuffer) = (s1.ptr == s2.ptr) && (s1.size == s2.size) && (UInt8(0) == _bufcmp(s1.data, s2.data, s1.size))

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a good point. These methods are also just strange in the first place. They break with the convention for other IO subtypes — those don't do equality by value. Plus I just now noticed there's a bug in the agreement between hash and ==.

I'd love to figure out how to remove them, but they are used both in LibGit2's source and its tests and I don't have a good replacement.

@Keno
Copy link
Member

Keno commented Jun 19, 2018

What's missing to merge this?

@stevengj
Copy link
Member

stevengj commented Jun 20, 2018

It has the major downside that the returned object from Base.prompt changes drastically depending upon the password keyword.

Maybe promptpassword (getpass?) should just be a separate function?

return byte
end

final_shred!(s::SecretBuffer) = shred!(s)
Copy link
Member

@stevengj stevengj Jun 20, 2018

Choose a reason for hiding this comment

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

Shouldn't it log a warning or something if !isshredded(s)? Waiting until gc runs seems like a bad idea.

@iblislin
Copy link
Member

FreeBSD CI got a segfault. seems related.
(starts from line 97: https://freebsdci.julialang.org/#/builders/1/builds/10478/steps/8/logs/stdio

@martinholters
Copy link
Member

Ref. #27702 regarding likely cause of the segfault.

@Keno Keno modified the milestones: 0.7, 0.7-beta Jun 21, 2018
@mbauman mbauman force-pushed the mb+cv/secretbuffer branch from bba1513 to 3e8960f Compare June 21, 2018 15:39
@mbauman
Copy link
Member Author

mbauman commented Jun 21, 2018

Ok, once CI gives this the green-light I think it's good to go. I believe I've addressed all review comments now.

base/util.jl Outdated
object containing the secret.

Note that on Windows, the secret might be displayed as it is typed; see
`Base.winprompt` for securely retrieving username/password pairs from a secure
Copy link
Member

Choose a reason for hiding this comment

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

winprompt seems misnamed now… should it be wingetpass?

Copy link
Member

Choose a reason for hiding this comment

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

(also, the word "secure" appears in this sentence twice, which seems a bit redundant)

Copy link
Member Author

Choose a reason for hiding this comment

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

It returns a username/password pair. It's more like wingetcredentials or some such. But these are all un-exported functions and can be changed down the line. I'm inclined to leave it be.

base/util.jl Outdated
@@ -467,36 +479,30 @@ function getpass(prompt::AbstractString)
end
else
function getpass(prompt::AbstractString)
Copy link
Member

Choose a reason for hiding this comment

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

Should prompt have a default value, e.g. "Password"?

Copy link
Member Author

Choose a reason for hiding this comment

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

It can always be added later if someone else feels strongly about it.

base/util.jl Outdated
if Sys.iswindows()
function getpass(prompt::AbstractString)
print(prompt)
print(prompt, ':')
Copy link
Member

Choose a reason for hiding this comment

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

": " ?

Copy link
Member Author

@mbauman mbauman Jun 21, 2018

Choose a reason for hiding this comment

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

The extra space there doesn't matter all that much because (ideally) the typed input doesn't get echo'ed back. In any case, this was required to match Base.prompt's behavior, which doesn't use a space either.

Copy link
Member

@stevengj stevengj Jun 21, 2018

Choose a reason for hiding this comment

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

The system might still show a blinking cursor of some kind. On MacOS it displays a key cursor
image
I think that Base.prompt should also show a space, as it looks even uglier to omit the space when the input is not hidden.

@stevengj
Copy link
Member

stevengj commented Jun 21, 2018

It would be nice to have something analogous to the Python Keyring library, but I guess that could be an external package.

@omus
Copy link
Member

omus commented Jun 21, 2018

These CachedCredentials uses in Pkg3 should be shredded:

creds = LibGit2.CachedCredentials()

creds = LibGit2.CachedCredentials()

creds = LibGit2.CachedCredentials()

creds = LibGit2.CachedCredentials()

@mbauman
Copy link
Member Author

mbauman commented Jun 21, 2018

Oh interesting; perhaps I should add a warning if those credential wrapper objects are shredded by the finalizer, too? That's why SecretString isn't warning here — the secrets they hold get explicitly shredded by a different finalizer, so when their finalizer runs all looks well.

Edit: no, that's wrong — none of the LibGit2 credential objects have finalizers. Perhaps our test suite just doesn't exercise those methods with repos that require passwords?

base/util.jl Outdated
@@ -459,7 +459,7 @@ function getpass end

if Sys.iswindows()
function getpass(prompt::AbstractString)
print(prompt, ':')
print(prompt, ": ")
Copy link
Member

@stevengj stevengj Jun 21, 2018

Choose a reason for hiding this comment

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

Honestly, it seems a bit weird to me that we add a colon at all. Can't we let the caller decide this? For example, what if they want to display a prompt "True (y/n)? " or "Enter password here => "?

Copy link
Member Author

Choose a reason for hiding this comment

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

It all stems from the behavior of Base.prompt, where you can also specify a default, which will appear as message [default]:.

@mbauman mbauman force-pushed the mb+cv/secretbuffer branch from 0645771 to c9b9cb8 Compare June 21, 2018 21:48
@omus
Copy link
Member

omus commented Jun 22, 2018

I would guess that the Pkg tests do not target repos requiring passwords. In that case the CachedCredentials instance will contain no secrets and no finalizer warning would appear. If a credential was used it would be cached and then secret's finalizer would warn us about the unshredded secret.

@mbauman mbauman force-pushed the mb+cv/secretbuffer branch 2 times, most recently from 2358901 to 9ab9f63 Compare June 22, 2018 07:37
@mbauman mbauman force-pushed the mb+cv/secretbuffer branch from 9ab9f63 to 5a50d9b Compare June 22, 2018 15:32
@mbauman
Copy link
Member Author

mbauman commented Jun 22, 2018

There we go, finally some green checks are starting to roll in from CI. I propose we merge this as it stands so Keno can finally tag a beta. Base.prompt, Base.getpass, and Base.winprompt are all documented-but-unexported APIs, and we can bikeshed them to our hearts delight in a different PR. The main point here is to get secure handling correct and avoid the segfaults that have plagued the buildbots.

@Keno Keno merged commit 160fc7d into master Jun 23, 2018
@StefanKarpinski StefanKarpinski deleted the mb+cv/secretbuffer branch June 23, 2018 01:21
@ararslan
Copy link
Member

This appears to have caused an issue with using credential helpers. I think @omus is investigating.

@@ -107,6 +111,11 @@ provided the URL produced will use the alternative [scp-like syntax](https://git
provided. Cannot be specified when using the scp-like syntax.
* `path::AbstractString=""`: the path to use in the output if provided.

!!!warning
Copy link
Member

Choose a reason for hiding this comment

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

This needs a space between ! and warning for it to be recognized correctly. Currently it shows up literally as !!!warning in the docstring rather than being rendered nicely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
libgit2 The libgit2 library or the LibGit2 stdlib module security System security concerns and vulnerabilities
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants