diff --git a/README.md b/README.md index 2ce6ea6..2853e0f 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,18 @@ See the [examples/](examples/) directory for runnable code samples. See [docs/architecture.md](docs/architecture.md) for the package layout and data flow. +### Portable quality graph + +`qualitygraph.Build` projects a completed `inspect.Report` into the shared +`hawk-core-contracts/graph` vocabulary. The projection contains a quality node +for the report, bounded quality nodes for findings, `contains` edges, and +immutable observation events. + +The projection is metadata-only: scan targets, URLs, messages, DOM elements, +fixes, and evidence are represented by SHA-256 digests rather than copied into +graph attributes. Inspect remains the source of truth for scans and reports; +consumers such as Hawk may journal or compose the portable projection. + ## Ecosystem inspect is part of the hawk ecosystem: diff --git a/docs/architecture.md b/docs/architecture.md index b40348d..78d006c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,7 @@ inspect/ βββ sarif.go π GenerateSARIF β SARIF 2.1.0 output βββ browser.go π BrowserEngine interface + page-data types (no rod import) βββ browser_fetcher.go π Adapts a BrowserEngine into the crawler's fetcher +βββ qualitygraph/ πΈοΈ Privacy-safe shared quality graph projection βββ checks/ β Built-in checks run against crawled responses β βββ headers.go π Missing security headers (CSP, HSTS, β¦) β βββ cookies.go πͺ Cookie Secure/HttpOnly/SameSite flags @@ -165,6 +166,11 @@ and check, per-check durations) and a `FailOn` threshold; `Report.Failed()` and `Report.MaxSeverity()` summarize the run. `GenerateSARIF` converts findings to SARIF 2.1.0. +`qualitygraph.Build` is an explicit read-only projection boundary after a scan +completes. It maps the report and a bounded set of findings to shared quality +nodes, containment edges, and observation events. Sensitive report fields are +hashed, and the package neither runs scans nor persists graph state. + --- ## π‘οΈ Crawler Safeguards diff --git a/go.mod b/go.mod index 0955918..26c8fd7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/GrayCodeAI/inspect go 1.26.5 require ( - github.com/GrayCodeAI/hawk-core-contracts v0.1.4 + github.com/GrayCodeAI/hawk-core-contracts v0.1.9 github.com/GrayCodeAI/hawk-mcpkit v0.1.4 github.com/mark3labs/mcp-go v0.49.0 golang.org/x/net v0.55.0 diff --git a/go.sum b/go.sum index da39a21..e00a87b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/GrayCodeAI/hawk-core-contracts v0.1.4 h1:HRcuxvl5RMgqmF0Lt1YwHY84nGcUwXculXtnrE/8nLI= -github.com/GrayCodeAI/hawk-core-contracts v0.1.4/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/hawk-core-contracts v0.1.9 h1:uXX/gtNM+3kxSEzu+rZkHykzcEaAbASn1lmPyOGMXvc= +github.com/GrayCodeAI/hawk-core-contracts v0.1.9/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= github.com/GrayCodeAI/hawk-mcpkit v0.1.4 h1:tlhZXKDbI679I7c1feeY/pzErFwndD+R2CQf9sqHAVE= github.com/GrayCodeAI/hawk-mcpkit v0.1.4/go.mod h1:C32HPDRqiDETbVbMIbOTvguek6KImpLCffJjet7sqck= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go index 2f86f31..6091b50 100644 --- a/internal/crawler/crawler.go +++ b/internal/crawler/crawler.go @@ -556,37 +556,42 @@ func (c *Crawler) validateURL(rawURL string) error { return nil } +// privateCIDRs are the RFC 1918, loopback, link-local, and CGNAT ranges +// used for SSRF protection. Parsed once at init time; a malformed entry +// is a programming error caught at startup, not at scan time. +var privateCIDRs = func() []*net.IPNet { + cidrs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "169.254.0.0/16", // link-local, incl. cloud metadata (169.254.169.254) + "100.64.0.0/10", // CGNAT shared address space + "::1/128", + "fc00::/7", + "fe80::/10", // IPv6 link-local + } + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, s := range cidrs { + _, network, err := net.ParseCIDR(s) + if err != nil { + panic(fmt.Sprintf("inspect/crawler: invalid private CIDR %q: %v", s, err)) + } + nets = append(nets, network) + } + return nets +}() + // isPrivateIP checks if an IP is in a private/loopback range. func isPrivateIP(ip net.IP) bool { - privateRanges := []struct { - network *net.IPNet - }{ - {mustParseCIDR("10.0.0.0/8")}, - {mustParseCIDR("172.16.0.0/12")}, - {mustParseCIDR("192.168.0.0/16")}, - {mustParseCIDR("127.0.0.0/8")}, - {mustParseCIDR("169.254.0.0/16")}, // link-local, incl. cloud metadata (169.254.169.254) - {mustParseCIDR("100.64.0.0/10")}, // CGNAT shared address space - {mustParseCIDR("::1/128")}, - {mustParseCIDR("fc00::/7")}, - {mustParseCIDR("fe80::/10")}, // IPv6 link-local - } - for _, r := range privateRanges { - if r.network.Contains(ip) { + for _, network := range privateCIDRs { + if network.Contains(ip) { return true } } return false } -func mustParseCIDR(s string) *net.IPNet { - _, network, err := net.ParseCIDR(s) - if err != nil { - panic(err) - } - return network -} - func isRetryable(statusCode int) bool { return statusCode == 429 || statusCode == 500 || statusCode == 502 || statusCode == 503 || statusCode == 504 || statusCode == 0 diff --git a/mcp/server.go b/mcp/server.go index 727debb..3060d4e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -51,6 +51,16 @@ func (s *Server) registerTools() { mcplib.WithDescription("Scan a local directory of HTML files"), mcplib.WithString("path", mcplib.Required(), mcplib.Description("Local directory path")), ), s.handleScanDir) + + s.kit.AddTool(mcplib.NewTool( + "inspect_checks", + mcplib.WithDescription("List all available audit checks and what they detect"), + ), s.handleChecks) + + s.kit.AddTool(mcplib.NewTool( + "inspect_status", + mcplib.WithDescription("Return the inspect server version and capabilities"), + ), s.handleStatus) } func (s *Server) handleScan(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { @@ -84,3 +94,26 @@ func (s *Server) handleScanDir(ctx context.Context, req mcplib.CallToolRequest) } return mcpkit.JSONResult(report) } + +func (s *Server) handleChecks(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + checks := []map[string]string{ + {"name": "links", "description": "Broken links, redirect chains, and dead anchors"}, + {"name": "security", "description": "Security headers (CSP, HSTS, X-Frame-Options), mixed content"}, + {"name": "forms", "description": "Form accessibility, autocomplete, and sensitive field handling"}, + {"name": "a11y", "description": "Accessibility: alt text, ARIA roles, contrast, heading structure"}, + {"name": "perf", "description": "Performance: render-blocking resources, image optimization hints"}, + {"name": "seo", "description": "SEO: meta tags, canonical URLs, structured data"}, + {"name": "sri", "description": "Subresource Integrity hashes on external scripts and styles"}, + {"name": "aiready", "description": "AI-readiness: robots.txt, llms.txt, structured data for crawlers"}, + {"name": "reachability", "description": "URL reachability and HTTP status codes"}, + } + return mcpkit.JSONResult(map[string]interface{}{"checks": checks}) +} + +func (s *Server) handleStatus(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return mcpkit.JSONResult(map[string]interface{}{ + "name": "inspect", + "version": inspect.Version, + "tools": []string{"inspect_scan", "inspect_scan_dir", "inspect_checks", "inspect_status"}, + }) +} diff --git a/mcp/server_test.go b/mcp/server_test.go index 1ce3df2..8a060ee 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -200,19 +200,18 @@ func TestProtocol_ToolsList(t *testing.T) { t.Fatalf("unmarshal: %v", err) } - if len(result.Result.Tools) != 2 { - t.Fatalf("expected 2 tools, got %d", len(result.Result.Tools)) + if len(result.Result.Tools) != 4 { + t.Fatalf("expected 4 tools, got %d", len(result.Result.Tools)) } names := make(map[string]bool) for _, tool := range result.Result.Tools { names[tool.Name] = true } - if !names["inspect_scan"] { - t.Error("inspect_scan tool not found in tools/list") - } - if !names["inspect_scan_dir"] { - t.Error("inspect_scan_dir tool not found in tools/list") + for _, expected := range []string{"inspect_scan", "inspect_scan_dir", "inspect_checks", "inspect_status"} { + if !names[expected] { + t.Errorf("%s tool not found in tools/list", expected) + } } } @@ -489,18 +488,15 @@ func TestHandleScanDir_NonexistentDir(t *testing.T) { if result == nil { t.Fatal("expected result") } - // The ScanDir function starts a file server on the given directory. - // For a nonexistent path, the server may still start and serve 404s. - // The scanner reports these as findings, not as a tool error. + // ScanDir calls os.Stat on the path; a nonexistent directory returns an + // error, which handleScanDir surfaces as a tool-level error result. + if !result.IsError { + t.Fatal("expected tool-level error for nonexistent directory") + } for _, c := range result.Content { if tc, ok := c.(mcplib.TextContent); ok { - var report inspect.Report - if err := json.Unmarshal([]byte(tc.Text), &report); err != nil { - t.Fatalf("expected valid JSON report: %v", err) - } - // Report should exist with a target. - if report.Target == "" { - t.Error("expected non-empty target in report") + if tc.Text == "" { + t.Fatal("expected non-empty error message") } } } @@ -644,5 +640,126 @@ func TestErrorHandling_UnknownMethod(t *testing.T) { } } +// --------------------------------------------------------------------------- +// inspect_checks and inspect_status tools +// --------------------------------------------------------------------------- + +func TestProtocol_ToolsCall_Checks(t *testing.T) { + ts, _ := newTestHTTPServer(t, inspect.Quick, inspect.WithAllowPrivateIPs()) + sid := initAndSession(t, ts) + + resp, err := postSessionJSON(ts.URL, sid, jsonRPCRequest(3, "tools/call", map[string]any{ + "name": "inspect_checks", + "arguments": map[string]any{}, + })) + if err != nil { + t.Fatalf("tools/call: %v", err) + } + + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + var rpcResp struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if rpcResp.Result.IsError { + t.Fatalf("expected success, got error: %s", rpcResp.Result.Content[0].Text) + } + if len(rpcResp.Result.Content) == 0 { + t.Fatal("expected at least one content item") + } + + // The text content should be a JSON object with a "checks" array. + var payload struct { + Checks []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"checks"` + } + if err := json.Unmarshal([]byte(rpcResp.Result.Content[0].Text), &payload); err != nil { + t.Fatalf("content is not valid JSON: %v\nraw: %s", err, rpcResp.Result.Content[0].Text) + } + if len(payload.Checks) == 0 { + t.Fatal("expected at least one audit check in the catalog") + } + + // Spot-check that a known check is present. + found := map[string]bool{} + for _, c := range payload.Checks { + found[c.Name] = true + } + for _, want := range []string{"links", "security", "a11y"} { + if !found[want] { + t.Errorf("expected %q check in catalog, not found", want) + } + } +} + +func TestProtocol_ToolsCall_Status(t *testing.T) { + ts, _ := newTestHTTPServer(t, inspect.Quick, inspect.WithAllowPrivateIPs()) + sid := initAndSession(t, ts) + + resp, err := postSessionJSON(ts.URL, sid, jsonRPCRequest(3, "tools/call", map[string]any{ + "name": "inspect_status", + "arguments": map[string]any{}, + })) + if err != nil { + t.Fatalf("tools/call: %v", err) + } + + body := readBody(t, resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + var rpcResp struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if rpcResp.Result.IsError { + t.Fatalf("expected success, got error: %s", rpcResp.Result.Content[0].Text) + } + if len(rpcResp.Result.Content) == 0 { + t.Fatal("expected at least one content item") + } + + var payload struct { + Name string `json:"name"` + Version string `json:"version"` + Tools []string `json:"tools"` + } + if err := json.Unmarshal([]byte(rpcResp.Result.Content[0].Text), &payload); err != nil { + t.Fatalf("content is not valid JSON: %v\nraw: %s", err, rpcResp.Result.Content[0].Text) + } + if payload.Name != "inspect" { + t.Errorf("name: want inspect, got %q", payload.Name) + } + if payload.Version != inspect.Version { + t.Errorf("version: want %s, got %s", inspect.Version, payload.Version) + } + if len(payload.Tools) == 0 { + t.Error("expected at least one tool in status output") + } +} + // Note: additional tests for the mcp package live in server_more_test.go // (split out for file size/clarity). Shared test helpers remain in this file. diff --git a/qualitygraph/projection.go b/qualitygraph/projection.go new file mode 100644 index 0000000..b236e9d --- /dev/null +++ b/qualitygraph/projection.go @@ -0,0 +1,216 @@ +// Package qualitygraph projects Inspect reports into the portable hawk-eco +// graph contract. Inspect remains the owner of scan execution and findings. +package qualitygraph + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/inspect" +) + +const ( + SchemaVersion = "inspect.graph/v1" + defaultLimit = 1000 +) + +// Export is a bounded, metadata-only quality graph projection. +type Export struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Scope graphcontracts.Scope `json:"scope,omitempty"` + Nodes []graphcontracts.Node `json:"nodes"` + Edges []graphcontracts.Edge `json:"edges"` + Events []graphcontracts.Event `json:"events"` +} + +// Options supplies observation identity and tenancy scope. ObservedAt should be +// fixed by callers that require byte-for-byte deterministic exports. +type Options struct { + ObservedAt time.Time + Scope graphcontracts.Scope + CorrelationID string + ProducerVersion string + MaxFindings int +} + +// Build converts a completed Inspect report into a privacy-safe quality graph. +// URLs, targets, messages, elements, fixes, and evidence never enter attributes. +func Build(report *inspect.Report, opts Options) (*Export, error) { + if report == nil { + return nil, errors.New("qualitygraph: report is required") + } + observedAt := opts.ObservedAt.UTC() + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + limit := opts.MaxFindings + if limit <= 0 || limit > defaultLimit { + limit = defaultLimit + } + provenance := graphcontracts.Provenance{ + Producer: "inspect", + Version: strings.TrimSpace(opts.ProducerVersion), + } + + reportDigest := digest(strings.TrimSpace(report.Target), observedAt.Format(time.RFC3339Nano)) + reportRef := graphcontracts.Ref{ + Kind: graphcontracts.NodeQuality, + ID: "inspect/report/" + reportDigest, + } + provenance.SourceID = reportDigest + provenance.Evidence = []graphcontracts.ArtifactRef{{URI: "inspect://report/" + reportDigest}} + + selected := report.Findings + if len(selected) > limit { + selected = selected[:limit] + } + export := &Export{ + SchemaVersion: SchemaVersion, + GeneratedAt: observedAt, + Scope: opts.Scope, + Nodes: make([]graphcontracts.Node, 0, len(selected)+1), + Edges: make([]graphcontracts.Edge, 0, len(selected)), + Events: make([]graphcontracts.Event, 0, len(selected)+1), + } + reportNode := graphcontracts.Node{ + ID: reportRef.ID, + Kind: reportRef.Kind, + Scope: opts.Scope, + CreatedAt: observedAt, + Provenance: provenance, + Attributes: map[string]string{ + "entity": "report", + "status": reportStatus(report), + "max_severity": report.MaxSeverity().String(), + "fail_on": report.FailOn.String(), + "pages_scanned": strconv.Itoa(report.Stats.PagesScanned), + "crawled_urls": strconv.Itoa(report.CrawledURLs), + "findings_total": strconv.Itoa(report.Stats.FindingsTotal), + "projected_findings": strconv.Itoa(len(selected)), + "truncated": strconv.FormatBool(len(report.Findings) > len(selected)), + "duration_ms": strconv.FormatInt(report.Duration.Milliseconds(), 10), + "target_digest": digest(strings.TrimSpace(report.Target)), + }, + } + if err := reportNode.Validate(); err != nil { + return nil, fmt.Errorf("qualitygraph: report node: %w", err) + } + export.Nodes = append(export.Nodes, reportNode) + export.Events = append(export.Events, observedEvent(reportRef, opts.Scope, observedAt, opts.CorrelationID, provenance)) + + for index, finding := range selected { + findingDigest := digest( + reportRef.ID, + strconv.Itoa(index), + finding.Check, + finding.Severity.String(), + finding.URL, + finding.Element, + finding.Message, + finding.Fix, + finding.Evidence, + ) + findingRef := graphcontracts.Ref{ + Kind: graphcontracts.NodeQuality, + ID: "inspect/finding/" + findingDigest, + } + findingProvenance := graphcontracts.Provenance{ + Producer: "inspect", + Version: strings.TrimSpace(opts.ProducerVersion), + SourceID: findingDigest, + Evidence: []graphcontracts.ArtifactRef{{URI: "inspect://finding/" + findingDigest}}, + } + node := graphcontracts.Node{ + ID: findingRef.ID, + Kind: findingRef.Kind, + Scope: opts.Scope, + CreatedAt: observedAt, + Provenance: findingProvenance, + Attributes: map[string]string{ + "entity": "finding", + "check": strings.TrimSpace(finding.Check), + "severity": finding.Severity.String(), + "url_digest": digest(strings.TrimSpace(finding.URL)), + "message_digest": digest(strings.TrimSpace(finding.Message)), + "element_digest": digest(strings.TrimSpace(finding.Element)), + "fix_digest": digest(strings.TrimSpace(finding.Fix)), + "evidence_digest": digest(strings.TrimSpace(finding.Evidence)), + }, + } + if err := node.Validate(); err != nil { + return nil, fmt.Errorf("qualitygraph: finding[%d] node: %w", index, err) + } + export.Nodes = append(export.Nodes, node) + + edge := graphcontracts.Edge{ + ID: "inspect/contains/" + digest(reportRef.ID, findingRef.ID), + Kind: graphcontracts.EdgeContains, + From: reportRef, + To: findingRef, + Scope: opts.Scope, + CreatedAt: observedAt, + Provenance: graphcontracts.Provenance{ + Producer: "inspect", + Version: strings.TrimSpace(opts.ProducerVersion), + SourceID: findingDigest, + }, + } + if err := edge.Validate(); err != nil { + return nil, fmt.Errorf("qualitygraph: finding[%d] edge: %w", index, err) + } + export.Edges = append(export.Edges, edge) + export.Events = append(export.Events, observedEvent( + findingRef, opts.Scope, observedAt, opts.CorrelationID, findingProvenance, + )) + } + + sort.Slice(export.Nodes, func(i, j int) bool { return export.Nodes[i].ID < export.Nodes[j].ID }) + sort.Slice(export.Edges, func(i, j int) bool { return export.Edges[i].ID < export.Edges[j].ID }) + sort.Slice(export.Events, func(i, j int) bool { return export.Events[i].ID < export.Events[j].ID }) + return export, nil +} + +func observedEvent( + subject graphcontracts.Ref, + scope graphcontracts.Scope, + observedAt time.Time, + correlationID string, + provenance graphcontracts.Provenance, +) graphcontracts.Event { + event := graphcontracts.Event{ + ID: "inspect/observed/" + digest(subject.ID, observedAt.Format(time.RFC3339Nano)), + Type: graphcontracts.EventObserved, + Subject: subject, + Scope: scope, + OccurredAt: observedAt, + CorrelationID: strings.TrimSpace(correlationID), + IdempotencyKey: digest(subject.ID, observedAt.Format(time.RFC3339Nano)), + Provenance: provenance, + } + return event +} + +func reportStatus(report *inspect.Report) string { + if report.Failed() { + return "failed" + } + return "passed" +} + +func digest(parts ...string) string { + hash := sha256.New() + for _, part := range parts { + _, _ = hash.Write([]byte(strconv.Itoa(len(part)))) + _, _ = hash.Write([]byte{':'}) + _, _ = hash.Write([]byte(part)) + } + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/qualitygraph/projection_test.go b/qualitygraph/projection_test.go new file mode 100644 index 0000000..48040a3 --- /dev/null +++ b/qualitygraph/projection_test.go @@ -0,0 +1,131 @@ +package qualitygraph_test + +import ( + "encoding/json" + "strings" + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/inspect" + "github.com/GrayCodeAI/inspect/qualitygraph" +) + +func TestBuildPrivacySafeDeterministicProjection(t *testing.T) { + t.Parallel() + + observedAt := time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) + report := &inspect.Report{ + Target: "https://private.example", + CrawledURLs: 2, + Duration: 1500 * time.Millisecond, + FailOn: inspect.SeverityMedium, + Stats: inspect.Stats{ + PagesScanned: 2, + FindingsTotal: 1, + }, + Findings: []inspect.Finding{{ + Check: "security", + Severity: inspect.SeverityHigh, + URL: "https://private.example/admin?token=secret", + Element: `