@bongos/core 1.18.19 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +514 -194
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +5 -5
- package/.claude/skills/scan-before-install/SKILL.md +165 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +63 -41
- package/clients/bongos-client/index.cjs +63 -41
- package/clients/bongos-client/index.d.ts +78 -44
- package/clients/bongos-client/index.mjs +63 -41
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
- package/docs/adr/0156-user-set-display-name-wins.md +62 -0
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
- package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
- package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
- package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
- package/docs/adr/README.md +8 -0
- package/docs/api/openapi.json +1209 -233
- package/docs/api-reference.md +61 -46
- package/docs/architecture.md +1 -1
- package/docs/canonical-permissions.md +61 -16
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +4 -1
- package/docs/onboarding/diagrams/assertions.json +3 -3
- package/docs/recipes/core-release-pipeline.md +179 -0
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +1 -3
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/migrations/core_197_display_name_source.sql +56 -0
- package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
- package/modules/autonomy/routes/autonomy.js +1 -1
- package/modules/builder-settings/db.js +7 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
- package/modules/dev-box/routes/box.js +11 -9
- package/modules/discord/auth.js +3 -1
- package/modules/discord/routes/discord.js +8 -2
- package/modules/discord/ship-broadcast.js +48 -1
- package/modules/governance/catalog.js +70 -20
- package/modules/governance/db.js +102 -0
- package/modules/governance/migrations/governance_006_version_create.sql +38 -0
- package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
- package/modules/governance/routes/governance.js +235 -6
- package/modules/grading/grader-workers/index.js +18 -9
- package/modules/hall-ui/public/gate.js +6 -2
- package/modules/hall-ui/public/goals-page.js +6 -2
- package/modules/hall-ui/public/governance.css +100 -0
- package/modules/hall-ui/public/governance.html +77 -0
- package/modules/hall-ui/public/governance.js +353 -0
- package/modules/hall-ui/public/harbor.js +14 -8
- package/modules/hall-ui/public/instances-panel.js +3 -1
- package/modules/hall-ui/public/new-project-wizard.js +6 -0
- package/modules/hall-ui/public/people.html +51 -0
- package/modules/hall-ui/public/people.js +100 -0
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +9 -3
- package/modules/hall-ui/public/sessions.js +4 -2
- package/modules/hall-ui/public/shell.js +28 -7
- package/modules/hall-ui/public/task-detail.js +6 -3
- package/modules/hall-ui/public/watch.js +17 -9
- package/modules/ideas/blockers.js +44 -7
- package/modules/ideas/routes/blockers.js +12 -7
- package/modules/ideas/routes/inbox.js +2 -2
- package/modules/lifecycle/db.js +84 -17
- package/modules/lifecycle/github-push.js +37 -10
- package/modules/lifecycle/publish-reconciler.js +128 -31
- package/modules/lifecycle/routes/claims.js +10 -4
- package/modules/lifecycle/routes/dependencies.js +2 -2
- package/modules/lifecycle/routes/done-when.js +4 -4
- package/modules/lifecycle/routes/gate-approvals.js +9 -3
- package/modules/lifecycle/routes/goals.js +5 -5
- package/modules/lifecycle/routes/tasks.js +16 -16
- package/modules/lifecycle/routes/versions.js +13 -2
- package/modules/lifecycle/ship-card.js +38 -7
- package/modules/memory/routes/memory.js +2 -2
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/routes/projects.js +10 -6
- package/modules/platform-identity/routes/scouting.js +2 -2
- package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
- package/modules/provisioning/provisioning.js +24 -6
- package/modules/provisioning/routes/provisioning.js +24 -6
- package/modules/security/artifact-scan/aggregate.js +133 -0
- package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
- package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
- package/modules/security/artifact-scan/detectors/credentials.js +112 -0
- package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
- package/modules/security/artifact-scan/detectors/hooks.js +116 -0
- package/modules/security/artifact-scan/detectors/injection.js +118 -0
- package/modules/security/artifact-scan/detectors/mcp.js +102 -0
- package/modules/security/artifact-scan/detectors/network.js +67 -0
- package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
- package/modules/security/artifact-scan/detectors/permissions.js +91 -0
- package/modules/security/artifact-scan/detectors/shared.js +219 -0
- package/modules/security/artifact-scan/fence-local.js +99 -0
- package/modules/security/artifact-scan/fetch.js +330 -0
- package/modules/security/artifact-scan/floor.js +319 -0
- package/modules/security/artifact-scan/limits.js +66 -0
- package/modules/security/artifact-scan/manifest.js +203 -0
- package/modules/security/artifact-scan/mirror.js +158 -0
- package/modules/security/artifact-scan/quarantine.js +149 -0
- package/modules/security/artifact-scan/rebuild-spec.js +120 -0
- package/modules/security/artifact-scan/report.js +155 -0
- package/modules/security/artifact-scan/sanitize.js +317 -0
- package/modules/security/migrations/security_001_tool_scans.sql +145 -0
- package/modules/security/module.json +2 -2
- package/modules/security/routes/reports.js +11 -7
- package/modules/security/routes/scans.js +115 -0
- package/modules/security/scan-store.js +214 -0
- package/modules/security/tests/aggregate.mjs +283 -0
- package/modules/security/tests/artifact_scan.mjs +591 -0
- package/modules/security/tests/floor.mjs +549 -0
- package/modules/security/tests/mirror.mjs +350 -0
- package/modules/security/tests/sanitize_rebuild.mjs +499 -0
- package/modules/security/tests/scan_store.mjs +250 -0
- package/modules/security/tests/tool_scans_migration.mjs +116 -0
- package/modules/sessions/routes/sessions.js +6 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +13 -3
- package/scripts/gds/abandon.js +1 -1
- package/scripts/gds/audit-authorship.sh +6 -0
- package/scripts/gds/box.js +1 -1
- package/scripts/gds/claim.js +15 -0
- package/scripts/gds/cli-lib.js +16 -4
- package/scripts/gds/gating.js +28 -13
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/goal.js +234 -0
- package/scripts/gds/init.js +70 -15
- package/scripts/gds/main-audit.js +80 -11
- package/scripts/gds/mirror-redact.js +6 -0
- package/scripts/gds/newcomer-floor.js +2 -2
- package/scripts/gds/provision.js +230 -94
- package/scripts/gds/run-unit-tests.js +9 -1
- package/scripts/gds/sandbox-stage.js +66 -9
- package/scripts/gds/search-ingest-md.js +1 -0
- package/scripts/gds/sequence.js +67 -8
- package/scripts/gds/ship.js +183 -50
- package/scripts/hall-preview/server.js +1 -1
- package/scripts/migrate.sh +5 -5
- package/src/bongos/auth.js +129 -27
- package/src/bongos/db-kernel.js +66 -5
- package/src/bongos/db.js +6 -7
- package/src/bongos/github-repos.js +19 -7
- package/src/bongos/goal-scope-check.js +1 -1
- package/src/bongos/module-scope-map.js +3 -1
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +73 -30
- package/src/bongos/routes/audit-log.js +4 -2
- package/src/bongos/routes/auth.js +8 -4
- package/src/bongos/routes/backup.js +7 -4
- package/src/bongos/routes/builders.js +23 -11
- package/src/bongos/routes/github.js +15 -4
- package/src/bongos/routes/llm-cache.js +5 -2
- package/src/bongos/routes/modules.js +9 -5
- package/src/bongos/routes/security.js +21 -12
- package/src/bongos/serve-internal.js +22 -7
- package/src/module-api.js +86 -13
- package/src/platform-server.js +14 -0
- package/tests/api_client.mjs +18 -2
- package/tests/api_path_404.mjs +3 -0
- package/tests/art_stage.mjs +2 -1
- package/tests/atlas.mjs +32 -4
- package/tests/auth_admission_gate.mjs +17 -5
- package/tests/auth_error_brand.mjs +41 -21
- package/tests/auth_resolve_failure.mjs +155 -0
- package/tests/blocker_link.mjs +88 -0
- package/tests/bongos_downloads.mjs +2 -1
- package/tests/box_dns_repoint.mjs +2 -0
- package/tests/box_ship_permissions.mjs +2 -0
- package/tests/box_terminal_ssrf.mjs +23 -17
- package/tests/claim_error_surface.mjs +23 -0
- package/tests/claude_materialize.mjs +4 -0
- package/tests/cli_box_scope.mjs +40 -0
- package/tests/cloud_bongos_pack.mjs +34 -4
- package/tests/conductor.mjs +2 -1
- package/tests/criterion_authoring_gate.mjs +28 -5
- package/tests/currency_label.mjs +10 -1
- package/tests/deploy_instance.mjs +2 -0
- package/tests/devbox_downloads.mjs +2 -1
- package/tests/display_name.mjs +95 -6
- package/tests/eviction_policy.mjs +2 -1
- package/tests/file_map.mjs +10 -0
- package/tests/first_admin_bootstrap.mjs +10 -2
- package/tests/first_run_engine_check.mjs +2 -1
- package/tests/fitness.mjs +26 -6
- package/tests/gate_approvals.mjs +15 -2
- package/tests/github_push_land_proof.mjs +50 -4
- package/tests/github_repos_helpers.mjs +30 -5
- package/tests/github_token_store.mjs +23 -0
- package/tests/goal_abuse_matrix.mjs +13 -3
- package/tests/goal_cli.mjs +89 -0
- package/tests/governance_abuse_matrix.mjs +386 -0
- package/tests/governance_fold.mjs +156 -0
- package/tests/governance_fold_bench.mjs +50 -0
- package/tests/governance_live_proof.mjs +201 -0
- package/tests/governance_page_gates.mjs +21 -9
- package/tests/governance_parity.mjs +11 -2
- package/tests/governance_require_permission.mjs +125 -1
- package/tests/governance_routes.mjs +334 -0
- package/tests/grader.mjs +7 -3
- package/tests/hall_nav.mjs +14 -4
- package/tests/helpers.mjs +73 -0
- package/tests/hook_core_resolve.mjs +2 -1
- package/tests/host_topology_skips.mjs +97 -0
- package/tests/infra_exec_paths.mjs +14 -4
- package/tests/init.mjs +128 -0
- package/tests/known_failing_ratchet.mjs +89 -0
- package/tests/lifecycle-card.mjs +2 -1
- package/tests/main_audit.mjs +72 -1
- package/tests/memory_isolation.mjs +56 -9
- package/tests/migrate_root_resolution.mjs +1 -1
- package/tests/mirror_redact.mjs +19 -0
- package/tests/module_api.mjs +3 -1
- package/tests/module_loader.mjs +4 -0
- package/tests/modules.mjs +35 -7
- package/tests/modules_route.mjs +29 -5
- package/tests/onboarding_observability.mjs +49 -11
- package/tests/onboarding_route_signals.mjs +1 -0
- package/tests/one_active_claim_per_session.mjs +80 -0
- package/tests/pre_compact.mjs +2 -1
- package/tests/preview_server.mjs +2 -1
- package/tests/profile_route.mjs +1 -0
- package/tests/provision.mjs +170 -0
- package/tests/provisioning_onboard_mode.mjs +180 -0
- package/tests/publish_manifest.mjs +16 -3
- package/tests/publish_reconciler.mjs +181 -10
- package/tests/rank_tier_single_source.mjs +205 -0
- package/tests/recall_hints.mjs +2 -1
- package/tests/repo_map.mjs +4 -0
- package/tests/route_permission_check.mjs +14 -5
- package/tests/rubric_fixtures.mjs +3 -0
- package/tests/run_routine_two_root.mjs +2 -0
- package/tests/sandbox_stage.mjs +69 -7
- package/tests/search_ingest.mjs +7 -0
- package/tests/security_docs.mjs +28 -6
- package/tests/sequence.mjs +64 -0
- package/tests/session-cards.mjs +2 -1
- package/tests/session_start_freshness.mjs +2 -1
- package/tests/ship_broadcast_server.mjs +63 -1
- package/tests/ship_card.mjs +66 -0
- package/tests/ship_ci_deploy.mjs +66 -1
- package/tests/ship_smoke_gate.mjs +76 -0
- package/tests/versions_route.mjs +15 -0
- package/tests/workflow_exec_paths.mjs +16 -4
- package/tests/worktree_guard.mjs +2 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// modules/security/artifact-scan/detectors/credentials.js — references to
|
|
2
|
+
// credential stores (task 1002544).
|
|
3
|
+
//
|
|
4
|
+
// The pattern list is CURATED AND NAMED, not clever: each entry is a specific
|
|
5
|
+
// place secrets live — including our own ~/.config/otb/gds-session.json and
|
|
6
|
+
// the Windows stores — spelled the way code spells it. A hit means "this
|
|
7
|
+
// artifact's text names a credential store", nothing more; whether it READS it
|
|
8
|
+
// is runtime behavior a static floor cannot see, and the finding says so.
|
|
9
|
+
//
|
|
10
|
+
// Patterns are tiered by SIGNAL, because the co-occurrence critical (floor.js)
|
|
11
|
+
// must not cry wolf:
|
|
12
|
+
// FILE — a concrete on-disk secret store path (.aws/credentials, id_rsa,
|
|
13
|
+
// gds-session.json). Co-located with a network sink this is the
|
|
14
|
+
// strong read-then-send shape, so ONLY file-class hits arm the
|
|
15
|
+
// co-occurrence critical.
|
|
16
|
+
// ENV — an environment-variable NAME (GITHUB_TOKEN, NPM_TOKEN). Ordinary
|
|
17
|
+
// tooling reads these next to a fetch() constantly, so an env hit is
|
|
18
|
+
// a medium on its own and never on its own arms the critical.
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { finding, lineAt } = require('./shared');
|
|
23
|
+
|
|
24
|
+
const SEP = String.raw`[\\/]+`;
|
|
25
|
+
|
|
26
|
+
// [label, regex, class]. Regexes are case-insensitive and separator-agnostic.
|
|
27
|
+
// The SSH pattern is written WITHOUT `.*` before an alternation — a greedy
|
|
28
|
+
// `.*(?:_rsa|_ed25519)` backtracks catastrophically on a long non-matching
|
|
29
|
+
// line (ReDoS) and would hang the floor before any report is persisted; a
|
|
30
|
+
// bounded `[^\\/\n]*` cannot.
|
|
31
|
+
const CREDENTIAL_PATTERNS = Object.freeze([
|
|
32
|
+
['our own GDS CLI session token', new RegExp(String.raw`gds-session\.json|\.config${SEP}otb`, 'i'), 'file'],
|
|
33
|
+
['SSH private key', new RegExp(String.raw`\.ssh${SEP}[^\\/\n]*(?:_rsa|_ed25519|_ecdsa)|\bid_rsa\b|\bid_ed25519\b|\bid_ecdsa\b`, 'i'), 'file'],
|
|
34
|
+
['AWS credentials file', new RegExp(String.raw`\.aws${SEP}credentials`, 'i'), 'file'],
|
|
35
|
+
['netrc', new RegExp(String.raw`(?:^|[^\w.])\.?_?netrc\b`, 'i'), 'file'],
|
|
36
|
+
['git stored credentials', new RegExp(String.raw`\.git-credentials`, 'i'), 'file'],
|
|
37
|
+
['GitHub CLI hosts file', new RegExp(String.raw`\.config${SEP}gh${SEP}hosts\.yml`, 'i'), 'file'],
|
|
38
|
+
['kubeconfig file', new RegExp(String.raw`\.kube${SEP}config`, 'i'), 'file'],
|
|
39
|
+
['Docker credential store', new RegExp(String.raw`\.docker${SEP}config\.json`, 'i'), 'file'],
|
|
40
|
+
['browser credential/cookie store', new RegExp(String.raw`Login Data|moz_logins|\blogins\.json\b|User Data${SEP}Default`, 'i'), 'file'],
|
|
41
|
+
['cloud metadata endpoint', metadataEndpointRegex(), 'file'],
|
|
42
|
+
// The negative lookbehind is load-bearing: without it, `.env` matches inside
|
|
43
|
+
// `process.env`, misclassifying every env-var access as a credential FILE
|
|
44
|
+
// read and arming the co-occurrence critical on ordinary Node code.
|
|
45
|
+
['generic .env secret file', new RegExp(String.raw`(?<![\w.])\.env(?:\.local|\.production|\.development)?\b`, 'i'), 'file'],
|
|
46
|
+
// ENV class — variable NAMES, not files. Medium, never arms co-occurrence.
|
|
47
|
+
['AWS secret env var', /AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN/, 'env'],
|
|
48
|
+
['npm auth env var', /\b_authToken\b|\bNPM_TOKEN\b/, 'env'],
|
|
49
|
+
['GitHub token env var', /\bGH_TOKEN\b|\bGITHUB_TOKEN\b/, 'env'],
|
|
50
|
+
['kubeconfig env var', /\bKUBECONFIG\b/, 'env'],
|
|
51
|
+
// Windows / macOS secret-store API calls — execution surface, treated file-class.
|
|
52
|
+
['Windows Credential Manager', /\bcmdkey\b|\bvaultcmd\b|\bCredRead\b|\bCredEnumerate\b|Windows\.Security\.Credentials|PasswordVault/i, 'file'],
|
|
53
|
+
['Windows DPAPI', /CryptUnprotectData|ProtectedData\.Unprotect/i, 'file'],
|
|
54
|
+
['macOS Keychain', /security\s+find-(?:generic|internet)-password|\bKeychain\b/, 'file'],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// The cloud-metadata endpoint in every form git/Node can reach it: dotted,
|
|
58
|
+
// decimal, hex, octal, IPv6, and the AWS/GCP hostnames. A literal-string match
|
|
59
|
+
// (169.254.169.254 only) is defeated by writing the same address as the integer
|
|
60
|
+
// 2852039166 or 0xa9fea9fe — both of which resolve to the same host.
|
|
61
|
+
function metadataEndpointRegex() {
|
|
62
|
+
return new RegExp([
|
|
63
|
+
'169\\.254\\.169\\.254', // dotted
|
|
64
|
+
'\\b2852039166\\b', // decimal
|
|
65
|
+
'0xa9fea9fe', // hex
|
|
66
|
+
'0251\\.0376\\.0251\\.0376', // dotted octal
|
|
67
|
+
'\\[?fd00:ec2::254\\]?', // AWS IPv6
|
|
68
|
+
'metadata\\.google\\.internal',
|
|
69
|
+
'\\binstance-data\\b',
|
|
70
|
+
'metadata\\.azure\\.com',
|
|
71
|
+
].join('|'), 'i');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Structured per-file hits for the floor's co-occurrence join. Each hit carries
|
|
75
|
+
// its class so floor.js can arm the critical on file-class hits only.
|
|
76
|
+
//
|
|
77
|
+
// Memoized per corpus object: detect() and floor.coOccurrence() both need this,
|
|
78
|
+
// and re-running every regex over the whole tree twice per scan is pure waste.
|
|
79
|
+
// The corpus is immutable for the life of a scan, so a WeakMap keyed on it is a
|
|
80
|
+
// safe cache that also lets the object be GC'd when the scan ends.
|
|
81
|
+
const _hitsCache = new WeakMap();
|
|
82
|
+
function perFileHits(corpus) {
|
|
83
|
+
const cached = _hitsCache.get(corpus);
|
|
84
|
+
if (cached) return cached;
|
|
85
|
+
const hits = new Map(); // path -> [{ label, line, klass }]
|
|
86
|
+
for (const file of corpus.files) {
|
|
87
|
+
if (file.text === null) continue;
|
|
88
|
+
for (const [label, re, klass] of CREDENTIAL_PATTERNS) {
|
|
89
|
+
const m = re.exec(file.text);
|
|
90
|
+
if (m) {
|
|
91
|
+
if (!hits.has(file.path)) hits.set(file.path, []);
|
|
92
|
+
hits.get(file.path).push({ label, line: lineAt(file.text, m.index), klass });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
_hitsCache.set(corpus, hits);
|
|
97
|
+
return hits;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function detect(corpus) {
|
|
101
|
+
const findings = [];
|
|
102
|
+
for (const [path, fileHits] of perFileHits(corpus)) {
|
|
103
|
+
for (const h of fileHits) {
|
|
104
|
+
findings.push(finding('credential_store_reference', h.klass === 'file' ? 'high' : 'medium', path,
|
|
105
|
+
`Names a credential store (${h.label}) at line ${h.line} — whether it is read is runtime behavior this static pass cannot see.`,
|
|
106
|
+
{ store: h.label, line: h.line, klass: h.klass }));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return findings;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { detect, perFileHits, CREDENTIAL_PATTERNS };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// modules/security/artifact-scan/detectors/dependencies.js — the declared
|
|
2
|
+
// dependency list and registry configuration (task 1002544).
|
|
3
|
+
//
|
|
4
|
+
// The scan never fetches dependencies, so every declared dependency is code
|
|
5
|
+
// the install would pull that NOBODY here read — that alone caps coverage.
|
|
6
|
+
// Within the list, specs are graded by how much the install outcome can drift
|
|
7
|
+
// from what a reviewer might have vetted: a git/http/file spec fetches from an
|
|
8
|
+
// arbitrary place (high), a floating spec ('*', 'latest', ranges) means the
|
|
9
|
+
// registry decides the bytes at install time (wildcards medium, ranges low —
|
|
10
|
+
// ranges are ecosystem-normal). A .npmrc registry override redirects EVERY
|
|
11
|
+
// package to a server the artifact's author chose — the classic dependency-
|
|
12
|
+
// confusion delivery vehicle (high).
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { finding, excerpt, basename, parseJsonStrict } = require('./shared');
|
|
17
|
+
|
|
18
|
+
const DEP_SECTIONS = Object.freeze([
|
|
19
|
+
'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function specSeverity(spec) {
|
|
23
|
+
const s = String(spec).trim();
|
|
24
|
+
// Protocol / shorthand sources that fetch from an arbitrary place, not the
|
|
25
|
+
// registry version the reviewer might have vetted.
|
|
26
|
+
if (/^(?:git\+|git:|github:|gitlab:|bitbucket:|https?:|file:|link:|portal:|workspace:|patch:)/i.test(s)) {
|
|
27
|
+
return { severity: 'high', kind: 'non-registry source' };
|
|
28
|
+
}
|
|
29
|
+
// `npm:pkg@range` alias — the installed package is NOT the one this key names.
|
|
30
|
+
if (/^npm:/i.test(s)) return { severity: 'high', kind: 'aliased package' };
|
|
31
|
+
// Bare `user/repo` (optionally `#ref`) with no protocol is GitHub shorthand.
|
|
32
|
+
if (/^[\w.-]+\/[\w.-]+(?:#.+)?$/.test(s) && !/^[\d]/.test(s)) {
|
|
33
|
+
return { severity: 'high', kind: 'github shorthand' };
|
|
34
|
+
}
|
|
35
|
+
if (s === '' || s === '*' || s === 'latest' || s === 'next' || /^[x*]$/i.test(s)) {
|
|
36
|
+
return { severity: 'medium', kind: 'wildcard' };
|
|
37
|
+
}
|
|
38
|
+
if (/^(?:\^|~|>=?|<=?)/.test(s) || /\.(x|\*)/i.test(s) || /\s*\|\|\s*/.test(s) || /\s-\s/.test(s)) {
|
|
39
|
+
return { severity: 'low', kind: 'floating range' };
|
|
40
|
+
}
|
|
41
|
+
return null; // pinned exact version — inventoried via the declared count, not a finding
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function detect(corpus) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
for (const file of corpus.files) {
|
|
47
|
+
if (file.text === null) continue;
|
|
48
|
+
const name = basename(file.path);
|
|
49
|
+
|
|
50
|
+
if (name === 'package.json') {
|
|
51
|
+
const parsed = parseJsonStrict(file.text);
|
|
52
|
+
// No config_unparseable here (asymmetric with hooks/mcp on purpose):
|
|
53
|
+
// npm-lifecycle.js already emits it for every unparseable package.json,
|
|
54
|
+
// and this detector fires on the SAME files — a second copy would only
|
|
55
|
+
// inflate the count.
|
|
56
|
+
if (!parsed.ok || !parsed.value || typeof parsed.value !== 'object') continue;
|
|
57
|
+
let declared = 0;
|
|
58
|
+
for (const section of DEP_SECTIONS) {
|
|
59
|
+
const deps = parsed.value[section];
|
|
60
|
+
if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue;
|
|
61
|
+
for (const [dep, spec] of Object.entries(deps)) {
|
|
62
|
+
declared++;
|
|
63
|
+
const graded = specSeverity(spec);
|
|
64
|
+
if (graded) {
|
|
65
|
+
findings.push(finding('dependency_floating_spec', graded.severity, file.path,
|
|
66
|
+
`${section}.${excerpt(dep, 80)} = "${excerpt(String(spec), 80)}" (${graded.kind}) — the installed bytes are decided at install time, not here.`,
|
|
67
|
+
{ section, dep: excerpt(dep, 80), spec: excerpt(String(spec), 80), kind: graded.kind }));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (declared > 0) {
|
|
72
|
+
findings.push(finding('dependencies_unfetched', 'medium', file.path,
|
|
73
|
+
`${declared} declared dependencies were never fetched or read — the install would pull code nobody here reviewed.`,
|
|
74
|
+
{ declared, unscannable: true }));
|
|
75
|
+
}
|
|
76
|
+
const overrides = parsed.value.overrides || parsed.value.resolutions;
|
|
77
|
+
if (overrides && typeof overrides === 'object') {
|
|
78
|
+
findings.push(finding('dependency_overrides', 'medium', file.path,
|
|
79
|
+
`package.json pins transitive dependencies via overrides/resolutions — the tree the lockfile implies is not the tree npm resolves.`,
|
|
80
|
+
null));
|
|
81
|
+
}
|
|
82
|
+
const pubReg = parsed.value.publishConfig && parsed.value.publishConfig.registry;
|
|
83
|
+
if (typeof pubReg === 'string') {
|
|
84
|
+
findings.push(finding('registry_override', 'high', file.path,
|
|
85
|
+
`publishConfig.registry points at ${excerpt(pubReg)}.`, { registry: excerpt(pubReg) }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (name === '.npmrc' || name === 'npmrc') {
|
|
90
|
+
// .npmrc is INI-ish; the floor greps line-wise rather than trusting any
|
|
91
|
+
// parser — every line is checked raw, so there is no parse/grep gap.
|
|
92
|
+
const lines = file.text.split(/\r?\n/);
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i].trim();
|
|
95
|
+
if (/^(?:@[^:]+:)?registry\s*=/.test(line)) {
|
|
96
|
+
findings.push(finding('registry_override', 'high', file.path,
|
|
97
|
+
`.npmrc redirects package resolution (line ${i + 1}): ${excerpt(line)} — the dependency-confusion delivery vehicle.`,
|
|
98
|
+
{ line: i + 1, text: excerpt(line) }));
|
|
99
|
+
}
|
|
100
|
+
if (/_authToken|_auth\s*=|always-auth/.test(line)) {
|
|
101
|
+
findings.push(finding('npmrc_auth_config', 'medium', file.path,
|
|
102
|
+
`.npmrc carries auth configuration (line ${i + 1}): ${excerpt(line)}.`,
|
|
103
|
+
{ line: i + 1 }));
|
|
104
|
+
}
|
|
105
|
+
if (/^ignore-scripts\s*=\s*false/.test(line)) {
|
|
106
|
+
findings.push(finding('npmrc_scripts_enabled', 'high', file.path,
|
|
107
|
+
`.npmrc explicitly re-enables install scripts (line ${i + 1}).`, { line: i + 1 }));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { detect, specSeverity, DEP_SECTIONS };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// modules/security/artifact-scan/detectors/hooks.js — Claude Code hook
|
|
2
|
+
// definitions bundled inside an artifact (task 1002544).
|
|
3
|
+
//
|
|
4
|
+
// A hook in .claude/settings.json runs a SHELL COMMAND every time the harness
|
|
5
|
+
// fires the matching event — installing an artifact that carries one is
|
|
6
|
+
// granting it standing code execution inside every future session. The floor
|
|
7
|
+
// quotes every hook command verbatim (scrubbed + capped) and names the matcher
|
|
8
|
+
// breadth, because "PreToolUse on *" and "PostToolUse on Edit:src/foo.js" are
|
|
9
|
+
// wildly different grants that render identically in a quick skim.
|
|
10
|
+
//
|
|
11
|
+
// The walk is shape-generic (any "hooks" key anywhere in any .claude/ JSON or
|
|
12
|
+
// a root settings.json): the schema has evolved and an artifact aiming at a
|
|
13
|
+
// FUTURE schema still deserves a finding today.
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { finding, excerpt, basename, parseJsonStrict, grepParseDisagreement } = require('./shared');
|
|
18
|
+
|
|
19
|
+
function isClaudeConfig(p) {
|
|
20
|
+
const name = basename(p);
|
|
21
|
+
if (p.includes('.claude/') || p.includes('.claude\\')) return name.endsWith('.json');
|
|
22
|
+
return name === 'settings.json' || name === 'settings.local.json'
|
|
23
|
+
|| name === 'managed-settings.json' || name === '.claude.json';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A matcher that matches everything: '*', empty, or missing.
|
|
27
|
+
function matcherBreadth(matcher) {
|
|
28
|
+
if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') return 'ALL tool calls';
|
|
29
|
+
return `matcher "${excerpt(matcher, 80)}"`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Same cap as shared.countKeysDeep: a legitimate hooks config never nests this
|
|
33
|
+
// deep, and an unbounded walk over hostile (but JSON.parse-valid) nesting is a
|
|
34
|
+
// stack overflow. Depth past the cap is UNREVIEWED, surfaced as a finding.
|
|
35
|
+
const MAX_TREE_DEPTH = 40;
|
|
36
|
+
|
|
37
|
+
// Collect { matcher, command, event } from any hooks-shaped subtree. In the
|
|
38
|
+
// live schema the matcher sits on an ANCESTOR of the command node
|
|
39
|
+
// ([{ matcher, hooks: [{ type, command }] }]), so the nearest enclosing
|
|
40
|
+
// matcher is threaded down to each command it governs. `state` is
|
|
41
|
+
// { hooks: [], truncated: false }; truncated flips when the depth cap stopped
|
|
42
|
+
// the walk, so detect() can report the unreviewed subtree.
|
|
43
|
+
function collectHooks(value, event, matcher, state, depth = 0) {
|
|
44
|
+
if (value === null || typeof value !== 'object') return;
|
|
45
|
+
if (depth > MAX_TREE_DEPTH) { state.truncated = true; return; }
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
for (const item of value) collectHooks(item, event, matcher, state, depth + 1);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const m = typeof value.matcher === 'string' ? value.matcher : matcher;
|
|
51
|
+
// A `command` key that is present but not a string is an unreviewable grant,
|
|
52
|
+
// not a non-event: record it as opaque so a sibling string command can never
|
|
53
|
+
// let it slip out of the report silently.
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(value, 'command')) {
|
|
55
|
+
if (typeof value.command === 'string') state.hooks.push({ event, matcher: m, command: value.command });
|
|
56
|
+
else state.hooks.push({ event, matcher: m, command: null, opaque: true });
|
|
57
|
+
}
|
|
58
|
+
for (const [k, v] of Object.entries(value)) {
|
|
59
|
+
if (k !== 'command') collectHooks(v, event, m, state, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function detect(corpus) {
|
|
64
|
+
const findings = [];
|
|
65
|
+
for (const file of corpus.files) {
|
|
66
|
+
if (file.text === null || !isClaudeConfig(file.path)) continue;
|
|
67
|
+
const parsed = parseJsonStrict(file.text);
|
|
68
|
+
|
|
69
|
+
const hooksNode = parsed.ok && parsed.value && typeof parsed.value === 'object' ? parsed.value.hooks : undefined;
|
|
70
|
+
if (hooksNode && typeof hooksNode === 'object') {
|
|
71
|
+
for (const [event, subtree] of Object.entries(hooksNode)) {
|
|
72
|
+
const state = { hooks: [], truncated: false };
|
|
73
|
+
collectHooks(subtree, event, undefined, state);
|
|
74
|
+
const collected = state.hooks;
|
|
75
|
+
if (state.truncated) {
|
|
76
|
+
findings.push(finding('config_depth_cap', 'high', file.path,
|
|
77
|
+
`"hooks.${event}" nests deeper than the ${MAX_TREE_DEPTH}-level review cap — the deeper subtree is an unreviewable hook grant.`,
|
|
78
|
+
{ event, reason: 'depth_cap' }));
|
|
79
|
+
}
|
|
80
|
+
for (const h of collected) {
|
|
81
|
+
if (h.opaque) {
|
|
82
|
+
findings.push(finding('claude_hook_opaque', 'high', file.path,
|
|
83
|
+
`${h.event} hook on ${matcherBreadth(h.matcher)} has a non-string command — unreviewable hook grant.`,
|
|
84
|
+
{ event: h.event, matcher: h.matcher ?? null }));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
findings.push(finding('claude_hook_command', 'high', file.path,
|
|
88
|
+
`${h.event} hook on ${matcherBreadth(h.matcher)} runs: ${excerpt(h.command)}`,
|
|
89
|
+
{ event: h.event, matcher: h.matcher ?? null, command: excerpt(h.command) }));
|
|
90
|
+
}
|
|
91
|
+
if (collected.length === 0 && !state.truncated) {
|
|
92
|
+
// A hooks event with no extractable command is still a grant we
|
|
93
|
+
// could not render — surface it rather than skipping silently.
|
|
94
|
+
// (The truncated case is already reported as config_depth_cap.)
|
|
95
|
+
findings.push(finding('claude_hook_opaque', 'high', file.path,
|
|
96
|
+
`"hooks.${event}" is present but its command text could not be extracted — unreviewable hook grant.`,
|
|
97
|
+
{ event }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const disagreement = grepParseDisagreement(file.text, 'hooks', parsed.ok ? parsed.value : null);
|
|
103
|
+
if (disagreement) {
|
|
104
|
+
findings.push(finding('parse_grep_disagreement', 'critical', file.path,
|
|
105
|
+
`"hooks" appears ${disagreement.rawCount}x in key position in the raw bytes but the strict parse yielded ${disagreement.parsedCount} — hidden hook config.`,
|
|
106
|
+
{ marker: 'hooks', ...disagreement }));
|
|
107
|
+
}
|
|
108
|
+
if (!parsed.ok) {
|
|
109
|
+
findings.push(finding('config_unparseable', 'medium', file.path,
|
|
110
|
+
'Claude config does not strictly parse as JSON — whatever the harness makes of it was not reviewable here.', null));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { detect, matcherBreadth };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// modules/security/artifact-scan/detectors/injection.js — text aimed at the
|
|
2
|
+
// scanner rather than at a human reader (task 1002544).
|
|
3
|
+
//
|
|
4
|
+
// Three families, all critical, because none has a benign reason to exist in
|
|
5
|
+
// a third-party artifact:
|
|
6
|
+
// 1. instruction-override phrases ("ignore previous instructions");
|
|
7
|
+
// 2. scanner/reviewer-targeting phrases ("report this as clean", "dear
|
|
8
|
+
// reviewer") — this artifact KNOWS it will be scanned and is talking to
|
|
9
|
+
// the process, the strongest malice indicator we have;
|
|
10
|
+
// 3. dangerous codepoints (zero-width, bidi, Unicode Tags) — text engineered
|
|
11
|
+
// to read differently to a human than to a parser. Detected on the corpus
|
|
12
|
+
// text, which floor.js has already had TextDecoder strip a legitimate
|
|
13
|
+
// leading BOM from — so ANY invisible that remains is anomalous.
|
|
14
|
+
//
|
|
15
|
+
// The phrase lists are deliberately NARROW: a scanner that flags ordinary code
|
|
16
|
+
// like `return 'cleanup'` as an attack is worse than useless, so verdict-
|
|
17
|
+
// steering patterns require explicit scanner-directed framing ("report … as
|
|
18
|
+
// clean"), never a bare `return "clean"`.
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { finding, lineAt } = require('./shared');
|
|
23
|
+
|
|
24
|
+
const OVERRIDE_PHRASES = Object.freeze([
|
|
25
|
+
['instruction override', /ignore\s+(?:all\s+)?(?:previous|prior|above|the\s+above)\s+instructions/i],
|
|
26
|
+
['instruction override', /disregard\s+(?:all\s+)?(?:previous|prior|above|your|the\s+above)\s+instructions/i],
|
|
27
|
+
['role reassignment', /you\s+are\s+no\s+longer\s+(?:a|an|the)\b/i],
|
|
28
|
+
['system-prompt spoof', /<\s*\/?\s*system\s*>|\[\s*system\s*\]|^#{1,3}\s*system\s+prompt/im],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// Scanner-directed framing only. Each requires an imperative addressed at the
|
|
32
|
+
// judgement ("report/mark/classify … as clean", "say this is safe"), with the
|
|
33
|
+
// verdict word \b-anchored so "clean" does not match inside "cleanup".
|
|
34
|
+
const SCANNER_PHRASES = Object.freeze([
|
|
35
|
+
// A REQUIRED object ("report THIS as clean") keeps defensive-code idioms like
|
|
36
|
+
// "treat as clean" / "mark as done" out; "treat/rate/score/label" are dropped
|
|
37
|
+
// as too generic to be scanner-talk on their own.
|
|
38
|
+
['verdict steering', /\b(?:report|mark|classify|flag)\s+(?:this|it|the\s+\w+|everything)\s+as\s+["']?(?:clean|safe|benign|trustworthy|harmless|not\s+malicious)\b/i],
|
|
39
|
+
['verdict steering', /\b(?:say|state|declare|respond|conclude)\s+(?:that\s+)?(?:this|it)\s+is\s+["']?(?:clean|safe|benign|harmless|not\s+malicious)\b/i],
|
|
40
|
+
['verdict steering', /\bno\s+(?:findings|issues|threats|vulnerabilities)\s+(?:were\s+)?(?:found|detected|present)\s+here\b/i],
|
|
41
|
+
['reviewer-addressed', /\bdear\s+(?:reviewer|auditor|scanner|ai|assistant|claude|security\s+\w+)\b/i],
|
|
42
|
+
['reviewer-addressed', /\b(?:to|for)\s+the\s+(?:ai|llm|model|scanner|reviewer|auditor)\s+(?:reading|reviewing|scanning|analyz)/i],
|
|
43
|
+
['scanner-aware', /\bif\s+you(?:'re|\s+are)\s+(?:an?\s+)?(?:ai|llm|language\s+model|scanner|security\s+reviewer|assistant)\b/i],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Codepoints that read differently than they parse, tiered by attack class:
|
|
47
|
+
// critical — bidi overrides/isolates + the Unicode Tags block (U+E0000–E007F,
|
|
48
|
+
// invisible characters that can carry a hidden instruction);
|
|
49
|
+
// high — zero-width space / word-joiner / a BOM anywhere (the legitimate
|
|
50
|
+
// leading BOM was already consumed by the decoder);
|
|
51
|
+
// medium — ZWJ/ZWNJ, RLM/LRM, C0/C1 controls, line/paragraph separators —
|
|
52
|
+
// classes with occasional legitimate use that still must not reach
|
|
53
|
+
// the mirror.
|
|
54
|
+
// Counts and CLASSES are reported, never the characters themselves.
|
|
55
|
+
const CODEPOINT_PATTERN =
|
|
56
|
+
'[' +
|
|
57
|
+
'\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f' + // C0/C1 controls
|
|
58
|
+
'\\u200b-\\u200f\\u202a-\\u202e\\u2060\\u2066-\\u2069\\ufeff' + // zw/bidi (fence-local set)
|
|
59
|
+
'\\u2028\\u2029' + // line/paragraph separators
|
|
60
|
+
']' +
|
|
61
|
+
'|[\\u{e0000}-\\u{e007f}]'; // Unicode Tags block
|
|
62
|
+
|
|
63
|
+
function classifyCodepoint(cp) {
|
|
64
|
+
if (cp >= 0xe0000 && cp <= 0xe007f) return { tier: 'critical', cls: 'unicode-tag' };
|
|
65
|
+
if ((cp >= 0x202a && cp <= 0x202e) || (cp >= 0x2066 && cp <= 0x2069)) return { tier: 'critical', cls: 'bidi-override' };
|
|
66
|
+
if (cp === 0x200b || cp === 0x2060 || cp === 0xfeff) return { tier: 'high', cls: 'invisible' };
|
|
67
|
+
if (cp === 0x200c || cp === 0x200d) return { tier: 'medium', cls: 'zero-width-joiner' };
|
|
68
|
+
if (cp === 0x200e || cp === 0x200f) return { tier: 'medium', cls: 'bidi-mark' };
|
|
69
|
+
if (cp === 0x2028 || cp === 0x2029) return { tier: 'medium', cls: 'line-separator' };
|
|
70
|
+
return { tier: 'medium', cls: 'control' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Compiled once; lastIndex is reset at each entry to codepointClasses so the
|
|
74
|
+
// stateful 'g' flag cannot leak scan position between calls.
|
|
75
|
+
const CODEPOINT_SCANNER = new RegExp(CODEPOINT_PATTERN, 'gu');
|
|
76
|
+
|
|
77
|
+
function codepointClasses(text) {
|
|
78
|
+
const tiers = { critical: new Map(), high: new Map(), medium: new Map() };
|
|
79
|
+
const re = CODEPOINT_SCANNER;
|
|
80
|
+
re.lastIndex = 0;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = re.exec(text)) !== null) {
|
|
83
|
+
const { tier, cls } = classifyCodepoint(m[0].codePointAt(0));
|
|
84
|
+
tiers[tier].set(cls, (tiers[tier].get(cls) || 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
return tiers;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ALL_PHRASES = Object.freeze([...OVERRIDE_PHRASES, ...SCANNER_PHRASES]);
|
|
90
|
+
|
|
91
|
+
function detect(corpus) {
|
|
92
|
+
const findings = [];
|
|
93
|
+
for (const file of corpus.files) {
|
|
94
|
+
if (file.text === null) continue;
|
|
95
|
+
|
|
96
|
+
for (const [family, re] of ALL_PHRASES) {
|
|
97
|
+
const m = re.exec(file.text);
|
|
98
|
+
if (m) {
|
|
99
|
+
const line = lineAt(file.text, m.index);
|
|
100
|
+
findings.push(finding('injection_phrase', 'critical', file.path,
|
|
101
|
+
`${family} phrase at line ${line} — text addressed to the scanning process, not a human reader.`,
|
|
102
|
+
{ family, line }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tiers = codepointClasses(file.text);
|
|
107
|
+
for (const [tier, classes] of Object.entries(tiers)) {
|
|
108
|
+
if (classes.size === 0) continue;
|
|
109
|
+
const summary = [...classes.entries()].map(([cls, n]) => `${n} ${cls}`).join(', ');
|
|
110
|
+
findings.push(finding('dangerous_codepoints', tier, file.path,
|
|
111
|
+
`Invisible/reordering codepoints (${summary}) — can read differently to a human than to a parser.`,
|
|
112
|
+
{ classes: Object.fromEntries(classes) }));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return findings;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { detect, OVERRIDE_PHRASES, SCANNER_PHRASES, codepointClasses };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// modules/security/artifact-scan/detectors/mcp.js — MCP server entries bundled
|
|
2
|
+
// in an artifact (task 1002544).
|
|
3
|
+
//
|
|
4
|
+
// An MCP server entry is not config, it is a grant: a `command` is a process
|
|
5
|
+
// the harness will spawn on the builder's machine; a `url` is a remote whose
|
|
6
|
+
// code lives off-box. NEITHER is scannable by a static read of this artifact —
|
|
7
|
+
// the command's binary and the remote's behavior are outside these bytes — so
|
|
8
|
+
// both are flagged unscannable (the reducer caps coverage over them), with the
|
|
9
|
+
// entry quoted verbatim so the reader sees exactly what would be granted.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { finding, excerpt, basename, parseJsonStrict, grepParseDisagreement } = require('./shared');
|
|
14
|
+
|
|
15
|
+
function isMcpConfig(p) {
|
|
16
|
+
const name = basename(p);
|
|
17
|
+
return name === '.mcp.json' || name === 'mcp.json' || name === 'claude_desktop_config.json'
|
|
18
|
+
|| name === '.claude.json'
|
|
19
|
+
|| ((p.includes('.claude/') || p.includes('.claude\\')) && name.endsWith('.json'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Same cap as shared.countKeysDeep: an unbounded walk over hostile (but
|
|
23
|
+
// JSON.parse-valid) nesting is a stack overflow; depth past the cap is
|
|
24
|
+
// UNREVIEWED and must surface as a finding, never a silent stop.
|
|
25
|
+
const MAX_TREE_DEPTH = 40;
|
|
26
|
+
|
|
27
|
+
// Collect MCP-server-shaped entries: any object under an "mcpServers" (or
|
|
28
|
+
// "servers") key whose members carry command/url. Returns { servers,
|
|
29
|
+
// truncated } — truncated flips when the depth cap stopped the walk.
|
|
30
|
+
function collectServers(root) {
|
|
31
|
+
const servers = [];
|
|
32
|
+
let truncated = false;
|
|
33
|
+
const visit = (value, depth) => {
|
|
34
|
+
if (value === null || typeof value !== 'object') return;
|
|
35
|
+
if (depth > MAX_TREE_DEPTH) { truncated = true; return; }
|
|
36
|
+
if (Array.isArray(value)) { for (const item of value) visit(item, depth + 1); return; }
|
|
37
|
+
for (const [k, v] of Object.entries(value)) {
|
|
38
|
+
if ((k === 'mcpServers' || k === 'servers') && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
39
|
+
for (const [name, entry] of Object.entries(v)) {
|
|
40
|
+
if (entry && typeof entry === 'object') servers.push({ name, entry });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
visit(v, depth + 1);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
visit(root, 0);
|
|
47
|
+
return { servers, truncated };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detect(corpus) {
|
|
51
|
+
const findings = [];
|
|
52
|
+
for (const file of corpus.files) {
|
|
53
|
+
if (file.text === null || !isMcpConfig(file.path)) continue;
|
|
54
|
+
const parsed = parseJsonStrict(file.text);
|
|
55
|
+
|
|
56
|
+
if (parsed.ok) {
|
|
57
|
+
const { servers, truncated } = collectServers(parsed.value);
|
|
58
|
+
if (truncated) {
|
|
59
|
+
findings.push(finding('config_depth_cap', 'high', file.path,
|
|
60
|
+
`MCP config nests deeper than the ${MAX_TREE_DEPTH}-level review cap — deeper entries were not reviewable.`,
|
|
61
|
+
{ reason: 'depth_cap', unscannable: true }));
|
|
62
|
+
}
|
|
63
|
+
for (const { name, entry } of servers) {
|
|
64
|
+
if (typeof entry.command === 'string') {
|
|
65
|
+
// Cap the arg COUNT here; finding() caps total length regardless, but
|
|
66
|
+
// an unbounded join would otherwise be truncated mid-token.
|
|
67
|
+
const args = Array.isArray(entry.args) ? entry.args.slice(0, 8) : [];
|
|
68
|
+
const argv = args.map((a) => excerpt(a, 60)).join(' ');
|
|
69
|
+
findings.push(finding('mcp_server_command', 'high', file.path,
|
|
70
|
+
`MCP server "${excerpt(name, 60)}" spawns a process — UNSCANNABLE (its code is not in these bytes): ${excerpt(entry.command)}${argv ? ` ${argv}` : ''}`,
|
|
71
|
+
{ server: excerpt(name, 60), command: excerpt(entry.command), unscannable: true }));
|
|
72
|
+
}
|
|
73
|
+
if (typeof entry.url === 'string') {
|
|
74
|
+
findings.push(finding('mcp_server_url', 'medium', file.path,
|
|
75
|
+
`MCP server "${excerpt(name, 60)}" points at a remote — UNSCANNABLE (its code is off-box): ${excerpt(entry.url)}`,
|
|
76
|
+
{ server: excerpt(name, 60), url: excerpt(entry.url), unscannable: true }));
|
|
77
|
+
}
|
|
78
|
+
if (typeof entry.command !== 'string' && typeof entry.url !== 'string') {
|
|
79
|
+
findings.push(finding('mcp_server_opaque', 'medium', file.path,
|
|
80
|
+
`MCP server "${excerpt(name, 60)}" has neither a readable command nor url — unreviewable entry.`,
|
|
81
|
+
{ server: excerpt(name, 60), unscannable: true }));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const key of ['mcpServers', 'servers']) {
|
|
87
|
+
const disagreement = grepParseDisagreement(file.text, key, parsed.ok ? parsed.value : null);
|
|
88
|
+
if (disagreement) {
|
|
89
|
+
findings.push(finding('parse_grep_disagreement', 'critical', file.path,
|
|
90
|
+
`"${key}" appears ${disagreement.rawCount}x in key position in the raw bytes but the strict parse yielded ${disagreement.parsedCount} — hidden MCP config.`,
|
|
91
|
+
{ marker: key, ...disagreement }));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!parsed.ok) {
|
|
95
|
+
findings.push(finding('config_unparseable', 'medium', file.path,
|
|
96
|
+
'MCP config does not strictly parse as JSON — whatever the harness makes of it was not reviewable here.', null));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { detect };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// modules/security/artifact-scan/detectors/network.js — network egress sinks
|
|
2
|
+
// (task 1002544).
|
|
3
|
+
//
|
|
4
|
+
// A network sink alone is a medium: most tools talk to the network for honest
|
|
5
|
+
// reasons, and the floor's job is inventory, not judgment. The signal that
|
|
6
|
+
// matters is computed by floor.js: a file that BOTH names a credential store
|
|
7
|
+
// AND carries an egress sink is flagged same-file co-occurrence — labeled
|
|
8
|
+
// exactly that, never claimed as proven dataflow, because co-location in a
|
|
9
|
+
// file is evidence of opportunity, not a taint analysis.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { finding, lineAt } = require('./shared');
|
|
14
|
+
|
|
15
|
+
const NETWORK_PATTERNS = Object.freeze([
|
|
16
|
+
['fetch()', /\bfetch\s*\(/],
|
|
17
|
+
['XMLHttpRequest', /XMLHttpRequest/],
|
|
18
|
+
['WebSocket', /\bnew\s+WebSocket\s*\(/],
|
|
19
|
+
['axios', /\baxios\b/],
|
|
20
|
+
['node http/https request', /\bhttps?\.(?:request|get)\s*\(/],
|
|
21
|
+
// Importing http/https at all is an egress capability — catches the
|
|
22
|
+
// destructured/aliased form `const { get } = require('node:https')` that has
|
|
23
|
+
// no literal `https.get(` token for the pattern above to see.
|
|
24
|
+
['node http/https import', /require\s*\(\s*['"](?:node:)?https?['"]\s*\)|from\s+['"](?:node:)?https?['"]/],
|
|
25
|
+
['raw socket', /\bnet\.(?:connect|createConnection)\s*\(|\bnew\s+net\.Socket\b/],
|
|
26
|
+
['UDP socket', /\bdgram\.createSocket\s*\(/],
|
|
27
|
+
['curl', /\bcurl\s+/],
|
|
28
|
+
['wget', /\bwget\s+/],
|
|
29
|
+
['PowerShell web request', /Invoke-WebRequest|Invoke-RestMethod|System\.Net\.WebClient/i],
|
|
30
|
+
['navigator.sendBeacon', /sendBeacon\s*\(/],
|
|
31
|
+
['undici', /\bundici\b/],
|
|
32
|
+
['http client lib', /\b(?:got|ky|node-fetch|needle|superagent|request-promise|phin)\b/],
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Memoized per corpus object — detect() and floor.coOccurrence() share the
|
|
36
|
+
// result rather than each re-scanning the whole tree (see credentials.js).
|
|
37
|
+
const _hitsCache = new WeakMap();
|
|
38
|
+
function perFileHits(corpus) {
|
|
39
|
+
const cached = _hitsCache.get(corpus);
|
|
40
|
+
if (cached) return cached;
|
|
41
|
+
const hits = new Map(); // path -> [{ label, line }]
|
|
42
|
+
for (const file of corpus.files) {
|
|
43
|
+
if (file.text === null) continue;
|
|
44
|
+
for (const [label, re] of NETWORK_PATTERNS) {
|
|
45
|
+
const m = re.exec(file.text);
|
|
46
|
+
if (m) {
|
|
47
|
+
if (!hits.has(file.path)) hits.set(file.path, []);
|
|
48
|
+
hits.get(file.path).push({ label, line: lineAt(file.text, m.index) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
_hitsCache.set(corpus, hits);
|
|
53
|
+
return hits;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function detect(corpus) {
|
|
57
|
+
const findings = [];
|
|
58
|
+
for (const [path, fileHits] of perFileHits(corpus)) {
|
|
59
|
+
const labels = fileHits.map((h) => h.label);
|
|
60
|
+
findings.push(finding('network_sink', 'medium', path,
|
|
61
|
+
`Network egress sink(s): ${labels.join(', ')} (first at line ${fileHits[0].line}).`,
|
|
62
|
+
{ sinks: labels, line: fileHits[0].line }));
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { detect, perFileHits, NETWORK_PATTERNS };
|