@bongos/core 1.18.19 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +514 -194
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +5 -5
- package/.claude/skills/scan-before-install/SKILL.md +165 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +63 -41
- package/clients/bongos-client/index.cjs +63 -41
- package/clients/bongos-client/index.d.ts +78 -44
- package/clients/bongos-client/index.mjs +63 -41
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
- package/docs/adr/0156-user-set-display-name-wins.md +62 -0
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
- package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
- package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
- package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
- package/docs/adr/README.md +8 -0
- package/docs/api/openapi.json +1209 -233
- package/docs/api-reference.md +61 -46
- package/docs/architecture.md +1 -1
- package/docs/canonical-permissions.md +61 -16
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +4 -1
- package/docs/onboarding/diagrams/assertions.json +3 -3
- package/docs/recipes/core-release-pipeline.md +179 -0
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +1 -3
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/migrations/core_197_display_name_source.sql +56 -0
- package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
- package/modules/autonomy/routes/autonomy.js +1 -1
- package/modules/builder-settings/db.js +7 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
- package/modules/dev-box/routes/box.js +11 -9
- package/modules/discord/auth.js +3 -1
- package/modules/discord/routes/discord.js +8 -2
- package/modules/discord/ship-broadcast.js +48 -1
- package/modules/governance/catalog.js +70 -20
- package/modules/governance/db.js +102 -0
- package/modules/governance/migrations/governance_006_version_create.sql +38 -0
- package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
- package/modules/governance/routes/governance.js +235 -6
- package/modules/grading/grader-workers/index.js +18 -9
- package/modules/hall-ui/public/gate.js +6 -2
- package/modules/hall-ui/public/goals-page.js +6 -2
- package/modules/hall-ui/public/governance.css +100 -0
- package/modules/hall-ui/public/governance.html +77 -0
- package/modules/hall-ui/public/governance.js +353 -0
- package/modules/hall-ui/public/harbor.js +14 -8
- package/modules/hall-ui/public/instances-panel.js +3 -1
- package/modules/hall-ui/public/new-project-wizard.js +6 -0
- package/modules/hall-ui/public/people.html +51 -0
- package/modules/hall-ui/public/people.js +100 -0
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +9 -3
- package/modules/hall-ui/public/sessions.js +4 -2
- package/modules/hall-ui/public/shell.js +28 -7
- package/modules/hall-ui/public/task-detail.js +6 -3
- package/modules/hall-ui/public/watch.js +17 -9
- package/modules/ideas/blockers.js +44 -7
- package/modules/ideas/routes/blockers.js +12 -7
- package/modules/ideas/routes/inbox.js +2 -2
- package/modules/lifecycle/db.js +84 -17
- package/modules/lifecycle/github-push.js +37 -10
- package/modules/lifecycle/publish-reconciler.js +128 -31
- package/modules/lifecycle/routes/claims.js +10 -4
- package/modules/lifecycle/routes/dependencies.js +2 -2
- package/modules/lifecycle/routes/done-when.js +4 -4
- package/modules/lifecycle/routes/gate-approvals.js +9 -3
- package/modules/lifecycle/routes/goals.js +5 -5
- package/modules/lifecycle/routes/tasks.js +16 -16
- package/modules/lifecycle/routes/versions.js +13 -2
- package/modules/lifecycle/ship-card.js +38 -7
- package/modules/memory/routes/memory.js +2 -2
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/routes/projects.js +10 -6
- package/modules/platform-identity/routes/scouting.js +2 -2
- package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
- package/modules/provisioning/provisioning.js +24 -6
- package/modules/provisioning/routes/provisioning.js +24 -6
- package/modules/security/artifact-scan/aggregate.js +133 -0
- package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
- package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
- package/modules/security/artifact-scan/detectors/credentials.js +112 -0
- package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
- package/modules/security/artifact-scan/detectors/hooks.js +116 -0
- package/modules/security/artifact-scan/detectors/injection.js +118 -0
- package/modules/security/artifact-scan/detectors/mcp.js +102 -0
- package/modules/security/artifact-scan/detectors/network.js +67 -0
- package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
- package/modules/security/artifact-scan/detectors/permissions.js +91 -0
- package/modules/security/artifact-scan/detectors/shared.js +219 -0
- package/modules/security/artifact-scan/fence-local.js +99 -0
- package/modules/security/artifact-scan/fetch.js +330 -0
- package/modules/security/artifact-scan/floor.js +319 -0
- package/modules/security/artifact-scan/limits.js +66 -0
- package/modules/security/artifact-scan/manifest.js +203 -0
- package/modules/security/artifact-scan/mirror.js +158 -0
- package/modules/security/artifact-scan/quarantine.js +149 -0
- package/modules/security/artifact-scan/rebuild-spec.js +120 -0
- package/modules/security/artifact-scan/report.js +155 -0
- package/modules/security/artifact-scan/sanitize.js +317 -0
- package/modules/security/migrations/security_001_tool_scans.sql +145 -0
- package/modules/security/module.json +2 -2
- package/modules/security/routes/reports.js +11 -7
- package/modules/security/routes/scans.js +115 -0
- package/modules/security/scan-store.js +214 -0
- package/modules/security/tests/aggregate.mjs +283 -0
- package/modules/security/tests/artifact_scan.mjs +591 -0
- package/modules/security/tests/floor.mjs +549 -0
- package/modules/security/tests/mirror.mjs +350 -0
- package/modules/security/tests/sanitize_rebuild.mjs +499 -0
- package/modules/security/tests/scan_store.mjs +250 -0
- package/modules/security/tests/tool_scans_migration.mjs +116 -0
- package/modules/sessions/routes/sessions.js +6 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +13 -3
- package/scripts/gds/abandon.js +1 -1
- package/scripts/gds/audit-authorship.sh +6 -0
- package/scripts/gds/box.js +1 -1
- package/scripts/gds/claim.js +15 -0
- package/scripts/gds/cli-lib.js +16 -4
- package/scripts/gds/gating.js +28 -13
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/goal.js +234 -0
- package/scripts/gds/init.js +70 -15
- package/scripts/gds/main-audit.js +80 -11
- package/scripts/gds/mirror-redact.js +6 -0
- package/scripts/gds/newcomer-floor.js +2 -2
- package/scripts/gds/provision.js +230 -94
- package/scripts/gds/run-unit-tests.js +9 -1
- package/scripts/gds/sandbox-stage.js +66 -9
- package/scripts/gds/search-ingest-md.js +1 -0
- package/scripts/gds/sequence.js +67 -8
- package/scripts/gds/ship.js +183 -50
- package/scripts/hall-preview/server.js +1 -1
- package/scripts/migrate.sh +5 -5
- package/src/bongos/auth.js +129 -27
- package/src/bongos/db-kernel.js +66 -5
- package/src/bongos/db.js +6 -7
- package/src/bongos/github-repos.js +19 -7
- package/src/bongos/goal-scope-check.js +1 -1
- package/src/bongos/module-scope-map.js +3 -1
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +73 -30
- package/src/bongos/routes/audit-log.js +4 -2
- package/src/bongos/routes/auth.js +8 -4
- package/src/bongos/routes/backup.js +7 -4
- package/src/bongos/routes/builders.js +23 -11
- package/src/bongos/routes/github.js +15 -4
- package/src/bongos/routes/llm-cache.js +5 -2
- package/src/bongos/routes/modules.js +9 -5
- package/src/bongos/routes/security.js +21 -12
- package/src/bongos/serve-internal.js +22 -7
- package/src/module-api.js +86 -13
- package/src/platform-server.js +14 -0
- package/tests/api_client.mjs +18 -2
- package/tests/api_path_404.mjs +3 -0
- package/tests/art_stage.mjs +2 -1
- package/tests/atlas.mjs +32 -4
- package/tests/auth_admission_gate.mjs +17 -5
- package/tests/auth_error_brand.mjs +41 -21
- package/tests/auth_resolve_failure.mjs +155 -0
- package/tests/blocker_link.mjs +88 -0
- package/tests/bongos_downloads.mjs +2 -1
- package/tests/box_dns_repoint.mjs +2 -0
- package/tests/box_ship_permissions.mjs +2 -0
- package/tests/box_terminal_ssrf.mjs +23 -17
- package/tests/claim_error_surface.mjs +23 -0
- package/tests/claude_materialize.mjs +4 -0
- package/tests/cli_box_scope.mjs +40 -0
- package/tests/cloud_bongos_pack.mjs +34 -4
- package/tests/conductor.mjs +2 -1
- package/tests/criterion_authoring_gate.mjs +28 -5
- package/tests/currency_label.mjs +10 -1
- package/tests/deploy_instance.mjs +2 -0
- package/tests/devbox_downloads.mjs +2 -1
- package/tests/display_name.mjs +95 -6
- package/tests/eviction_policy.mjs +2 -1
- package/tests/file_map.mjs +10 -0
- package/tests/first_admin_bootstrap.mjs +10 -2
- package/tests/first_run_engine_check.mjs +2 -1
- package/tests/fitness.mjs +26 -6
- package/tests/gate_approvals.mjs +15 -2
- package/tests/github_push_land_proof.mjs +50 -4
- package/tests/github_repos_helpers.mjs +30 -5
- package/tests/github_token_store.mjs +23 -0
- package/tests/goal_abuse_matrix.mjs +13 -3
- package/tests/goal_cli.mjs +89 -0
- package/tests/governance_abuse_matrix.mjs +386 -0
- package/tests/governance_fold.mjs +156 -0
- package/tests/governance_fold_bench.mjs +50 -0
- package/tests/governance_live_proof.mjs +201 -0
- package/tests/governance_page_gates.mjs +21 -9
- package/tests/governance_parity.mjs +11 -2
- package/tests/governance_require_permission.mjs +125 -1
- package/tests/governance_routes.mjs +334 -0
- package/tests/grader.mjs +7 -3
- package/tests/hall_nav.mjs +14 -4
- package/tests/helpers.mjs +73 -0
- package/tests/hook_core_resolve.mjs +2 -1
- package/tests/host_topology_skips.mjs +97 -0
- package/tests/infra_exec_paths.mjs +14 -4
- package/tests/init.mjs +128 -0
- package/tests/known_failing_ratchet.mjs +89 -0
- package/tests/lifecycle-card.mjs +2 -1
- package/tests/main_audit.mjs +72 -1
- package/tests/memory_isolation.mjs +56 -9
- package/tests/migrate_root_resolution.mjs +1 -1
- package/tests/mirror_redact.mjs +19 -0
- package/tests/module_api.mjs +3 -1
- package/tests/module_loader.mjs +4 -0
- package/tests/modules.mjs +35 -7
- package/tests/modules_route.mjs +29 -5
- package/tests/onboarding_observability.mjs +49 -11
- package/tests/onboarding_route_signals.mjs +1 -0
- package/tests/one_active_claim_per_session.mjs +80 -0
- package/tests/pre_compact.mjs +2 -1
- package/tests/preview_server.mjs +2 -1
- package/tests/profile_route.mjs +1 -0
- package/tests/provision.mjs +170 -0
- package/tests/provisioning_onboard_mode.mjs +180 -0
- package/tests/publish_manifest.mjs +16 -3
- package/tests/publish_reconciler.mjs +181 -10
- package/tests/rank_tier_single_source.mjs +205 -0
- package/tests/recall_hints.mjs +2 -1
- package/tests/repo_map.mjs +4 -0
- package/tests/route_permission_check.mjs +14 -5
- package/tests/rubric_fixtures.mjs +3 -0
- package/tests/run_routine_two_root.mjs +2 -0
- package/tests/sandbox_stage.mjs +69 -7
- package/tests/search_ingest.mjs +7 -0
- package/tests/security_docs.mjs +28 -6
- package/tests/sequence.mjs +64 -0
- package/tests/session-cards.mjs +2 -1
- package/tests/session_start_freshness.mjs +2 -1
- package/tests/ship_broadcast_server.mjs +63 -1
- package/tests/ship_card.mjs +66 -0
- package/tests/ship_ci_deploy.mjs +66 -1
- package/tests/ship_smoke_gate.mjs +76 -0
- package/tests/versions_route.mjs +15 -0
- package/tests/workflow_exec_paths.mjs +16 -4
- package/tests/worktree_guard.mjs +2 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
-- security_001_tool_scans.sql — the durable record of an external-artifact scan.
|
|
2
|
+
--
|
|
3
|
+
-- One row per `/scan-before-install` run, so a scan is queryable across builders
|
|
4
|
+
-- rather than living only in one session's chat and one file on one laptop. The
|
|
5
|
+
-- builder most likely to install a shiny new tool is the newest one, so the read
|
|
6
|
+
-- side of this table exists to let one builder's scan warn the next.
|
|
7
|
+
--
|
|
8
|
+
-- STRUCTURE ONLY, NEVER TARGET CONTENT. The row carries the verdict, the tier,
|
|
9
|
+
-- the pinned SHA, the content hash, per-severity counts, and one-line finding
|
|
10
|
+
-- claims — never quoted bytes from the artifact. Two reasons: a scanned artifact
|
|
11
|
+
-- is hostile input, and anything stored here is later rendered to whoever reads
|
|
12
|
+
-- the table, so a quoted payload would make this table a delivery mechanism for
|
|
13
|
+
-- the very injection the scan exists to catch. The `findings_summary` size cap
|
|
14
|
+
-- below is the DB-layer half of that rule; the route validates it too.
|
|
15
|
+
--
|
|
16
|
+
-- AN ABORTED SCAN IS RECORDABLE. `completion` distinguishes a finished scan from
|
|
17
|
+
-- one that died (clone timeout, cap exceeded, host resolved private). That is
|
|
18
|
+
-- deliberate: a scan that produced no verdict must be visible as "aborted, here
|
|
19
|
+
-- is why" rather than being indistinguishable from silence — otherwise a failed
|
|
20
|
+
-- scan reads like a clean one.
|
|
21
|
+
--
|
|
22
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
|
|
23
|
+
-- object is security_-prefixed, there is no down-migration, and the filename is
|
|
24
|
+
-- security_-prefixed (ADR 0108 §5) so its stem never collides across repos.
|
|
25
|
+
-- `scripts/migrate.sh` discovers + applies this when the security module is
|
|
26
|
+
-- enabled (default:true). Idempotent: safe to re-run.
|
|
27
|
+
|
|
28
|
+
BEGIN;
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS security_tool_scans (
|
|
31
|
+
id bigserial PRIMARY KEY,
|
|
32
|
+
|
|
33
|
+
-- What was scanned. target_url is the https URL as validated; target_host is
|
|
34
|
+
-- split out so "have we seen anything from this host" is an index-able question.
|
|
35
|
+
target_url text NOT NULL,
|
|
36
|
+
target_host text,
|
|
37
|
+
-- 'repo' (a plain GitHub repo) | 'plugin' | 'marketplace'. Text plus a CHECK
|
|
38
|
+
-- rather than a Postgres enum TYPE: the constraint pins today's known kinds
|
|
39
|
+
-- fail-closed, and widening to a new kind is a one-line constraint change in a
|
|
40
|
+
-- later security_* migration — not an enum-type migration.
|
|
41
|
+
artifact_kind text NOT NULL,
|
|
42
|
+
|
|
43
|
+
-- The pinned commit. NULL only when the scan aborted before resolving HEAD.
|
|
44
|
+
-- A branch or tag is never an install source; only this SHA is.
|
|
45
|
+
resolved_sha text,
|
|
46
|
+
-- sha256 over the sorted (path, kind, size, sha256) manifest. The dedupe key
|
|
47
|
+
-- for "we already scanned these exact bytes", and what the install offer
|
|
48
|
+
-- re-checks so the artifact cannot change between scanning and installing.
|
|
49
|
+
content_hash text,
|
|
50
|
+
file_count integer,
|
|
51
|
+
total_bytes bigint,
|
|
52
|
+
|
|
53
|
+
-- Thoroughness the scan ran at. The low tier can never reach a clean verdict,
|
|
54
|
+
-- so tier is load-bearing context for reading `verdict`, not decoration.
|
|
55
|
+
tier text NOT NULL,
|
|
56
|
+
-- clean | caution | dangerous. NULL when completion <> 'complete' — an
|
|
57
|
+
-- unfinished scan has no verdict, and must never be read as a passing one.
|
|
58
|
+
verdict text,
|
|
59
|
+
completion text NOT NULL DEFAULT 'complete',
|
|
60
|
+
-- the abort's finding code (clone_timeout, cap_max_bytes, host_resolves_private…)
|
|
61
|
+
abort_code text,
|
|
62
|
+
|
|
63
|
+
findings_critical integer NOT NULL DEFAULT 0,
|
|
64
|
+
findings_high integer NOT NULL DEFAULT 0,
|
|
65
|
+
findings_medium integer NOT NULL DEFAULT 0,
|
|
66
|
+
findings_low integer NOT NULL DEFAULT 0,
|
|
67
|
+
-- One-line claims only (code, severity, file, short message). The full report
|
|
68
|
+
-- with evidence lives in the builder's own user directory, never here and
|
|
69
|
+
-- never in the repo: a scan report is a timestamped accusation about a named
|
|
70
|
+
-- third party, and docs/ is a public AGPL tree indexed by /recall.
|
|
71
|
+
findings_summary jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
72
|
+
|
|
73
|
+
-- What the builder did with the verdict: none | install_command_printed |
|
|
74
|
+
-- sanitized_copy | rebuild_spec | declined. Note the scanner never installs;
|
|
75
|
+
-- the strongest value here is that a command was PRINTED for the user to run.
|
|
76
|
+
action_taken text,
|
|
77
|
+
-- engine version that produced this row, so an old row is never mistaken for
|
|
78
|
+
-- a verdict the current detector set would reach.
|
|
79
|
+
engine_version text NOT NULL,
|
|
80
|
+
|
|
81
|
+
scanned_by_builder_id bigint REFERENCES builders(id) ON DELETE SET NULL,
|
|
82
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
83
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
84
|
+
|
|
85
|
+
CONSTRAINT security_tool_scans_completion_chk
|
|
86
|
+
CHECK (completion IN ('complete', 'aborted', 'incomplete')),
|
|
87
|
+
CONSTRAINT security_tool_scans_verdict_chk
|
|
88
|
+
CHECK (verdict IS NULL OR verdict IN ('clean', 'caution', 'dangerous')),
|
|
89
|
+
-- A finished scan must carry a verdict, and an unfinished one must not. This is
|
|
90
|
+
-- the invariant that keeps "the scan died" from ever being read as "it passed".
|
|
91
|
+
CONSTRAINT security_tool_scans_verdict_completion_chk
|
|
92
|
+
CHECK ((completion = 'complete') = (verdict IS NOT NULL)),
|
|
93
|
+
CONSTRAINT security_tool_scans_tier_chk
|
|
94
|
+
CHECK (tier IN ('low', 'default', 'high', 'xhigh', 'max')),
|
|
95
|
+
CONSTRAINT security_tool_scans_artifact_kind_chk
|
|
96
|
+
CHECK (artifact_kind IN ('repo', 'plugin', 'marketplace')),
|
|
97
|
+
CONSTRAINT security_tool_scans_action_chk
|
|
98
|
+
CHECK (action_taken IS NULL OR action_taken IN
|
|
99
|
+
('none', 'install_command_printed', 'sanitized_copy', 'rebuild_spec', 'declined')),
|
|
100
|
+
CONSTRAINT security_tool_scans_counts_chk
|
|
101
|
+
CHECK (findings_critical >= 0 AND findings_high >= 0
|
|
102
|
+
AND findings_medium >= 0 AND findings_low >= 0),
|
|
103
|
+
-- Defense in depth with the route's own cap: a hostile artifact must not be
|
|
104
|
+
-- able to push unbounded text through a summary field into every future reader.
|
|
105
|
+
CONSTRAINT security_tool_scans_summary_size_chk
|
|
106
|
+
CHECK (length(findings_summary::text) <= 65536),
|
|
107
|
+
-- The summary is a LIST of one-line claims — pinning the shape at the DB layer
|
|
108
|
+
-- means a scalar or object smuggled past the route can never land.
|
|
109
|
+
CONSTRAINT security_tool_scans_summary_shape_chk
|
|
110
|
+
CHECK (jsonb_typeof(findings_summary) = 'array'),
|
|
111
|
+
CONSTRAINT security_tool_scans_url_len_chk
|
|
112
|
+
CHECK (length(target_url) <= 2048)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
-- "Have these exact bytes been scanned before?" — the dedupe lookup. Partial: an
|
|
116
|
+
-- aborted scan never produced a content_hash, and those NULLs are dead weight.
|
|
117
|
+
CREATE INDEX IF NOT EXISTS security_tool_scans_content_hash_idx
|
|
118
|
+
ON security_tool_scans (content_hash)
|
|
119
|
+
WHERE content_hash IS NOT NULL;
|
|
120
|
+
|
|
121
|
+
-- "What has this builder scanned lately?" — the scanner's own history.
|
|
122
|
+
CREATE INDEX IF NOT EXISTS security_tool_scans_scanner_idx
|
|
123
|
+
ON security_tool_scans (scanned_by_builder_id, created_at DESC);
|
|
124
|
+
|
|
125
|
+
-- "Anything scanned from this host?" — cheap reputation lookup across builders.
|
|
126
|
+
CREATE INDEX IF NOT EXISTS security_tool_scans_host_idx
|
|
127
|
+
ON security_tool_scans (target_host, created_at DESC)
|
|
128
|
+
WHERE target_host IS NOT NULL;
|
|
129
|
+
|
|
130
|
+
-- Rows ARE mutated after insert (action_taken is recorded once the builder acts
|
|
131
|
+
-- on the verdict), so updated_at needs a real trigger — without one it silently
|
|
132
|
+
-- equals created_at forever. Idempotent: OR REPLACE + DROP IF EXISTS.
|
|
133
|
+
CREATE OR REPLACE FUNCTION security_tool_scans_touch_updated_at() RETURNS trigger AS $$
|
|
134
|
+
BEGIN
|
|
135
|
+
NEW.updated_at = now();
|
|
136
|
+
RETURN NEW;
|
|
137
|
+
END;
|
|
138
|
+
$$ LANGUAGE plpgsql;
|
|
139
|
+
|
|
140
|
+
DROP TRIGGER IF EXISTS security_tool_scans_updated_at ON security_tool_scans;
|
|
141
|
+
CREATE TRIGGER security_tool_scans_updated_at
|
|
142
|
+
BEFORE UPDATE ON security_tool_scans
|
|
143
|
+
FOR EACH ROW EXECUTE FUNCTION security_tool_scans_touch_updated_at();
|
|
144
|
+
|
|
145
|
+
COMMIT;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"key": "security",
|
|
3
3
|
"title": "Security (red-team · bounty)",
|
|
4
|
-
"description": "The red-team / vulnerability-report intake and the bounty payout system (rank-gated Metic+): the /builder-redteam skill files a report, the Archon dashboard adjudicates (confirm/dispute/fix/link-fix), and the env-aware bounty table sets the per-severity drachmae payout. A core-domain module carved out of the GDS core in BV1.R79 (ADR 0093 §1; 02-module-map §5 — security-the-FEATURE, NOT security-the-property, which stays kernel). default-on because every instance can take red-team reports. Registers the `security` kernel PORT the core /public/bounty-table resolves for the payout schedule.",
|
|
4
|
+
"description": "The red-team / vulnerability-report intake and the bounty payout system (rank-gated Metic+): the /builder-redteam skill files a report, the Archon dashboard adjudicates (confirm/dispute/fix/link-fix), and the env-aware bounty table sets the per-severity drachmae payout. A core-domain module carved out of the GDS core in BV1.R79 (ADR 0093 §1; 02-module-map §5 — security-the-FEATURE, NOT security-the-property, which stays kernel). default-on because every instance can take red-team reports. Registers the `security` kernel PORT the core /public/bounty-table resolves for the payout schedule. Also owns the scan-before-install surface (goal 1000055): the artifact-scan engine (quarantine/manifest/mirror) and the security_tool_scans record — POST Metic+, GET every rank.",
|
|
5
5
|
"coreVersion": "^1.8.0",
|
|
6
6
|
"default": true,
|
|
7
|
-
"contributes": { "routes": ["reports"] },
|
|
7
|
+
"contributes": { "routes": ["reports", "scans"], "migrations": true },
|
|
8
8
|
"provides": ["security"],
|
|
9
9
|
"consumes": []
|
|
10
10
|
}
|
|
@@ -62,7 +62,7 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
62
62
|
router.post(
|
|
63
63
|
'/security/reports',
|
|
64
64
|
api.requireBuilder,
|
|
65
|
-
api.
|
|
65
|
+
api.requirePermission('security.report.file'),
|
|
66
66
|
async (req, res) => {
|
|
67
67
|
if (validateOrRespond(req, res, {
|
|
68
68
|
target: { required: true, type: 'string', maxLength: LIMITS.TITLE, minLength: 1 },
|
|
@@ -99,11 +99,15 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
99
99
|
// lifecycle bucket (open / repro_attempted / confirmed / disputed /
|
|
100
100
|
// fixed / wontfix); omitted = all.
|
|
101
101
|
//
|
|
102
|
-
// rank: archon — adjudication surface; reading every builder's reports.
|
|
102
|
+
// rank: metic+archon — adjudication surface; reading every builder's reports.
|
|
103
|
+
// BV1.R105 left this on requireRank because `security.report.adjudicate` was declared
|
|
104
|
+
// over the adjudication WRITES + /security/docs CRUD, not over this read. ADR 0157
|
|
105
|
+
// extends that atom's guards to name GET /security/reports and lowers its floor
|
|
106
|
+
// archon→metic, so the mapping now exists and the audience is the working rank.
|
|
103
107
|
router.get(
|
|
104
108
|
'/security/reports',
|
|
105
109
|
api.requireBuilder,
|
|
106
|
-
api.
|
|
110
|
+
api.requirePermission('security.report.adjudicate'),
|
|
107
111
|
async (req, res) => {
|
|
108
112
|
const status = req.query.status ? String(req.query.status) : null;
|
|
109
113
|
if (status && !security.VULN_STATUSES.includes(status)) {
|
|
@@ -135,7 +139,7 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
135
139
|
router.post(
|
|
136
140
|
'/security/reports/:id/confirm',
|
|
137
141
|
api.requireBuilder,
|
|
138
|
-
api.
|
|
142
|
+
api.requirePermission('security.report.adjudicate'),
|
|
139
143
|
async (req, res) => {
|
|
140
144
|
// drachmae_awarded is OPTIONAL and deliberately UNTYPED here: the handler
|
|
141
145
|
// accepts a number, a stringified number, '' and null alike, and the inline
|
|
@@ -174,7 +178,7 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
174
178
|
router.post(
|
|
175
179
|
'/security/reports/:id/dispute',
|
|
176
180
|
api.requireBuilder,
|
|
177
|
-
api.
|
|
181
|
+
api.requirePermission('security.report.adjudicate'),
|
|
178
182
|
async (req, res) => {
|
|
179
183
|
const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
|
|
180
184
|
if (id === null) return;
|
|
@@ -199,7 +203,7 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
199
203
|
router.post(
|
|
200
204
|
'/security/reports/:id/fix',
|
|
201
205
|
api.requireBuilder,
|
|
202
|
-
api.
|
|
206
|
+
api.requirePermission('security.report.adjudicate'),
|
|
203
207
|
async (req, res) => {
|
|
204
208
|
const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
|
|
205
209
|
if (id === null) return;
|
|
@@ -225,7 +229,7 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
225
229
|
router.post(
|
|
226
230
|
'/security/reports/:id/link-fix',
|
|
227
231
|
api.requireBuilder,
|
|
228
|
-
api.
|
|
232
|
+
api.requirePermission('security.report.adjudicate'),
|
|
229
233
|
async (req, res) => {
|
|
230
234
|
// task_id is REQUIRED but untyped: the handler accepts a number or a
|
|
231
235
|
// stringified number and coerces below, so a type would narrow the live
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// modules/security/routes/scans.js — the scan-record surface (task 1002547,
|
|
2
|
+
// goal 1000055): POST one row per /scan-before-install run, GET the rows for a
|
|
3
|
+
// content hash so one builder's scan warns the next.
|
|
4
|
+
//
|
|
5
|
+
// Rank shape, deliberately asymmetric:
|
|
6
|
+
// * WRITE is Metic+ — a verdict-shaped write is a trust-shaped write; a row
|
|
7
|
+
// here tells every future reader "these bytes were judged X".
|
|
8
|
+
// * READ is every rank — the builder most likely to install a shiny new tool
|
|
9
|
+
// is the newest one, and the warning must reach exactly them.
|
|
10
|
+
//
|
|
11
|
+
// A Xenos who runs a scan still gets a DEFINED path (below), not a dead-end:
|
|
12
|
+
// the refusal names what they keep (report file + chat summary) and what to do
|
|
13
|
+
// next, so the degraded experience is a documented contract, not a mystery 403.
|
|
14
|
+
//
|
|
15
|
+
// Same idioms as reports.js next door: doorway-only requires, validateOrRespond
|
|
16
|
+
// schema, res.fail codes, factory export mounted via module.json contributes.
|
|
17
|
+
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const api = require('../../../src/module-api');
|
|
20
|
+
const scanStore = require('../scan-store');
|
|
21
|
+
const { validateOrRespond } = api;
|
|
22
|
+
|
|
23
|
+
// Interposed BEFORE api.requireRank so a Xenos gets the documented degraded
|
|
24
|
+
// path instead of the generic rank refusal. Anyone Metic+ falls through to the
|
|
25
|
+
// real gate (which fitness/route-rank-check reads as this route's rank).
|
|
26
|
+
function xenosDegradedPath(req, res, next) {
|
|
27
|
+
if (api.rankMeetsThreshold(req.builder?.rank, 'metic')) return next();
|
|
28
|
+
return res.fail('scan_row_requires_metic', 403, {
|
|
29
|
+
degraded_path:
|
|
30
|
+
'Your scan itself is unaffected — keep the report file in your user directory and the chat summary. ' +
|
|
31
|
+
'What your rank cannot do is write the SHARED scan record other builders query. ' +
|
|
32
|
+
'Ask any Metic+ builder to POST this scan, or re-run the record step after promotion.',
|
|
33
|
+
kept: ['report_file', 'chat_summary'],
|
|
34
|
+
not_recorded: ['gds_scan_row'],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = function buildSecurityScansRouter() {
|
|
39
|
+
const router = express.Router();
|
|
40
|
+
|
|
41
|
+
// POST /api/bongos/security/scans — record one scan run.
|
|
42
|
+
//
|
|
43
|
+
// Body: structure only, never quoted target content — the deep checks
|
|
44
|
+
// (one-line claims, fence-marker rejection, scrubbing, verdict/completion
|
|
45
|
+
// coupling) live in scanStore.normalizeScanPayload; the schema here rejects
|
|
46
|
+
// unknown/typo'd top-level fields (strict default) and pins basic shapes.
|
|
47
|
+
//
|
|
48
|
+
// rank: metic+archon — a verdict-shaped write is a trust-shaped write.
|
|
49
|
+
router.post(
|
|
50
|
+
'/security/scans',
|
|
51
|
+
api.requireBuilder,
|
|
52
|
+
xenosDegradedPath,
|
|
53
|
+
api.requireRank('metic', 'archon'),
|
|
54
|
+
async (req, res) => {
|
|
55
|
+
if (validateOrRespond(req, res, {
|
|
56
|
+
target_url: { required: true, type: 'string', maxLength: 2048, minLength: 9 },
|
|
57
|
+
artifact_kind: { required: true, type: 'string', enum: [...scanStore.ARTIFACT_KINDS] },
|
|
58
|
+
resolved_sha: { type: 'string', maxLength: 40 },
|
|
59
|
+
content_hash: { type: 'string', maxLength: 64 },
|
|
60
|
+
file_count: { type: 'integer', min: 0 },
|
|
61
|
+
total_bytes: { type: 'integer', min: 0 },
|
|
62
|
+
tier: { required: true, type: 'string', enum: [...scanStore.TIERS] },
|
|
63
|
+
verdict: { type: 'string', enum: [...scanStore.VERDICTS] },
|
|
64
|
+
completion: { required: true, type: 'string', enum: [...scanStore.COMPLETIONS] },
|
|
65
|
+
abort_code: { type: 'string', maxLength: 64 },
|
|
66
|
+
findings_critical: { type: 'integer', min: 0 },
|
|
67
|
+
findings_high: { type: 'integer', min: 0 },
|
|
68
|
+
findings_medium: { type: 'integer', min: 0 },
|
|
69
|
+
findings_low: { type: 'integer', min: 0 },
|
|
70
|
+
findings_summary: { type: 'array' },
|
|
71
|
+
action_taken: { type: 'string', enum: [...scanStore.ACTIONS] },
|
|
72
|
+
engine_version: { required: true, type: 'string', maxLength: 64, minLength: 1 },
|
|
73
|
+
})) return;
|
|
74
|
+
|
|
75
|
+
const norm = scanStore.normalizeScanPayload(req.body);
|
|
76
|
+
if (!norm.ok) return res.fail(norm.code, 400, norm.detail);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const scan = await scanStore.insertScan(norm.value, req.builder.id);
|
|
80
|
+
res.status(201).json({ ok: true, scan });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error('[gds] POST /security/scans', err);
|
|
83
|
+
res.fail('scan_record_failed', 500);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// GET /api/bongos/security/scans?content_hash=<64 hex>
|
|
89
|
+
//
|
|
90
|
+
// "Have these exact bytes been scanned before, and what did the scan say?"
|
|
91
|
+
// Newest first. Rows carry structure only, so reading them can never expose
|
|
92
|
+
// the reader to the scanned artifact's content.
|
|
93
|
+
//
|
|
94
|
+
// rank: any-builder — deliberately open to every rank (see header).
|
|
95
|
+
router.get(
|
|
96
|
+
'/security/scans',
|
|
97
|
+
api.requireBuilder,
|
|
98
|
+
async (req, res) => {
|
|
99
|
+
const contentHash = String(req.query.content_hash || '').toLowerCase();
|
|
100
|
+
if (!/^[0-9a-f]{64}$/.test(contentHash)) {
|
|
101
|
+
return res.fail('bad_content_hash', 400, { expected: '64 lowercase hex chars' });
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const scans = await scanStore.findScans({ contentHash });
|
|
105
|
+
res.set('Cache-Control', 'no-store');
|
|
106
|
+
res.json({ scans });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('[gds] GET /security/scans', err);
|
|
109
|
+
res.fail('scan_list_failed', 500);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return router;
|
|
115
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// modules/security/scan-store.js — the security_tool_scans domain (task
|
|
2
|
+
// 1002547, goal 1000055): one durable, queryable row per /scan-before-install
|
|
3
|
+
// run, so one builder's scan warns the next.
|
|
4
|
+
//
|
|
5
|
+
// STRUCTURE ONLY, NEVER TARGET CONTENT. Everything stored here is later
|
|
6
|
+
// rendered to whoever reads the table, so a quoted payload would make this
|
|
7
|
+
// table a delivery mechanism for the very injection the scan exists to catch.
|
|
8
|
+
// The normalize step therefore rejects anything shaped like quoted content —
|
|
9
|
+
// multi-line messages, unknown item keys, fence-marked strings, oversized
|
|
10
|
+
// summaries — and scrubs dangerous codepoints out of what remains. The DB's
|
|
11
|
+
// own constraints (size cap, array shape, verdict/completion coupling) are the
|
|
12
|
+
// second layer of the same rule.
|
|
13
|
+
//
|
|
14
|
+
// AN ABORTED SCAN IS RECORDABLE. completion='aborted'/'incomplete' with no
|
|
15
|
+
// verdict is a first-class row — a scan that died must be visible as "died,
|
|
16
|
+
// here is why", never laundered into a verdict or lost.
|
|
17
|
+
//
|
|
18
|
+
// The pure seam (normalizeScanPayload) is exported for the DB-free unit gate;
|
|
19
|
+
// insertScan/findScans are the thin SQL on top. Pool reaches through the
|
|
20
|
+
// published doorway; the scrub is an intra-module require of the vendored
|
|
21
|
+
// fence-local (a module never requires a sibling module or a core internal).
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { pool } = require('../../src/module-api');
|
|
26
|
+
const { scrubUntrusted, FENCE_MARKER } = require('./artifact-scan/fence-local');
|
|
27
|
+
|
|
28
|
+
// Mirrors of the DB CHECK constraints — enforced here first so a bad payload
|
|
29
|
+
// gets a clean 400 with a named code instead of a raw constraint violation.
|
|
30
|
+
const TIERS = Object.freeze(['low', 'default', 'high', 'xhigh', 'max']);
|
|
31
|
+
const VERDICTS = Object.freeze(['clean', 'caution', 'dangerous']);
|
|
32
|
+
const COMPLETIONS = Object.freeze(['complete', 'aborted', 'incomplete']);
|
|
33
|
+
const ARTIFACT_KINDS = Object.freeze(['repo', 'plugin', 'marketplace']);
|
|
34
|
+
const ACTIONS = Object.freeze(['none', 'install_command_printed', 'sanitized_copy', 'rebuild_spec', 'declined']);
|
|
35
|
+
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
|
|
36
|
+
|
|
37
|
+
// One-line claims only. maxSerializedBytes stays under the DB's 65536 cap so
|
|
38
|
+
// the route rejects before the constraint ever fires.
|
|
39
|
+
const SUMMARY_LIMITS = Object.freeze({
|
|
40
|
+
maxItems: 200,
|
|
41
|
+
maxCodeChars: 64,
|
|
42
|
+
maxFileChars: 300,
|
|
43
|
+
maxMessageChars: 300,
|
|
44
|
+
maxSerializedBytes: 60000,
|
|
45
|
+
});
|
|
46
|
+
const SUMMARY_ITEM_KEYS = Object.freeze(['code', 'severity', 'file', 'message']);
|
|
47
|
+
|
|
48
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
49
|
+
const SHA_GIT = /^[0-9a-f]{7,40}$/i;
|
|
50
|
+
|
|
51
|
+
const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
|
|
52
|
+
|
|
53
|
+
// Scrub + one-line + length + no-fence-marker checks for one summary string.
|
|
54
|
+
// Returns { ok, value } | { ok:false, code }.
|
|
55
|
+
function cleanSummaryString(raw, maxChars) {
|
|
56
|
+
const s = scrubUntrusted(raw);
|
|
57
|
+
if (/[\r\n]/.test(s)) return fail('summary_not_one_line');
|
|
58
|
+
if (s.length > maxChars) return fail('summary_string_too_long', { limit: maxChars });
|
|
59
|
+
if (s.includes(FENCE_MARKER)) return fail('summary_quoted_content');
|
|
60
|
+
return { ok: true, value: s };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function nonNegInt(v) {
|
|
64
|
+
return Number.isInteger(v) && v >= 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Validate + normalize one scan payload into the exact column shape insertScan
|
|
68
|
+
// writes. Pure — no DB, no I/O — so the whole contract is unit-testable.
|
|
69
|
+
// target_host is DERIVED from the validated URL; a caller-supplied host field
|
|
70
|
+
// is deliberately ignored (never trust the label over the address).
|
|
71
|
+
function normalizeScanPayload(body) {
|
|
72
|
+
const b = body || {};
|
|
73
|
+
|
|
74
|
+
let url;
|
|
75
|
+
try {
|
|
76
|
+
url = new URL(String(b.target_url));
|
|
77
|
+
} catch {
|
|
78
|
+
return fail('bad_target_url');
|
|
79
|
+
}
|
|
80
|
+
if (url.protocol !== 'https:') return fail('bad_target_url', { reason: 'https only' });
|
|
81
|
+
if (String(b.target_url).length > 2048) return fail('bad_target_url', { reason: 'too long' });
|
|
82
|
+
// Embedded userinfo (https://ghp_xxx@github.com/…) would persist a live
|
|
83
|
+
// credential into a row EVERY rank can read. Refuse it outright — and store
|
|
84
|
+
// the parsed serialization below, never the caller's raw string, so nothing
|
|
85
|
+
// the URL parser did not vouch for can reach the table.
|
|
86
|
+
if (url.username || url.password) return fail('bad_target_url', { reason: 'credentials in URL are never stored' });
|
|
87
|
+
|
|
88
|
+
if (!ARTIFACT_KINDS.includes(b.artifact_kind)) return fail('bad_artifact_kind', { allowed: ARTIFACT_KINDS });
|
|
89
|
+
if (!TIERS.includes(b.tier)) return fail('bad_tier', { allowed: TIERS });
|
|
90
|
+
if (!COMPLETIONS.includes(b.completion)) return fail('bad_completion', { allowed: COMPLETIONS });
|
|
91
|
+
|
|
92
|
+
const hasVerdict = b.verdict !== undefined && b.verdict !== null;
|
|
93
|
+
if (hasVerdict && !VERDICTS.includes(b.verdict)) return fail('bad_verdict', { allowed: VERDICTS });
|
|
94
|
+
// The invariant that keeps "the scan died" from ever being read as "it
|
|
95
|
+
// passed": complete ⟺ verdict present. Same rule the DB enforces.
|
|
96
|
+
if ((b.completion === 'complete') !== hasVerdict) return fail('verdict_completion_mismatch');
|
|
97
|
+
|
|
98
|
+
if (b.resolved_sha != null && !SHA_GIT.test(String(b.resolved_sha))) return fail('bad_resolved_sha');
|
|
99
|
+
if (b.content_hash != null && !HEX64.test(String(b.content_hash))) return fail('bad_content_hash');
|
|
100
|
+
|
|
101
|
+
if (b.action_taken != null && !ACTIONS.includes(b.action_taken)) return fail('bad_action_taken', { allowed: ACTIONS });
|
|
102
|
+
if (typeof b.engine_version !== 'string' || b.engine_version.length === 0 || b.engine_version.length > 64) {
|
|
103
|
+
return fail('bad_engine_version');
|
|
104
|
+
}
|
|
105
|
+
if (b.abort_code != null && (typeof b.abort_code !== 'string' || b.abort_code.length > 64)) {
|
|
106
|
+
return fail('bad_abort_code');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (b.file_count != null && !nonNegInt(b.file_count)) return fail('bad_counts', { field: 'file_count' });
|
|
110
|
+
if (b.total_bytes != null && !nonNegInt(b.total_bytes)) return fail('bad_counts', { field: 'total_bytes' });
|
|
111
|
+
const counts = {};
|
|
112
|
+
for (const sev of ['critical', 'high', 'medium', 'low']) {
|
|
113
|
+
const v = b[`findings_${sev}`] ?? 0;
|
|
114
|
+
if (!nonNegInt(v)) return fail('bad_counts', { field: `findings_${sev}` });
|
|
115
|
+
counts[sev] = v;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rawSummary = b.findings_summary ?? [];
|
|
119
|
+
if (!Array.isArray(rawSummary)) return fail('summary_not_array');
|
|
120
|
+
if (rawSummary.length > SUMMARY_LIMITS.maxItems) return fail('summary_too_many_items', { limit: SUMMARY_LIMITS.maxItems });
|
|
121
|
+
const summary = [];
|
|
122
|
+
for (const item of rawSummary) {
|
|
123
|
+
if (item == null || typeof item !== 'object' || Array.isArray(item)) return fail('summary_bad_item');
|
|
124
|
+
for (const key of Object.keys(item)) {
|
|
125
|
+
if (!SUMMARY_ITEM_KEYS.includes(key)) return fail('summary_unknown_key', { key });
|
|
126
|
+
}
|
|
127
|
+
if (!SEVERITIES.includes(item.severity)) return fail('summary_bad_severity', { allowed: SEVERITIES });
|
|
128
|
+
const code = cleanSummaryString(String(item.code ?? ''), SUMMARY_LIMITS.maxCodeChars);
|
|
129
|
+
if (!code.ok) return code;
|
|
130
|
+
const file = cleanSummaryString(String(item.file ?? ''), SUMMARY_LIMITS.maxFileChars);
|
|
131
|
+
if (!file.ok) return file;
|
|
132
|
+
const message = cleanSummaryString(String(item.message ?? ''), SUMMARY_LIMITS.maxMessageChars);
|
|
133
|
+
if (!message.ok) return message;
|
|
134
|
+
summary.push({ code: code.value, severity: item.severity, file: file.value, message: message.value });
|
|
135
|
+
}
|
|
136
|
+
const serialized = JSON.stringify(summary);
|
|
137
|
+
if (Buffer.byteLength(serialized, 'utf8') > SUMMARY_LIMITS.maxSerializedBytes) {
|
|
138
|
+
return fail('summary_too_large', { limit: SUMMARY_LIMITS.maxSerializedBytes });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (url.href.length > 2048) return fail('bad_target_url', { reason: 'too long' });
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
value: {
|
|
146
|
+
target_url: url.href,
|
|
147
|
+
target_host: url.hostname,
|
|
148
|
+
artifact_kind: b.artifact_kind,
|
|
149
|
+
resolved_sha: b.resolved_sha != null ? String(b.resolved_sha).toLowerCase() : null,
|
|
150
|
+
content_hash: b.content_hash != null ? String(b.content_hash) : null,
|
|
151
|
+
file_count: b.file_count ?? null,
|
|
152
|
+
total_bytes: b.total_bytes ?? null,
|
|
153
|
+
tier: b.tier,
|
|
154
|
+
verdict: hasVerdict ? b.verdict : null,
|
|
155
|
+
completion: b.completion,
|
|
156
|
+
abort_code: b.abort_code ?? null,
|
|
157
|
+
findings_critical: counts.critical,
|
|
158
|
+
findings_high: counts.high,
|
|
159
|
+
findings_medium: counts.medium,
|
|
160
|
+
findings_low: counts.low,
|
|
161
|
+
findings_summary: summary,
|
|
162
|
+
action_taken: b.action_taken ?? null,
|
|
163
|
+
engine_version: b.engine_version,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------- SQL (thin — everything above already validated) ----------
|
|
169
|
+
|
|
170
|
+
async function insertScan(value, scannedByBuilderId) {
|
|
171
|
+
const { rows } = await pool.query(
|
|
172
|
+
`INSERT INTO security_tool_scans
|
|
173
|
+
(target_url, target_host, artifact_kind, resolved_sha, content_hash,
|
|
174
|
+
file_count, total_bytes, tier, verdict, completion, abort_code,
|
|
175
|
+
findings_critical, findings_high, findings_medium, findings_low,
|
|
176
|
+
findings_summary, action_taken, engine_version, scanned_by_builder_id)
|
|
177
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
|
178
|
+
RETURNING *`,
|
|
179
|
+
[
|
|
180
|
+
value.target_url, value.target_host, value.artifact_kind, value.resolved_sha, value.content_hash,
|
|
181
|
+
value.file_count, value.total_bytes, value.tier, value.verdict, value.completion, value.abort_code,
|
|
182
|
+
value.findings_critical, value.findings_high, value.findings_medium, value.findings_low,
|
|
183
|
+
JSON.stringify(value.findings_summary), value.action_taken, value.engine_version, scannedByBuilderId,
|
|
184
|
+
]
|
|
185
|
+
);
|
|
186
|
+
return rows[0];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// "Have these exact bytes been scanned before?" — the read every rank gets.
|
|
190
|
+
// Explicit projection: exactly the columns the any-rank read surface intends,
|
|
191
|
+
// so a future column addition never widens this boundary by default.
|
|
192
|
+
async function findScans({ contentHash, limit = 20 }) {
|
|
193
|
+
const { rows } = await pool.query(
|
|
194
|
+
`SELECT id, target_url, target_host, artifact_kind, resolved_sha, content_hash,
|
|
195
|
+
file_count, total_bytes, tier, verdict, completion, abort_code,
|
|
196
|
+
findings_critical, findings_high, findings_medium, findings_low,
|
|
197
|
+
findings_summary, action_taken, engine_version, scanned_by_builder_id,
|
|
198
|
+
created_at, updated_at
|
|
199
|
+
FROM security_tool_scans
|
|
200
|
+
WHERE content_hash = $1
|
|
201
|
+
ORDER BY created_at DESC
|
|
202
|
+
LIMIT $2`,
|
|
203
|
+
[contentHash, Math.min(limit, 100)]
|
|
204
|
+
);
|
|
205
|
+
return rows;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
normalizeScanPayload,
|
|
210
|
+
insertScan,
|
|
211
|
+
findScans,
|
|
212
|
+
TIERS, VERDICTS, COMPLETIONS, ARTIFACT_KINDS, ACTIONS, SEVERITIES,
|
|
213
|
+
SUMMARY_LIMITS,
|
|
214
|
+
};
|