@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,591 @@
|
|
|
1
|
+
// modules/security/tests/artifact_scan.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1002542 (goal 1000055) — the hardened fetch, the quarantine location
|
|
4
|
+
// rules, and the deterministic manifest.
|
|
5
|
+
//
|
|
6
|
+
// These are the FIRST leg of the scan engine, and their whole job is to deny a
|
|
7
|
+
// hostile artifact any chance to act. So the tests assert the security contract
|
|
8
|
+
// directly rather than the happy path: the exact git hardening flags, the
|
|
9
|
+
// refusal rules for where a quarantine may live, that symlinks are never
|
|
10
|
+
// followed, and that a cap trips FAIL-CLOSED instead of truncating quietly.
|
|
11
|
+
//
|
|
12
|
+
// The fetch tests inject a stub runner, so the full argv/env contract is proven
|
|
13
|
+
// with zero network and nothing cloned.
|
|
14
|
+
//
|
|
15
|
+
// Pure (no DB / network) — runs in `node scripts/gds/run-unit-tests.js`.
|
|
16
|
+
// Run directly: node modules/security/tests/artifact_scan.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const quarantine = require('../artifact-scan/quarantine.js');
|
|
27
|
+
const manifest = require('../artifact-scan/manifest.js');
|
|
28
|
+
const fetchMod = require('../artifact-scan/fetch.js');
|
|
29
|
+
|
|
30
|
+
const { createQuarantine, siteRefusal, isSyncBacked, isInside, REPO_ROOT, CAPS } = quarantine;
|
|
31
|
+
const { buildManifest, verifyUnchanged, CapExceeded, KIND } = manifest;
|
|
32
|
+
const { validateTargetUrl, hardeningConfig, hardenedEnv, parseLsFiles, fetchArtifact, blockedAddress, assertHostResolvesPublic } = fetchMod;
|
|
33
|
+
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
async function test(name, fn) {
|
|
37
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
38
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tmpDirs = [];
|
|
42
|
+
function mkTmp() {
|
|
43
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'as-test-'));
|
|
44
|
+
tmpDirs.push(d);
|
|
45
|
+
return fs.realpathSync(d);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------- quarantine
|
|
49
|
+
|
|
50
|
+
await test('siteRefusal refuses the repository itself', async () => {
|
|
51
|
+
const r = siteRefusal(path.join(REPO_ROOT, 'sub'), []);
|
|
52
|
+
assert.ok(r && r.includes('repository'), `expected a repo refusal, got ${r}`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await test('siteRefusal refuses a cloud-sync folder (it would upload the artifact)', async () => {
|
|
56
|
+
const r = siteRefusal(path.join(path.sep, 'Users', 'x', 'OneDrive', 'tmp'), []);
|
|
57
|
+
assert.ok(r && r.includes('sync'), `expected a sync refusal, got ${r}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await test('siteRefusal refuses a workspace root — an artifact CLAUDE.md must not auto-load', async () => {
|
|
61
|
+
const ws = path.join(path.sep, 'work', 'space');
|
|
62
|
+
const r = siteRefusal(path.join(ws, 'inner'), [ws]);
|
|
63
|
+
assert.ok(r && r.includes('workspace root'), `expected a workspace refusal, got ${r}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await test('siteRefusal allows a clean temp location', async () => {
|
|
67
|
+
assert.equal(siteRefusal(mkTmp(), []), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('isSyncBacked matches case-insensitively on a path segment', async () => {
|
|
71
|
+
assert.equal(isSyncBacked(path.join(path.sep, 'a', 'onedrive', 'b')), true);
|
|
72
|
+
assert.equal(isSyncBacked(path.join(path.sep, 'a', 'OneDrive', 'b')), true);
|
|
73
|
+
assert.equal(isSyncBacked(path.join(path.sep, 'a', 'onedriver', 'b')), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await test('isInside is directional — a parent is not inside its child', async () => {
|
|
77
|
+
const root = path.join(path.sep, 'q');
|
|
78
|
+
assert.equal(isInside(root, path.join(root, 'a', 'b')), true);
|
|
79
|
+
assert.equal(isInside(root, root), true);
|
|
80
|
+
assert.equal(isInside(path.join(root, 'a'), root), false);
|
|
81
|
+
assert.equal(isInside(root, path.join(path.sep, 'other')), false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await test('createQuarantine builds an EMPTY REAL hooks dir and an empty gitconfig', async () => {
|
|
85
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
86
|
+
try {
|
|
87
|
+
assert.ok(fs.statSync(q.hooksDir).isDirectory(), 'hooksDir must be a real directory (Windows has no /dev/null dir)');
|
|
88
|
+
assert.equal(fs.readdirSync(q.hooksDir).length, 0, 'hooksDir must be empty');
|
|
89
|
+
assert.equal(fs.readFileSync(q.gitConfigFile, 'utf8'), '');
|
|
90
|
+
assert.ok(fs.statSync(q.mirrorDir).isDirectory());
|
|
91
|
+
assert.equal(fs.existsSync(q.pristineDir), false, 'git clone must create pristineDir itself');
|
|
92
|
+
} finally { q.cleanup(); }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('createQuarantine refuses to build inside a workspace root', async () => {
|
|
96
|
+
const ws = mkTmp();
|
|
97
|
+
assert.throws(() => createQuarantine({ tmpRoot: ws, forbiddenRoots: [ws] }), /workspace root/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('forbiddenRoots is REQUIRED — a silent default would disable the defense', async () => {
|
|
101
|
+
assert.throws(() => createQuarantine({ tmpRoot: mkTmp() }), /requires forbiddenRoots/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await test('cleanup removes the whole pen', async () => {
|
|
105
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
106
|
+
const root = q.root;
|
|
107
|
+
q.cleanup();
|
|
108
|
+
assert.equal(fs.existsSync(root), false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ------------------------------------------------------------------ manifest
|
|
112
|
+
|
|
113
|
+
function seedTree(root) {
|
|
114
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
115
|
+
fs.writeFileSync(path.join(root, 'README.md'), 'hello\n');
|
|
116
|
+
fs.writeFileSync(path.join(root, 'src', 'a.js'), 'module.exports = 1;\n');
|
|
117
|
+
fs.writeFileSync(path.join(root, 'src', 'b.js'), 'module.exports = 2;\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await test('manifest paths are POSIX and sorted by UTF-8 bytes, not locale', async () => {
|
|
121
|
+
const root = mkTmp();
|
|
122
|
+
seedTree(root);
|
|
123
|
+
const m = await buildManifest(root, { maxFiles: 100, maxBytes: 1e6 });
|
|
124
|
+
assert.deepEqual(m.entries.map((e) => e.path), ['README.md', 'src/a.js', 'src/b.js']);
|
|
125
|
+
assert.equal(m.fileCount, 3);
|
|
126
|
+
assert.ok(m.entries.every((e) => !e.path.includes('\\')), 'no backslashes in manifest paths');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await test('content_hash is stable across rebuilds and changes when a byte changes', async () => {
|
|
130
|
+
const root = mkTmp();
|
|
131
|
+
seedTree(root);
|
|
132
|
+
const a = await buildManifest(root, { maxFiles: 100, maxBytes: 1e6 });
|
|
133
|
+
const b = await buildManifest(root, { maxFiles: 100, maxBytes: 1e6 });
|
|
134
|
+
assert.equal(a.contentHash, b.contentHash);
|
|
135
|
+
|
|
136
|
+
fs.writeFileSync(path.join(root, 'src', 'a.js'), 'module.exports = 99;\n');
|
|
137
|
+
const c = await buildManifest(root, { maxFiles: 100, maxBytes: 1e6 });
|
|
138
|
+
assert.notEqual(a.contentHash, c.contentHash);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('verifyUnchanged is what stands between "scan passed" and "here is the command"', async () => {
|
|
142
|
+
const root = mkTmp();
|
|
143
|
+
seedTree(root);
|
|
144
|
+
const m = await buildManifest(root, { maxFiles: 100, maxBytes: 1e6 });
|
|
145
|
+
assert.equal((await verifyUnchanged(root, m)).ok, true);
|
|
146
|
+
|
|
147
|
+
fs.writeFileSync(path.join(root, 'evil.js'), 'gotcha\n');
|
|
148
|
+
assert.equal((await verifyUnchanged(root, m)).ok, false, 'a file added after the scan must invalidate the manifest');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await test('buildManifest refuses an implicit unlimited budget', async () => {
|
|
152
|
+
await assert.rejects(() => buildManifest(mkTmp(), {}), /fail-closed/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await test('a file-count cap aborts FAIL-CLOSED rather than truncating', async () => {
|
|
156
|
+
const root = mkTmp();
|
|
157
|
+
seedTree(root);
|
|
158
|
+
try {
|
|
159
|
+
await buildManifest(root, { maxFiles: 2, maxBytes: 1e6 });
|
|
160
|
+
assert.fail('expected CapExceeded');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
assert.ok(err instanceof CapExceeded);
|
|
163
|
+
assert.equal(err.code, 'cap_max_files');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await test('a byte cap aborts FAIL-CLOSED', async () => {
|
|
168
|
+
const root = mkTmp();
|
|
169
|
+
seedTree(root);
|
|
170
|
+
try {
|
|
171
|
+
await buildManifest(root, { maxFiles: 100, maxBytes: 5 });
|
|
172
|
+
assert.fail('expected CapExceeded');
|
|
173
|
+
} catch (err) {
|
|
174
|
+
assert.equal(err.code, 'cap_max_bytes');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await test('a symlink is recorded by its link text and its target is NEVER opened', async () => {
|
|
179
|
+
const root = mkTmp();
|
|
180
|
+
const secretDir = mkTmp();
|
|
181
|
+
const secret = path.join(secretDir, 'credentials');
|
|
182
|
+
fs.writeFileSync(secret, 'AKIA-SUPER-SECRET-TOKEN\n');
|
|
183
|
+
fs.writeFileSync(path.join(root, 'README.md'), 'hi\n');
|
|
184
|
+
try {
|
|
185
|
+
fs.symlinkSync(secret, path.join(root, 'stolen'));
|
|
186
|
+
} catch {
|
|
187
|
+
console.log(' (skipped: this platform/account cannot create symlinks)');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const m = await buildManifest(root, { maxFiles: 100, maxBytes: 1e6 });
|
|
191
|
+
const link = m.entries.find((e) => e.path === 'stolen');
|
|
192
|
+
assert.equal(link.kind, KIND.SYMLINK);
|
|
193
|
+
assert.equal(link.sha256, null, 'a symlink must not be hashed — that would read the target');
|
|
194
|
+
assert.equal(link.linkTarget, secret);
|
|
195
|
+
const serialized = JSON.stringify(m);
|
|
196
|
+
assert.ok(!serialized.includes('SUPER-SECRET'), 'the target CONTENTS must appear nowhere in the manifest');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// --------------------------------------------------------------------- fetch
|
|
200
|
+
|
|
201
|
+
await test('validateTargetUrl accepts https and records the host', async () => {
|
|
202
|
+
const r = validateTargetUrl('https://github.com/acme/tool');
|
|
203
|
+
assert.equal(r.ok, true);
|
|
204
|
+
assert.equal(r.host, 'github.com');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await test('validateTargetUrl refuses every non-https scheme', async () => {
|
|
208
|
+
for (const u of ['ssh://git@github.com/a/b', 'git://github.com/a/b', 'file:///etc/passwd', 'http://github.com/a/b']) {
|
|
209
|
+
assert.equal(validateTargetUrl(u).ok, false, `${u} must be refused`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await test('validateTargetUrl refuses embedded credentials and loopback', async () => {
|
|
214
|
+
assert.equal(validateTargetUrl('https://user:pw@github.com/a/b').ok, false);
|
|
215
|
+
assert.equal(validateTargetUrl('https://localhost/a/b').ok, false);
|
|
216
|
+
assert.equal(validateTargetUrl('https://127.0.0.1/a/b').ok, false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// SSRF. The scan URL is attacker-chosen, so an unblocked private range turns
|
|
220
|
+
// the scanner into a metadata-endpoint fetcher and an internal port scanner.
|
|
221
|
+
await test('the cloud metadata endpoint is refused', async () => {
|
|
222
|
+
const r = validateTargetUrl('https://169.254.169.254/latest/meta-data/');
|
|
223
|
+
assert.equal(r.ok, false);
|
|
224
|
+
assert.match(r.refusal, /metadata/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('every private and reserved literal range is refused', async () => {
|
|
228
|
+
const blocked = [
|
|
229
|
+
'10.0.0.5', '172.16.0.1', '172.31.255.254', '192.168.1.1',
|
|
230
|
+
'169.254.169.254', '127.0.0.1', '0.0.0.0', '100.64.0.1', '224.0.0.1',
|
|
231
|
+
];
|
|
232
|
+
for (const host of blocked) {
|
|
233
|
+
assert.equal(validateTargetUrl(`https://${host}/a/b`).ok, false, `${host} must be refused`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('IPv6 loopback, unique-local, link-local, and v4-mapped forms are refused', async () => {
|
|
238
|
+
for (const host of ['[::1]', '[::ffff:127.0.0.1]', '[fc00::1]', '[fd12:3456::1]', '[fe80::1]']) {
|
|
239
|
+
assert.equal(validateTargetUrl(`https://${host}/a/b`).ok, false, `${host} must be refused`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await test('routable public addresses and hostnames still pass', async () => {
|
|
244
|
+
for (const host of ['github.com', '140.82.121.4', '8.8.8.8', '172.15.0.1', '172.32.0.1', '192.169.0.1']) {
|
|
245
|
+
assert.equal(validateTargetUrl(`https://${host}/a/b`).ok, true, `${host} must be allowed`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await test('blockedAddress is honest about what it cannot catch', async () => {
|
|
250
|
+
// A NAME that resolves to a private address is not caught here — resolution
|
|
251
|
+
// happens inside git. This asserts the documented limit rather than pretending.
|
|
252
|
+
assert.equal(blockedAddress('internal.corp.example'), null);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await test('the git hardening contract is present in full', async () => {
|
|
256
|
+
const cfg = hardeningConfig({ hooksDir: '/q/empty-hooks' }).join(' ');
|
|
257
|
+
for (const required of [
|
|
258
|
+
'core.symlinks=false',
|
|
259
|
+
'core.hooksPath=/q/empty-hooks',
|
|
260
|
+
'protocol.file.allow=never',
|
|
261
|
+
'protocol.ext.allow=never',
|
|
262
|
+
'transfer.fsckObjects=true',
|
|
263
|
+
'credential.helper=',
|
|
264
|
+
'filter.lfs.smudge=',
|
|
265
|
+
]) {
|
|
266
|
+
assert.ok(cfg.includes(required), `missing hardening: ${required}`);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await test("hardenedEnv neutralizes the builder's own git config and LFS", () => {
|
|
271
|
+
const env = hardenedEnv({ gitConfigFile: '/q/empty-gitconfig' });
|
|
272
|
+
assert.equal(env.GIT_TERMINAL_PROMPT, '0');
|
|
273
|
+
assert.equal(env.GIT_CONFIG_GLOBAL, '/q/empty-gitconfig');
|
|
274
|
+
assert.equal(env.GIT_CONFIG_SYSTEM, '/q/empty-gitconfig');
|
|
275
|
+
assert.equal(env.GIT_CONFIG_NOSYSTEM, '1');
|
|
276
|
+
assert.equal(env.GIT_LFS_SKIP_SMUDGE, '1');
|
|
277
|
+
assert.equal(env.GIT_ASKPASS, '');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await test('hardenedEnv emits no case-insensitive duplicate keys (Windows spawn ENOENT)', async () => {
|
|
281
|
+
const env = hardenedEnv({ gitConfigFile: '/q/empty-gitconfig' });
|
|
282
|
+
const lowered = Object.keys(env).map((k) => k.toLowerCase());
|
|
283
|
+
assert.equal(lowered.length, new Set(lowered).size, 'a duplicate PATH/Path breaks CreateProcess on Windows');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await test('hardenedEnv forwards only what git needs — no ambient secrets', async () => {
|
|
287
|
+
const KEY = 'ARTIFACT_SCAN_FAKE_SECRET';
|
|
288
|
+
process.env[KEY] = 'super-secret-token';
|
|
289
|
+
try {
|
|
290
|
+
const env = hardenedEnv({ gitConfigFile: '/q/empty-gitconfig' });
|
|
291
|
+
assert.equal(env[KEY], undefined, 'an ambient secret must NOT reach a subprocess talking to a third-party host');
|
|
292
|
+
assert.ok(env.PATH || env.Path, 'git still needs PATH');
|
|
293
|
+
} finally {
|
|
294
|
+
delete process.env[KEY];
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await test('parseLsFiles reads mode and path, tolerating paths with spaces', async () => {
|
|
299
|
+
const modes = parseLsFiles('100644 abc 0\tREADME.md\n120000 def 0\tsome dir/link\n');
|
|
300
|
+
assert.deepEqual(modes, [
|
|
301
|
+
{ mode: '100644', path: 'README.md' },
|
|
302
|
+
{ mode: '120000', path: 'some dir/link' },
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
// Stub runner + stub resolver standing in for git and DNS: the whole argv/env
|
|
308
|
+
// contract is proven with zero network and nothing cloned or resolved.
|
|
309
|
+
const PUBLIC_IP = ['140.82.121.4'];
|
|
310
|
+
const stubResolve = async () => PUBLIC_IP;
|
|
311
|
+
|
|
312
|
+
function stubRun(q, { modes = '100644 abc 0\tREADME.md\n', sha = 'a'.repeat(40), lsTruncated = false, lsStatus = 0 } = {}) {
|
|
313
|
+
const calls = [];
|
|
314
|
+
return {
|
|
315
|
+
calls,
|
|
316
|
+
async run(args, opts) {
|
|
317
|
+
calls.push({ args, opts });
|
|
318
|
+
if (args.includes('clone')) {
|
|
319
|
+
fs.mkdirSync(path.join(q.pristineDir, '.git'), { recursive: true });
|
|
320
|
+
fs.writeFileSync(path.join(q.pristineDir, '.git', 'config'), 'x');
|
|
321
|
+
fs.writeFileSync(path.join(q.pristineDir, 'README.md'), 'hello\n');
|
|
322
|
+
return { status: 0, stdout: '', stderr: '', timedOut: false, truncated: false, error: null };
|
|
323
|
+
}
|
|
324
|
+
if (args.includes('rev-parse')) return { status: 0, stdout: sha + '\n', stderr: '', timedOut: false, truncated: false, error: null };
|
|
325
|
+
if (args.includes('ls-files')) return { status: lsStatus, stdout: lsStatus === 0 ? modes : '', stderr: '', timedOut: false, truncated: lsTruncated, error: null };
|
|
326
|
+
return { status: 1, stdout: '', stderr: 'unexpected', timedOut: false, truncated: false, error: null };
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
await test('fetchArtifact pins a SHA, drops .git, and manifests the content', async () => {
|
|
332
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
333
|
+
try {
|
|
334
|
+
const stub = stubRun(q);
|
|
335
|
+
const res = await fetchArtifact('https://github.com/acme/tool', { quarantine: q, run: stub.run, resolveHost: stubResolve });
|
|
336
|
+
assert.equal(res.sha, 'a'.repeat(40));
|
|
337
|
+
assert.equal(fs.existsSync(path.join(q.pristineDir, '.git')), false, '.git must be removed after the SHA is captured');
|
|
338
|
+
assert.deepEqual(res.manifest.entries.map((e) => e.path), ['README.md']);
|
|
339
|
+
|
|
340
|
+
const cloneArgs = stub.calls[0].args.join(' ');
|
|
341
|
+
assert.ok(cloneArgs.includes('--depth 1'));
|
|
342
|
+
assert.ok(cloneArgs.includes('--no-recurse-submodules'));
|
|
343
|
+
assert.ok(cloneArgs.includes('core.symlinks=false'));
|
|
344
|
+
assert.ok(cloneArgs.includes(' -- '), 'the URL must sit after -- so it can never be read as a flag');
|
|
345
|
+
} finally { q.cleanup(); }
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await test('ls-files runs BEFORE .git is deleted — it is the only symlink witness', async () => {
|
|
349
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
350
|
+
try {
|
|
351
|
+
const stub = stubRun(q);
|
|
352
|
+
await fetchArtifact('https://github.com/acme/tool', { quarantine: q, run: stub.run, resolveHost: stubResolve });
|
|
353
|
+
const order = stub.calls.map((c) => (c.args.includes('clone') ? 'clone' : c.args.includes('rev-parse') ? 'rev-parse' : 'ls-files'));
|
|
354
|
+
assert.deepEqual(order, ['clone', 'rev-parse', 'ls-files']);
|
|
355
|
+
} finally { q.cleanup(); }
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await test('a gitlink and a symlink each become a reported finding', async () => {
|
|
359
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
360
|
+
try {
|
|
361
|
+
const stub = stubRun(q, { modes: '120000 abc 0\tstolen\n160000 def 0\tvendor/sub\n' });
|
|
362
|
+
const res = await fetchArtifact('https://github.com/acme/tool', { quarantine: q, run: stub.run, resolveHost: stubResolve });
|
|
363
|
+
const codes = res.findings.map((f) => f.code);
|
|
364
|
+
assert.ok(codes.includes('symlink_in_artifact'));
|
|
365
|
+
assert.ok(codes.includes('submodule_not_fetched'), 'an unfetched submodule is unreviewed code and must be surfaced');
|
|
366
|
+
} finally { q.cleanup(); }
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await test('the gitlink finding carries unscannable so the coverage cap can see wholly unreviewed submodule code', async () => {
|
|
370
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
371
|
+
try {
|
|
372
|
+
const stub = stubRun(q, { modes: '160000 def 0\tvendor/sub\n' });
|
|
373
|
+
const res = await fetchArtifact('https://github.com/acme/tool', { quarantine: q, run: stub.run, resolveHost: stubResolve });
|
|
374
|
+
const sub = res.findings.find((f) => f.code === 'submodule_not_fetched');
|
|
375
|
+
// A gitlink materializes as an EMPTY dir, so the manifest records nothing
|
|
376
|
+
// for it — this flag is the only thing that keeps it off a clean verdict.
|
|
377
|
+
assert.equal(sub.detail.unscannable, true);
|
|
378
|
+
} finally { q.cleanup(); }
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
await test('a TRUNCATED git index is a finding — a submodule past the cut must never read as "none"', async () => {
|
|
382
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
383
|
+
try {
|
|
384
|
+
// The clipped listing shows only regular files; the real gitlink sat past
|
|
385
|
+
// the 1 MiB capture cap and is invisible. Silence here was a fail-open.
|
|
386
|
+
const stub = stubRun(q, { lsTruncated: true });
|
|
387
|
+
const res = await fetchArtifact('https://github.com/acme/tool', { quarantine: q, run: stub.run, resolveHost: stubResolve });
|
|
388
|
+
const f = res.findings.find((x) => x.code === 'git_index_truncated');
|
|
389
|
+
assert.ok(f, 'a truncated index listing must be surfaced');
|
|
390
|
+
assert.equal(f.severity, 'high');
|
|
391
|
+
assert.equal(f.detail.unscannable, true);
|
|
392
|
+
} finally { q.cleanup(); }
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
await test('a FAILED git index read is a finding, not an empty list of modes', async () => {
|
|
396
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
397
|
+
try {
|
|
398
|
+
const stub = stubRun(q, { lsStatus: 128 });
|
|
399
|
+
const res = await fetchArtifact('https://github.com/acme/tool', { quarantine: q, run: stub.run, resolveHost: stubResolve });
|
|
400
|
+
const f = res.findings.find((x) => x.code === 'git_index_unread');
|
|
401
|
+
assert.ok(f, 'an unreadable index must be surfaced');
|
|
402
|
+
assert.equal(f.detail.unscannable, true);
|
|
403
|
+
} finally { q.cleanup(); }
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// DNS. A literal-only block is trivially bypassed by pointing an A record at a
|
|
407
|
+
// private address, so resolution is checked before git is ever invoked.
|
|
408
|
+
await test('a hostname RESOLVING to cloud metadata is refused before git runs', async () => {
|
|
409
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
410
|
+
try {
|
|
411
|
+
let ran = false;
|
|
412
|
+
await assert.rejects(
|
|
413
|
+
() => fetchArtifact('https://evil.example.com/a/b', {
|
|
414
|
+
quarantine: q,
|
|
415
|
+
run: async () => { ran = true; return { status: 0 }; },
|
|
416
|
+
resolveHost: async () => ['169.254.169.254'],
|
|
417
|
+
}),
|
|
418
|
+
/resolves to .*metadata/
|
|
419
|
+
);
|
|
420
|
+
assert.equal(ran, false, 'git must never be invoked once the name resolves private');
|
|
421
|
+
} finally { q.cleanup(); }
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
await test('a hostname resolving to RFC 1918 is refused, even if one answer is public', async () => {
|
|
425
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
426
|
+
try {
|
|
427
|
+
await assert.rejects(
|
|
428
|
+
() => fetchArtifact('https://evil.example.com/a/b', {
|
|
429
|
+
quarantine: q,
|
|
430
|
+
run: async () => ({ status: 0 }),
|
|
431
|
+
resolveHost: async () => ['140.82.121.4', '10.0.0.5'],
|
|
432
|
+
}),
|
|
433
|
+
/host_resolves_private|RFC 1918/
|
|
434
|
+
);
|
|
435
|
+
} finally { q.cleanup(); }
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
await test('an unresolvable host aborts with a finding rather than reaching git', async () => {
|
|
439
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
440
|
+
try {
|
|
441
|
+
await assert.rejects(
|
|
442
|
+
() => fetchArtifact('https://nope.invalid/a/b', {
|
|
443
|
+
quarantine: q,
|
|
444
|
+
run: async () => ({ status: 0 }),
|
|
445
|
+
resolveHost: async () => { throw new Error('ENOTFOUND'); },
|
|
446
|
+
}),
|
|
447
|
+
(err) => err.code === 'host_unresolvable' && err.finding.severity === 'high'
|
|
448
|
+
);
|
|
449
|
+
} finally { q.cleanup(); }
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await test('assertHostResolvesPublic passes a genuinely public answer through', async () => {
|
|
453
|
+
const addrs = await assertHostResolvesPublic('github.com', stubResolve);
|
|
454
|
+
assert.deepEqual(addrs, PUBLIC_IP);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await test('a clone timeout aborts the scan rather than reporting on nothing', async () => {
|
|
458
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
459
|
+
try {
|
|
460
|
+
const run = async () => ({ status: null, stdout: '', stderr: '', timedOut: true, error: 'ETIMEDOUT' });
|
|
461
|
+
await assert.rejects(
|
|
462
|
+
() => fetchArtifact('https://github.com/acme/tool', { quarantine: q, run, resolveHost: stubResolve }),
|
|
463
|
+
/exceeded the .*cap/
|
|
464
|
+
);
|
|
465
|
+
} finally { q.cleanup(); }
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await test('EVERY abort carries a .finding — a dead scan must be recordable, not silent', async () => {
|
|
469
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
470
|
+
try {
|
|
471
|
+
const cases = [
|
|
472
|
+
['clone_timeout', async () => ({ status: null, stdout: '', stderr: '', timedOut: true, error: 'ETIMEDOUT' })],
|
|
473
|
+
['clone_failed', async () => ({ status: 128, stdout: '', stderr: 'repository not found', timedOut: false, error: null })],
|
|
474
|
+
];
|
|
475
|
+
for (const [code, run] of cases) {
|
|
476
|
+
await assert.rejects(
|
|
477
|
+
() => fetchArtifact('https://github.com/acme/tool', { quarantine: q, run, resolveHost: stubResolve }),
|
|
478
|
+
(err) => err.code === code && err.finding?.code === code && err.finding.severity === 'high'
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
} finally { q.cleanup(); }
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
await test("git stderr stays out of the thrown message and only a capped excerpt survives", async () => {
|
|
485
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
486
|
+
try {
|
|
487
|
+
const secret = 'internal-host-42.corp.local';
|
|
488
|
+
const run = async () => ({ status: 128, stdout: '', stderr: `fatal: could not read from ${secret}`, timedOut: false, error: null });
|
|
489
|
+
await assert.rejects(
|
|
490
|
+
() => fetchArtifact('https://github.com/acme/tool', { quarantine: q, run, resolveHost: stubResolve }),
|
|
491
|
+
(err) => {
|
|
492
|
+
assert.ok(!err.message.includes(secret), 'stderr must not land in a message a caller might echo');
|
|
493
|
+
assert.ok(err.finding.detail.stderr_excerpt.includes(secret), 'the excerpt is still available for the record');
|
|
494
|
+
assert.ok(err.finding.detail.stderr_excerpt.length <= 300);
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
} finally { q.cleanup(); }
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await test('chunked hashing matches a whole-file hash exactly', async () => {
|
|
502
|
+
const root = mkTmp();
|
|
503
|
+
// Comfortably larger than one 1 MiB hash chunk, so the loop really iterates.
|
|
504
|
+
const big = Buffer.alloc(3 * 1024 * 1024 + 12345, 0x61);
|
|
505
|
+
fs.writeFileSync(path.join(root, 'big.bin'), big);
|
|
506
|
+
const m = await buildManifest(root, { maxFiles: 10, maxBytes: 10 * 1024 * 1024 });
|
|
507
|
+
const expected = crypto.createHash('sha256').update(big).digest('hex');
|
|
508
|
+
assert.equal(m.entries[0].sha256, expected);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await test('the shared hash buffer does not bleed between files', async () => {
|
|
512
|
+
const root = mkTmp();
|
|
513
|
+
const big = Buffer.alloc(2 * 1024 * 1024, 0x62);
|
|
514
|
+
const small = Buffer.from('tiny\n');
|
|
515
|
+
fs.writeFileSync(path.join(root, 'a-big.bin'), big);
|
|
516
|
+
fs.writeFileSync(path.join(root, 'b-small.txt'), small);
|
|
517
|
+
const m = await buildManifest(root, { maxFiles: 10, maxBytes: 10 * 1024 * 1024 });
|
|
518
|
+
const byPath = Object.fromEntries(m.entries.map((e) => [e.path, e.sha256]));
|
|
519
|
+
assert.equal(byPath['a-big.bin'], crypto.createHash('sha256').update(big).digest('hex'));
|
|
520
|
+
assert.equal(byPath['b-small.txt'], crypto.createHash('sha256').update(small).digest('hex'));
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await test('concurrent manifests do not corrupt each other (per-call hash buffer)', async () => {
|
|
524
|
+
// Async made concurrency possible, so a module-level shared buffer would let
|
|
525
|
+
// two interleaved walks overwrite each other's chunks mid-digest.
|
|
526
|
+
const mk = (fill, size) => {
|
|
527
|
+
const root = mkTmp();
|
|
528
|
+
const body = Buffer.alloc(size, fill);
|
|
529
|
+
fs.writeFileSync(path.join(root, 'blob.bin'), body);
|
|
530
|
+
return { root, body };
|
|
531
|
+
};
|
|
532
|
+
const a = mk(0x61, 2 * 1024 * 1024 + 7);
|
|
533
|
+
const b = mk(0x62, 3 * 1024 * 1024 + 11);
|
|
534
|
+
const opts = { maxFiles: 10, maxBytes: 16 * 1024 * 1024 };
|
|
535
|
+
const [ma, mb] = await Promise.all([buildManifest(a.root, opts), buildManifest(b.root, opts)]);
|
|
536
|
+
assert.equal(ma.entries[0].sha256, crypto.createHash('sha256').update(a.body).digest('hex'));
|
|
537
|
+
assert.equal(mb.entries[0].sha256, crypto.createHash('sha256').update(b.body).digest('hex'));
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await test('symlinks count against maxFiles too — a cap on regular files only is bypassable', async () => {
|
|
541
|
+
const root = mkTmp();
|
|
542
|
+
fs.writeFileSync(path.join(root, 'a.txt'), 'a');
|
|
543
|
+
try {
|
|
544
|
+
fs.symlinkSync(path.join(root, 'a.txt'), path.join(root, 'l1'));
|
|
545
|
+
fs.symlinkSync(path.join(root, 'a.txt'), path.join(root, 'l2'));
|
|
546
|
+
} catch {
|
|
547
|
+
console.log(' (skipped: this platform/account cannot create symlinks)');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
await assert.rejects(() => buildManifest(root, { maxFiles: 2, maxBytes: 1e6 }), /entry cap/);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await test('a cap trip surfaces as a fail-closed abort carrying its finding', async () => {
|
|
554
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
555
|
+
try {
|
|
556
|
+
const stub = stubRun(q);
|
|
557
|
+
await assert.rejects(
|
|
558
|
+
() => fetchArtifact('https://github.com/acme/tool', { quarantine: q, run: stub.run, resolveHost: stubResolve, maxFiles: 0 }),
|
|
559
|
+
(err) => /cannot produce an honest verdict/.test(err.message) && err.finding.code === 'cap_max_files'
|
|
560
|
+
);
|
|
561
|
+
} finally { q.cleanup(); }
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
await test('fetchArtifact refuses a non-https target before doing anything', async () => {
|
|
565
|
+
const q = createQuarantine({ tmpRoot: mkTmp(), forbiddenRoots: [] });
|
|
566
|
+
try {
|
|
567
|
+
let ran = false;
|
|
568
|
+
let resolved = false;
|
|
569
|
+
await assert.rejects(
|
|
570
|
+
() => fetchArtifact('file:///etc/passwd', {
|
|
571
|
+
quarantine: q,
|
|
572
|
+
run: async () => { ran = true; return { status: 0 }; },
|
|
573
|
+
resolveHost: async () => { resolved = true; return PUBLIC_IP; },
|
|
574
|
+
}),
|
|
575
|
+
/Only https/
|
|
576
|
+
);
|
|
577
|
+
assert.equal(ran, false, 'git must never be invoked for a refused URL');
|
|
578
|
+
assert.equal(resolved, false, 'a refused URL must not even be resolved');
|
|
579
|
+
} finally { q.cleanup(); }
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await test('CAPS are finite — no accidental unlimited default', async () => {
|
|
583
|
+
assert.ok(Number.isFinite(CAPS.maxFiles) && CAPS.maxFiles > 0);
|
|
584
|
+
assert.ok(Number.isFinite(CAPS.maxBytes) && CAPS.maxBytes > 0);
|
|
585
|
+
assert.ok(Number.isFinite(CAPS.cloneTimeoutMs) && CAPS.cloneTimeoutMs > 0);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
for (const d of tmpDirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
589
|
+
|
|
590
|
+
console.log(`\nartifact_scan: ${passed} passed, ${failed} failed`);
|
|
591
|
+
if (failed > 0) process.exit(1);
|