add clairctl
This commit is contained in:
parent
4f729ec47e
commit
c52c952f0a
3
cmd/clairctl/.gitignore
vendored
Normal file
3
cmd/clairctl/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
clairctl
|
||||
clairctl.yml
|
||||
reports/
|
25
cmd/clairctl/.travis.yml
Normal file
25
cmd/clairctl/.travis.yml
Normal file
@ -0,0 +1,25 @@
|
||||
language: go
|
||||
go:
|
||||
- 1.7
|
||||
|
||||
install:
|
||||
- go get -v github.com/Masterminds/glide
|
||||
- cd $GOPATH/src/github.com/Masterminds/glide && git checkout tags/v0.12.3 && go install && cd -
|
||||
- glide install -v
|
||||
|
||||
script:
|
||||
- go build -ldflags "-X github.com/jgsqware/clairctl/cmd.version=$(cat VERSION)" -v
|
||||
|
||||
deploy:
|
||||
provider: releases
|
||||
api_key:
|
||||
secure: SPeZzw212p/0iYPLVWUkLq226j19oWHBogHIlbKzkp8zNk7tdJEHMthAhka1zxS5afr+JWu3BBdduPHbnvvpXtv2axsrCXVsW7jPEcxXgfM3m/YSxQwjsGojMG318nX/E5ApY8eDhiZuHoTAP67DJZEoLteV5GKvNk7np74bMNexBxCPDIbXepBbjrEqxrUG/uVDFrmFf0LlCKVGxALK42uriaDM1tPGDy0+7zX7a3RqG9J4ROmZCQNzd9+rcur4DndrxYPCvJK3awUwXL5XzdRed24m5S8IG6Q/gMMUhUVECMNZ6/Z+KZ4CKqcQ9e9NvOtHYvO0OtPNrd4/HajZpUpfO008imSj6/g9NxdC6hJYvyK/HuRv5DsLgZukyvwroVSM5rC77FJeOmoLXfEBHo7E16I5XKGcp31NoCv5kdQuaHLxkBZk43CcnT/rraQ8cjCQAERQEh6u0fidyOvZ4vkTW30/c6H25zDejCzDcx1oHL4O8QwJwMfRSifSA8vk81saOurmQeviZK1UkpeWLxMYLpFZ8vN9kK/2Nn24ud9DN7N8bqGob13I8lJ69j9xcXuq/86vITziESZCHaYs2Hw1vSHD8vpkQxu8aSYqPXgwAKDwV9JkGV1/kcUdwpOlD/xkKunluu8Mf5K2JSm9G8xWr6foOibKsuHDn0nH+jY=
|
||||
file: clairctl
|
||||
skip_cleanup: true
|
||||
on:
|
||||
repo: jgsqware/clairctl
|
||||
tags: true
|
||||
branch: master
|
||||
env:
|
||||
global:
|
||||
secure: QpHC0r/d3sUNZ4Tisu7IjRnE+exzUUdTRlWUvvepWA2/wdsvsh9IFKmPgHvmt/V46RtByc52HVwE/uE6gF8eVhRs4L4DP/gSi0TxmfNi2m+6EdL1MmFvdIVGfPZDDbFnNNXpWcNJGv5/UDJ8eke003Mtm7/qQfJqH3UzxoWnTrvsA7IHbhxomgsHOFqHU+e6fmaQ3q7neUJEci2HSPLehl11yBwMcsYyFjeW+33GVQeI0m2j1TrcldG3KN63tszIx7tHXTUrIK369nrndLjYWnc7Hi+k6Uc8vPh111/hLBG4UtxffP+/DTkhEtzxDNn3hn4zSKTMIyeKA/UhnySYchzDYOdVZoM0oVxAkbTcdTPWCxS75W1u3AW3QAGxScNEqmy5enrW+kGyRpd1zIRJ1qS2tHAVmos8N6bl5Rp9UJF5UITr9/DyQS93O9DOSY5Z6UVGI5J+MlG89Nr4XJ5NW4ak5yC5fc+9IwM58VMk8ULS3I0g/YBWsWRWHGZrHurnvcGbiXHk5S+anQsiYUV8n3OUa67ai7Duil3qiPklEhHeFZzmXrJMvLdeVxywfvIfKzSEGqDrUYw3hO9pkMzLxDQfvaNzAHWZGJ970N7apX2pnAUgPDsNb8cOnbDSjX36FICAiUZlrkpsz6O6h3Fm/2t/HDCyYH7gf6eJXypUOYc=
|
202
cmd/clairctl/LICENSE
Normal file
202
cmd/clairctl/LICENSE
Normal file
@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright {yyyy} {name of copyright owner}
|
||||
|
||||
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.
|
||||
|
58
cmd/clairctl/README.md
Normal file
58
cmd/clairctl/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# clairctl
|
||||
[![Build Status](https://travis-ci.org/jgsqware/clairctl.svg?branch=master)](https://travis-ci.org/jgsqware/clairctl)
|
||||
|
||||
> Tracking container vulnerabilities with Clair Control
|
||||
|
||||
Clairctl is a lightweight command-line tool doing the bridge between Registries as Docker Hub, Docker Registry or Quay.io, and the CoreOS vulnerability tracker, Clair.
|
||||
Clairctl will play as reverse proxy for authentication.
|
||||
|
||||
# Usage
|
||||
|
||||
[![asciicast](https://asciinema.org/a/41461.png)](https://asciinema.org/a/41461)
|
||||
|
||||
# Reporting
|
||||
|
||||
**clairctl** get vulnerabilities report from Clair and generate HTML report
|
||||
|
||||
clairctl can be used for Docker Hub and self-hosted Registry
|
||||
|
||||
# Command
|
||||
|
||||
```
|
||||
Analyze your docker image with Clair, directly from your registry.
|
||||
|
||||
Usage:
|
||||
clairctl [command]
|
||||
|
||||
Available Commands:
|
||||
analyze Analyze Docker image
|
||||
health Get Health of clairctl and underlying services
|
||||
login Log in to a Docker registry
|
||||
logout Log out from a Docker registry
|
||||
pull Pull Docker image information
|
||||
push Push Docker image to Clair
|
||||
report Generate Docker Image vulnerabilities report
|
||||
version Get Versions of clairctl and underlying services
|
||||
|
||||
Flags:
|
||||
--config string config file (default is ./.clairctl.yml)
|
||||
--log-level string log level [Panic,Fatal,Error,Warn,Info,Debug]
|
||||
|
||||
Use "clairctl [command] --help" for more information about a command.
|
||||
```
|
||||
|
||||
# Optional Configuration
|
||||
|
||||
```yaml
|
||||
clair:
|
||||
port: 6060
|
||||
healthPort: 6061
|
||||
uri: http://clair
|
||||
report:
|
||||
path: ./reports
|
||||
format: html
|
||||
```
|
||||
|
||||
# Contribution and Test
|
||||
|
||||
Go to /contrib folder
|
1
cmd/clairctl/VERSION
Normal file
1
cmd/clairctl/VERSION
Normal file
@ -0,0 +1 @@
|
||||
1.2.2
|
73
cmd/clairctl/clair/analyze.go
Normal file
73
cmd/clairctl/clair/analyze.go
Normal file
@ -0,0 +1,73 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/clair/api/v1"
|
||||
"github.com/docker/distribution"
|
||||
"github.com/docker/distribution/manifest/schema1"
|
||||
"github.com/docker/distribution/manifest/schema2"
|
||||
"github.com/docker/docker/reference"
|
||||
)
|
||||
|
||||
//Analyze return Clair Image analysis
|
||||
func Analyze(image reference.NamedTagged, manifest distribution.Manifest) ImageAnalysis {
|
||||
layers, err := newLayering(image)
|
||||
if err != nil {
|
||||
log.Fatalf("cannot parse manifest")
|
||||
return ImageAnalysis{}
|
||||
}
|
||||
|
||||
switch manifest.(type) {
|
||||
case schema1.SignedManifest:
|
||||
|
||||
for _, l := range manifest.(schema1.SignedManifest).FSLayers {
|
||||
layers.digests = append(layers.digests, l.BlobSum.String())
|
||||
}
|
||||
return layers.analyze()
|
||||
case *schema1.SignedManifest:
|
||||
for _, l := range manifest.(*schema1.SignedManifest).FSLayers {
|
||||
layers.digests = append(layers.digests, l.BlobSum.String())
|
||||
}
|
||||
return layers.analyze()
|
||||
case schema2.DeserializedManifest:
|
||||
log.Debugf("json: %v", image)
|
||||
for _, l := range manifest.(schema2.DeserializedManifest).Layers {
|
||||
layers.digests = append(layers.digests, l.Digest.String())
|
||||
}
|
||||
return layers.analyze()
|
||||
case *schema2.DeserializedManifest:
|
||||
log.Debugf("json: %v", image)
|
||||
for _, l := range manifest.(*schema2.DeserializedManifest).Layers {
|
||||
layers.digests = append(layers.digests, l.Digest.String())
|
||||
}
|
||||
return layers.analyze()
|
||||
default:
|
||||
log.Fatalf("Unsupported Schema version.")
|
||||
return ImageAnalysis{}
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeLayer(id string) (v1.LayerEnvelope, error) {
|
||||
|
||||
lURI := fmt.Sprintf("%v/layers/%v?vulnerabilities", uri, id)
|
||||
response, err := http.Get(lURI)
|
||||
if err != nil {
|
||||
return v1.LayerEnvelope{}, fmt.Errorf("analysing layer %v: %v", id, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var analysis v1.LayerEnvelope
|
||||
err = json.NewDecoder(response.Body).Decode(&analysis)
|
||||
if err != nil {
|
||||
return v1.LayerEnvelope{}, fmt.Errorf("reading layer analysis: %v", err)
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
//TODO(jgsqware): should I show reponse body in case of error?
|
||||
return v1.LayerEnvelope{}, fmt.Errorf("receiving http error: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
return analysis, nil
|
||||
}
|
63
cmd/clairctl/clair/clair.go
Normal file
63
cmd/clairctl/clair/clair.go
Normal file
@ -0,0 +1,63 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/clair/api/v1"
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
"github.com/jgsqware/clairctl/xstrings"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var log = capnslog.NewPackageLogger("github.com/jgsqware/clairctl", "clair")
|
||||
|
||||
var uri string
|
||||
var healthURI string
|
||||
|
||||
//ImageAnalysis Full image analysis
|
||||
type ImageAnalysis struct {
|
||||
Registry, ImageName, Tag string
|
||||
Layers []v1.LayerEnvelope
|
||||
}
|
||||
|
||||
func (imageAnalysis ImageAnalysis) String() string {
|
||||
return imageAnalysis.Registry + "/" + imageAnalysis.ImageName + ":" + imageAnalysis.Tag
|
||||
}
|
||||
|
||||
//LastLayer return the last layer of ImageAnalysis
|
||||
func (imageAnalysis ImageAnalysis) LastLayer() *v1.Layer {
|
||||
return imageAnalysis.Layers[len(imageAnalysis.Layers)-1].Layer
|
||||
}
|
||||
|
||||
func (imageAnalysis ImageAnalysis) CountVulnerabilities(l v1.Layer) int {
|
||||
count := 0
|
||||
for _, feature := range l.Features {
|
||||
count += len(feature.Vulnerabilities)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func fmtURI(u string, port int) string {
|
||||
|
||||
if port != 0 {
|
||||
u += ":" + strconv.Itoa(port)
|
||||
}
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
u = "http://" + u
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func (imageAnalysis ImageAnalysis) ShortName(l v1.Layer) string {
|
||||
return xstrings.Substr(l.Name, 0, 12)
|
||||
}
|
||||
|
||||
//Config configure Clair from configFile
|
||||
func Config() {
|
||||
uri = fmtURI(viper.GetString("clair.uri"), viper.GetInt("clair.port")) + "/v1"
|
||||
healthURI = fmtURI(viper.GetString("clair.uri"), viper.GetInt("clair.healthPort")) + "/health"
|
||||
Report.Path = viper.GetString("clair.report.path")
|
||||
Report.Format = viper.GetString("clair.report.format")
|
||||
}
|
65
cmd/clairctl/clair/clair_test.go
Normal file
65
cmd/clairctl/clair/clair_test.go
Normal file
@ -0,0 +1,65 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
)
|
||||
|
||||
func getSampleAnalysis() []byte {
|
||||
file, err := ioutil.ReadFile("./samples/clair_report.json")
|
||||
if err != nil {
|
||||
log.Errorf("File error: %v\n", err)
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
func newServer(httpStatus int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(httpStatus)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestIsHealthy(t *testing.T) {
|
||||
server := newServer(http.StatusOK)
|
||||
defer server.Close()
|
||||
healthURI = server.URL
|
||||
if h := IsHealthy(); !h {
|
||||
t.Errorf("IsHealthy() => %v, want %v", h, true)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNotHealthy(t *testing.T) {
|
||||
server := newServer(http.StatusInternalServerError)
|
||||
defer server.Close()
|
||||
uri = server.URL
|
||||
if h := IsHealthy(); h {
|
||||
t.Errorf("IsHealthy() => %v, want %v", h, true)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeCount(t *testing.T) {
|
||||
var analysis ImageAnalysis
|
||||
err := json.Unmarshal([]byte(getSampleAnalysis()), &analysis)
|
||||
if err != nil {
|
||||
t.Errorf("Failing with error: %v", err)
|
||||
}
|
||||
|
||||
vulnerabilitiesCount := allVulnerabilities(analysis)
|
||||
if vulnerabilitiesCount.RelativeCount("High") != 1.3 {
|
||||
t.Errorf("analysis.CountAllVulnerabilities().RelativeCount(\"High\") => %v, want 1.3", vulnerabilitiesCount.RelativeCount("High"))
|
||||
}
|
||||
|
||||
if vulnerabilitiesCount.RelativeCount("Medium") != 23.38 {
|
||||
t.Errorf("analysis.CountAllVulnerabilities().RelativeCount(\"Medium\") => %v, want 23.38", vulnerabilitiesCount.RelativeCount("Medium"))
|
||||
}
|
||||
|
||||
if vulnerabilitiesCount.RelativeCount("Low") != 74.03 {
|
||||
t.Errorf("analysis.CountAllVulnerabilities().RelativeCount(\"Low\") => %v, want 74.03", vulnerabilitiesCount.RelativeCount("Low"))
|
||||
}
|
||||
}
|
20
cmd/clairctl/clair/health.go
Normal file
20
cmd/clairctl/clair/health.go
Normal file
@ -0,0 +1,20 @@
|
||||
package clair
|
||||
|
||||
import "net/http"
|
||||
|
||||
//IsHealthy return Health clair result
|
||||
func IsHealthy() bool {
|
||||
log.Debug("requesting health on: " + healthURI)
|
||||
response, err := http.Get(healthURI)
|
||||
if err != nil {
|
||||
log.Errorf("requesting Clair health: %v", err)
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
102
cmd/clairctl/clair/layering.go
Normal file
102
cmd/clairctl/clair/layering.go
Normal file
@ -0,0 +1,102 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/clair/api/v1"
|
||||
"github.com/docker/docker/reference"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/jgsqware/clairctl/xstrings"
|
||||
)
|
||||
|
||||
type layering struct {
|
||||
image reference.NamedTagged
|
||||
digests []string
|
||||
parentID, hURL string
|
||||
}
|
||||
|
||||
func newLayering(image reference.NamedTagged) (*layering, error) {
|
||||
layer := layering{
|
||||
parentID: "",
|
||||
image: image,
|
||||
}
|
||||
|
||||
localIP, err := config.LocalServerIP()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layer.hURL = fmt.Sprintf("http://%v/v2", localIP)
|
||||
if config.IsLocal {
|
||||
layer.hURL = strings.Replace(layer.hURL, "/v2", "/local", -1)
|
||||
log.Infof("using %v as local url", layer.hURL)
|
||||
}
|
||||
return &layer, nil
|
||||
}
|
||||
|
||||
func (layer *layering) pushAll() error {
|
||||
layerCount := len(layer.digests)
|
||||
|
||||
if layerCount == 0 {
|
||||
log.Warning("there is no layer to push")
|
||||
}
|
||||
for index, digest := range layer.digests {
|
||||
|
||||
if config.IsLocal {
|
||||
digest = strings.TrimPrefix(digest, "sha256:")
|
||||
}
|
||||
|
||||
lUID := xstrings.Substr(digest, 0, 12)
|
||||
log.Infof("Pushing Layer %d/%d [%v]", index+1, layerCount, lUID)
|
||||
|
||||
insertRegistryMapping(digest, layer.image.Hostname())
|
||||
payload := v1.LayerEnvelope{Layer: &v1.Layer{
|
||||
Name: digest,
|
||||
Path: blobsURI(layer.image.Hostname(), layer.image.RemoteName(), digest),
|
||||
ParentName: layer.parentID,
|
||||
Format: "Docker",
|
||||
}}
|
||||
|
||||
//FIXME Update to TLS
|
||||
if config.IsLocal {
|
||||
payload.Layer.Path += "/layer.tar"
|
||||
}
|
||||
payload.Layer.Path = strings.Replace(payload.Layer.Path, layer.image.Hostname(), layer.hURL, 1)
|
||||
if err := pushLayer(payload); err != nil {
|
||||
log.Infof("adding layer %d/%d [%v]: %v", index+1, layerCount, lUID, err)
|
||||
if err != ErrUnanalizedLayer {
|
||||
return err
|
||||
}
|
||||
layer.parentID = ""
|
||||
} else {
|
||||
layer.parentID = payload.Layer.Name
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (layers *layering) analyze() ImageAnalysis {
|
||||
c := len(layers.digests)
|
||||
res := []v1.LayerEnvelope{}
|
||||
|
||||
for i := range layers.digests {
|
||||
digest := layers.digests[c-i-1]
|
||||
if config.IsLocal {
|
||||
digest = strings.TrimPrefix(digest, "sha256:")
|
||||
}
|
||||
lShort := xstrings.Substr(digest, 0, 12)
|
||||
|
||||
if a, err := analyzeLayer(digest); err != nil {
|
||||
log.Errorf("analysing layer [%v] %d/%d: %v", lShort, i+1, c, err)
|
||||
} else {
|
||||
log.Infof("analysing layer [%v] %d/%d", lShort, i+1, c)
|
||||
res = append(res, a)
|
||||
}
|
||||
}
|
||||
return ImageAnalysis{
|
||||
Registry: xstrings.TrimPrefixSuffix(layers.image.Hostname(), "http://", "/v2"),
|
||||
ImageName: layers.image.Name(),
|
||||
Tag: layers.image.Tag(),
|
||||
Layers: res,
|
||||
}
|
||||
}
|
108
cmd/clairctl/clair/push.go
Normal file
108
cmd/clairctl/clair/push.go
Normal file
@ -0,0 +1,108 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/clair/api/v1"
|
||||
"github.com/docker/distribution"
|
||||
"github.com/docker/distribution/manifest/schema1"
|
||||
"github.com/docker/distribution/manifest/schema2"
|
||||
"github.com/docker/docker/reference"
|
||||
"github.com/jgsqware/clairctl/docker/dockerdist"
|
||||
)
|
||||
|
||||
// ErrUnanalizedLayer is returned when the layer was not correctly analyzed
|
||||
var ErrUnanalizedLayer = errors.New("layer cannot be analyzed")
|
||||
|
||||
var registryMapping map[string]string
|
||||
|
||||
func Push(image reference.NamedTagged, manifest distribution.Manifest) error {
|
||||
layers, err := newLayering(image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch manifest.(type) {
|
||||
case schema1.SignedManifest:
|
||||
for _, l := range manifest.(schema1.SignedManifest).FSLayers {
|
||||
layers.digests = append(layers.digests, l.BlobSum.String())
|
||||
}
|
||||
return layers.pushAll()
|
||||
case *schema1.SignedManifest:
|
||||
for _, l := range manifest.(*schema1.SignedManifest).FSLayers {
|
||||
layers.digests = append(layers.digests, l.BlobSum.String())
|
||||
}
|
||||
return layers.pushAll()
|
||||
case schema2.DeserializedManifest:
|
||||
for _, l := range manifest.(schema2.DeserializedManifest).Layers {
|
||||
layers.digests = append(layers.digests, l.Digest.String())
|
||||
}
|
||||
return layers.pushAll()
|
||||
case *schema2.DeserializedManifest:
|
||||
for _, l := range manifest.(*schema2.DeserializedManifest).Layers {
|
||||
layers.digests = append(layers.digests, l.Digest.String())
|
||||
}
|
||||
return layers.pushAll()
|
||||
default:
|
||||
return errors.New("Unsupported Schema version.")
|
||||
}
|
||||
}
|
||||
|
||||
func pushLayer(layer v1.LayerEnvelope) error {
|
||||
lJSON, err := json.Marshal(layer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshalling layer: %v", err)
|
||||
}
|
||||
|
||||
lURI := fmt.Sprintf("%v/layers", uri)
|
||||
request, err := http.NewRequest("POST", lURI, bytes.NewBuffer(lJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating 'add layer' request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := (&http.Client{}).Do(request)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("pushing layer to clair: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 201 {
|
||||
if response.StatusCode == 422 {
|
||||
return ErrUnanalizedLayer
|
||||
}
|
||||
return fmt.Errorf("receiving http error: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func blobsURI(registry string, name string, digest string) string {
|
||||
return strings.Join([]string{registry, name, "blobs", digest}, "/")
|
||||
}
|
||||
|
||||
func insertRegistryMapping(layerDigest string, registryURI string) {
|
||||
|
||||
hostURL, _ := dockerdist.GetPushURL(registryURI)
|
||||
log.Debugf("Saving %s[%s]", layerDigest, hostURL.String())
|
||||
registryMapping[layerDigest] = hostURL.String()
|
||||
}
|
||||
|
||||
//GetRegistryMapping return the registryURI corresponding to the layerID passed as parameter
|
||||
func GetRegistryMapping(layerDigest string) (string, error) {
|
||||
registryURI, present := registryMapping[layerDigest]
|
||||
if !present {
|
||||
return "", fmt.Errorf("%v mapping not found", layerDigest)
|
||||
}
|
||||
return registryURI, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
registryMapping = map[string]string{}
|
||||
}
|
28
cmd/clairctl/clair/push_test.go
Normal file
28
cmd/clairctl/clair/push_test.go
Normal file
@ -0,0 +1,28 @@
|
||||
package clair
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInsertRegistryMapping(t *testing.T) {
|
||||
layerID := "sha256:13be4a52fdee2f6c44948b99b5b65ec703b1ca76c1ab5d2d90ae9bf18347082e"
|
||||
registryURI := "registry:5000"
|
||||
insertRegistryMapping(layerID, registryURI)
|
||||
|
||||
if r := registryMapping[layerID]; r != "http://registry:5000/v2" {
|
||||
t.Errorf("insertRegistryMapping(%q,%q) => %q, want %q", layerID, registryURI, r, "http://registry:5000/v2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegistryMapping(t *testing.T) {
|
||||
layerID := "sha256:13be4a52fdee2f6c44948b99b5b65ec703b1ca76c1ab5d2d90ae9bf18347082e"
|
||||
registryURI := "registry:5000"
|
||||
insertRegistryMapping(layerID, registryURI)
|
||||
|
||||
if r, err := GetRegistryMapping(layerID); r != "http://registry:5000/v2" {
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("GetRegistryMapping(%q) failed => %v", layerID, err)
|
||||
} else {
|
||||
t.Errorf("GetRegistryMapping(%q) => %q, want %q", layerID, registryURI, r)
|
||||
}
|
||||
}
|
||||
}
|
141
cmd/clairctl/clair/report.go
Normal file
141
cmd/clairctl/clair/report.go
Normal file
@ -0,0 +1,141 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"text/template"
|
||||
|
||||
"github.com/coreos/clair/api/v1"
|
||||
"github.com/coreos/clair/utils/types"
|
||||
)
|
||||
|
||||
//execute go generate ./clair
|
||||
//go:generate go-bindata -pkg clair -o templates.go templates/...
|
||||
|
||||
//Report Reporting Config value
|
||||
var Report ReportConfig
|
||||
|
||||
//ReportConfig Reporting configuration
|
||||
type ReportConfig struct {
|
||||
Path string
|
||||
Format string
|
||||
}
|
||||
|
||||
//ReportAsHTML report analysis as HTML
|
||||
func ReportAsHTML(analyzes ImageAnalysis) (string, error) {
|
||||
asset, err := Asset("templates/analysis-template.html")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("accessing template: %v", err)
|
||||
}
|
||||
|
||||
funcs := template.FuncMap{
|
||||
"vulnerabilities": vulnerabilities,
|
||||
"allVulnerabilities": allVulnerabilities,
|
||||
"sortedVulnerabilities": sortedVulnerabilities,
|
||||
}
|
||||
|
||||
templte := template.Must(template.New("analysis-template").Funcs(funcs).Parse(string(asset)))
|
||||
|
||||
var doc bytes.Buffer
|
||||
err = templte.Execute(&doc, analyzes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rendering HTML report: %v", err)
|
||||
}
|
||||
return doc.String(), nil
|
||||
}
|
||||
|
||||
func invertedPriorities() []types.Priority {
|
||||
ip := make([]types.Priority, len(types.Priorities))
|
||||
for i, j := 0, len(types.Priorities)-1; i <= j; i, j = i+1, j-1 {
|
||||
ip[i], ip[j] = types.Priorities[j], types.Priorities[i]
|
||||
}
|
||||
return ip
|
||||
|
||||
}
|
||||
|
||||
type vulnerabilityWithFeature struct {
|
||||
v1.Vulnerability
|
||||
Feature string
|
||||
}
|
||||
|
||||
//VulnerabiliesCounts Total count of vulnerabilities by type
|
||||
type vulnerabiliesCounts map[types.Priority]int
|
||||
|
||||
//Total return to total of Vulnerabilities
|
||||
func (v vulnerabiliesCounts) Total() int {
|
||||
var c int
|
||||
for _, count := range v {
|
||||
c += count
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
//Count return count of severities in Vulnerabilities
|
||||
func (v vulnerabiliesCounts) Count(severity string) int {
|
||||
return v[types.Priority(severity)]
|
||||
}
|
||||
|
||||
//RelativeCount get the percentage of vulnerabilities of a severity
|
||||
func (v vulnerabiliesCounts) RelativeCount(severity string) float64 {
|
||||
count := v[types.Priority(severity)]
|
||||
result := float64(count) / float64(v.Total()) * 100
|
||||
return math.Ceil(result*100) / 100
|
||||
}
|
||||
|
||||
// allVulnerabilities Total count of vulnerabilities
|
||||
func allVulnerabilities(imageAnalysis ImageAnalysis) vulnerabiliesCounts {
|
||||
result := make(vulnerabiliesCounts)
|
||||
|
||||
l := imageAnalysis.Layers[len(imageAnalysis.Layers)-1]
|
||||
|
||||
for _, f := range l.Layer.Features {
|
||||
|
||||
for _, v := range f.Vulnerabilities {
|
||||
result[types.Priority(v.Severity)]++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
//Vulnerabilities return a list a vulnerabilities
|
||||
func vulnerabilities(imageAnalysis ImageAnalysis) map[types.Priority][]vulnerabilityWithFeature {
|
||||
|
||||
result := make(map[types.Priority][]vulnerabilityWithFeature)
|
||||
|
||||
l := imageAnalysis.Layers[len(imageAnalysis.Layers)-1]
|
||||
for _, f := range l.Layer.Features {
|
||||
for _, v := range f.Vulnerabilities {
|
||||
|
||||
result[types.Priority(v.Severity)] = append(result[types.Priority(v.Severity)], vulnerabilityWithFeature{Vulnerability: v, Feature: f.Name + ":" + f.Version})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SortedVulnerabilities get all vulnerabilities sorted by Severity
|
||||
func sortedVulnerabilities(imageAnalysis ImageAnalysis) []v1.Feature {
|
||||
features := []v1.Feature{}
|
||||
|
||||
l := imageAnalysis.Layers[len(imageAnalysis.Layers)-1]
|
||||
|
||||
for _, f := range l.Layer.Features {
|
||||
if len(f.Vulnerabilities) > 0 {
|
||||
vulnerabilities := []v1.Vulnerability{}
|
||||
for _, p := range invertedPriorities() {
|
||||
for _, v := range f.Vulnerabilities {
|
||||
if types.Priority(v.Severity) == p {
|
||||
vulnerabilities = append(vulnerabilities, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
nf := f
|
||||
nf.Vulnerabilities = vulnerabilities
|
||||
features = append(features, nf)
|
||||
}
|
||||
}
|
||||
|
||||
return features
|
||||
}
|
39
cmd/clairctl/clair/report_test.go
Normal file
39
cmd/clairctl/clair/report_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/clair/utils/types"
|
||||
)
|
||||
|
||||
func TestReportAsHtml(t *testing.T) {
|
||||
var analysis ImageAnalysis
|
||||
err := json.Unmarshal([]byte(getSampleAnalysis()), &analysis)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Failing with error: %v", err)
|
||||
}
|
||||
|
||||
html, err := ReportAsHTML(analysis)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(os.TempDir()+"/clairctl-html-report.html", []byte(html), 0700)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvertedPriorities(t *testing.T) {
|
||||
expected := []types.Priority{types.Defcon1, types.Critical, types.High, types.Medium, types.Low, types.Negligible, types.Unknown}
|
||||
ip := invertedPriorities()
|
||||
for i, v := range ip {
|
||||
if v != expected[i] {
|
||||
t.Errorf("Expecting %v, got %v", expected, ip)
|
||||
}
|
||||
}
|
||||
}
|
1558
cmd/clairctl/clair/samples/clair_report.json
Normal file
1558
cmd/clairctl/clair/samples/clair_report.json
Normal file
File diff suppressed because it is too large
Load Diff
237
cmd/clairctl/clair/templates.go
Normal file
237
cmd/clairctl/clair/templates.go
Normal file
File diff suppressed because one or more lines are too long
558
cmd/clairctl/clair/templates/analysis-template.html
Normal file
558
cmd/clairctl/clair/templates/analysis-template.html
Normal file
@ -0,0 +1,558 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Clair Control report : {{.ImageName}}</title>
|
||||
|
||||
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600,600italic,400italic,300italic,300' rel='stylesheet' type='text/css'>
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.1/css/font-awesome.min.css">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: ghostwhite;
|
||||
padding-bottom: 2em;
|
||||
}
|
||||
/* Typography */
|
||||
|
||||
.lead {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
/* global layout */
|
||||
|
||||
.container {
|
||||
padding: 0 0;
|
||||
}
|
||||
|
||||
.clearfix:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.row {
|
||||
margin: 0 -20px;
|
||||
}
|
||||
|
||||
.row:after {
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
[class*="col-"] {
|
||||
padding: 0 20px;
|
||||
float: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.col-6 {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
/* padding: 1em; */
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
box-shadow: 0px 1px 2px #e2e2e2;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin-top: 0;
|
||||
padding-bottom: .2em;
|
||||
border-bottom: solid 1px gainsboro;
|
||||
}
|
||||
|
||||
.panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Header */
|
||||
|
||||
.app-header {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
margin: 0 0px 0px 0px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 -2px 16px #263238;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-weight: lighter;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-intro {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #263238;
|
||||
background: rgba(255, 255, 255, .8);
|
||||
border-bottom: solid 1px #ECEFF1;
|
||||
}
|
||||
|
||||
.app-intro h2 {
|
||||
font-size: 2em;
|
||||
font-weight: lighter;
|
||||
}
|
||||
|
||||
.summary {
|
||||
line-height: .6em;
|
||||
}
|
||||
/* report */
|
||||
|
||||
.report {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.graph {
|
||||
margin: 0 auto;
|
||||
max-width: 960px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
/* Style of the graph */
|
||||
|
||||
.graph .node {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.graph .node .dot {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 24px;
|
||||
float: left;
|
||||
background: gray;
|
||||
/* box-shadow: 0 1px 2px rgba(0, 0, 0, .2);
|
||||
border: solid 1px rgba(255, 255, 255, .2); */
|
||||
}
|
||||
|
||||
.graph .node.Defcon1 .dot {
|
||||
background: black;
|
||||
}
|
||||
|
||||
.graph .node.Critical .dot {
|
||||
background: #e81e1e;
|
||||
}
|
||||
|
||||
.graph .node.High .dot {
|
||||
background: #E91E63;
|
||||
}
|
||||
|
||||
.graph .node.Medium .dot {
|
||||
background: #FFA726;
|
||||
}
|
||||
|
||||
.graph .node.Low .dot {
|
||||
background: #8BC34A;
|
||||
}
|
||||
|
||||
.graph .node.Negligible .dot {
|
||||
background: #37474F;
|
||||
}
|
||||
|
||||
.graph .node.Unknown .dot {
|
||||
background: #37474F;
|
||||
}
|
||||
|
||||
.graph .node .popup {
|
||||
display: none;
|
||||
width: 300px;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
margin-bottom: 20px;
|
||||
margin-left: -150px;
|
||||
left: 2px;
|
||||
background: white;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, .2);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
/* border: solid 1px #e2e2e2; */
|
||||
text-shadow: 0 0 0 transparent;
|
||||
}
|
||||
|
||||
.graph .node .popup:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-width: 10px 10px 0 10px;
|
||||
border-color: white transparent transparent transparent;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.graph .node .popup:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-width: 11px 11px 0 10px;
|
||||
border-color: rgba(0, 0, 0, .2) transparent transparent transparent;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.graph .node .popup > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.graph .node:hover .dot {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.graph .node:hover .popup {
|
||||
display: block;
|
||||
max-height: 180px;
|
||||
color: dimgray;
|
||||
}
|
||||
/* bars */
|
||||
|
||||
.bar-bg {
|
||||
display: inline-block;
|
||||
width: 240px;
|
||||
height: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bar-bar {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: #E91E63;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.bar-bar.Defcon1 {
|
||||
background: black;
|
||||
}
|
||||
|
||||
.bar-bar.Critical {
|
||||
background: #e81e1e;
|
||||
}
|
||||
|
||||
.bar-bar.High {
|
||||
background: #E91E63;
|
||||
}
|
||||
|
||||
.bar-bar.Medium {
|
||||
background: #FFA726;
|
||||
}
|
||||
|
||||
.bar-bar.Low {
|
||||
background: #8BC34A;
|
||||
}
|
||||
|
||||
.bar-bar.Negligible {
|
||||
background: #37474F;
|
||||
}
|
||||
|
||||
.bar-bar.Unknown {
|
||||
background: #37474F;
|
||||
}
|
||||
/* vulnerabilities */
|
||||
|
||||
.report {
|
||||
margin: 18px auto;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.report .vulnerabilities,
|
||||
.report .features > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.report .features > ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.feature {
|
||||
border-bottom: solid 1px #ECEFF1;
|
||||
}
|
||||
|
||||
.feature:last-child {
|
||||
border-color: #CFD8DC;
|
||||
}
|
||||
|
||||
.feature__title {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.vulnerabilities {
|
||||
padding-left: 2.6em !important;
|
||||
}
|
||||
|
||||
.vulnerability {
|
||||
padding-bottom: .8em;
|
||||
padding-right: 2.2em;
|
||||
}
|
||||
|
||||
.vulnerabilities .Defcon1 .name {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.vulnerabilities .Critical .name {
|
||||
color: #e81e1e;
|
||||
}
|
||||
|
||||
.vulnerabilities .High .name {
|
||||
color: #E91E63;
|
||||
}
|
||||
|
||||
.vulnerabilities .Medium .name {
|
||||
color: #FFA726;
|
||||
}
|
||||
|
||||
.vulnerabilities .Low .name {
|
||||
color: #8BC34A;
|
||||
}
|
||||
|
||||
.vulnerabilities .Negligible .name {
|
||||
color: #37474F;
|
||||
}
|
||||
|
||||
.vulnerabilities .Unknown .name {
|
||||
color: #37474F;
|
||||
}
|
||||
/* layers */
|
||||
|
||||
.layer .layer__title {
|
||||
cursor: pointer;
|
||||
padding: 1em;
|
||||
border-bottom: solid 1px #CFD8DC;
|
||||
color: #37474F;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.layer .layer__title:hover {
|
||||
background: #ECEFF1;
|
||||
}
|
||||
|
||||
.layer.closed .features {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
display: flex;
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
margin-bottom: 1em;
|
||||
margin-top: 3em;
|
||||
}
|
||||
|
||||
.summary-text .node {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.summary-text .node:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 50%;
|
||||
background: #2196F3;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.summary-text .node.Defcon1:before {
|
||||
background: black;
|
||||
}
|
||||
|
||||
.summary-text .node.Critical:before {
|
||||
background: #e81e1e;
|
||||
}
|
||||
|
||||
.summary-text .node.High:before {
|
||||
background: #E91E63;
|
||||
}
|
||||
|
||||
.summary-text .node.Medium:before {
|
||||
background: #FFA726;
|
||||
}
|
||||
|
||||
.summary-text .node.Low:before {
|
||||
background: #8BC34A;
|
||||
}
|
||||
|
||||
.summary-text .node.Negligible:before {
|
||||
background: #37474F;
|
||||
}
|
||||
|
||||
.summary-text .node.Unknown:before {
|
||||
background: #37474F;
|
||||
}
|
||||
|
||||
.relative-graph {
|
||||
display: flex;
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
background: #2196F3;
|
||||
flex-direction: row-reverse;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.relative-graph .node {
|
||||
text-align: center;
|
||||
height: 8px;
|
||||
background: #2196F3;
|
||||
}
|
||||
|
||||
.relative-graph .node.Defcon1 {
|
||||
background: black;
|
||||
}
|
||||
|
||||
.relative-graph .node.Critical {
|
||||
background: #e81e1e;
|
||||
}
|
||||
|
||||
.relative-graph .node.High {
|
||||
background: #E91E63;
|
||||
}
|
||||
|
||||
.relative-graph .node.Medium {
|
||||
background: #FFA726;
|
||||
}
|
||||
|
||||
.relative-graph .node.Low {
|
||||
background: #8BC34A;
|
||||
}
|
||||
|
||||
.relative-graph .node.Negligible {
|
||||
background: #37474F;
|
||||
}
|
||||
|
||||
.relative-graph .node.Unknown {
|
||||
background: #37474F;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="app-header">
|
||||
<h1>Clair Control report</h1>
|
||||
</header>
|
||||
|
||||
<div class="app-intro clearfix">
|
||||
<h2>Image: {{.ImageName}}</h2>
|
||||
{{ $ia := .}}
|
||||
|
||||
<section class="summary">
|
||||
<div>
|
||||
{{with $vulnerabilitiesCount := allVulnerabilities $ia}}
|
||||
<p><span class="lead"><strong>Total : {{$vulnerabilitiesCount.Total}} vulnerabilities</strong></span></p>
|
||||
</p>
|
||||
<div class="summary-text">
|
||||
{{if gt ($vulnerabilitiesCount.Count "Unknown") 0}}
|
||||
<div class="node Unknown">Unknown : <strong>{{$vulnerabilitiesCount.Count "Unknown"}}</strong></div>
|
||||
{{end}} {{if gt ($vulnerabilitiesCount.Count "Negligible") 0}}
|
||||
<div class="node Negligible">Negligible : <strong>{{$vulnerabilitiesCount.Count "Negligible"}}</strong></div>
|
||||
{{end}} {{if gt ($vulnerabilitiesCount.Count "Low") 0}}
|
||||
<div class="node Low">Low : <strong>{{$vulnerabilitiesCount.Count "Low"}}</strong></div>
|
||||
{{end}} {{if gt ($vulnerabilitiesCount.Count "Medium") 0}}
|
||||
<div class="node Medium">Medium : <strong>{{$vulnerabilitiesCount.Count "Medium"}}</strong></div>
|
||||
{{end}} {{if gt ($vulnerabilitiesCount.Count "High") 0}}
|
||||
<div class="node High">High : <strong>{{$vulnerabilitiesCount.Count "High"}}</strong></div>
|
||||
{{end}} {{if gt ($vulnerabilitiesCount.Count "Critical") 0}}
|
||||
<div class="node Critical">Critical : <strong>{{$vulnerabilitiesCount.Count "Critical"}}</strong></div>
|
||||
{{end}} {{if gt ($vulnerabilitiesCount.Count "Defcon1") 0}}
|
||||
<div class="node Defcon1">Defcon1 : <strong>{{$vulnerabilitiesCount.Count "Defcon1"}}</strong></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="relative-graph">
|
||||
<div class="node Defcon1" style="width: {{$vulnerabilitiesCount.RelativeCount "Defcon1"}}%"></div>
|
||||
<div class="node Critical" style="width: {{$vulnerabilitiesCount.RelativeCount "Critical"}}%"></div>
|
||||
<div class="node High" style="width: {{$vulnerabilitiesCount.RelativeCount "High"}}%"></div>
|
||||
<div class="node Medium" style="width: {{$vulnerabilitiesCount.RelativeCount "Medium"}}%"></div>
|
||||
<div class="node Low" style="width: {{$vulnerabilitiesCount.RelativeCount "Low"}}%"></div>
|
||||
<div class="node Negligible" style="width: {{$vulnerabilitiesCount.RelativeCount "Negligible"}}%"></div>
|
||||
<div class="node Unknown" style="width: {{$vulnerabilitiesCount.RelativeCount "Unknown"}}%"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="graph">
|
||||
{{range $k,$v := vulnerabilities $ia}}
|
||||
{{range $v}}
|
||||
<a class="node {{.Severity}}" href="#{{ .Name }}">
|
||||
<div class="dot"></div>
|
||||
<div class="popup">
|
||||
<div><strong>{{.Name}}</strong></div>
|
||||
<div>{{.Severity}}</div>
|
||||
<div>{{.Feature}}</div>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="report">
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="layers">
|
||||
<div id="{{.LastLayer.Name}}" class="layer">
|
||||
<h3 class="layer__title" data-toggle-layer="{{.LastLayer.Name}}">{{.LastLayer.Name}}</h3>
|
||||
<div class="features">
|
||||
<ul>
|
||||
{{ range sortedVulnerabilities $ia}}
|
||||
<li class="feature">
|
||||
<div class="feature__title">
|
||||
<strong>{{ .Name }}</strong> <span>{{ .Version }}</span> - <span class="fa fa-exclamation-triangle" aria-hidden="true"></span>
|
||||
</div>
|
||||
{{ range .Vulnerabilities}}
|
||||
<ul class="vulnerabilities">
|
||||
<li class="vulnerability {{ .Severity }}">
|
||||
<a class="vulnerability__title" name="{{ .Name }}"></a>
|
||||
<strong class="name">{{ .Name }}</strong>
|
||||
<div>{{ .Description }}</div>
|
||||
<a href="{{ .Link }}" target="blank">Link</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const togglers = document.querySelectorAll('[data-toggle-layer]');
|
||||
console.log(togglers);
|
||||
|
||||
for (var i = togglers.length - 1; i >= 0; i--) {
|
||||
togglers[i].onclick = function(e) {
|
||||
e.target.parentNode.classList.toggle('closed');
|
||||
};
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
23
cmd/clairctl/clair/versions.go
Normal file
23
cmd/clairctl/clair/versions.go
Normal file
@ -0,0 +1,23 @@
|
||||
package clair
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Versions() (interface{}, error) {
|
||||
Config()
|
||||
response, err := http.Get(uri + "/versions")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("requesting Clair version: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var versionBody interface{}
|
||||
err = json.NewDecoder(response.Body).Decode(&versionBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading Clair version body: %v", err)
|
||||
}
|
||||
return versionBody, nil
|
||||
}
|
10
cmd/clairctl/clairctl.yml.default
Normal file
10
cmd/clairctl/clairctl.yml.default
Normal file
@ -0,0 +1,10 @@
|
||||
clair:
|
||||
port: 6060
|
||||
healthPort: 6061
|
||||
uri: http://clair
|
||||
report:
|
||||
path: ./reports
|
||||
format: html
|
||||
docker:
|
||||
insecure-registries:
|
||||
- "my-own-registry:5000"
|
60
cmd/clairctl/cmd/analyze.go
Normal file
60
cmd/clairctl/cmd/analyze.go
Normal file
@ -0,0 +1,60 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/template"
|
||||
|
||||
"github.com/jgsqware/clairctl/clair"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/jgsqware/clairctl/docker"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const analyzeTplt = `
|
||||
Image: {{.String}}
|
||||
{{.Layers | len}} layers found
|
||||
{{$ia := .}}
|
||||
{{range .Layers}} ➜ {{with .Layer}}Analysis [{{.|$ia.ShortName}}] found {{.|$ia.CountVulnerabilities}} vulnerabilities.{{end}}
|
||||
{{end}}
|
||||
`
|
||||
|
||||
var analyzeCmd = &cobra.Command{
|
||||
Use: "analyze IMAGE",
|
||||
Short: "Analyze Docker image",
|
||||
Long: `Analyze a Docker image with Clair, against Ubuntu, Red hat and Debian vulnerabilities databases`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
|
||||
if len(args) != 1 {
|
||||
fmt.Printf("clairctl: \"analyze\" requires a minimum of 1 argument")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
config.ImageName = args[0]
|
||||
image, manifest, err := docker.RetrieveManifest(config.ImageName, true)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("retrieving manifest for %q: %v", config.ImageName, err)
|
||||
}
|
||||
|
||||
startLocalServer()
|
||||
if err := clair.Push(image, manifest); err != nil {
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("pushing image %q: %v", image.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
analysis := clair.Analyze(image, manifest)
|
||||
err = template.Must(template.New("analysis").Parse(analyzeTplt)).Execute(os.Stdout, analysis)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("rendering analysis: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(analyzeCmd)
|
||||
analyzeCmd.Flags().BoolVarP(&config.IsLocal, "local", "l", false, "Use local images")
|
||||
}
|
37
cmd/clairctl/cmd/health.go
Normal file
37
cmd/clairctl/cmd/health.go
Normal file
@ -0,0 +1,37 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/template"
|
||||
|
||||
"github.com/jgsqware/clairctl/clair"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const healthTplt = `
|
||||
Clair: {{if .}}✔{{else}}✘{{end}}
|
||||
`
|
||||
|
||||
type health struct {
|
||||
Clair interface{} `json:"clair"`
|
||||
}
|
||||
|
||||
var healthCmd = &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Get Health of clairctl and underlying services",
|
||||
Long: `Get Health of clairctl and underlying services`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ok := clair.IsHealthy()
|
||||
err := template.Must(template.New("health").Parse(healthTplt)).Execute(os.Stdout, ok)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("rendering the health: %v", err)
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(healthCmd)
|
||||
}
|
65
cmd/clairctl/cmd/pull.go
Normal file
65
cmd/clairctl/cmd/pull.go
Normal file
@ -0,0 +1,65 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
|
||||
"github.com/docker/docker/reference"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/jgsqware/clairctl/docker"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const pullTplt = `
|
||||
Image: {{.Named.FullName}}:{{.Named.Tag}}
|
||||
{{.Layers | len}} layers found
|
||||
{{range .Layers}} ➜ {{.}}
|
||||
{{end}}
|
||||
`
|
||||
|
||||
var pullCmd = &cobra.Command{
|
||||
Use: "pull IMAGE",
|
||||
Short: "Pull Docker image to Clair",
|
||||
Long: `Upload a Docker image to Clair for further analysis`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
|
||||
if len(args) != 1 {
|
||||
fmt.Printf("clairctl: \"pull\" requires a minimum of 1 argument\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
config.ImageName = args[0]
|
||||
image, manifest, err := docker.RetrieveManifest(config.ImageName, true)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("retrieving manifest for %q: %v", config.ImageName, err)
|
||||
}
|
||||
|
||||
layers, err := docker.GetLayerDigests(manifest)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("retrieving layers for %q: %v", config.ImageName, err)
|
||||
}
|
||||
data := struct {
|
||||
Layers []digest.Digest
|
||||
Named reference.Named
|
||||
}{
|
||||
Layers: layers,
|
||||
Named: image,
|
||||
}
|
||||
|
||||
err = template.Must(template.New("pull").Parse(pullTplt)).Execute(os.Stdout, data)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("rendering image: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(pullCmd)
|
||||
pullCmd.Flags().BoolVarP(&config.Insecure, "insecure", "i", false, "use an insecure registry")
|
||||
pullCmd.Flags().BoolVarP(&config.IsLocal, "local", "l", false, "Use local images")
|
||||
}
|
58
cmd/clairctl/cmd/push.go
Normal file
58
cmd/clairctl/cmd/push.go
Normal file
@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/jgsqware/clairctl/clair"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/jgsqware/clairctl/docker"
|
||||
"github.com/jgsqware/clairctl/server"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var pushCmd = &cobra.Command{
|
||||
Use: "push IMAGE",
|
||||
Short: "Push Docker image to Clair",
|
||||
Long: `Upload a Docker image to Clair for further analysis`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) != 1 {
|
||||
fmt.Printf("clairctl: \"push\" requires a minimum of 1 argument\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
startLocalServer()
|
||||
config.ImageName = args[0]
|
||||
image, manifest, err := docker.RetrieveManifest(config.ImageName, true)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("retrieving manifest for %q: %v", config.ImageName, err)
|
||||
}
|
||||
|
||||
if err := clair.Push(image, manifest); err != nil {
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("pushing image %q: %v", image.String(), err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%v has been pushed to Clair\n", image.String())
|
||||
},
|
||||
}
|
||||
|
||||
func startLocalServer() {
|
||||
sURL, err := config.LocalServerIP()
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("retrieving internal server IP: %v", err)
|
||||
}
|
||||
err = server.Serve(sURL)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("starting local server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(pushCmd)
|
||||
pushCmd.Flags().BoolVarP(&config.IsLocal, "local", "l", false, "Use local images")
|
||||
}
|
99
cmd/clairctl/cmd/report.go
Normal file
99
cmd/clairctl/cmd/report.go
Normal file
@ -0,0 +1,99 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jgsqware/clairctl/clair"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/jgsqware/clairctl/docker"
|
||||
"github.com/jgsqware/clairctl/xstrings"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var reportCmd = &cobra.Command{
|
||||
Use: "report IMAGE",
|
||||
Short: "Generate Docker Image vulnerabilities report",
|
||||
Long: `Generate Docker Image vulnerabilities report as HTML or JSON`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) != 1 {
|
||||
fmt.Printf("clairctl: \"report\" requires a minimum of 1 argument")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
config.ImageName = args[0]
|
||||
image, manifest, err := docker.RetrieveManifest(config.ImageName, true)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("retrieving manifest for %q: %v", config.ImageName, err)
|
||||
}
|
||||
|
||||
analyzes := clair.Analyze(image, manifest)
|
||||
imageName := strings.Replace(analyzes.ImageName, "/", "-", -1)
|
||||
if analyzes.Tag != "" {
|
||||
imageName += "-" + analyzes.Tag
|
||||
}
|
||||
|
||||
switch clair.Report.Format {
|
||||
case "html":
|
||||
html, err := clair.ReportAsHTML(analyzes)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("generating HTML report: %v", err)
|
||||
}
|
||||
err = saveReport(imageName, string(html))
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("saving HTML report: %v", err)
|
||||
}
|
||||
|
||||
case "json":
|
||||
json, err := xstrings.ToIndentJSON(analyzes)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("indenting JSON: %v", err)
|
||||
}
|
||||
err = saveReport(imageName, string(json))
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("saving JSON report: %v", err)
|
||||
}
|
||||
|
||||
default:
|
||||
fmt.Printf("Unsupported Report format: %v", clair.Report.Format)
|
||||
log.Fatalf("Unsupported Report format: %v", clair.Report.Format)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func saveReport(name string, content string) error {
|
||||
path := viper.GetString("clair.report.path") + "/" + clair.Report.Format
|
||||
if err := os.MkdirAll(path, 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reportsName := fmt.Sprintf("%v/analysis-%v.%v", path, name, strings.ToLower(clair.Report.Format))
|
||||
f, err := os.Create(reportsName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating report file: %v", err)
|
||||
}
|
||||
|
||||
_, err = f.WriteString(content)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing report file: %v", err)
|
||||
}
|
||||
fmt.Printf("%v report at %v\n", strings.ToUpper(clair.Report.Format), reportsName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(reportCmd)
|
||||
reportCmd.Flags().BoolVarP(&config.IsLocal, "local", "l", false, "Use local images")
|
||||
reportCmd.Flags().StringP("format", "f", "html", "Format for Report [html,json]")
|
||||
viper.BindPFlag("clair.report.format", reportCmd.Flags().Lookup("format"))
|
||||
}
|
54
cmd/clairctl/cmd/root.go
Normal file
54
cmd/clairctl/cmd/root.go
Normal file
@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
"github.com/jgsqware/clairctl/clair"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var errInternalError = errors.New("client quit unexpectedly")
|
||||
|
||||
var log = capnslog.NewPackageLogger("github.com/jgsqware/clairctl", "cmd")
|
||||
|
||||
var cfgFile string
|
||||
var logLevel string
|
||||
var noClean bool
|
||||
|
||||
// RootCmd represents the base command when called without any subcommands
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "clairctl",
|
||||
Short: "Analyze your docker image with Clair, directly from your registry or local images.",
|
||||
Long: ``,
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
if err := RootCmd.Execute(); err != nil {
|
||||
err = config.Clean()
|
||||
fmt.Println(err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
|
||||
if err := config.Clean(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.clairctl.yml)")
|
||||
RootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "", "log level [Panic,Fatal,Error,Warn,Info,Debug]")
|
||||
RootCmd.PersistentFlags().BoolVar(&noClean, "no-clean", false, "Disable the temporary folder cleaning")
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
config.Init(cfgFile, logLevel, noClean)
|
||||
clair.Config()
|
||||
}
|
35
cmd/clairctl/cmd/version.go
Normal file
35
cmd/clairctl/cmd/version.go
Normal file
@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/template"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const versionTplt = `
|
||||
Clairctl version {{.}}
|
||||
`
|
||||
|
||||
var version string
|
||||
|
||||
var templ = template.Must(template.New("versions").Parse(versionTplt))
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Get Versions of Clairctl and underlying services",
|
||||
Long: `Get Versions of Clairctl and underlying services`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
|
||||
err := templ.Execute(os.Stdout, version)
|
||||
if err != nil {
|
||||
fmt.Println(errInternalError)
|
||||
log.Fatalf("rendering the version: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(versionCmd)
|
||||
}
|
342
cmd/clairctl/config/config.go
Normal file
342
cmd/clairctl/config/config.go
Normal file
@ -0,0 +1,342 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
"github.com/jgsqware/clairctl/xstrings"
|
||||
"github.com/jgsqware/xnet"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var log = capnslog.NewPackageLogger("github.com/jgsqware/clairctl", "config")
|
||||
|
||||
var errNoInterfaceProvided = errors.New("could not load configuration: no interface provided")
|
||||
var errInvalidInterface = errors.New("Interface does not exist")
|
||||
var ErrLoginNotFound = errors.New("user is not log in")
|
||||
|
||||
var IsLocal = false
|
||||
var Insecure = false
|
||||
var NoClean = false
|
||||
|
||||
var ImageName string
|
||||
|
||||
type reportConfig struct {
|
||||
Path, Format string
|
||||
}
|
||||
type clairConfig struct {
|
||||
URI string
|
||||
Port, HealthPort int
|
||||
Report reportConfig
|
||||
}
|
||||
type authConfig struct {
|
||||
InsecureSkipVerify bool
|
||||
}
|
||||
type clairctlConfig struct {
|
||||
IP, Interface, TempFolder string
|
||||
Port int
|
||||
}
|
||||
type docker struct {
|
||||
InsecureRegistries []string
|
||||
}
|
||||
|
||||
type config struct {
|
||||
Clair clairConfig
|
||||
Auth authConfig
|
||||
Clairctl clairctlConfig
|
||||
Docker docker
|
||||
}
|
||||
|
||||
// Init reads in config file and ENV variables if set.
|
||||
func Init(cfgFile string, logLevel string, noClean bool) {
|
||||
|
||||
NoClean = noClean
|
||||
|
||||
lvl := capnslog.WARNING
|
||||
if logLevel != "" {
|
||||
// Initialize logging system
|
||||
var err error
|
||||
lvl, err = capnslog.ParseLevel(strings.ToUpper(logLevel))
|
||||
if err != nil {
|
||||
log.Warningf("Wrong Log level %v, defaults to [Warning]", logLevel)
|
||||
lvl = capnslog.WARNING
|
||||
}
|
||||
|
||||
}
|
||||
capnslog.SetGlobalLogLevel(lvl)
|
||||
capnslog.SetFormatter(capnslog.NewPrettyFormatter(os.Stdout, false))
|
||||
|
||||
viper.SetEnvPrefix("clairctl")
|
||||
viper.SetConfigName("clairctl") // name of config file (without extension)
|
||||
viper.AddConfigPath("$HOME/.clairctl") // adding home directory as first search path
|
||||
viper.AddConfigPath(".") // adding home directory as first search path
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
if cfgFile != "" {
|
||||
viper.SetConfigFile(cfgFile)
|
||||
}
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
log.Debugf("No config file used")
|
||||
} else {
|
||||
log.Debugf("Using config file: %v", viper.ConfigFileUsed())
|
||||
}
|
||||
|
||||
if viper.Get("clair.uri") == nil {
|
||||
viper.Set("clair.uri", "http://localhost")
|
||||
}
|
||||
if viper.Get("clair.port") == nil {
|
||||
viper.Set("clair.port", "6060")
|
||||
}
|
||||
if viper.Get("clair.healthPort") == nil {
|
||||
viper.Set("clair.healthPort", "6061")
|
||||
}
|
||||
|
||||
if viper.Get("clair.report.path") == nil {
|
||||
viper.Set("clair.report.path", "reports")
|
||||
}
|
||||
if viper.Get("clair.report.format") == nil {
|
||||
viper.Set("clair.report.format", "html")
|
||||
}
|
||||
if viper.Get("auth.insecureSkipVerify") == nil {
|
||||
viper.Set("auth.insecureSkipVerify", "true")
|
||||
}
|
||||
if viper.Get("clairctl.ip") == nil {
|
||||
viper.Set("clairctl.ip", "")
|
||||
}
|
||||
if viper.Get("clairctl.port") == nil {
|
||||
viper.Set("clairctl.port", 0)
|
||||
}
|
||||
if viper.Get("clairctl.interface") == nil {
|
||||
viper.Set("clairctl.interface", "")
|
||||
}
|
||||
if viper.Get("clairctl.tempFolder") == nil {
|
||||
viper.Set("clairctl.tempFolder", "/tmp/clairctl")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TmpLocal() string {
|
||||
return viper.GetString("clairctl.tempFolder")
|
||||
}
|
||||
|
||||
func values() config {
|
||||
return config{
|
||||
Clair: clairConfig{
|
||||
URI: viper.GetString("clair.uri"),
|
||||
Port: viper.GetInt("clair.port"),
|
||||
HealthPort: viper.GetInt("clair.healthPort"),
|
||||
Report: reportConfig{
|
||||
Path: viper.GetString("clair.report.path"),
|
||||
Format: viper.GetString("clair.report.format"),
|
||||
},
|
||||
},
|
||||
Auth: authConfig{
|
||||
InsecureSkipVerify: viper.GetBool("auth.insecureSkipVerify"),
|
||||
},
|
||||
Clairctl: clairctlConfig{
|
||||
IP: viper.GetString("clairctl.ip"),
|
||||
Port: viper.GetInt("clairctl.port"),
|
||||
TempFolder: viper.GetString("clairctl.tempFolder"),
|
||||
Interface: viper.GetString("clairctl.interface"),
|
||||
},
|
||||
Docker: docker{
|
||||
InsecureRegistries: viper.GetStringSlice("docker.insecure-registries"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Print() {
|
||||
cfg := values()
|
||||
cfgBytes, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("marshalling configuration: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Configuration")
|
||||
fmt.Printf("%v", string(cfgBytes))
|
||||
}
|
||||
|
||||
func ClairctlHome() string {
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
p := usr.HomeDir + "/.clairctl"
|
||||
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
os.Mkdir(p, 0700)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type loginMapping map[string]Login
|
||||
|
||||
func ClairctlConfig() string {
|
||||
return ClairctlHome() + "/config.json"
|
||||
}
|
||||
|
||||
func AddLogin(registry string, login Login) error {
|
||||
var logins loginMapping
|
||||
|
||||
if err := readConfigFile(&logins, ClairctlConfig()); err != nil {
|
||||
return fmt.Errorf("reading clairctl file: %v", err)
|
||||
}
|
||||
|
||||
logins[registry] = login
|
||||
|
||||
if err := writeConfigFile(logins, ClairctlConfig()); err != nil {
|
||||
return fmt.Errorf("indenting login: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func GetLogin(registry string) (Login, error) {
|
||||
if _, err := os.Stat(ClairctlConfig()); err == nil {
|
||||
var logins loginMapping
|
||||
|
||||
if err := readConfigFile(&logins, ClairctlConfig()); err != nil {
|
||||
return Login{}, fmt.Errorf("reading clairctl file: %v", err)
|
||||
}
|
||||
|
||||
if login, present := logins[registry]; present {
|
||||
d, err := base64.StdEncoding.DecodeString(login.Password)
|
||||
if err != nil {
|
||||
return Login{}, fmt.Errorf("decoding password: %v", err)
|
||||
}
|
||||
login.Password = string(d)
|
||||
return login, nil
|
||||
}
|
||||
}
|
||||
return Login{}, ErrLoginNotFound
|
||||
}
|
||||
|
||||
func RemoveLogin(registry string) (bool, error) {
|
||||
if _, err := os.Stat(ClairctlConfig()); err == nil {
|
||||
var logins loginMapping
|
||||
|
||||
if err := readConfigFile(&logins, ClairctlConfig()); err != nil {
|
||||
return false, fmt.Errorf("reading clairctl file: %v", err)
|
||||
}
|
||||
|
||||
if _, present := logins[registry]; present {
|
||||
delete(logins, registry)
|
||||
|
||||
if err := writeConfigFile(logins, ClairctlConfig()); err != nil {
|
||||
return false, fmt.Errorf("indenting login: %v", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func readConfigFile(logins *loginMapping, file string) error {
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
f, err := ioutil.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(f, &logins); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
*logins = loginMapping{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeConfigFile(logins loginMapping, file string) error {
|
||||
s, err := xstrings.ToIndentJSON(logins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = ioutil.WriteFile(file, s, os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
//LocalServerIP return the local clairctl server IP
|
||||
func LocalServerIP() (string, error) {
|
||||
localPort := viper.GetString("clairctl.port")
|
||||
localIP := viper.GetString("clairctl.ip")
|
||||
localInterfaceConfig := viper.GetString("clairctl.interface")
|
||||
|
||||
if localIP == "" {
|
||||
log.Info("retrieving interface for local IP")
|
||||
var err error
|
||||
var localInterface net.Interface
|
||||
localInterface, err = translateInterface(localInterfaceConfig)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("retrieving interface: %v", err)
|
||||
}
|
||||
|
||||
localIP, err = xnet.IPv4(localInterface)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("retrieving interface ip: %v", err)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(localIP) + ":" + localPort, nil
|
||||
}
|
||||
|
||||
func translateInterface(localInterface string) (net.Interface, error) {
|
||||
|
||||
if localInterface != "" {
|
||||
log.Debug("interface provided, looking for " + localInterface)
|
||||
netInterface, err := net.InterfaceByName(localInterface)
|
||||
if err != nil {
|
||||
return net.Interface{}, err
|
||||
}
|
||||
return *netInterface, nil
|
||||
}
|
||||
|
||||
log.Debug("no interface provided, looking for docker0")
|
||||
netInterface, err := net.InterfaceByName("docker0")
|
||||
if err != nil {
|
||||
log.Debug("docker0 not found, looking for first connected broadcast interface")
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return net.Interface{}, err
|
||||
}
|
||||
|
||||
i, err := xnet.First(xnet.Filter(interfaces, xnet.IsBroadcast), xnet.HasAddr)
|
||||
if err != nil {
|
||||
return net.Interface{}, err
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
return *netInterface, nil
|
||||
|
||||
}
|
||||
|
||||
func Clean() error {
|
||||
if IsLocal && !NoClean {
|
||||
log.Debug("cleaning temporary local repository")
|
||||
err := os.RemoveAll(TmpLocal())
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("cleaning temporary local repository: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
121
cmd/clairctl/config/config_test.go
Normal file
121
cmd/clairctl/config/config_test.go
Normal file
@ -0,0 +1,121 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jgsqware/clairctl/test"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
const defaultValues = `
|
||||
clair:
|
||||
uri: http://localhost
|
||||
port: 6060
|
||||
healthport: 6061
|
||||
report:
|
||||
path: reports
|
||||
format: html
|
||||
auth:
|
||||
insecureskipverify: true
|
||||
clairctl:
|
||||
ip: ""
|
||||
tempfolder: /tmp/clairctl
|
||||
port: 0
|
||||
`
|
||||
|
||||
const customValues = `
|
||||
clair:
|
||||
uri: http://clair
|
||||
port: 6061
|
||||
healthport: 6062
|
||||
report:
|
||||
path: reports/test
|
||||
format: json
|
||||
auth:
|
||||
insecureskipverify: false
|
||||
clairctl:
|
||||
ip: "localhost"
|
||||
tempfolder: /tmp/clairctl/test
|
||||
port: 64157
|
||||
`
|
||||
|
||||
func TestInitDefault(t *testing.T) {
|
||||
Init("", "INFO")
|
||||
|
||||
cfg := values()
|
||||
|
||||
var expected config
|
||||
err := yaml.Unmarshal([]byte(defaultValues), &expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg != expected {
|
||||
t.Error("Default values are not correct")
|
||||
}
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
func TestInitCustomLocal(t *testing.T) {
|
||||
tmpfile := test.CreateConfigFile(customValues, "clairctl.yml", ".")
|
||||
defer os.Remove(tmpfile) // clean up
|
||||
fmt.Println(tmpfile)
|
||||
Init("", "INFO")
|
||||
|
||||
cfg := values()
|
||||
|
||||
var expected config
|
||||
err := yaml.Unmarshal([]byte(customValues), &expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg != expected {
|
||||
t.Error("values are not correct")
|
||||
}
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
func TestInitCustomHome(t *testing.T) {
|
||||
tmpfile := test.CreateConfigFile(customValues, "clairctl.yml", ClairctlHome())
|
||||
defer os.Remove(tmpfile) // clean up
|
||||
fmt.Println(tmpfile)
|
||||
Init("", "INFO")
|
||||
|
||||
cfg := values()
|
||||
|
||||
var expected config
|
||||
err := yaml.Unmarshal([]byte(customValues), &expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg != expected {
|
||||
t.Error("values are not correct")
|
||||
}
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
func TestInitCustom(t *testing.T) {
|
||||
tmpfile := test.CreateConfigFile(customValues, "clairctl.yml", "/tmp")
|
||||
defer os.Remove(tmpfile) // clean up
|
||||
fmt.Println(tmpfile)
|
||||
Init(tmpfile, "INFO")
|
||||
|
||||
cfg := values()
|
||||
|
||||
var expected config
|
||||
err := yaml.Unmarshal([]byte(customValues), &expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg != expected {
|
||||
t.Error("values are not correct")
|
||||
}
|
||||
viper.Reset()
|
||||
}
|
7
cmd/clairctl/contrib/.hyperclair.yml
Normal file
7
cmd/clairctl/contrib/.hyperclair.yml
Normal file
@ -0,0 +1,7 @@
|
||||
clair:
|
||||
port: 6060
|
||||
healthPort: 6061
|
||||
uri: http://clair
|
||||
report:
|
||||
path: ./reports
|
||||
format: html
|
14
cmd/clairctl/contrib/README.md
Normal file
14
cmd/clairctl/contrib/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
CONTRIBUTION
|
||||
-----------------
|
||||
|
||||
# Running full dev environnement
|
||||
|
||||
```bash
|
||||
# Running Authentication server, Registry, Clair
|
||||
docker-compose up -d
|
||||
|
||||
# Run Any command ex:
|
||||
go run main.go help
|
||||
# Or
|
||||
go run main.go pull registry:5000/wemanity-belgium/ubuntu-git
|
||||
```
|
29
cmd/clairctl/contrib/auth_server/config/auth_config.yml
Normal file
29
cmd/clairctl/contrib/auth_server/config/auth_config.yml
Normal file
@ -0,0 +1,29 @@
|
||||
server:
|
||||
addr: :5001
|
||||
certificate: /ssl/server.pem
|
||||
key: /ssl/server.key
|
||||
token:
|
||||
issuer: auth_service
|
||||
expiration: 900
|
||||
users:
|
||||
"": {}
|
||||
jgsqware:
|
||||
password: $2y$05$oGKwJ8QJDLBOoTBmC/EQiefIMV1N9Yt9jpX3SqMoRqZRRql6q7yym
|
||||
acl:
|
||||
- match:
|
||||
account: jgsqware
|
||||
actions: ['*']
|
||||
- match:
|
||||
account: /.+/
|
||||
name: ${account}/*
|
||||
actions: ['*']
|
||||
- match:
|
||||
type: registry
|
||||
name: catalog
|
||||
actions: ['*']
|
||||
comment: Anonymous users can get catalog.
|
||||
- match:
|
||||
account: ""
|
||||
name: /.*/
|
||||
actions: [pull]
|
||||
comment: Anonymous users can pull everything.
|
28
cmd/clairctl/contrib/auth_server/ssl/old/server.key
Normal file
28
cmd/clairctl/contrib/auth_server/ssl/old/server.key
Normal file
@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQClbJbUWDCY31g5
|
||||
hyFlhCpgXOntcqR72vVRew6vw9ooN7LKjC/ev1AbBD3JYca1R9vd/7D6wI5rYNR0
|
||||
ZtCMxaQLEgvDf166VdrZewkq8UmbVSFwqtBvcZCG7mTapzgap6jD5H/fFoFoSkFa
|
||||
V3dGWlEaS5emtk7fLjXTKO0OpByuO2mmRqAoTsnN4O3tYfrW7ReBHueibVIpjZo9
|
||||
0qyFK3sZ2ewKG98m8OGh1jzOhxM8PZDKs9KjkZ/SJiGwxIQ/Ny+rXci7q3cshQna
|
||||
n7ac2Wf0nG21j7eNk0lSKOkT3unb2+1B2+59mEtLauoYs1VcEmM55RnVdawvCmIy
|
||||
LRowuLlDAgMBAAECggEAP3ELz1gbGyXcwgNPDY3Iarh3hncHGfD5UExvb30fN3lU
|
||||
+lUVLsoUQKg5wffbqz5p1hPvndsnQ4sZL6MWrEZICW7cUBeTDsdKbUnAVFXBMu9N
|
||||
KdZ4paTaFsVqrGihHafbE3WYjMgmzQZdVfZhafvNStZezLLyQKmKPvddItZRoYfN
|
||||
sc+iFpT94hPp9Hjs9ClLQv/w9Xt8lVgD1FUh6yAlLUAn77HzbZuyC2nF4gbD2LiS
|
||||
4G+xHcH77FyAU5W6BRv1DqNsuu0ksX/93GiYx0EebzT/IXa7xc0mYE0758EXk72y
|
||||
yoznglkPkSOyyhcuI75FKMyYdQGKpyvw+y4aEv5JwQKBgQDTAaQ827Tpn/aMhP7L
|
||||
jngFgTdfeq/7Q3eZjGgtr5RFnen6YS6WzWigvh5/70ASDziFd4fyd0P41/MjPkO6
|
||||
FTFWisRCpW14+mSTUSDmgTQfsONy1Xr2ib4v4CX2NEy+nUsvpdl72dwZAG/fSu3K
|
||||
MfkVksd5Z56WJ4wxKrB4riHukQKBgQDIsren8ljtxrLepMHvaNLx5Gl1EtrgX3gy
|
||||
zTuUM2CSQktwBYNsp68AloOi6/tuH8t1xcBc8ywgCZ2WXzYdRL/koXsd2IpOTsLd
|
||||
m/zGILgRPVow70yoxKxqxW8YYuQ1gLeAOshj8IHGGfnXTvvpNQNvrnja0NzavjFU
|
||||
tR3aZQb8kwKBgQCOqNx2vQCKt7kEdmKiE1e4OQ3MAvH6SjoRWWmSAdSYYNSxkITk
|
||||
NkpX61JJouNJknrfWdpTJymQk8hx+oXlyLBL15Qrjxb9pSTcqQw6a/5msryEhisV
|
||||
hjlMuxpPZDrC4SvVMidhYgE58h6w9ELi4niKimtM/K6uzFwvXbJkVS7h0QKBgErT
|
||||
Zum0zzcHZ9TedHfACzWoRTEi8HvK3FOEdPwSE6U0FlATniY6dmKvuzBY7wrly8OD
|
||||
EO8WspLXQuu3X8OVyD2DfxVnkFkVwE1DRQDRXg7/YsrvzRL3EJlWNs9Ov2q7LK8g
|
||||
O2oXVyr2sFF33y/ZVgijceeTC2R6mIXOaOzt0acFAoGASB7aF8PTT7YzFCGK/x2O
|
||||
kg4GLJJSlDyhAZzQqe5LBZB+RhkoHZjdQHcMW84iHp8CsFqb3/D8X+5FsDkwBSMP
|
||||
bN1fCFE03BsqubtKhI9kMz5hP1OhxlMZdMxRscbdRZqo57f3imtXg6laOktYyPOy
|
||||
uOzr/Cxm5YUQqyAJ/S4zVuc=
|
||||
-----END PRIVATE KEY-----
|
21
cmd/clairctl/contrib/auth_server/ssl/old/server.pem
Normal file
21
cmd/clairctl/contrib/auth_server/ssl/old/server.pem
Normal file
@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJAOMN706JOuJOMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQwHhcNMTYwMTI4MTcwNjE4WhcNMTcwMTI3MTcwNjE4WjBF
|
||||
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
|
||||
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
|
||||
CgKCAQEApWyW1FgwmN9YOYchZYQqYFzp7XKke9r1UXsOr8PaKDeyyowv3r9QGwQ9
|
||||
yWHGtUfb3f+w+sCOa2DUdGbQjMWkCxILw39eulXa2XsJKvFJm1UhcKrQb3GQhu5k
|
||||
2qc4Gqeow+R/3xaBaEpBWld3RlpRGkuXprZO3y410yjtDqQcrjtppkagKE7JzeDt
|
||||
7WH61u0XgR7nom1SKY2aPdKshSt7GdnsChvfJvDhodY8zocTPD2QyrPSo5Gf0iYh
|
||||
sMSEPzcvq13Iu6t3LIUJ2p+2nNln9JxttY+3jZNJUijpE97p29vtQdvufZhLS2rq
|
||||
GLNVXBJjOeUZ1XWsLwpiMi0aMLi5QwIDAQABo1AwTjAdBgNVHQ4EFgQUWCDpNrvl
|
||||
IPntyV7Y4uyoAq+aPiQwHwYDVR0jBBgwFoAUWCDpNrvlIPntyV7Y4uyoAq+aPiQw
|
||||
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAROOTzaxkx9YdvnAEj8Pt
|
||||
Ej1FgiN1ogxrL4RMwyXs5cAdHi0G71En9onB/8CaWXVrrJbu6DosNn2RQmNapxPG
|
||||
CkT7QfuYVyZF5jtigsxc+W7aLzASLZHHRA0FcgxUAlEUVaxD3xs6U2jMMntp53ij
|
||||
kOWmalMi5qOBps8PCD9sd9MDejLFihPAIz15l3TgVkbRvtcUlfmMio5AJYzjbm4/
|
||||
0c8brR9tOp3qapeT78AhOmsF7zOVygd/BRIBG+Ynzo2DudBUs/j/4VOt9D9XO4I7
|
||||
e3UaqN2OMcL5RYZ5cHemAAy9jjq9/NAYUyLLP0DiCe6OY7SKsDlGfkYVLpZMbUth
|
||||
9w==
|
||||
-----END CERTIFICATE-----
|
28
cmd/clairctl/contrib/auth_server/ssl/server.key
Normal file
28
cmd/clairctl/contrib/auth_server/ssl/server.key
Normal file
@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDP8wLYKQgqxNRw
|
||||
Liy4ZuYGHvDfK/NT0CIlvMJuobPt6NIGi6NRISLyR7eZvo6oHTN3i0ElpblzL0Hq
|
||||
JmF87d7o7tqVxO2Fq+faslXrKTgMpDiPj+WxUR3igTy2+DZ4ZP00Y4jwaPYtvwSi
|
||||
sOP2YeM6r+3sSETj5XonIr+/U3mQYkU9GgFQIbPmJnyPb+WaS0fBZ686zeIKvY3Y
|
||||
+enSCKOjss1QFyTb0TmmwQUcTBCiEXV10FSgzV383ghj+Fq9+nKo3cwooHNYwU+a
|
||||
/6gpI1GGWR074J7PGeBId4DNItcpR1x58BGmgaVHabsEW+9RdMNnh17QFk3Wt2eT
|
||||
S5Knl4UTAgMBAAECggEAcmSoZ+kKiRyGEMAV8csJNszGjL5MuQqB/mh8PQfPR00Q
|
||||
XHFsgjDMXKN/KKBfMbP+oACG8gLcpbSVeg1rC6J/QXxD2qfeUe5jOTdpdFfUcX/V
|
||||
bYQnQwfwfK3DjJO2wzwq3irzJe1Xn4q5LhZJETyAF8S4CYcn/oY6UFUZTlLJSNcH
|
||||
chQOFWvjk13DBjGAmZmjwWKxHoZsKs0ioHtShgONpPM8TZU6SmtJxdFD2pBNp+ba
|
||||
Lj5IQUYWrfCudBlzqvpXmqBlZe1J1MG+FafvAKx2CFbYkVObjRa/5DtQs99qac8y
|
||||
rhn8uloK9gljiszwwUVq/ImrUICP+20rHW+kLfHeYQKBgQD9U2XqXz0d2asD72wS
|
||||
+6yhxY4KZ3TD6W3GgfADC/kTfY+pME7KAXr//7paJJP/GtOxsLGRDHV347c3o3js
|
||||
OGlFWuUSsuJxGq4SwKuo9eRVbOMEXiVlgCuUL5HAk2co1MbKVhSJ5RGbrp6785JO
|
||||
JJcuNUTlaUsgQExEsIFJmZpbdQKBgQDSJPwl3uZIg0GC4QbQTAG1ueiQ9oPJ9kyz
|
||||
cjT31ar9L6VrLwY/MMHYKgBD5JLxkq8qL1h9ii/LJKjX7rX3yttu/qtTMO4Y82CP
|
||||
XnmR5kbODUUfiirQjTQFS3YP390nAewLwRgYPcvpyNIWA6Im6UdFJECLOTUBeiYg
|
||||
VumEhSe1ZwKBgAEj6faHHThQLYPkBQGE3n8P65bCZnUnTNYy6Yip+iILU6U4UXJ5
|
||||
VTtnxEf5mCzyyvcmy3XSr4itnrqCYt31Vwv338YYxgoqS5RMB7nH+ZIk3lS7s8Fk
|
||||
NU4CdM6AG1vEsWxhvM/uFwkzXQWNkCAH7CJKHRhHRA5OG8nHXZ2eMmKtAoGBAJ0J
|
||||
1IA8fVys8bTrkprwYcq6/ifugHfZnmHvM9QNEXWZOIXLo2BvgDyYzo/t7T2nv0zI
|
||||
Ctnt/V9SqvaKxeNB7g+ZMtC9XQC6R2t8T18PddQfqIs0RmCJVNmsFbMxOOQglJQI
|
||||
HYhoDc1MLGsVFgT8CS2LNMyV2J2c+YbrTCCjHRR7AoGAICzoSClfvjmRg+4TP9/d
|
||||
rixJF1UX77TnEhcHaFNBDnmSEX0K4rUr1o6GVZCwI+urL7ZmziDdmTDdbXWjqviJ
|
||||
73COPw798Ox50VoVWssMGZQkXfbkk2yilLbok08ohlvVhzpiyecgbxAe4C6KRWWg
|
||||
WEALyN3lILlyj1cYknRJ7gk=
|
||||
-----END PRIVATE KEY-----
|
21
cmd/clairctl/contrib/auth_server/ssl/server.pem
Normal file
21
cmd/clairctl/contrib/auth_server/ssl/server.pem
Normal file
@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDezCCAmOgAwIBAgIJAJXlshcLjIlpMA0GCSqGSIb3DQEBCwUAMFQxCzAJBgNV
|
||||
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQxDTALBgNVBAMMBGF1dGgwHhcNMTYwMTI5MDcyMTE3WhcN
|
||||
MTcwMTI4MDcyMTE3WjBUMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0
|
||||
ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMQ0wCwYDVQQDDARh
|
||||
dXRoMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz/MC2CkIKsTUcC4s
|
||||
uGbmBh7w3yvzU9AiJbzCbqGz7ejSBoujUSEi8ke3mb6OqB0zd4tBJaW5cy9B6iZh
|
||||
fO3e6O7alcTthavn2rJV6yk4DKQ4j4/lsVEd4oE8tvg2eGT9NGOI8Gj2Lb8EorDj
|
||||
9mHjOq/t7EhE4+V6JyK/v1N5kGJFPRoBUCGz5iZ8j2/lmktHwWevOs3iCr2N2Pnp
|
||||
0gijo7LNUBck29E5psEFHEwQohF1ddBUoM1d/N4IY/havfpyqN3MKKBzWMFPmv+o
|
||||
KSNRhlkdO+CezxngSHeAzSLXKUdcefARpoGlR2m7BFvvUXTDZ4de0BZN1rdnk0uS
|
||||
p5eFEwIDAQABo1AwTjAdBgNVHQ4EFgQUcCD00y15Rdvwe8VnwoZee+J+6ucwHwYD
|
||||
VR0jBBgwFoAUcCD00y15Rdvwe8VnwoZee+J+6ucwDAYDVR0TBAUwAwEB/zANBgkq
|
||||
hkiG9w0BAQsFAAOCAQEAvmlCA49FGGOZS5CWl/NzH3es3N1Gr8MihdAK0vYLxbOM
|
||||
8qA2PirEjJ6sWSeB0ZthVpk/dcod68r4dpFh7hpypvaEerFbpr+eWa9nf/KVJ/ft
|
||||
ClLw+iWZpjEjmtSbSg/XIfraWfvwQp9XNMcmIeHvovHd4HyyU1Ulx6aE31wnZ6SJ
|
||||
UKhTPgft0DRsmvFMc683jjeUg/Ik/XknnCiSyfVvwv7UEUs7sH85mE0p4giJxhEv
|
||||
7MdGlQkob+58BpzsErjoj+RpZSljna98NpwBZUfbxkYE2KzU0oqPC0zQ8KawPtw1
|
||||
OB9O45KN2mJ9dPIAbezQHolrTQ7V+49/nhTghS/T3Q==
|
||||
-----END CERTIFICATE-----
|
77
cmd/clairctl/contrib/config/clair.yml
Normal file
77
cmd/clairctl/contrib/config/clair.yml
Normal file
@ -0,0 +1,77 @@
|
||||
# Copyright 2015 clair authors
|
||||
#
|
||||
# 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.
|
||||
|
||||
# The values specified here are the default values that Clair uses if no configuration file is specified or if the keys are not defined.
|
||||
clair:
|
||||
database:
|
||||
# PostgreSQL Connection string
|
||||
# http://www.postgresql.org/docs/9.4/static/libpq-connect.html
|
||||
source: postgresql://postgres:5432?sslmode=disable&user=postgres&password=root
|
||||
|
||||
# Number of elements kept in the cache
|
||||
# Values unlikely to change (e.g. namespaces) are cached in order to save prevent needless roundtrips to the database.
|
||||
cacheSize: 16384
|
||||
|
||||
api:
|
||||
# API server port
|
||||
port: 6060
|
||||
|
||||
# Health server port
|
||||
# This is an unencrypted endpoint useful for load balancers to check to healthiness of the clair server.
|
||||
healthport: 6061
|
||||
|
||||
# Deadline before an API request will respond with a 503
|
||||
timeout: 900s
|
||||
|
||||
# 32-bit URL-safe base64 key used to encrypt pagination tokens
|
||||
# If one is not provided, it will be generated.
|
||||
# Multiple clair instances in the same cluster need the same value.
|
||||
paginationKey:
|
||||
|
||||
# Optional PKI configuration
|
||||
# If you want to easily generate client certificates and CAs, try the following projects:
|
||||
# https://github.com/coreos/etcd-ca
|
||||
# https://github.com/cloudflare/cfssl
|
||||
servername:
|
||||
cafile:
|
||||
keyfile:
|
||||
certfile:
|
||||
|
||||
updater:
|
||||
# Frequency the database will be updated with vulnerabilities from the default data sources
|
||||
# The value 0 disables the updater entirely.
|
||||
interval: 2h
|
||||
|
||||
notifier:
|
||||
# Number of attempts before the notification is marked as failed to be sent
|
||||
attempts: 3
|
||||
|
||||
# Duration before a failed notification is retried
|
||||
renotifyInterval: 2h
|
||||
|
||||
http:
|
||||
# Optional endpoint that will receive notifications via POST requests
|
||||
endpoint:
|
||||
|
||||
# Optional PKI configuration
|
||||
# If you want to easily generate client certificates and CAs, try the following projects:
|
||||
# https://github.com/cloudflare/cfssl
|
||||
# https://github.com/coreos/etcd-ca
|
||||
servername:
|
||||
cafile:
|
||||
keyfile:
|
||||
certfile:
|
||||
|
||||
# Optional HTTP Proxy: must be a valid URL (including the scheme).
|
||||
proxy:
|
52
cmd/clairctl/contrib/docker-compose.yml
Normal file
52
cmd/clairctl/contrib/docker-compose.yml
Normal file
@ -0,0 +1,52 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
auth:
|
||||
image: cesanta/docker_auth:stable
|
||||
ports:
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
- ./auth_server/config:/config:ro
|
||||
- ./auth_server/ssl:/ssl
|
||||
command: --v=2 --alsologtostderr /config/auth_config.yml
|
||||
container_name: "auth"
|
||||
|
||||
registry:
|
||||
image: registry:2.2.1
|
||||
ports:
|
||||
- 5000:5000
|
||||
volumes:
|
||||
- ./auth_server/ssl:/ssl
|
||||
- registry-data:/var/lib/registry
|
||||
container_name: "registry"
|
||||
environment:
|
||||
- REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY=/var/lib/registry
|
||||
# - REGISTRY_AUTH=token
|
||||
# - REGISTRY_AUTH_TOKEN_REALM=https://auth:5001/auth
|
||||
# - REGISTRY_AUTH_TOKEN_SERVICE="registry"
|
||||
# - REGISTRY_AUTH_TOKEN_ISSUER="auth_service"
|
||||
# - REGISTRY_AUTH_TOKEN_ROOTCERTBUNDLE=/ssl/server.pem
|
||||
|
||||
clair:
|
||||
image: quay.io/coreos/clair:v1.2.6
|
||||
volumes:
|
||||
- /tmp:/tmp
|
||||
- ./config:/config
|
||||
- clair-data:/var/local
|
||||
ports:
|
||||
- 6060:6060
|
||||
- 6061:6061
|
||||
container_name: "clair"
|
||||
command: --log-level=debug --config=/config/clair.yml
|
||||
|
||||
postgres:
|
||||
image: postgres
|
||||
container_name: "postgres"
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=root
|
||||
|
||||
volumes:
|
||||
clair-data:
|
||||
driver: local
|
||||
registry-data:
|
||||
driver: local
|
34
cmd/clairctl/contrib/ssl_registry/registry.crt
Normal file
34
cmd/clairctl/contrib/ssl_registry/registry.crt
Normal file
@ -0,0 +1,34 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
51
cmd/clairctl/contrib/ssl_registry/registry.key
Normal file
51
cmd/clairctl/contrib/ssl_registry/registry.key
Normal file
@ -0,0 +1,51 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
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
|
||||
-----END RSA PRIVATE KEY-----
|
54
cmd/clairctl/docker/docker.go
Normal file
54
cmd/clairctl/docker/docker.go
Normal file
@ -0,0 +1,54 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
"github.com/docker/distribution"
|
||||
"github.com/docker/distribution/manifest/schema1"
|
||||
"github.com/docker/distribution/manifest/schema2"
|
||||
"github.com/docker/docker/reference"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/jgsqware/clairctl/docker/dockercli"
|
||||
"github.com/jgsqware/clairctl/docker/dockerdist"
|
||||
"github.com/opencontainers/go-digest"
|
||||
)
|
||||
|
||||
//RetrieveManifest get manifest from local or remote docker registry
|
||||
func RetrieveManifest(imageName string, withExport bool) (image reference.NamedTagged, manifest distribution.Manifest, err error) {
|
||||
|
||||
if !config.IsLocal {
|
||||
image, manifest, err = dockerdist.DownloadManifest(imageName, true)
|
||||
} else {
|
||||
image, manifest, err = dockercli.GetLocalManifest(imageName, withExport)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
//GetLayerDigests return layer digests from manifest schema1 and schema2
|
||||
func GetLayerDigests(manifest distribution.Manifest) ([]digest.Digest, error) {
|
||||
layers := []digest.Digest{}
|
||||
|
||||
switch manifest.(type) {
|
||||
case schema1.SignedManifest:
|
||||
for _, l := range manifest.(schema1.SignedManifest).FSLayers {
|
||||
layers = append(layers, l.BlobSum)
|
||||
}
|
||||
case *schema1.SignedManifest:
|
||||
for _, l := range manifest.(*schema1.SignedManifest).FSLayers {
|
||||
layers = append(layers, l.BlobSum)
|
||||
}
|
||||
case *schema2.DeserializedManifest:
|
||||
for _, d := range manifest.(*schema2.DeserializedManifest).Layers {
|
||||
layers = append(layers, d.Digest)
|
||||
}
|
||||
case schema2.DeserializedManifest:
|
||||
for _, d := range manifest.(schema2.DeserializedManifest).Layers {
|
||||
layers = append(layers, d.Digest)
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("Not supported manifest schema type: " + reflect.TypeOf(manifest).String())
|
||||
}
|
||||
|
||||
return layers, nil
|
||||
}
|
224
cmd/clairctl/docker/dockercli/dockercli.go
Normal file
224
cmd/clairctl/docker/dockercli/dockercli.go
Normal file
@ -0,0 +1,224 @@
|
||||
package dockercli
|
||||
|
||||
import (
|
||||
"compress/bzip2"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/artyom/untar"
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
"github.com/docker/distribution"
|
||||
"github.com/docker/distribution/manifest/schema1"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/image"
|
||||
"github.com/docker/docker/layer"
|
||||
"github.com/docker/docker/reference"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/opencontainers/go-digest"
|
||||
)
|
||||
|
||||
var log = capnslog.NewPackageLogger("github.com/jgsqware/clairctl", "dockercli")
|
||||
|
||||
//GetLocalManifest retrieve manifest for local image
|
||||
func GetLocalManifest(imageName string, withExport bool) (reference.NamedTagged, distribution.Manifest, error) {
|
||||
|
||||
n, err := reference.ParseNamed(imageName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var image reference.NamedTagged
|
||||
if reference.IsNameOnly(n) {
|
||||
image = reference.WithDefaultTag(n).(reference.NamedTagged)
|
||||
} else {
|
||||
image = n.(reference.NamedTagged)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var manifest distribution.Manifest
|
||||
if withExport {
|
||||
manifest, err = save(image.Name() + ":" + image.Tag())
|
||||
} else {
|
||||
manifest, err = historyFromCommand(image.Name() + ":" + image.Tag())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, schema1.SignedManifest{}, err
|
||||
}
|
||||
m := manifest.(schema1.SignedManifest)
|
||||
m.Name = image.Name()
|
||||
m.Tag = image.Tag()
|
||||
return image, m, err
|
||||
}
|
||||
|
||||
func saveImage(imageName string, fo *os.File) error {
|
||||
|
||||
return nil
|
||||
// save.Stderr = &stderr
|
||||
|
||||
// save.Stdout = writer
|
||||
// err := save.Run()
|
||||
// if err != nil {
|
||||
// return errors.New(stderr.String())
|
||||
// }
|
||||
|
||||
// return nil
|
||||
}
|
||||
|
||||
func save(imageName string) (distribution.Manifest, error) {
|
||||
path := config.TmpLocal() + "/" + strings.Split(imageName, ":")[0] + "/blobs"
|
||||
|
||||
if _, err := os.Stat(path); os.IsExist(err) {
|
||||
err := os.RemoveAll(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err := os.MkdirAll(path, 0755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug("docker image to save: ", imageName)
|
||||
log.Debug("saving in: ", path)
|
||||
|
||||
cli, err := client.NewEnvClient()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
img, err := cli.ImageSave(context.Background(), []string{imageName})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
all, err := ioutil.ReadAll(img)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
img.Close()
|
||||
|
||||
fo, err := os.Create(path + "/output.tar")
|
||||
// close fo on exit and check for its returned error
|
||||
defer func() {
|
||||
if err := fo.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := fo.Write(all); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = openAndUntar(path+"/output.tar", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = os.Remove(path + "/output.tar")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return historyFromManifest(path)
|
||||
}
|
||||
|
||||
func historyFromManifest(path string) (distribution.Manifest, error) {
|
||||
mf, err := os.Open(path + "/manifest.json")
|
||||
defer mf.Close()
|
||||
|
||||
if err != nil {
|
||||
return schema1.SignedManifest{}, err
|
||||
}
|
||||
|
||||
// https://github.com/docker/docker/blob/master/image/tarexport/tarexport.go#L17
|
||||
type manifestItem struct {
|
||||
Config string
|
||||
RepoTags []string
|
||||
Layers []string
|
||||
Parent image.ID `json:",omitempty"`
|
||||
LayerSources map[layer.DiffID]distribution.Descriptor `json:",omitempty"`
|
||||
}
|
||||
|
||||
var manifest []manifestItem
|
||||
if err = json.NewDecoder(mf).Decode(&manifest); err != nil {
|
||||
return schema1.SignedManifest{}, err
|
||||
} else if len(manifest) != 1 {
|
||||
return schema1.SignedManifest{}, err
|
||||
}
|
||||
var layers []string
|
||||
for _, layer := range manifest[0].Layers {
|
||||
layers = append(layers, strings.TrimSuffix(layer, "/layer.tar"))
|
||||
}
|
||||
var m schema1.SignedManifest
|
||||
|
||||
for _, layer := range manifest[0].Layers {
|
||||
var d digest.Digest
|
||||
d, err := digest.Parse("sha256:" + strings.TrimSuffix(layer, "/layer.tar"))
|
||||
if err != nil {
|
||||
return schema1.SignedManifest{}, err
|
||||
}
|
||||
m.FSLayers = append(m.FSLayers, schema1.FSLayer{BlobSum: d})
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func historyFromCommand(imageName string) (schema1.SignedManifest, error) {
|
||||
|
||||
client, err := client.NewEnvClient()
|
||||
if err != nil {
|
||||
return schema1.SignedManifest{}, err
|
||||
}
|
||||
histories, err := client.ImageHistory(context.Background(), imageName)
|
||||
|
||||
manifest := schema1.SignedManifest{}
|
||||
for _, history := range histories {
|
||||
var d digest.Digest
|
||||
d, err := digest.Parse(history.ID)
|
||||
if err != nil {
|
||||
return schema1.SignedManifest{}, err
|
||||
}
|
||||
manifest.FSLayers = append(manifest.FSLayers, schema1.FSLayer{BlobSum: d})
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func openAndUntar(name, dst string) error {
|
||||
var rd io.Reader
|
||||
f, err := os.Open(name)
|
||||
defer f.Close()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = f
|
||||
if strings.HasSuffix(name, ".gz") || strings.HasSuffix(name, ".tgz") {
|
||||
gr, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gr.Close()
|
||||
rd = gr
|
||||
} else if strings.HasSuffix(name, ".bz2") {
|
||||
rd = bzip2.NewReader(f)
|
||||
}
|
||||
if err := os.MkdirAll(dst, os.ModeDir|os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
// resetting umask is essential to have exact permissions on unpacked
|
||||
// files; it's not not put inside untar function as it changes
|
||||
// process-wide umask
|
||||
mask := syscall.Umask(0)
|
||||
defer syscall.Umask(mask)
|
||||
return untar.Untar(rd, dst)
|
||||
}
|
83
cmd/clairctl/docker/dockerdist/auth.go
Normal file
83
cmd/clairctl/docker/dockerdist/auth.go
Normal file
@ -0,0 +1,83 @@
|
||||
package dockerdist
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
)
|
||||
|
||||
//ErrUnauthorized is return when requested user don't have access to the resource
|
||||
var ErrUnauthorized = errors.New("unauthorized access")
|
||||
|
||||
//bearerAuthParams parse Bearer Token on Www-Authenticate header
|
||||
func bearerAuthParams(r *http.Response) map[string]string {
|
||||
s := strings.SplitN(r.Header.Get("Www-Authenticate"), " ", 2)
|
||||
if len(s) != 2 || s[0] != "Bearer" {
|
||||
return nil
|
||||
}
|
||||
result := map[string]string{}
|
||||
|
||||
for _, kv := range strings.Split(s[1], ",") {
|
||||
parts := strings.Split(kv, "=")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
result[strings.Trim(parts[0], "\" ")] = strings.Trim(parts[1], "\" ")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
//AuthenticateResponse add authentication headers on request
|
||||
func AuthenticateResponse(client *http.Client, dockerResponse *http.Response, request *http.Request) error {
|
||||
bearerToken := bearerAuthParams(dockerResponse)
|
||||
url := bearerToken["realm"] + "?service=" + url.QueryEscape(bearerToken["service"])
|
||||
if bearerToken["scope"] != "" {
|
||||
url += "&scope=" + bearerToken["scope"]
|
||||
}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authConfig, err := GetAuthCredentials(config.ImageName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(authConfig.Username, authConfig.Password)
|
||||
|
||||
response, err := client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode == http.StatusUnauthorized {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("authentication server response: %v - %v", response.StatusCode, response.Status)
|
||||
}
|
||||
|
||||
type token struct {
|
||||
Value string `json:"token"`
|
||||
}
|
||||
var tok token
|
||||
err = json.NewDecoder(response.Body).Decode(&tok)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", "Bearer "+tok.Value)
|
||||
|
||||
return nil
|
||||
}
|
271
cmd/clairctl/docker/dockerdist/dockerdist.go
Normal file
271
cmd/clairctl/docker/dockerdist/dockerdist.go
Normal file
@ -0,0 +1,271 @@
|
||||
// Copyright 2016 CoreOS, 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 dockerdist provides helper methods for retrieving and parsing a
|
||||
// information from a remote Docker repository.
|
||||
package dockerdist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"reflect"
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
distlib "github.com/docker/distribution"
|
||||
"github.com/docker/distribution/manifest/schema1"
|
||||
"github.com/docker/distribution/manifest/schema2"
|
||||
"github.com/docker/distribution/registry/api/v2"
|
||||
"github.com/docker/distribution/registry/client"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/cli/config"
|
||||
"github.com/docker/docker/distribution"
|
||||
"github.com/docker/docker/dockerversion"
|
||||
"github.com/docker/docker/reference"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
var log = capnslog.NewPackageLogger("github.com/jgsqware/clairctl", "dockerdist")
|
||||
|
||||
var ErrTagNotFound = errors.New("this image or tag is not found")
|
||||
|
||||
func isInsecureRegistry(registryHostname string) bool {
|
||||
for _, r := range viper.GetStringSlice("docker.insecure-registries") {
|
||||
if r == registryHostname {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getService() *registry.DefaultService {
|
||||
serviceOptions := registry.ServiceOptions{
|
||||
InsecureRegistries: viper.GetStringSlice("docker.insecure-registries"),
|
||||
}
|
||||
return registry.NewService(serviceOptions)
|
||||
}
|
||||
|
||||
// getRepositoryClient returns a client for performing registry operations against the given named
|
||||
// image.
|
||||
func getRepositoryClient(image reference.Named, insecure bool, scopes ...string) (distlib.Repository, error) {
|
||||
service := getService()
|
||||
log.Debugf("Retrieving repository client")
|
||||
|
||||
ctx := context.Background()
|
||||
authConfig, err := GetAuthCredentials(image.String())
|
||||
if err != nil {
|
||||
log.Debugf("GetAuthCredentials error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if (types.AuthConfig{}) != authConfig {
|
||||
|
||||
userAgent := dockerversion.DockerUserAgent(ctx)
|
||||
_, _, err = service.Auth(ctx, &authConfig, userAgent)
|
||||
if err != nil {
|
||||
log.Debugf("Auth: err: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
repoInfo, err := service.ResolveRepository(image)
|
||||
if err != nil {
|
||||
log.Debugf("ResolveRepository err: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metaHeaders := map[string][]string{}
|
||||
|
||||
endpoints, err := service.LookupPullEndpoints(image.Hostname())
|
||||
if err != nil {
|
||||
log.Debugf("registry.LookupPullEndpoints error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var confirmedV2 bool
|
||||
var repository distlib.Repository
|
||||
for _, endpoint := range endpoints {
|
||||
if confirmedV2 && endpoint.Version == registry.APIVersion1 {
|
||||
log.Debugf("Skipping v1 endpoint %s because v2 registry was detected", endpoint.URL)
|
||||
continue
|
||||
}
|
||||
|
||||
endpoint.TLSConfig.InsecureSkipVerify = viper.GetBool("auth.insecureSkipVerify")
|
||||
if isInsecureRegistry(endpoint.URL.Host) {
|
||||
endpoint.URL.Scheme = "http"
|
||||
}
|
||||
log.Debugf("endpoint.TLSConfig.InsecureSkipVerify: %v", endpoint.TLSConfig.InsecureSkipVerify)
|
||||
repository, confirmedV2, err = distribution.NewV2Repository(ctx, repoInfo, endpoint, metaHeaders, &authConfig, scopes...)
|
||||
if err != nil {
|
||||
log.Debugf("cannot instanciate new v2 repository on %v", endpoint.URL)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !confirmedV2 {
|
||||
return nil, errors.New("Only V2 repository are supported")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
func GetPushURL(hostname string) (*url.URL, error) {
|
||||
service := getService()
|
||||
endpoints, err := service.LookupPushEndpoints(hostname)
|
||||
if err != nil {
|
||||
log.Debugf("registry.LookupPushEndpoints error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
endpoint.TLSConfig.InsecureSkipVerify = viper.GetBool("auth.insecureSkipVerify")
|
||||
if isInsecureRegistry(endpoint.URL.Host) {
|
||||
endpoint.URL.Scheme = "http"
|
||||
}
|
||||
return endpoint.URL, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("No endpoints found")
|
||||
}
|
||||
|
||||
// getDigest returns the digest for the given image.
|
||||
func getDigest(ctx context.Context, repo distlib.Repository, image reference.Named) (digest.Digest, error) {
|
||||
if withDigest, ok := image.(reference.Canonical); ok {
|
||||
return withDigest.Digest(), nil
|
||||
}
|
||||
// Get TagService.
|
||||
tagSvc := repo.Tags(ctx)
|
||||
|
||||
// Get Tag name.
|
||||
tag := "latest"
|
||||
if withTag, ok := image.(reference.NamedTagged); ok {
|
||||
tag = withTag.Tag()
|
||||
}
|
||||
|
||||
// Get Tag's Descriptor.
|
||||
descriptor, err := tagSvc.Get(ctx, tag)
|
||||
if err != nil {
|
||||
|
||||
// Docker returns an UnexpectedHTTPResponseError if it cannot parse the JSON body of an
|
||||
// unexpected error. Unfortunately, HEAD requests *by definition* don't have bodies, so
|
||||
// Docker will return this error for non-200 HEAD requests. We therefore have to hack
|
||||
// around it... *sigh*.
|
||||
if _, ok := err.(*client.UnexpectedHTTPResponseError); ok {
|
||||
return "", errors.New("Received error when trying to fetch the specified tag: it might not exist or you do not have access")
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), v2.ErrorCodeManifestUnknown.Message()) {
|
||||
return "", ErrTagNotFound
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return descriptor.Digest, nil
|
||||
}
|
||||
|
||||
// GetAuthCredentials returns the auth credentials (if any found) for the given repository, as found
|
||||
// in the user's docker config.
|
||||
func GetAuthCredentials(image string) (types.AuthConfig, error) {
|
||||
// Lookup the index information for the name.
|
||||
indexInfo, err := registry.ParseSearchIndexInfo(image)
|
||||
if err != nil {
|
||||
return types.AuthConfig{}, err
|
||||
}
|
||||
// Retrieve the user's Docker configuration file (if any).
|
||||
configFile, err := config.Load(config.Dir())
|
||||
if err != nil {
|
||||
return types.AuthConfig{}, err
|
||||
}
|
||||
|
||||
// Resolve the authentication information for the registry specified, via the config file.
|
||||
return registry.ResolveAuthConfig(configFile.AuthConfigs, indexInfo), nil
|
||||
}
|
||||
|
||||
// DownloadManifest the manifest for the given image, using the given credentials.
|
||||
func DownloadManifest(image string, insecure bool) (reference.NamedTagged, distlib.Manifest, error) {
|
||||
log.Debugf("Downloading manifest for %v", image)
|
||||
// Parse the image name as a docker image reference.
|
||||
n, err := reference.ParseNamed(image)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reference.IsNameOnly(n) {
|
||||
n, _ = reference.ParseNamed(image + ":" + reference.DefaultTag)
|
||||
}
|
||||
|
||||
named := n.(reference.NamedTagged)
|
||||
|
||||
// Create a reference to a repository client for the repo.
|
||||
repo, err := getRepositoryClient(named, insecure, "pull")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Get the digest.
|
||||
ctx := context.Background()
|
||||
|
||||
digest, err := getDigest(ctx, repo, named)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Retrieve the manifest for the tag.
|
||||
manSvc, err := repo.Manifests(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
manifest, err := manSvc.Get(ctx, digest)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Verify the manifest if it's signed.
|
||||
log.Debugf("manifest type: %v", reflect.TypeOf(manifest))
|
||||
|
||||
switch manifest.(type) {
|
||||
case *schema1.SignedManifest:
|
||||
_, verr := schema1.Verify(manifest.(*schema1.SignedManifest))
|
||||
if verr != nil {
|
||||
return nil, nil, verr
|
||||
}
|
||||
case *schema2.DeserializedManifest:
|
||||
log.Debugf("retrieved schema2 manifest, no verification")
|
||||
default:
|
||||
log.Printf("Could not verify manifest for image %v: not signed", image)
|
||||
}
|
||||
|
||||
return named, manifest, nil
|
||||
}
|
||||
|
||||
// DownloadV1Manifest the manifest for the given image in v1 schema format, using the given credentials.
|
||||
func DownloadV1Manifest(imageName string, insecure bool) (reference.NamedTagged, schema1.SignedManifest, error) {
|
||||
image, manifest, err := DownloadManifest(imageName, insecure)
|
||||
|
||||
if err != nil {
|
||||
return nil, schema1.SignedManifest{}, err
|
||||
}
|
||||
// Ensure that the manifest type is supported.
|
||||
switch manifest.(type) {
|
||||
case *schema1.SignedManifest:
|
||||
return image, *manifest.(*schema1.SignedManifest), nil
|
||||
default:
|
||||
return nil, schema1.SignedManifest{}, errors.New("only v1 manifests are currently supported")
|
||||
}
|
||||
}
|
21
cmd/clairctl/main.go
Normal file
21
cmd/clairctl/main.go
Normal file
@ -0,0 +1,21 @@
|
||||
// Copyright © 2016 NAME HERE <EMAIL ADDRESS>
|
||||
//
|
||||
// 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 main
|
||||
|
||||
import "github.com/jgsqware/clairctl/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
120
cmd/clairctl/server/server.go
Normal file
120
cmd/clairctl/server/server.go
Normal file
@ -0,0 +1,120 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
"github.com/jgsqware/clairctl/clair"
|
||||
"github.com/jgsqware/clairctl/config"
|
||||
"github.com/jgsqware/clairctl/docker/dockerdist"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var log = capnslog.NewPackageLogger("github.com/jgsqware/clairctl", "server")
|
||||
|
||||
//Serve run a local server with the fileserver and the reverse proxy
|
||||
func Serve(sURL string) error {
|
||||
|
||||
go func() {
|
||||
http.Handle("/v2/", newSingleHostReverseProxy())
|
||||
http.Handle("/local/", http.StripPrefix("/local", restrictedFileServer(config.TmpLocal())))
|
||||
|
||||
listener := tcpListener(sURL)
|
||||
log.Info("Starting Server on ", listener.Addr())
|
||||
|
||||
if err := http.Serve(listener, nil); err != nil {
|
||||
log.Fatalf("local server error: %v", err)
|
||||
}
|
||||
}()
|
||||
//sleep needed to wait the server start. Maybe use a channel for that
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
return nil
|
||||
}
|
||||
|
||||
func tcpListener(sURL string) (listener net.Listener) {
|
||||
listener, err := net.Listen("tcp", sURL)
|
||||
if err != nil {
|
||||
log.Fatalf("cannot instanciate listener: %v", err)
|
||||
}
|
||||
|
||||
if viper.GetInt("clairctl.port") == 0 {
|
||||
port := strings.Split(listener.Addr().String(), ":")[1]
|
||||
log.Debugf("Update local server port from %q to %q", "0", port)
|
||||
viper.Set("clairctl.port", port)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func restrictedFileServer(path string) http.Handler {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
os.Mkdir(path, 0777)
|
||||
}
|
||||
|
||||
fc := func(w http.ResponseWriter, r *http.Request) {
|
||||
http.FileServer(http.Dir(path)).ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fc)
|
||||
}
|
||||
|
||||
func newSingleHostReverseProxy() *httputil.ReverseProxy {
|
||||
director := func(request *http.Request) {
|
||||
|
||||
var validID = regexp.MustCompile(`.*/blobs/(.*)$`)
|
||||
u := request.URL.Path
|
||||
log.Debugf("request url: %v", u)
|
||||
log.Debugf("request for image: %v", config.ImageName)
|
||||
if !validID.MatchString(u) {
|
||||
log.Errorf("cannot parse url: %v", u)
|
||||
}
|
||||
var host string
|
||||
host, err := clair.GetRegistryMapping(validID.FindStringSubmatch(u)[1])
|
||||
log.Debugf("host retreived: %v", host)
|
||||
if err != nil {
|
||||
log.Errorf("response error: %v", err)
|
||||
return
|
||||
}
|
||||
out, _ := url.Parse(host)
|
||||
request.URL.Scheme = out.Scheme
|
||||
request.URL.Host = out.Host
|
||||
client := &http.Client{Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: viper.GetBool("auth.insecureSkipVerify")},
|
||||
DisableCompression: true,
|
||||
}}
|
||||
|
||||
log.Debugf("auth.insecureSkipVerify: %v", viper.GetBool("auth.insecureSkipVerify"))
|
||||
log.Debugf("request.URL.String(): %v", request.URL.String())
|
||||
req, _ := http.NewRequest("HEAD", request.URL.String(), nil)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Errorf("response error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
log.Info("pull from clair is unauthorized")
|
||||
dockerdist.AuthenticateResponse(client, resp, request)
|
||||
}
|
||||
|
||||
r, _ := http.NewRequest("GET", request.URL.String(), nil)
|
||||
r.Header.Set("Authorization", request.Header.Get("Authorization"))
|
||||
r.Header.Set("Accept-Encoding", request.Header.Get("Accept-Encoding"))
|
||||
*request = *r
|
||||
}
|
||||
return &httputil.ReverseProxy{
|
||||
Director: director,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: viper.GetBool("auth.insecureSkipVerify")},
|
||||
DisableCompression: true,
|
||||
},
|
||||
}
|
||||
}
|
39
cmd/clairctl/test/test.go
Normal file
39
cmd/clairctl/test/test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"github.com/coreos/pkg/capnslog"
|
||||
)
|
||||
|
||||
var log = capnslog.NewPackageLogger("github.com/jgsqware/clairctl", "test")
|
||||
|
||||
func CreateTmpConfigFile(content string) string {
|
||||
|
||||
c := []byte(content)
|
||||
tmpfile, err := ioutil.TempFile("", "test-clairctl")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if content != "" {
|
||||
if _, err := tmpfile.Write(c); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(tmpfile.Name()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
return tmpfile.Name()
|
||||
}
|
||||
|
||||
func CreateConfigFile(content string, name string, path string) string {
|
||||
if err := ioutil.WriteFile(path+"/"+name, []byte(content), 0600); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return path + "/" + name
|
||||
}
|
29
cmd/clairctl/xstrings/xstrings.go
Normal file
29
cmd/clairctl/xstrings/xstrings.go
Normal file
@ -0,0 +1,29 @@
|
||||
package xstrings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//Substr extract string of length in s starting at pos
|
||||
func Substr(s string, pos, length int) string {
|
||||
runes := []rune(s)
|
||||
l := pos + length
|
||||
if l > len(runes) {
|
||||
l = len(runes)
|
||||
}
|
||||
return string(runes[pos:l])
|
||||
}
|
||||
|
||||
//TrimPrefixSuffix combine TrimPrefix and TrimSuffix
|
||||
func TrimPrefixSuffix(s string, prefix string, suffix string) string {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(s, prefix), suffix)
|
||||
}
|
||||
|
||||
func ToIndentJSON(v interface{}) ([]byte, error) {
|
||||
b, err := json.MarshalIndent(v, "", "\t")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
27
cmd/clairctl/xstrings/xstrings_test.go
Normal file
27
cmd/clairctl/xstrings/xstrings_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
package xstrings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSubstrFromBeginning(t *testing.T) {
|
||||
commitID := "e3ff9321271b0a5cec45ca6e0cdc72b2f376afd2"
|
||||
expected := "e3ff9"
|
||||
if s := Substr(commitID, 0, 5); s != expected {
|
||||
t.Errorf("is %v, expect %v", s, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstrFromCharFive(t *testing.T) {
|
||||
commitID := "e3ff9321271b0a5cec45ca6e0cdc72b2f376afd2"
|
||||
expected := "32127"
|
||||
if s := Substr(commitID, 5, 5); s != expected {
|
||||
t.Errorf("is %v, expect %v", s, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimPrefixSuffix(t *testing.T) {
|
||||
v := "http://registry:5555/v2"
|
||||
e := "registry:5555"
|
||||
if s := TrimPrefixSuffix(v, "http://", "/v2"); s != e {
|
||||
t.Errorf("is %v, expect %v", s, e)
|
||||
}
|
||||
}
|
29
glide.yaml
29
glide.yaml
@ -91,3 +91,32 @@ import:
|
||||
version: ^0.1.0
|
||||
- package: github.com/kr/text
|
||||
- package: github.com/remind101/migrate
|
||||
|
||||
- package: github.com/artyom/untar
|
||||
- package: github.com/docker/distribution
|
||||
version: 129ad8ea0c3760d878b34cffdb9c3be874a7b2f7
|
||||
subpackages:
|
||||
- manifest/schema1
|
||||
- registry/client
|
||||
- package: github.com/docker/go-connections
|
||||
version: 4ccf312bf1d35e5dbda654e57a9be4c3f3cd0366
|
||||
subpackages:
|
||||
- tlsconfig
|
||||
- package: github.com/spf13/cobra
|
||||
- package: github.com/spf13/viper
|
||||
- package: github.com/docker/go-units
|
||||
version: ^0.3.1
|
||||
- package: github.com/docker/docker
|
||||
version: 48dd90d3985889ca008faa3b041bf31d2ada95c5
|
||||
subpackages:
|
||||
- api/types
|
||||
- cli/config
|
||||
- distribution
|
||||
- reference
|
||||
- registry
|
||||
- package: github.com/spf13/pflag
|
||||
version: 7597b2702729ebb651fc9bb2adac40bcc62db82d
|
||||
- package: golang.org/x/crypto
|
||||
version: ca7e7f10cb9fd9c1a6ff7f60436c086d73714180
|
||||
- package: github.com/opencontainers/go-digest
|
||||
version: 21dfd564fd89c944783d00d069f33e3e7123c448
|
Loading…
Reference in New Issue
Block a user