fix comments sweep

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-10-02 12:14:35 +02:00
1 parent c6bd7b4102
commit 185e07c42d
34 files changed
+157 -157

No files matched your search

+1 -1
View File
@@ -62,7 +62,7 @@ func setupCertMagic(
return nil
}
// allow on-demand ACME only for domains that use managed TLS (let's encrypt / acme).
// own_managed_tls and self_signed_tls domains must never trigger ACME acquisition —
// own_managed_tls and self_signed_tls domains must never trigger ACME acquisition,
// their certificates are provided manually or generated internally.
res := db.
Select("id").
+5 -5
View File
@@ -85,7 +85,7 @@ const (
ROUTE_V1_COMPANY_REPORT_CONFIG_LOG = "/api/v1/company/report-config/:companyID/log"
ROUTE_V1_REPORT_CONFIG_GLOBAL = "/api/v1/report-config"
ROUTE_V1_REPORT_CONFIG_SEND = "/api/v1/report-config/send/:id"
// scim v2 provisioning endpoints (public — authenticated via bearer token)
// scim v2 provisioning endpoints (public, authenticated via bearer token)
ROUTE_SCIM_V2_SERVICE_PROVIDER_CONFIG = "/api/v1/scim/v2/:companyID/ServiceProviderConfig"
ROUTE_SCIM_V2_RESOURCE_TYPES = "/api/v1/scim/v2/:companyID/ResourceTypes"
ROUTE_SCIM_V2_SCHEMAS = "/api/v1/scim/v2/:companyID/Schemas"
@@ -312,7 +312,7 @@ func setupRoutes(
controllers *Controllers,
middleware *Middlewares,
) *gin.Engine {
// SCIM v2 provisioning endpoints are NOT served here — they live on the
// SCIM v2 provisioning endpoints are NOT served here, they live on the
// phishing server (app/server.go AssignRoutes), gated to a single global
// domain, so the admin port does not need public exposure for SCIM.
@@ -548,7 +548,7 @@ func setupRoutes(
POST(ROUTE_V1_CAMPAIGN_ANONYMIZE_DATA, middleware.SessionHandler, controllers.Campaign.AnonymizeDataByID).
DELETE(ROUTE_V1_CAMPAIGN_DEVICE_CODES, middleware.SessionHandler, controllers.Campaign.DeleteDeviceCodesByCampaignID).
DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID).
// campaign PDF report — ExtendedTimeout required for headless browser rendering
// campaign PDF report, ExtendedTimeout required for headless browser rendering
GET(ROUTE_V1_CAMPAIGN_REPORT, middleware.ExtendedTimeout(3*time.Minute), middleware.SessionHandler, controllers.ReportTemplate.GeneratePDFByCampaignID).
// report templates
GET(ROUTE_V1_REPORT_TEMPLATE, middleware.SessionHandler, controllers.ReportTemplate.GetAll).
@@ -835,7 +835,7 @@ func (a *administrationServer) loadEmbeddedFileSystem(
embedFS := frontend.GetEmbededFS()
// make embedded .html work
frontend.LoadHTMLFromEmbedFS(a.router, *embedFS, "build/*.html")
// serve favicons only to authenticated users — unauthenticated requests get 404
// serve favicons only to authenticated users, unauthenticated requests get 404
// so the file is not indexable by scanners probing common paths
for _, faviconPath := range []string{"/favicon.ico", "/favicon.png"} {
fp := faviconPath
@@ -859,7 +859,7 @@ func (a *administrationServer) loadEmbeddedFileSystem(
})
continue
}
// skip favicons — registered separately with session gating
// skip favicons, registered separately with session gating
if path == "favicon.png" || path == "favicon.ico" {
continue
}
+1 -1
View File
@@ -1664,7 +1664,7 @@ func (s *Server) checkAndServePhishingPage(
// expects when the request arrives at the proxy.
urlParam := cTemplate.URLIdentifier.Name.MustGet()
// collect rewrite_urls rules for the start domain — host-specific first, then global
// collect rewrite_urls rules for the start domain, host-specific first, then global
var rewriteRules []service.ProxyServiceURLRewriteRule
if hostCfg, ok := parsedConfig.Hosts[startDomain]; ok && hostCfg != nil {
rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...)
+7 -7
View File
@@ -102,7 +102,7 @@ func (a *activeSession) setBrowserPage(page *rod.Page) {
// interest in it. get returns the latest frame (nil until the first arrives); release
// drops interest and stops the screencast when the last consumer leaves. Fixed params
// (native-resolution ceiling, quality 90) so every consumer sees identical frames
// regardless of who else is watching — this is what keeps a victim stream invariant to
// regardless of who else is watching, this is what keeps a victim stream invariant to
// operator presence.
func (a *activeSession) scAcquire(page *rod.Page) (get func() *scFrame, release func()) {
tid := page.TargetID
@@ -532,7 +532,7 @@ func (m *RemoteBrowserController) RunByID(g *gin.Context) {
}
}()
// Drain StreamCh — test runner doesn't serve cropped streams.
// Drain StreamCh, test runner doesn't serve cropped streams.
go func() {
for range runner.StreamCh {
}
@@ -698,7 +698,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
}
sess.victimConnected.Store(true)
// One active session per campaign recipient — cancel any previous one.
// One active session per campaign recipient, cancel any previous one.
// Exception: if the previous session is in keepAlive state the script has
// parked and is waiting for operator takeover; cancelling it would destroy
// a live browser the operator may be about to use. In that case put the
@@ -714,7 +714,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
}
defer func() {
// For keepAlive sessions the runner is still parked waiting for the
// operator — do not cancel or remove it here. CloseLiveSession handles
// operator, do not cancel or remove it here. CloseLiveSession handles
// cleanup when the operator explicitly ends the session.
if !sess.isKeepAlive.Load() {
m.RemoteBrowserService.CompareAndDeleteSession(crIDStr, sess)
@@ -785,7 +785,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) {
case runner.Incoming <- remotebrowser.IncomingMsg{Event: "disconnect"}:
default:
}
// keepAlive: browser is parked for operator takeover — a victim
// keepAlive: browser is parked for operator takeover, a victim
// disconnect must not kill the session, the operator still needs it.
if !sess.isKeepAlive.Load() {
cancel()
@@ -1569,7 +1569,7 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
// Editor test runs only: size the target to the admin's own resolution
// so remote control is pixel-accurate instead of the headless 800x600
// default. Gated on isTest so a live recipient's viewport is never
// touched here — the victim owns the shared target's size, and resizing
// touched here, the victim owns the shared target's size, and resizing
// it under an operator would change what the recipient sees.
if controlMode && sess.isTest && header.Width > 0 && header.Height > 0 {
if p := getActivePage(); p != nil {
@@ -2228,7 +2228,7 @@ func (m *RemoteBrowserController) runNamedStream(
// Subscribe to the session's shared screencast for this page. Victim frames are
// always cropped from this one screencast, so the stream is identical whether or not
// an operator is also viewing — no operator-presence leak. releaseSC stops the
// an operator is also viewing, no operator-presence leak. releaseSC stops the
// screencast once this and every other consumer of the target has gone.
getFrame, releaseSC := sess.scAcquire(page)
defer releaseSC()
+2 -2
View File
@@ -430,7 +430,7 @@ func (c *Scim) DeleteGroup(g *gin.Context) {
return
}
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
// rfc 7644 §3.6 — successful DELETE returns 204 No Content
// rfc 7644 §3.6, successful DELETE returns 204 No Content
g.Status(http.StatusNoContent)
}
@@ -634,7 +634,7 @@ func (c *Scim) DeleteUser(g *gin.Context) {
return
}
go c.ScimService.UpdateLastSync(context.Background(), result.Config)
// RFC 7644 §3.6 — successful DELETE returns 204 No Content
// RFC 7644 §3.6, successful DELETE returns 204 No Content
g.Status(http.StatusNoContent)
}
+1 -1
View File
@@ -85,7 +85,7 @@
streamLastStart[m.name] = m;
var st = streams[m.name];
if (st) {
// Stream already mounted — this is a resize/reposition update only.
// Stream already mounted, this is a resize/reposition update only.
// Do NOT re-fire user handlers; that would call mountStream() again
// and create duplicate canvases.
applyStreamStart(st, m);
+1 -1
View File
@@ -112,7 +112,7 @@ func (r *KeyRateLimiter) GetLimiter(key string) *rate.Limiter {
entry.lastAccess.Store(time.Now().UnixNano())
// LoadOrStore atomically either stores our new entry or returns the
// existing one — correctly handles both the common case and concurrent
// existing one, correctly handles both the common case and concurrent
// goroutines racing to create an entry for the same key
actual, loaded := r.key.LoadOrStore(key, entry)
if loaded {
+1 -1
View File
@@ -98,7 +98,7 @@ func handleAPISession(
found := false
// compare each key using constant-time comparison to avoid hash timing leaks.
// early exit via break is acceptable here because the key slice is derived from
// a go map, which randomizes iteration order on every call — an attacker cannot
// a go map, which randomizes iteration order on every call, an attacker cannot
// correlate iteration count to a stable key position across requests.
var rApiUser *model.APIUser
for _, apiUser := range apiUsers {
+5 -5
View File
@@ -2811,7 +2811,7 @@ func (m *ProxyHandler) rewriteRuleMatchesRequest(rule service.ProxyServiceReplac
}
if rule.Path != "" {
if rule.PathRe == nil {
// path was set but failed to compile — skip rule safely
// path was set but failed to compile, skip rule safely
return false
}
if !rule.PathRe.MatchString(req.URL.Path) {
@@ -2889,7 +2889,7 @@ func (m *ProxyHandler) applyReplacementWithVariables(body []byte, replacement se
case "dom":
return m.applyDomReplacement(body, interpolatedReplacement, sessionID, contentType)
case "header":
// header engine operates directly on http.Header — it cannot be used in a body rewrite context.
// header engine operates directly on http.Header, it cannot be used in a body rewrite context.
// callers that handle headers (applyCustomResponseHeaderReplacementsWithVariables,
// applyCustomResponseHeaderReplacementsWithoutSession, applyEarlyRequestHeaderReplacements)
// intercept this engine before reaching here.
@@ -3840,7 +3840,7 @@ func (m *ProxyHandler) buildCampaignFlowRedirectURL(session *service.ProxySessio
}
// createCampaignInfoEvent saves captured data as a low-priority info event instead of a submit event.
// The capture still participates in completion tracking and flow progression normally — only the
// The capture still participates in completion tracking and flow progression normally, only the
// saved event type differs.
func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, capturedData map[string]interface{}, req *http.Request, originalUserAgent string) {
if session.CampaignID == nil || session.CampaignRecipientID == nil {
@@ -4785,7 +4785,7 @@ func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *Reque
}
// build the post-evasion redirect url using the start url as the source of truth for
// path and query params — the incoming request only has the campaign id param, not the
// path and query params, the incoming request only has the campaign id param, not the
// real start url params (client_id, redirect_uri etc.). apply rewrite_urls rules so the
// victim sees the friendly path and remapped param names, not the real ones.
var startPath string
@@ -4805,7 +4805,7 @@ func (m *ProxyHandler) checkAndServeEvasionPage(req *http.Request, reqCtx *Reque
startQuery = url.Values{}
}
// collect rewrite_urls rules — host-specific first, then global
// collect rewrite_urls rules, host-specific first, then global
var rewriteRules []service.ProxyServiceURLRewriteRule
if hostCfg, ok := reqCtx.ProxyConfig.Hosts[reqCtx.TargetDomain]; ok && hostCfg != nil {
rewriteRules = append(rewriteRules, hostCfg.RewriteURLs...)
+4 -4
View File
@@ -82,7 +82,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
// frameCtxs tracks execution contexts for same-process sub-frames, keyed by context ID.
// Value is [3]string{frameId, origin, name}. Populated on demand by resolveSameOriginFrames
// via Page.createIsolatedWorld (not Runtime execution-context events, which would enable
// the Runtime domain — a CDP tell). frameWorlds caches which frame already has a world so
// the Runtime domain, a CDP tell). frameWorlds caches which frame already has a world so
// we don't recreate one every scan; navigation invalidates the entry.
var frameCtxs sync.Map // proto.RuntimeExecutionContextID → [3]string{frameId, origin, name}
var frameWorlds sync.Map // frameId string → proto.RuntimeExecutionContextID
@@ -125,7 +125,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
// IMPORTANT (opsec): do NOT subscribe to any Runtime.* events here. rod auto-enables
// a domain for every event type passed to EachEvent, and enabling the Runtime domain
// is a detectable CDP tell — the console/Error.stack serialization leak that trips
// is a detectable CDP tell, the console/Error.stack serialization leak that trips
// isAutomatedWithCDP. We only track OOPIF targets (Target.*, no such leak); same-origin
// sub-frame contexts are resolved on demand via Page.createIsolatedWorld instead.
waitFrameEvt := page.EachEvent(
@@ -151,7 +151,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
return false
},
// Page.* is safe to subscribe to (no Runtime-enable tell). On navigation a frame's
// isolated world is destroyed, so drop the cache entry — resolveSameOriginFrames
// isolated world is destroyed, so drop the cache entry, resolveSameOriginFrames
// recreates it on the next scan.
func(e *proto.PageFrameNavigated) bool {
if e.Frame == nil {
@@ -176,7 +176,7 @@ func RegisterBrowserBindings(vm *goja.Runtime, pc *goja.Object, page *rod.Page,
// same-process child frame (Page.createIsolatedWorld). This replaces the old Runtime
// execution-context event tracking, which enabled the Runtime domain (a CDP tell).
// OOPIF frames live in another process, so createIsolatedWorld fails for them and they
// are skipped — those are scanned separately via framePages. Worlds are cached per
// are skipped, those are scanned separately via framePages. Worlds are cached per
// frame (frameWorlds) and invalidated on navigation, so scanning does not churn worlds.
// Throttled so a tight poll loop issues at most one getFrameTree per interval.
var lastFrameResolve time.Time
+12 -12
View File
@@ -512,8 +512,8 @@ func (r *Runner) Run(ctx context.Context) error {
vm := goja.New()
// Interrupt the JS VM on termination. Two cases:
// DeadlineExceeded — real timeout: interrupt immediately.
// Canceled — either keepAlive() cancelled the script timeout to
// DeadlineExceeded, real timeout: interrupt immediately.
// Canceled , either keepAlive() cancelled the script timeout to
// park the session, or the operator cancelled. Either
// way wait for the outer context so we only interrupt
// when the operator actually ends the session.
@@ -1054,16 +1054,16 @@ func (r *Runner) Run(ctx context.Context) error {
}
// Note: console.* are deliberately left native. Replacing them with noop
// functions (to suppress page logging) makes console.log.toString() non-native,
// which trips TamperedFunctions. The console is not captured anyway — we never
// which trips TamperedFunctions. The console is not captured anyway, we never
// enable the Runtime domain (see the opsec note in browser.go), so no page
// console output reaches the server regardless.
}
// Identity handling differs by mode:
// local — the UA is already set via the --user-agent launch flag above,
// local , the UA is already set via the --user-agent launch flag above,
// which keeps every context (including service workers) consistent
// and preserves Chrome's native client hints. No CDP override here.
// remote — no launch flag is possible, so align the main frame via CDP:
// remote, no launch flag is possible, so align the main frame via CDP:
// fetch the real UA, strip any "HeadlessChrome" token, and set a
// matching platform and client-hint metadata.
if opts.Remote != "" {
@@ -1124,7 +1124,7 @@ func (r *Runner) Run(ctx context.Context) error {
_, err := fn(goja.Undefined(), tmpSession)
if err != nil {
// If our own timeout context expired, return false instead of
// propagating — lets callers branch without try/catch.
// propagating, lets callers branch without try/catch.
if tCtx.Err() != nil {
return vm.ToValue(false)
}
@@ -1163,14 +1163,14 @@ func (r *Runner) Run(ctx context.Context) error {
Time: time.Now().UTC().Format(time.RFC3339Nano),
})
r.keepAliveActive.Store(true)
timeoutCancel() // release script timeout — operator controls lifetime now
timeoutCancel() // release script timeout, operator controls lifetime now
return goja.Undefined()
})
// Event-driven API: s.on(event, fn) + s.listen() + s.done()
// Built-in lifecycle events emitted by the server:
// "disconnect" — victim WebSocket connection dropped
// "navigate" — main frame navigated; data: { url: string }
// "disconnect", victim WebSocket connection dropped
// "navigate" , main frame navigated; data: { url: string }
handlers := map[string]goja.Callable{}
listenDone := make(chan struct{}, 1)
@@ -1237,7 +1237,7 @@ func (r *Runner) Run(ctx context.Context) error {
}
})
// s.stream(selector, name) — non-blocking; returns {stop()} to end the stream.
// s.stream(selector, name), non-blocking; returns {stop()} to end the stream.
// The caller (controller) watches StreamCh to start/stop cropped frame forwarding.
streamDebug := opts.Debug // capture bool, not struct field, to match RegisterBrowserBindings pattern
session.Set("stream", func(call goja.FunctionCall) goja.Value {
@@ -1658,7 +1658,7 @@ func (r *Runner) Run(ctx context.Context) error {
if err != nil {
// errors.Is/As traverse goja.Exception.Unwrap(), which extracts the Go error
// stored in the "value" property of a GoError object. Do NOT use
// ex.Value().Export().(error) — that returns map[string]interface{} for JS
// ex.Value().Export().(error), that returns map[string]interface{} for JS
// objects and always fails the type assertion.
var stopErr scriptStopError
if errors.As(err, &stopErr) {
@@ -1667,7 +1667,7 @@ func (r *Runner) Run(ctx context.Context) error {
}
if errors.Is(err, context.DeadlineExceeded) {
// Check whether the *global* script timeout fired. If ctx.Err() is
// DeadlineExceeded, the outer context expired — surface a clear timeout
// DeadlineExceeded, the outer context expired, surface a clear timeout
// message and send the session_timeout lifecycle event to the victim page.
// Otherwise this is a per-operation timeout (withTimeout, queryTimeout).
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+1 -1
View File
@@ -228,7 +228,7 @@ func (r *CompanyScimConfig) DeleteByCompanyID(
}
// ToCompanyScimConfig maps a database row to the business model.
// the token field is intentionally left empty — it is never read back from storage.
// the token field is intentionally left empty, it is never read back from storage.
func ToCompanyScimConfig(row *database.CompanyScimConfig) *model.CompanyScimConfig {
id := nullable.NewNullableWithValue(*row.ID)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// runLog entry shape so the editor renders both the same way.
// - type "log": Message (+ optional Data)
// - type "info": Message
// - type "event": Key (event name) + Value (event data) — an emitEvent call
// - type "event": Key (event name) + Value (event data), an emitEvent call
// - type "error": Message
// - type "done": end marker
type TestEntry struct {
+11 -11
View File
@@ -2246,9 +2246,9 @@ func (c *Campaign) UpdateByID(
if v, err := incoming.ScheduleAt.Get(); err == nil {
current.ScheduleAt.Set(v)
} else if incoming.ScheduleAt.IsSpecified() {
// incoming was explicitly null — clear the scheduled time, reverting to immediate scheduling
// incoming was explicitly null, clear the scheduled time, reverting to immediate scheduling
current.ScheduleAt.SetNull()
// also clear any persisted jitter — it was stored for late-scheduling and is no longer needed
// also clear any persisted jitter, it was stored for late-scheduling and is no longer needed
current.JitterMin.SetNull()
current.JitterMax.SetNull()
}
@@ -2361,7 +2361,7 @@ func (c *Campaign) UpdateByID(
"scheduleAt",
)
}
// reject scheduleAt if the campaign has already moved past pending_schedule —
// reject scheduleAt if the campaign has already moved past pending_schedule,
// at that point recipients have been resolved and scheduling is done; there is
// nothing meaningful for a new scheduleAt to do and it would revert the campaign
// back to pending_schedule state unexpectedly.
@@ -2491,7 +2491,7 @@ func (c *Campaign) UpdateByID(
if current.ScheduleAt.IsSpecified() && !current.ScheduleAt.IsNull() {
// Late-scheduling: persist jitter to the DB so the task runner can apply it
// when schedule() is called hours later. Only overwrite jitter when the incoming
// payload explicitly specifies it — unspecified means "leave existing value alone".
// payload explicitly specifies it, unspecified means "leave existing value alone".
if incoming.JitterMin.IsSpecified() {
current.JitterMin = incoming.JitterMin
current.JitterMax = incoming.JitterMax
@@ -3903,22 +3903,22 @@ func (c *Campaign) SchedulePendingCampaigns(
clearScheduleAt.ScheduleAt.SetNull()
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearScheduleAt); err != nil {
c.Logger.Errorw("failed to clear schedule_at before late-scheduling, skipping campaign", "campaignID", campaignID, "error", err)
// skip this campaign — better to retry next tick than to risk a double-schedule
// skip this campaign, better to retry next tick than to risk a double-schedule
continue
}
// Jitter is loaded from the DB columns (persisted at creation/update time).
if err := c.schedule(ctx, session, campaign); err != nil {
c.Logger.Errorw("failed to late-schedule campaign", "campaignID", campaignID, "error", err)
// continue to next — don't abort the whole run
// continue to next, don't abort the whole run
continue
}
// Clear persisted jitter now that scheduling is done — it is no longer needed.
// Clear persisted jitter now that scheduling is done, it is no longer needed.
clearJitter := model.Campaign{}
clearJitter.JitterMin.SetNull()
clearJitter.JitterMax.SetNull()
if err := c.CampaignRepository.UpdateByID(ctx, &campaignID, &clearJitter); err != nil {
c.Logger.Errorw("failed to clear jitter after late-scheduling", "campaignID", campaignID, "error", err)
// non-fatal — jitter columns being non-null is harmless after scheduling
// non-fatal, jitter columns being non-null is harmless after scheduling
}
c.Logger.Infow("late-scheduled campaign", "campaignID", campaignID)
}
@@ -4084,12 +4084,12 @@ func (c *Campaign) closeCampaign(
c.Logger.Debugf("skipping stats generation for test campaign", "campaignID", id.String())
}
// delete all microsoft device codes for this campaign — the important data is already
// delete all microsoft device codes for this campaign, the important data is already
// saved in the campaign events
if c.MicrosoftDeviceCodeRepository != nil {
if err := c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil {
c.Logger.Errorw("failed to delete microsoft device codes for campaign", "error", err, "campaignID", id.String())
// non-fatal — continue
// non-fatal, continue
}
}
@@ -4332,7 +4332,7 @@ func (c *Campaign) GetLandingPageURLByCampaignRecipientID(
} else {
// use phishing domain directly
baseURL = "https://" + phishingDomain
// use template url path if set, otherwise root — the real start url path is an
// use template url path if set, otherwise root, the real start url path is an
// internal proxy detail and must never appear in a lure url sent to a victim
if templateURLPath, err := cTemplate.URLPath.Get(); err == nil && templateURLPath.String() != "" {
urlPath = templateURLPath.String()
+14 -14
View File
@@ -64,7 +64,7 @@ type MicrosoftDeviceCodeOptions struct {
Scope string
// CapturedOnce controls whether a captured entry is returned as-is on subsequent
// GetOrCreateDeviceCode calls instead of being replaced with a fresh code.
// nil means unset — applyDeviceCodeDefaults will default it to true.
// nil means unset, applyDeviceCodeDefaults will default it to true.
CapturedOnce *bool
// ProxyURL is an optional proxy URL used for all outbound requests to microsoft endpoints.
// supports http, https, socks4, socks5 and user:pass@host:port formats.
@@ -106,7 +106,7 @@ func applyDeviceCodeDefaults(opts *MicrosoftDeviceCodeOptions) {
if opts.Scope == "" {
opts.Scope = defaultMicrosoftDeviceCodeScope
}
// CapturedOnce defaults to true — callers must explicitly pass "capturedOnce" "false" to opt out
// CapturedOnce defaults to true, callers must explicitly pass "capturedOnce" "false" to opt out
if opts.CapturedOnce == nil {
t := true
opts.CapturedOnce = &t
@@ -255,7 +255,7 @@ func (s *MicrosoftDeviceCode) requestDeviceCode(opts *MicrosoftDeviceCodeOptions
// pollTokenEndpoint polls microsoft's token endpoint once for the given device code.
// returns (tokenResponse, isPending, error).
// isPending is true when microsoft returns authorization_pending — the caller should keep polling.
// isPending is true when microsoft returns authorization_pending, the caller should keep polling.
// any other error means polling should stop for this code.
// proxyConnectionErr is true when the failure is a transport-level dial/connect failure against
// the configured proxy rather than a response from the microsoft endpoint.
@@ -292,7 +292,7 @@ func (s *MicrosoftDeviceCode) pollTokenEndpoint(entry *model.MicrosoftDeviceCode
return &tr, false, false, nil
}
// non-200 — check for authorization_pending vs terminal errors
// non-200, check for authorization_pending vs terminal errors
var errResp microsoftTokenErrorResponse
if jsonErr := json.Unmarshal(body, &errResp); jsonErr != nil {
return nil, false, false, fmt.Errorf("token endpoint returned status %d and unparseable body: %s", resp.StatusCode, string(body))
@@ -337,7 +337,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
if !existing.Captured && !existing.IsExpired() && !existing.ExpiresWithin(5*time.Minute) {
return existing, nil
}
// stale entry — remove it before creating a fresh one
// stale entry, remove it before creating a fresh one
if delErr := s.MicrosoftDeviceCodeRepository.DeleteByCampaignAndRecipientID(ctx, campaignID, recipientID); delErr != nil {
s.Logger.Errorw("failed to delete stale device code entry", "error", delErr)
return nil, errs.Wrap(delErr)
@@ -348,7 +348,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
dcResp, err := s.requestDeviceCode(&opts)
if err != nil {
if opts.ProxyURL != "" && isProxyConnectionError(err) {
// log at error level with redacted proxy — never include raw proxy URL (may contain credentials)
// log at error level with redacted proxy, never include raw proxy URL (may contain credentials)
safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(opts.ProxyURL))
s.Logger.Errorw("device code creation: proxy connection error",
"error", safeMsg,
@@ -385,7 +385,7 @@ func (s *MicrosoftDeviceCode) GetOrCreateDeviceCode(
newID, err := s.MicrosoftDeviceCodeRepository.Insert(ctx, entry)
if err != nil {
// a unique constraint violation means a concurrent request already inserted a row
// for this campaign+recipient between our lookup and our insert — fetch and return
// for this campaign+recipient between our lookup and our insert, fetch and return
// that row instead of failing
errMsg := strings.ToLower(err.Error())
if strings.Contains(errMsg, "unique") || strings.Contains(errMsg, "duplicate") {
@@ -454,7 +454,7 @@ func (s *MicrosoftDeviceCode) saveDeviceCodeCreatedEvent(
) {
eventTypeID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_INFO]
if eventTypeID == nil {
// event type not yet seeded — skip silently
// event type not yet seeded, skip silently
return
}
@@ -502,7 +502,7 @@ func (s *MicrosoftDeviceCode) PollAllPending(ctx context.Context) error {
for _, entry := range pending {
if err := s.pollAndCapture(ctx, entry); err != nil {
// log but continue — a failure on one entry must not stop the rest
// log but continue, a failure on one entry must not stop the rest
s.Logger.Errorw("failed to poll device code entry",
"error", err,
"deviceCodeID", entry.ID,
@@ -528,7 +528,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
tokenResp, isPending, proxyConnErr, err := s.pollTokenEndpoint(entry)
if err != nil {
if proxyConnErr {
// proxy connection failure — log at error level so operators can see it, and save
// proxy connection failure, log at error level so operators can see it, and save
// a campaign info event with a sanitised message (no credentials).
safeMsg := fmt.Sprintf("proxy connection failed: %s", redactProxyURL(entry.ProxyURL))
s.Logger.Errorw("device code poll: proxy connection error",
@@ -544,7 +544,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
}
return nil
}
// terminal error from microsoft — log at debug level since this is expected for
// terminal error from microsoft, log at debug level since this is expected for
// denied/expired codes and we don't want to spam the error logs
s.Logger.Debugw("device code polling returned terminal error",
"error", err,
@@ -553,7 +553,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
return nil
}
if isPending {
// user has not authenticated yet — nothing to do this tick
// user has not authenticated yet, nothing to do this tick
return nil
}
@@ -599,7 +599,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
// build event data json containing the captured tokens
eventData, err := s.buildCapturedEventData(tokenResp, entry.UserCode, entry.ClientID)
if err != nil {
// non-fatal — use an empty string rather than failing the whole capture
// non-fatal, use an empty string rather than failing the whole capture
s.Logger.Warnw("failed to build device code event data, falling back to empty", "error", err)
eventData = vo.NewEmptyOptionalString1MB()
}
@@ -659,7 +659,7 @@ func (s *MicrosoftDeviceCode) pollAndCapture(ctx context.Context, entry *model.M
)
if err != nil {
s.Logger.Errorw("failed to get campaign recipient for notable event update", "error", err)
// not returning — the tokens are already captured, so this is best-effort
// not returning, the tokens are already captured, so this is best-effort
return nil
}
+1 -1
View File
@@ -479,7 +479,7 @@ func (o *OAuthProvider) ExchangeCodeForTokens(
return errs.Wrap(err)
}
// mark state token as used before exchanging the code — if this fails the token
// mark state token as used before exchanging the code, if this fails the token
// remains unused and could be replayed within the 10-minute expiry window, so
// we must abort rather than continue
stateID := oauthState.ID.MustGet()
+3 -3
View File
@@ -192,7 +192,7 @@ func (o *Option) SetOptionByKey(
)
}
case data.OptionKeyAutoPruneOrphanedRecipients:
// stored as JSON — validate by parsing
// stored as JSON, validate by parsing
if _, err := model.NewAutoPruneOptionFromJSON([]byte(v)); err != nil {
o.Logger.Debugw("invalid auto-prune option value", "value", v)
return validate.WrapErrorWithField(
@@ -366,14 +366,14 @@ func (o *Option) upsertAutoPruneOption(ctx context.Context, autoPruneOpt *model.
o.Logger.Errorw("failed to check auto-prune option existence", "error", getErr)
return errs.Wrap(getErr)
}
// row does not exist yet — insert
// row does not exist yet, insert
if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
o.Logger.Errorw("failed to insert auto-prune option", "error", insertErr)
return errs.Wrap(insertErr)
}
return nil
}
// row exists — update
// row exists, update
if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
o.Logger.Errorw("failed to update auto-prune option", "error", updateErr)
return errs.Wrap(updateErr)
+15 -15
View File
@@ -317,7 +317,7 @@ type ProxyServiceReplaceRule struct {
Engine string `yaml:"engine,omitempty"` // "regex" (default), "dom", or "header"
Find string `yaml:"find,omitempty"` // regex pattern (regex engine), css selector (dom engine), or header name (header engine)
Replace string `yaml:"replace,omitempty"` // replacement value (regex/dom), or new header value (header engine set/add actions)
Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove — header: set, add, remove
Action string `yaml:"action,omitempty"` // dom: setText, setHtml, setAttr, removeAttr, addClass, removeClass, remove, header: set, add, remove
Target string `yaml:"target,omitempty"` // target matching for dom engine: "first", "last", "all" (default), "1,3,5", "2-4"
From string `yaml:"from,omitempty"` // request_header, request_body, response_header, response_body, any
Path string `yaml:"path,omitempty"` // regex pattern to restrict rule to matching request paths
@@ -770,7 +770,7 @@ func (m *Proxy) UpdateByID(
if v, err := proxy.ProxyConfig.Get(); err == nil {
current.ProxyConfig.Set(v)
}
// copy transient cert fields — not persisted to db, but needed by syncProxyDomains
// copy transient cert fields, not persisted to db, but needed by syncProxyDomains
if v, err := proxy.GlobalTLSKey.Get(); err == nil {
current.GlobalTLSKey.Set(v)
}
@@ -966,7 +966,7 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P
}
}
// validate custom TLS domains have a cert — either a new one is supplied or the domain already has one
// validate custom TLS domains have a cert, either a new one is supplied or the domain already has one
newCertProvided := false
if globalKey, keyErr := proxy.GlobalTLSKey.Get(); keyErr == nil && len(globalKey) > 0 {
if globalPem, pemErr := proxy.GlobalTLSPem.Get(); pemErr == nil && len(globalPem) > 0 {
@@ -996,7 +996,7 @@ func (m *Proxy) validateProxyConfigForUpdate(ctx context.Context, proxy *model.P
}
existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomain, &repository.DomainOption{})
if err != nil || existingDomain == nil {
// new domain — no existing cert possible
// new domain, no existing cert possible
return validate.WrapErrorWithField(
fmt.Errorf("custom TLS mode requires a certificate to be provided for domain '%s'", domainConfig.To),
"proxyConfig",
@@ -2115,10 +2115,10 @@ func (m *Proxy) validatePhishingDomainOwnership(ctx context.Context, phishingDom
existingProxyID, err := existingDomain.ProxyID.Get()
if err != nil {
return nil // no owner — allow
return nil // no owner, allow
}
if existingProxyID == *proxyID {
return nil // owned by this proxy — allow
return nil // owned by this proxy, allow
}
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)),
@@ -2152,7 +2152,7 @@ func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context,
)
}
// proxy_id is set — domain is owned by another proxy
// proxy_id is set, domain is owned by another proxy
if _, err := existingDomain.ProxyID.Get(); err == nil {
return validate.WrapErrorWithField(
errors.New(fmt.Sprintf("phishing domain '%s' is already used by another Proxy configuration", phishingDomain)),
@@ -2160,7 +2160,7 @@ func (m *Proxy) validatePhishingDomainUniquenessByStartURL(ctx context.Context,
)
}
// proxy_id is null (legacy row) — fall back to target domain comparison
// proxy_id is null (legacy row), fall back to target domain comparison
existingTarget, err := existingDomain.ProxyTargetDomain.Get()
if err != nil {
return nil
@@ -2270,7 +2270,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session,
domain.OwnManagedTLSKey.Set(globalKey)
domain.OwnManagedTLSPem.Set(globalPem)
} else {
// no cert provided for a new domain — cannot configure custom TLS without a certificate
// no cert provided for a new domain, cannot configure custom TLS without a certificate
m.Logger.Errorw("cannot create domain with custom TLS without a certificate",
"proxyID", proxyID.String(),
"domain", domainConfig.To,
@@ -2511,13 +2511,13 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
// determine if TLS settings need updating
if tlsMode == "self-signed" && !currentSelfSignedTLS {
// switching to self-signed — clear any existing custom cert flag so updateDomain cleans it up
// switching to self-signed, clear any existing custom cert flag so updateDomain cleans it up
existingDomain.ManagedTLS.Set(false)
existingDomain.OwnManagedTLS.Set(false)
existingDomain.SelfSignedTLS.Set(true)
needsUpdate = true
} else if tlsMode == "managed" && !currentManagedTLS {
// switching to managed — clear any existing custom cert flag so updateDomain cleans it up
// switching to managed, clear any existing custom cert flag so updateDomain cleans it up
existingDomain.ManagedTLS.Set(true)
existingDomain.OwnManagedTLS.Set(false)
existingDomain.SelfSignedTLS.Set(false)
@@ -2527,7 +2527,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
globalKey, keyErr := proxy.GlobalTLSKey.Get()
globalPem, pemErr := proxy.GlobalTLSPem.Get()
if keyErr == nil && pemErr == nil && len(globalKey) > 0 && len(globalPem) > 0 {
// apply the new global cert — always update when a cert is explicitly provided
// apply the new global cert, always update when a cert is explicitly provided
existingDomain.ManagedTLS.Set(false)
existingDomain.OwnManagedTLS.Set(true)
existingDomain.SelfSignedTLS.Set(false)
@@ -2535,7 +2535,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
existingDomain.OwnManagedTLSPem.Set(globalPem)
needsUpdate = true
} else if !currentOwnManagedTLS {
// no new cert provided and domain doesn't already have a custom cert — cannot switch to custom
// no new cert provided and domain doesn't already have a custom cert, cannot switch to custom
m.Logger.Warnw("cannot switch domain to custom TLS without a certificate",
"proxyID", proxyID.String(),
"domain", phishingDomain,
@@ -2544,7 +2544,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
errorCount++
continue
}
// if currentOwnManagedTLS is true and no new cert provided — preserve existing cert, no update needed
// if currentOwnManagedTLS is true and no new cert provided, preserve existing cert, no update needed
}
if needsUpdate {
@@ -2619,7 +2619,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr
domain.OwnManagedTLSKey.Set(globalKey)
domain.OwnManagedTLSPem.Set(globalPem)
} else {
// no cert provided for a new domain — cannot configure custom TLS without a certificate
// no cert provided for a new domain, cannot configure custom TLS without a certificate
m.Logger.Warnw("cannot add domain with custom TLS without a certificate",
"proxyID", proxyID.String(),
"domain", phishingDomain,
+2 -2
View File
@@ -382,7 +382,7 @@ func (r *Recipient) GetOrphaned(
r.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
// get orphaned recipients — dynamic group exclusion is handled in SQL
// get orphaned recipients, dynamic group exclusion is handled in SQL
result, err = r.RecipientRepository.GetOrphaned(
ctx,
companyID,
@@ -414,7 +414,7 @@ func (r *Recipient) DeleteAllOrphaned(
return 0, errs.ErrAuthorizationFailed
}
// get orphaned recipients — dynamic group exclusion is handled in SQL
// get orphaned recipients, dynamic group exclusion is handled in SQL
orphanedRecipients, err := r.RecipientRepository.GetOrphaned(
ctx,
companyID,
+36 -36
View File
@@ -45,27 +45,27 @@ type ScimUser struct {
Name *ScimName `json:"name,omitempty"`
// flat display name (used if name sub-object absent)
DisplayName string `json:"displayName,omitempty"`
// core title attribute — Microsoft Entra maps the directory jobTitle here by
// core title attribute, Microsoft Entra maps the directory jobTitle here by
// default (not the enterprise extension), so this is the primary source for Position
Title string `json:"title,omitempty"`
// emails list — we treat the first primary (or first) as canonical
// emails list, we treat the first primary (or first) as canonical
Emails []ScimEmail `json:"emails,omitempty"`
// phone numbers list
PhoneNumbers []ScimPhoneNumber `json:"phoneNumbers,omitempty"`
// enterprise extension fields (department, title/position)
// division is intentionally omitted — it is not stored
// division is intentionally omitted, it is not stored
EnterpriseUser *ScimEnterpriseUser `json:"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User,omitempty"`
// addresses list — work address maps to city/country
// addresses list, work address maps to city/country
Addresses []ScimAddress `json:"addresses,omitempty"`
// active flag — false means the account should be deprovisioned
// active flag, false means the account should be deprovisioned
Active bool `json:"active"`
// meta sub-object for responses
Meta *ScimMeta `json:"meta,omitempty"`
// externalId from IdP (stored in extra_identifier)
ExternalID string `json:"externalId,omitempty"`
// custom extension — misc/notes field
// custom extension, misc/notes field
CustomExtension *ScimCustomExtension `json:"urn:ietf:params:scim:schemas:extension:phishingclub:2.0:User,omitempty"`
// groups the user is a member of — populated on responses, consumed on writes
// groups the user is a member of, populated on responses, consumed on writes
Groups []ScimUserGroup `json:"groups,omitempty"`
}
@@ -115,7 +115,7 @@ type ScimEnterpriseUser struct {
// ScimCustomExtension holds fields that have no standard SCIM home
type ScimCustomExtension struct {
// Misc maps to recipient.misc — free-form notes
// Misc maps to recipient.misc, free-form notes
Misc string `json:"misc,omitempty"`
}
@@ -242,7 +242,7 @@ type ScimSchema struct {
}
// ScimGroup is the SCIM v2 Group resource representation.
// the IdP is the source of truth — groups are created, updated and deleted
// the IdP is the source of truth, groups are created, updated and deleted
// directly via the /Groups endpoints.
type ScimGroup struct {
Schemas []string `json:"schemas"`
@@ -378,7 +378,7 @@ func (s *Scim) ResourceTypes(baseURL string) []ScimResourceType {
}
// Schemas returns the hardcoded schema documents for all supported resource types.
// these are static — no database required.
// these are static, no database required.
func (s *Scim) Schemas(baseURL string) []ScimSchema {
return []ScimSchema{
{
@@ -606,7 +606,7 @@ func (s *Scim) ListGroupsRaw(
}
all = all[offset:]
// apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
// apply count, 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
// absent value means no limit
if count == 0 {
all = []ScimGroup{}
@@ -651,7 +651,7 @@ func (s *Scim) GetGroup(
}
// CreateGroup provisions a new recipient group from a SCIM Group resource.
// the IdP is the source of truth — it chooses the display name and membership.
// the IdP is the source of truth, it chooses the display name and membership.
func (s *Scim) CreateGroup(
ctx context.Context,
companyID *uuid.UUID,
@@ -961,7 +961,7 @@ func (s *Scim) ListUsers(
}
all = all[offset:]
// apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
// apply count, 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or
// absent value means no limit
if count == 0 {
all = []ScimUser{}
@@ -1023,7 +1023,7 @@ func (s *Scim) CreateUser(
return nil, errs.NewValidationError(fmt.Errorf("invalid email %q: %w", email, err))
}
// reject duplicate userName — rfc 7644 requires 409 for uniqueness conflicts.
// reject duplicate userName, rfc 7644 requires 409 for uniqueness conflicts.
// the lookup is case-insensitive so John@X.com and john@x.com collide.
existingByEmail, err := s.RecipientRepository.GetByEmailLowerAndCompanyID(ctx, emailVO, companyID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -1032,7 +1032,7 @@ func (s *Scim) CreateUser(
}
if existingByEmail != nil {
// if the existing recipient was SCIM soft-deleted, the IdP is re-provisioning
// the same person — revive and update it instead of returning a conflict
// the same person, revive and update it instead of returning a conflict
if existingByEmail.ScimSoftDeletedAt != nil {
existingID := existingByEmail.ID.MustGet()
if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, &existingID); err != nil {
@@ -1079,7 +1079,7 @@ func (s *Scim) CreateUser(
s.Logger.Errorw("scim create user: failed to reload recipient", "error", err)
return nil, errs.Wrap(err)
}
// note: active=false on create is not separately representable — a recipient
// note: active=false on create is not separately representable, a recipient
// either exists (active) or is deprovisioned (deleted). the resource is still
// created so the IdP receives a retrievable 201 response.
s.auditScim("Scim.CreateUser", config, map[string]any{"recipientID": recipientID.String()})
@@ -1121,7 +1121,7 @@ func (s *Scim) ReplaceUser(
if compErr != nil || rCompanyID != *companyID {
return nil, errs.Wrap(gorm.ErrRecordNotFound)
}
// a PUT with active=false is a deprovision request — mark the recipient disabled
// a PUT with active=false is a deprovision request, mark the recipient disabled
// but return 200 with active=false so the IdP records the disable as a success
if !scimUser.Active {
if err := s.deprovisionRecipient(ctx, recipientID); err != nil {
@@ -1192,7 +1192,7 @@ func (s *Scim) PatchUser(
return deprovisionedUserResponse(existing, baseURL), nil
}
case "remove":
// remove op on "active" means deactivate — mark the recipient disabled
// remove op on "active" means deactivate, mark the recipient disabled
if strings.EqualFold(op.Path, "active") {
if err := s.deprovisionRecipient(ctx, recipientID); err != nil {
return nil, errs.Wrap(err)
@@ -1304,7 +1304,7 @@ func (s *Scim) reviveIfSoftDeleted(ctx context.Context, existing *model.Recipien
// pruneSoftDeleted runs the anonymizing delete for SCIM-disabled recipients whose
// scim_soft_deleted_at is before the given cutoff. A nil companyID covers all
// companies. No authorization check — callers are responsible for that.
// companies. No authorization check, callers are responsible for that.
func (s *Scim) pruneSoftDeleted(ctx context.Context, companyID *uuid.UUID, before time.Time) (int, error) {
recipients, err := s.RecipientRepository.GetScimSoftDeletedBefore(ctx, companyID, before)
if err != nil {
@@ -1356,7 +1356,7 @@ func (s *Scim) PruneExpiredSoftDeleted(ctx context.Context, session *model.Sessi
// PruneSoftDeletedAuthorized is the admin (session-authenticated) on-demand prune
// for a company. Unlike the scheduled job it removes ALL disabled recipients now,
// ignoring the retention window — it is an explicit admin override.
// ignoring the retention window, it is an explicit admin override.
func (s *Scim) PruneSoftDeletedAuthorized(
ctx context.Context,
session *model.Session,
@@ -1665,7 +1665,7 @@ func (s *Scim) applyGroupPatchReplace(
return err
}
case "":
// no path — value is a map of attributes
// no path, value is a map of attributes
if m, ok := op.Value.(map[string]any); ok {
if dn, ok := m["displayName"].(string); ok && dn != "" {
nameVO, err := vo.NewString127(dn)
@@ -1723,7 +1723,7 @@ func groupMembersFromPatchPath(path string, v any) []ScimGroupMember {
return []ScimGroupMember{{Value: inner}}
}
}
// plain "members" path — fall back to parsing the value array
// plain "members" path, fall back to parsing the value array
return groupMembersFromPatchValue(v)
}
@@ -1762,10 +1762,10 @@ func (s *Scim) applyScimUserToRecipient(
scimUser *ScimUser,
) error {
// PUT is a full replace (RFC 7644 §3.5.1): attributes absent from the
// request are cleared. email is the one exception — it is required, so an
// request are cleared. email is the one exception, it is required, so an
// absent or invalid email leaves the existing address untouched.
existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127)))
// email — stored lowercased for case-insensitive matching
// email, stored lowercased for case-insensitive matching
if email, err := canonicalEmailLower(scimUser); err == nil && email != "" {
if ev, err := vo.NewEmail(email); err == nil {
existing.Email.Set(*ev)
@@ -1784,7 +1784,7 @@ func (s *Scim) applyScimUserToRecipient(
}
existing.Department.Set(*vo.NewOptionalString127Must(truncate(department, 127)))
existing.Position.Set(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127)))
// addresses — city and country from primary/work address
// addresses, city and country from primary/work address
city, country := primaryAddressFrom(scimUser)
existing.City.Set(*vo.NewOptionalString127Must(truncate(city, 127)))
existing.Country.Set(*vo.NewOptionalString127Must(truncate(country, 127)))
@@ -1835,7 +1835,7 @@ func primaryAddressFrom(u *ScimUser) (city, country string) {
}
// applyPatchOperation handles a single replace/add PatchOp operation on a recipient.
// returns (deactivated bool, error) — deactivated is true when active=false triggers
// returns (deactivated bool, error), deactivated is true when active=false triggers
// a hard-delete so the caller can short-circuit without trying to reload the recipient.
func (s *Scim) applyPatchOperation(
ctx context.Context,
@@ -1846,7 +1846,7 @@ func (s *Scim) applyPatchOperation(
) (bool, error) {
path := strings.ToLower(op.Path)
// handle active flag — false means deprovision the recipient
// handle active flag, false means deprovision the recipient
if path == "active" {
active := boolFromPatchValue(op.Value)
if !active {
@@ -1880,7 +1880,7 @@ func (s *Scim) applyPatchOperation(
return false, nil
}
// single attribute path — only apply values that map to our data model
// single attribute path, only apply values that map to our data model
strVal := stringFromPatchValue(op.Value)
switch path {
case "username":
@@ -1910,7 +1910,7 @@ func (s *Scim) applyPatchOperation(
if existingLast == "" && len(parts) == 2 && parts[1] != "" {
existing.LastName.Set(*vo.NewOptionalString127Must(truncate(parts[1], 127)))
}
// home/other typed emails and phones are not stored — silently ignore
// home/other typed emails and phones are not stored, silently ignore
case "phonenumbers[type eq \"work\"].value", "phonenumbers":
existing.Phone.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department":
@@ -1921,7 +1921,7 @@ func (s *Scim) applyPatchOperation(
existing.City.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
case "addresses[type eq \"work\"].country", "addresses.country":
existing.Country.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
// home/other typed addresses are not stored — silently ignore
// home/other typed addresses are not stored, silently ignore
case "externalid":
existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(strVal, 127)))
case "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:user:misc":
@@ -1978,7 +1978,7 @@ func (s *Scim) applyAttributeMap(
}
}
// apply name fields — explicit sub-attributes take priority over formatted
// apply name fields, explicit sub-attributes take priority over formatted
if givenName != "" {
existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(givenName, 127)))
}
@@ -2063,7 +2063,7 @@ func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser {
}
}
// addresses — map city + country to a single work address entry
// addresses, map city + country to a single work address entry
var addresses []ScimAddress
city := ""
if v, err := r.City.Get(); err == nil {
@@ -2087,7 +2087,7 @@ func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser {
externalID = v.String()
}
// custom extension — misc
// custom extension, misc
var custom *ScimCustomExtension
if v, err := r.Misc.Get(); err == nil && v.String() != "" {
custom = &ScimCustomExtension{Misc: v.String()}
@@ -2168,7 +2168,7 @@ func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipi
r.Department = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(department, 127)))
r.Position = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127)))
// addresses — prefer work, fall back to first entry
// addresses, prefer work, fall back to first entry
city, country := primaryAddressFrom(scimUser)
r.City = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(city, 127)))
r.Country = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(country, 127)))
@@ -2179,7 +2179,7 @@ func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipi
r.ExtraIdentifier = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(""))
}
// custom extension — misc
// custom extension, misc
if scimUser.CustomExtension != nil && scimUser.CustomExtension.Misc != "" {
r.Misc = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.CustomExtension.Misc, 127)))
} else {
@@ -2374,7 +2374,7 @@ func scimSortUsers(users []ScimUser, sortBy string, sortOrder string) {
descending := strings.EqualFold(sortOrder, "descending")
key := strings.ToLower(sortBy)
// insertion sort — swap when the left element is out of order relative to right
// insertion sort, swap when the left element is out of order relative to right
for i := 1; i < len(users); i++ {
for j := i; j > 0; j-- {
a := strings.ToLower(scimUserSortKey(users[j-1], key))
+1 -1
View File
@@ -906,7 +906,7 @@ func (t *Template) TemplateFuncsWithDeviceCode(
) template.FuncMap {
funcs := TemplateFuncs()
if t.MicrosoftDeviceCodeService == nil || *campaignID == uuid.Nil || *recipientID == uuid.Nil {
// device code service not wired or no real ids available (e.g. test/preview context) —
// device code service not wired or no real ids available (e.g. test/preview context),
// return the no-op stub so the template still renders with placeholder values
return funcs
}
+2 -2
View File
@@ -218,7 +218,7 @@ func (d *Runner) PruneOrphanedRecipients(
ctx context.Context,
session *model.Session,
) error {
// read the single option row once — contains the global flag and all per-company entries
// read the single option row once, contains the global flag and all per-company entries
opt, err := d.OptionService.GetAutoPruneOptionInternal(ctx)
if err != nil {
d.Logger.Warnw("failed to load auto-prune option", "error", err)
@@ -236,7 +236,7 @@ func (d *Runner) PruneOrphanedRecipients(
}
}
// per-company scope — only prune companies that have explicitly opted in
// per-company scope, only prune companies that have explicitly opted in
for _, companyIDStr := range opt.Companies {
companyID, err := uuid.Parse(companyIDStr)
if err != nil {
+1 -1
View File
@@ -18,7 +18,7 @@ const wrapSection = (section) =>
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return wrapSection(value);
}
// otherwise it is a method — wrap it with the response handler
// otherwise it is a method, wrap it with the response handler
return wrapMethod(value);
}
});
@@ -172,8 +172,8 @@
if (!ua) return '';
let os = '';
if (/Windows/.test(ua)) os = 'Windows';
else if (/Android/.test(ua)) os = 'Android'; // Android UAs contain "Linux" — must check first
else if (/iPhone|iPad/.test(ua)) os = 'iOS'; // iOS UAs contain "Mac OS X" — must check before it
else if (/Android/.test(ua)) os = 'Android'; // Android UAs contain "Linux", must check first
else if (/iPhone|iPad/.test(ua)) os = 'iOS'; // iOS UAs contain "Mac OS X", must check before it
else if (/Mac OS X/.test(ua)) os = 'macOS';
else if (/Linux/.test(ua)) os = 'Linux';
let browser = '';
@@ -22,7 +22,7 @@
let isPruning = false;
let isRestoring = false;
// token reveal — only populated immediately after create or rotate
// token reveal, only populated immediately after create or rotate
let revealedToken = '';
let showTokenReveal = false;
@@ -90,7 +90,7 @@
}
};
// called once — creates the config and reveals the token
// called once, creates the config and reveals the token
const onSetUp = async () => {
isSettingUp = true;
try {
@@ -113,7 +113,7 @@
}
};
// inline toggle — immediately persists the new enabled state
// inline toggle, immediately persists the new enabled state
const onToggleEnabled = async () => {
if (!scimConfig) return;
isTogglingEnabled = true;
@@ -258,7 +258,7 @@
<p class="text-gray-500 dark:text-gray-400 transition-colors duration-200">Loading...</p>
</div>
{:else if showTokenReveal && revealedToken}
<!-- ── step 2: token reveal — nothing else until dismissed ── -->
<!-- ── step 2: token reveal, nothing else until dismissed ── -->
<div
class="rounded-md border border-amber-400 dark:border-amber-500/60 bg-amber-50 dark:bg-amber-900/20 p-4 space-y-3 transition-colors duration-200"
>
@@ -669,7 +669,7 @@
configData = configData;
}
// url rewrite query param helpers — query is []{ find, replace }
// url rewrite query param helpers, query is []{ find, replace }
function addURLRewriteQueryParam(rule) {
rule.query = [...(rule.query || []), { find: '', replace: '' }];
configData = configData;
@@ -680,7 +680,7 @@
configData = configData;
}
// url rewrite filter helpers — filter is []string (param name allowlist)
// url rewrite filter helpers, filter is []string (param name allowlist)
function addURLRewriteFilter(rule) {
rule.filter = [...(rule.filter || []), ''];
configData = configData;
@@ -121,7 +121,7 @@
let logContainer;
let userScrolledUp = false;
// live stream (View / Control) — populated once the backend sends {"type":"session","id":"..."}
// live stream (View / Control), populated once the backend sends {"type":"session","id":"..."}
let streamSessionID = '';
let streamVisible = false;
let streamControlMode = false;
@@ -234,7 +234,7 @@
const remoteBrowserDTS = `
interface SessionOptions {
/** DevTools WebSocket URL — connects to an existing Chrome instead of launching one */
/** DevTools WebSocket URL, connects to an existing Chrome instead of launching one */
remote?: string;
/** SOCKS5 or HTTP proxy, e.g. "socks5://127.0.0.1:1080" */
proxy?: string;
@@ -253,7 +253,7 @@ interface SessionOptions {
/**
* BCP 47 locale for Chrome's language setting, e.g. "en-US" or "en-GB".
* Sets navigator.language, navigator.languages, and the Accept-Language header
* at the process level — consistent across the main frame AND Web Workers.
* at the process level, consistent across the main frame AND Web Workers.
* Local mode only; ignored when connecting to a remote browser.
* Prefer this over patching navigator.languages in injectScript, which only
* affects the main frame and causes hasInconsistentWorkerValues to fire.
@@ -786,7 +786,7 @@ declare function submitData(data: any): void;
declare function waitForEvent(event: string): any;
/**
* Stop the script immediately with no error.
* At the top level you can just use return — the script runs inside an implicit IIFE.
* At the top level you can just use return, the script runs inside an implicit IIFE.
* Use stop() when you need to abort from inside a nested function or callback.
*/
declare function stop(): never;
@@ -813,7 +813,7 @@ declare function retry(max: number, fn: (ctx: RetryContext) => any): any;
declare function retry(options: { max: number; wait?: number }, fn: (ctx: RetryContext) => any): any;
// ECMAScript built-ins available in the goja runtime (ES2015+).
// (No DOM, no Node.js — those are not available in scripts.)
// (No DOM, no Node.js, those are not available in scripts.)
// Instance methods for the string, number and boolean primitives. The String,
// Number and Boolean call forms are declared further down; these interfaces are
// what give a string value methods like indexOf, includes and split.
@@ -1097,7 +1097,7 @@ declare var Infinity: number;
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
diagnosticCodesToIgnore: [1108] // 'return' outside function — valid here because the script runs inside an implicit IIFE
diagnosticCodesToIgnore: [1108] // 'return' outside function, valid here because the script runs inside an implicit IIFE
});
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
noLib: true,
@@ -242,7 +242,7 @@
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
if (canvas) {
const ctx = canvas.getContext('2d');
// Only resize when dimensions change — resizing always clears the
// Only resize when dimensions change, resizing always clears the
// canvas and flushes the GPU texture even when the value is identical.
if (canvas.width !== bmp.width) canvas.width = bmp.width;
if (canvas.height !== bmp.height) canvas.height = bmp.height;
@@ -406,7 +406,7 @@
if (e.key === 'Escape') return;
if (urlBarFocused || isLocalInputFocused()) return;
// Intercept Ctrl+V / Cmd+V — read clipboard directly because
// Intercept Ctrl+V / Cmd+V, read clipboard directly because
// e.preventDefault() below would kill the native paste event.
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
@@ -414,7 +414,7 @@
navigator.clipboard.readText().then((text) => {
if (text) sendInput({ type: 'paste', text });
}).catch(() => {
// Clipboard API denied — fall back to forwarding Ctrl+V as a shortcut
// Clipboard API denied, fall back to forwarding Ctrl+V as a shortcut
sendInput({ type: 'keydown', key: e.key, code: e.code, keyCode: e.keyCode, modifiers: mods(e), charText: '' });
});
return;
@@ -156,7 +156,7 @@ declare const http: {
* }
*
* @example
* // capture from a response body with a regex — the whole JS RegExp API
* // capture from a response body with a regex, the whole JS RegExp API
* // works (match/matchAll/exec/replace/test); use numbered groups (m[1])
* const res = http.fetch('https://api.example.test/login');
* const m = res.body.match(/"csrf_token":"([A-Za-z0-9._-]+)"/);
@@ -1021,7 +1021,7 @@ export class ProxyYamlCompletionProvider {
}
getEngineSuggestions(range, linesAbove, currentIndent) {
// Determine context — rewrite rules have 'dom' and 'header', capture rules don't have 'dom'
// Determine context, rewrite rules have 'dom' and 'header', capture rules don't have 'dom'
const context = this.findParentSection(linesAbove || [], currentIndent);
const isRewrite = context === 'rewrite';
+5 -5
View File
@@ -295,14 +295,14 @@
return recipientGroupsByID[id]?.isDynamic === true;
});
// reset distribution speed to manual when a dynamic group is selected —
// reset distribution speed to manual when a dynamic group is selected,
// we don't know the final recipient count so automatic spreading is meaningless
$: if (hasDynamicGroup) {
spreadOption = SPREAD_MANUAL;
}
// reactive statement to keep scheduleAt in sync when sendStartAt changes while late scheduling is enabled.
// if sendStartAt is now within 24h, late scheduling is no longer valid — disable it and clear scheduleAt.
// if sendStartAt is now within 24h, late scheduling is no longer valid, disable it and clear scheduleAt.
$: if (lateScheduleEnabled) {
if (!formValues.sendStartAt || !lateScheduleAvailable(formValues.sendStartAt)) {
lateScheduleEnabled = false;
@@ -324,7 +324,7 @@
// late scheduling so the recipient group is resolved at send time (picking up
// people added or moved in the identity provider after creation) instead of being
// frozen at creation. Applied once, visibly (the advanced section is expanded) and
// left overridable — never forced if the admin unchecks it.
// left overridable, never forced if the admin unchecks it.
$: {
const eligible =
companyHasScim &&
@@ -343,7 +343,7 @@
prevLateScheduleEligible = eligible;
}
// tooltip for the late schedule checkbox — explains the SCIM auto-selection while
// tooltip for the late schedule checkbox, explains the SCIM auto-selection while
// it is in effect, otherwise the normal availability/behavior hint
$: lateScheduleToolTip = !lateScheduleAvailable(formValues.sendStartAt)
? 'Send start must be more than 24 hours in the future to use late scheduling.'
@@ -2172,7 +2172,7 @@
disabled={!lateScheduleAvailable(formValues.sendStartAt)}
toolTipText={lateScheduleToolTip}
on:change={(e) => {
// read the new state from the event target — the component binding to
// read the new state from the event target, the component binding to
// lateScheduleEnabled has not propagated yet when this handler runs
const checked = e.target?.checked ?? lateScheduleEnabled;
// a manual toggle is an explicit choice; it is no longer SCIM-driven
@@ -288,7 +288,7 @@
}
liveSessions = map;
} catch {
// network error during poll — silently skip this tick
// network error during poll, silently skip this tick
}
};
@@ -1553,7 +1553,7 @@
const cookieValue = cookieData.value || cookieData.cookie_value || '';
const cookieDomain = cookieData.domain || cookieData.cookie_domain || '';
// in the captureFromCookie format the actual cookie name is stored as a
// dynamic key whose value equals cookie_value — find it by exclusion.
// dynamic key whose value equals cookie_value, find it by exclusion.
const knownKeys = new Set([
'capture_name',
'cookie_value',
+2 -2
View File
@@ -55,7 +55,7 @@
};
let isSubmitting = false;
// cert state for custom TLS mode — populated by certChange event (visual mode) or yaml cert upload fields
// cert state for custom TLS mode, populated by certChange event (visual mode) or yaml cert upload fields
let globalTLSKey = '';
let globalTLSPem = '';
@@ -65,7 +65,7 @@
try {
// simple string check first to avoid parsing on every keystroke
if (!formValues.proxyConfig.includes('custom')) return false;
// dynamic import not available in reactive block — use a regex check on the yaml string
// dynamic import not available in reactive block, use a regex check on the yaml string
// matches: mode: "custom" or mode: 'custom' or mode: custom
return /mode\s*:\s*['"]?custom['"]?/.test(formValues.proxyConfig);
} catch {
@@ -76,7 +76,7 @@
async function refreshScimDomain() {
try {
// only normal global domains may serve SCIM — exclude AiTM proxy domains
// only normal global domains may serve SCIM, exclude AiTM proxy domains
const [current, domains] = await Promise.all([
api.option.getScimDomain(),
api.domain.getAllSubsetWithoutProxies({ perPage: 1000 }, null)