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
60 changes: 53 additions & 7 deletions internal/audit/apache.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ var apacheConfigRoots = []string{
`C:\Program Files (x86)\Apache Software Foundation\Apache2.4\conf`,
}

// Bounded single-segment globs for WampServer Apache layouts (expanded via filepath.Glob;
// not recursive host-wide scanning). Presence of config does not prove an active runtime.
var apacheConfigRootGlobs = []string{
`C:\wamp64\bin\apache\*\conf`,
`C:\wamp\bin\apache\*\conf`,
}

var (
reApacheDirective = regexp.MustCompile(`(?i)^\s*(DocumentRoot|ServerName|ServerAlias|LoadModule|SetHandler|AddHandler|Action|ProxyPassMatch|ProxyPass|FcgidWrapper|Include|IncludeOptional|PHPIniDir)\s+(.+?)\s*$`)
reApacheVHostOpen = regexp.MustCompile(`(?i)^\s*<VirtualHost\b([^>]*)>`)
Expand Down Expand Up @@ -57,6 +64,18 @@ func scanApacheConfiguration(r *Report) {
func accessibleApacheRoots() []string {
var out []string
seen := map[string]bool{}
add := func(resolved string) {
fi, err := os.Lstat(resolved)
if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 {
return
}
key := normalizeInstallPath(resolved)
if seen[key] {
return
}
seen[key] = true
out = append(out, resolved)
}
for _, raw := range apacheConfigRoots {
resolved := raw
if runtime.GOOS != "windows" && isWindowsDrivePath(strings.ReplaceAll(raw, `\`, `/`)) {
Expand All @@ -65,16 +84,20 @@ func accessibleApacheRoots() []string {
if runtime.GOOS == "windows" {
resolved = filepath.Clean(os.ExpandEnv(raw))
}
fi, err := os.Lstat(resolved)
if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 {
add(resolved)
}
for _, pat := range apacheConfigRootGlobs {
if runtime.GOOS != "windows" && isWindowsDrivePath(strings.ReplaceAll(pat, `\`, `/`)) {
continue
}
key := normalizeInstallPath(resolved)
if seen[key] {
continue
pattern := pat
if runtime.GOOS == "windows" {
pattern = filepath.Clean(os.ExpandEnv(pat))
}
matches, _ := filepath.Glob(pattern)
for _, m := range matches {
add(m)
}
seen[key] = true
out = append(out, resolved)
}
return out
}
Expand Down Expand Up @@ -427,6 +450,12 @@ func (s *apacheScanState) inspectHandlerBinary(r *Report, configured string) {
if !HandlerExecutableLooksLikePHP(target) && !HandlerExecutableLooksLikePHP(configured) {
return
}
// Skip re-inventory when the same path was already recorded (e.g. well-known /
// PATH discovery). Handler config evidence still stands; avoid duplicate install
// rows and patch assessments for one binary path.
if reportHasBinaryPathEvidence(r, target) || reportHasBinaryPathEvidence(r, configured) {
return
}
if !isSafeRegularFile(target) {
s.inaccessibleBinaries++
r.AddEvidence(Evidence{
Expand All @@ -444,6 +473,23 @@ func (s *apacheScanState) inspectHandlerBinary(r *Report, configured string) {
AddBinaryVersionEvidence(r, target, filepath.Base(target), "Apache handler static binary inspection")
}

// reportHasBinaryPathEvidence reports whether inventory already recorded this PHP binary path.
func reportHasBinaryPathEvidence(r *Report, path string) bool {
if r == nil || strings.TrimSpace(path) == "" {
return false
}
key := normalizeInstallPath(path)
for _, e := range r.Evidence {
switch e.Type {
case "binary_path", "binary_static_version", "binary_path_inferred_version":
if normalizeInstallPath(e.Location) == key {
return true
}
}
}
return false
}

// classifyApachePHPBackend extracts backend identity and whether it is remote.
func classifyApachePHPBackend(dir, args string) (backend string, remote bool, localExe string) {
raw := strings.TrimSpace(args)
Expand Down
67 changes: 66 additions & 1 deletion internal/audit/discovery_coverage.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package audit

import "strings"
import (
"fmt"
"path/filepath"
"strings"
)

// PHP discovery classification (internal). Used to keep HTML/CLI/CSV/summary semantics aligned
// without changing vulnerability-detection rules or scan-execution status.
Expand Down Expand Up @@ -182,6 +186,60 @@ func classifyPHPDiscovery(r *Report) string {
return phpDiscNoneIncomplete
}

// BinaryInstallCoverage summarizes PHP binary candidate version provenance for operator-facing reports.
// Path-inferred counts are not confirmed installations or proven vulnerability exposure.
type BinaryInstallCoverage struct {
BinaryCandidates int
InstallDirs int
Verified int
PathInferred int
VersionUnknown int
}

// CountBinaryInstallCoverage tallies binary_path / binary_static_version / binary_path_inferred_version evidence.
func CountBinaryInstallCoverage(r *Report) BinaryInstallCoverage {
var c BinaryInstallCoverage
if r == nil {
return c
}
dirs := map[string]struct{}{}
for _, e := range r.Evidence {
switch e.Type {
case "binary_static_version":
c.Verified++
c.BinaryCandidates++
if e.Location != "" {
dirs[normalizeInstallPath(filepath.Dir(e.Location))] = struct{}{}
}
case "binary_path_inferred_version":
c.PathInferred++
c.BinaryCandidates++
if e.Location != "" {
dirs[normalizeInstallPath(filepath.Dir(e.Location))] = struct{}{}
}
case "binary_path":
c.VersionUnknown++
c.BinaryCandidates++
if e.Location != "" {
dirs[normalizeInstallPath(filepath.Dir(e.Location))] = struct{}{}
}
}
}
c.InstallDirs = len(dirs)
return c
}

// FormatBinaryInstallCoverageDetail returns a concise operator-facing coverage sentence.
func FormatBinaryInstallCoverageDetail(c BinaryInstallCoverage) string {
if c.BinaryCandidates == 0 {
return ""
}
return fmt.Sprintf(
"%d PHP binary candidate(s) across %d install director(ies): %d with verified binary metadata, %d path-inferred (MEDIUM confidence; not binary-verified), %d without comparable version. Path-inferred versions are not confirmed installations or proven vulnerability exposure and require manual verification. Not proof of the HTTP-serving runtime.",
c.BinaryCandidates, c.InstallDirs, c.Verified, c.PathInferred, c.VersionUnknown,
)
}

// executiveVerificationGaps lists operator-facing gaps for the HTML "What could not be verified" card.
// Excludes Windows packages NOT_SUPPORTED and does not invent an HTTP-runtime gap when no PHP was found.
func executiveVerificationGaps(r *Report) []string {
Expand Down Expand Up @@ -211,6 +269,13 @@ func executiveVerificationGaps(r *Report) []string {
if rtLabel != "Verified" {
add("HTTP-serving PHP runtime")
}
cov := CountBinaryInstallCoverage(r)
if cov.PathInferred > 0 {
add("path-inferred PHP version(s) (not binary-verified; manual verification required)")
}
if cov.VersionUnknown > 0 {
add("PHP binary candidate(s) without comparable version")
}
for _, g := range relevantPHPInventoryGaps(r) {
add(g)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/audit/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func scanBinaries(r *Report, opts HostScanOptions) []deepDiscoverOutcome {
r.AddCheck("deep_discovery", status, detail)
}

versioned, inferred, partialInspect := 0, 0, 0
versioned, inferred, unknownVer, partialInspect := 0, 0, 0, 0
distinctDirs := map[string]struct{}{}
for _, e := range r.Evidence {
switch e.Type {
Expand All @@ -249,14 +249,15 @@ func scanBinaries(r *Report, opts HostScanOptions) []deepDiscoverOutcome {
inferred++
distinctDirs[normalizeInstallPath(filepath.Dir(e.Location))] = struct{}{}
case "binary_path":
unknownVer++
distinctDirs[normalizeInstallPath(filepath.Dir(e.Location))] = struct{}{}
}
}
status := "PARTIAL"
if deepTruncated {
status = "TRUNCATED"
}
detail := fmt.Sprintf("Found %d PHP-like binary paths across %d install director(ies) (%d with verified static version markers, %d path-inferred); no binaries executed", found, len(distinctDirs), versioned, inferred)
detail := fmt.Sprintf("Found %d PHP-like binary paths across %d install director(ies) (%d with verified static version markers, %d path-inferred, %d without comparable version); path-inferred versions are not binary-verified and are not proof of the HTTP-serving runtime; no binaries executed", found, len(distinctDirs), versioned, inferred, unknownVer)
if runtime.GOOS == "windows" {
detail += `; searched well-known dirs, %LOCALAPPDATA%\Programs\PHP (bounded junction resolve), all eligible PATH entries (not only the first match), IIS/Apache handler follow-up when present, operator --config-root, and optional --deep-root`
} else {
Expand Down
14 changes: 9 additions & 5 deletions internal/audit/path_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,31 @@ import (
"strings"
)

// Exact PHP patch versions embedded as directory segments (e.g. ...\PHP\8.4.24\nts\x64\).
var pathPHPVersionSegment = regexp.MustCompile(`(?i)^(\d+)\.(\d+)\.(\d+)$`)
// Exact PHP patch versions as directory segments: bare X.Y.Z (e.g. ...\PHP\8.4.24\nts\x64\)
// or WampServer-style phpX.Y.Z (e.g. ...\wamp64\bin\php\php7.4.33\php.exe).
// Capture group 1 is always the numeric x.y.z (never the "php" prefix).
var pathPHPVersionSegment = regexp.MustCompile(`(?i)^(?:php)?(\d+\.\d+\.\d+)$`)

// VersionFromInstallPath extracts a PHP x.y.z version from well-known installer path
// segments. Returns empty when the path only uses aliases such as "current".
// Inferred versions keep PATH_INFERRED / MEDIUM provenance via VersionFromBinary;
// they must not be treated as verified binary metadata or as the active HTTP runtime.
func VersionFromInstallPath(path string) (version string, source string) {
cleaned := filepath.Clean(path)
parts := strings.FieldsFunc(cleaned, func(r rune) bool {
return r == '/' || r == '\\'
})
var found []string
for _, p := range parts {
if pathPHPVersionSegment.MatchString(p) {
found = append(found, p)
if m := pathPHPVersionSegment.FindStringSubmatch(p); len(m) == 2 {
found = append(found, m[1])
}
}
if len(found) == 0 {
return "", ""
}
// Prefer the last (deepest) exact version segment; official Windows layout uses
// Programs\PHP\<version>\nts|ts\x64|x86\php.exe.
// Programs\PHP\<version>\nts|ts\x64|x86\php.exe. WampServer uses bin\php\phpX.Y.Z\.
return found[len(found)-1], "install_path_version_segment"
}

Expand Down
1 change: 1 addition & 0 deletions internal/audit/php_ini.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ func phpIniWellKnownPatterns() []string {
`C:\php\php.ini-development`,
`C:\xampp\php\php.ini`,
`C:\wamp\bin\php\*\php.ini`,
`C:\wamp64\bin\php\*\php.ini`,
`C:\laragon\bin\php\*\php.ini`,
`C:\inetpub\PHP\*\php.ini`,
`C:\Program Files\PHP\*\php.ini`,
Expand Down
8 changes: 7 additions & 1 deletion internal/audit/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,17 @@ details{border:1px solid var(--line);border-radius:8px;background:var(--card);ma
b.WriteString("Only filenames or configuration hints were seen; treat as unconfirmed.")
case phpDiscVersionUnknown:
b.WriteString("PHP-related evidence was observed, but no comparable release version was extracted.")
if covDetail := FormatBinaryInstallCoverageDetail(CountBinaryInstallCoverage(r)); covDetail != "" {
b.WriteString(" " + h(covDetail))
}
default:
if len(versions) == 0 {
b.WriteString("No comparable PHP release versions were extracted from this scan scope.")
} else {
b.WriteString("Observed inventory versions (not proof of the HTTP-serving runtime): " + h(strings.Join(versions, ", ")))
b.WriteString("Observed inventory versions (not proof of the HTTP-serving runtime): " + h(strings.Join(versions, ", ")) + ".")
}
if covDetail := FormatBinaryInstallCoverageDetail(CountBinaryInstallCoverage(r)); covDetail != "" {
b.WriteString(" " + h(covDetail))
}
}
b.WriteString(`</p></article>`)
Expand Down
3 changes: 3 additions & 0 deletions internal/audit/report_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,9 @@ func BuildFocusedNextSteps(r *Report) []string {
if kind == phpDiscVersionUnknown {
add("PHP-related evidence was observed without a comparable patch version. Inventory the exact active PHP patch versions for production web handlers before assigning vulnerability status.")
}
if cov := CountBinaryInstallCoverage(r); cov.PathInferred > 0 {
add("Manually verify path-inferred PHP version(s) (directory-derived only; not binary metadata). Treat them as inventory leads requiring confirmation — not as confirmed installations or proven vulnerability exposure.")
}
rtLabel, _ := humanRuntime(r)
if rtLabel != "Verified" && rtLabel != "Not applicable" {
add("Identify which PHP binary or SAPI actually serves HTTP (FPM pool, Apache module, IIS handler, container entrypoint). Package and CLI versions alone are not sufficient.")
Expand Down
Loading