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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
#
# Runtime stays on golang so Scythe.embedded can `go build` at request time, but we only
# ship the binary + Scythe sources + Operator AI skill files (not the full vendor/ source tree).
FROM golang:1.25.12-bookworm AS builder
FROM golang:1.25.13-bookworm AS builder

ARG TARGETOS=linux
ARG TARGETARCH
Expand Down Expand Up @@ -45,7 +45,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
go build -mod=mod -trimpath -ldflags="-s -w" -o /out/ReaperC2 .; \
fi

FROM golang:1.25.12-bookworm
FROM golang:1.25.13-bookworm

# Refresh OS packages to pick up Debian security updates in the golang base image.
RUN apt-get update \
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.pack
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
# Package a host-built ReaperC2 binary (see `make build-binaries`). No `go build` in Docker.
FROM golang:1.25.12-bookworm AS pack
FROM golang:1.25.13-bookworm AS pack

ARG TARGETARCH
ARG SCYTHE_GIT_REF=main
Expand All @@ -24,7 +24,7 @@ RUN set -eux; \
fi; \
test -f third_party/Scythe/go.mod

FROM golang:1.25.12-bookworm
FROM golang:1.25.13-bookworm

RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
Expand Down
1 change: 1 addition & 0 deletions deployments/k8s/ollama.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ metadata:
spec:
accessModes:
- ReadWriteOnce
storageClassName: gp2
resources:
requests:
storage: 20Gi
Expand Down
4 changes: 2 additions & 2 deletions docs/operator-guide-users.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
**Path:** `/users`
**Requires:** Admin role

Create portal accounts and assign **Admin** or **Operator**. Enable/disable accounts (disabled users cannot sign in). Admins cannot disable themselves from the list actions.
Create portal accounts and assign **Admin** or **Operator**. Change an existing user’s role with the **Role** dropdown in the accounts table (admins only; you cannot change your own role). Enable/disable accounts (disabled users cannot sign in). Admins cannot disable themselves from the list actions.

API: `POST /api/users` and related user management endpoints. Operators do not see this page.
API: `POST /api/users`, `PATCH /api/users/{username}` with `{ "role": "admin" | "operator" }` and/or `{ "disabled": true | false }`. Operators do not see this page.
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module ReaperC2

go 1.25.0

toolchain go1.25.12
toolchain go1.25.13

require (
github.com/aws/aws-sdk-go-v2 v1.41.7
Expand All @@ -14,7 +14,7 @@ require (
github.com/gorilla/mux v1.8.1
github.com/pquerna/otp v1.5.0
github.com/yuin/goldmark v1.7.17
go.mongodb.org/mongo-driver v1.17.3
go.mongodb.org/mongo-driver v1.17.7
golang.org/x/crypto v0.54.0
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU=
go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
Expand Down
6 changes: 5 additions & 1 deletion pkg/adminpanel/ai_drawer.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,12 @@ func operatorAIDrawerScript(hasEngagement bool) string {
if (r.ok && j.reply) {
var label = j.provider ? (' [' + j.provider + (j.model ? ' · ' + j.model : '') + ']') : '';
history.push({ role: 'assistant', content: j.reply + label });
} else if (j && j.error) {
history.push({ role: 'assistant', content: j.error });
} else if (r.ok) {
history.push({ role: 'assistant', content: 'Model returned an empty reply. Try again or choose another model.' });
} else {
history.push({ role: 'assistant', content: (j && j.error) ? j.error : (r.status + ' ' + r.statusText) });
history.push({ role: 'assistant', content: r.status + ' ' + r.statusText });
}
} catch (e) {
history = history.filter(function(m) { return !m.thinking; });
Expand Down
2 changes: 2 additions & 0 deletions pkg/adminpanel/ghostwriter_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ func ghostwriterUserContext(action string) string {
return "Admin | user disabled"
case dbconnections.AuditActionUserEnabled:
return "Admin | user enabled"
case dbconnections.AuditActionUserRoleUpdated:
return "Admin | user role updated"
case dbconnections.AuditActionEngagementOperatorsUpdated:
return "Admin | engagement operators updated"
case dbconnections.AuditActionAIChat:
Expand Down
119 changes: 104 additions & 15 deletions pkg/adminpanel/handlers_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,31 @@ func (s *Server) handleUsersPage(w http.ResponseWriter, r *http.Request) {
rows.WriteString("<tr><td>")
rows.WriteString(template.HTMLEscapeString(op.Username))
rows.WriteString("</td><td>")
rows.WriteString(template.HTMLEscapeString(rn))
if op.Username == u {
rows.WriteString(template.HTMLEscapeString(rn))
} else {
rows.WriteString(`<select class="user-role-select" data-username="`)
rows.WriteString(template.HTMLEscapeString(op.Username))
rows.WriteString(`" aria-label="Role for `)
rows.WriteString(template.HTMLEscapeString(op.Username))
rows.WriteString(`">`)
for _, rv := range []string{dbconnections.RoleOperator, dbconnections.RoleAdmin} {
rows.WriteString(`<option value="`)
rows.WriteString(rv)
rows.WriteString(`"`)
if rn == rv {
rows.WriteString(` selected`)
}
rows.WriteString(`>`)
if rv == dbconnections.RoleAdmin {
rows.WriteString("Admin")
} else {
rows.WriteString("Operator")
}
rows.WriteString(`</option>`)
}
rows.WriteString(`</select>`)
}
rows.WriteString("</td><td>")
rows.WriteString(template.HTMLEscapeString(st))
rows.WriteString("</td><td>")
Expand Down Expand Up @@ -95,7 +119,7 @@ func (s *Server) handleUsersPage(w http.ResponseWriter, r *http.Request) {
selfQuoted, _ := json.Marshal(u)
body := `
<h1>Users</h1>
<p class="muted">Create portal accounts. <strong>Disabled</strong> users cannot sign in; their sessions end immediately. You cannot disable yourself. <strong>Admin</strong> may manage users and <strong>All logs</strong>; <strong>Operator</strong> may use beacons, commands, reports, topology, chat, and <strong>Engagement logs</strong> for the selected engagement.</p>
<p class="muted">Create portal accounts and change roles. <strong>Disabled</strong> users cannot sign in; their sessions end immediately. You cannot disable yourself or change your own role. <strong>Admin</strong> may manage users and <strong>All logs</strong>; <strong>Operator</strong> may use beacons, commands, reports, topology, chat, and <strong>Engagement logs</strong> for the selected engagement.</p>
<div class="card">
<h2>Create user</h2>
<label>Username</label>
Expand Down Expand Up @@ -131,32 +155,48 @@ document.getElementById('createu').onclick = async function() {
out.textContent = r.ok ? JSON.stringify(j, null, 2) : (j.error || r.statusText);
};
document.getElementById('refusers').onclick = function() { location.reload(); };
async function patchUser(username, disabled) {
async function patchUser(username, body) {
var r = await fetch('/api/users/' + encodeURIComponent(username), {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled: disabled })
body: JSON.stringify(body)
});
var j = await r.json().catch(function() { return {}; });
if (!r.ok) { alert(j.error || r.statusText); return; }
location.reload();
if (!r.ok) { alert(j.error || r.statusText); return false; }
return true;
}
document.querySelectorAll('[data-disable]').forEach(function(btn) {
btn.onclick = function() {
btn.onclick = async function() {
var name = btn.getAttribute('data-disable');
if (!name || name === window.__USERS_SELF__) return;
if (!confirm('Disable ' + name + '? They will be signed out and cannot log in until re-enabled.')) return;
patchUser(name, true);
if (await patchUser(name, { disabled: true })) location.reload();
};
});
document.querySelectorAll('[data-enable]').forEach(function(btn) {
btn.onclick = function() {
btn.onclick = async function() {
var name = btn.getAttribute('data-enable');
if (!name) return;
patchUser(name, false);
if (await patchUser(name, { disabled: false })) location.reload();
};
});
document.querySelectorAll('.user-role-select').forEach(function(sel) {
var initial = sel.value;
sel.addEventListener('change', async function() {
var name = sel.getAttribute('data-username');
var role = sel.value;
if (!name || name === window.__USERS_SELF__) { sel.value = initial; return; }
var label = role === 'admin' ? 'Admin' : 'Operator';
if (!confirm('Change ' + name + ' to ' + label + '?')) { sel.value = initial; return; }
if (await patchUser(name, { role: role })) {
initial = role;
location.reload();
} else {
sel.value = initial;
}
});
});
</script>`
s.writeAppPage(w, u, role, "users", "Users", body, nil)
}
Expand Down Expand Up @@ -240,7 +280,7 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
return
}
target := strings.TrimSpace(mux.Vars(r)["username"])
if target == "" {
if target == "" || !isValidUsername(target) {
jsonError(w, http.StatusBadRequest, "username required")
return
}
Expand All @@ -249,13 +289,14 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
return
}
var req struct {
Disabled *bool `json:"disabled"`
Disabled *bool `json:"disabled"`
Role *string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Disabled == nil {
if req.Disabled == nil && req.Role == nil {
jsonError(w, http.StatusBadRequest, "no changes")
return
}
Expand All @@ -270,7 +311,54 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusInternalServerError, "lookup failed")
return
}
if *req.Disabled && isAdmin(effectivePortalRole(targetOp)) {
resp := map[string]interface{}{"ok": true, "username": target}
currentRole := effectivePortalRole(targetOp)

if req.Role != nil {
newRole := strings.ToLower(strings.TrimSpace(*req.Role))
if newRole != dbconnections.RoleAdmin && newRole != dbconnections.RoleOperator {
jsonError(w, http.StatusBadRequest, "role must be admin or operator")
return
}
if newRole != currentRole {
if currentRole == dbconnections.RoleAdmin && newRole == dbconnections.RoleOperator && !dbconnections.OperatorIsDisabled(targetOp) {
n, err := dbconnections.CountActiveAdminsExcluding(ctx, target)
if err != nil {
log.Printf("admin: count active admins: %v", err)
jsonError(w, http.StatusInternalServerError, "failed")
return
}
if n == 0 {
jsonError(w, http.StatusBadRequest, "cannot demote the last active administrator")
return
}
}
if err := dbconnections.SetOperatorRole(ctx, target, newRole); err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
jsonError(w, http.StatusNotFound, "user not found")
return
}
jsonError(w, http.StatusInternalServerError, "failed to update role")
return
}
if aerr := dbconnections.InsertAuditLog(ctx, adminUser, dbconnections.AuditActionUserRoleUpdated, bson.M{
"target_username": target,
"old_role": currentRole,
"new_role": newRole,
}, ""); aerr != nil {
log.Printf("admin: audit user role: %v", aerr)
}
currentRole = newRole
resp["role"] = newRole
}
}

if req.Disabled == nil {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
if *req.Disabled && currentRole == dbconnections.RoleAdmin && !dbconnections.OperatorIsDisabled(targetOp) {
n, err := dbconnections.CountActiveAdminsExcluding(ctx, target)
if err != nil {
log.Printf("admin: count active admins: %v", err)
Expand Down Expand Up @@ -299,8 +387,9 @@ func (s *Server) handleAPIUserByUsername(w http.ResponseWriter, r *http.Request)
if aerr := dbconnections.InsertAuditLog(ctx, adminUser, action, bson.M{"target_username": target}, ""); aerr != nil {
log.Printf("admin: audit user enable/disable: %v", aerr)
}
resp["disabled"] = *req.Disabled
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "username": target, "disabled": *req.Disabled})
_ = json.NewEncoder(w).Encode(resp)
}

func isValidUsername(s string) bool {
Expand Down
61 changes: 58 additions & 3 deletions pkg/ai/openai_compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ func chatOpenAICompatible(ctx context.Context, cfg ProviderSettings, system stri

var parsed struct {
Choices []struct {
Message struct {
Content string `json:"content"`
FinishReason string `json:"finish_reason"`
Message struct {
Content json.RawMessage `json:"content"`
Refusal *string `json:"refusal"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Expand All @@ -77,7 +79,60 @@ func chatOpenAICompatible(ctx context.Context, cfg ProviderSettings, system stri
if len(parsed.Choices) == 0 {
return "", fmt.Errorf("%s: empty response", cfg.Label)
}
return strings.TrimSpace(parsed.Choices[0].Message.Content), nil
choice := parsed.Choices[0]
if choice.Message.Refusal != nil {
if s := strings.TrimSpace(*choice.Message.Refusal); s != "" {
return "", fmt.Errorf("%s: model refused: %s", cfg.Label, s)
}
}
content, err := extractOpenAIMessageContent(choice.Message.Content)
if err != nil {
return "", fmt.Errorf("parse %s response content: %w", cfg.Label, err)
}
if content == "" {
return "", fmt.Errorf("%s: %s", cfg.Label, openAICompatEmptyReplyError(choice.FinishReason, useAzureOpenAICompatMaxCompletion(cfg)))
}
return content, nil
}

// extractOpenAIMessageContent handles OpenAI-compatible message content as a string or
// an array of {type,text} parts (Azure GPT-5 / newer SKUs may return the latter).
func extractOpenAIMessageContent(raw json.RawMessage) (string, error) {
if len(raw) == 0 || string(raw) == "null" {
return "", nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s), nil
}
var parts []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &parts); err == nil {
var out []string
for _, p := range parts {
if t := strings.TrimSpace(p.Text); t != "" {
out = append(out, t)
}
}
return strings.TrimSpace(strings.Join(out, "\n")), nil
}
return "", fmt.Errorf("unexpected content shape")
}

func openAICompatEmptyReplyError(finishReason string, azureReasoning bool) string {
switch strings.ToLower(strings.TrimSpace(finishReason)) {
case "length":
if azureReasoning {
return "model returned no visible text (output token budget exhausted; reasoning tokens count toward max_completion_tokens). Increase REAPER_AI_MAX_TOKENS or retry with a shorter prompt"
}
return "model returned no visible text (output token budget exhausted). Increase REAPER_AI_MAX_TOKENS or retry with a shorter prompt"
case "content_filter":
return "response blocked by content filter"
default:
return "empty message content"
}
}

// useAzureOpenAICompatMaxCompletion is true for Azure AI Foundry / Azure OpenAI inference
Expand Down
Loading
Loading