diff --git a/internal/config/config.go b/internal/config/config.go index 3a4637d..dabc82f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,6 +66,8 @@ type Settings struct { Framework bool Crawl bool CrawlDepth int + TLSCert bool + TLSCertPort int Passive bool Probe bool SARIF string // path to write a sarif 2.1.0 report to ("" = off) @@ -166,6 +168,8 @@ func registerFlags(settings *Settings) *goflags.FlagSet { flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"), flagSet.BoolVar(&settings.Crawl, "crawl", false, "Enable web crawling (spider same-host links/scripts/forms)"), flagSet.IntVar(&settings.CrawlDepth, "crawl-depth", defaultCrawlDepth, "Max crawl recursion depth"), + flagSet.BoolVar(&settings.TLSCert, "tls-cert", false, "Enable tls certificate recon (mine SANs, issuer, posture from the leaf cert)"), + flagSet.IntVar(&settings.TLSCertPort, "tls-cert-port", 0, "Port for tls certificate recon (default 443)"), flagSet.BoolVar(&settings.Passive, "passive", false, "Enable passive subdomain/url discovery (zero traffic to target)"), flagSet.BoolVar(&settings.Probe, "probe", false, "Probe the target for liveness (status, title, server, redirect chain)"), ) diff --git a/internal/scan/tlscert.go b/internal/scan/tlscert.go new file mode 100644 index 0000000..39f5ead --- /dev/null +++ b/internal/scan/tlscert.go @@ -0,0 +1,196 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/url" + "strconv" + "time" + + "github.com/vmfunc/sif/internal/logger" + "github.com/vmfunc/sif/internal/output" +) + +// tlsCertExpirySoonWindow flags a leaf certificate as "expiring soon" inside +// this window, mirroring the threshold nmap's ssl-cert and testssl.sh use. +const tlsCertExpirySoonWindow = 30 * 24 * time.Hour + +// TLSCertResult holds what the target's leaf certificate reveals: extra +// hostnames from the SAN list, issuer/validity metadata, and posture flags a +// human would want surfaced without reading the cert by hand. +type TLSCertResult struct { + Subject string `json:"subject"` + Issuer string `json:"issuer"` + SANs []string `json:"sans"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + SerialNumber string `json:"serial_number"` + SelfSigned bool `json:"self_signed"` + Expired bool `json:"expired"` + ExpiringSoon bool `json:"expiring_soon"` + Wildcard bool `json:"wildcard"` + NewSubdomains []string `json:"new_subdomains"` // SANs not already known to be the target host + ChainLength int `json:"chain_length"` +} + +func (r *TLSCertResult) ResultType() string { return "tlscert" } + +var _ ScanResult = (*TLSCertResult)(nil) + +// tlsDial is a var so tests can substitute a fake dialer without touching the +// network. +var tlsDial = func(addr string, timeout time.Duration) (*tls.Conn, error) { + dialer := &net.Dialer{Timeout: timeout} + // InsecureSkipVerify is deliberate: this module mines whatever certificate + // the target presents, self-signed or expired included, rather than only + // certs that pass validation like the main http client requires. + return tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // recon target, not a trust decision +} + +// TLSCert connects to the target on the given port (443 when unset) and mines +// its leaf certificate: SANs become candidate subdomains, issuer/validity feed +// posture flags (self-signed, expired, expiring soon). Unlike Passive's crt.sh +// and certspotter feeds this is an active probe against the target itself, so +// it catches certs that were never logged to a public CT log (short-lived +// certs not yet propagated, internal CAs) at the cost of touching the target. +func TLSCert(targetURL string, port int, timeout time.Duration, logdir string) (*TLSCertResult, error) { + log := output.Module("TLSCERT") + log.Start() + + parsed, err := url.Parse(targetURL) + if err != nil { + return nil, fmt.Errorf("parse target url %q: %w", targetURL, err) + } + host := parsed.Hostname() + if host == "" { + return nil, fmt.Errorf("target url %q has no host", targetURL) + } + if port == 0 { + port = 443 + } + addr := net.JoinHostPort(host, strconv.Itoa(port)) + + sanitizedURL := stripScheme(targetURL) + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "tls certificate recon"); err != nil { + log.Error("error creating log file: %v", err) + return nil, fmt.Errorf("create tlscert log: %w", err) + } + } + + conn, err := tlsDial(addr, timeout) + if err != nil { + log.Warn("tls dial %s failed: %v", addr, err) + return nil, fmt.Errorf("tls dial %q: %w", addr, err) + } + defer func() { _ = conn.Close() }() + + state := conn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return nil, fmt.Errorf("tls handshake with %q presented no certificates", addr) + } + leaf := state.PeerCertificates[0] + + result := buildTLSCertResult(leaf, state.PeerCertificates, host) + + logTLSCertResult(log, sanitizedURL, logdir, result) + + log.Complete(len(result.SANs), "san entries") + return result, nil +} + +// buildTLSCertResult turns a parsed leaf certificate into the recon-facing +// result: SAN-derived hostnames, issuer/validity, and posture flags. +func buildTLSCertResult(leaf *x509.Certificate, chain []*x509.Certificate, targetHost string) *TLSCertResult { + now := time.Now() + + sanSet := make(map[string]struct{}, len(leaf.DNSNames)) + for _, name := range leaf.DNSNames { + sanSet[normalizeHost(name)] = struct{}{} + } + sans := sortedKeys(sanSet) + + var newSubs []string + for _, san := range sans { + if san != normalizeHost(targetHost) { + newSubs = append(newSubs, san) + } + } + + wildcard := false + for _, name := range leaf.DNSNames { + if len(name) > 1 && name[0] == '*' { + wildcard = true + break + } + } + + // self-signed: issuer == subject (raw DER, not the human-readable string) + // and the cert's own signature verifies against its own TBS bytes. + // CheckSignatureFrom(leaf) is the wrong tool here - it additionally + // requires CA key-usage/basic-constraints bits a self-signed leaf usually + // doesn't set, so it false-negatives on exactly the certs this flag exists + // to catch. + selfSigned := bytes.Equal(leaf.RawIssuer, leaf.RawSubject) && + leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature) == nil + + return &TLSCertResult{ + Subject: leaf.Subject.String(), + Issuer: leaf.Issuer.String(), + SANs: sans, + NotBefore: leaf.NotBefore.UTC().Format(time.RFC3339), + NotAfter: leaf.NotAfter.UTC().Format(time.RFC3339), + SerialNumber: leaf.SerialNumber.String(), + SelfSigned: selfSigned, + Expired: now.After(leaf.NotAfter), + ExpiringSoon: !now.After(leaf.NotAfter) && leaf.NotAfter.Sub(now) < tlsCertExpirySoonWindow, + Wildcard: wildcard, + NewSubdomains: newSubs, + ChainLength: len(chain), + } +} + +func logTLSCertResult(log *output.ModuleLogger, sanitizedURL, logdir string, result *TLSCertResult) { + log.Info("subject: %s", result.Subject) + log.Info("issuer: %s", result.Issuer) + if result.SelfSigned { + log.Warn("certificate is self-signed") + } + if result.Expired { + log.Warn("certificate expired %s", result.NotAfter) + } else if result.ExpiringSoon { + log.Warn("certificate expires soon: %s", result.NotAfter) + } + for _, san := range result.NewSubdomains { + log.Success("san: %s", output.Highlight.Render(san)) + } + + if logdir == "" { + return + } + + sb := fmt.Sprintf("Subject: %s\nIssuer: %s\nNotBefore: %s\nNotAfter: %s\nSelfSigned: %v\nExpired: %v\nWildcard: %v\n", + result.Subject, result.Issuer, result.NotBefore, result.NotAfter, result.SelfSigned, result.Expired, result.Wildcard) + if len(result.SANs) > 0 { + sb += "\nSANs:\n" + for _, san := range result.SANs { + sb += " " + san + "\n" + } + } + _ = logger.Write(sanitizedURL, logdir, sb) +} diff --git a/internal/scan/tlscert_test.go b/internal/scan/tlscert_test.go new file mode 100644 index 0000000..e37b8fa --- /dev/null +++ b/internal/scan/tlscert_test.go @@ -0,0 +1,99 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" +) + +// makeLeaf builds a self-signed leaf cert with the given SANs and validity so +// the posture flags can be exercised without a live tls handshake. +func makeLeaf(t *testing.T, cn string, sans []string, notBefore, notAfter time.Time) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + DNSNames: sans, + NotBefore: notBefore, + NotAfter: notAfter, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse cert: %v", err) + } + return leaf +} + +func TestBuildTLSCertResult(t *testing.T) { + now := time.Now() + leaf := makeLeaf(t, "example.com", + []string{"example.com", "api.example.com", "*.cdn.example.com"}, + now.Add(-24*time.Hour), now.Add(365*24*time.Hour)) + + res := buildTLSCertResult(leaf, []*x509.Certificate{leaf}, "example.com") + + if !res.SelfSigned { + t.Error("self-signed leaf not flagged as self-signed") + } + if !res.Wildcard { + t.Error("*.cdn.example.com SAN not flagged as wildcard") + } + if res.Expired || res.ExpiringSoon { + t.Errorf("year-long cert flagged expired=%v soon=%v", res.Expired, res.ExpiringSoon) + } + // the target host itself must not appear as a "new" subdomain; the wildcard + // SAN is normalized to its base (the *. prefix is stripped) and Wildcard is + // flagged separately. + want := map[string]bool{"api.example.com": true, "cdn.example.com": true} + if len(res.NewSubdomains) != len(want) { + t.Fatalf("new subdomains = %v, want the two non-target SANs", res.NewSubdomains) + } + for _, s := range res.NewSubdomains { + if !want[s] { + t.Errorf("unexpected new subdomain %q", s) + } + } +} + +func TestBuildTLSCertResultExpiry(t *testing.T) { + now := time.Now() + + expired := buildTLSCertResult( + makeLeaf(t, "old.example.com", []string{"old.example.com"}, now.Add(-48*time.Hour), now.Add(-1*time.Hour)), + nil, "old.example.com") + if !expired.Expired || expired.ExpiringSoon { + t.Errorf("past-NotAfter cert: expired=%v soon=%v, want expired", expired.Expired, expired.ExpiringSoon) + } + + soon := buildTLSCertResult( + makeLeaf(t, "soon.example.com", []string{"soon.example.com"}, now.Add(-1*time.Hour), now.Add(72*time.Hour)), + nil, "soon.example.com") + if soon.Expired || !soon.ExpiringSoon { + t.Errorf("cert inside the soon window: expired=%v soon=%v, want soon", soon.Expired, soon.ExpiringSoon) + } +} diff --git a/sif.go b/sif.go index 61d2914..0f217c7 100644 --- a/sif.go +++ b/sif.go @@ -683,6 +683,16 @@ func (app *App) scanTarget(url, storeDir string, wantReport bool) (targetScan, e } } + if app.settings.TLSCert { + result, err := scan.TLSCert(url, app.settings.TLSCertPort, app.settings.Timeout, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running tls certificate recon: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, NewModuleResult(result)) + scansRun = append(scansRun, "TLSCert") + } + } + if app.settings.Passive { result, err := scan.Passive(url, app.settings.Timeout, app.settings.LogDir) if err != nil {