@event4u/agent-config 9.6.0 → 9.8.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +207 -942
- package/README.md +21 -21
- package/dist/agent-src/README.md +2 -2
- package/dist/agent-src/commands/commit/in-chunks.md +8 -0
- package/dist/agent-src/commands/commit.md +17 -0
- package/dist/agent-src/commands/pr/create.md +4 -0
- package/dist/agent-src/contexts/override-system.md +75 -5
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
- package/dist/agent-src/rules/domain-safety-pii.md +1 -0
- package/dist/agent-src/rules/downstream-changes.md +31 -1
- package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +2 -0
- package/dist/agent-src/rules/language-and-tone.md +2 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +3 -0
- package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
- package/dist/agent-src/rules/onboarding-gate.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +3 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/telegraph-speak.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +1 -0
- package/dist/agent-src/rules/verify-before-complete.md +2 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
- package/dist/agent-src/skills/agent-docs-writing/SKILL.md +34 -16
- package/dist/agent-src/skills/agents-md-thin-root/SKILL.md +9 -9
- package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
- package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
- package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
- package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
- package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
- package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
- package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
- package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
- package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/github-workflows/doc-impact.yml +101 -0
- package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
- package/dist/cli/configRoot.js +112 -0
- package/dist/cli/configRoot.js.map +1 -0
- package/dist/cli/configRoot.test.js +97 -0
- package/dist/cli/configRoot.test.js.map +1 -0
- package/dist/cli/help.js +4 -1
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +19 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +164 -47
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -8
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +11 -6
- package/dist/install/install.mjs +16 -0
- package/dist/mcp/awesome-mcp-servers.row.md +1 -1
- package/dist/mcp/mcp-cloudflare-catalogue.json +1 -1
- package/dist/mcp/registry-manifest.json +4 -4
- package/dist/router.json +1 -1
- package/dist/server/app.js +10 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/routes/ping.js +18 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/schemas/settings.js +8 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/serverInfo.js +6 -2
- package/dist/server/serverInfo.js.map +1 -1
- package/dist/server/token.js +11 -4
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +15 -1
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/shared/capabilities.js +33 -0
- package/dist/shared/capabilities.js.map +1 -0
- package/dist/shared/capabilities.test.js +39 -0
- package/dist/shared/capabilities.test.js.map +1 -0
- package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
- package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
- package/dist/ui/index.html +14 -5
- package/docs/CLAIMS.md +64 -38
- package/docs/architecture.md +2 -2
- package/docs/archive/CHANGELOG-6.0.0-overview.md +953 -0
- package/docs/benchmark.md +170 -9
- package/docs/catalog.md +8 -3
- package/docs/comparison.yaml +44 -2
- package/docs/contracts/local-server-ports.md +53 -0
- package/docs/contracts/no-runtime-boundary.md +1 -1
- package/docs/contracts/subagent-boundary.md +15 -0
- package/docs/customization.md +14 -3
- package/docs/decisions/ADR-016-installer-architecture.md +2 -0
- package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
- package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
- package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
- package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
- package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
- package/docs/decisions/ADR-122-adversarial-verification-council.md +30 -0
- package/docs/decisions/ADR-123-runtime-security-scope-and-spawn-hardening.md +31 -0
- package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
- package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
- package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
- package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
- package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
- package/docs/decisions/INDEX.md +21 -0
- package/docs/decisions/engine-reclassification-2026-07.md +104 -0
- package/docs/distribution/awesome-list-submission.md +1 -1
- package/docs/distribution/registries.md +36 -27
- package/docs/enforcement-by-host.md +7 -0
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/guides/gated-platform-reads.md +221 -0
- package/docs/positioning-evidence.md +1 -1
- package/docs/proof.md +92 -16
- package/docs/spawn-site-policy.md +72 -0
- package/docs/threat-model.md +2 -1
- package/package.json +6 -3
- package/src/agent-src/README.md +2 -2
- package/src/config/agent-settings.template.yml +43 -0
- package/src/config/reach-channels.yml +217 -0
- package/src/config/reach-prescriptions-intake.yml +200 -0
- package/src/scripts/_dispatch.bash +15 -0
- package/src/scripts/_lib/adversarial_bench_score.ts +106 -0
- package/src/scripts/_lib/exec_evidence.ts +251 -0
- package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
- package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
- package/src/scripts/_lib/kernel_rules.ts +46 -0
- package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
- package/src/scripts/_lib/risk_paths.ts +160 -0
- package/src/scripts/_lib/secret_detector.test.ts +103 -0
- package/src/scripts/_lib/secret_detector.ts +319 -0
- package/src/scripts/_lib/spawn_env.ts +24 -4
- package/src/scripts/_lib/tool_probe.ts +359 -0
- package/src/scripts/adr/regenerate_index.ts +81 -6
- package/src/scripts/audit_adr_coverage.ts +33 -0
- package/src/scripts/bench_adversarial_council.ts +292 -0
- package/src/scripts/build_proof.ts +162 -0
- package/src/scripts/check_adr_frontmatter.ts +195 -0
- package/src/scripts/check_artefact_count_messaging.ts +4 -0
- package/src/scripts/check_backstop_debt.ts +200 -0
- package/src/scripts/check_claims.ts +188 -8
- package/src/scripts/check_comparison.ts +12 -0
- package/src/scripts/check_enforcement_coverage.ts +592 -0
- package/src/scripts/check_kernel_rule_bundle.ts +16 -12
- package/src/scripts/check_no_new_legacy_path.ts +2 -0
- package/src/scripts/check_reach_channels.ts +407 -0
- package/src/scripts/check_reach_staleness.ts +364 -0
- package/src/scripts/check_secret_leak.test.ts +47 -0
- package/src/scripts/check_secret_leak.ts +290 -0
- package/src/scripts/check_source_pointer_freshness.ts +165 -0
- package/src/scripts/code_graph/build.ts +0 -0
- package/src/scripts/code_graph/cli.ts +207 -0
- package/src/scripts/code_graph/detect.ts +134 -0
- package/src/scripts/code_graph/extract.ts +386 -0
- package/src/scripts/code_graph/loader.ts +123 -0
- package/src/scripts/code_graph/query.ts +176 -0
- package/src/scripts/code_graph/sanitize.ts +30 -0
- package/src/scripts/code_graph/types.ts +83 -0
- package/src/scripts/code_graph/validate.ts +63 -0
- package/src/scripts/external_sources_denylist.json +3 -1
- package/src/scripts/generate_subagent_floor.ts +194 -0
- package/src/scripts/hook_manifest.yaml +13 -3
- package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
- package/src/scripts/hooks/dispatch_hook.ts +5 -1
- package/src/scripts/iron_law_sha.ts +3 -12
- package/src/scripts/lint_override_kernel_guard.ts +219 -0
- package/src/scripts/lint_readme_serial_comma.ts +169 -0
- package/src/scripts/measure_rule_budget.ts +3 -12
- package/src/scripts/postinstall_gui.ts +129 -0
- package/src/scripts/reach_doctor.ts +1651 -0
- package/src/scripts/release.ts +24 -3
- package/src/scripts/risk_escalation_shadow.ts +160 -0
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/reach-channels.schema.json +134 -0
- package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/scripts/skill_linter.ts +55 -0
- package/src/scripts/validate_reach_prescriptions.ts +751 -0
- package/src/server/schemas/settings.ts +16 -0
- package/src/shared/capabilities.test.ts +43 -0
- package/src/shared/capabilities.ts +75 -0
- package/src/templates/minimal/agents-overrides-readme.md +20 -0
- package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust-boundary risk classification — the second axis of rigor.
|
|
3
|
+
*
|
|
4
|
+
* `discipline_profile: auto` answers one question: *how much governance can
|
|
5
|
+
* this host afford?* It is a property of the environment. The question it
|
|
6
|
+
* cannot ask is *how dangerous is this diff?* — a property of the change.
|
|
7
|
+
*
|
|
8
|
+
* The two are orthogonal, and today only one of them is wired. That leaves a
|
|
9
|
+
* live combination: a weak host plus a diff in the installer's provenance path
|
|
10
|
+
* resolves to minimum discipline, which is exactly the change class hardened in
|
|
11
|
+
* 9.6.0 (CWE-426/88/427). The fix went in; the process escalation for the next
|
|
12
|
+
* change of the same class did not.
|
|
13
|
+
*
|
|
14
|
+
* **This module classifies. It does not gate.** It exists to produce the number
|
|
15
|
+
* that decides whether gating is justified — see
|
|
16
|
+
* `internal/reports/risk-escalation-shadow.json`. The teeth threshold was
|
|
17
|
+
* written into the roadmap before the report was first read, so the measurement
|
|
18
|
+
* cannot be rationalised after the fact.
|
|
19
|
+
*
|
|
20
|
+
* Classification is path-based on purpose. A model judgement about whether a
|
|
21
|
+
* diff "feels risky" is not reproducible from the diff alone; a path match is.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { KERNEL_RULE_FILENAMES } from './kernel_rules.js';
|
|
25
|
+
|
|
26
|
+
/** Risk classes, in ascending severity. `none` is the common case. */
|
|
27
|
+
export type RiskClass = 'none' | 'governance' | 'auth' | 'trust-boundary';
|
|
28
|
+
|
|
29
|
+
/** Severity order — used to take the max across a multi-file diff. */
|
|
30
|
+
const SEVERITY: Record<RiskClass, number> = {
|
|
31
|
+
none: 0,
|
|
32
|
+
governance: 1,
|
|
33
|
+
auth: 2,
|
|
34
|
+
'trust-boundary': 3,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface RiskVerdict {
|
|
38
|
+
/** Highest class across every path in the diff. */
|
|
39
|
+
risk: RiskClass;
|
|
40
|
+
/** Why — one reason per matched path, for the report. */
|
|
41
|
+
reasons: { path: string; risk: RiskClass; because: string }[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Docs-only changes are exempt from escalation.
|
|
46
|
+
*
|
|
47
|
+
* Taken from the reference, which carves this out explicitly, and it is the
|
|
48
|
+
* right carve-out: a paragraph describing the installer does not cross a trust
|
|
49
|
+
* boundary.
|
|
50
|
+
*
|
|
51
|
+
* The exemption is about what a file *is*, not what extension it carries. In
|
|
52
|
+
* this repo governance is written in markdown — a rule file, `AGENTS.md`, and
|
|
53
|
+
* the kernel set are all `.md`, and all of them are executable policy rather
|
|
54
|
+
* than prose about policy. An extension-only test classified
|
|
55
|
+
* `src/rules/icon-consistency.md` as `none`, which is how the most governed
|
|
56
|
+
* surface in the tree would have escaped the classifier entirely. Governed
|
|
57
|
+
* markdown is excluded from the exemption up front.
|
|
58
|
+
*/
|
|
59
|
+
function is_docs_only(p: string): boolean {
|
|
60
|
+
if (!/\.(md|mdx|txt)$/i.test(p)) return false;
|
|
61
|
+
if (p.startsWith('src/rules/')) return false;
|
|
62
|
+
if (/(^|\/)AGENTS\.md$/.test(p)) return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Does this path carry authentication, authorization, session, or token logic?
|
|
68
|
+
* Kept to the names this repo actually uses, so it does not fire on every file
|
|
69
|
+
* with "user" in it.
|
|
70
|
+
*/
|
|
71
|
+
function is_auth_path(p: string): boolean {
|
|
72
|
+
return /(^|\/)(auth|authz|session|token|credential|secret)s?[/.]/i.test(p)
|
|
73
|
+
|| /(^|\/)(keys|keychain)\b/i.test(p);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Does this path decide *where framework code comes from* or *whether it is
|
|
78
|
+
* genuine*? This is the class the reference justifies in its own text as
|
|
79
|
+
* crossing a downstream trust boundary — the change affects people other than
|
|
80
|
+
* the one making it, because a consumer installs the result.
|
|
81
|
+
*/
|
|
82
|
+
function is_trust_boundary_path(p: string): boolean {
|
|
83
|
+
if (p.startsWith('src/install/')) return true;
|
|
84
|
+
if (/(^|\/)(install|bootstrap|update)r?\.(ts|sh|mjs|cjs)$/i.test(p)) return true;
|
|
85
|
+
if (/scripts\/install/i.test(p)) return true;
|
|
86
|
+
if (/(^|\/)(provenance|manifest|integrity|bundle)[.-]/i.test(p)) return true;
|
|
87
|
+
// Publishing and release automation resolve the source a consumer receives.
|
|
88
|
+
if (/^\.github\/workflows\/(publish|release)/i.test(p)) return true;
|
|
89
|
+
// The spawn path composes the prompt a subagent runs under, floor included.
|
|
90
|
+
if (/subagent_(spawn|bundle|floor)/i.test(p)) return true;
|
|
91
|
+
if (/generate_subagent_floor/i.test(p)) return true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Kernel rules and the safety floor — the invariants everything else rests on. */
|
|
96
|
+
function is_kernel_path(p: string): boolean {
|
|
97
|
+
const base = p.slice(p.lastIndexOf('/') + 1);
|
|
98
|
+
if (p.startsWith('src/rules/') && KERNEL_RULE_FILENAMES.has(base)) return true;
|
|
99
|
+
return /_lib\/kernel_rules\.ts$/.test(p) || /iron_law_sha/.test(p);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Governance surfaces — rules, settings schema, and the agent contract files. */
|
|
103
|
+
function is_governance_path(p: string): boolean {
|
|
104
|
+
if (p.startsWith('src/rules/')) return true;
|
|
105
|
+
if (/(^|\/)AGENTS\.md$/.test(p)) return true;
|
|
106
|
+
if (/agent-settings.*\.(yml|yaml|json)$/i.test(p)) return true;
|
|
107
|
+
if (/schemas\/.*\.schema\.json$/.test(p)) return true;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Classify one path. */
|
|
112
|
+
export function classify_path(p: string): { risk: RiskClass; because: string } {
|
|
113
|
+
// Kernel and spawn paths outrank the docs exemption: a kernel rule IS a
|
|
114
|
+
// markdown file, so exempting by extension first would classify the most
|
|
115
|
+
// dangerous surface in the repo as `none`.
|
|
116
|
+
if (is_kernel_path(p)) {
|
|
117
|
+
return { risk: 'trust-boundary', because: 'kernel rule or Iron-Law integrity surface' };
|
|
118
|
+
}
|
|
119
|
+
if (is_docs_only(p)) return { risk: 'none', because: 'docs-only' };
|
|
120
|
+
if (is_trust_boundary_path(p)) {
|
|
121
|
+
return {
|
|
122
|
+
risk: 'trust-boundary',
|
|
123
|
+
because: 'installer, provenance, publish, or subagent-spawn path — crosses a downstream trust boundary',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (is_auth_path(p)) {
|
|
127
|
+
return { risk: 'auth', because: 'authentication, session, token, or secret handling' };
|
|
128
|
+
}
|
|
129
|
+
if (is_governance_path(p)) {
|
|
130
|
+
return { risk: 'governance', because: 'rule, settings, or schema surface' };
|
|
131
|
+
}
|
|
132
|
+
return { risk: 'none', because: '' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Classify a whole diff — the verdict is the highest class present. */
|
|
136
|
+
export function classify_diff(paths: readonly string[]): RiskVerdict {
|
|
137
|
+
const reasons: RiskVerdict['reasons'] = [];
|
|
138
|
+
let risk: RiskClass = 'none';
|
|
139
|
+
for (const p of paths) {
|
|
140
|
+
const v = classify_path(p);
|
|
141
|
+
if (v.risk === 'none') continue;
|
|
142
|
+
reasons.push({ path: p, risk: v.risk, because: v.because });
|
|
143
|
+
if (SEVERITY[v.risk] > SEVERITY[risk]) risk = v.risk;
|
|
144
|
+
}
|
|
145
|
+
return { risk, reasons };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Combine the host axis with the risk axis.
|
|
150
|
+
*
|
|
151
|
+
* The direction is the whole point and is encoded rather than left to a caller:
|
|
152
|
+
* the host profile sets a floor, a risk trigger may only **raise** it. A weak
|
|
153
|
+
* host must never be able to lower an escalation, which is the failure mode
|
|
154
|
+
* that makes the two-axis model worth having at all.
|
|
155
|
+
*/
|
|
156
|
+
export function escalated_floor(hostFloor: number, risk: RiskClass): number {
|
|
157
|
+
return Math.max(hostFloor, SEVERITY[risk]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { SEVERITY as RISK_SEVERITY };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secret_detector — coverage (secret-hygiene-guardrail Phase 0).
|
|
3
|
+
*
|
|
4
|
+
* Positive fixtures: each labelled line carries one fake secret; expectations
|
|
5
|
+
* come from an in-test label→kind map, NOT from detector output.
|
|
6
|
+
* Negative fixtures: every line must be free of high/medium findings (low is
|
|
7
|
+
* only for placeholder-shaped lines; the `secret-allow` line yields nothing).
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { scanText, type SecretFinding } from './secret_detector.js';
|
|
16
|
+
|
|
17
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const FIXTURES = path.join(HERE, 'fixtures', 'secret-detector');
|
|
19
|
+
|
|
20
|
+
function readFixture(name: string): string {
|
|
21
|
+
return fs.readFileSync(path.join(FIXTURES, name), 'utf-8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Label → expected kind. Keyed by the leading `<label>|` token, so a reordered
|
|
25
|
+
// fixture cannot silently pass.
|
|
26
|
+
const EXPECTED_KIND: Readonly<Record<string, string>> = {
|
|
27
|
+
aws: 'aws-access-key',
|
|
28
|
+
github: 'github-pat',
|
|
29
|
+
stripe: 'stripe-secret-key',
|
|
30
|
+
google: 'google-api-key',
|
|
31
|
+
slack: 'slack-token',
|
|
32
|
+
pem: 'pem-private-key',
|
|
33
|
+
jwt: 'jwt',
|
|
34
|
+
dburl: 'db-connection-uri',
|
|
35
|
+
assignment: 'generic-assignment',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function isReal(f: SecretFinding): boolean {
|
|
39
|
+
return f.confidence === 'high' || f.confidence === 'medium';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('scanText — positives', () => {
|
|
43
|
+
const text = readFixture('positives.txt');
|
|
44
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
45
|
+
const findings = scanText(text);
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i] as string;
|
|
49
|
+
if (!line.includes('|') || line.startsWith('#')) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const label = line.slice(0, line.indexOf('|'));
|
|
53
|
+
const expectedKind = EXPECTED_KIND[label];
|
|
54
|
+
const lineNo = i + 1;
|
|
55
|
+
|
|
56
|
+
it(`flags ${label} as ${expectedKind}`, () => {
|
|
57
|
+
expect(expectedKind, `no expected kind mapped for label "${label}"`).toBeDefined();
|
|
58
|
+
const onLine = findings.filter((f) => f.line === lineNo && isReal(f));
|
|
59
|
+
const kinds = onLine.map((f) => f.kind);
|
|
60
|
+
expect(
|
|
61
|
+
kinds,
|
|
62
|
+
`line ${lineNo} (${label}) expected kind ${expectedKind}, got ${JSON.stringify(onLine)}`,
|
|
63
|
+
).toContain(expectedKind);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('scanText — negatives', () => {
|
|
69
|
+
const text = readFixture('negatives.txt');
|
|
70
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
71
|
+
const findings = scanText(text);
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i] as string;
|
|
75
|
+
if (line.trim() === '' || line.startsWith('#')) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const lineNo = i + 1;
|
|
79
|
+
it(`no high/medium finding on line ${lineNo}`, () => {
|
|
80
|
+
const real = findings.filter((f) => f.line === lineNo && isReal(f));
|
|
81
|
+
expect(real, `unexpected real findings: ${JSON.stringify(real)}`).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it('the secret-allow line yields zero findings of any confidence', () => {
|
|
86
|
+
const allowLineNo = lines.findIndex((l) => l.includes('secret-allow')) + 1;
|
|
87
|
+
expect(allowLineNo, 'no secret-allow line in fixture').toBeGreaterThan(0);
|
|
88
|
+
const onLine = findings.filter((f) => f.line === allowLineNo);
|
|
89
|
+
expect(onLine).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('scanText — masking', () => {
|
|
94
|
+
it('never reproduces the raw secret in `masked`', () => {
|
|
95
|
+
const raw = 'AKIA1234567890ABCDEF';
|
|
96
|
+
const findings = scanText(`aws_key = "${raw}"`);
|
|
97
|
+
const hit = findings.find((f) => f.kind === 'aws-access-key');
|
|
98
|
+
expect(hit, 'expected an aws-access-key finding').toBeDefined();
|
|
99
|
+
const masked = (hit as SecretFinding).masked;
|
|
100
|
+
expect(masked).not.toBe(raw);
|
|
101
|
+
expect(masked.includes(raw)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// VCS-agnostic secret detector (secret-hygiene-guardrail Phase 0).
|
|
2
|
+
//
|
|
3
|
+
// A pure, I/O-free, network-free module: given a blob of text it returns the
|
|
4
|
+
// secrets it finds. Callers (pre-commit hooks, CI scanners, editor lints)
|
|
5
|
+
// decide policy — this module only detects.
|
|
6
|
+
//
|
|
7
|
+
// Three detection layers, in confidence order:
|
|
8
|
+
// 1. Regex rule pack (HIGH) — reuses the exact `_SECRET` patterns from
|
|
9
|
+
// `lint_mcp_config_security.ts` (sk-ant-, sk-proj-, AKIA, AIza, ghp_, JWT)
|
|
10
|
+
// and extends them with Stripe, Slack, PEM private-key headers, DB
|
|
11
|
+
// connection URLs with embedded credentials, and generic secret-ish
|
|
12
|
+
// assignments. The reused patterns are NOT weakened.
|
|
13
|
+
// 2. Entropy layer (MEDIUM) — Shannon entropy over base64/hex-ish tokens
|
|
14
|
+
// ≥ ENTROPY_MIN_TOKEN_LENGTH chars, flagging those above a tunable
|
|
15
|
+
// per-charset threshold (base64 ≥ 4.5 bits/char, hex ≥ 3.0).
|
|
16
|
+
// 3. Keyword/context — a high-entropy token on a line that also mentions a
|
|
17
|
+
// secret keyword is raised to HIGH; standalone entropy stays MEDIUM.
|
|
18
|
+
//
|
|
19
|
+
// False-positive suppression lives INSIDE the detector: a `secret-allow`
|
|
20
|
+
// marker mutes a line, placeholder-shaped values are demoted to LOW (still
|
|
21
|
+
// returned so callers can choose), and the masked preview never reproduces the
|
|
22
|
+
// full secret.
|
|
23
|
+
|
|
24
|
+
/** One detected secret. Never carries the raw value — see `masked`. */
|
|
25
|
+
export interface SecretFinding {
|
|
26
|
+
rule: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
line: number;
|
|
29
|
+
column: number;
|
|
30
|
+
masked: string;
|
|
31
|
+
confidence: 'high' | 'medium' | 'low';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Options for a scan. `path` lets `.example` / `.sample` files demote hits. */
|
|
35
|
+
export interface ScanOptions {
|
|
36
|
+
path?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------
|
|
40
|
+
// Entropy thresholds (exported so callers / tests can tune + assert them).
|
|
41
|
+
// ---------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** Minimum token length before the entropy layer even considers a candidate. */
|
|
44
|
+
export const ENTROPY_MIN_TOKEN_LENGTH = 20;
|
|
45
|
+
/** base64/mixed-alphabet tokens above this bits/char are entropy hits. */
|
|
46
|
+
export const BASE64_ENTROPY_THRESHOLD = 4.5;
|
|
47
|
+
/** pure-hex tokens above this bits/char are entropy hits (with keyword ctx). */
|
|
48
|
+
export const HEX_ENTROPY_THRESHOLD = 3.0;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------
|
|
51
|
+
// Regex rule pack (HIGH-confidence).
|
|
52
|
+
// ---------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
interface Rule {
|
|
55
|
+
rule: string;
|
|
56
|
+
kind: string;
|
|
57
|
+
regex: RegExp; // MUST carry the global flag
|
|
58
|
+
valueGroup?: number; // capture group holding the sensitive value (default 0)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Reused verbatim from lint_mcp_config_security.ts `_SECRET` (do NOT weaken),
|
|
62
|
+
// split into individually-kinded rules, plus the extension set from the spec.
|
|
63
|
+
const RULES: readonly Rule[] = [
|
|
64
|
+
{ rule: 'anthropic-api-key', kind: 'anthropic-api-key', regex: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
65
|
+
{ rule: 'anthropic-project-key', kind: 'anthropic-api-key', regex: /sk-proj-[A-Za-z0-9_-]{20,}/g },
|
|
66
|
+
{ rule: 'aws-access-key', kind: 'aws-access-key', regex: /AKIA[0-9A-Z]{16}/g },
|
|
67
|
+
{ rule: 'google-api-key', kind: 'google-api-key', regex: /AIza[0-9A-Za-z_-]{35}/g },
|
|
68
|
+
{ rule: 'github-pat', kind: 'github-pat', regex: /ghp_[0-9A-Za-z]{36}/g },
|
|
69
|
+
{
|
|
70
|
+
rule: 'jwt',
|
|
71
|
+
kind: 'jwt',
|
|
72
|
+
regex: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
73
|
+
},
|
|
74
|
+
// Extensions.
|
|
75
|
+
{ rule: 'stripe-secret-key', kind: 'stripe-secret-key', regex: /sk_(?:live|test)_[0-9A-Za-z]{16,}/g },
|
|
76
|
+
{ rule: 'slack-token', kind: 'slack-token', regex: /xox[baprs]-[0-9A-Za-z-]{10,}/g },
|
|
77
|
+
{ rule: 'pem-private-key', kind: 'pem-private-key', regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
|
|
78
|
+
{
|
|
79
|
+
rule: 'db-connection-uri',
|
|
80
|
+
kind: 'db-connection-uri',
|
|
81
|
+
regex: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]+@/g,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
rule: 'generic-assignment',
|
|
85
|
+
kind: 'generic-assignment',
|
|
86
|
+
regex: /(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?([^'"\s]{8,})['"]?/gi,
|
|
87
|
+
valueGroup: 1,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------
|
|
92
|
+
// Suppression + placeholder detection.
|
|
93
|
+
// ---------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
// A line carrying any comment-style `secret-allow` marker is fully muted.
|
|
96
|
+
const SECRET_ALLOW = /secret-allow/;
|
|
97
|
+
|
|
98
|
+
// A high-entropy token that looks like a placeholder is demoted to LOW.
|
|
99
|
+
const PLACEHOLDER_PATTERNS: readonly RegExp[] = [
|
|
100
|
+
/x{4,}/i,
|
|
101
|
+
/example/i,
|
|
102
|
+
/changeme/i,
|
|
103
|
+
/your[_-].*here/i,
|
|
104
|
+
/<[^>]+>/,
|
|
105
|
+
/\.\.\./,
|
|
106
|
+
/^your[_-]/i,
|
|
107
|
+
/placeholder/i,
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
// A base64 data URI (embedded image/font) — its payload is not a secret.
|
|
111
|
+
const DATA_URI = /data:[\w.+-]+\/[\w.+-]+;base64,/i;
|
|
112
|
+
|
|
113
|
+
// A UUID is a benign identifier, not a credential.
|
|
114
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
115
|
+
|
|
116
|
+
// Secret-context keywords that raise an otherwise-MEDIUM entropy hit to HIGH.
|
|
117
|
+
const KEYWORD_CONTEXT = /password|secret|token|api[_-]?key|credential/i;
|
|
118
|
+
|
|
119
|
+
// Candidate high-entropy tokens (base64 / hex alphabets). `/` is deliberately
|
|
120
|
+
// excluded — it appears in URL paths far more than in inline credentials, and
|
|
121
|
+
// including it turns every long path slug into a false positive.
|
|
122
|
+
const ENTROPY_TOKEN = /[A-Za-z0-9+=_-]{20,}/g;
|
|
123
|
+
const IS_HEX = /^[0-9a-fA-F]+$/;
|
|
124
|
+
// Common digest lengths (MD5 / SHA-1 / SHA-256 hex). A token of exactly one of
|
|
125
|
+
// these is a hash — a commit SHA, lockfile digest, condensation hash — not a
|
|
126
|
+
// credential. Never flag it, even with a keyword nearby.
|
|
127
|
+
const HASH_HEX_LENGTHS: ReadonlySet<number> = new Set([32, 40, 64]);
|
|
128
|
+
|
|
129
|
+
function isAllSameChar(value: string): boolean {
|
|
130
|
+
return value.length > 0 && /^(.)\1*$/.test(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Minimum length + entropy for a `generic-assignment` value to count as a real
|
|
134
|
+
// credential. A bare `secret: moderate` / `token: modelName` / `key: request()`
|
|
135
|
+
// is a keyword-shaped assignment of a low-entropy WORD — not a secret. Real
|
|
136
|
+
// credentials assigned this way are long and high-entropy. Without this gate the
|
|
137
|
+
// generic rule floods on prose, config, and test data (the FP class the council
|
|
138
|
+
// warned about — precision over allowlist growth).
|
|
139
|
+
const GENERIC_MIN_LENGTH = 16;
|
|
140
|
+
const GENERIC_MIN_ENTROPY = 3.3;
|
|
141
|
+
// Code-expression shapes: an assignment RHS that is a reference / call /
|
|
142
|
+
// interpolation, not a literal credential (`opts.apiKey`, `${API_KEY}`,
|
|
143
|
+
// `load()`, `process.env.X`, `$this->get()`). Never a real inline secret.
|
|
144
|
+
const CODE_EXPR = /[()$`{}<>[\];,]|->|=>|\.\w/;
|
|
145
|
+
|
|
146
|
+
function looksLikeSecretValue(value: string): boolean {
|
|
147
|
+
if (value.length < GENERIC_MIN_LENGTH) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (CODE_EXPR.test(value)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
// A real credential carries entropy AND is not a plain lowercase word —
|
|
154
|
+
// it has a digit or mixed case.
|
|
155
|
+
const hasDigit = /[0-9]/.test(value);
|
|
156
|
+
const mixedCase = /[a-z]/.test(value) && /[A-Z]/.test(value);
|
|
157
|
+
if (!hasDigit && !mixedCase) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return shannonEntropy(value) >= GENERIC_MIN_ENTROPY;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isPlaceholderValue(value: string, pathHint?: string): boolean {
|
|
164
|
+
if (pathHint !== undefined) {
|
|
165
|
+
if (
|
|
166
|
+
pathHint.endsWith('.example') ||
|
|
167
|
+
pathHint.endsWith('.sample') ||
|
|
168
|
+
pathHint.includes('.env.example')
|
|
169
|
+
) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (isAllSameChar(value)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
return PLACEHOLDER_PATTERNS.some((p) => p.test(value));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** first ≤4 + `…` + last ≤2; guaranteed never to contain the full value. */
|
|
180
|
+
function maskSecret(value: string): string {
|
|
181
|
+
const front = value.slice(0, 4);
|
|
182
|
+
const back = value.length > 6 ? value.slice(-2) : '';
|
|
183
|
+
if (front.length + back.length >= value.length) {
|
|
184
|
+
// Too short for a 4+2 window without revealing everything — hide more.
|
|
185
|
+
return `${value.slice(0, 1)}…`;
|
|
186
|
+
}
|
|
187
|
+
return `${front}…${back}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function shannonEntropy(value: string): number {
|
|
191
|
+
const freq = new Map<string, number>();
|
|
192
|
+
for (const ch of value) {
|
|
193
|
+
freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
const n = value.length;
|
|
196
|
+
let entropy = 0;
|
|
197
|
+
for (const count of freq.values()) {
|
|
198
|
+
const p = count / n;
|
|
199
|
+
entropy -= p * Math.log2(p);
|
|
200
|
+
}
|
|
201
|
+
return entropy;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function overlaps(spans: ReadonlyArray<[number, number]>, start: number, end: number): boolean {
|
|
205
|
+
return spans.some(([s, e]) => start < e && s < end);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Scan `text` for secrets. Pure — no I/O, no network. Line and column are
|
|
210
|
+
* 1-based; column points at the sensitive value, not the surrounding syntax.
|
|
211
|
+
*/
|
|
212
|
+
export function scanText(text: string, opts: ScanOptions = {}): SecretFinding[] {
|
|
213
|
+
const pathHint = opts.path;
|
|
214
|
+
const findings: SecretFinding[] = [];
|
|
215
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < lines.length; i++) {
|
|
218
|
+
const line = lines[i] as string;
|
|
219
|
+
const lineNo = i + 1;
|
|
220
|
+
|
|
221
|
+
// A `secret-allow` marker mutes the whole line — no findings at all.
|
|
222
|
+
if (SECRET_ALLOW.test(line)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const covered: Array<[number, number]> = [];
|
|
227
|
+
|
|
228
|
+
// Layer 1 — regex rule pack (HIGH, demoted to LOW for placeholders).
|
|
229
|
+
for (const rule of RULES) {
|
|
230
|
+
rule.regex.lastIndex = 0;
|
|
231
|
+
let m: RegExpExecArray | null;
|
|
232
|
+
while ((m = rule.regex.exec(line)) !== null) {
|
|
233
|
+
const value = rule.valueGroup !== undefined ? m[rule.valueGroup] : m[0];
|
|
234
|
+
if (value === undefined) {
|
|
235
|
+
if (m.index === rule.regex.lastIndex) {
|
|
236
|
+
rule.regex.lastIndex += 1;
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// The keyword-anchored generic rule needs a value-likeness gate:
|
|
241
|
+
// a low-entropy word after `secret:`/`token:` is not a credential.
|
|
242
|
+
if (rule.rule === 'generic-assignment' && !looksLikeSecretValue(value)) {
|
|
243
|
+
if (m.index === rule.regex.lastIndex) {
|
|
244
|
+
rule.regex.lastIndex += 1;
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const valueStart = m.index + m[0].indexOf(value);
|
|
249
|
+
const confidence: SecretFinding['confidence'] = isPlaceholderValue(value, pathHint)
|
|
250
|
+
? 'low'
|
|
251
|
+
: 'high';
|
|
252
|
+
findings.push({
|
|
253
|
+
rule: rule.rule,
|
|
254
|
+
kind: rule.kind,
|
|
255
|
+
line: lineNo,
|
|
256
|
+
column: valueStart + 1,
|
|
257
|
+
masked: maskSecret(value),
|
|
258
|
+
confidence,
|
|
259
|
+
});
|
|
260
|
+
covered.push([valueStart, valueStart + value.length]);
|
|
261
|
+
if (m.index === rule.regex.lastIndex) {
|
|
262
|
+
rule.regex.lastIndex += 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Layer 2 + 3 — entropy, raised by keyword context. Skip data URIs.
|
|
268
|
+
if (!DATA_URI.test(line)) {
|
|
269
|
+
const hasKeyword = KEYWORD_CONTEXT.test(line);
|
|
270
|
+
ENTROPY_TOKEN.lastIndex = 0;
|
|
271
|
+
let tm: RegExpExecArray | null;
|
|
272
|
+
while ((tm = ENTROPY_TOKEN.exec(line)) !== null) {
|
|
273
|
+
const token = tm[0];
|
|
274
|
+
const start = tm.index;
|
|
275
|
+
const end = start + token.length;
|
|
276
|
+
if (token.length < ENTROPY_MIN_TOKEN_LENGTH) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (overlaps(covered, start, end)) {
|
|
280
|
+
continue; // already reported by the regex pack
|
|
281
|
+
}
|
|
282
|
+
if (UUID.test(token)) {
|
|
283
|
+
continue; // benign identifier
|
|
284
|
+
}
|
|
285
|
+
const entropy = shannonEntropy(token);
|
|
286
|
+
const isHex = IS_HEX.test(token);
|
|
287
|
+
if (isHex && HASH_HEX_LENGTHS.has(token.length)) {
|
|
288
|
+
continue; // MD5 / SHA-1 / SHA-256 digest — a hash, not a secret
|
|
289
|
+
}
|
|
290
|
+
let confidence: SecretFinding['confidence'] | null = null;
|
|
291
|
+
if (isHex) {
|
|
292
|
+
// Pure hex is usually a SHA / UUID / lockfile hash — only a
|
|
293
|
+
// keyword nearby lifts it out of benign territory.
|
|
294
|
+
if (entropy >= HEX_ENTROPY_THRESHOLD && hasKeyword) {
|
|
295
|
+
confidence = 'high';
|
|
296
|
+
}
|
|
297
|
+
} else if (entropy >= BASE64_ENTROPY_THRESHOLD) {
|
|
298
|
+
confidence = hasKeyword ? 'high' : 'medium';
|
|
299
|
+
}
|
|
300
|
+
if (confidence === null) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (isPlaceholderValue(token, pathHint)) {
|
|
304
|
+
confidence = 'low';
|
|
305
|
+
}
|
|
306
|
+
findings.push({
|
|
307
|
+
rule: 'entropy',
|
|
308
|
+
kind: 'high-entropy-string',
|
|
309
|
+
line: lineNo,
|
|
310
|
+
column: start + 1,
|
|
311
|
+
masked: maskSecret(token),
|
|
312
|
+
confidence,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return findings;
|
|
319
|
+
}
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* - git command-injection (CWE-88): GIT_EXTERNAL_DIFF, GIT_SSH,
|
|
15
15
|
* GIT_SSH_COMMAND, GIT_PROXY_COMMAND, any GIT_*_COMMAND, GIT_PAGER, PAGER —
|
|
16
16
|
* git runs these as shell commands (`core.pager` / external-diff RCE).
|
|
17
|
+
* - git config-injection (CWE-88): the GIT_CONFIG* family (GIT_CONFIG,
|
|
18
|
+
* GIT_CONFIG_GLOBAL/_SYSTEM, and inline GIT_CONFIG_COUNT / GIT_CONFIG_KEY_<n>
|
|
19
|
+
* / GIT_CONFIG_VALUE_<n>) sets ANY git config — `core.fsmonitor` runs shell
|
|
20
|
+
* on every `git status`; GIT_ALTERNATE_OBJECT_DIRECTORIES / HOSTALIASES
|
|
21
|
+
* redirect object stores / hostname resolution.
|
|
17
22
|
* - runtime auto-exec hooks: NODE_OPTIONS (`--require attacker.js`),
|
|
18
23
|
* BASH_ENV / ENV (sourced by non-interactive shells), PYTHONPATH /
|
|
19
24
|
* PYTHONSTARTUP / PYTHONINSPECT, PERL5OPT / PERL5LIB, RUBYOPT.
|
|
@@ -45,6 +50,13 @@ const DENY_EXACT: ReadonlySet<string> = new Set([
|
|
|
45
50
|
'GIT_PROXY_COMMAND',
|
|
46
51
|
'GIT_PAGER',
|
|
47
52
|
'PAGER',
|
|
53
|
+
// git config-injection: GIT_ALTERNATE_OBJECT_DIRECTORIES can point git at
|
|
54
|
+
// attacker-controlled object stores; the GIT_CONFIG* family is handled by
|
|
55
|
+
// prefix below (it is a general arbitrary-config-injection primitive).
|
|
56
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
57
|
+
// resolver hijack: HOSTALIASES remaps hostnames via an attacker file,
|
|
58
|
+
// redirecting any network fetch the child performs (CWE-427-adjacent).
|
|
59
|
+
'HOSTALIASES',
|
|
48
60
|
// runtime auto-exec hooks
|
|
49
61
|
'NODE_OPTIONS',
|
|
50
62
|
'BASH_ENV',
|
|
@@ -61,14 +73,22 @@ const DENY_EXACT: ReadonlySet<string> = new Set([
|
|
|
61
73
|
|
|
62
74
|
/**
|
|
63
75
|
* Prefix families removed by pattern. `LD_` / `DYLD_` cover loader variants we
|
|
64
|
-
* did not enumerate; `GIT_*_COMMAND` covers every git config-command hook
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
76
|
+
* did not enumerate; `GIT_*_COMMAND` covers every git config-command hook; the
|
|
77
|
+
* `GIT_CONFIG` family (GIT_CONFIG, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM, and the
|
|
78
|
+
* inline GIT_CONFIG_COUNT / GIT_CONFIG_KEY_<n> / GIT_CONFIG_VALUE_<n> trio) is a
|
|
79
|
+
* general arbitrary-config-injection primitive — an attacker can set ANY git
|
|
80
|
+
* config key, e.g. `core.fsmonitor` / `core.pager` / `core.sshCommand` /
|
|
81
|
+
* `core.hooksPath` / `alias.*`, all of which git executes as shell on ordinary
|
|
82
|
+
* operations (`git status` alone runs `core.fsmonitor`). The plain `GIT_*_COMMAND`
|
|
83
|
+
* check missed it (no `_COMMAND` suffix), so it is denied by its own prefix.
|
|
84
|
+
* (GIT_ASKPASS is intentionally NOT denied — it is a path, not a shell command,
|
|
85
|
+
* and legitimate credential helpers need it; the *_COMMAND family is the
|
|
86
|
+
* shell-executed one.)
|
|
68
87
|
*/
|
|
69
88
|
function isDeniedByFamily(name: string): boolean {
|
|
70
89
|
if (name.startsWith('LD_') || name.startsWith('DYLD_')) return true;
|
|
71
90
|
if (name.startsWith('GIT_') && name.endsWith('_COMMAND')) return true;
|
|
91
|
+
if (name === 'GIT_CONFIG' || name.startsWith('GIT_CONFIG_')) return true;
|
|
72
92
|
return false;
|
|
73
93
|
}
|
|
74
94
|
|