@bongos/core 1.18.19 → 1.19.3
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 +547 -197
- 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/bin/bongos.js +33 -7
- 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/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
- 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/0161-publish-on-merge.md +34 -0
- package/docs/adr/README.md +9 -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 +181 -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 +6 -2
- 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/migrations/core_205_versions_track_default.sql +28 -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 +99 -69
- 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/bump-version.js +76 -0
- 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 +77 -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 +96 -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_cli.mjs +91 -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/bump_version.mjs +71 -0
- 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 +147 -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/patch_gate_ordering.mjs +102 -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,317 @@
|
|
|
1
|
+
// modules/security/artifact-scan/sanitize.js — the sanitize landing
|
|
2
|
+
// (task 1002548, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// The sanitize offer is the narrowest of the three landings, and every one of
|
|
5
|
+
// its gates is ARITHMETIC over recorded evidence — the same rule as the
|
|
6
|
+
// reducer. planSanitize() decides eligibility from the reducer's components
|
|
7
|
+
// and the findings themselves; no model opinion, no narrative, no override.
|
|
8
|
+
// The owner's four gates, verbatim from the task:
|
|
9
|
+
//
|
|
10
|
+
// 1. NO MALICE INDICATOR — any critical finding anywhere (floor or panel),
|
|
11
|
+
// or a dangerous floor/verdict, kills the offer. A sanitized copy of a
|
|
12
|
+
// malicious artifact is a laundered artifact.
|
|
13
|
+
// 2. WHOLE-NODE REMOVALS ONLY — every finding must name a concrete manifest
|
|
14
|
+
// file that can be removed entire. We never edit inside a file: a
|
|
15
|
+
// line-level "fix" of hostile code is a claim of understanding the scan
|
|
16
|
+
// cannot honestly make.
|
|
17
|
+
// 3. FULL COVERAGE — nothing skipped, nothing unscannable, no unfetched
|
|
18
|
+
// dependencies or submodules. Static analysis cannot vouch for a copy it
|
|
19
|
+
// never fully read. Read off the reducer's coverage_cap, which is why
|
|
20
|
+
// fetch-stage findings must reach the floor report (runFloor's
|
|
21
|
+
// extraFindings) — routed anywhere else, this gate cannot see them.
|
|
22
|
+
// 4. CLEAN RE-SCAN BY FRESH SUBAGENTS — enforced by the SKILL, which
|
|
23
|
+
// re-runs the whole pipeline (manifest → mirror → floor → panel) over
|
|
24
|
+
// the sanitized copy in a SECOND quarantine with fresh contexts. This
|
|
25
|
+
// module only produces the copy; it never blesses it.
|
|
26
|
+
//
|
|
27
|
+
// buildSanitizedCopy() writes ONLY inside the quarantine — the sanitized tree
|
|
28
|
+
// lands at <quarantine>/sanitized/ and the diff at <quarantine>/sanitize-diff.md.
|
|
29
|
+
// The sanitized copy is PRISTINE-CLASS: raw artifact bytes (it must stay
|
|
30
|
+
// byte-identical to install), so no model may read it — the re-scan mirrors
|
|
31
|
+
// it first, same as any fetch. File reads follow the mirror.js confinement
|
|
32
|
+
// discipline: lexical check before any fs call, realpath verified inside the
|
|
33
|
+
// pen, read the verified resolved path.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
const fsp = require('node:fs/promises');
|
|
39
|
+
const path = require('node:path');
|
|
40
|
+
|
|
41
|
+
const { isInside } = require('./quarantine');
|
|
42
|
+
const { excerpt } = require('./detectors/shared');
|
|
43
|
+
|
|
44
|
+
const SANITIZED_DIRNAME = 'sanitized';
|
|
45
|
+
const DIFF_BASENAME = 'sanitize-diff.md';
|
|
46
|
+
|
|
47
|
+
// The cap detectors/shared.finding() applies to a finding's `file`. A finding
|
|
48
|
+
// carries the SCRUBBED, capped form of a manifest path; the manifest carries
|
|
49
|
+
// the raw bytes. Matching one against the other directly is the collision bug
|
|
50
|
+
// below — this constant is what makes the two comparable.
|
|
51
|
+
const PATH_MAX_CHARS = 300;
|
|
52
|
+
const CODE_MAX_CHARS = 64;
|
|
53
|
+
const MAX_REASON_CODES = 20;
|
|
54
|
+
|
|
55
|
+
function allFindings(floorReport = {}, lenses = []) {
|
|
56
|
+
const floor = Array.isArray(floorReport.findings) ? floorReport.findings : [];
|
|
57
|
+
const panel = [];
|
|
58
|
+
for (const lens of lenses) {
|
|
59
|
+
for (const f of (Array.isArray(lens && lens.findings) ? lens.findings : [])) panel.push(f);
|
|
60
|
+
}
|
|
61
|
+
return { floor, panel };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Gate 1 as its own testable piece: is there any indicator of actual malice?
|
|
65
|
+
// Arithmetic on purpose — any critical-severity finding, from the floor or
|
|
66
|
+
// from any lens, or a dangerous floor verdict. Severity is the one field the
|
|
67
|
+
// pipeline already treats as evidence; a separate "looks malicious" judgment
|
|
68
|
+
// would reintroduce the opinion the reducer exists to exclude.
|
|
69
|
+
function hasMaliceIndicator(floorReport = {}, lenses = []) {
|
|
70
|
+
const { floor, panel } = allFindings(floorReport, lenses);
|
|
71
|
+
// Codes are model-authored on panel findings and artifact-derived on floor
|
|
72
|
+
// ones, and they flow into reasons[] which the skill relays to chat — so they
|
|
73
|
+
// pass the same scrub + one-line + cap every report string does.
|
|
74
|
+
const codes = [...floor, ...panel]
|
|
75
|
+
.filter((f) => f && f.severity === 'critical')
|
|
76
|
+
.map((f) => excerpt(String(f.code || 'unlabeled'), CODE_MAX_CHARS));
|
|
77
|
+
if (floorReport.floor_verdict === 'dangerous' && codes.length === 0) codes.push('floor_verdict_dangerous');
|
|
78
|
+
return { malicious: codes.length > 0, codes };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The eligibility decision. Takes the REDUCER RESULT (never a narrative) plus
|
|
82
|
+
// the raw findings and the manifest entries, and returns
|
|
83
|
+
// { eligible, reasons, removals, unremovable, provenance }. `removals` is the
|
|
84
|
+
// sorted set of RAW manifest paths whose removal addresses every finding; a
|
|
85
|
+
// finding that names no concrete manifest file (e.g. file '(floor)', an
|
|
86
|
+
// off-box surface, or a path the manifest does not carry) is unremovable and
|
|
87
|
+
// kills the offer — fail closed, gate 2.
|
|
88
|
+
//
|
|
89
|
+
// PATH IDENTITY (review finding). A finding's `file` has been through
|
|
90
|
+
// scrubUntrusted + whitespace-collapse + a 300-char cap; a manifest path has
|
|
91
|
+
// not. Joining them directly let an artifact ship two files whose names differ
|
|
92
|
+
// only by a scrubbed codepoint — the removal set then named the benign twin
|
|
93
|
+
// while the flagged file was copied into sanitized/. So the join runs on the
|
|
94
|
+
// SCRUBBED form of both sides, and a scrubbed name that maps to more than one
|
|
95
|
+
// raw manifest entry is AMBIGUOUS: it becomes unremovable rather than a guess,
|
|
96
|
+
// because deleting the wrong file is exactly the failure this gate exists to
|
|
97
|
+
// prevent.
|
|
98
|
+
//
|
|
99
|
+
// PROVENANCE (review finding, split verdict). A panel finding's `file` is
|
|
100
|
+
// model-authored text, so it is both evidence AND a destructive instruction.
|
|
101
|
+
// The gates cannot verify it, so they LABEL it: `provenance` reports which
|
|
102
|
+
// removals a code-computed floor finding backs and which rest on panel text
|
|
103
|
+
// alone. The blast radius stays bounded by three things that do not trust the
|
|
104
|
+
// panel — removals are whole files inside the quarantine only, the human reads
|
|
105
|
+
// the diff, and the copy must pass a fresh-subagent re-scan (gate 4).
|
|
106
|
+
function planSanitize({ reduced, floorReport = {}, lenses = [], entries = [] } = {}) {
|
|
107
|
+
if (!reduced || !reduced.components) {
|
|
108
|
+
throw new Error('planSanitize requires the reducer result — sanitize eligibility is arithmetic, never a narrative');
|
|
109
|
+
}
|
|
110
|
+
const reasons = [];
|
|
111
|
+
|
|
112
|
+
const malice = hasMaliceIndicator(floorReport, lenses);
|
|
113
|
+
if (malice.malicious) reasons.push(`malice_indicator: ${malice.codes.join(', ')}`);
|
|
114
|
+
if (reduced.verdict === 'dangerous') reasons.push('verdict_dangerous');
|
|
115
|
+
|
|
116
|
+
if (reduced.components.coverage_cap !== 'clean') {
|
|
117
|
+
reasons.push(`coverage_incomplete: ${(reduced.coverage_reasons || []).join('; ') || 'coverage cap held'}`);
|
|
118
|
+
}
|
|
119
|
+
// Gates 1–3 are about what THIS scan proved, so the scan must have been a
|
|
120
|
+
// full one: a panel-bearing tier (low never ran a real panel) and a panel
|
|
121
|
+
// that completed healthy. The clean re-scan (gate 4) is the skill's job.
|
|
122
|
+
if (reduced.components.tier_clamp !== 'clean') reasons.push('tier_ran_no_full_panel');
|
|
123
|
+
if (reduced.components.lens_health !== 'clean') reasons.push('panel_unhealthy_or_absent');
|
|
124
|
+
|
|
125
|
+
const { floor, panel } = allFindings(floorReport, lenses);
|
|
126
|
+
const findings = [...floor.map((f) => ({ f, from: 'floor' })), ...panel.map((f) => ({ f, from: 'panel' }))];
|
|
127
|
+
if (findings.length === 0) reasons.push('nothing_to_remove');
|
|
128
|
+
|
|
129
|
+
// Scrubbed manifest name → the entries that render to it. EVERY kind is
|
|
130
|
+
// indexed, not just files: a symlink whose scrubbed name collides with a
|
|
131
|
+
// real file must make that name ambiguous, or a finding about the symlink
|
|
132
|
+
// schedules the innocent file for deletion (verification finding).
|
|
133
|
+
const byScrubbed = new Map();
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
if (!e || typeof e.path !== 'string') continue;
|
|
136
|
+
const key = excerpt(e.path, PATH_MAX_CHARS);
|
|
137
|
+
// A name that scrubs away to nothing (all-whitespace, all-zero-width) can
|
|
138
|
+
// never identify anything. Indexing it would let it match every finding
|
|
139
|
+
// that carries no file at all — the fail-open this gate exists to prevent.
|
|
140
|
+
if (key === '') continue;
|
|
141
|
+
if (!byScrubbed.has(key)) byScrubbed.set(key, []);
|
|
142
|
+
byScrubbed.get(key).push(e);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const removals = new Set();
|
|
146
|
+
const backedByFloor = new Set();
|
|
147
|
+
const unremovable = [];
|
|
148
|
+
for (const { f, from } of findings) {
|
|
149
|
+
const claimed = f && typeof f.file === 'string' ? excerpt(f.file, PATH_MAX_CHARS) : '';
|
|
150
|
+
const matches = claimed === '' ? [] : (byScrubbed.get(claimed) || []);
|
|
151
|
+
if (matches.length === 1 && matches[0].kind === 'file') {
|
|
152
|
+
removals.add(matches[0].path);
|
|
153
|
+
if (from === 'floor') backedByFloor.add(matches[0].path);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
let reason = 'no_manifest_file';
|
|
157
|
+
if (matches.length > 1) reason = 'ambiguous_path';
|
|
158
|
+
else if (matches.length === 1) reason = 'not_a_regular_file';
|
|
159
|
+
unremovable.push({
|
|
160
|
+
code: excerpt(String((f && f.code) || 'unlabeled'), CODE_MAX_CHARS),
|
|
161
|
+
file: claimed,
|
|
162
|
+
reason,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (unremovable.length > 0) {
|
|
166
|
+
// Count-capped as well as per-item capped: a hostile panel returning
|
|
167
|
+
// thousands of findings must not build one unbounded reason string.
|
|
168
|
+
const codes = unremovable.slice(0, MAX_REASON_CODES).map((u) => u.code).join(', ');
|
|
169
|
+
const more = unremovable.length > MAX_REASON_CODES ? ` (+${unremovable.length - MAX_REASON_CODES} more)` : '';
|
|
170
|
+
reasons.push(`not_whole_node_removable: ${codes}${more}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// removals[] carries RAW manifest paths because buildSanitizedCopy needs the
|
|
174
|
+
// real bytes to find the file on disk — so it is NOT display-safe and must
|
|
175
|
+
// never be printed. provenance carries the scrubbed rendering of the same
|
|
176
|
+
// paths and is what a human is shown (verification finding: a bidi override
|
|
177
|
+
// or an embedded newline in a filename otherwise forges the approval
|
|
178
|
+
// display at the exact moment deletions are approved).
|
|
179
|
+
const sorted = [...removals].sort();
|
|
180
|
+
const display = (p) => excerpt(p, PATH_MAX_CHARS);
|
|
181
|
+
return {
|
|
182
|
+
eligible: reasons.length === 0,
|
|
183
|
+
reasons,
|
|
184
|
+
removals: sorted,
|
|
185
|
+
unremovable,
|
|
186
|
+
provenance: {
|
|
187
|
+
floor_backed: sorted.filter((p) => backedByFloor.has(p)).map(display),
|
|
188
|
+
panel_only: sorted.filter((p) => !backedByFloor.has(p)).map(display),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Copy the pristine tree minus the removals into <quarantine>/sanitized/, and
|
|
194
|
+
// write the diff (whole files removed — nothing else changed) next to it.
|
|
195
|
+
// Writes ONLY inside the quarantine; kept files are copied byte-identical.
|
|
196
|
+
// Refuses stale state (an existing sanitized/ dir) and any removal path the
|
|
197
|
+
// manifest does not carry — a typo'd removal must never silently keep a file
|
|
198
|
+
// the plan meant to drop.
|
|
199
|
+
//
|
|
200
|
+
// Every kept file is re-hashed against its manifest sha256 before it is
|
|
201
|
+
// written (review finding): the diff's promise that kept files are
|
|
202
|
+
// "byte-identical to the scanned pristine copy" was previously prose the code
|
|
203
|
+
// never tested, so anything that changed the pen between the scan and the
|
|
204
|
+
// sanitize — a concurrent process, another tool, a shared build box — was
|
|
205
|
+
// copied in under a provenance claim. Now a mismatch aborts.
|
|
206
|
+
//
|
|
207
|
+
// On ANY failure the partial sanitized/ tree is removed before the error
|
|
208
|
+
// propagates: a directory that looks like a finished sanitized copy but is
|
|
209
|
+
// silently missing files, with no diff beside it, is worse than no directory
|
|
210
|
+
// (and the non-recursive mkdir would otherwise refuse every retry).
|
|
211
|
+
async function buildSanitizedCopy({ pristineDir, quarantineRoot, entries, removals } = {}) {
|
|
212
|
+
if (!pristineDir || !quarantineRoot || !Array.isArray(entries) || !Array.isArray(removals) || removals.length === 0) {
|
|
213
|
+
throw new Error('buildSanitizedCopy requires pristineDir, quarantineRoot, manifest entries[], and a non-empty removals[].');
|
|
214
|
+
}
|
|
215
|
+
const fileEntries = new Map(entries.filter((e) => e && e.kind === 'file').map((e) => [e.path, e]));
|
|
216
|
+
for (const r of removals) {
|
|
217
|
+
if (!fileEntries.has(r)) throw new Error(`Removal "${excerpt(String(r), 300)}" is not a manifest file — refusing a plan the manifest does not vouch for.`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const realQuarantine = await fsp.realpath(quarantineRoot);
|
|
221
|
+
const sanitizedDir = path.join(realQuarantine, SANITIZED_DIRNAME);
|
|
222
|
+
await fsp.mkdir(sanitizedDir); // non-recursive: an existing sanitized/ is stale state, refuse
|
|
223
|
+
|
|
224
|
+
const remove = new Set(removals);
|
|
225
|
+
const removed = [];
|
|
226
|
+
const skippedNonFile = [];
|
|
227
|
+
let copied = 0;
|
|
228
|
+
const madeDirs = new Set();
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
if (!entry || entry.kind !== 'file') {
|
|
233
|
+
if (entry) skippedNonFile.push(entry.path);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const rel = entry.path;
|
|
237
|
+
if (remove.has(rel)) {
|
|
238
|
+
removed.push({ path: rel, size: entry.size, sha256: entry.sha256 || null });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Confinement, same discipline as mirror.js: lexical first, then physical.
|
|
243
|
+
if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
|
|
244
|
+
|| !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
|
|
245
|
+
throw new Error(`Manifest path "${excerpt(String(rel), 300)}" escapes the pristine tree — refusing to build a sanitized copy over it.`);
|
|
246
|
+
}
|
|
247
|
+
const abs = path.resolve(pristineDir, ...rel.split('/'));
|
|
248
|
+
const real = await fsp.realpath(abs);
|
|
249
|
+
if (!isInside(realQuarantine, real)) {
|
|
250
|
+
throw new Error(`"${excerpt(rel, 300)}" resolves outside the quarantine — refusing.`);
|
|
251
|
+
}
|
|
252
|
+
const st = await fsp.lstat(abs);
|
|
253
|
+
if (st.isSymbolicLink()) {
|
|
254
|
+
throw new Error(`"${excerpt(rel, 300)}" is a symlink on disk though the manifest calls it a file — refusing.`);
|
|
255
|
+
}
|
|
256
|
+
const buf = await fsp.readFile(real); // the verified resolved path, not abs
|
|
257
|
+
|
|
258
|
+
// The diff claims every kept file is byte-identical to what was scanned.
|
|
259
|
+
// Prove it against the manifest hash rather than asserting it in prose.
|
|
260
|
+
if (!entry.sha256) {
|
|
261
|
+
throw new Error(`"${excerpt(rel, 300)}" has no manifest sha256 — refusing to claim byte-identity the manifest cannot vouch for.`);
|
|
262
|
+
}
|
|
263
|
+
const fresh = crypto.createHash('sha256').update(buf).digest('hex');
|
|
264
|
+
if (fresh !== entry.sha256) {
|
|
265
|
+
throw new Error(`"${excerpt(rel, 300)}" changed since the scan (manifest sha256 ${excerpt(String(entry.sha256), 64)}, on disk ${fresh}) — refusing to copy bytes nobody reviewed.`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const dest = path.resolve(sanitizedDir, ...rel.split('/'));
|
|
269
|
+
if (!isInside(sanitizedDir, dest)) {
|
|
270
|
+
throw new Error(`"${excerpt(rel, 300)}" would land outside sanitized/ — refusing.`);
|
|
271
|
+
}
|
|
272
|
+
const destDir = path.dirname(dest);
|
|
273
|
+
if (!madeDirs.has(destDir)) {
|
|
274
|
+
await fsp.mkdir(destDir, { recursive: true });
|
|
275
|
+
madeDirs.add(destDir);
|
|
276
|
+
}
|
|
277
|
+
await fsp.writeFile(dest, buf);
|
|
278
|
+
copied++;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The diff: whole files removed, nothing else touched. Paths and hashes are
|
|
282
|
+
// artifact-controlled strings, so they pass through the same scrub+cap every
|
|
283
|
+
// report string does.
|
|
284
|
+
const diffLines = [
|
|
285
|
+
'# Sanitize diff',
|
|
286
|
+
'',
|
|
287
|
+
'Whole-file removals only — every kept file was re-hashed against its manifest sha256 before being copied.',
|
|
288
|
+
`Removed ${removed.length} file(s); copied ${copied} file(s) into ${SANITIZED_DIRNAME}/.`,
|
|
289
|
+
'',
|
|
290
|
+
];
|
|
291
|
+
for (const r of removed) {
|
|
292
|
+
diffLines.push(`- REMOVED ${excerpt(r.path, 300)} (${Number(r.size) || 0} bytes, sha256 ${excerpt(String(r.sha256 || '(none)'), 64)})`);
|
|
293
|
+
}
|
|
294
|
+
if (skippedNonFile.length > 0) {
|
|
295
|
+
diffLines.push('');
|
|
296
|
+
diffLines.push(`Not copied (non-file manifest entries, never reviewed): ${skippedNonFile.map((p) => excerpt(String(p), 120)).join(', ')}`);
|
|
297
|
+
}
|
|
298
|
+
const diff = diffLines.join('\n');
|
|
299
|
+
const diffPath = path.join(realQuarantine, DIFF_BASENAME);
|
|
300
|
+
await fsp.writeFile(diffPath, diff, 'utf8');
|
|
301
|
+
|
|
302
|
+
return { sanitizedDir, diffPath, diff, copied, removed, skippedNonFile };
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// No half-built tree survives a failure — see the header. Best-effort so a
|
|
305
|
+
// cleanup problem can never mask the real error.
|
|
306
|
+
await fsp.rm(sanitizedDir, { recursive: true, force: true }).catch(() => {});
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
SANITIZED_DIRNAME,
|
|
313
|
+
DIFF_BASENAME,
|
|
314
|
+
hasMaliceIndicator,
|
|
315
|
+
planSanitize,
|
|
316
|
+
buildSanitizedCopy,
|
|
317
|
+
};
|
|
@@ -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
|
+
};
|