deps(actions): bump actions/upload-artifact from 4 to 6 - #3
Merged
0xmanhnv merged 1 commit intoFeb 5, 2026
Merged
Conversation
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@v4...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
Contributor
Author
LabelsThe following labels could not be found: Please fix the above issues or remove invalid values from |
dependabot
Bot
deleted the
dependabot/github_actions/actions/upload-artifact-6
branch
February 5, 2026 04:37
This was referenced Jun 6, 2026
0xmanhnv
added a commit
that referenced
this pull request
Jun 13, 2026
0xmanhnv
added a commit
that referenced
this pull request
Aug 3, 2026
* feat(scancoverage): license-aware batch planner core (RFC-007 §3.2/§3.3) (#145)
Pure, IO-free planning logic for rolling coverage — the algorithmic heart the
scheduler will drive, decoupled from any scanner so it's unit-testable and
reused by both execution modes:
- LicensePolicy.Headroom: Unlimited→perf batch; ActiveIPCap→Cap-SafetyMargin-active (clamped)
- SelectBatch: order by (criticality DESC, LastScannedAt ASC nulls-first), greedily fill to maxIPs, always take the top candidate to avoid starvation
- CountIPs: CIDR-aware license accounting (full block, MaxInt32 cap), single IP/host = 1
Fully unit-tested. No external deps / Tenable instance needed.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* chore(deps): pin ctis to v1.1.0 (was branch pseudo-version) (#146)
ctis #5 (cve_ids/vpr_score/network/evidence) merged + tagged v1.1.0. Replace
the temporary branch-commit pseudo-version (v1.0.1-0.2026...-ff6b005503fe) that
#144 introduced with the released tag, removing the dependency-stability risk.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(integration): Tenable integration config + security validation (agent-mode no creds) (#147)
* feat(integration): Tenable integration config + security validation (RFC-007)
Add config plumbing to integration creation (CreateIntegrationInput.Config →
intg.SetConfig; handler request.config) and Tenable-specific validation
(internal/app/scancoverage/tenable_config.go):
- execution_mode (agent default | direct) + engine (nessus_pro default | tenable_sc),
unknown values rejected, config normalized to explicit values.
- SECURITY (§8 R3/R4): agent-mode integrations must NOT store credentials in the
control plane (rejected — creds belong on the runner); direct-mode requires
credentials + base_url.
Pure validation unit-tested; build/vet/gofmt clean.
* test: fix integration list tests for Tenable agent-mode no-creds rule
The new validation rejects agent-mode Tenable integrations that carry
credentials; the List setup used the shared (creds-bearing) input for Tenable,
which now requires direct mode. Set execution_mode=direct for those cases and
add a regression test asserting agent-mode+creds is rejected and agent-mode
without creds is accepted.
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(integration): enforce Tenable agent-mode no-creds rule on update too (RFC-007 §8) (#148)
The create-time rule could be bypassed: UpdateIntegration set credentials with
no Tenable check, so an agent-mode integration could gain control-plane creds
later. Reject non-empty credential updates on agent-mode Tenable integrations
(fail-secure: missing/legacy config → agent). Regression test covers
agent-reject + direct-allow.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs: Tenable user flow + data flow (UI interaction, agent/direct/upload, isolation) (#149)
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(integration): editable Tenable config on update + fix synthetic-agent stats bug (#150)
- UpdateIntegrationInput.Config: edit execution_mode/engine after create; the
update path re-validates the EFFECTIVE post-update state (mode + creds-present
+ base_url) so security holds across mode switches (direct→agent must clear
creds; →direct needs creds+base_url) and normalizes config. Handler accepts
request.config.
- fix(ingest): skip updateAgentStatsAsync when agentID.IsZero() — the synthetic
agent used by the tenant .nessus upload (#141) has no agent row; previously it
issued a no-op stats write + noisy warning for a non-existent agent.
Tests: update mode-switch (direct→agent reject/allow, engine change). Full
tests/unit green.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(rfc-007): the Tenable runner IS an OpenCTEM agent (no duplication) (#151)
* feat(scancoverage): Tenable coverage dispatcher (RFC-007 dispatch primitive) (#152)
* feat(scancoverage): Tenable coverage dispatcher (RFC-007 Phase 3 primitive)
Dispatcher.DispatchTenableScan enqueues a scan command for a tenable-capable
runner: a generic command (type=scan) whose payload carries scanner=tenable (the
agent's routing discriminator — see agent routing fix), the target batch, the
coverage session_id (scopes batch auto-resolve), and required_capabilities=
[infra]. Optional AgentID pins a specific runner (C3); nil = capability-routed.
The runner scans its LOCAL appliance and pushes CTIS — control plane holds no
creds. Narrow CommandCreator interface (satisfied by command.Repository), fully
unit-tested (routable payload, session gen, agent pin, validation, error
propagation). The scheduler controller (cursor/cadence over the planner) wraps
this next.
* feat(scancoverage): rolling-coverage scheduler core (RFC-007 Phase 3)
Wraps the planner + dispatcher into a license-aware rotation pass:
- Scheduler.RunOnce walks each tenant's active coverage config, sizes a
batch against the engine's license headroom (unlimited -> perf batch;
active-IP cap -> Cap-margin-active, gated on the un-reclaimed count),
selects by criticality+staleness, dispatches, and records the dispatch
so the same assets are not re-picked next cycle.
- Capped engines pause when the cap is full (awaiting runner reclaim) and
refuse an oversized single target rather than blow the license; unlimited
engines dispatch the top target regardless of perf batch size.
- IO-free behind narrow interfaces (CoverageSource/BatchDispatcher/
CursorStore); per-tenant failures are logged and skipped, never aborting
the pass. Fully unit-tested with fakes.
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(scancoverage): live coverage scheduler controller + rotation cursor (RFC-007 Phase 3, A9) (#153)
* chore(arch): hardening batch from 2026-06 architecture audit (#154)
* ops(migrate): preflight-migrate.sh — block deploy on data that would fail a migration (#155)
* feat(scancoverage): coverage observability API (RFC-007 Phase 4) (#156)
GET /api/v1/scans/coverage?window_days=30 (JWT; scans:read) returns a
tenant-scoped rolling-coverage summary so RFC-007 scans become verifiable:
total scannable, never-scanned, covered-in-window, stale, critical-never-scanned,
critical-uncovered, oldest-dispatched, coverage_percent.
- scancoverage.CoverageStats + CoverageStatsReader interface
- ScanCoverageRepository.CoverageStats: one conditional-aggregation query over the
scannable estate LEFT JOIN scan_coverage_state (tenant-scoped; SQL validated by
PREPARE on PG17)
- ScanHandler.CoverageStatus (window_days bound 1..3650; nil-reader + missing-tenant
guarded) wired via repos.ScanCoverage; route under existing /scans group
- handler unit tests (default/custom/invalid window, missing tenant, nil reader)
- docs: scan-coverage.md Phase 4 section + roadmap
The headline risk metric is critical_never_scanned. Capped-engine (.sc) license
utilisation lands with Phase 3.5 accounting.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(integration): tenant-scoped fetch on mutating paths (defense-in-depth) (#157)
Update/Delete/TestIntegration fetched via GetByID then verified tenant
ownership in the service (fetch-then-check). The audit flagged this foot-gun:
the repo method is tenant-agnostic, so a future caller could forget the check.
Add integration.Repository.GetByTenantAndID (tenant predicate enforced in SQL,
returns ErrIntegrationNotFound for a missing OR other-tenant record) and use it
on the three mutating service paths — moving the guarantee into the data layer,
no fetch-then-check window. Behaviour is unchanged for valid callers.
Tests: cross-tenant Update/Delete -> NotFound; the record survives a cross-tenant
delete attempt.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(outbox): alert when a notification is dead-lettered (#158)
A notification that exhausts its retries is marked 'dead', then archived to
notification_events and removed from the outbox — but it was logged only at
Debug, indistinguishable from a successful send, so a permanently-failed
notification vanished silently (audit finding).
Emit a structured ERROR (alertIfDeadLettered) with tenant/event_type/title/
retry_count/last_error before archiving, so ops can alert on level=error. No new
infra. Unit-tested (errors only for 'dead'; silent for completed/pending/failed/
processing).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(rfc-008): native shift-left CI scanning — plan + architecture diagrams (#159)
* docs(rfc-008): native shift-left CI/CD code scanning (agent-first)
Plan to make OpenCTEM's own agent best-in-class for CI shift-left (SAST/SCA/
secrets + PR decoration + risk-aware gate), learning from the califio code-secure
study WITHOUT depending on it. Grounds the work in an audit showing our agent is
already a peer/ahead, and phases the remaining polish (Phase 1 risk-aware gate
already shipped as agent #27). Indexed in docs/rfcs/README.md.
* docs(rfc-008): architecture doc with structure + dataflow + component diagrams
Add docs/architecture/shift-left-ci-scanning.md (Mermaid: component structure,
end-to-end PR-scan sequence, finding repo-vs-branch storage model + invariants,
responsibilities, phase status, code map) and register it in docs/README.md.
Satisfies the document-fully standard (RFC + architecture doc + index).
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(ingest): baseline-diff (new-vs-target) for PR scans (RFC-008 Phase 3) (#160)
POST /api/v1/agent/ingest/baseline-diff (agent API-key auth): given a PR's
current-scan fingerprints + the base/target branch, returns which are NEW (not
already open on the base branch) vs pre-existing tech debt. Lets a PR gate /
inline comments focus only on findings the PR introduces — the highest-value
learnable from the code-secure ASPM study, built on our occurrence model.
- vulnerability.FindingRepository.FingerprintsOpenOnBranch (occurrences JOIN
findings, tenant+branch+status='open' scoped; SQL PREPARE-verified on PG17).
- ingest.Service.BaselineDiff: resolve repo asset + base branch; unknown repo/
branch -> all new (no history). Pure partitionByBaseline helper, unit-tested.
- handler BaselineDiff + route /agent/ingest/baseline-diff (sibling of
/ingest/check). Tenant from authenticated agent.
- updated all FindingRepository test mocks for the new interface method.
- docs: architecture phase table + endpoint contract.
Naming: 'baseline-diff' (clear, consistent with /ingest/check) + explicit
new_fingerprints/pre_existing_fingerprints fields. Agent consumption (gate +
comment filter) is the next step.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(rfc-008): mark Phase 3 (PR new-vs-base) shipped end-to-end (#161)
Phase 3 is now complete across api #160 (baseline-diff endpoint), sdk-go v0.4.0
(#35 Client.BaselineDiff + handler NewFingerprints comment filter), and agent #28
(gate.FilterNewFindings + main.go baselineNewSet, fail-safe). Update RFC-008 +
the architecture doc: status line, capability table, phase sections, sequence
diagram, code map. Also reflect the other phases that were already shipped/present
(2/4/5/7); only Phase 6 export remains partial.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(pentest): XLSX export for campaign findings (RFC-008 Phase 6) (#162)
* feat(pentest): XLSX export for campaign findings (RFC-008 Phase 6)
Adds a real .xlsx export format to GET /pentest/campaigns/{id}/findings/export
alongside the existing CSV/JSON (format=xlsx). Unlike CSV, XLSX keeps multi-line
cells (steps, PoC) clean and avoids delimiter pitfalls; the header row is bold
and frozen.
Refactors the shared column order + per-finding row builder into pentest_export.go
so CSV and XLSX stay in lockstep, and rewrites the CSV path on encoding/csv (was
hand-assembled). Both spreadsheet formats run every cell through sanitizeCSVCell
to defuse formula injection (=,+,-,@). Adds excelize/v2.
Tested: row mapping, CSV BOM + sanitization, and a round-trip that re-opens the
produced workbook and asserts header + sanitized cells.
* docs(rfc-008): Phase 6 — CSV/XLSX export shipped; note scheduler gap
Mark CSV+XLSX findings export done (api#162 + ui#159). Document the real
remaining gap: report_schedules + ListDue() + the report:generate_scheduled
task exist but no controller invokes ListDue(), so configured schedules never
run — wiring needs a generic report generator + auto-email cron (deferred,
needs a product decision).
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(rfc-006): detailed Phase 3 — bidirectional Jira status sync (#163)
* docs(rfc-006): detailed Phase 3 — bidirectional Jira status sync
Per a user use case (create task in OpenCTEM ↔ Jira, and Jira board status drag
syncs back). Grounds the current state (outbound-create + inbound-status already
work; outbound-status missing — client has no transition call) and specifies the
missing half + the machinery a two-way loop needs:
- provider GetTransitions/DoTransition/AddComment (Jira has no 'set status')
- ticket_links typed table (replaces URL-substring heuristic; holds echo-guard
bookkeeping: last_pushed/last_inbound status+time)
- echo-guard: state-compare (skip-on-equal / skip-on-last-pushed) + origin tag
- outbound delivery via the transactional outbox + bounded worker (retry/rate-
limit/per-tenant fairness); opt-in per integration, default off
- conflict policy (last-writer-wins by event time; FP/risk-accepted authoritative)
- a WorkItem seam so the same engine serves findings now and a grouping
remediation_task later (user wanted both)
Sub-phases 3a (transitions) / 3b (ticket_links) / 3c (echo-guard+outbound) /
3d (per-tenant maps) / 3e (remediation_task entity). Links from parent RFC-006
and the RFC index.
* docs(rfc-006): time-bound the echo-guard inbound compare
A bare 'skip inbound if status == last_pushed' suppresses a later LEGITIMATE
re-set to the same status (push Done -> echo skipped; weeks later a human re-drags
to Done -> matches stale last_pushed and is wrongly dropped). Bound the echo match
to a short window after last_pushed_at (+ clear last_pushed once consumed); the
provenance tag remains the primary loop-breaker, the windowed compare is the net.
* docs(rfc-006): full status-model evaluation (§3.6.1)
Evaluate the status maps against all 14 finding statuses + the transition graph
+ approval/verify rules. Document: the inbound/outbound default matrices, the 3
domain constraints (RequiresApproval, RequiresVerifyPermission, no graph skips),
round-trip stability, and why FP/accepted are not inbound-mapped. Verdict:
defaults now sufficient+correct for stock Jira; richness gap covered by per-
integration overrides + comment-fallback + approval-gating.
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(jira): client transition + comment support (RFC-006 Phase 3a) (#167)
Foundation for outbound status sync. Jira has no 'set status' — you POST a
workflow transition whose availability depends on current status. Add:
- GetTransitions(issueKey) — list available transitions (id, name, to-status)
- DoTransition(issueKey, transitionID, comment) — perform one (+ optional comment)
- AddComment(issueKey, body) — fallback when no transition reaches the target
- TransitionToStatus(issueKey, targetStatus, comment) — resolve target status
name → transition id (case-insensitive) and perform; ErrNoMatchingTransition
when the workflow forbids the move (caller falls back to AddComment)
No caller yet → zero behavior change; design-independent of the rest of RFC-006
Phase 3. httptest-covered (parse, match+post, no-match sentinel, comment, error).
REST shapes per Jira v2; verify against a live appliance before enabling sync.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(jira): complete + correct status maps, add outbound direction (RFC-006 Phase 3d) (#168)
Evaluated the maps against the full 14-status finding vocabulary + the domain
transition graph + approval/verify rules. Changes:
Outbound (NEW — finding status -> Jira status name):
- StatusOutbound map + JiraStatusForFinding() + SyncEnabled (default OFF).
- Stock-Jira defaults (To Do/In Progress/Done) covering new/confirmed/in_progress/
remediation/retest/fix_applied/resolved/verified.
- Deliberately UNMAPPED (no stock status -> comment-fallback / customer config):
false_positive, accepted, accepted_risk, draft, in_review, duplicate.
Inbound (completeness + correctness):
- 'open' -> confirmed (Jira's initial/unstarted status; was wrongly in_progress).
- add 'duplicate' -> duplicate (webhook-settable, no approval), 'verified'/'reviewing'/'selected'.
- Documented WHY false_positive/accepted are NOT inbound-mapped (RequiresApproval)
and why every done-like status -> fix_applied not resolved (resolved needs
verify permission; rescan hook promotes).
No caller yet -> zero behavior change. config.ticketing gains status_outbound +
sync_enabled. Tests cover outbound defaults, overlay, invalid-key skip, switch.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* ci(dependabot): target develop, not the default branch (main) (#169)
Dependabot was opening PRs against main (the default branch). main can lag
develop, so bumps failed CI on code develop had already fixed — e.g. a go-chi
bump flags chimw.RealIP as deprecated (SA1019), which develop already removed but
main still calls. The repo's workflow is 'PRs target develop'; align dependabot
so its PRs are based on the clean integration branch and merge the normal way.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(jira): bidirectional sync — outbound status push (RFC-006 Phase 3c) (#171)
* feat(jira): outbound status-sync engine SyncFindingStatusToTicket (RFC-006 Phase 3c core)
The outbound half of bidirectional sync: push a finding's status to its linked
Jira issue. Self-contained + tested; no caller yet (activation = the finding
status-change trigger + async wiring, a focused follow-up), mirroring how 3a
(client transitions) landed as foundation.
- Extend app-layer Client interface with GetIssueStatus/TransitionToStatus/
AddComment; clientAdapter forwards them and maps infra ErrNoMatchingTransition
to the app sentinel so the caller can fall back to a comment.
- SyncFindingStatusToTicket(tenantID, findingID, mapping):
* opt-in gate (mapping.SyncEnabled, default off) — no surprise Jira writes;
* resolves target via the merged status_outbound map; unmapped status = no-op;
* issue key parsed from finding WorkItemURIs (firstJiraIssueKey);
* ECHO-SAFE: only the OpenCTEM-initiated path calls this (the inbound webhook
updates findings directly, bypassing it) + skips when Jira already at target;
* no workflow transition to target -> comment fallback (never hard-fail).
Tests: transition-when-enabled, disabled-noop, already-at-target skip, comment
fallback on no-transition, unlinked-noop, issue-key parser. go build ./... + vet
green (GOWORK=off).
* feat(jira): activate outbound status sync (RFC-006 Phase 3c)
Wire the outbound engine so it actually fires (still opt-in, default off):
- MappingResolver (app) + IntegrationClientResolver.ResolveMapping (infra) load
per-tenant status_outbound + sync_enabled from the integration config.
- SyncService.SyncFindingStatus(tenantID, findingID) = async entrypoint:
resolve mapping → SyncFindingStatusToTicket; no integration → no-op.
- asynq task jira:sync_finding_status + JiraSyncTaskHandler (+ worker.go
WithJiraStatusSyncer registration) + Client.EnqueueJiraSyncFindingStatus.
- VulnerabilityService.SetJiraStatusSyncHook + trigger in UpdateFindingStatus:
fires ONLY when status changed AND the finding has a work-item link (avoids
noise). Echo-safe: the inbound webhook updates findings via a different path,
so it never re-triggers outbound.
- Wired in cmd/server: SetMappingResolver on JiraSync; NewJobWorker gets the
syncer; main.go sets the enqueue hook from the job client.
End-to-end now: OpenCTEM status change → enqueue → worker → resolve mapping/
client → transition Jira (or comment fallback), gated by config.ticketing.
sync_enabled. Tests: SyncFindingStatus resolver paths + asynq handler (happy +
bad-payload). go build ./... + vet + finding/jira/jobs tests green.
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(ticketing): document bidirectional sync (outbound) + config.ticketing reference (#172)
Update the ticketing architecture doc now that outbound status sync shipped
(#167/#168/#171 + ui#170): both-ways overview, an Outbound status sync section
(asynq flow + echo-safety + why-asynq-not-outbox), a full config.ticketing
reference table (sync_enabled/status_outbound/status_inbound/...), corrected
default mapping tables, roadmap (Phases 2&3 Done), and key files. Completes the
'document features fully' requirement for the bidirectional sync.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix: hidden bugs from deep-dive (jira url-escape, bulk Jira sync, ratelimiter panic guard) (#173)
Three real bugs found by an adversarial cross-codebase review (the rest of the
candidates were verified non-issues — webhook HMAC exists, github split is
guarded, the 'fail-open' baseline path actually fails closed, switch-team is a
Next route):
1. jira/client.go GetIssueStatus: issueKey was not url.PathEscape'd (every
sibling method escapes it) → a key with URL-special chars would corrupt the
echo-guard status check. Renamed the shadowing 'url' var to 'u' and escape.
2. finding bulk status: BulkUpdateFindingsStatus never fired the outbound Jira
sync hook, so bulk status changes silently skipped Jira (single-finding did
sync) — asymmetric. Now fires for each updated finding with a ticket link,
mirroring the single path. Regression test added.
3. redis/ratelimiter: Allow/Status/AllowN type-asserted result[0..2] from
.Slice() with no length check → a short/malformed Redis Lua reply panics the
limiter goroutine. Added len(result) < 3 guards (3 sites).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(websocket): send-on-closed-channel panic race in Client (#174)
* fix(websocket): send-on-closed-channel panic race in Client
SendMessage checked c.closed under c.mu, RELEASED the lock, then sent on c.send;
Close() set closed and close(c.send) after releasing the same lock. Interleaving
(check passes -> Close closes the channel -> send proceeds) panics with 'send on
closed channel' and crashes the whole API process on a routine websocket
disconnect under load.
Fix: hold c.mu across the closed-check AND the (non-blocking) send, and perform
close(c.send) under the same mutex — the pair can no longer interleave, and the
select/default send cannot deadlock against Close. Regression test hammers
SendMessage from 8 goroutines against a concurrent Close (50 rounds, -race
clean) + double-Close idempotency.
* fix(websocket): hub channel senders hang after shutdown, stalling graceful stop
After Run exits (ctx cancelled at server shutdown), Broadcast / DeliverLocal /
RegisterClient / UnregisterClient sent on channels nobody reads — an in-flight
HTTP handler broadcasting a finding event, or a ReadPump/WritePump defer
unregistering its client, blocked forever and stalled graceful shutdown until
the hard timeout.
Add hub.done (closed via defer when Run returns); every channel send selects on
it: post-shutdown broadcasts are dropped (Debug log), registers close the client,
unregisters no-op. Regression test: stop the hub, then call all four senders —
must return promptly (was: permanent hang).
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(report): generic findings executive-summary generator (#175)
The only report generator today is pentest-campaign-specific. Add a generic,
tenant-wide executive summary (pkg/report.GenerateSummaryHTML) built from
vulnerability.FindingStats: total/open/resolved KPIs, severity breakdown with
bars, and a reporting-window movement section (new vs resolved → net backlog
trend). Self-contained printable HTML; all dynamic values escaped by
html/template (XSS-safe, tested). Dependency-free (caller maps FindingStats →
SummaryInput) so it can also back on-demand export.
This is the content engine for the scheduled 'executive_summary' report — the
next piece is the scheduler controller that runs report_schedules.ListDue and
delivers it (the schedule table + ListDue + cron lib already exist; no controller
invokes them today).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs: project assessment & prioritized roadmap (#176)
Whole-platform evaluation (strengths verified across 3 deepdive rounds +
competitive study; gaps in the operator/management layer) and a value-ranked
roadmap. Tier 1 (report scheduler / remediation campaigns / risk trending) all
build on existing infra with no product unknowns; Tier 2 breadth; Tier 3
commercial foundation. Serves as the index for 'what to build next and why'.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(report): scheduler controller — run due schedules + email delivery (#177)
* docs(roadmap): mark Tier-1 #1 (report scheduler) + #3 (risk trending) shipped (#178)
* feat(remediation): compute live campaign progress + auto-complete (#179)
* docs: feature-ideas backlog — full-surface audit + 40 grounded proposals (#180)
* fix(priority): derive reachability from asset exposure (#181)
* feat(sso): platform-wide Entra ID env fallback when tenant has none (#182)
* feat(priority): risk-score explainability endpoint (#183)
* feat(remediation): create Jira epic from a campaign (#184)
* feat(remediation): campaign↔Jira-epic bidirectional sync (outbound + inbound) (#186)
* feat(report): enrich scheduled digest — KEV/EPSS/SLA risk posture + new-vs-resolved trend (#187)
* feat(remediation): include linked Jira epic in campaign responses (#188)
* feat(ticketing): add GitHub Issues as a finding ticket provider (#189)
* feat(ticketing): inbound GitHub issue → finding status sync (#190)
* fix: hidden-bug deep-dive — lifecycle, async resilience, tenant DiD, errorlint (#191)
A focused 4-area sweep (concurrency, SQL/repo, service logic, auth/http);
logic and auth swept clean. Genuine issues fixed:
- workers: the startup session-cleanup ran as a bare `go runCleanup()`
NOT tracked by cleanupWG, so graceful shutdown (Stop→cleanupWG.Wait)
didn't wait for it — it could race a torn-down DB pool. Now runs inside
the tracked goroutine before the ticker loop.
- admin_auth: the per-request async RecordUsage used context.Background()
with NO timeout and read the *http.Request inside the goroutine. Under a
burst with a slow DB this piles up unbounded goroutines (the failure mode
that previously OOM'd the audit writer); reading r async is also a
request-recycle race. Now bounded by auditWriteTimeout, with the IP/ID
captured before the goroutine.
- agent service: async UpdateLastSeen used an unbounded context.Background();
bounded with a 5s timeout (high-frequency path).
- asset repo: the has_findings EXISTS subquery wasn't scoped to
a.tenant_id. Not a live leak (a finding's tenant equals its asset's), but
added as defense-in-depth so a stray cross-tenant finding row can't flip
the filter.
- errorlint: 3 repos compared `err == sql.ErrNoRows` instead of
errors.Is (repo standard); converted (+ errors import).
Build + touched-package tests green; gofmt clean.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(ticketing): outbound GitHub issue status sync (#193)
Completes GitHub Issues provider parity with Jira (create + inbound
already shipped) by pushing OpenCTEM finding status changes to the
linked GitHub issue.
- scm.GitHubClient.UpdateIssueState: PATCH issue state (open/closed),
path-escaped owner/repo, status-only errors (no body leak)
- ticketing.GitHubTicketService.SyncFindingStatus: resolves the tenant
GitHub integration, no-ops when the finding has no linked GitHub
issue, closes/reopens by FindingStatus.IsClosed()
- jobs: github:sync_finding_status asynq task + handler, wired via
WithGitHubStatusSyncer; enqueued from the finding status-change hook
alongside the Jira push (each provider no-ops when not linked)
- tests: UpdateIssueState httptest (success/invalid-state/non-200),
github sync handler (calls/bad-payload/invalid-ids), SyncFindingStatus
close/reopen/no-link
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(auth): verify SSO id_token signature, nonce, issuer & audience (#194)
Closes the documented OIDC hardening follow-up. The SSO callback now
validates the provider id_token (when one is returned) before completing
login, in addition to the existing access-token -> Graph /me identity.
- oidcVerifier (new): RS256 signature against the provider JWKS with a
per-URL key cache (1h TTL, refresh on unknown kid); rejects alg=none
and non-RS256; enforces aud==client_id, exp/nbf/iat with leeway, and a
provider-specific issuer check
- nonce: validateState now returns the authorize-time nonce; the callback
compares it (constant-time) to the id_token nonce claim (replay guard)
- Entra issuer: must be login.microsoftonline.com/{tid}/v2.0 consistent
with the token tid; single-tenant configs pin the directory, multi-tenant
authorities (common/organizations/consumers) accept any
- Provider.JWKSURL added (mirrors AuthEndpoints) for entra/google/okta
- fail-closed when id_token present; skipped when absent (server-to-server
TLS token response, not attacker-controllable) for backward compat
- tests: valid, nonce/aud/issuer/expiry/signature/alg-none/tenant-mismatch,
JWKS parsing, issuer validator table
- docs: sso-authentication.md moves id_token validation to shipped
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(validation): evidence ingestion MVP — record proof-of-fix, reconcile finding status (#195)
Activates CTEM Stage-4 (Validation), which was domain+orchestration only
(no persistence, no endpoint, no wiring). Agents can now POST validation/
proof-of-fix evidence for a finding; it is persisted (redacted) and the
finding status is reconciled from the outcome.
- migration 000178: validation_evidence table (tenant+finding scoped,
JSONB envelope + denormalised columns, outcome CHECK, indexes)
- postgres ValidationEvidenceRepository (implements EvidenceRepository)
- validation.EvidenceIngestService: validates, tenant-guards the finding,
records via EvidenceStore, applies outcome (not_detected→resolved,
detected→in_progress+notify, else no-op); transition-blocked is non-fatal
- shared applyOutcomeToFinding extracted from ProofOfFixService so the
ingest and dispatch paths share one outcome→status mapping
- HTTP: POST /api/v1/validation/evidence (agent API-key auth, tenant from
agent context never the body) returns 202; GET /api/v1/findings/{id}/
evidence (JWT, findings:read)
- wired: repositories, services (findingMutatorAdapter), handlers, routes
- tests: ingest service (outcomes, tenant guard, invalid outcome, blocked
transition, list) + handler (202, 401, 400, 404, list)
- docs/architecture/validation-engine.md (shipped vs deferred)
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(reports): pentest report PDF export (pure Go, no headless browser) (#196)
Adds server-side PDF generation for pentest reports via ?format=pdf on the
existing download endpoint. The PDF is rendered directly from the structured
report data with go-pdf/fpdf (BSD-3, pure Go, no cgo) — no headless browser
or external render service, so nothing heavy is added to the image.
- pkg/report/pdf.go: GeneratePDF(ReportInput) — title, engagement details,
team, severity summary, per-finding sections (badge/meta/description/steps/
impact/remediation/PoC/targets/refs), classification+watermark footer,
auto pagination; text mapped to cp1252 via fpdf unicode translator
- compliance: extracted buildReportInput shared by GenerateReportHTML and the
new GenerateReportPDF; finding-mapping pulled into pentestFindingToReportData
+ metaStringSlice helpers
- handler: DownloadReport honours ?format=pdf (application/pdf) else HTML
- tests: valid PDF (magic + EOF), empty findings, minimal input, exclude-poc
- docs/architecture/report-pdf-export.md
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(rfc): RFC-009 enterprise SSO — SAML 2.0 + SCIM 2.0 provisioning (#197)
Design for the two enterprise-identity gaps beyond the current OIDC SSO:
inbound SAML 2.0 login and SCIM 2.0 automated user lifecycle. Grounded in
the existing apikey crypto (HashTokenPeppered/VerifyTokenHashAny), the
membership Suspend/Reactivate path (immediate session revoke), role strings,
and the OIDC identity-resolution tail. Phased so SCIM (pure REST, fully
unit-testable, highest deprovisioning value) ships first. Surfaces the two
real decisions: SAML library (crewjam/saml — no hand-rolled XML-sig crypto)
and the need for an Okta/Azure test tenant for end-to-end validation.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(scim): SCIM 2.0 provisioning core — tokens + Users (RFC-009 9a/9b) (#198)
Inbound user lifecycle: a tenant IdP (Okta/Azure AD) provisions and, crucially,
deprovisions users over SCIM 2.0. Deactivation suspends the tenant membership,
which revokes sessions + clears the permission cache immediately (0-second
offboarding) — the gap JIT/invite-only login left open.
Auth (9a):
- scim_tokens table (migration 000179) + scimtoken domain entity + repo
- per-tenant bearer token stored as peppered HMAC-SHA256 (crypto.HashTokenPeppered,
pepper = APP_ENCRYPTION_KEY), plaintext shown once; mirrors the API-key scheme
- scim.TokenService (mint/list/revoke/authenticate, tenant-scoped revoke)
- middleware.SCIMAuth resolves the tenant from the token into context (one token
= one tenant → isolation by construction; tenant never read from the body)
- admin endpoints POST/GET/DELETE /api/v1/scim-tokens (JWT, owner/admin)
Provisioning (9b):
- scim.ProvisioningService maps SCIM Users onto users + memberships: find-or-
create a passwordless local user (SSO can later claim it), add/suspend/
reactivate membership via TenantService (full audit + session-revoke semantics)
- /scim/v2/Users create(201/200 idempotent)/get/list+filter(userName eq)/PUT/
PATCH(active)/DELETE + ServiceProviderConfig/ResourceTypes/Schemas
- RFC-7644 error envelope; PATCH rejects unsupported paths with 400 invalidPath
- wired: repositories, services (scimMembershipAdapter injects SCIM audit ctx),
handlers, routes
Tests: token service (mint→auth roundtrip, revoked/bad rejected, cross-tenant
revoke blocked); provisioning (create/idempotent/deactivate/reactivate/not-member/
filter/create-inactive); handler via real SCIMAuth (201, 401, get, 404, filter,
PATCH-deactivate, DELETE, unsupported-path 400).
Docs: docs/architecture/scim-provisioning.md + RFC-009 status (9a/9b shipped).
Deferred: Groups (9c), admin UI, SAML SP (9d-9f).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(scim): three hidden bugs in SCIM provisioning (#199)
Deep-dive review of the SCIM 2.0 provisioning code surfaced three real
defects (all missed by the original unit tests because the fakes bypassed
the affected paths):
1. (HIGH) New-member provisioning would fail with an FK violation.
TenantService.AddMember wrapped its inviterUserID as a non-nil invited_by
even when zero; the SCIM adapter passes a zero inviter (no human inviter),
so the insert wrote the all-zeros UUID into invited_by, violating the
invited_by -> users(id) foreign key. Fix: a zero inviter maps to NULL.
2. (MEDIUM/HIGH) A revoked token could be silently resurrected. The
last-used stamp on the auth path called repo.Update, whose UPDATE sets
status from the stale in-memory token; a touch racing with a revoke wrote
status=active back, undoing the revoke permanently. Fix: TouchLastUsed —
a status-preserving UPDATE guarded by 'AND status = active'.
3. (LOW) SCIM list with count=0 returned all members instead of zero
resources (RFC-7644 §3.4.2.4: count=0 = totalResults only). Fix: honour
count=0 as an empty page; count<0 = no limit.
Regression tests added for each (AddMember zero-inviter -> nil invited_by;
revoked token stays revoked after a concurrent touch; count=0 -> empty page).
Other deep-dive candidates (JWKS refresh single-flight, SSO state single-use)
are perf/pre-existing-design, not correctness bugs — left as follow-ups.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(scim): system deprovisioning fails (no actor) + real-DB integration test (#200)
A real-DB integration test of the SCIM provision->deprovision round-trip
(the path the unit fakes bypassed) surfaced a 4th hidden bug:
SCIM deprovisioning (PATCH active:false / DELETE) would 500 in production.
The SCIM membership adapter calls TenantService.SuspendMember with a system
audit context that has no ActorID; SuspendMember required a valid acting user
id ("invalid acting user id"), and even past that, membership.Suspend wrapped
the zero actor as a non-nil suspended_by → would violate the suspended_by ->
users(id) FK.
Fixes:
- TenantService.SuspendMember: an empty ActorID = system action (no human
suspender) instead of an error; a non-empty malformed id is still rejected.
- membership.Suspend: a zero actor → suspended_by nil (NULL), mirroring the
invited_by fix; correct at the entity level, not just papered over by the
repo's nullID on write.
- normal admin suspend (real ActorID) is unchanged.
Tests:
- tests/integration/scim_provisioning_test.go (NEW): real-Postgres round trip —
provision new user (asserts invited_by NULL — the api#199 regression),
idempotent re-provision, deprovision (asserts status=suspended), reactivate,
and token mint/authenticate/revoke. Skips when no DB; verified locally
against postgres:17 with all 179 migrations applied.
- unit: SuspendMember with empty ActorID → suspended, suspended_by nil.
This closes the verification gap that hid both this bug and api#199: the SCIM
provisioning now has end-to-end coverage against real SQL/FKs.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* test(validation): real-DB integration coverage for evidence ingest (#201)
Broadens integration coverage to the validation evidence path (same gap class
as the SCIM fakes). Verified against postgres:17 with all migrations applied:
- ValidationEvidenceRepository Create→ListByFinding round-trip: JSONB Evidence
envelope (incl shared.ID target), NULL vs set simulation_run_id, newest-first.
- EvidenceIngestService tenant guard: an agent in tenant B cannot record
evidence against tenant A's finding (ErrNotFound, no row written).
- full ingest: not_detected on a fix_applied finding records evidence and
transitions it to resolved.
Skips gracefully when no DB. The path verified clean — no defects found.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(scim): SCIM 2.0 Groups → role mapping (RFC-009 Phase 9c) (#202)
Completes SCIM provisioning: an IdP can now drive a user's tenant role via
group membership, not just create them as 'member'.
- migration 000180: scim_groups + scim_group_members
- pkg/domain/scimgroup + postgres ScimGroupRepository (CRUD + member ops +
RoleGroupNamesForUser)
- scim.GroupService: a group whose displayName (case-insensitive) is a tenant
role (admin/member/viewer) maps its members to that role; effective role =
highest-privilege role-group, else member; owner never assignable. Every
add/remove/replace/delete reconciles affected users through
TenantService.UpdateMemberRole (full audit + cache invalidation). Group
membership is authoritative (removal from last role-group reverts to member).
- /scim/v2/Groups create/read/list/PUT/PATCH/DELETE; PATCH supports both Okta
(member value-arrays) and Azure AD (members[value eq "id"] path filters)
- wired: repositories, services (scimMembershipAdapter gains UpdateMemberRole),
handlers, routes
Tests:
- unit: effectiveRole precedence table; handler PATCH parsing (Okta value-array,
members-object, Azure path-filter).
- integration (real Postgres, tests/integration/scim_groups_test.go): full
lifecycle — provision member → admin group promotes → admin wins over viewer
→ remove-from-admin reverts to viewer → delete-last-role-group reverts to
member; + group repository CRUD/member round-trip. Verified against
postgres:17 with migrations through 000180.
Docs: scim-provisioning.md (Groups section) + RFC-009 status (9a-9c done).
Deferred: admin UI, SAML SP (9d-9f).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(scim): configurable group -> role mapping (RFC-009 9c refinement) (#203)
Makes SCIM Groups usable with real IdPs, whose groups are named arbitrarily
(e.g. 'Acme-OpenCTEM-Admins'), not literally admin/member/viewer.
- migration 000181: scim_group_role_mappings (tenant_id, group_name lowercased,
role; owner excluded via CHECK)
- scimgroup.Repository: GetRoleMappings / ReplaceRoleMappings + postgres impl
- GroupService: effectiveRole now consults the per-tenant mapping first, then
the built-in name-match default (highest-privilege match wins, none → member);
SetRoleMappings validates roles (rejects owner) and re-reconciles all current
group members immediately so the change takes effect at once
- admin API: GET/PUT /api/v1/scim-tokens/group-mappings (JWT owner/admin)
Tests:
- unit: effectiveRole with custom mappings (precedence, case-insensitive,
fallback to name-match, owner rejection via SetRoleMappings).
- integration (real Postgres): set mapping for an arbitrary group name →
creating that group promotes the member to admin; mapping read round-trip;
owner rejected. Verified against postgres:17 through migration 000181.
Docs: scim-provisioning.md group-mapping section. Reverted an unused
crewjam/saml dep added while scoping SAML (9d-9f remains a focused follow-up).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(saml): SAML 2.0 SP config + metadata + federated-login seam (RFC-009 9d) (#204)
* feat(saml): SAML 2.0 SP config + metadata + federated-login seam (RFC-009 9d)
First phase of SAML SSO: an enterprise can configure their IdP and download SP
metadata. The SP-initiated login + ACS (9e) build on this.
- migration 000182 saml_providers (per-tenant, disabled by default)
- pkg/domain/samlprovider + postgres repo (allowed_domains TEXT[]; nil coerced
to '{}' so a no-domain config saves cleanly)
- auth.SAMLService: config CRUD + PEM X.509 cert validation + SP metadata via
github.com/crewjam/saml (XML-dsig handled by the library, not hand-rolled)
- SSOService.CompleteFederatedLogin: shared session/provisioning tail for
externally-authenticated identities — claimable passwordless user, tenant
auto-provision, and an ACCOUNT-TAKEOVER GUARD (a password-backed local
account cannot be logged into via an external assertion)
- HTTP: GET /api/v1/auth/saml/{org}/metadata (public, SP URLs derived from the
request host incl X-Forwarded-*); GET/PUT/DELETE /api/v1/settings/saml (JWT
owner/admin)
- wired: repositories, services, handlers, routes, app shim aliases
Tests:
- unit: cert validation, UpsertConfig (required fields/cert/role, upsert
preserves id); CompleteFederatedLogin (creates user+session, takeover guard
blocks password users, reuses passwordless users).
- integration (real Postgres): config Upsert→Get→update→delete round-trip
(incl allowed_domains array + enabled) — caught a real NOT-NULL bug on a nil
domains array; SP metadata generation. Verified through migration 000182.
Deferred: SP login + ACS (9e, the replay-sensitive step needing a live IdP),
IdP-initiated + SLO (9f), admin UI.
* fix(saml): bump goxmldsig to v1.6.0 (GO-2026-4753 signature-bypass)
govulncheck flagged GO-2026-4753 — a signature-bypass in goxmldsig v1.4.0
(pulled in transitively by crewjam/saml), which is precisely the XML-dsig
library SAML assertion validation relies on. Bumped to v1.6.0 (the fixed
version) via an explicit require; build + auth tests green.
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* feat(ticketing): default Jira project + project picker + wire mapping into create (#207)
CreateTicketFromFinding now resolves the tenant's config.ticketing mapping and
uses it for the destination project, issue type, and severity->priority — the
create path previously ignored MappingConfig (hardcoded 'Bug' + stock priority).
Defaults reproduce the original behavior exactly.
- MappingConfig.DefaultProjectKey (config.ticketing.project_key): the per-tenant
'where do tickets go' default. Destination resolution is explicit request key
-> tenant default -> validation error (never guess).
- Client.ListProjects + infra impl (GET /rest/api/2/project/search, paginated,
bounded) feeds an admin project picker via
GET /api/v1/integrations/jira/projects (IntegrationsRead).
- Unit tests: project_key parse, default-project fallback, explicit override,
no-project validation error, ListProjects wiring + pagination + error status.
- docs: ticketing-integration.md (config ref, setup, roadmap 2b).
RFC-006 Phase 2b.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(rfc): RFC-010 Jira Assets / JSM CMDB integration (enrich + reconcile) (#208)
Designs the one place Jira and 'asset' legitimately intersect: reconciling JSM
Assets/CMDB objects with OpenCTEM's asset inventory (pull-first enrich, CI↔ticket
link, opt-in push). Clarifies project = routing destination (not asset), site =
web_application asset, CMDB objects = asset records. Phased 10a–10d; license-gated
(JSM Premium), per-tenant schema mapping, tenant-isolated, pull-never-deletes.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* chore: stop tracking .claude/settings.local.json (per-developer local config)
It is machine-local Claude Code config (the .local suffix denotes user-local,
not shared). Remove from the index and gitignore it so it stops being pushed.
.claude/settings.json (shared) is unaffected.
* deps(go): bump github.com/redis/go-redis/v9 in the go-minor-patch group (#211)
Bumps the go-minor-patch group with 1 update: [github.com/redis/go-redis/v9](https://github.com/redis/go-redis).
Updates `github.com/redis/go-redis/v9` from 9.20.1 to 9.21.0
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.20.1...v9.21.0)
---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
dependency-version: 9.21.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-minor-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat(ticketing): routing rules — route findings to projects by severity/tag/asset attrs (#209) (#212)
Adds config.ticketing.routing: ordered rules that select the destination Jira
project (+ optional issue_type) by matching finding/asset attributes, so 'each
project = a team/business unit' without modeling a project as an asset.
- MappingConfig.Routing + RoutingRule (severity/tag/scope/criticality/asset_group);
match semantics: OR within a condition, AND across, empty = wildcard, first
match wins. RouteFor() + matches().
- CreateTicketFromFinding destination order: explicit project_key -> routing
match -> default project -> validation error. A route may override issue_type.
- AssetRouteResolver (optional) supplies asset scope/criticality from the asset
repo; wired in cmd/server. asset_group dimension parsed+matchable but not yet
populated (deferred). Routing context lookup is best-effort — never blocks create.
- Tests: routing parse (string|array, drop no-project), match matrix (AND/OR/
wildcard/first-match), create routes by asset scope + issue-type override,
fall-through to default, explicit wins over routing.
- docs: ticketing-integration.md routing section + config ref + roadmap 4a.
RFC-006 Phase 4a.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* ci: drop Dependabot docker ecosystem (ECR Public not queryable → always errored) (#213)
The Dockerfiles pin base images on public.ecr.aws to dodge Docker Hub pull rate
limits, but Dependabot's docker updater can't auth/query ECR Public for tags —
the weekly 'docker in /.' job failed every run (DENIED / HTTP 429) and never
produced a PR. Remove the non-functional ecosystem to stop the recurring red
CI; base images are bumped manually.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* docs(ticketing): reflect shipped operator UI (picker, create-ticket, routing + mapping editors) (#214)
Update RFC-006 roadmap + add a UI-surfaces table mapping each operator screen to
its PR. Corrects a stale entry that credited a ui#170 mapping editor that never
reached develop — the real UI landed in ui#184/#189/#192/#193. Notes the one
remaining UI gap (inbound status-name mapping editor).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* deps(actions): bump imjasonh/setup-crane from 0.5 to 0.7 (#215)
Bumps [imjasonh/setup-crane](https://github.com/imjasonh/setup-crane) from 0.5 to 0.7.
- [Release notes](https://github.com/imjasonh/setup-crane/releases)
- [Commits](https://github.com/imjasonh/setup-crane/compare/v0.5...v0.7)
---
updated-dependencies:
- dependency-name: imjasonh/setup-crane
dependency-version: '0.7'
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* deps(go): bump golang.org/x/tools in the go-minor-patch group (#216)
Bumps the go-minor-patch group with 1 update: [golang.org/x/tools](https://github.com/golang/tools).
Updates `golang.org/x/tools` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.46.0...v0.47.0)
---
updated-dependencies:
- dependency-name: golang.org/x/tools
dependency-version: 0.47.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: go-minor-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix(jira): case-insensitive idempotency marker + robust ticket-key extraction (#217)
Two low-severity create-ticket bugs found in a deep review:
- The idempotency check built '/browse/<projectKey>-' case-sensitively, so a
lower-case configured project key (e.g. 'sec') never matched the upper-case
Jira browse URL ('/browse/SEC-123') → a duplicate ticket on every retry. Now
compares upper-cased.
- The idempotent-hit key was taken via LastIndex('/'), which mangles a URL with
a trailing slash or ?query. Use the shared jiraBrowseKeyRe (fallback to the
path segment) so the returned TicketKey is the clean 'SEC-123'.
Tests cover lower-case project + dirty-URL extraction.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(auth): block cross-IdP account takeover in OAuth/SSO find-or-create (HIGH) (#218)
Users are matched by email on federated login, but the account-takeover guard
only blocked a password-backed LOCAL account — it did NOT block a DIFFERENT
federated provider. So an account created via provider A (e.g. Google) could be
taken over by an attacker who controls provider B (e.g. GitHub) with the same
verified email: existingProvider!=expected but existingProvider!=Local, so the
guard was skipped and findOrCreateUser returned the victim's account.
Now: on any provider mismatch, only a CLAIMABLE LOCAL account (invited, no
password yet) may be adopted by the IdP (the legitimate invite→SSO flow); a
password-backed local account AND any different federated provider are blocked.
A verified email at one IdP does not prove ownership of an account at another.
Fixed in both internal/app/auth/oauth.go (OAuth) and sso.go (SSO). Tests cover:
cross-federated blocked, same-provider OK, claimable-local adopted, local+password
blocked. Exploitable only when >=2 federated providers are enabled for a tenant.
Found in a core security audit.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(security): scope credential-bearing repo Updates by tenant_id (defense-in-depth) (#219)
A core audit found the repo layer relies entirely on caller discipline for
tenant isolation (RLS is implemented but unwired). Update statements on
credential-bearing tables filtered on 'WHERE id = $1' only, despite the entity
carrying TenantID(). Callers compensate today with a tenant-scoped pre-fetch,
but a future caller that forgets would cross tenants.
Add 'AND tenant_id = $N' to Update on the highest-value tables: api_keys,
scim_tokens, credentials (secret store), integrations (encrypted creds). The
entity already exposes TenantID(); no signature change. Rows-affected guards
already return NotFound, so a wrong-tenant update now fails closed instead of
silently matching by id. Found in the core security audit.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(reports): validate report-schedule cron + report_type at creation (#220)
* fix(reports): validate report schedule cron syntax + report_type at creation
NewReportSchedule only checked non-empty + cron length. A core audit flagged
that arbitrary report_type/format/cron persist and are consumed later by the
scheduler. Add: cron parsed with the same robfig/cron 5-field parser the
scheduler uses (a malformed cron would otherwise break the run), and report_type
whitelisted to the types the scheduler actually generates (executive_summary/
summary/findings — mirrors ReportScheduler.supportsType). Format left permissive
(not consumed by the scheduler yet). Tests cover valid/invalid cron + type.
* test(reports): build invalid-state schedules via Reconstitute in scheduler tests
The new NewReportSchedule validation (cron syntax + report_type whitelist)
correctly rejects the intentionally-invalid schedules these scheduler tests
used to construct via the newSchedule helper. Switch the helper to
ReconstituteReportSchedule (no validation — simulates rows already persisted)
so UnsupportedType_Skips / BadCron_FallsBackTo24h still exercise the scheduler's
defensive runtime handling. Creation-time validation is covered separately.
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(core): reject empty-tenant on delete (IDOR) + align AutoReopen status (#221)
Core-audit LOW findings:
- T2: DeleteExposure and DeleteSLAPolicy skipped the tenant ownership check when
tenantID was empty ('if tenantID != ""'). Not reachable today (HTTP callers
pass MustGetTenantID), but a future empty-tenant caller would bypass the IDOR
guard. Now fail closed: empty tenant returns a validation error and no delete.
Updated the exposure test that asserted the old skip behavior.
- I1: AutoReopenByFingerprint (single) reopened to status='open' and left
resolution_method set, while the batch version (the one ingest uses) reopens to
'confirmed' and clears resolution_method. Align the single to the batch so both
paths agree (a re-detected auto-resolved finding is confirmed-present).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(auth): bind SSO/OAuth sessions to their JWT so they are revocable (A3) (#222)
The SSO and OAuth global-login createSession minted GenerateTokenPair(userID,
"", "user") — an EMPTY session id — then persisted a session via
sessiondom.New() under a different random id. The JWT therefore carried no
session id, so the session row and the token were unlinked and the SSO/OAuth
access token could not be revoked (the password Login flow does NOT have this
gap — it generates the id first and embeds it).
Mirror the password flow: generate the session id first, embed it in the JWT,
and persist the session via sessiondom.NewWithID(sessionID, ...). Now an
SSO/OAuth session is revocable like a password session. Found in the core audit.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* chore(security): delete dead unscoped repo + handler (landmines) (#223)
A core audit found two dead, fully-unscoped code paths — uninstantiated/unmounted
but a future wiring would cross tenants:
- internal/infra/postgres/datasource_repository.go: DataSourceRepository with
unscoped GetByID/Update/Delete on the multi-tenant data_sources table.
NewDataSourceRepository has zero callers (verified).
- internal/infra/http/handler/rule_handler.go: the catalog RuleHandler (uses an
unscoped RuleService.GetRule, //getbyid:unsafe). NewRuleHandler is never
constructed or routed — the mounted /{id} rule routes belong to
AssignmentRuleHandler/SuppressionHandler. Its local timeFormat const is used
nowhere else.
Both verified dead (no refs, no tests, build green). Removing them deletes the
unscoped-SQL landmines outright.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(security): enforce agent ingest/telemetry rate limits (DoS bypass) (#224)
The per-tenant ingest (20 rps) and runtime-telemetry (200 rps) rate limiters
on the agent API-key routes were permanent no-ops. TelemetryRateLimiter.Middleware
keys on middleware.GetTenantID(ctx), but the agent auth middleware
(IngestHandler.AuthenticateSource) only stored the agent under agentContextKey
and never set TenantIDKey. So GetTenantID returned "" on every agent request and
the limiter fell through its empty-tenant pass-through branch — the exact
'compromised agent key replays cached batches at line rate' abuse the limiters
were built to stop went fully unmitigated.
Fix: AuthenticateSource now also sets TenantIDKey from the authenticated agent's
tenant, so the limiters (which run later in the chain) key on the real tenant.
This also makes middleware.GetTenantID correct for any agent-route handler that
reads it. Platform agents (nil tenant) are out of scope here — they are rejected
by per-handler nil-tenant guards before acting (follow-up) and cannot be created
in this codebase.
Adds telemetry_ratelimit_test.go: enforces-when-tenant-present (throttles a
flood) + passes-through-when-absent (regression guard for the original gap).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(security): gate workflow action nodes by per-resource permission (#225)
Creating or triggering a workflow required only WorkflowsWrite
("findings:workflows:write"), but action nodes execute privileged mutations
through the same services as the direct API routes: update_status/assign/tags/
create_ticket/trigger_ai_triage (FindingsWrite), trigger_scan (ScansWrite),
trigger_pipeline (PipelinesWrite). Permission matching is exact (no wildcard),
so a member granted only WorkflowsWrite could build a workflow whose actions
perform finding mutations, scans and pipeline runs they were never granted —
an intra-tenant privilege escalation. (Tenant isolation was never affected;
every downstream service re-checks tenant ownership.)
Fix: gate action nodes at workflow create / graph-update / add-node / update-node
by the permission of their underlying mutation, mirroring the direct routes.
Checking at build time (rather than per-run) covers both manual (POST /runs) and
event-dispatched executions, which carry no actor context. Owners/admins bypass
via middleware.HasPermission. run_script (disabled) and http_request (outbound,
no platform resource) need nothing beyond WorkflowsWrite.
New workflow_action_authz.go (mapping + enforcement helper) +
workflow_action_authz_test.go (denial/allow/admin-bypass/non-gated/mixed-graph).
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(workflow): unimplemented action handlers fail loudly (no false success) (#226)
* fix(workflow): unimplemented action handlers fail loudly, not false-success
assign_team, update_priority, create_ticket and update_ticket action handlers
returned {"assigned"/"updated"/"created": true} without doing anything — the
node run was marked successful, so operators believed findings were routed to
teams / priorities updated / Jira tickets filed when nothing happened (the
integration service is injected but was never called). This contradicts the
project rule against shipping half-wired features.
These now return a clear 'not implemented' error so the node — and the run —
fail loudly and visibly until the backing services are wired (create_ticket
should follow the same path as POST /findings/{id}/create-ticket; the wired
siblings updateStatus/assignUser/addTags show the pattern). Config validation
still runs first, so a misconfigured node reports the precise config problem.
Adds action_handlers_test.go covering fail-loud + config-validated-first.
* test: align workflow stub-action tests with fail-loud behavior
The tests/unit suite asserted the old false-success behavior of the four stub
actions (assign_team/update_priority/create_ticket/update_ticket returning
{assigned/updated/created:true}). Updated those _Success tests to _NotImplemented
expecting the 'not implemented' error + nil result, and removed
UpdatePriority_AnyStringIsAccepted (premise no longer holds). Config-validation
(Missing*) tests are unchanged — validation still runs first.
---------
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(security): guard agent handlers against nil-tenant platform agents (#227)
Platform agents (is_platform_agent, tenant_id NULL) authenticate fine but the
command (Poll/Acknowledge/Start/Complete/Fail), runtime-telemetry and chunk-
ingest handlers dereferenced agt.TenantID.String() unconditionally — a nil deref
recovered by middleware as a 500 instead of a clean 403. The scansession handler
already had the correct 'if agt.TenantID == nil { Forbidden }' guard; this aligns
the rest.
Added requireAgentTenant(w, agt) (403 for nil tenant) on the tenant-scoped agent
operations, and agentTenantString(agt) (nil-safe "") for the heartbeat response
field, where a tenant-less agent should still be able to heartbeat rather than be
rejected. Completes the nil-tenant story from the agent rate-limit fix.
Not reachable today (no code path provisions a platform agent — AgentRepository
.Create itself derefs nil tenant; the 'Platform Agents v3.2' architecture is
dead/unimplemented), so this is defensive hardening + consistency. Tests in
agent_tenant_guard_test.go.
Co-authored-by: Nguyen Manh <0xmanhnv@gmail.com>
* fix(security): bind federated accounts to their IdP issuer (cross-IdP takeover) (#228)
* fix(security): bind federated accounts to their IdP issuer (cross-IdP takeover)
The #218 takeover fix only blocked a provider *mismatch*, but mapAuthProvider
collapses every Okta org AND every generic OIDC IdP to a single
AuthProviderOIDC, and the user record stored only that coarse enum — no issuer
or subject. So on an email match where both sides are 'oidc', findOrCreateUser
adopted the existing account. An attacker who configures their OWN tenant's
Okta/OIDC IdP (Okta org URLs are only restricted to *.okta.com, which the
attacker can register), asserts a victim's email in their own directory
(controlling email_verified), and completes SSO would be handed a session for
the victim's user — then ExchangeToken into every tenant the victim belongs to.
The Okta id_token path does not validate issuer (only Entra checks tid), so
nothing caught the foreign IdP.
Fix: bind a federated account to the verified id_token issuer (+subject).
- migration 000183: users.federated_issuer / federated_subject (nullable).
- verifyIDToken now returns the verified (issuer, subject); the SSO callback
carries them into SSOUserInfo (authoritative — signature+JWKS-pinned, not the
userinfo body).
- findOrCreateUser: on adoption, a recorded issuer must match the login issuer
or the login is blocked; a pre-tracking/claimable account is bound on first
use (trust-on-first-use) and enforced thereafter; new federated users record
the issuer at creation. When no id_token is present (issuer empty) behaviour
is unchanged (no regression) — the provider-match guard still applies.
Scope: the SSO path (Okta/generic OIDC) where the enum collapses. Google/GitHub
OAuth are distinct enums already separated by #218; Entra validates tid.
Tests: sso_idp_binding_test.go (cross-IdP blocked, same-issuer ok, legacy TOFU
bind, no-issuer no-regression, new-user binds). Existing Reconstitute call sites
updated for the 2 new trailing params.
* fix(sso): create Okta / generic-OIDC users (NewOAuthUser rejected OIDC)
mapAuthProvider maps Okta and generic OIDC to AuthProviderOIDC, but the SSO
create path called NewOAuthUser, whose IsOAuth() check rejects OIDC — so a
first-time login via Okta or a generic OIDC IdP failed with 'invalid OAuth
provider: oidc' and no account was created (Entra→Microsoft and Google→Google
worked; Okta/generic did not). Added AuthProvider.IsFederated() and
NewFederatedUser (accepts OAuth providers AND OIDC) and switched the SSO create
path to it. Test: TestSSOFindOrCreate_NewOktaUserCreated.
* fix(security): apply the takeover guard on the SSO create-race retry path
findOrCreateUser's create path retries GetByEmail when Create fails (c…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps actions/upload-artifact from 4 to 6.
Release notes
Sourced from actions/upload-artifact's releases.
... (truncated)
Commits
b7c566aMerge pull request #745 from actions/upload-artifact-v6-releasee516bc8docs: correct description of Node.js 24 support in READMEddc45eddocs: update README to correct action name for Node.js 24 support615b319chore: release v6.0.0 for Node.js 24 support017748bMerge pull request #744 from actions/fix-storage-blob38d4c79chore: rebuild dist7d27270chore: add missing license cache files for@actions/core,@actions/io, and mi...5f643d3chore: update license files for@actions/artifact@5.0.1 dependencies1df1684chore: update package-lock.json with@actions/artifact@5.0.1b5b1a91fix: update@actions/artifactto ^5.0.0 for Node.js 24 punycode fixDependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting
@dependabot rebase.Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
@dependabot rebasewill rebase this PR@dependabot recreatewill recreate this PR, overwriting any edits that have been made to it@dependabot show <dependency name> ignore conditionswill show all of the ignore conditions of the specified dependency@dependabot ignore this major versionwill close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this minor versionwill close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this dependencywill close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)