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
2 changes: 2 additions & 0 deletions docs/01-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ fastrecon --targets-url https://internal/hosts --targets-header "Authorization:
the value), and a redirect that would carry them to another host or down to `http` is
refused.
- `run.input` is `targets`, `sources` is empty, and `stats.enumerated` counts the list.
- `fastrecon serve` takes the same list as `targets` in the request body — see
[Deployment](04-deployment.md#fastrecon-serve).

Background: [Why a target list exists](05-design.md#why-a-target-list-exists).

Expand Down
25 changes: 20 additions & 5 deletions docs/04-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,26 @@ curl -X POST localhost:8080/run \
-d '{"domain":"example.com","stages":"enum"}'
```

`POST /run` takes a `domain` and, optionally, `exclude`, `stages`, `ports` and `timeout`;
anything omitted comes from the environment. A request's `exclude` is **added** to the
deployment's exclusions, never substituted, so a caller cannot widen the scope. The response
is the [report](03-output.md#report-shape). `GET /healthz` needs no token, for platform
probes.
`POST /run` takes a `domain` or a `targets` list and, optionally, `exclude`, `stages`,
`ports` and `timeout`; anything omitted comes from the environment. The response is the
[report](03-output.md#report-shape). `GET /healthz` needs no token, for platform probes.

| Field | Meaning |
|---|---|
| `domain` | the root to enumerate; required without `targets`, an optional report label with it |
| `targets` | bare hosts to scan instead of enumerating — the CLI's [`--targets`](01-usage.md#scanning-a-known-list) |
| `exclude` | patterns **added** to the deployment's exclusions, never substituted, so a caller cannot widen the scope |
| `stages` | `enum`, `resolve`, `ports` or `full` |
| `ports` | port expression, as `--ports` |
| `timeout` | Go duration, as `--timeout` |

```json
{"targets": ["api.example.com", "www.example.net"], "stages": "resolve", "timeout": "30m"}
```

A `targets` list goes through the CLI's loader: a host with a scheme, a path or a port, an
empty list, or a body over 1 MiB is a `400`, and nothing is scanned. Exclusions still apply
to it, and a list configured on the deployment never runs in its place.

| Status | Meaning |
|---|---|
Expand Down
4 changes: 4 additions & 0 deletions docs/05-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ statistics (verified in the engine's source). Hence:
- **Exclusions only add up**: a request's `exclude` extends the deployment's, so it cannot
lift a scope guard.

What a request *may* set is the target — a `domain` or a `targets` list — and how far to
take it. A list goes through the CLI's loader before the run takes the instance, so a bad
entry is refused whole rather than scanned in part.

Rejected: forking the engine to keep keys per run — forty-seven source integrations to
maintain, for what a mutex solves.

Expand Down
36 changes: 31 additions & 5 deletions internal/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,19 @@ import (

"github.com/JoshuaMart/FastRecon/internal/app"
"github.com/JoshuaMart/FastRecon/internal/config"
"github.com/JoshuaMart/FastRecon/internal/enumerate"
"github.com/JoshuaMart/FastRecon/internal/report"
"github.com/JoshuaMart/FastRecon/internal/secrets"
"github.com/JoshuaMart/FastRecon/internal/stage"
)

const (
// maxBodyBytes bounds a request body: a handful of fields, so anything
// larger is a mistake or an attempt to exhaust the instance.
maxBodyBytes = 64 << 10
// maxBodyBytes bounds a request body: a handful of fields and at most a
// target list, so anything larger is a mistake or an attempt to exhaust
// the instance. It is a refusal, not a truncation: a list cut short would
// turn hosts that were never queried into hosts that did not answer. A
// list this size is far past what fits in one function's timeout.
maxBodyBytes = 1 << 20
// busyRetryAfter is what a caller is told to wait when a run holds the
// instance.
busyRetryAfter = 30 * time.Second
Expand Down Expand Up @@ -125,8 +129,12 @@ func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
// engine holds credentials in process-global state, and a caller-supplied
// destination would turn the function into a request forwarder onto its own
// network.
//
// Targets is the CLI's --targets: the run scans exactly these hosts instead of
// enumerating, and Domain becomes an optional label for the report.
type runRequest struct {
Domain string `json:"domain"`
Targets []string `json:"targets,omitempty"`
Exclude []string `json:"exclude,omitempty"`
Stages string `json:"stages,omitempty"`
Ports string `json:"ports,omitempty"`
Expand Down Expand Up @@ -155,7 +163,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
return
}

runCfg, err := s.runConfig(req)
runCfg, err := s.runConfig(r.Context(), req)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return
Expand Down Expand Up @@ -234,10 +242,28 @@ func decodeRequest(w http.ResponseWriter, r *http.Request) (runRequest, error) {

// runConfig overlays the request onto the process configuration and validates
// the result with the same rules the command line uses.
func (s *Server) runConfig(req runRequest) (*config.Config, error) {
func (s *Server) runConfig(ctx context.Context, req runRequest) (*config.Config, error) {
cfg := s.app.Config().Clone()

cfg.Domain = req.Domain
// The request says what to scan, so a list configured on the deployment
// does not run in its place or merge into it.
cfg.Targets, cfg.TargetsFile, cfg.TargetsURL, cfg.TargetsHeader = nil, "", "", nil
// Present but empty is refused rather than read as "no list": it is a
// caller whose list came out empty, not one asking to enumerate.
if req.Targets != nil {
// Checked here, with the loader the command line uses, so a bad list
// is a 400 before the run takes the instance.
hosts, err := enumerate.LoadTargets(ctx, enumerate.TargetOptions{
Inline: req.Targets,
Redactor: s.app.Redactor(),
Logger: s.log,
})
if err != nil {
return nil, err
}
cfg.Targets = hosts
}
// Added to the deployment's exclusions, never in place of them: those
// include the exclude file, and a request that could drop them could
// send the run onto hosts the deployment declared out of scope.
Expand Down
102 changes: 102 additions & 0 deletions internal/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,108 @@ func TestOversizedBodyIsRejected(t *testing.T) {
}
}

// A target list runs through the real stage wiring: the same loader and
// enumerator the command line uses, with no source queried.
func TestTargetsReplaceEnumeration(t *testing.T) {
cfg := baseConfig(t)
cfg.Exclude = []string{"admin.example.com"}
a, err := app.New(cfg, discardLogger())
if err != nil {
t.Fatalf("app.New: %v", err)
}
s, err := New(a, &config.Config{APIToken: "secret"}, discardLogger())
if err != nil {
t.Fatalf("New: %v", err)
}

rec := post(t, s, "secret", `{"targets":["WWW.example.com","api.example.net.","admin.example.com","old.example.com"],"exclude":["old.example.com"],"stages":"enum"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body)
}
var got report.Report
if err := json.Unmarshal(bytes.TrimSpace(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("response is not a report: %v", err)
}
if got.Run.Input != report.InputTargets {
t.Errorf("input = %q, want %q", got.Run.Input, report.InputTargets)
}
if len(got.Sources) != 0 {
t.Errorf("sources = %v, want none queried", got.Sources)
}
if got.Run.Domain != "" {
t.Errorf("domain = %q, want no label when none was given", got.Run.Domain)
}
if got.Stats.Enumerated != 4 || got.Stats.Excluded != 2 {
t.Errorf("stats = %+v, want 4 supplied and 2 excluded", got.Stats)
}
var names []string
for _, h := range got.Hosts {
names = append(names, h.Host)
}
// The deployment's exclusion and the request's both apply to the list.
if !slices.Equal(names, []string{"api.example.net", "www.example.com"}) {
t.Errorf("hosts = %v, want the list normalized and both exclusions applied", names)
}
}

func TestTargetsOverlayOntoTheProcessConfiguration(t *testing.T) {
runner := &fakeRunner{cfg: baseConfig(t)}
runner.cfg.TargetsFile = "/deployment/list.txt"
s := newTestServer(t, runner)

rec := post(t, s, "secret", `{"domain":"Example.COM","targets":["b.example.com","a.example.com"],"stages":"resolve"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body)
}
got := runner.lastCfg
if !slices.Equal(got.Targets, []string{"a.example.com", "b.example.com"}) || got.TargetsFile != "" {
t.Errorf("targets = %v / %q, want only the request's list", got.Targets, got.TargetsFile)
}
if got.Domain != "example.com" {
t.Errorf("domain = %q, want the label normalized", got.Domain)
}
if len(runner.cfg.Targets) != 0 {
t.Errorf("the process configuration was mutated: %v", runner.cfg.Targets)
}
}

// A list is scanned whole or not at all: a host dropped for its formatting is
// a host never queried, and nothing downstream would say so.
func TestInvalidTargets(t *testing.T) {
runner := &fakeRunner{}
s := newTestServer(t, runner)

cases := map[string]string{
"empty list": `{"targets":[]}`,
"scheme": `{"targets":["a.example.com","https://b.example.com"]}`,
"path": `{"targets":["a.example.com/login"]}`,
"port": `{"targets":["a.example.com:8443"]}`,
"single label": `{"targets":["localhost"]}`,
"bad domain label": `{"domain":"https://example.com","targets":["a.example.com"]}`,
"not a list": `{"targets":"a.example.com"}`,
}
for name, body := range cases {
rec := post(t, s, "secret", body)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400 (body %s)", name, rec.Code, rec.Body)
}
}

var b strings.Builder
b.WriteString(`{"targets":["a.example.com"`)
for i := 0; b.Len() <= maxBodyBytes; i++ {
fmt.Fprintf(&b, `,"host-%d.example.com"`, i)
}
b.WriteString(`]}`)
if rec := post(t, s, "secret", b.String()); rec.Code != http.StatusBadRequest {
t.Errorf("oversized list: status = %d, want 400", rec.Code)
}

if runner.calls != 0 {
t.Errorf("an invalid list reached the pipeline %d time(s)", runner.calls)
}
}

// Refused rather than queued, so a request does not spend its deadline waiting.
func TestSecondConcurrentRunIsRefused(t *testing.T) {
runner := &fakeRunner{block: make(chan struct{})}
Expand Down
Loading