// +build go1.3 /* * Licensed to Qualys, Inc. (QUALYS) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * QUALYS licenses this file to You 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 "crypto/tls" import "errors" import "encoding/json" import "flag" import "fmt" import "io/ioutil" import "bufio" import "os" import "log" import "math/rand" import "net" import "net/http" import "net/url" import "strconv" import "strings" import "sync/atomic" import "time" import "sort" const ( LOG_NONE = -1 LOG_EMERG = 0 LOG_ALERT = 1 LOG_CRITICAL = 2 LOG_ERROR = 3 LOG_WARNING = 4 LOG_NOTICE = 5 LOG_INFO = 6 LOG_DEBUG = 7 LOG_TRACE = 8 ) var USER_AGENT = "ssllabs-scan v1.4.0 (stable $Id$)" var logLevel = LOG_NOTICE // How many assessment do we have in progress? var activeAssessments = 0 // How many assessments does the server think we have in progress? var currentAssessments = -1 // The maximum number of assessments we can have in progress at any one time. var maxAssessments = -1 var requestCounter uint64 = 0 var apiLocation = "https://api.ssllabs.com/api/v2" var globalNewAssessmentCoolOff int64 = 1100 var globalIgnoreMismatch = false var globalStartNew = true var globalFromCache = false var globalMaxAge = 0 var globalInsecure = false var httpClient *http.Client type LabsError struct { Field string Message string } type LabsErrorResponse struct { ResponseErrors []LabsError `json:"errors"` } func (e LabsErrorResponse) Error() string { msg, err := json.Marshal(e) if err != nil { return err.Error() } else { return string(msg) } } type LabsKey struct { Size int Strength int Alg string DebianFlaw bool Q int } type LabsCert struct { Subject string CommonNames []string AltNames []string NotBefore int64 NotAfter int64 IssuerSubject string SigAlg string IssuerLabel string RevocationInfo int CrlURIs []string OcspURIs []string RevocationStatus int CrlRevocationStatus int OcspRevocationStatus int Sgc int ValidationType string Issues int Sct bool MustStaple int } type LabsChainCert struct { Subject string Label string NotBefore int64 NotAfter int64 IssuerSubject string IssuerLabel string SigAlg string Issues int KeyAlg string KeySize int KeyStrength int RevocationStatus int CrlRevocationStatus int OcspRevocationStatus int Raw string } type LabsChain struct { Certs []LabsChainCert Issues int } type LabsProtocol struct { Id int Name string Version string V2SuitesDisabled bool ErrorMessage bool Q int } type LabsSimClient struct { Id int Name string Platform string Version string IsReference bool } type LabsSimulation struct { Client LabsSimClient ErrorCode int Attempts int ProtocolId int SuiteId int KxInfo string } type LabsSimDetails struct { Results []LabsSimulation } type LabsSuite struct { Id int Name string CipherStrength int DhStrength int DhP int DhG int DhYs int EcdhBits int EcdhStrength int Q int } type LabsSuites struct { List []LabsSuite Preference bool } type LabsHstsPolicy struct { LONG_MAX_AGE int64 Header string Status string Error string MaxAge int64 IncludeSubDomains bool Preload bool Directives map[string]string } type LabsHstsPreload struct { Source string Status string Error string SourceTime int64 } type LabsHpkpPin struct { HashFunction string Value string } type LabsHpkpDirective struct { Name string Value string } type LabsHpkpPolicy struct { Header string Status string Error string MaxAge int64 IncludeSubDomains bool ReportUri string Pins []LabsHpkpPin MatchedPins []LabsHpkpPin Directives []LabsHpkpDirective } type DrownHost struct { Ip string Export bool Port int Special bool Sslv2 bool Status string } type LabsEndpointDetails struct { HostStartTime int64 Key LabsKey Cert LabsCert Chain LabsChain Protocols []LabsProtocol Suites LabsSuites ServerSignature string PrefixDelegation bool NonPrefixDelegation bool VulnBeast bool RenegSupport int SessionResumption int CompressionMethods int SupportsNpn bool NpnProtocols string SessionTickets int OcspStapling bool StaplingRevocationStatus int StaplingRevocationErrorMessage string SniRequired bool HttpStatusCode int HttpForwarding string ForwardSecrecy int SupportsRc4 bool Rc4WithModern bool Rc4Only bool Sims LabsSimDetails Heartbleed bool Heartbeat bool OpenSslCcs int OpenSSLLuckyMinus20 int Poodle bool PoodleTls int FallbackScsv bool Freak bool HasSct int DhPrimes []string DhUsesKnownPrimes int DhYsReuse bool Logjam bool ChaCha20Preference bool HstsPolicy LabsHstsPolicy HstsPreloads []LabsHstsPreload HpkpPolicy LabsHpkpPolicy HpkpRoPolicy LabsHpkpPolicy DrownHosts []DrownHost DrownErrors bool DrownVulnerable bool } type LabsEndpoint struct { IpAddress string ServerName string StatusMessage string StatusDetailsMessage string Grade string GradeTrustIgnored string HasWarnings bool IsExceptional bool Progress int Duration int Eta int Delegation int Details LabsEndpointDetails } type LabsReport struct { Host string Port int Protocol string IsPublic bool Status string StatusMessage string StartTime int64 TestTime int64 EngineVersion string CriteriaVersion string CacheExpiryTime int64 Endpoints []LabsEndpoint CertHostnames []string rawJSON string } type LabsResults struct { reports []LabsReport responses []string } type LabsInfo struct { EngineVersion string CriteriaVersion string MaxAssessments int CurrentAssessments int NewAssessmentCoolOff int64 Messages []string } func invokeGetRepeatedly(url string) (*http.Response, []byte, error) { retryCount := 0 for { var reqId = atomic.AddUint64(&requestCounter, 1) if logLevel >= LOG_DEBUG { log.Printf("[DEBUG] Request #%v: %v", reqId, url) } req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, nil, err } req.Header.Add("User-Agent", USER_AGENT) resp, err := httpClient.Do(req) if err == nil { if logLevel >= LOG_DEBUG { log.Printf("[DEBUG] Response #%v status: %v %v", reqId, resp.Proto, resp.Status) } if logLevel >= LOG_TRACE { for key, values := range resp.Header { for _, value := range values { log.Printf("[TRACE] %v: %v\n", key, value) } } } if logLevel >= LOG_NOTICE { for key, values := range resp.Header { if strings.ToLower(key) == "x-message" { for _, value := range values { log.Printf("[NOTICE] Server message: %v\n", value) } } } } // Update current assessments. headerValue := resp.Header.Get("X-Current-Assessments") if headerValue != "" { i, err := strconv.Atoi(headerValue) if err == nil { if currentAssessments != i { currentAssessments = i if logLevel >= LOG_DEBUG { log.Printf("[DEBUG] Server set current assessments to %v", headerValue) } } } else { if logLevel >= LOG_WARNING { log.Printf("[WARNING] Ignoring invalid X-Current-Assessments value (%v): %v", headerValue, err) } } } // Update maximum assessments. headerValue = resp.Header.Get("X-Max-Assessments") if headerValue != "" { i, err := strconv.Atoi(headerValue) if err == nil { if maxAssessments != i { maxAssessments = i if maxAssessments <= 0 { log.Fatalf("[ERROR] Server doesn't allow further API requests") } if logLevel >= LOG_DEBUG { log.Printf("[DEBUG] Server set maximum assessments to %v", headerValue) } } } else { if logLevel >= LOG_WARNING { log.Printf("[WARNING] Ignoring invalid X-Max-Assessments value (%v): %v", headerValue, err) } } } // Retrieve the response body. defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, nil, err } if logLevel >= LOG_TRACE { log.Printf("[TRACE] Response #%v body:\n%v", reqId, string(body)) } return resp, body, nil } else { if strings.Contains(err.Error(), "EOF") { // Server closed a persistent connection on us, which // Go doesn't seem to be handling well. So we'll try one // more time. if retryCount > 5 { log.Fatalf("[ERROR] Too many HTTP requests (5) failed with EOF (ref#2)") } if logLevel >= LOG_DEBUG { log.Printf("[DEBUG] HTTP request failed with EOF (ref#2)") } } else { log.Fatalf("[ERROR] HTTP request failed: %v (ref#2)", err.Error()) } retryCount++ } } } func invokeApi(command string) (*http.Response, []byte, error) { var url = apiLocation + "/" + command for { resp, body, err := invokeGetRepeatedly(url) if err != nil { return nil, nil, err } // Status codes 429, 503, and 529 essentially mean try later. Thus, // if we encounter them, we sleep for a while and try again. if resp.StatusCode == 429 { return resp, body, errors.New("Assessment failed: 429") } else if (resp.StatusCode == 503) || (resp.StatusCode == 529) { // In case of the overloaded server, randomize the sleep time so // that some clients reconnect earlier and some later. sleepTime := 15 + rand.Int31n(15) if logLevel >= LOG_NOTICE { log.Printf("[NOTICE] Sleeping for %v minutes after a %v response", sleepTime, resp.StatusCode) } time.Sleep(time.Duration(sleepTime) * time.Minute) } else if (resp.StatusCode != 200) && (resp.StatusCode != 400) { log.Fatalf("[ERROR] Unexpected response status code %v", resp.StatusCode) } else { return resp, body, nil } } } func invokeInfo() (*LabsInfo, error) { var command = "info" _, body, err := invokeApi(command) if err != nil { return nil, err } var labsInfo LabsInfo err = json.Unmarshal(body, &labsInfo) if err != nil { log.Printf("[ERROR] JSON unmarshal error: %v", err) return nil, err } return &labsInfo, nil } func invokeAnalyze(host string, startNew bool, fromCache bool) (*LabsReport, error) { var command = "analyze?host=" + host + "&all=done" if fromCache { command = command + "&fromCache=on" if globalMaxAge != 0 { command = command + "&maxAge=" + strconv.Itoa(globalMaxAge) } } else if startNew { command = command + "&startNew=on" } if globalIgnoreMismatch { command = command + "&ignoreMismatch=on" } resp, body, err := invokeApi(command) if err != nil { return nil, err } // Use the status code to determine if the response is an error. if resp.StatusCode == 400 { // Parameter validation error. var apiError LabsErrorResponse err = json.Unmarshal(body, &apiError) if err != nil { log.Printf("[ERROR] JSON unmarshal error: %v", err) return nil, err } return nil, apiError } else { // We should have a proper response. var analyzeResponse LabsReport err = json.Unmarshal(body, &analyzeResponse) if err != nil { log.Printf("[ERROR] JSON unmarshal error: %v", err) return nil, err } // Add the JSON body to the response analyzeResponse.rawJSON = string(body) return &analyzeResponse, nil } } type Event struct { host string eventType int report *LabsReport } const ( ASSESSMENT_FAILED = -1 ASSESSMENT_STARTING = 0 ASSESSMENT_COMPLETE = 1 ) func NewAssessment(host string, eventChannel chan Event) { eventChannel <- Event{host, ASSESSMENT_STARTING, nil} var report *LabsReport var startTime int64 = -1 var startNew = globalStartNew for { myResponse, err := invokeAnalyze(host, startNew, globalFromCache) if err != nil { eventChannel <- Event{host, ASSESSMENT_FAILED, nil} return } if startTime == -1 { startTime = myResponse.StartTime startNew = false } else { // Abort this assessment if the time we receive in a follow-up check // is older than the time we got when we started the request. The // upstream code should then retry the hostname in order to get // consistent results. if myResponse.StartTime > startTime { eventChannel <- Event{host, ASSESSMENT_FAILED, nil} return } else { startTime = myResponse.StartTime } } if (myResponse.Status == "READY") || (myResponse.Status == "ERROR") { report = myResponse break } time.Sleep(5 * time.Second) } eventChannel <- Event{host, ASSESSMENT_COMPLETE, report} } type HostProvider struct { hostnames []string StartingLen int } func NewHostProvider(hs []string) *HostProvider { hostnames := make([]string, len(hs)) copy(hostnames, hs) hostProvider := HostProvider{hostnames, len(hs)} return &hostProvider } func (hp *HostProvider) next() (string, bool) { if len(hp.hostnames) == 0 { return "", false } var e string e, hp.hostnames = hp.hostnames[0], hp.hostnames[1:] return e, true } func (hp *HostProvider) retry(host string) { hp.hostnames = append(hp.hostnames, host) } type Manager struct { hostProvider *HostProvider FrontendEventChannel chan Event BackendEventChannel chan Event results *LabsResults } func NewManager(hostProvider *HostProvider) *Manager { manager := Manager{ hostProvider: hostProvider, FrontendEventChannel: make(chan Event), BackendEventChannel: make(chan Event), results: &LabsResults{reports: make([]LabsReport, 0)}, } go manager.run() return &manager } func (manager *Manager) startAssessment(h string) { go NewAssessment(h, manager.BackendEventChannel) activeAssessments++ } func (manager *Manager) run() { transport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: globalInsecure}, DisableKeepAlives: false, Proxy: http.ProxyFromEnvironment, } httpClient = &http.Client{Transport: transport} // Ping SSL Labs to determine how many concurrent // assessments we're allowed to use. Print the API version // information and the limits. labsInfo, err := invokeInfo() if err != nil { // TODO Signal error so that we return the correct exit code close(manager.FrontendEventChannel) } if logLevel >= LOG_INFO { log.Printf("[INFO] SSL Labs v%v (criteria version %v)", labsInfo.EngineVersion, labsInfo.CriteriaVersion) } if logLevel >= LOG_NOTICE { for _, message := range labsInfo.Messages { log.Printf("[NOTICE] Server message: %v", message) } } maxAssessments = labsInfo.MaxAssessments if maxAssessments <= 0 { if logLevel >= LOG_WARNING { log.Printf("[WARNING] You're not allowed to request new assessments") } } moreAssessments := true if labsInfo.NewAssessmentCoolOff >= 1000 { globalNewAssessmentCoolOff = 100 + labsInfo.NewAssessmentCoolOff } else { if logLevel >= LOG_WARNING { log.Printf("[WARNING] Info.NewAssessmentCoolOff too small: %v", labsInfo.NewAssessmentCoolOff) } } for { select { // Handle assessment events (e.g., starting and finishing). case e := <-manager.BackendEventChannel: if e.eventType == ASSESSMENT_FAILED { activeAssessments-- manager.hostProvider.retry(e.host) } if e.eventType == ASSESSMENT_STARTING { if logLevel >= LOG_INFO { log.Printf("[INFO] Assessment starting: %v", e.host) } } if e.eventType == ASSESSMENT_COMPLETE { if logLevel >= LOG_INFO { msg := "" if len(e.report.Endpoints) == 0 { msg = fmt.Sprintf("[WARN] Assessment failed: %v (%v)", e.host, e.report.StatusMessage) } else if len(e.report.Endpoints) > 1 { msg = fmt.Sprintf("[INFO] Assessment complete: %v (%v hosts in %v seconds)", e.host, len(e.report.Endpoints), (e.report.TestTime-e.report.StartTime)/1000) } else { msg = fmt.Sprintf("[INFO] Assessment complete: %v (%v host in %v seconds)", e.host, len(e.report.Endpoints), (e.report.TestTime-e.report.StartTime)/1000) } for _, endpoint := range e.report.Endpoints { if endpoint.Grade != "" { msg = msg + "\n " + endpoint.IpAddress + ": " + endpoint.Grade } else { msg = msg + "\n " + endpoint.IpAddress + ": Err: " + endpoint.StatusMessage } } log.Println(msg) } activeAssessments-- manager.results.reports = append(manager.results.reports, *e.report) manager.results.responses = append(manager.results.responses, e.report.rawJSON) if logLevel >= LOG_DEBUG { log.Printf("[DEBUG] Active assessments: %v (more: %v)", activeAssessments, moreAssessments) } } // Are we done? if (activeAssessments == 0) && (moreAssessments == false) { close(manager.FrontendEventChannel) return } break // Once a second, start a new assessment, provided there are // hostnames left and we're not over the concurrent assessment limit. default: if manager.hostProvider.StartingLen > 0 { <-time.NewTimer(time.Duration(globalNewAssessmentCoolOff) * time.Millisecond).C } if moreAssessments { if currentAssessments < maxAssessments { host, hasNext := manager.hostProvider.next() if hasNext { manager.startAssessment(host) } else { // We've run out of hostnames and now just need // to wait for all the assessments to complete. moreAssessments = false if activeAssessments == 0 { close(manager.FrontendEventChannel) return } } } } break } } } func parseLogLevel(level string) int { switch { case level == "error": return LOG_ERROR case level == "notice": return LOG_NOTICE case level == "info": return LOG_INFO case level == "debug": return LOG_DEBUG case level == "trace": return LOG_TRACE } log.Fatalf("[ERROR] Unrecognized log level: %v", level) return -1 } func flattenJSON(inputJSON map[string]interface{}, rootKey string, flattened *map[string]interface{}) { var keysep = "." // Char to separate keys var Q = "\"" // Char to envelope strings for rkey, value := range inputJSON { key := rootKey + rkey if _, ok := value.(string); ok { (*flattened)[key] = Q + value.(string) + Q } else if _, ok := value.(float64); ok { (*flattened)[key] = fmt.Sprintf("%.f", value) } else if _, ok := value.(bool); ok { (*flattened)[key] = value.(bool) } else if _, ok := value.([]interface{}); ok { for i := 0; i < len(value.([]interface{})); i++ { aKey := key + keysep + strconv.Itoa(i) if _, ok := value.([]interface{})[i].(string); ok { (*flattened)[aKey] = Q + value.([]interface{})[i].(string) + Q } else if _, ok := value.([]interface{})[i].(float64); ok { (*flattened)[aKey] = value.([]interface{})[i].(float64) } else if _, ok := value.([]interface{})[i].(bool); ok { (*flattened)[aKey] = value.([]interface{})[i].(bool) } else { flattenJSON(value.([]interface{})[i].(map[string]interface{}), key+keysep+strconv.Itoa(i)+keysep, flattened) } } } else if value == nil { (*flattened)[key] = nil } else { flattenJSON(value.(map[string]interface{}), key+keysep, flattened) } } } func flattenAndFormatJSON(inputJSON []byte) *[]string { var flattened = make(map[string]interface{}) mappedJSON := map[string]interface{}{} err := json.Unmarshal(inputJSON, &mappedJSON) if err != nil { log.Fatalf("[ERROR] Reconsitution of JSON failed: %v", err) } // Flatten the JSON structure, recursively flattenJSON(mappedJSON, "", &flattened) // Make a sorted index, so we can print keys in order kIndex := make([]string, len(flattened)) ki := 0 for key, _ := range flattened { kIndex[ki] = key ki++ } sort.Strings(kIndex) // Ordered flattened data var flatStrings []string for _, value := range kIndex { flatStrings = append(flatStrings, fmt.Sprintf("\"%v\": %v\n", value, flattened[value])) } return &flatStrings } func readLines(path *string) ([]string, error) { file, err := os.Open(*path) if err != nil { return nil, err } defer file.Close() var lines []string scanner := bufio.NewScanner(file) for scanner.Scan() { var line = strings.TrimSpace(scanner.Text()) if (!strings.HasPrefix(line, "#")) && (line != "") { lines = append(lines, line) } } return lines, scanner.Err() } func validateURL(URL string) bool { _, err := url.Parse(URL) if err != nil { return false } else { return true } } func validateHostname(hostname string) bool { addrs, err := net.LookupHost(hostname) // In some cases there is no error // but there are also no addresses if err != nil || len(addrs) < 1 { return false } else { return true } } func main() { var conf_api = flag.String("api", "BUILTIN", "API entry point, for example https://www.example.com/api/") var conf_grade = flag.Bool("grade", false, "Output only the hostname: grade") var conf_hostcheck = flag.Bool("hostcheck", false, "If true, host resolution failure will result in a fatal error.") var conf_hostfile = flag.String("hostfile", "", "File containing hosts to scan (one per line)") var conf_ignore_mismatch = flag.Bool("ignore-mismatch", false, "If true, certificate hostname mismatch does not stop assessment.") var conf_insecure = flag.Bool("insecure", false, "Skip certificate validation. For use in development only. Do not use.") var conf_json_flat = flag.Bool("json-flat", false, "Output results in flattened JSON format") var conf_quiet = flag.Bool("quiet", false, "Disable status messages (logging)") var conf_usecache = flag.Bool("usecache", false, "If true, accept cached results (if available), else force live scan.") var conf_maxage = flag.Int("maxage", 0, "Maximum acceptable age of cached results, in hours. A zero value is ignored.") var conf_verbosity = flag.String("verbosity", "info", "Configure log verbosity: error, notice, info, debug, or trace.") var conf_version = flag.Bool("version", false, "Print version and API location information and exit") flag.Parse() if *conf_version { fmt.Println(USER_AGENT) fmt.Println("API location: " + apiLocation) return } logLevel = parseLogLevel(strings.ToLower(*conf_verbosity)) globalIgnoreMismatch = *conf_ignore_mismatch if *conf_quiet { logLevel = LOG_NONE } // We prefer cached results if *conf_usecache { globalFromCache = true globalStartNew = false } if *conf_maxage != 0 { globalMaxAge = *conf_maxage } // Verify that the API entry point is a URL. if *conf_api != "BUILTIN" { apiLocation = *conf_api } if validateURL(apiLocation) == false { log.Fatalf("[ERROR] Invalid API URL: %v", apiLocation) } var hostnames []string if *conf_hostfile != "" { // Open file, and read it var err error hostnames, err = readLines(conf_hostfile) if err != nil { log.Fatalf("[ERROR] Reading from specified hostfile failed: %v", err) } } else { // Read hostnames from the rest of the args hostnames = flag.Args() } if *conf_hostcheck { // Validate all hostnames before we attempt to test them. At least // one hostname is required. for _, host := range hostnames { if validateHostname(host) == false { log.Fatalf("[ERROR] Invalid hostname: %v", host) } } } if *conf_insecure { globalInsecure = *conf_insecure } hp := NewHostProvider(hostnames) manager := NewManager(hp) // Respond to events until all the work is done. for { _, running := <-manager.FrontendEventChannel if running == false { var results []byte var err error if hp.StartingLen == 0 { return } if *conf_grade { // Just the grade(s). We use flatten and RAW /* "endpoints.0.grade": "A" "host": "testing.spatialkey.com" */ for i := range manager.results.responses { results := []byte(manager.results.responses[i]) name := "" grade := "" flattened := flattenAndFormatJSON(results) for _, fval := range *flattened { if strings.HasPrefix(fval, "\"host\"") { // hostname parts := strings.Split(fval, ": ") name = strings.TrimSuffix(parts[1], "\n") if grade != "" { break } } else if strings.HasPrefix(fval, "\"endpoints.0.grade\"") { // grade parts := strings.Split(fval, ": ") grade = strings.TrimSuffix(parts[1], "\n") if name != "" { break } } } if grade != "" && name != "" { fmt.Println(name + ": " + grade) } } } else if *conf_json_flat { // Flat JSON and RAW for i := range manager.results.responses { results := []byte(manager.results.responses[i]) flattened := flattenAndFormatJSON(results) // Print the flattened data fmt.Println(*flattened) } } else { // Raw (non-Go-mangled) JSON output fmt.Println("[") for i := range manager.results.responses { results := manager.results.responses[i] if i > 0 { fmt.Println(",") } fmt.Println(results) } fmt.Println("]") } if err != nil { log.Fatalf("[ERROR] Output to JSON failed: %v", err) } fmt.Println(string(results)) if logLevel >= LOG_INFO { log.Println("[INFO] All assessments complete; shutting down") } return } } }