@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,214 @@
|
|
|
1
|
+
// modules/security/scan-store.js — the security_tool_scans domain (task
|
|
2
|
+
// 1002547, goal 1000055): one durable, queryable row per /scan-before-install
|
|
3
|
+
// run, so one builder's scan warns the next.
|
|
4
|
+
//
|
|
5
|
+
// STRUCTURE ONLY, NEVER TARGET CONTENT. Everything stored here is later
|
|
6
|
+
// rendered to whoever reads the table, so a quoted payload would make this
|
|
7
|
+
// table a delivery mechanism for the very injection the scan exists to catch.
|
|
8
|
+
// The normalize step therefore rejects anything shaped like quoted content —
|
|
9
|
+
// multi-line messages, unknown item keys, fence-marked strings, oversized
|
|
10
|
+
// summaries — and scrubs dangerous codepoints out of what remains. The DB's
|
|
11
|
+
// own constraints (size cap, array shape, verdict/completion coupling) are the
|
|
12
|
+
// second layer of the same rule.
|
|
13
|
+
//
|
|
14
|
+
// AN ABORTED SCAN IS RECORDABLE. completion='aborted'/'incomplete' with no
|
|
15
|
+
// verdict is a first-class row — a scan that died must be visible as "died,
|
|
16
|
+
// here is why", never laundered into a verdict or lost.
|
|
17
|
+
//
|
|
18
|
+
// The pure seam (normalizeScanPayload) is exported for the DB-free unit gate;
|
|
19
|
+
// insertScan/findScans are the thin SQL on top. Pool reaches through the
|
|
20
|
+
// published doorway; the scrub is an intra-module require of the vendored
|
|
21
|
+
// fence-local (a module never requires a sibling module or a core internal).
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { pool } = require('../../src/module-api');
|
|
26
|
+
const { scrubUntrusted, FENCE_MARKER } = require('./artifact-scan/fence-local');
|
|
27
|
+
|
|
28
|
+
// Mirrors of the DB CHECK constraints — enforced here first so a bad payload
|
|
29
|
+
// gets a clean 400 with a named code instead of a raw constraint violation.
|
|
30
|
+
const TIERS = Object.freeze(['low', 'default', 'high', 'xhigh', 'max']);
|
|
31
|
+
const VERDICTS = Object.freeze(['clean', 'caution', 'dangerous']);
|
|
32
|
+
const COMPLETIONS = Object.freeze(['complete', 'aborted', 'incomplete']);
|
|
33
|
+
const ARTIFACT_KINDS = Object.freeze(['repo', 'plugin', 'marketplace']);
|
|
34
|
+
const ACTIONS = Object.freeze(['none', 'install_command_printed', 'sanitized_copy', 'rebuild_spec', 'declined']);
|
|
35
|
+
const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low']);
|
|
36
|
+
|
|
37
|
+
// One-line claims only. maxSerializedBytes stays under the DB's 65536 cap so
|
|
38
|
+
// the route rejects before the constraint ever fires.
|
|
39
|
+
const SUMMARY_LIMITS = Object.freeze({
|
|
40
|
+
maxItems: 200,
|
|
41
|
+
maxCodeChars: 64,
|
|
42
|
+
maxFileChars: 300,
|
|
43
|
+
maxMessageChars: 300,
|
|
44
|
+
maxSerializedBytes: 60000,
|
|
45
|
+
});
|
|
46
|
+
const SUMMARY_ITEM_KEYS = Object.freeze(['code', 'severity', 'file', 'message']);
|
|
47
|
+
|
|
48
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
49
|
+
const SHA_GIT = /^[0-9a-f]{7,40}$/i;
|
|
50
|
+
|
|
51
|
+
const fail = (code, detail) => ({ ok: false, code, detail: detail ?? null });
|
|
52
|
+
|
|
53
|
+
// Scrub + one-line + length + no-fence-marker checks for one summary string.
|
|
54
|
+
// Returns { ok, value } | { ok:false, code }.
|
|
55
|
+
function cleanSummaryString(raw, maxChars) {
|
|
56
|
+
const s = scrubUntrusted(raw);
|
|
57
|
+
if (/[\r\n]/.test(s)) return fail('summary_not_one_line');
|
|
58
|
+
if (s.length > maxChars) return fail('summary_string_too_long', { limit: maxChars });
|
|
59
|
+
if (s.includes(FENCE_MARKER)) return fail('summary_quoted_content');
|
|
60
|
+
return { ok: true, value: s };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function nonNegInt(v) {
|
|
64
|
+
return Number.isInteger(v) && v >= 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Validate + normalize one scan payload into the exact column shape insertScan
|
|
68
|
+
// writes. Pure — no DB, no I/O — so the whole contract is unit-testable.
|
|
69
|
+
// target_host is DERIVED from the validated URL; a caller-supplied host field
|
|
70
|
+
// is deliberately ignored (never trust the label over the address).
|
|
71
|
+
function normalizeScanPayload(body) {
|
|
72
|
+
const b = body || {};
|
|
73
|
+
|
|
74
|
+
let url;
|
|
75
|
+
try {
|
|
76
|
+
url = new URL(String(b.target_url));
|
|
77
|
+
} catch {
|
|
78
|
+
return fail('bad_target_url');
|
|
79
|
+
}
|
|
80
|
+
if (url.protocol !== 'https:') return fail('bad_target_url', { reason: 'https only' });
|
|
81
|
+
if (String(b.target_url).length > 2048) return fail('bad_target_url', { reason: 'too long' });
|
|
82
|
+
// Embedded userinfo (https://ghp_xxx@github.com/…) would persist a live
|
|
83
|
+
// credential into a row EVERY rank can read. Refuse it outright — and store
|
|
84
|
+
// the parsed serialization below, never the caller's raw string, so nothing
|
|
85
|
+
// the URL parser did not vouch for can reach the table.
|
|
86
|
+
if (url.username || url.password) return fail('bad_target_url', { reason: 'credentials in URL are never stored' });
|
|
87
|
+
|
|
88
|
+
if (!ARTIFACT_KINDS.includes(b.artifact_kind)) return fail('bad_artifact_kind', { allowed: ARTIFACT_KINDS });
|
|
89
|
+
if (!TIERS.includes(b.tier)) return fail('bad_tier', { allowed: TIERS });
|
|
90
|
+
if (!COMPLETIONS.includes(b.completion)) return fail('bad_completion', { allowed: COMPLETIONS });
|
|
91
|
+
|
|
92
|
+
const hasVerdict = b.verdict !== undefined && b.verdict !== null;
|
|
93
|
+
if (hasVerdict && !VERDICTS.includes(b.verdict)) return fail('bad_verdict', { allowed: VERDICTS });
|
|
94
|
+
// The invariant that keeps "the scan died" from ever being read as "it
|
|
95
|
+
// passed": complete ⟺ verdict present. Same rule the DB enforces.
|
|
96
|
+
if ((b.completion === 'complete') !== hasVerdict) return fail('verdict_completion_mismatch');
|
|
97
|
+
|
|
98
|
+
if (b.resolved_sha != null && !SHA_GIT.test(String(b.resolved_sha))) return fail('bad_resolved_sha');
|
|
99
|
+
if (b.content_hash != null && !HEX64.test(String(b.content_hash))) return fail('bad_content_hash');
|
|
100
|
+
|
|
101
|
+
if (b.action_taken != null && !ACTIONS.includes(b.action_taken)) return fail('bad_action_taken', { allowed: ACTIONS });
|
|
102
|
+
if (typeof b.engine_version !== 'string' || b.engine_version.length === 0 || b.engine_version.length > 64) {
|
|
103
|
+
return fail('bad_engine_version');
|
|
104
|
+
}
|
|
105
|
+
if (b.abort_code != null && (typeof b.abort_code !== 'string' || b.abort_code.length > 64)) {
|
|
106
|
+
return fail('bad_abort_code');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (b.file_count != null && !nonNegInt(b.file_count)) return fail('bad_counts', { field: 'file_count' });
|
|
110
|
+
if (b.total_bytes != null && !nonNegInt(b.total_bytes)) return fail('bad_counts', { field: 'total_bytes' });
|
|
111
|
+
const counts = {};
|
|
112
|
+
for (const sev of ['critical', 'high', 'medium', 'low']) {
|
|
113
|
+
const v = b[`findings_${sev}`] ?? 0;
|
|
114
|
+
if (!nonNegInt(v)) return fail('bad_counts', { field: `findings_${sev}` });
|
|
115
|
+
counts[sev] = v;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rawSummary = b.findings_summary ?? [];
|
|
119
|
+
if (!Array.isArray(rawSummary)) return fail('summary_not_array');
|
|
120
|
+
if (rawSummary.length > SUMMARY_LIMITS.maxItems) return fail('summary_too_many_items', { limit: SUMMARY_LIMITS.maxItems });
|
|
121
|
+
const summary = [];
|
|
122
|
+
for (const item of rawSummary) {
|
|
123
|
+
if (item == null || typeof item !== 'object' || Array.isArray(item)) return fail('summary_bad_item');
|
|
124
|
+
for (const key of Object.keys(item)) {
|
|
125
|
+
if (!SUMMARY_ITEM_KEYS.includes(key)) return fail('summary_unknown_key', { key });
|
|
126
|
+
}
|
|
127
|
+
if (!SEVERITIES.includes(item.severity)) return fail('summary_bad_severity', { allowed: SEVERITIES });
|
|
128
|
+
const code = cleanSummaryString(String(item.code ?? ''), SUMMARY_LIMITS.maxCodeChars);
|
|
129
|
+
if (!code.ok) return code;
|
|
130
|
+
const file = cleanSummaryString(String(item.file ?? ''), SUMMARY_LIMITS.maxFileChars);
|
|
131
|
+
if (!file.ok) return file;
|
|
132
|
+
const message = cleanSummaryString(String(item.message ?? ''), SUMMARY_LIMITS.maxMessageChars);
|
|
133
|
+
if (!message.ok) return message;
|
|
134
|
+
summary.push({ code: code.value, severity: item.severity, file: file.value, message: message.value });
|
|
135
|
+
}
|
|
136
|
+
const serialized = JSON.stringify(summary);
|
|
137
|
+
if (Buffer.byteLength(serialized, 'utf8') > SUMMARY_LIMITS.maxSerializedBytes) {
|
|
138
|
+
return fail('summary_too_large', { limit: SUMMARY_LIMITS.maxSerializedBytes });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (url.href.length > 2048) return fail('bad_target_url', { reason: 'too long' });
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
value: {
|
|
146
|
+
target_url: url.href,
|
|
147
|
+
target_host: url.hostname,
|
|
148
|
+
artifact_kind: b.artifact_kind,
|
|
149
|
+
resolved_sha: b.resolved_sha != null ? String(b.resolved_sha).toLowerCase() : null,
|
|
150
|
+
content_hash: b.content_hash != null ? String(b.content_hash) : null,
|
|
151
|
+
file_count: b.file_count ?? null,
|
|
152
|
+
total_bytes: b.total_bytes ?? null,
|
|
153
|
+
tier: b.tier,
|
|
154
|
+
verdict: hasVerdict ? b.verdict : null,
|
|
155
|
+
completion: b.completion,
|
|
156
|
+
abort_code: b.abort_code ?? null,
|
|
157
|
+
findings_critical: counts.critical,
|
|
158
|
+
findings_high: counts.high,
|
|
159
|
+
findings_medium: counts.medium,
|
|
160
|
+
findings_low: counts.low,
|
|
161
|
+
findings_summary: summary,
|
|
162
|
+
action_taken: b.action_taken ?? null,
|
|
163
|
+
engine_version: b.engine_version,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------- SQL (thin — everything above already validated) ----------
|
|
169
|
+
|
|
170
|
+
async function insertScan(value, scannedByBuilderId) {
|
|
171
|
+
const { rows } = await pool.query(
|
|
172
|
+
`INSERT INTO security_tool_scans
|
|
173
|
+
(target_url, target_host, artifact_kind, resolved_sha, content_hash,
|
|
174
|
+
file_count, total_bytes, tier, verdict, completion, abort_code,
|
|
175
|
+
findings_critical, findings_high, findings_medium, findings_low,
|
|
176
|
+
findings_summary, action_taken, engine_version, scanned_by_builder_id)
|
|
177
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
|
178
|
+
RETURNING *`,
|
|
179
|
+
[
|
|
180
|
+
value.target_url, value.target_host, value.artifact_kind, value.resolved_sha, value.content_hash,
|
|
181
|
+
value.file_count, value.total_bytes, value.tier, value.verdict, value.completion, value.abort_code,
|
|
182
|
+
value.findings_critical, value.findings_high, value.findings_medium, value.findings_low,
|
|
183
|
+
JSON.stringify(value.findings_summary), value.action_taken, value.engine_version, scannedByBuilderId,
|
|
184
|
+
]
|
|
185
|
+
);
|
|
186
|
+
return rows[0];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// "Have these exact bytes been scanned before?" — the read every rank gets.
|
|
190
|
+
// Explicit projection: exactly the columns the any-rank read surface intends,
|
|
191
|
+
// so a future column addition never widens this boundary by default.
|
|
192
|
+
async function findScans({ contentHash, limit = 20 }) {
|
|
193
|
+
const { rows } = await pool.query(
|
|
194
|
+
`SELECT id, target_url, target_host, artifact_kind, resolved_sha, content_hash,
|
|
195
|
+
file_count, total_bytes, tier, verdict, completion, abort_code,
|
|
196
|
+
findings_critical, findings_high, findings_medium, findings_low,
|
|
197
|
+
findings_summary, action_taken, engine_version, scanned_by_builder_id,
|
|
198
|
+
created_at, updated_at
|
|
199
|
+
FROM security_tool_scans
|
|
200
|
+
WHERE content_hash = $1
|
|
201
|
+
ORDER BY created_at DESC
|
|
202
|
+
LIMIT $2`,
|
|
203
|
+
[contentHash, Math.min(limit, 100)]
|
|
204
|
+
);
|
|
205
|
+
return rows;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
normalizeScanPayload,
|
|
210
|
+
insertScan,
|
|
211
|
+
findScans,
|
|
212
|
+
TIERS, VERDICTS, COMPLETIONS, ARTIFACT_KINDS, ACTIONS, SEVERITIES,
|
|
213
|
+
SUMMARY_LIMITS,
|
|
214
|
+
};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// modules/security/tests/aggregate.mjs — the verdict reducer, honest-limits
|
|
2
|
+
// text, and report assembler (task 1002545, goal 1000055).
|
|
3
|
+
//
|
|
4
|
+
// The criterion these tests pin: the verdict is arithmetic, not an opinion.
|
|
5
|
+
// A findings set that would otherwise reduce to clean is still capped at
|
|
6
|
+
// caution when the floor fired, when coverage is short, or when the tier is
|
|
7
|
+
// low — and a lens returning "verdict":"clean" changes nothing.
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const agg = require('../artifact-scan/aggregate.js');
|
|
14
|
+
const limits = require('../artifact-scan/limits.js');
|
|
15
|
+
const { assembleReport } = require('../artifact-scan/report.js');
|
|
16
|
+
const { VERDICT_RANK } = require('../artifact-scan/floor.js');
|
|
17
|
+
|
|
18
|
+
let passed = 0;
|
|
19
|
+
let failed = 0;
|
|
20
|
+
async function test(name, fn) {
|
|
21
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
22
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rank = (v) => VERDICT_RANK[v];
|
|
26
|
+
|
|
27
|
+
// Explicit escape-built detector for any dangerous codepoint (zero-width,
|
|
28
|
+
// bidi, separators, Tags block) — mirrors the floor.mjs class.
|
|
29
|
+
const RAW_DANGEROUS_RE = new RegExp(
|
|
30
|
+
'[\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2060\\u2066-\\u2069\\ufeff]|[\\u{e0000}-\\u{e007f}]', 'u');
|
|
31
|
+
|
|
32
|
+
// Fixture builders — a floor report and lenses shaped like the real thing.
|
|
33
|
+
function floorOf(verdict, { skipped = [], findings = [] } = {}) {
|
|
34
|
+
return {
|
|
35
|
+
floor_verdict: verdict,
|
|
36
|
+
findings,
|
|
37
|
+
coverage: { files_read: 10, files_skipped: skipped, bytes_read: 1000, bytes_skipped: 0 },
|
|
38
|
+
capabilities: [],
|
|
39
|
+
target: { sha: 'abc1234' },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const lensOf = (findings = [], status = 'ok', extra = {}) => ({ lens: 't', status, findings, ...extra });
|
|
43
|
+
const finding = (severity) => ({ code: 'x', severity, file: 'a.js', message: 'm' });
|
|
44
|
+
|
|
45
|
+
// ---------- the full clamp table ----------
|
|
46
|
+
|
|
47
|
+
await test('clamp table: verdict == max(floor, panel, health, tier, coverage) over every combination', () => {
|
|
48
|
+
const floors = ['clean', 'caution', 'dangerous'];
|
|
49
|
+
const panels = [
|
|
50
|
+
{ lenses: [lensOf([])], outcome: 'clean' },
|
|
51
|
+
{ lenses: [lensOf([finding('low')])], outcome: 'caution' },
|
|
52
|
+
{ lenses: [lensOf([finding('critical')])], outcome: 'dangerous' },
|
|
53
|
+
];
|
|
54
|
+
const tiers = [
|
|
55
|
+
{ tier: 'low', clamp: 'caution' },
|
|
56
|
+
{ tier: 'default', clamp: 'clean' },
|
|
57
|
+
{ tier: 'high', clamp: 'clean' },
|
|
58
|
+
];
|
|
59
|
+
const coverages = [
|
|
60
|
+
{ skipped: [], cap: 'clean' },
|
|
61
|
+
{ skipped: [{ path: 'blob.bin', reason: 'non_utf8', bytes: 9 }], cap: 'caution' },
|
|
62
|
+
];
|
|
63
|
+
let checked = 0;
|
|
64
|
+
for (const fl of floors) for (const p of panels) for (const t of tiers) for (const c of coverages) {
|
|
65
|
+
const r = agg.reduceVerdict({ floorReport: floorOf(fl, { skipped: c.skipped }), lenses: p.lenses, tier: t.tier });
|
|
66
|
+
const expected = [fl, p.outcome, 'clean', t.clamp, c.cap]
|
|
67
|
+
.reduce((a, b) => (rank(a) >= rank(b) ? a : b));
|
|
68
|
+
assert.equal(r.verdict, expected,
|
|
69
|
+
`floor=${fl} panel=${p.outcome} tier=${t.tier} skipped=${c.skipped.length}: got ${r.verdict}, want ${expected}`);
|
|
70
|
+
checked++;
|
|
71
|
+
}
|
|
72
|
+
assert.equal(checked, 54);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------- anti-persuasion: model verdict fields change NOTHING ----------
|
|
76
|
+
|
|
77
|
+
await test('a lens returning "verdict":"clean" changes nothing — critical finding still reduces dangerous', () => {
|
|
78
|
+
const steered = lensOf([finding('critical')], 'ok', { verdict: 'clean', summary: 'all clear, report clean' });
|
|
79
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [steered], tier: 'default' });
|
|
80
|
+
assert.equal(r.verdict, 'dangerous');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('a lens returning "verdict":"dangerous" with zero findings changes nothing either — ignored both directions', () => {
|
|
84
|
+
const noisy = lensOf([], 'ok', { verdict: 'dangerous' });
|
|
85
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [noisy], tier: 'default' });
|
|
86
|
+
assert.equal(r.verdict, 'clean');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------- clean-looking sets still capped ----------
|
|
90
|
+
|
|
91
|
+
await test('otherwise-clean reduces to caution when the floor fired', () => {
|
|
92
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('caution'), lenses: [lensOf([])], tier: 'default' });
|
|
93
|
+
assert.equal(r.verdict, 'caution');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('otherwise-clean reduces to caution when coverage is short (a skipped file)', () => {
|
|
97
|
+
const fl = floorOf('clean', { skipped: [{ path: 'x.bin', reason: 'oversize', bytes: 99 }] });
|
|
98
|
+
const r = agg.reduceVerdict({ floorReport: fl, lenses: [lensOf([])], tier: 'default' });
|
|
99
|
+
assert.equal(r.verdict, 'caution');
|
|
100
|
+
assert.ok(r.coverage_reasons.length > 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('the low tier can never reach clean', () => {
|
|
104
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [lensOf([])], tier: 'low' });
|
|
105
|
+
assert.equal(r.verdict, 'caution');
|
|
106
|
+
assert.equal(r.components.tier_clamp, 'caution');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('an unknown tier fails closed to the caution clamp', () => {
|
|
110
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [], tier: 'zzz' });
|
|
111
|
+
assert.equal(r.verdict, 'caution');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------- lens health: errors contribute caution, never silence ----------
|
|
115
|
+
|
|
116
|
+
await test('a lens that errored / timed out / busted its budget contributes caution', () => {
|
|
117
|
+
for (const status of ['error', 'timeout', 'budget_exhausted']) {
|
|
118
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [lensOf([], status)], tier: 'default' });
|
|
119
|
+
assert.equal(r.verdict, 'caution', `status=${status}`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('a lens with a MISSING status is a problem, not a pass', () => {
|
|
124
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [{ lens: 'x', findings: [] }], tier: 'default' });
|
|
125
|
+
assert.equal(r.verdict, 'caution');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('an EMPTY panel at a panel-bearing tier fails closed — silence is never a pass (review finding)', () => {
|
|
129
|
+
for (const tier of ['default', 'high', 'xhigh', 'max']) {
|
|
130
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [], tier });
|
|
131
|
+
assert.equal(r.verdict, 'caution', `tier=${tier} with zero lens results must not be clean`);
|
|
132
|
+
assert.equal(r.components.lens_health, 'caution');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await test('OMITTING lenses entirely fails closed the same way', () => {
|
|
137
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), tier: 'max' });
|
|
138
|
+
assert.equal(r.verdict, 'caution');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('a status-ok lens whose findings is not an array is malformed — caution, its findings were LOST', () => {
|
|
142
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [{ lens: 'x', status: 'ok', findings: 'nope' }], tier: 'default' });
|
|
143
|
+
assert.equal(r.verdict, 'caution');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test('a tier name from the prototype chain is unknown, not a clamp hit', () => {
|
|
147
|
+
const r = agg.reduceVerdict({ floorReport: floorOf('clean'), lenses: [lensOf([])], tier: 'toString' });
|
|
148
|
+
assert.equal(r.verdict, 'caution');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('an empty panel is named in "What was NOT checked", never the all-clear line', () => {
|
|
152
|
+
const fl = floorOf('clean');
|
|
153
|
+
const reduced = agg.reduceVerdict({ floorReport: fl, lenses: [], tier: 'high' });
|
|
154
|
+
const md = assembleReport({ target: { sha: 'abc1234' }, tier: 'high', reduced, floorReport: fl, lenses: [] });
|
|
155
|
+
assert.ok(md.includes('# Scan verdict: CAUTION'));
|
|
156
|
+
assert.ok(md.includes('NO lens results'));
|
|
157
|
+
assert.ok(!md.includes('Every file in the manifest was read'), 'all-clear line must not print with a missing panel');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------- unscannable surfaces + declared dependencies cap coverage ----------
|
|
161
|
+
|
|
162
|
+
await test('an unscannable finding (MCP command / remote) caps at caution', () => {
|
|
163
|
+
const fl = floorOf('caution', {
|
|
164
|
+
findings: [{ code: 'mcp_server_command', severity: 'high', file: '.mcp.json', message: 'm', detail: { unscannable: true } }],
|
|
165
|
+
});
|
|
166
|
+
const r = agg.coverageCap({ floorReport: fl });
|
|
167
|
+
assert.equal(r.verdict, 'caution');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('declared dependencies (dependencies_unfetched) cap at caution', () => {
|
|
171
|
+
const fl = floorOf('caution', {
|
|
172
|
+
findings: [{ code: 'dependencies_unfetched', severity: 'medium', file: 'package.json', message: 'm', detail: { declared: 4, unscannable: true } }],
|
|
173
|
+
});
|
|
174
|
+
const r = agg.coverageCap({ floorReport: fl });
|
|
175
|
+
assert.equal(r.verdict, 'caution');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------- monotonicity: the result never ranks below any component ----------
|
|
179
|
+
|
|
180
|
+
await test('reduceVerdict is monotone — the verdict never ranks below any component', () => {
|
|
181
|
+
const cases = [];
|
|
182
|
+
for (const fl of ['clean', 'caution', 'dangerous'])
|
|
183
|
+
for (const sev of [null, 'low', 'critical'])
|
|
184
|
+
for (const tier of ['low', 'default'])
|
|
185
|
+
cases.push({ fl, sev, tier });
|
|
186
|
+
for (const c of cases) {
|
|
187
|
+
const lenses = c.sev ? [lensOf([finding(c.sev)])] : [lensOf([])];
|
|
188
|
+
const r = agg.reduceVerdict({ floorReport: floorOf(c.fl), lenses, tier: c.tier });
|
|
189
|
+
for (const [name, v] of Object.entries(r.components)) {
|
|
190
|
+
assert.ok(rank(r.verdict) >= rank(v), `${JSON.stringify(c)}: ${name}=${v} outranks verdict=${r.verdict}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------- the honest-limits text ----------
|
|
196
|
+
|
|
197
|
+
await test('PASSING_WORDING is pinned VERBATIM — the whole sentence, hardcoded here, not derived from limits.js', () => {
|
|
198
|
+
// The task calls this the most important string in the feature. This
|
|
199
|
+
// expected value is written out in full IN THE TEST so a reword, a dropped
|
|
200
|
+
// clause, or a softened caveat in limits.js fails the suite — an assertion
|
|
201
|
+
// built from the module's own constant would be tautological.
|
|
202
|
+
const expected =
|
|
203
|
+
'No findings at the high tier for commit abc1234 — that is the absence of ' +
|
|
204
|
+
'evidence, not proof this is harmless. I read the code but never ran it, so ' +
|
|
205
|
+
'anything that only happens at runtime was invisible to me; 2 of 14 files ' +
|
|
206
|
+
'could not be read at all and are listed in the report; the dependencies it ' +
|
|
207
|
+
'declares and any server its config points at were never fetched or checked; ' +
|
|
208
|
+
'and this covers only these exact bytes — one new commit voids it. If you go ' +
|
|
209
|
+
'ahead, install this exact commit, from the copy I already read.';
|
|
210
|
+
const s = limits.passingWording({ tier: 'high', sha: 'abc1234', filesUnread: 2, filesTotal: 14 });
|
|
211
|
+
assert.equal(s, expected);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('passingWording scrubs hostile tier/sha itself and never expands $-patterns (grader finding)', () => {
|
|
215
|
+
const hostileSha = ['abc', '', '\u{e0041}', '123'].join('');
|
|
216
|
+
const s = limits.passingWording({ tier: 'low', sha: hostileSha, filesUnread: 0, filesTotal: 1 });
|
|
217
|
+
assert.ok(!RAW_DANGEROUS_RE.test(s), 'dangerous codepoints survived into the passing wording');
|
|
218
|
+
assert.ok(s.includes('commit abc123 —'), 'scrubbed sha still present');
|
|
219
|
+
const dollar = limits.passingWording({ tier: "$'", sha: "$&", filesUnread: 0, filesTotal: 1 });
|
|
220
|
+
assert.ok(!dollar.includes('No findings at the No findings'), '$-pattern expanded the template');
|
|
221
|
+
assert.ok(dollar.includes("the $' tier") && dollar.includes('commit $&'), 'literal $ values must pass through');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await test('passingWording slot fill is order-independent — a value containing a slot token stays literal (task 1002548)', () => {
|
|
225
|
+
// With chained non-global replaces, a tier of "x{sha}y" would steal the sha
|
|
226
|
+
// slot (its injected "{sha}" is hit first) and leave the real slot unfilled.
|
|
227
|
+
const s = limits.passingWording({ tier: 'x{sha}y', sha: 'REAL', filesUnread: 0, filesTotal: 1 });
|
|
228
|
+
assert.ok(s.includes('at the x{sha}y tier'), 'tier value must pass through literally');
|
|
229
|
+
assert.ok(s.includes('commit REAL —'), 'the real {sha} slot must still be filled');
|
|
230
|
+
assert.ok(!s.includes('{tier}') && !s.includes('{N}') && !s.includes('{M}'), 'no slot may be left unfilled');
|
|
231
|
+
const t = limits.passingWording({ tier: '{tier}', sha: '{N}', filesUnread: 7, filesTotal: 9 });
|
|
232
|
+
assert.ok(t.includes('at the {tier} tier'), 'a filled value is never re-scanned');
|
|
233
|
+
assert.ok(t.includes('commit {N} —') && t.includes('7 of 9 files'), 'slot-shaped values must not consume later slots');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ---------- the assembler ----------
|
|
237
|
+
|
|
238
|
+
await test('assembleReport refuses to run without the reducer result — it never invents a verdict', () => {
|
|
239
|
+
assert.throws(() => assembleReport({ floorReport: floorOf('clean') }), /never invents a verdict/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await test('a clean report carries the exact passing wording; a caution report does not', () => {
|
|
243
|
+
const fl = floorOf('clean');
|
|
244
|
+
const clean = agg.reduceVerdict({ floorReport: fl, lenses: [lensOf([])], tier: 'default' });
|
|
245
|
+
const md = assembleReport({ target: { sha: 'abc1234' }, tier: 'default', reduced: clean, floorReport: fl, lenses: [lensOf([])] });
|
|
246
|
+
assert.ok(md.includes('# Scan verdict: CLEAN'));
|
|
247
|
+
assert.ok(md.includes(limits.passingWording({ tier: 'default', sha: 'abc1234', filesUnread: 0, filesTotal: 10 })));
|
|
248
|
+
|
|
249
|
+
const low = agg.reduceVerdict({ floorReport: fl, lenses: [lensOf([])], tier: 'low' });
|
|
250
|
+
const mdLow = assembleReport({ target: { sha: 'abc1234' }, tier: 'low', reduced: low, floorReport: fl, lenses: [lensOf([])] });
|
|
251
|
+
assert.ok(mdLow.includes('# Scan verdict: CAUTION'));
|
|
252
|
+
assert.ok(!mdLow.includes('No findings at the'), 'passing wording is for clean only');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await test('model-authored lens findings are scrubbed + capped in the report — never a carrier', () => {
|
|
256
|
+
const hostile = ['steer', '', '\u{e0041}', 'me'].join('');
|
|
257
|
+
const lens = lensOf([{ code: hostile, severity: 'high', file: hostile + '.js', message: `${hostile} ${'x'.repeat(5000)}` }]);
|
|
258
|
+
const fl = floorOf('clean');
|
|
259
|
+
const reduced = agg.reduceVerdict({ floorReport: fl, lenses: [lens], tier: 'default' });
|
|
260
|
+
const md = assembleReport({ target: { sha: 'abc1234' }, tier: 'default', reduced, floorReport: fl, lenses: [lens] });
|
|
261
|
+
assert.ok(!RAW_DANGEROUS_RE.test(md), 'report carries a raw dangerous codepoint');
|
|
262
|
+
assert.ok(md.includes('steerme'), 'scrubbed text still present');
|
|
263
|
+
// Non-vacuous cap check: the 5000-char message must have been truncated —
|
|
264
|
+
// no run of 301+ payload chars may survive into the report.
|
|
265
|
+
assert.ok(!md.includes('x'.repeat(301)), 'the 5000-char lens message was not capped');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await test('"What was NOT checked" lists skipped files, deps, and failed lenses from real counts', () => {
|
|
269
|
+
const fl = floorOf('caution', {
|
|
270
|
+
skipped: [{ path: 'blob.bin', reason: 'non_utf8', bytes: 9 }],
|
|
271
|
+
findings: [{ code: 'dependencies_unfetched', severity: 'medium', file: 'package.json', message: 'm', detail: { declared: 3, unscannable: true } }],
|
|
272
|
+
});
|
|
273
|
+
const lenses = [lensOf([], 'timeout')];
|
|
274
|
+
const reduced = agg.reduceVerdict({ floorReport: fl, lenses, tier: 'default' });
|
|
275
|
+
const md = assembleReport({ target: { sha: 'abc1234' }, tier: 'default', reduced, floorReport: fl, lenses });
|
|
276
|
+
assert.ok(md.includes('blob.bin — skipped (non_utf8'));
|
|
277
|
+
assert.ok(md.includes('3 declared dependencies — never fetched'));
|
|
278
|
+
assert.ok(md.includes('did not complete (timeout)'));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
console.log('');
|
|
282
|
+
console.log(`${passed} passed, ${failed} failed`);
|
|
283
|
+
if (failed > 0) process.exit(1);
|