@node9/policy-engine 2.16.2 → 2.18.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/dist/index.d.mts +57 -12
- package/dist/index.d.ts +57 -12
- package/dist/index.js +84 -23
- package/dist/index.mjs +82 -23
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -402,6 +402,15 @@ interface ShellCommandAnalysis {
|
|
|
402
402
|
*/
|
|
403
403
|
declare function analyzeShellCommand(command: string): ShellCommandAnalysis;
|
|
404
404
|
|
|
405
|
+
/**
|
|
406
|
+
* A destination a tool call will reach, whichever carrier declared it: a
|
|
407
|
+
* shell command (extractShellDestinations) or a tool argument
|
|
408
|
+
* (extractToolDestinations). The interface is still spelled ShellDestination
|
|
409
|
+
* where it lives (shell/index.ts, which is hashed by the extractor-version
|
|
410
|
+
* gate, so a rename there is not free); this is the name that says what the
|
|
411
|
+
* shape is for.
|
|
412
|
+
*/
|
|
413
|
+
type Destination = ShellDestination;
|
|
405
414
|
interface EgressPolicy {
|
|
406
415
|
/** Master switch. Default false — opt-in, like dlp.pii. */
|
|
407
416
|
enabled: boolean;
|
|
@@ -459,7 +468,7 @@ declare function isPrivateHost(host: string): boolean;
|
|
|
459
468
|
* first review; null if everything is allowed (or policy disabled / mode off).
|
|
460
469
|
* Pure.
|
|
461
470
|
*/
|
|
462
|
-
declare function evaluateEgress(dests: readonly
|
|
471
|
+
declare function evaluateEgress(dests: readonly Destination[], policy: EgressPolicy): EgressVerdict | null;
|
|
463
472
|
|
|
464
473
|
interface PipeChainAnalysis {
|
|
465
474
|
isPipeline: boolean;
|
|
@@ -628,16 +637,6 @@ declare function resolvePinned(matches: SmartRule[]): SmartRule | undefined;
|
|
|
628
637
|
* Returns a reason string if dangerous, null if safe.
|
|
629
638
|
*/
|
|
630
639
|
declare function checkDangerousSql(sql: string): string | null;
|
|
631
|
-
/**
|
|
632
|
-
* Stateless policy evaluation. Same waterfall as the original
|
|
633
|
-
* proxy/src/policy/index.ts:evaluatePolicy, but config + context + I/O
|
|
634
|
-
* hooks come in as parameters so this function works in any host.
|
|
635
|
-
*
|
|
636
|
-
* Returns 'allow' for ignored tools, the matched smart-rule verdict,
|
|
637
|
-
* inline-execution review, eval-detection verdict, pipe-chain verdict,
|
|
638
|
-
* provenance verdict, sandbox allow, dangerous-word review, or strict-mode
|
|
639
|
-
* fallback. See the design doc for the full tier table.
|
|
640
|
-
*/
|
|
641
640
|
declare function evaluatePolicy(config: PolicyConfig, toolName: string, args?: unknown, context?: PolicyContext, hooks?: PolicyHostHooks): Promise<PolicyVerdict>;
|
|
642
641
|
/** Returns true when toolName matches the config's ignoredTools list. */
|
|
643
642
|
declare function isIgnoredTool(toolName: string, config: PolicyConfig): boolean;
|
|
@@ -1439,6 +1438,33 @@ interface SsrfFloorOptions {
|
|
|
1439
1438
|
* those constantly (72 of 308 destinations on measured real history). */
|
|
1440
1439
|
ssrfStrict?: boolean;
|
|
1441
1440
|
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Does this exemption list release this address?
|
|
1443
|
+
*
|
|
1444
|
+
* ONE function, because the shell floor and the declared-URL floor must answer
|
|
1445
|
+
* identically. They had two spellings of it — `normalizeIpLiteral(e) ?? e.trim()
|
|
1446
|
+
* .toLowerCase()` here and `classifySsrf(a)?.normalized ?? a` in
|
|
1447
|
+
* destinations.ts — and for an entry classifySsrf does not match they already
|
|
1448
|
+
* disagreed. Nothing depended on the difference, which is exactly when it is
|
|
1449
|
+
* cheap to remove.
|
|
1450
|
+
*
|
|
1451
|
+
* An entry is an exact address or a CIDR range. The range form exists so an
|
|
1452
|
+
* operator can exempt the mesh-VPN range they actually use (100.64.0.0/10)
|
|
1453
|
+
* instead of listing peers one at a time, which is what the settings panel's
|
|
1454
|
+
* "remove this default" control needs.
|
|
1455
|
+
*
|
|
1456
|
+
* ⚠ This answers "is it in the list", NOT "may it be released". The caller
|
|
1457
|
+
* keeps its `m.overridable` guard, and that guard is per ADDRESS, decided by
|
|
1458
|
+
* classifySsrf before this is consulted. So a range can never release a
|
|
1459
|
+
* protected address inside it: 100.100.100.200 (Alibaba IMDS) sits inside
|
|
1460
|
+
* 100.64.0.0/10 and is named in METADATA_ADDRESSES above the range check, so
|
|
1461
|
+
* it classifies as metadata / overridable:false and stays blocked with the
|
|
1462
|
+
* whole range exempted. Pinned by E2 in exempt-range.spec.ts.
|
|
1463
|
+
*
|
|
1464
|
+
* Never throws: this runs on the hook path for every tool call, and a bad
|
|
1465
|
+
* entry in a hand-edited config must not break every command on the machine.
|
|
1466
|
+
*/
|
|
1467
|
+
declare function ssrfExemptMatches(entries: readonly string[] | undefined, normalized: string | undefined): boolean;
|
|
1442
1468
|
/**
|
|
1443
1469
|
* The floor. Returns the first protected destination, or null.
|
|
1444
1470
|
*
|
|
@@ -1464,6 +1490,25 @@ declare function ssrfFloor(tokens: ReadonlyArray<{
|
|
|
1464
1490
|
* `[]` in a path means "every element of this array".
|
|
1465
1491
|
*/
|
|
1466
1492
|
declare const DESTINATION_ARGS: ReadonlyMap<string, readonly string[]>;
|
|
1493
|
+
/**
|
|
1494
|
+
* The hosts a non-shell tool call will reach, in the SAME shape the shell
|
|
1495
|
+
* extractor produces, so both feed one evaluateEgress (G10).
|
|
1496
|
+
*
|
|
1497
|
+
* Measured 2026-09-20 on dev e748b4a, egress { mode: 'block', allow: [] }:
|
|
1498
|
+
* `curl https://evil.example.com/` denied, the same URL through WebFetch, MCP
|
|
1499
|
+
* fetch and browser navigate allowed. The allowlist was shell-only, and an
|
|
1500
|
+
* agent that wants to exfiltrate does not need curl. The extraction below is
|
|
1501
|
+
* what the floor above already does; the policy was never applied to it.
|
|
1502
|
+
*
|
|
1503
|
+
* Same closed table, same helpers, for the same reason: a tool is covered
|
|
1504
|
+
* because it is LISTED, never because an argument looked like a URL. A tool
|
|
1505
|
+
* not in DESTINATION_ARGS yields [] and is invisible to the egress policy,
|
|
1506
|
+
* exactly as it is invisible to the floor. `binary` carries the tool name so
|
|
1507
|
+
* the reason string and the audit row say which carrier it was.
|
|
1508
|
+
*
|
|
1509
|
+
* Never throws: this runs on the hook path for every tool call.
|
|
1510
|
+
*/
|
|
1511
|
+
declare function extractToolDestinations(toolName: string, args: unknown): Destination[];
|
|
1467
1512
|
interface DestinationHit extends SsrfMatch {
|
|
1468
1513
|
/** The path that carried it, for the message and the audit row. */
|
|
1469
1514
|
argPath: string;
|
|
@@ -1483,4 +1528,4 @@ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
|
|
|
1483
1528
|
/** Engine version stamped on audit entries for future drift detection. */
|
|
1484
1529
|
declare const ENGINE_VERSION = "1.4.0";
|
|
1485
1530
|
|
|
1486
|
-
export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
|
|
1531
|
+
export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type Destination, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, extractToolDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfExemptMatches, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
|
package/dist/index.d.ts
CHANGED
|
@@ -402,6 +402,15 @@ interface ShellCommandAnalysis {
|
|
|
402
402
|
*/
|
|
403
403
|
declare function analyzeShellCommand(command: string): ShellCommandAnalysis;
|
|
404
404
|
|
|
405
|
+
/**
|
|
406
|
+
* A destination a tool call will reach, whichever carrier declared it: a
|
|
407
|
+
* shell command (extractShellDestinations) or a tool argument
|
|
408
|
+
* (extractToolDestinations). The interface is still spelled ShellDestination
|
|
409
|
+
* where it lives (shell/index.ts, which is hashed by the extractor-version
|
|
410
|
+
* gate, so a rename there is not free); this is the name that says what the
|
|
411
|
+
* shape is for.
|
|
412
|
+
*/
|
|
413
|
+
type Destination = ShellDestination;
|
|
405
414
|
interface EgressPolicy {
|
|
406
415
|
/** Master switch. Default false — opt-in, like dlp.pii. */
|
|
407
416
|
enabled: boolean;
|
|
@@ -459,7 +468,7 @@ declare function isPrivateHost(host: string): boolean;
|
|
|
459
468
|
* first review; null if everything is allowed (or policy disabled / mode off).
|
|
460
469
|
* Pure.
|
|
461
470
|
*/
|
|
462
|
-
declare function evaluateEgress(dests: readonly
|
|
471
|
+
declare function evaluateEgress(dests: readonly Destination[], policy: EgressPolicy): EgressVerdict | null;
|
|
463
472
|
|
|
464
473
|
interface PipeChainAnalysis {
|
|
465
474
|
isPipeline: boolean;
|
|
@@ -628,16 +637,6 @@ declare function resolvePinned(matches: SmartRule[]): SmartRule | undefined;
|
|
|
628
637
|
* Returns a reason string if dangerous, null if safe.
|
|
629
638
|
*/
|
|
630
639
|
declare function checkDangerousSql(sql: string): string | null;
|
|
631
|
-
/**
|
|
632
|
-
* Stateless policy evaluation. Same waterfall as the original
|
|
633
|
-
* proxy/src/policy/index.ts:evaluatePolicy, but config + context + I/O
|
|
634
|
-
* hooks come in as parameters so this function works in any host.
|
|
635
|
-
*
|
|
636
|
-
* Returns 'allow' for ignored tools, the matched smart-rule verdict,
|
|
637
|
-
* inline-execution review, eval-detection verdict, pipe-chain verdict,
|
|
638
|
-
* provenance verdict, sandbox allow, dangerous-word review, or strict-mode
|
|
639
|
-
* fallback. See the design doc for the full tier table.
|
|
640
|
-
*/
|
|
641
640
|
declare function evaluatePolicy(config: PolicyConfig, toolName: string, args?: unknown, context?: PolicyContext, hooks?: PolicyHostHooks): Promise<PolicyVerdict>;
|
|
642
641
|
/** Returns true when toolName matches the config's ignoredTools list. */
|
|
643
642
|
declare function isIgnoredTool(toolName: string, config: PolicyConfig): boolean;
|
|
@@ -1439,6 +1438,33 @@ interface SsrfFloorOptions {
|
|
|
1439
1438
|
* those constantly (72 of 308 destinations on measured real history). */
|
|
1440
1439
|
ssrfStrict?: boolean;
|
|
1441
1440
|
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Does this exemption list release this address?
|
|
1443
|
+
*
|
|
1444
|
+
* ONE function, because the shell floor and the declared-URL floor must answer
|
|
1445
|
+
* identically. They had two spellings of it — `normalizeIpLiteral(e) ?? e.trim()
|
|
1446
|
+
* .toLowerCase()` here and `classifySsrf(a)?.normalized ?? a` in
|
|
1447
|
+
* destinations.ts — and for an entry classifySsrf does not match they already
|
|
1448
|
+
* disagreed. Nothing depended on the difference, which is exactly when it is
|
|
1449
|
+
* cheap to remove.
|
|
1450
|
+
*
|
|
1451
|
+
* An entry is an exact address or a CIDR range. The range form exists so an
|
|
1452
|
+
* operator can exempt the mesh-VPN range they actually use (100.64.0.0/10)
|
|
1453
|
+
* instead of listing peers one at a time, which is what the settings panel's
|
|
1454
|
+
* "remove this default" control needs.
|
|
1455
|
+
*
|
|
1456
|
+
* ⚠ This answers "is it in the list", NOT "may it be released". The caller
|
|
1457
|
+
* keeps its `m.overridable` guard, and that guard is per ADDRESS, decided by
|
|
1458
|
+
* classifySsrf before this is consulted. So a range can never release a
|
|
1459
|
+
* protected address inside it: 100.100.100.200 (Alibaba IMDS) sits inside
|
|
1460
|
+
* 100.64.0.0/10 and is named in METADATA_ADDRESSES above the range check, so
|
|
1461
|
+
* it classifies as metadata / overridable:false and stays blocked with the
|
|
1462
|
+
* whole range exempted. Pinned by E2 in exempt-range.spec.ts.
|
|
1463
|
+
*
|
|
1464
|
+
* Never throws: this runs on the hook path for every tool call, and a bad
|
|
1465
|
+
* entry in a hand-edited config must not break every command on the machine.
|
|
1466
|
+
*/
|
|
1467
|
+
declare function ssrfExemptMatches(entries: readonly string[] | undefined, normalized: string | undefined): boolean;
|
|
1442
1468
|
/**
|
|
1443
1469
|
* The floor. Returns the first protected destination, or null.
|
|
1444
1470
|
*
|
|
@@ -1464,6 +1490,25 @@ declare function ssrfFloor(tokens: ReadonlyArray<{
|
|
|
1464
1490
|
* `[]` in a path means "every element of this array".
|
|
1465
1491
|
*/
|
|
1466
1492
|
declare const DESTINATION_ARGS: ReadonlyMap<string, readonly string[]>;
|
|
1493
|
+
/**
|
|
1494
|
+
* The hosts a non-shell tool call will reach, in the SAME shape the shell
|
|
1495
|
+
* extractor produces, so both feed one evaluateEgress (G10).
|
|
1496
|
+
*
|
|
1497
|
+
* Measured 2026-09-20 on dev e748b4a, egress { mode: 'block', allow: [] }:
|
|
1498
|
+
* `curl https://evil.example.com/` denied, the same URL through WebFetch, MCP
|
|
1499
|
+
* fetch and browser navigate allowed. The allowlist was shell-only, and an
|
|
1500
|
+
* agent that wants to exfiltrate does not need curl. The extraction below is
|
|
1501
|
+
* what the floor above already does; the policy was never applied to it.
|
|
1502
|
+
*
|
|
1503
|
+
* Same closed table, same helpers, for the same reason: a tool is covered
|
|
1504
|
+
* because it is LISTED, never because an argument looked like a URL. A tool
|
|
1505
|
+
* not in DESTINATION_ARGS yields [] and is invisible to the egress policy,
|
|
1506
|
+
* exactly as it is invisible to the floor. `binary` carries the tool name so
|
|
1507
|
+
* the reason string and the audit row say which carrier it was.
|
|
1508
|
+
*
|
|
1509
|
+
* Never throws: this runs on the hook path for every tool call.
|
|
1510
|
+
*/
|
|
1511
|
+
declare function extractToolDestinations(toolName: string, args: unknown): Destination[];
|
|
1467
1512
|
interface DestinationHit extends SsrfMatch {
|
|
1468
1513
|
/** The path that carried it, for the message and the audit row. */
|
|
1469
1514
|
argPath: string;
|
|
@@ -1483,4 +1528,4 @@ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
|
|
|
1483
1528
|
/** Engine version stamped on audit entries for future drift detection. */
|
|
1484
1529
|
declare const ENGINE_VERSION = "1.4.0";
|
|
1485
1530
|
|
|
1486
|
-
export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
|
|
1531
|
+
export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COMMAND_WRAPPERS, COPY_VERBS, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type Destination, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, MAX_BLAST_PATH, NET_BINARIES, PATTERN_VERB_NAMES, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type PositionedArg, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, extractToolDestinations, fileOperandFlagsOf, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, patternShapeOf, positionedArgs, previewArgs, redactText, resolvePinned, safeMessage, sampleCopyCommand, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfExemptMatches, ssrfFloor, ssrfReason, stripControlChars, stripTerminalEscapes, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, unwrapCommandHead, validateOverrides, validateRegex, validateShieldDefinition };
|
package/dist/index.js
CHANGED
|
@@ -90,6 +90,7 @@ __export(src_exports, {
|
|
|
90
90
|
extractSessionLevelFindings: () => extractSessionLevelFindings,
|
|
91
91
|
extractShellDestTokens: () => extractShellDestTokens,
|
|
92
92
|
extractShellDestinations: () => extractShellDestinations,
|
|
93
|
+
extractToolDestinations: () => extractToolDestinations,
|
|
93
94
|
fileOperandFlagsOf: () => fileOperandFlagsOf,
|
|
94
95
|
getCompiledRegex: () => getCompiledRegex,
|
|
95
96
|
getNestedValue: () => getNestedValue,
|
|
@@ -122,6 +123,7 @@ __export(src_exports, {
|
|
|
122
123
|
scanText: () => scanText,
|
|
123
124
|
sensitivePathMatch: () => sensitivePathMatch,
|
|
124
125
|
ssrfDestinationFloor: () => ssrfDestinationFloor,
|
|
126
|
+
ssrfExemptMatches: () => ssrfExemptMatches,
|
|
125
127
|
ssrfFloor: () => ssrfFloor,
|
|
126
128
|
ssrfReason: () => ssrfReason,
|
|
127
129
|
stripControlChars: () => stripControlChars,
|
|
@@ -3398,6 +3400,38 @@ function classifySsrf(host) {
|
|
|
3398
3400
|
return null;
|
|
3399
3401
|
}
|
|
3400
3402
|
}
|
|
3403
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
3404
|
+
if (!entries?.length || !normalized) return false;
|
|
3405
|
+
const target = bitsOf(normalized);
|
|
3406
|
+
for (const raw of entries) {
|
|
3407
|
+
const entry = raw.trim().toLowerCase();
|
|
3408
|
+
if (!entry) continue;
|
|
3409
|
+
const slash = entry.indexOf("/");
|
|
3410
|
+
if (slash === -1) {
|
|
3411
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
3412
|
+
continue;
|
|
3413
|
+
}
|
|
3414
|
+
if (!target) continue;
|
|
3415
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
3416
|
+
const prefixText = entry.slice(slash + 1);
|
|
3417
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
3418
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
3419
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
3420
|
+
base.length !== target.length || prefix > base.length) {
|
|
3421
|
+
continue;
|
|
3422
|
+
}
|
|
3423
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
3424
|
+
}
|
|
3425
|
+
return false;
|
|
3426
|
+
}
|
|
3427
|
+
function bitsOf(normalized) {
|
|
3428
|
+
const o = v4Octets(normalized);
|
|
3429
|
+
if (o) {
|
|
3430
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
3431
|
+
}
|
|
3432
|
+
const g = expandIpv6(normalized);
|
|
3433
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
3434
|
+
}
|
|
3401
3435
|
var TIER_REASON = {
|
|
3402
3436
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3403
3437
|
"link-local": "a link-local address",
|
|
@@ -3407,14 +3441,11 @@ var TIER_REASON = {
|
|
|
3407
3441
|
private: "a loopback or private address"
|
|
3408
3442
|
};
|
|
3409
3443
|
function ssrfFloor(tokens, opts = {}) {
|
|
3410
|
-
const exempt = new Set(
|
|
3411
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3412
|
-
);
|
|
3413
3444
|
for (const { token, binary } of tokens) {
|
|
3414
3445
|
const m = classifySsrf(token);
|
|
3415
3446
|
if (!m) continue;
|
|
3416
3447
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3417
|
-
if (m.overridable &&
|
|
3448
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3418
3449
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3419
3450
|
}
|
|
3420
3451
|
return null;
|
|
@@ -3445,18 +3476,18 @@ var DEFAULT_EGRESS_ALLOWLIST = [
|
|
|
3445
3476
|
"*.ubuntu.com"
|
|
3446
3477
|
];
|
|
3447
3478
|
function hostMatches(host, pattern) {
|
|
3448
|
-
const
|
|
3449
|
-
const p = pattern.toLowerCase().trim();
|
|
3479
|
+
const p = pattern.trim().toLowerCase();
|
|
3450
3480
|
if (!p) return false;
|
|
3451
3481
|
if (p === "*") return true;
|
|
3482
|
+
const h = canonicalHost(host);
|
|
3452
3483
|
if (p.startsWith("*.")) {
|
|
3453
|
-
const suffix = p.slice(2);
|
|
3484
|
+
const suffix = canonicalHost(p.slice(2));
|
|
3454
3485
|
return h === suffix || h.endsWith("." + suffix);
|
|
3455
3486
|
}
|
|
3456
|
-
return h === p;
|
|
3487
|
+
return h === canonicalHost(p);
|
|
3457
3488
|
}
|
|
3458
3489
|
function matchesAny(host, patterns) {
|
|
3459
|
-
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
3490
|
+
for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
|
|
3460
3491
|
return false;
|
|
3461
3492
|
}
|
|
3462
3493
|
var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
|
|
@@ -3467,13 +3498,17 @@ function isUniqueLocalV6(host) {
|
|
|
3467
3498
|
return g !== null && (g[0] & 65024) === 64512;
|
|
3468
3499
|
}
|
|
3469
3500
|
function isPrivateHost(host) {
|
|
3470
|
-
const h = host.trim().toLowerCase();
|
|
3501
|
+
const h = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3471
3502
|
const m = classifySsrf(h);
|
|
3472
3503
|
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
3473
3504
|
if (h === "localhost") return true;
|
|
3474
3505
|
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
3475
3506
|
return isUniqueLocalV6(h);
|
|
3476
3507
|
}
|
|
3508
|
+
function canonicalHost(host) {
|
|
3509
|
+
const ip = normalizeIpLiteral(host);
|
|
3510
|
+
return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
|
|
3511
|
+
}
|
|
3477
3512
|
function evaluateEgress(dests, policy) {
|
|
3478
3513
|
if (!policy.enabled) return null;
|
|
3479
3514
|
let review = null;
|
|
@@ -3867,11 +3902,26 @@ function hostOf(value) {
|
|
|
3867
3902
|
return null;
|
|
3868
3903
|
}
|
|
3869
3904
|
}
|
|
3905
|
+
function extractToolDestinations(toolName, args) {
|
|
3906
|
+
try {
|
|
3907
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
3908
|
+
if (!paths) return [];
|
|
3909
|
+
const out = [];
|
|
3910
|
+
for (const path of paths) {
|
|
3911
|
+
for (const value of valuesAt(args, path)) {
|
|
3912
|
+
const host = hostOf(value);
|
|
3913
|
+
if (host) out.push({ host, binary: toolName, raw: value });
|
|
3914
|
+
}
|
|
3915
|
+
}
|
|
3916
|
+
return out;
|
|
3917
|
+
} catch {
|
|
3918
|
+
return [];
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3870
3921
|
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
3871
3922
|
try {
|
|
3872
3923
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
3873
3924
|
if (!paths) return null;
|
|
3874
|
-
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
3875
3925
|
for (const path of paths) {
|
|
3876
3926
|
for (const value of valuesAt(args, path)) {
|
|
3877
3927
|
const host = hostOf(value);
|
|
@@ -3879,7 +3929,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
3879
3929
|
const m = classifySsrf(host);
|
|
3880
3930
|
if (!m) continue;
|
|
3881
3931
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3882
|
-
if (m.overridable &&
|
|
3932
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3883
3933
|
return { ...m, argPath: path, host, reason: ssrfReason(m, host) };
|
|
3884
3934
|
}
|
|
3885
3935
|
}
|
|
@@ -3985,6 +4035,16 @@ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
|
|
|
3985
4035
|
tier: 3
|
|
3986
4036
|
};
|
|
3987
4037
|
}
|
|
4038
|
+
function egressPolicyVerdict(eg) {
|
|
4039
|
+
return {
|
|
4040
|
+
decision: eg.verdict,
|
|
4041
|
+
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4042
|
+
reason: eg.reason,
|
|
4043
|
+
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4044
|
+
ruleDescription: eg.reason,
|
|
4045
|
+
tier: eg.verdict === "block" ? 3 : 4
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
3988
4048
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
3989
4049
|
const { agent, cwd, activeEnvironment } = context;
|
|
3990
4050
|
const { checkProvenance, isTrustedHost } = hooks;
|
|
@@ -4019,7 +4079,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4019
4079
|
};
|
|
4020
4080
|
}
|
|
4021
4081
|
}
|
|
4022
|
-
|
|
4082
|
+
const pendingToolEgress = config.policy.egress?.enabled ? (() => {
|
|
4083
|
+
const dests = extractToolDestinations(toolName, args);
|
|
4084
|
+
const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
|
|
4085
|
+
return eg ? egressPolicyVerdict(eg) : void 0;
|
|
4086
|
+
})() : void 0;
|
|
4087
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) {
|
|
4088
|
+
return pendingToolEgress ?? { decision: "allow" };
|
|
4089
|
+
}
|
|
4023
4090
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
4024
4091
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
4025
4092
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
@@ -4108,6 +4175,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4108
4175
|
}
|
|
4109
4176
|
let allTokens = [];
|
|
4110
4177
|
let pathTokens = [];
|
|
4178
|
+
if (pendingToolEgress) return pendingToolEgress;
|
|
4111
4179
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
4112
4180
|
if (shellCommand) {
|
|
4113
4181
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
@@ -4173,16 +4241,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4173
4241
|
const dests = extractShellDestinations(shellCommand);
|
|
4174
4242
|
if (dests.length > 0) {
|
|
4175
4243
|
const eg = evaluateEgress(dests, config.policy.egress);
|
|
4176
|
-
if (eg)
|
|
4177
|
-
return {
|
|
4178
|
-
decision: eg.verdict,
|
|
4179
|
-
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4180
|
-
reason: eg.reason,
|
|
4181
|
-
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4182
|
-
ruleDescription: eg.reason,
|
|
4183
|
-
tier: eg.verdict === "block" ? 3 : 4
|
|
4184
|
-
};
|
|
4185
|
-
}
|
|
4244
|
+
if (eg) return egressPolicyVerdict(eg);
|
|
4186
4245
|
}
|
|
4187
4246
|
}
|
|
4188
4247
|
const firstToken = analyzed.actions[0] ?? "";
|
|
@@ -6106,6 +6165,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
6106
6165
|
extractSessionLevelFindings,
|
|
6107
6166
|
extractShellDestTokens,
|
|
6108
6167
|
extractShellDestinations,
|
|
6168
|
+
extractToolDestinations,
|
|
6109
6169
|
fileOperandFlagsOf,
|
|
6110
6170
|
getCompiledRegex,
|
|
6111
6171
|
getNestedValue,
|
|
@@ -6138,6 +6198,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
6138
6198
|
scanText,
|
|
6139
6199
|
sensitivePathMatch,
|
|
6140
6200
|
ssrfDestinationFloor,
|
|
6201
|
+
ssrfExemptMatches,
|
|
6141
6202
|
ssrfFloor,
|
|
6142
6203
|
ssrfReason,
|
|
6143
6204
|
stripControlChars,
|
package/dist/index.mjs
CHANGED
|
@@ -3258,6 +3258,38 @@ function classifySsrf(host) {
|
|
|
3258
3258
|
return null;
|
|
3259
3259
|
}
|
|
3260
3260
|
}
|
|
3261
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
3262
|
+
if (!entries?.length || !normalized) return false;
|
|
3263
|
+
const target = bitsOf(normalized);
|
|
3264
|
+
for (const raw of entries) {
|
|
3265
|
+
const entry = raw.trim().toLowerCase();
|
|
3266
|
+
if (!entry) continue;
|
|
3267
|
+
const slash = entry.indexOf("/");
|
|
3268
|
+
if (slash === -1) {
|
|
3269
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
3270
|
+
continue;
|
|
3271
|
+
}
|
|
3272
|
+
if (!target) continue;
|
|
3273
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
3274
|
+
const prefixText = entry.slice(slash + 1);
|
|
3275
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
3276
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
3277
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
3278
|
+
base.length !== target.length || prefix > base.length) {
|
|
3279
|
+
continue;
|
|
3280
|
+
}
|
|
3281
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
3282
|
+
}
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3285
|
+
function bitsOf(normalized) {
|
|
3286
|
+
const o = v4Octets(normalized);
|
|
3287
|
+
if (o) {
|
|
3288
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
3289
|
+
}
|
|
3290
|
+
const g = expandIpv6(normalized);
|
|
3291
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
3292
|
+
}
|
|
3261
3293
|
var TIER_REASON = {
|
|
3262
3294
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3263
3295
|
"link-local": "a link-local address",
|
|
@@ -3267,14 +3299,11 @@ var TIER_REASON = {
|
|
|
3267
3299
|
private: "a loopback or private address"
|
|
3268
3300
|
};
|
|
3269
3301
|
function ssrfFloor(tokens, opts = {}) {
|
|
3270
|
-
const exempt = new Set(
|
|
3271
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3272
|
-
);
|
|
3273
3302
|
for (const { token, binary } of tokens) {
|
|
3274
3303
|
const m = classifySsrf(token);
|
|
3275
3304
|
if (!m) continue;
|
|
3276
3305
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3277
|
-
if (m.overridable &&
|
|
3306
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3278
3307
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3279
3308
|
}
|
|
3280
3309
|
return null;
|
|
@@ -3305,18 +3334,18 @@ var DEFAULT_EGRESS_ALLOWLIST = [
|
|
|
3305
3334
|
"*.ubuntu.com"
|
|
3306
3335
|
];
|
|
3307
3336
|
function hostMatches(host, pattern) {
|
|
3308
|
-
const
|
|
3309
|
-
const p = pattern.toLowerCase().trim();
|
|
3337
|
+
const p = pattern.trim().toLowerCase();
|
|
3310
3338
|
if (!p) return false;
|
|
3311
3339
|
if (p === "*") return true;
|
|
3340
|
+
const h = canonicalHost(host);
|
|
3312
3341
|
if (p.startsWith("*.")) {
|
|
3313
|
-
const suffix = p.slice(2);
|
|
3342
|
+
const suffix = canonicalHost(p.slice(2));
|
|
3314
3343
|
return h === suffix || h.endsWith("." + suffix);
|
|
3315
3344
|
}
|
|
3316
|
-
return h === p;
|
|
3345
|
+
return h === canonicalHost(p);
|
|
3317
3346
|
}
|
|
3318
3347
|
function matchesAny(host, patterns) {
|
|
3319
|
-
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
3348
|
+
for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
|
|
3320
3349
|
return false;
|
|
3321
3350
|
}
|
|
3322
3351
|
var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
|
|
@@ -3327,13 +3356,17 @@ function isUniqueLocalV6(host) {
|
|
|
3327
3356
|
return g !== null && (g[0] & 65024) === 64512;
|
|
3328
3357
|
}
|
|
3329
3358
|
function isPrivateHost(host) {
|
|
3330
|
-
const h = host.trim().toLowerCase();
|
|
3359
|
+
const h = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3331
3360
|
const m = classifySsrf(h);
|
|
3332
3361
|
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
3333
3362
|
if (h === "localhost") return true;
|
|
3334
3363
|
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
3335
3364
|
return isUniqueLocalV6(h);
|
|
3336
3365
|
}
|
|
3366
|
+
function canonicalHost(host) {
|
|
3367
|
+
const ip = normalizeIpLiteral(host);
|
|
3368
|
+
return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
|
|
3369
|
+
}
|
|
3337
3370
|
function evaluateEgress(dests, policy) {
|
|
3338
3371
|
if (!policy.enabled) return null;
|
|
3339
3372
|
let review = null;
|
|
@@ -3727,11 +3760,26 @@ function hostOf(value) {
|
|
|
3727
3760
|
return null;
|
|
3728
3761
|
}
|
|
3729
3762
|
}
|
|
3763
|
+
function extractToolDestinations(toolName, args) {
|
|
3764
|
+
try {
|
|
3765
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
3766
|
+
if (!paths) return [];
|
|
3767
|
+
const out = [];
|
|
3768
|
+
for (const path of paths) {
|
|
3769
|
+
for (const value of valuesAt(args, path)) {
|
|
3770
|
+
const host = hostOf(value);
|
|
3771
|
+
if (host) out.push({ host, binary: toolName, raw: value });
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
return out;
|
|
3775
|
+
} catch {
|
|
3776
|
+
return [];
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3730
3779
|
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
3731
3780
|
try {
|
|
3732
3781
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
3733
3782
|
if (!paths) return null;
|
|
3734
|
-
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
3735
3783
|
for (const path of paths) {
|
|
3736
3784
|
for (const value of valuesAt(args, path)) {
|
|
3737
3785
|
const host = hostOf(value);
|
|
@@ -3739,7 +3787,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
3739
3787
|
const m = classifySsrf(host);
|
|
3740
3788
|
if (!m) continue;
|
|
3741
3789
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3742
|
-
if (m.overridable &&
|
|
3790
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
3743
3791
|
return { ...m, argPath: path, host, reason: ssrfReason(m, host) };
|
|
3744
3792
|
}
|
|
3745
3793
|
}
|
|
@@ -3845,6 +3893,16 @@ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
|
|
|
3845
3893
|
tier: 3
|
|
3846
3894
|
};
|
|
3847
3895
|
}
|
|
3896
|
+
function egressPolicyVerdict(eg) {
|
|
3897
|
+
return {
|
|
3898
|
+
decision: eg.verdict,
|
|
3899
|
+
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
3900
|
+
reason: eg.reason,
|
|
3901
|
+
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
3902
|
+
ruleDescription: eg.reason,
|
|
3903
|
+
tier: eg.verdict === "block" ? 3 : 4
|
|
3904
|
+
};
|
|
3905
|
+
}
|
|
3848
3906
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
3849
3907
|
const { agent, cwd, activeEnvironment } = context;
|
|
3850
3908
|
const { checkProvenance, isTrustedHost } = hooks;
|
|
@@ -3879,7 +3937,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3879
3937
|
};
|
|
3880
3938
|
}
|
|
3881
3939
|
}
|
|
3882
|
-
|
|
3940
|
+
const pendingToolEgress = config.policy.egress?.enabled ? (() => {
|
|
3941
|
+
const dests = extractToolDestinations(toolName, args);
|
|
3942
|
+
const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
|
|
3943
|
+
return eg ? egressPolicyVerdict(eg) : void 0;
|
|
3944
|
+
})() : void 0;
|
|
3945
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) {
|
|
3946
|
+
return pendingToolEgress ?? { decision: "allow" };
|
|
3947
|
+
}
|
|
3883
3948
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
3884
3949
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
3885
3950
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
@@ -3968,6 +4033,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3968
4033
|
}
|
|
3969
4034
|
let allTokens = [];
|
|
3970
4035
|
let pathTokens = [];
|
|
4036
|
+
if (pendingToolEgress) return pendingToolEgress;
|
|
3971
4037
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
3972
4038
|
if (shellCommand) {
|
|
3973
4039
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
@@ -4033,16 +4099,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4033
4099
|
const dests = extractShellDestinations(shellCommand);
|
|
4034
4100
|
if (dests.length > 0) {
|
|
4035
4101
|
const eg = evaluateEgress(dests, config.policy.egress);
|
|
4036
|
-
if (eg)
|
|
4037
|
-
return {
|
|
4038
|
-
decision: eg.verdict,
|
|
4039
|
-
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4040
|
-
reason: eg.reason,
|
|
4041
|
-
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4042
|
-
ruleDescription: eg.reason,
|
|
4043
|
-
tier: eg.verdict === "block" ? 3 : 4
|
|
4044
|
-
};
|
|
4045
|
-
}
|
|
4102
|
+
if (eg) return egressPolicyVerdict(eg);
|
|
4046
4103
|
}
|
|
4047
4104
|
}
|
|
4048
4105
|
const firstToken = analyzed.actions[0] ?? "";
|
|
@@ -5965,6 +6022,7 @@ export {
|
|
|
5965
6022
|
extractSessionLevelFindings,
|
|
5966
6023
|
extractShellDestTokens,
|
|
5967
6024
|
extractShellDestinations,
|
|
6025
|
+
extractToolDestinations,
|
|
5968
6026
|
fileOperandFlagsOf,
|
|
5969
6027
|
getCompiledRegex,
|
|
5970
6028
|
getNestedValue,
|
|
@@ -5997,6 +6055,7 @@ export {
|
|
|
5997
6055
|
scanText,
|
|
5998
6056
|
sensitivePathMatch,
|
|
5999
6057
|
ssrfDestinationFloor,
|
|
6058
|
+
ssrfExemptMatches,
|
|
6000
6059
|
ssrfFloor,
|
|
6001
6060
|
ssrfReason,
|
|
6002
6061
|
stripControlChars,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/policy-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.18.0",
|
|
4
4
|
"description": "Shared policy evaluation engine for node9 — DLP, smart rules, AST shell parsing, shields, loop detection. Pure functions, no I/O. Used by both node9-proxy and the node9 SaaS firewall.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://node9.ai",
|