@bongos/core 1.18.18 → 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 +515 -195
- 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 +99 -14
- 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 -1
- 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 +124 -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,330 @@
|
|
|
1
|
+
// modules/security/artifact-scan/fetch.js — the hardened fetch.
|
|
2
|
+
//
|
|
3
|
+
// `git clone` is not an inert download: by default it runs filters and hooks,
|
|
4
|
+
// reads the user's config, follows submodules into other protocols, materializes
|
|
5
|
+
// symlinks anywhere, and prompts for credentials. Each setting below closes one
|
|
6
|
+
// of those doors and is annotated at its use site. Nothing beyond git itself is
|
|
7
|
+
// ever executed. Async by design — see defaultRun.
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const dns = require('node:dns');
|
|
12
|
+
const fsp = require('node:fs/promises');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { spawn } = require('node:child_process');
|
|
15
|
+
|
|
16
|
+
const { CAPS } = require('./quarantine');
|
|
17
|
+
const { buildManifest, CapExceeded } = require('./manifest');
|
|
18
|
+
|
|
19
|
+
const MAX_CAPTURE_BYTES = 1 * 1024 * 1024;
|
|
20
|
+
// git's stderr can name internal hosts and paths; only a short excerpt travels,
|
|
21
|
+
// and only in a finding's detail, never in a message a caller might echo.
|
|
22
|
+
const STDERR_EXCERPT = 300;
|
|
23
|
+
|
|
24
|
+
// Literal addresses git must never be pointed at. Without this the scanner is an
|
|
25
|
+
// SSRF gadget: an attacker-chosen URL makes OUR process fetch cloud metadata, or
|
|
26
|
+
// probe internal hosts via the difference between an error and a timeout.
|
|
27
|
+
function blockedAddress(hostname) {
|
|
28
|
+
const h = String(hostname).toLowerCase().replace(/^\[|\]$/g, '');
|
|
29
|
+
if (h === '') return 'an empty host';
|
|
30
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return 'loopback';
|
|
31
|
+
if (h === '::1' || h === '::') return 'IPv6 loopback/unspecified';
|
|
32
|
+
if (/^f[cd][0-9a-f]{2}:/.test(h)) return 'an IPv6 unique-local address';
|
|
33
|
+
if (/^fe80:/.test(h)) return 'an IPv6 link-local address';
|
|
34
|
+
|
|
35
|
+
// WHATWG URL canonicalizes ::ffff:127.0.0.1 to ::ffff:7f00:1, so the hex form
|
|
36
|
+
// must decode too — checking only the dotted spelling is itself a bypass.
|
|
37
|
+
let v4 = h;
|
|
38
|
+
const dotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
39
|
+
const hex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
40
|
+
if (dotted) v4 = dotted[1];
|
|
41
|
+
else if (hex) {
|
|
42
|
+
const [hi, lo] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
|
|
43
|
+
v4 = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const m = v4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
47
|
+
if (!m) return null;
|
|
48
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
49
|
+
if (a === 127) return 'loopback';
|
|
50
|
+
if (a === 0) return 'the unspecified range';
|
|
51
|
+
if (a === 10) return 'a private (RFC 1918) address';
|
|
52
|
+
if (a === 172 && b >= 16 && b <= 31) return 'a private (RFC 1918) address';
|
|
53
|
+
if (a === 192 && b === 168) return 'a private (RFC 1918) address';
|
|
54
|
+
if (a === 169 && b === 254) return 'the link-local range (cloud metadata lives at 169.254.169.254)';
|
|
55
|
+
if (a === 100 && b >= 64 && b <= 127) return 'the carrier-grade NAT range';
|
|
56
|
+
if (a >= 224) return 'a multicast/reserved address';
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// https only: ssh:// executes a remote command, file:// and ext:: reach local
|
|
61
|
+
// paths, git:// is unauthenticated. A URL is refused, never rewritten — quietly
|
|
62
|
+
// "fixing" an attacker's URL into one we like is its own vulnerability.
|
|
63
|
+
function validateTargetUrl(raw) {
|
|
64
|
+
const value = String(raw ?? '').trim();
|
|
65
|
+
if (!value) return { ok: false, refusal: 'No URL given.' };
|
|
66
|
+
let u;
|
|
67
|
+
try { u = new URL(value); } catch { return { ok: false, refusal: `Not a URL: ${value}` }; }
|
|
68
|
+
|
|
69
|
+
if (u.protocol !== 'https:') return { ok: false, refusal: `Only https:// targets are scannable — got "${u.protocol}".` };
|
|
70
|
+
if (u.username || u.password) return { ok: false, refusal: 'Refusing a URL with embedded credentials.' };
|
|
71
|
+
const blocked = blockedAddress(u.hostname);
|
|
72
|
+
if (blocked) return { ok: false, refusal: `Refusing to point the scanner at ${blocked} (${u.hostname}).` };
|
|
73
|
+
return { ok: true, url: u.toString(), host: u.hostname };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultResolveHost(hostname) {
|
|
77
|
+
return dns.promises.lookup(hostname, { all: true }).then((rs) => rs.map((r) => r.address));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Resolve the name ourselves and refuse if ANY answer is private. A literal-only
|
|
81
|
+
// check is trivially bypassed by pointing a domain's A record at 169.254.169.254.
|
|
82
|
+
//
|
|
83
|
+
// RESIDUAL LIMIT, stated rather than papered over: git re-resolves when it
|
|
84
|
+
// connects, so a name that answers publicly here and privately microseconds
|
|
85
|
+
// later (true DNS rebinding) still gets through. Closing that needs address
|
|
86
|
+
// pinning git does not expose. This removes the trivial attack, not the race.
|
|
87
|
+
async function assertHostResolvesPublic(hostname, resolveHost) {
|
|
88
|
+
let addresses;
|
|
89
|
+
try {
|
|
90
|
+
addresses = await resolveHost(hostname);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw abort('host_unresolvable', `Could not resolve "${hostname}" — refusing to fetch.`, { hostname });
|
|
93
|
+
}
|
|
94
|
+
for (const addr of addresses) {
|
|
95
|
+
const blocked = blockedAddress(addr);
|
|
96
|
+
if (blocked) {
|
|
97
|
+
throw abort('host_resolves_private', `"${hostname}" resolves to ${blocked} (${addr}) — refusing to fetch.`, { hostname, addr });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return addresses;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The `-c key=value` pairs applied to every git invocation. This list IS the
|
|
104
|
+
// security control, not boilerplate.
|
|
105
|
+
function hardeningConfig(q) {
|
|
106
|
+
return [
|
|
107
|
+
'core.symlinks=false', // a symlink becomes inert text, not a live pointer at ~/.aws
|
|
108
|
+
`core.hooksPath=${q.hooksDir}`, // must be a real EMPTY dir — /dev/null is not a directory on Windows
|
|
109
|
+
'protocol.file.allow=never', // blocks file:// submodule/url-rewrite escapes
|
|
110
|
+
'protocol.ext.allow=never', // blocks ext:: command execution
|
|
111
|
+
'transfer.fsckObjects=true',
|
|
112
|
+
'fetch.fsckObjects=true',
|
|
113
|
+
'credential.helper=', // never offer a stored credential to a third-party host
|
|
114
|
+
'core.askPass=',
|
|
115
|
+
'filter.lfs.smudge=', // an LFS smudge filter is a process git runs on checkout
|
|
116
|
+
'filter.lfs.process=',
|
|
117
|
+
'filter.lfs.required=false',
|
|
118
|
+
'advice.detachedHead=false',
|
|
119
|
+
].flatMap((kv) => ['-c', kv]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Only what git needs. The builder's ambient env — API tokens, DB passwords,
|
|
123
|
+
// cloud credentials — is deliberately not forwarded to a process talking to a
|
|
124
|
+
// server chosen by the person we are investigating. Proxy vars ARE forwarded, a
|
|
125
|
+
// deliberate call: without them a builder behind a corporate proxy cannot scan.
|
|
126
|
+
const ENV_PASSTHROUGH = [
|
|
127
|
+
'PATH', 'Path', 'PATHEXT',
|
|
128
|
+
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH',
|
|
129
|
+
'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'windir',
|
|
130
|
+
'TEMP', 'TMP', 'TMPDIR',
|
|
131
|
+
'LANG', 'LC_ALL',
|
|
132
|
+
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'GIT_SSL_CAINFO',
|
|
133
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// Deduped case-insensitively: Windows' process.env is case-insensitive, so
|
|
137
|
+
// reading both 'PATH' and 'Path' yields the same value under two keys, and the
|
|
138
|
+
// duplicate makes CreateProcess fail to resolve git at all (spawn ENOENT).
|
|
139
|
+
function hardenedEnv(q) {
|
|
140
|
+
const env = Object.create(null);
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const key of ENV_PASSTHROUGH) {
|
|
143
|
+
const lower = key.toLowerCase();
|
|
144
|
+
if (seen.has(lower) || process.env[key] === undefined) continue;
|
|
145
|
+
seen.add(lower);
|
|
146
|
+
env[key] = process.env[key];
|
|
147
|
+
}
|
|
148
|
+
return Object.assign(env, {
|
|
149
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
150
|
+
GIT_ASKPASS: '',
|
|
151
|
+
SSH_ASKPASS: '',
|
|
152
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
153
|
+
GIT_CONFIG_GLOBAL: q.gitConfigFile,
|
|
154
|
+
GIT_CONFIG_SYSTEM: q.gitConfigFile,
|
|
155
|
+
GIT_LFS_SKIP_SMUDGE: '1',
|
|
156
|
+
GCM_INTERACTIVE: 'never',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Async spawn, never spawnSync: a 120s block would stall the event loop for any
|
|
161
|
+
// caller that ever runs inside a request. argv is an array (no shell), so a URL
|
|
162
|
+
// containing shell metacharacters is data, never a command. Output is bounded.
|
|
163
|
+
function defaultRun(args, { cwd, env, timeoutMs }) {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
let child;
|
|
166
|
+
try {
|
|
167
|
+
child = spawn('git', args, { cwd, env, windowsHide: true });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
return resolve({ status: null, stdout: '', stderr: '', timedOut: false, truncated: false, error: String(err.message) });
|
|
170
|
+
}
|
|
171
|
+
let stdout = '';
|
|
172
|
+
let stderr = '';
|
|
173
|
+
let timedOut = false;
|
|
174
|
+
// Capture is bounded, but a caller that PARSES stdout must be able to tell
|
|
175
|
+
// a complete answer from a clipped one — silently dropping the tail of
|
|
176
|
+
// `ls-files` hid gitlinks, and an unnoticed missing submodule is exactly
|
|
177
|
+
// the "unreviewed reads as clean" failure the floor exists to prevent.
|
|
178
|
+
let truncated = false;
|
|
179
|
+
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
|
|
180
|
+
child.stdout.on('data', (d) => {
|
|
181
|
+
if (stdout.length < MAX_CAPTURE_BYTES) stdout += d;
|
|
182
|
+
else truncated = true;
|
|
183
|
+
});
|
|
184
|
+
child.stderr.on('data', (d) => { if (stderr.length < MAX_CAPTURE_BYTES) stderr += d; });
|
|
185
|
+
child.on('error', (err) => {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
resolve({ status: null, stdout, stderr, timedOut, truncated, error: String(err.message) });
|
|
188
|
+
});
|
|
189
|
+
child.on('close', (status) => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
resolve({ status, stdout, stderr, timedOut, truncated, error: null });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// "<mode> <sha> <stage>\t<path>". Read BEFORE .git is deleted — the index is the
|
|
197
|
+
// only remaining witness that a file was really a symlink (120000) or a
|
|
198
|
+
// submodule (160000) once core.symlinks=false has flattened it.
|
|
199
|
+
function parseLsFiles(stdout) {
|
|
200
|
+
const modes = [];
|
|
201
|
+
for (const line of String(stdout).split('\n')) {
|
|
202
|
+
if (!line.trim()) continue;
|
|
203
|
+
const tab = line.indexOf('\t');
|
|
204
|
+
if (tab === -1) continue;
|
|
205
|
+
modes.push({ mode: line.slice(0, line.indexOf(' ')), path: line.slice(tab + 1) });
|
|
206
|
+
}
|
|
207
|
+
return modes;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function finding(code, severity, message, detail) {
|
|
211
|
+
return { code, severity, message, detail: detail ?? null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Every throw out of fetchArtifact carries evidence, so a scan that died is
|
|
215
|
+
// recordable as "aborted, here is why" rather than as silence.
|
|
216
|
+
function abort(code, message, detail) {
|
|
217
|
+
const err = new Error(message);
|
|
218
|
+
err.code = code;
|
|
219
|
+
err.finding = finding(code, 'high', message, detail);
|
|
220
|
+
return err;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fetch one artifact into an existing quarantine.
|
|
224
|
+
// Returns { url, host, addresses, sha, pristineDir, manifest, gitModes, findings }.
|
|
225
|
+
async function fetchArtifact(rawUrl, {
|
|
226
|
+
quarantine,
|
|
227
|
+
timeoutMs = CAPS.cloneTimeoutMs,
|
|
228
|
+
maxFiles = CAPS.maxFiles,
|
|
229
|
+
maxBytes = CAPS.maxBytes,
|
|
230
|
+
run = defaultRun,
|
|
231
|
+
resolveHost = defaultResolveHost,
|
|
232
|
+
} = {}) {
|
|
233
|
+
if (!quarantine || !quarantine.pristineDir) {
|
|
234
|
+
throw new Error('fetchArtifact requires a quarantine from createQuarantine().');
|
|
235
|
+
}
|
|
236
|
+
const valid = validateTargetUrl(rawUrl);
|
|
237
|
+
if (!valid.ok) throw abort('url_refused', valid.refusal, null);
|
|
238
|
+
|
|
239
|
+
const q = quarantine;
|
|
240
|
+
const env = hardenedEnv(q);
|
|
241
|
+
const cfg = hardeningConfig(q);
|
|
242
|
+
const findings = [];
|
|
243
|
+
|
|
244
|
+
const addresses = await assertHostResolvesPublic(valid.host, resolveHost);
|
|
245
|
+
|
|
246
|
+
const clone = await run(
|
|
247
|
+
[...cfg, 'clone', '--depth', '1', '--single-branch', '--no-tags', '--no-recurse-submodules', '--quiet', '--', valid.url, q.pristineDir],
|
|
248
|
+
{ cwd: q.root, env, timeoutMs }
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (clone.timedOut) {
|
|
252
|
+
throw abort('clone_timeout', `Clone exceeded the ${timeoutMs}ms cap and was killed.`, { timeoutMs, url: valid.url });
|
|
253
|
+
}
|
|
254
|
+
if (clone.status !== 0) {
|
|
255
|
+
throw abort('clone_failed', `Clone failed (exit ${clone.status}).`, {
|
|
256
|
+
status: clone.status,
|
|
257
|
+
stderr_excerpt: String(clone.stderr || clone.error || '').trim().slice(0, STDERR_EXCERPT),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const rev = await run([...cfg, 'rev-parse', 'HEAD'], { cwd: q.pristineDir, env, timeoutMs });
|
|
262
|
+
if (rev.status !== 0) {
|
|
263
|
+
throw abort('head_unresolved', 'Could not resolve HEAD.', { stderr_excerpt: String(rev.stderr || '').trim().slice(0, STDERR_EXCERPT) });
|
|
264
|
+
}
|
|
265
|
+
const sha = rev.stdout.trim();
|
|
266
|
+
|
|
267
|
+
// The index is the ONLY witness that a path was really a symlink (120000) or
|
|
268
|
+
// a submodule (160000) once core.symlinks=false has flattened it, and a
|
|
269
|
+
// gitlink leaves an empty directory the manifest walk records nothing for.
|
|
270
|
+
// So when this read is incomplete the artifact's true shape is UNKNOWN —
|
|
271
|
+
// that must be a finding, never an empty list. Both fail-opens below were
|
|
272
|
+
// found by verifying the fix that folds these findings into the floor: the
|
|
273
|
+
// fold is worthless if the evidence was never produced.
|
|
274
|
+
const ls = await run([...cfg, 'ls-files', '-s'], { cwd: q.pristineDir, env, timeoutMs });
|
|
275
|
+
const gitModes = ls.status === 0 ? parseLsFiles(ls.stdout) : [];
|
|
276
|
+
if (ls.status !== 0 || ls.timedOut) {
|
|
277
|
+
findings.push(finding('git_index_unread', 'high',
|
|
278
|
+
'Could not read the git index — whether this artifact carries submodules or symlinks is UNKNOWN, not "none".',
|
|
279
|
+
{ status: ls.status, timedOut: !!ls.timedOut, unscannable: true }));
|
|
280
|
+
} else if (ls.truncated) {
|
|
281
|
+
findings.push(finding('git_index_truncated', 'high',
|
|
282
|
+
'The git index listing was too large to read in full — any submodule or symlink past the cut is UNSEEN, so this artifact\'s shape is only partly known.',
|
|
283
|
+
{ captured_bytes: ls.stdout.length, entries_parsed: gitModes.length, unscannable: true }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const m of gitModes) {
|
|
287
|
+
if (m.mode === '120000') {
|
|
288
|
+
findings.push(finding('symlink_in_artifact', 'medium', `Symlink "${m.path}" — materialized as inert text, its target was never opened.`, m));
|
|
289
|
+
} else if (m.mode === '160000') {
|
|
290
|
+
// unscannable: a gitlink materializes as an EMPTY directory, so the
|
|
291
|
+
// manifest walk records no entry for it and the floor has nothing to
|
|
292
|
+
// skip — without this flag the reducer's coverage cap cannot see that
|
|
293
|
+
// real third-party code went unreviewed (review finding).
|
|
294
|
+
findings.push(finding('submodule_not_fetched', 'medium', `Submodule at "${m.path}" was NOT fetched and is therefore unreviewed.`, { ...m, unscannable: true }));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The SHA and modes are captured, so a bare content tree makes the manifest
|
|
299
|
+
// mean exactly "the files this artifact would install".
|
|
300
|
+
await fsp.rm(path.join(q.pristineDir, '.git'), { recursive: true, force: true });
|
|
301
|
+
|
|
302
|
+
let manifest;
|
|
303
|
+
try {
|
|
304
|
+
manifest = await buildManifest(q.pristineDir, { maxFiles, maxBytes });
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err instanceof CapExceeded) {
|
|
307
|
+
throw abort(err.code, `${err.message} Scan aborted — a partial read cannot produce an honest verdict.`, err.detail);
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (manifest.symlinkCount > 0) {
|
|
313
|
+
findings.push(finding('real_symlink_present', 'high', `${manifest.symlinkCount} real symlink(s) survived core.symlinks=false; targets were not followed.`, null));
|
|
314
|
+
}
|
|
315
|
+
if (manifest.otherCount > 0) {
|
|
316
|
+
findings.push(finding('unreadable_node', 'medium', `${manifest.otherCount} non-regular file(s) were recorded but not read.`, null));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { url: valid.url, host: valid.host, addresses, sha, pristineDir: q.pristineDir, manifest, gitModes, findings };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = {
|
|
323
|
+
fetchArtifact,
|
|
324
|
+
validateTargetUrl,
|
|
325
|
+
blockedAddress,
|
|
326
|
+
assertHostResolvesPublic,
|
|
327
|
+
hardeningConfig,
|
|
328
|
+
hardenedEnv,
|
|
329
|
+
parseLsFiles,
|
|
330
|
+
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// modules/security/artifact-scan/floor.js — the zero-LLM deterministic floor
|
|
2
|
+
// (task 1002544, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// The floor's whole purpose is that a successful prompt injection cannot erase
|
|
5
|
+
// its own evidence: this report is computed by CODE and written to disk BEFORE
|
|
6
|
+
// any model reads a byte of the artifact. A panel can add findings on top; the
|
|
7
|
+
// reducer takes the MAXIMUM of this floor and everything downstream, so
|
|
8
|
+
// nothing a model says can lower what the detectors already proved.
|
|
9
|
+
//
|
|
10
|
+
// The floor reads the PRISTINE copy, not the mirror. Code is immune to
|
|
11
|
+
// persuasion, so the injection-safety argument for the mirror does not apply —
|
|
12
|
+
// and the mirror is scrubbed, so codepoint evidence EXISTS only in pristine.
|
|
13
|
+
// Reads follow the same confinement discipline as mirror.js: manifest-driven,
|
|
14
|
+
// lexically checked before any fs call, realpath-verified inside the pen.
|
|
15
|
+
//
|
|
16
|
+
// Verdict arithmetic (monotone by construction):
|
|
17
|
+
// any critical finding → 'dangerous'
|
|
18
|
+
// any finding at all → 'caution'
|
|
19
|
+
// no findings → 'clean'
|
|
20
|
+
// A detector that crashes contributes a finding (never silence), so a broken
|
|
21
|
+
// detector degrades the verdict toward caution rather than toward clean.
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fsp = require('node:fs/promises');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { TextDecoder } = require('node:util');
|
|
28
|
+
|
|
29
|
+
const { isInside } = require('./quarantine');
|
|
30
|
+
const { MIRROR_CAPS } = require('./mirror');
|
|
31
|
+
const { scrubUntrusted } = require('./fence-local');
|
|
32
|
+
const { finding, isSensitivePath, SEVERITIES, excerpt } = require('./detectors/shared');
|
|
33
|
+
|
|
34
|
+
const npmLifecycle = require('./detectors/npm-lifecycle');
|
|
35
|
+
const hooks = require('./detectors/hooks');
|
|
36
|
+
const mcp = require('./detectors/mcp');
|
|
37
|
+
const permissions = require('./detectors/permissions');
|
|
38
|
+
const credentials = require('./detectors/credentials');
|
|
39
|
+
const network = require('./detectors/network');
|
|
40
|
+
const injection = require('./detectors/injection');
|
|
41
|
+
const dependencies = require('./detectors/dependencies');
|
|
42
|
+
const configObfuscation = require('./detectors/config-obfuscation');
|
|
43
|
+
const capabilities = require('./detectors/capabilities');
|
|
44
|
+
|
|
45
|
+
const FLOOR_VERSION = '1.0.0';
|
|
46
|
+
const REPORT_BASENAME = 'floor-report.json';
|
|
47
|
+
|
|
48
|
+
// Bounds on the fetch-stage fold (see runFloor's extraFindings).
|
|
49
|
+
const MAX_EXTRA_FINDINGS = 500;
|
|
50
|
+
const CODE_MAX_CHARS = 64;
|
|
51
|
+
|
|
52
|
+
// Shared with the reducer (task 1002545): the max() it takes is over this rank.
|
|
53
|
+
const VERDICT_RANK = Object.freeze({ clean: 0, caution: 1, dangerous: 2 });
|
|
54
|
+
|
|
55
|
+
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true });
|
|
56
|
+
|
|
57
|
+
const DETECTORS = Object.freeze([
|
|
58
|
+
['npm-lifecycle', npmLifecycle.detect],
|
|
59
|
+
['claude-hooks', hooks.detect],
|
|
60
|
+
['mcp-servers', mcp.detect],
|
|
61
|
+
['permissions', permissions.detect],
|
|
62
|
+
['credential-stores', credentials.detect],
|
|
63
|
+
['network-sinks', network.detect],
|
|
64
|
+
['injection-text', injection.detect],
|
|
65
|
+
['dependencies', dependencies.detect],
|
|
66
|
+
['config-obfuscation', configObfuscation.detect],
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// Per-chunk parallel-read width. Bounded so a huge artifact cannot exhaust
|
|
70
|
+
// file descriptors; chunked (not a free-running pool) so files[]/skipped[]
|
|
71
|
+
// keep deterministic manifest order.
|
|
72
|
+
const READ_CONCURRENCY = 16;
|
|
73
|
+
|
|
74
|
+
// Manifest-driven confined read of every renderable text file. Anything the
|
|
75
|
+
// floor cannot honestly read (binary, oversize, escaping, unreadable) becomes
|
|
76
|
+
// a skipped record — counted, never silently dropped — because "I did not read
|
|
77
|
+
// it" must always be visible to the reducer's coverage cap.
|
|
78
|
+
async function buildCorpus({ pristineDir, quarantineRoot, entries, caps = {}, realQuarantine: realQuarantineIn = null }) {
|
|
79
|
+
const maxFileBytes = caps.maxFileBytes ?? MIRROR_CAPS.maxFileBytes;
|
|
80
|
+
const realQuarantine = realQuarantineIn || await fsp.realpath(quarantineRoot);
|
|
81
|
+
|
|
82
|
+
// One entry → { file } | { skip }, so chunked parallel reads reassemble in
|
|
83
|
+
// manifest order below. The RAW rel is used for every filesystem operation;
|
|
84
|
+
// the STORED path is scrubbed at this chokepoint, because manifest paths are
|
|
85
|
+
// artifact-controlled and corpus records flow into the report (coverage,
|
|
86
|
+
// capability inventory) — the report must never become a carrier.
|
|
87
|
+
const readOne = async (entry) => {
|
|
88
|
+
const rel = entry.path;
|
|
89
|
+
const safeRel = scrubUntrusted(rel);
|
|
90
|
+
const skip = (reason, bytes) => ({ skip: { path: safeRel, reason, bytes: bytes || 0 } });
|
|
91
|
+
if (entry.kind !== 'file') return skip(entry.kind === 'symlink' ? 'symlink' : 'special', entry.size);
|
|
92
|
+
if (typeof rel !== 'string' || rel.length === 0 || path.isAbsolute(rel)
|
|
93
|
+
|| !isInside(pristineDir, path.resolve(pristineDir, ...rel.split('/')))) {
|
|
94
|
+
return skip('path_escape', entry.size);
|
|
95
|
+
}
|
|
96
|
+
if (entry.size > maxFileBytes) return skip('oversize', entry.size);
|
|
97
|
+
|
|
98
|
+
const abs = path.resolve(pristineDir, ...rel.split('/'));
|
|
99
|
+
let buf;
|
|
100
|
+
try {
|
|
101
|
+
const real = await fsp.realpath(abs);
|
|
102
|
+
if (!isInside(realQuarantine, real)) return skip('path_escape', entry.size);
|
|
103
|
+
const st = await fsp.lstat(abs);
|
|
104
|
+
if (st.isSymbolicLink()) return skip('path_escape', entry.size);
|
|
105
|
+
// Read the verified resolved path — same TOCTOU close as mirror.js.
|
|
106
|
+
buf = await fsp.readFile(real);
|
|
107
|
+
} catch {
|
|
108
|
+
return skip('unreadable', entry.size);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
return { file: { path: safeRel, size: buf.length, text: UTF8_STRICT.decode(buf) } };
|
|
113
|
+
} catch {
|
|
114
|
+
return skip('non_utf8', buf.length);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const files = [];
|
|
119
|
+
const skipped = [];
|
|
120
|
+
for (let i = 0; i < entries.length; i += READ_CONCURRENCY) {
|
|
121
|
+
const chunk = await Promise.all(entries.slice(i, i + READ_CONCURRENCY).map(readOne));
|
|
122
|
+
for (const r of chunk) {
|
|
123
|
+
if (r.file) files.push(r.file);
|
|
124
|
+
else skipped.push(r.skip);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { files, skipped };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function computeFloorVerdict(findings) {
|
|
131
|
+
if (findings.some((f) => f.severity === 'critical')) return 'dangerous';
|
|
132
|
+
return findings.length > 0 ? 'caution' : 'clean';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function countBySeverity(findings) {
|
|
136
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
137
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
138
|
+
return counts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The same-file join: a file that both names a credential store AND carries a
|
|
142
|
+
// network sink. Labeled exactly "same-file co-occurrence" — the floor has no
|
|
143
|
+
// dataflow analysis and must never imply one; co-location is opportunity, and
|
|
144
|
+
// opportunity inside one file is the strongest static exfil signal we can
|
|
145
|
+
// honestly claim.
|
|
146
|
+
function coOccurrence(corpus) {
|
|
147
|
+
const creds = credentials.perFileHits(corpus);
|
|
148
|
+
const sinks = network.perFileHits(corpus);
|
|
149
|
+
const findings = [];
|
|
150
|
+
for (const [file, credHits] of creds) {
|
|
151
|
+
const sinkHits = sinks.get(file);
|
|
152
|
+
if (!sinkHits) continue;
|
|
153
|
+
// Only FILE-class credential hits (a concrete on-disk secret path) arm this
|
|
154
|
+
// critical. An env-var NAME next to a fetch() is the single most common
|
|
155
|
+
// shape in ordinary tooling, so it must not be the read half of a
|
|
156
|
+
// read-then-send accusation — it still stands as its own medium finding.
|
|
157
|
+
const fileCreds = credHits.filter((h) => h.klass === 'file');
|
|
158
|
+
if (fileCreds.length === 0) continue;
|
|
159
|
+
findings.push(finding('read_then_send_same_file', 'critical', file,
|
|
160
|
+
`Same-file co-occurrence: names ${fileCreds[0].label} (line ${fileCreds[0].line}) AND carries ${sinkHits[0].label} (line ${sinkHits[0].line}). Co-location, not proven dataflow.`,
|
|
161
|
+
{
|
|
162
|
+
credential_stores: fileCreds.map((h) => h.label),
|
|
163
|
+
network_sinks: sinkHits.map((h) => h.label),
|
|
164
|
+
claimed_as: 'same-file co-occurrence, not dataflow',
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
return findings;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Coverage findings: a skip is only honest if it cannot resolve to "clean". A
|
|
171
|
+
// skipped SENSITIVE file (a config that changes what the harness/installer
|
|
172
|
+
// does) becomes a finding; an artifact NONE of whose bytes were readable
|
|
173
|
+
// becomes a finding. Both keep the floor's own verdict off 'clean' when
|
|
174
|
+
// executable/config content went unreviewed — the header's promise that
|
|
175
|
+
// unreviewed never reads as clean, enforced in the floor itself and not left to
|
|
176
|
+
// the downstream reducer alone.
|
|
177
|
+
function coverageFindings(corpus) {
|
|
178
|
+
const findings = [];
|
|
179
|
+
for (const s of corpus.skipped) {
|
|
180
|
+
if (isSensitivePath(s.path)) {
|
|
181
|
+
findings.push(finding('unreviewed_sensitive_file', 'high', s.path,
|
|
182
|
+
`A security-relevant file was skipped (${s.reason}) and never reviewed — the installer/harness can still read it.`,
|
|
183
|
+
{ reason: s.reason, bytes: s.bytes || 0 }));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (corpus.files.length === 0 && corpus.skipped.length > 0) {
|
|
187
|
+
findings.push(finding('nothing_readable', 'high', '(floor)',
|
|
188
|
+
`None of the artifact's ${corpus.skipped.length} file(s) were readable as text — the floor reviewed nothing.`,
|
|
189
|
+
{ skipped: corpus.skipped.length }));
|
|
190
|
+
}
|
|
191
|
+
return findings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Run every detector crash-isolated: a detector that throws yields a finding,
|
|
195
|
+
// never a hole in the report. `detectors` is injectable so the crash-isolation
|
|
196
|
+
// contract is unit-testable with a deliberately-throwing detector.
|
|
197
|
+
function runDetectors(corpus, detectors = DETECTORS) {
|
|
198
|
+
const findings = [];
|
|
199
|
+
const errors = [];
|
|
200
|
+
for (const [name, detect] of detectors) {
|
|
201
|
+
try {
|
|
202
|
+
findings.push(...detect(corpus));
|
|
203
|
+
} catch (err) {
|
|
204
|
+
errors.push({ detector: name, error: String(err && err.message ? err.message : err) });
|
|
205
|
+
findings.push(finding('detector_error', 'high', '(floor)',
|
|
206
|
+
`Detector "${name}" crashed — its surface is UNREVIEWED and counts against coverage, not toward clean.`,
|
|
207
|
+
{ detector: name }));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { findings, errors };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build + persist the floor report. Returns the report object; the file at
|
|
214
|
+
// <quarantineRoot>/floor-report.json is on disk before this resolves — callers
|
|
215
|
+
// (the skill) MUST await this before any model reads a byte.
|
|
216
|
+
//
|
|
217
|
+
// `extraFindings` carries code-computed evidence produced BEFORE the floor ran
|
|
218
|
+
// — fetchArtifact's findings (unfetched submodules, surviving symlinks,
|
|
219
|
+
// non-regular nodes). They belong in the persisted report for the same reason
|
|
220
|
+
// every detector finding does: they are arithmetic, not opinion, and the
|
|
221
|
+
// reducer takes its floor component from this report. Routing them only to a
|
|
222
|
+
// chat summary let a repo whose submodule was never fetched reduce to CLEAN
|
|
223
|
+
// (review finding) — an artifact with unreviewed code must never read as clean.
|
|
224
|
+
async function runFloor({ pristineDir, quarantineRoot, entries, sha = null, contentHash = null, caps = {}, extraFindings = [] }) {
|
|
225
|
+
// Resolved once, shared by the corpus reads and the persist below. Allowed to
|
|
226
|
+
// throw: an unresolvable pen means no report can honestly land anywhere.
|
|
227
|
+
const realQuarantine = await fsp.realpath(quarantineRoot);
|
|
228
|
+
let corpus = { files: [], skipped: [] };
|
|
229
|
+
let corpusError = null;
|
|
230
|
+
try {
|
|
231
|
+
corpus = await buildCorpus({ pristineDir, quarantineRoot, entries, caps, realQuarantine });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
corpusError = String(err && err.message ? err.message : err);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { findings, errors } = runDetectors(corpus);
|
|
237
|
+
// Each post-detector step is crash-isolated too: the whole point of the floor
|
|
238
|
+
// is that a report ALWAYS lands, so a throw in co-occurrence, the coverage
|
|
239
|
+
// pass, or the capability inventory must degrade to a finding, never abort
|
|
240
|
+
// the persist. isolate() runs a step, appends its result, and on a throw
|
|
241
|
+
// records a detector_error (which contributes caution) instead of propagating.
|
|
242
|
+
const isolate = (name, fn) => {
|
|
243
|
+
try { return fn(); }
|
|
244
|
+
catch (err) {
|
|
245
|
+
errors.push({ detector: name, error: String(err && err.message ? err.message : err) });
|
|
246
|
+
findings.push(finding('detector_error', 'high', '(floor)',
|
|
247
|
+
`Step "${name}" crashed — its surface is UNREVIEWED and counts against coverage, not toward clean.`, { detector: name }));
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
findings.push(...(isolate('co-occurrence', () => coOccurrence(corpus)) || []));
|
|
253
|
+
findings.push(...(isolate('coverage', () => coverageFindings(corpus)) || []));
|
|
254
|
+
// Re-built through finding() so fetch-stage strings get the same scrub + cap
|
|
255
|
+
// every other finding gets — a caller's array is untrusted input here too.
|
|
256
|
+
// `code` does NOT pass through finding()'s chokepoint (it is a label, not a
|
|
257
|
+
// quoted string), so it is scrubbed + capped explicitly here; and the array
|
|
258
|
+
// is length-capped, because an unbounded fold would let one stage balloon
|
|
259
|
+
// the persisted report.
|
|
260
|
+
findings.push(...(isolate('fetch-stage', () => (Array.isArray(extraFindings) ? extraFindings : [])
|
|
261
|
+
.filter((f) => f && typeof f === 'object')
|
|
262
|
+
.slice(0, MAX_EXTRA_FINDINGS)
|
|
263
|
+
.map((f) => finding(
|
|
264
|
+
excerpt(String(f.code || 'fetch_finding'), CODE_MAX_CHARS),
|
|
265
|
+
SEVERITIES.includes(f.severity) ? f.severity : 'high',
|
|
266
|
+
String(f.file || '(fetch)'),
|
|
267
|
+
String(f.message || ''),
|
|
268
|
+
f.detail ?? null
|
|
269
|
+
))) || []));
|
|
270
|
+
const capabilityInventory = isolate('capabilities', () => capabilities.inventory(corpus)) || [];
|
|
271
|
+
if (corpusError) {
|
|
272
|
+
findings.push(finding('corpus_error', 'high', '(floor)',
|
|
273
|
+
'The floor could not read the artifact tree at all — nothing here was reviewed.', { error: corpusError }));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const report = {
|
|
277
|
+
floor_version: FLOOR_VERSION,
|
|
278
|
+
detectors_run: DETECTORS.map(([name]) => name),
|
|
279
|
+
detector_errors: errors,
|
|
280
|
+
generated_at: new Date().toISOString(),
|
|
281
|
+
target: { sha, content_hash: contentHash },
|
|
282
|
+
findings,
|
|
283
|
+
counts: countBySeverity(findings),
|
|
284
|
+
floor_verdict: computeFloorVerdict(findings),
|
|
285
|
+
// Printed, never scored — the reader's power budget for the artifact.
|
|
286
|
+
capabilities: capabilityInventory,
|
|
287
|
+
coverage: {
|
|
288
|
+
files_read: corpus.files.length,
|
|
289
|
+
bytes_read: corpus.files.reduce((sum, f) => sum + f.size, 0),
|
|
290
|
+
files_skipped: corpus.skipped,
|
|
291
|
+
bytes_skipped: corpus.skipped.reduce((sum, s) => sum + (s.bytes || 0), 0),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Atomic persist inside the pen: write-then-rename so a crash mid-write can
|
|
296
|
+
// never leave a half-valid report a later stage might trust. Persist is the
|
|
297
|
+
// one step that MUST be allowed to throw — a report we could not write to disk
|
|
298
|
+
// is not a report, and the caller needs to know the floor did not land.
|
|
299
|
+
const outPath = path.join(realQuarantine, REPORT_BASENAME);
|
|
300
|
+
const tmpPath = `${outPath}.tmp`;
|
|
301
|
+
await fsp.writeFile(tmpPath, JSON.stringify(report, null, 2), 'utf8');
|
|
302
|
+
await fsp.rename(tmpPath, outPath);
|
|
303
|
+
|
|
304
|
+
return { report, reportPath: outPath };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = {
|
|
308
|
+
FLOOR_VERSION,
|
|
309
|
+
REPORT_BASENAME,
|
|
310
|
+
VERDICT_RANK,
|
|
311
|
+
DETECTORS,
|
|
312
|
+
buildCorpus,
|
|
313
|
+
computeFloorVerdict,
|
|
314
|
+
countBySeverity,
|
|
315
|
+
coOccurrence,
|
|
316
|
+
coverageFindings,
|
|
317
|
+
runDetectors,
|
|
318
|
+
runFloor,
|
|
319
|
+
};
|