Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ PROBES:
-title display page title
-bp, -body-preview display first N characters of response body (default 100)
-server, -web-server display server name
-td, -tech-detect display technology in use based on wappalyzer dataset
-td, -tech-detect display technology in use based on wappalyzer dataset
-cff, -custom-fingerprint-file string path to a custom fingerprint file for technology detection
-method display http request method
-cpe display CPE (Common Platform Enumeration) based on awesome-search-queries
-wp, -wordpress display WordPress plugins and themes
-method display http request method
-ws, -websocket display server using websocket
-ip display host ip
-cname display host cname
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ require (
github.com/pierrec/lz4/v4 v4.1.23 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/projectdiscovery/awesome-search-queries v0.0.0-20260104120501-961ef30f7193 // indirect
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, find the runner directory and check what files exist
find . -type f -name "cpe.go" -o -name "wordpress.go" | head -20

Repository: projectdiscovery/httpx

Length of output: 103


🏁 Script executed:

# Also check if go.mod exists and verify line 131
if [ -f go.mod ]; then
  sed -n '130,132p' go.mod
fi

Repository: projectdiscovery/httpx

Length of output: 303


🏁 Script executed:

# Search for imports of awesome-search-queries in the codebase
rg "awesome-search-queries" --type go

Repository: projectdiscovery/httpx

Length of output: 397


Dependency should be marked as direct, not indirect.

The awesome-search-queries dependency is directly imported in runner/cpe.go and runner/wordpress.go, so it should not be marked as // indirect in go.mod. Run go mod tidy to correct this automatically.

🤖 Prompt for AI Agents
In @go.mod at line 131, The dependency
github.com/projectdiscovery/awesome-search-queries is marked as indirect in
go.mod but is directly imported by runner/cpe.go and runner/wordpress.go; run
`go mod tidy` (or manually remove the `// indirect` comment and ensure the
require line matches `github.com/projectdiscovery/awesome-search-queries
v0.0.0-20260104120501-961ef30f7193`) so the module is recorded as a direct
dependency, then re-run `go build`/tests to confirm imports in runner/cpe.go and
runner/wordpress.go resolve correctly.

github.com/projectdiscovery/blackrock v0.0.1 // indirect
github.com/projectdiscovery/freeport v0.0.7 // indirect
github.com/projectdiscovery/gostruct v0.0.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kID2iwsDqI=
github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
github.com/projectdiscovery/awesome-search-queries v0.0.0-20260104120501-961ef30f7193 h1:UCZRqs1BP1wsvhCwQxfIQc7NJcXGBhQvAnEw3awhsng=
github.com/projectdiscovery/awesome-search-queries v0.0.0-20260104120501-961ef30f7193/go.mod h1:nSovPcipgSx/EzAefF+iCfORolkKAuodiRWL3RCGHOM=
github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
github.com/projectdiscovery/cdncheck v1.2.17 h1:Ah7KIft60ZiE6etGuX/63HiDJu0C7szhEwYTQugVorU=
Expand Down
225 changes: 225 additions & 0 deletions runner/cpe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package runner

import (
"encoding/json"
"fmt"
"strings"

awesomesearchqueries "github.com/projectdiscovery/awesome-search-queries"
)

type CPEInfo struct {
Product string `json:"product,omitempty"`
Vendor string `json:"vendor,omitempty"`
CPE string `json:"cpe,omitempty"`
}

type CPEDetector struct {
titlePatterns map[string][]CPEInfo
bodyPatterns map[string][]CPEInfo
faviconPatterns map[string][]CPEInfo
}

type rawQuery struct {
Name string `json:"name"`
Vendor json.RawMessage `json:"vendor"`
Type string `json:"type"`
Engines []rawEngine `json:"engines"`
}

type rawEngine struct {
Platform string `json:"platform"`
Queries []string `json:"queries"`
}

func NewCPEDetector() (*CPEDetector, error) {
data, err := awesomesearchqueries.GetQueries()
if err != nil {
return nil, fmt.Errorf("failed to load queries: %w", err)
}

var queries []rawQuery
if err := json.Unmarshal(data, &queries); err != nil {
return nil, fmt.Errorf("failed to parse queries: %w", err)
}

detector := &CPEDetector{
titlePatterns: make(map[string][]CPEInfo),
bodyPatterns: make(map[string][]CPEInfo),
faviconPatterns: make(map[string][]CPEInfo),
}

for _, q := range queries {
vendor := parseVendor(q.Vendor)
info := CPEInfo{
Product: q.Name,
Vendor: vendor,
CPE: generateCPE(vendor, q.Name),
}

for _, engine := range q.Engines {
for _, query := range engine.Queries {
detector.extractPattern(query, info)
}
}
}

return detector, nil
}

func parseVendor(raw json.RawMessage) string {
var vendorStr string
if err := json.Unmarshal(raw, &vendorStr); err == nil {
return vendorStr
}

var vendorSlice []string
if err := json.Unmarshal(raw, &vendorSlice); err == nil && len(vendorSlice) > 0 {
return vendorSlice[0]
}

return ""
}

func generateCPE(vendor, product string) string {
if vendor == "" || product == "" {
return ""
}
return fmt.Sprintf("cpe:2.3:a:%s:%s:*:*:*:*:*:*:*:*",
strings.ToLower(strings.ReplaceAll(vendor, " ", "_")),
strings.ToLower(strings.ReplaceAll(product, " ", "_")))
}

func (d *CPEDetector) extractPattern(query string, info CPEInfo) {
query = strings.TrimSpace(query)

titlePrefixes := []string{
"http.title:",
"title=",
"title==",
"intitle:",
"title:",
"title='",
`title="`,
}

for _, prefix := range titlePrefixes {
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
pattern = strings.TrimPrefix(pattern, prefix[:len(prefix)-1])
if pattern != "" {
pattern = strings.ToLower(pattern)
d.titlePatterns[pattern] = appendUnique(d.titlePatterns[pattern], info)
}
return
}
}
Comment on lines +106 to +116
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Suspicious redundant prefix stripping.

Line 109 attempts to strip prefix[:len(prefix)-1] after already trimming the full prefix on line 108. This appears to be dead code or a logic error — if the prefix was already removed, stripping a shorter version won't have any effect.

🔎 Suggested fix

If the intent was to handle cases where the prefix includes a trailing quote character (like title=' or title="), line 108 should handle that. Consider removing line 109 if it's redundant:

 		if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
 			pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
-			pattern = strings.TrimPrefix(pattern, prefix[:len(prefix)-1])
 			if pattern != "" {
 				pattern = strings.ToLower(pattern)
 				d.titlePatterns[pattern] = appendUnique(d.titlePatterns[pattern], info)
 			}
 			return
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, prefix := range titlePrefixes {
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
pattern = strings.TrimPrefix(pattern, prefix[:len(prefix)-1])
if pattern != "" {
pattern = strings.ToLower(pattern)
d.titlePatterns[pattern] = appendUnique(d.titlePatterns[pattern], info)
}
return
}
}
for _, prefix := range titlePrefixes {
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
if pattern != "" {
pattern = strings.ToLower(pattern)
d.titlePatterns[pattern] = appendUnique(d.titlePatterns[pattern], info)
}
return
}
}
🤖 Prompt for AI Agents
In @runner/cpe.go around lines 106 - 116, The code in the loop over
titlePrefixes redundantly strips the prefix twice: first with
strings.TrimPrefix(query, prefix) then again with strings.TrimPrefix(...,
prefix[:len(prefix)-1]); update the logic in the block handling titlePrefixes
(the loop using titlePrefixes, extractQuotedValue, and writing into
d.titlePatterns via appendUnique) so you only strip the intended prefix
once—either remove the second TrimPrefix call entirely, or replace the first
TrimPrefix with logic that conditionally trims the variant without its last
character when the prefix form includes a trailing quote (e.g., handle prefixes
like `title='`/`title="`), then normalize to lowercase and proceed to set
d.titlePatterns[pattern] as before.


bodyPrefixes := []string{
"http.html:",
"body=",
"body==",
"intext:",
}

for _, prefix := range bodyPrefixes {
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
if pattern != "" {
pattern = strings.ToLower(pattern)
d.bodyPatterns[pattern] = appendUnique(d.bodyPatterns[pattern], info)
}
return
}
}

faviconPrefixes := []string{
"http.favicon.hash:",
"icon_hash=",
"icon_hash==",
}

for _, prefix := range faviconPrefixes {
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
if pattern != "" {
d.faviconPatterns[pattern] = appendUnique(d.faviconPatterns[pattern], info)
}
return
}
}
}

func extractQuotedValue(s string) string {
s = strings.TrimSpace(s)

if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
s = s[1 : len(s)-1]
}
}

if idx := strings.Index(s, "\" ||"); idx > 0 {
s = s[:idx]
}
if idx := strings.Index(s, "' ||"); idx > 0 {
s = s[:idx]
}

return strings.TrimSpace(s)
}

func appendUnique(slice []CPEInfo, info CPEInfo) []CPEInfo {
for _, existing := range slice {
if existing.Product == info.Product && existing.Vendor == info.Vendor {
return slice
}
}
return append(slice, info)
}

func (d *CPEDetector) Detect(title, body, faviconHash string) []CPEInfo {
seen := make(map[string]bool)
var results []CPEInfo

titleLower := strings.ToLower(title)
bodyLower := strings.ToLower(body)

for pattern, infos := range d.titlePatterns {
if strings.Contains(titleLower, pattern) {
for _, info := range infos {
key := info.Product + "|" + info.Vendor
if !seen[key] {
seen[key] = true
results = append(results, info)
}
}
}
}

for pattern, infos := range d.bodyPatterns {
if strings.Contains(bodyLower, pattern) {
for _, info := range infos {
key := info.Product + "|" + info.Vendor
if !seen[key] {
seen[key] = true
results = append(results, info)
}
}
}
}

if faviconHash != "" {
if infos, ok := d.faviconPatterns[faviconHash]; ok {
for _, info := range infos {
key := info.Product + "|" + info.Vendor
if !seen[key] {
seen[key] = true
results = append(results, info)
}
}
}
}

return results
}
8 changes: 8 additions & 0 deletions runner/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ type ScanOptions struct {
NoFallback bool
NoFallbackScheme bool
TechDetect bool
CPEDetect bool
WordPress bool
StoreChain bool
StoreVisionReconClusters bool
MaxResponseBodySizeToSave int
Expand Down Expand Up @@ -148,6 +150,8 @@ func (s *ScanOptions) Clone() *ScanOptions {
NoFallback: s.NoFallback,
NoFallbackScheme: s.NoFallbackScheme,
TechDetect: s.TechDetect,
CPEDetect: s.CPEDetect,
WordPress: s.WordPress,
StoreChain: s.StoreChain,
OutputExtractRegex: s.OutputExtractRegex,
MaxResponseBodySizeToSave: s.MaxResponseBodySizeToSave,
Expand Down Expand Up @@ -256,6 +260,8 @@ type Options struct {
NoFallback bool
NoFallbackScheme bool
TechDetect bool
CPEDetect bool
WordPress bool
CustomFingerprintFile string
TLSGrab bool
protocol string
Expand Down Expand Up @@ -387,6 +393,8 @@ func ParseOptions() *Options {
flagSet.BoolVarP(&options.OutputServerHeader, "web-server", "server", false, "display server name"),
flagSet.BoolVarP(&options.TechDetect, "tech-detect", "td", false, "display technology in use based on wappalyzer dataset"),
flagSet.StringVarP(&options.CustomFingerprintFile, "custom-fingerprint-file", "cff", "", "path to a custom fingerprint file for technology detection"),
flagSet.BoolVar(&options.CPEDetect, "cpe", false, "display CPE (Common Platform Enumeration) based on awesome-search-queries"),
flagSet.BoolVarP(&options.WordPress, "wordpress", "wp", false, "display WordPress plugins and themes"),
flagSet.BoolVar(&options.OutputMethod, "method", false, "display http request method"),
flagSet.BoolVarP(&options.OutputWebSocket, "websocket", "ws", false, "display server using websocket"),
flagSet.BoolVar(&options.OutputIP, "ip", false, "display host ip"),
Expand Down
61 changes: 61 additions & 0 deletions runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ type Runner struct {
options *Options
hp *httpx.HTTPX
wappalyzer *wappalyzer.Wappalyze
cpeDetector *CPEDetector
wpDetector *WordPressDetector
scanopts ScanOptions
hm *hybrid.HybridMap
excludeCdn bool
Expand Down Expand Up @@ -133,6 +135,20 @@ func New(options *Options) (*Runner, error) {
return nil, errors.Wrap(err, "could not create wappalyzer client")
}

if options.CPEDetect || options.JSONOutput || options.CSVOutput {
runner.cpeDetector, err = NewCPEDetector()
if err != nil {
gologger.Warning().Msgf("Could not create CPE detector: %s", err)
}
}

if options.WordPress || options.JSONOutput || options.CSVOutput {
runner.wpDetector, err = NewWordPressDetector()
if err != nil {
gologger.Warning().Msgf("Could not create WordPress detector: %s", err)
}
}

if options.StoreResponseDir != "" {
_ = os.RemoveAll(filepath.Join(options.StoreResponseDir, "response", "index.txt"))
_ = os.RemoveAll(filepath.Join(options.StoreResponseDir, "screenshot", "index_screenshot.txt"))
Expand Down Expand Up @@ -297,6 +313,8 @@ func New(options *Options) (*Runner, error) {
scanopts.NoFallback = options.NoFallback
scanopts.NoFallbackScheme = options.NoFallbackScheme
scanopts.TechDetect = options.TechDetect || options.JSONOutput || options.CSVOutput || options.AssetUpload
scanopts.CPEDetect = options.CPEDetect || options.JSONOutput || options.CSVOutput
scanopts.WordPress = options.WordPress || options.JSONOutput || options.CSVOutput
scanopts.StoreChain = options.StoreChain
scanopts.StoreVisionReconClusters = options.StoreVisionReconClusters
scanopts.MaxResponseBodySizeToSave = options.MaxResponseBodySizeToSave
Expand Down Expand Up @@ -2311,6 +2329,47 @@ retry:
}
}

var cpeMatches []CPEInfo
if r.cpeDetector != nil {
cpeMatches = r.cpeDetector.Detect(title, string(resp.Data), faviconMMH3)
if len(cpeMatches) > 0 && r.options.CPEDetect {
for _, cpe := range cpeMatches {
builder.WriteString(" [")
if !scanopts.OutputWithNoColor {
builder.WriteString(aurora.Cyan(cpe.CPE).String())
} else {
builder.WriteString(cpe.CPE)
}
builder.WriteRune(']')
}
}
}

var wpInfo *WordPressInfo
if r.wpDetector != nil {
wpInfo = r.wpDetector.Detect(string(resp.Data))
if wpInfo.HasData() && r.options.WordPress {
if len(wpInfo.Plugins) > 0 {
builder.WriteString(" [")
if !scanopts.OutputWithNoColor {
builder.WriteString(aurora.Green("wp-plugins:" + strings.Join(wpInfo.Plugins, ",")).String())
} else {
builder.WriteString("wp-plugins:" + strings.Join(wpInfo.Plugins, ","))
}
builder.WriteRune(']')
}
if len(wpInfo.Themes) > 0 {
builder.WriteString(" [")
if !scanopts.OutputWithNoColor {
builder.WriteString(aurora.Green("wp-themes:" + strings.Join(wpInfo.Themes, ",")).String())
} else {
builder.WriteString("wp-themes:" + strings.Join(wpInfo.Themes, ","))
}
builder.WriteRune(']')
}
}
}

result := Result{
Timestamp: time.Now(),
Request: request,
Expand Down Expand Up @@ -2374,6 +2433,8 @@ retry:
RequestRaw: requestDump,
Response: resp,
FaviconData: faviconData,
CPE: cpeMatches,
WordPress: wpInfo,
}
if resp.BodyDomains != nil {
result.Fqdns = resp.BodyDomains.Fqdns
Expand Down
2 changes: 2 additions & 0 deletions runner/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ type Result struct {
Response *httpx.Response `json:"-" csv:"-" mapstructure:"-"`
FaviconData []byte `json:"-" csv:"-" mapstructure:"-"`
Trace *retryablehttp.TraceInfo `json:"trace,omitempty" csv:"-" mapstructure:"trace"`
CPE []CPEInfo `json:"cpe,omitempty" csv:"cpe" mapstructure:"cpe"`
WordPress *WordPressInfo `json:"wordpress,omitempty" csv:"wordpress" mapstructure:"wordpress"`
}

type Trace struct {
Expand Down
Loading
Loading