From fb23fe6d9f5ac4b32fb9c3295aa185474e420012 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 1 Aug 2026 02:34:13 +0900 Subject: [PATCH 1/4] Fix safety audit semantics (#4911) --- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 28 +- changelog.d/unreleased/4911.fixed.md | 19 ++ .../Cli/QueryCommandRunner.SearchRecipes.cs | 298 ++++++++++++++++++ src/CodeIndex/Cli/SearchAuditRecipes.cs | 49 ++- .../QueryCommandRunnerSearchTests.cs | 260 ++++++++++++++- 6 files changed, 640 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/4911.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cadef907e..eb52b679b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -195,6 +195,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. Status-explain coverage must enumerate the source-generated `StatusResult` serializer properties and require every serialized top-level key to succeed without opening a database. Keep major readiness/trust/extension/maintenance/cap-hit metadata assertions, dotted-member resolution, bounded valid unknown candidates, and redaction of hostile field input in the same focused fixture so serialization and explainability cannot drift independently. Keep every structured explain response key registered for status `--fields` projection, and exercise the response through a bounded JSON projection that proves the outer envelope also omits runtime and path metadata. + Safety-recipe semantic coverage must keep safe and unsafe controls in separate indexed files: assert that `Regex.Escape` / `Regex.Unescape` and literal `UseShellExecute=false` are absent, while matching or unresolved Regex members, literal `true`, and propagated shell-policy values remain with classification evidence. Keep count output aligned with full JSON and retain a separate `new Regex` construction positive. Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. Search-to-find recovery coverage must exercise `--regex` and `--all` in human and JSON modes, option-shaped and shell-sensitive query values, normalized path/language/exclusion/limit arguments, and explicit non-equivalent or unsafe cases. Assert typed argv plus POSIX-shell and PowerShell displays, absence of implicit workspace defaults, and the no-execution markers; an unmappable request must return no alternative command. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. @@ -1148,6 +1149,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 status explain の coverage は source-generated `StatusResult` serializer property を列挙し、database を開かずに serialized top-level key がすべて成功することを必須にします。主要な readiness / trust / extension / maintenance / cap-hit metadata、dot 区切り member resolution、unknown key に対する上限付きの有効な candidate、悪意ある field input の redaction を同じ focused fixture に置き、serialization と explainability が独立して drift しないようにしてください。structured explain response の全 key を status の `--fields` projection に登録し、outer envelope からも runtime / path metadata が省略されることを上限付き JSON projection で固定してください。 + safety recipe の意味論 coverage では、安全側と危険側の control を別々の indexed file に置いてください。`Regex.Escape` / `Regex.Unescape` と literal `UseShellExecute=false` が除外され、matching または解決不能な Regex member、literal `true`、伝播された shell-policy 値が classification evidence 付きで残ることを検証します。count 出力を full JSON と一致させ、`new Regex` construction の正例は別に維持してください。 graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 search から find への recovery coverage では、human / JSON mode の `--regex` と `--all`、option 風および shell-sensitive な query 値、正規化した path / language / exclude / limit 引数、明示的に非等価または unsafe な case を検証してください。型付き argv と POSIX shell / PowerShell 表示、暗黙の workspace default が含まれないこと、未実行 marker を固定し、対応不能な request が代替 command を返さないことを必須にします。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 74b0ca22a..5b694a52a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1410,15 +1410,22 @@ issue-draft export and downstream triage tools can keep the reviewer guidance next to the evidence path. For example, `classifiers` describe the triage dimensions that downstream tools should use, such as `source_origin`, `guard_evidence`, `secret_origin`, -`parser_guard_evidence`, `process_launch_boundary`, `cancellation_intent`, -`task_result_intent`, `active_skip_governance`, `broad_catch_boundary`, and -`diagnostic_redaction`; each classifier lists categories, evidence fields, and -guidance so noisy audit terms can be separated before filing. +`parser_guard_evidence`, `process_launch_boundary`, `regex_operation_semantics`, +`shell_execute_polarity`, `cancellation_intent`, `task_result_intent`, +`active_skip_governance`, `broad_catch_boundary`, and `diagnostic_redaction`; +each classifier lists categories, evidence fields, and guidance so noisy audit +terms can be separated before filing. `dogfood-risk-patterns` includes process-launch boundary child queries for `ProcessStartInfo`, `Process.Start`, `ArgumentList`, `UseShellExecute`, working-directory choices, stdout/stderr redirection, waits, termination, shared launch/environment policies, and broad plugin/hook/trust-override discovery terms. +The `static-regex-api*` children inspect the matched `Regex` member: exact +`Escape` / `Unescape` helpers are suppressed, while matching operations and +unresolved receiver/member evidence remain findings. `process-shell-execute` +similarly suppresses only a direct literal `UseShellExecute=false` assignment; +literal `true` and propagated or otherwise unresolved values remain findings +with semantic classification evidence. `risky-code/broad-exception-catch` includes broad-catch boundary categories and expected diagnostic behaviors so users can distinguish intentional top-level, cleanup, probe, diagnostic-sanitization, and worker boundaries from catches that @@ -4801,14 +4808,19 @@ facet の短い一覧です。recipe run の JSON は各 matching result にも issue-draft export や下流の triage tool が evidence path の近くに reviewer guidance を 保持できます。`classifiers` は下流 tool が使うべき triage の軸を表し、 `source_origin`、`guard_evidence`、`secret_origin`、`parser_guard_evidence`、 -`process_launch_boundary`、`cancellation_intent`、`task_result_intent`、 -`active_skip_governance`、`broad_catch_boundary`、`diagnostic_redaction` などの -classifier が category、evidence field、guidance を持つため、ノイズの多い audit term を -起票前に切り分けられます。 +`process_launch_boundary`、`regex_operation_semantics`、`shell_execute_polarity`、 +`cancellation_intent`、`task_result_intent`、`active_skip_governance`、 +`broad_catch_boundary`、`diagnostic_redaction` などの classifier が category、 +evidence field、guidance を持つため、ノイズの多い audit term を起票前に切り分けられます。 `dogfood-risk-patterns` は `ProcessStartInfo`、`Process.Start`、`ArgumentList`、 `UseShellExecute`、working-directory 選択、stdout/stderr redirection、wait、 termination、共有 launch/environment policy、広めの plugin/hook/trust-override discovery 用語を process-launch boundary の child query として含みます。 +`static-regex-api*` child は一致した `Regex` member を判定し、厳密な `Escape` / `Unescape` +helper を除外する一方、matching operation と receiver/member を解決できない evidence は +finding として残します。`process-shell-execute` も、直接の literal +`UseShellExecute=false` 代入だけを除外します。literal `true` と、伝播またはその他の理由で +解決できない値は、意味論的な分類 evidence を伴う finding として残ります。 たとえば `risky-code/broad-exception-catch` は broad catch の境界カテゴリと期待される diagnostic behavior を含めるため、意図的な top-level、cleanup、probe、diagnostic-sanitization、worker 境界と、narrowing または diff --git a/changelog.d/unreleased/4911.fixed.md b/changelog.d/unreleased/4911.fixed.md new file mode 100644 index 000000000..91a60668e --- /dev/null +++ b/changelog.d/unreleased/4911.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 4911 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Safety audits now distinguish safe helper operations and negative policy values (#4911)** — the dogfood static-Regex audit suppresses exact `Regex.Escape` / `Regex.Unescape` helpers without hiding pattern execution or unresolved members, while the shell-execution audit suppresses direct literal `UseShellExecute=false` assignments and retains `true` or propagated values. Retained findings include semantic classification evidence for the matched operation or value. + +## 日本語 + +- **安全性 audit が安全な helper 操作と否定 policy 値を区別するようになりました (#4911)** — dogfood の static Regex audit は厳密な `Regex.Escape` / `Regex.Unescape` helper を除外しつつ、pattern 実行と解決不能な member を検出に残します。shell-execution audit は直接の literal `UseShellExecute=false` 代入を除外し、`true` または伝播された値を残します。残った finding には、一致した操作または値の意味論的な分類 evidence が含まれます。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 9cbeeaa51..deb8f346f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1953,6 +1953,7 @@ private static List CollectSearchRecipeQueryR fetchLimit); results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); + rows = ApplySearchRecipeSemanticFilter(reader, options, recipeQuery, rows); var outputSelection = ApplySearchOutputSelection(rows, options, resultLimit, sourceTotalAuthoritative); rows = outputSelection.Rows; ApplySearchRecipeAuditClassifications(recipeQuery, rows); @@ -2054,6 +2055,7 @@ private static List CollectSearchRecip fetchLimit); results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, recipeQuery: recipeQuery); + rows = ApplySearchRecipeSemanticFilter(reader, options, recipeQuery, rows); var outputSelection = ApplySearchOutputSelection(rows, options, resultLimit, sourceTotalAuthoritative); rows = outputSelection.Rows; ApplySearchRecipeAuditClassifications(recipeQuery, rows); @@ -2129,6 +2131,7 @@ private static bool IsSearchRecipeSourceTotalAuthoritative( int fetchLimit) => guardFilters.Count == 0 && recipeQuery.RejectFileQueries.Count == 0 + && recipeQuery.SemanticFilter == SearchRecipeSemanticFilter.None && !HasSearchOriginFilters(BuildSearchDisplayFacetFilters(options, recipeQuery)) && resultCount < fetchLimit; @@ -2177,6 +2180,7 @@ private static List CountSearchRecipeQueryResu requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); + rows = ApplySearchRecipeSemanticFilter(reader, options, recipeQuery, rows); ApplySearchRecipeAuditClassifications(recipeQuery, rows); var count = rows.Count; var fileCountForQuery = rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); @@ -2221,6 +2225,299 @@ private static void ApplySearchRecipeAuditClassifications(SearchAuditRecipeQuery } } + private static List ApplySearchRecipeSemanticFilter( + DbReader reader, + QueryCommandOptions options, + SearchAuditRecipeQuery recipeQuery, + List rows) + { + if (recipeQuery.SemanticFilter == SearchRecipeSemanticFilter.None) + return rows; + + var classifierName = recipeQuery.SemanticFilter switch + { + SearchRecipeSemanticFilter.RegexStaticMember => "regex_operation_semantics", + SearchRecipeSemanticFilter.ShellExecuteAssignment => "shell_execute_polarity", + _ => null, + }; + var classifier = classifierName == null + ? null + : recipeQuery.Classifiers.FirstOrDefault(candidate => string.Equals(candidate.Name, classifierName, StringComparison.Ordinal)); + if (classifier == null) + return rows; + + var retained = new List(rows.Count); + var regexAliasPaths = new Dictionary(StringComparer.Ordinal); + foreach (var row in rows) + { + var evidence = recipeQuery.SemanticFilter switch + { + SearchRecipeSemanticFilter.RegexStaticMember => GetRegexOperationEvidence( + row, + HasRegexAliasDeclaration(reader, options, row.Result.Path, regexAliasPaths)), + SearchRecipeSemanticFilter.ShellExecuteAssignment => GetShellExecuteAssignmentEvidence(row), + _ => null, + }; + if (evidence?.Suppress == true) + continue; + + if (evidence != null) + { + var classification = BuildSearchRecipeSemanticClassification(classifier, evidence, row); + if (classification != null) + { + row.Compact.AuditClassifications ??= []; + row.Compact.AuditClassifications.Add(classification); + } + } + + retained.Add(row); + } + + return retained; + } + + private static SearchAuditClassificationJsonResult? BuildSearchRecipeSemanticClassification( + SearchRecipeClassifierJsonResult classifier, + SearchRecipeSemanticEvidence evidence, + SearchDisplayRow row) + { + var categoryMetadata = classifier.Categories + .FirstOrDefault(category => string.Equals(category.Name, evidence.Category, StringComparison.Ordinal)); + if (categoryMetadata == null) + return null; + + var details = new List + { + $"reason:{evidence.Reason}", + }; + if (!string.IsNullOrWhiteSpace(evidence.Operation)) + details.Add($"operation:{evidence.Operation}"); + if (!string.IsNullOrWhiteSpace(evidence.Value)) + details.Add($"value:{evidence.Value}"); + if (evidence.Line.HasValue) + details.Add($"line:{evidence.Line.Value.ToString(CultureInfo.InvariantCulture)}"); + if (!string.IsNullOrWhiteSpace(row.Compact.EnclosingSymbolName)) + details.Add($"enclosing_symbol_name:{row.Compact.EnclosingSymbolName}"); + if (!string.IsNullOrWhiteSpace(row.Compact.EnclosingSymbolKind)) + details.Add($"enclosing_symbol_kind:{row.Compact.EnclosingSymbolKind}"); + + return new SearchAuditClassificationJsonResult( + classifier.Name, + categoryMetadata.Name, + categoryMetadata.Description, + categoryMetadata.ReviewGuidance, + details); + } + + private static SearchRecipeSemanticEvidence GetRegexOperationEvidence(SearchDisplayRow row, bool hasRegexAliasDeclaration) + { + var operations = new List(); + int? firstLine = null; + var foundRiskOperation = false; + var foundUnresolvedOperation = false; + var foundSafeOperation = false; + + foreach (var (text, line) in GetSemanticEvidenceLines(row, "Regex.")) + { + var searchFrom = 0; + while (searchFrom < text.Length) + { + var regexIndex = text.IndexOf("Regex.", searchFrom, StringComparison.Ordinal); + if (regexIndex < 0) + break; + searchFrom = regexIndex + "Regex.".Length; + firstLine ??= line; + + var receiver = ExtractRegexReceiver(text, regexIndex); + var member = ExtractIdentifier(text, searchFrom); + operations.Add(string.IsNullOrWhiteSpace(member) ? receiver : $"{receiver}.{member}"); + if (!IsSystemRegexReceiver(receiver) + || (hasRegexAliasDeclaration && string.Equals(receiver, "Regex", StringComparison.Ordinal)) + || string.IsNullOrWhiteSpace(member)) + { + foundUnresolvedOperation = true; + continue; + } + + if (member is "Escape" or "Unescape") + { + foundSafeOperation = true; + continue; + } + + if (member is "IsMatch" or "Match" or "Matches" or "Replace" or "Split" or "EnumerateMatches" or "Count") + foundRiskOperation = true; + else + foundUnresolvedOperation = true; + } + } + + var operation = string.Join(",", operations.Distinct(StringComparer.Ordinal)); + if (foundRiskOperation) + return new SearchRecipeSemanticEvidence(false, "regex_pattern_operation", "matched_pattern_operation", operation, null, firstLine); + if (foundUnresolvedOperation || !foundSafeOperation) + return new SearchRecipeSemanticEvidence(false, "regex_operation_unresolved", "receiver_or_member_not_proven_safe", operation, null, firstLine); + return new SearchRecipeSemanticEvidence(true, "safe_escape_helper", "escape_helper_does_not_execute_pattern", operation, null, firstLine); + } + + private static string ExtractRegexReceiver(string text, int regexIndex) + { + var start = regexIndex; + while (start > 0 && IsQualifiedIdentifierCharacter(text[start - 1])) + start--; + return text[start..(regexIndex + "Regex".Length)].Trim('.'); + } + + private static bool IsSystemRegexReceiver(string receiver) + => string.Equals(receiver, "Regex", StringComparison.Ordinal) + || string.Equals(receiver, "System.Text.RegularExpressions.Regex", StringComparison.Ordinal) + || string.Equals(receiver, "global::System.Text.RegularExpressions.Regex", StringComparison.Ordinal); + + private static bool HasRegexAliasDeclaration( + DbReader reader, + QueryCommandOptions options, + string path, + Dictionary aliasPaths) + { + if (aliasPaths.TryGetValue(path, out var hasAlias)) + return hasAlias; + + hasAlias = new[] { "using Regex =", "using Regex=" } + .Any(aliasQuery => reader.Search( + aliasQuery, + 1, + options.Lang, + rawQuery: false, + pathPatterns: [path], + excludePathPatterns: null, + excludeTests: false, + deduplicate: true, + since: options.Since, + exact: true, + prefix: false, + visibilityRank: false).Count > 0); + aliasPaths[path] = hasAlias; + return hasAlias; + } + + private static bool IsQualifiedIdentifierCharacter(char value) + => char.IsLetterOrDigit(value) || value is '_' or '.' or ':'; + + private static string ExtractIdentifier(string text, int start) + { + var end = start; + while (end < text.Length && (char.IsLetterOrDigit(text[end]) || text[end] == '_')) + end++; + return text[start..end]; + } + + private static SearchRecipeSemanticEvidence GetShellExecuteAssignmentEvidence(SearchDisplayRow row) + { + var values = new List(); + int? firstLine = null; + var foundFalse = false; + var foundTrue = false; + var foundUnresolved = false; + + foreach (var (text, line) in GetSemanticEvidenceLines(row, "UseShellExecute")) + { + var searchFrom = 0; + while (searchFrom < text.Length) + { + var propertyIndex = text.IndexOf("UseShellExecute", searchFrom, StringComparison.Ordinal); + if (propertyIndex < 0) + break; + searchFrom = propertyIndex + "UseShellExecute".Length; + if ((propertyIndex > 0 && IsIdentifierCharacter(text[propertyIndex - 1])) + || (searchFrom < text.Length && IsIdentifierCharacter(text[searchFrom]))) + continue; + + firstLine ??= line; + var value = ExtractAssignedBooleanLiteral(text, searchFrom); + values.Add(value ?? "unresolved"); + if (string.Equals(value, "false", StringComparison.Ordinal)) + foundFalse = true; + else if (string.Equals(value, "true", StringComparison.Ordinal)) + foundTrue = true; + else + foundUnresolved = true; + } + } + + var valueEvidence = string.Join(",", values.Distinct(StringComparer.Ordinal)); + if (foundTrue) + return new SearchRecipeSemanticEvidence(false, "shell_explicitly_enabled", "literal_true_enables_shell", "UseShellExecute", valueEvidence, firstLine); + if (foundUnresolved || !foundFalse) + return new SearchRecipeSemanticEvidence(false, "shell_policy_unresolved", "assigned_value_not_literal_boolean", "UseShellExecute", valueEvidence, firstLine); + return new SearchRecipeSemanticEvidence(true, "shell_explicitly_disabled", "literal_false_disables_shell", "UseShellExecute", valueEvidence, firstLine); + } + + private static string? ExtractAssignedBooleanLiteral(string text, int start) + { + var cursor = start; + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) + cursor++; + if (cursor >= text.Length || (text[cursor] != '=' && text[cursor] != ':')) + return null; + if (text[cursor] == '=' && cursor + 1 < text.Length && text[cursor + 1] is '=' or '>') + return null; + + cursor++; + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) + cursor++; + var value = ExtractIdentifier(text, cursor); + if (value is not ("false" or "true")) + return null; + + cursor += value.Length; + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) + cursor++; + if (cursor == text.Length + || text[cursor] is ',' or ';' or '}' or ')' or ']' + || (cursor + 1 < text.Length && text[cursor] == '/' && text[cursor + 1] is '/' or '*')) + return value; + return null; + } + + private static bool IsIdentifierCharacter(char value) + => char.IsLetterOrDigit(value) || value == '_'; + + private static IEnumerable<(string Text, int? Line)> GetSemanticEvidenceLines(SearchDisplayRow row, string marker) + { + var contentLines = row.Result.Content.Split('\n', StringSplitOptions.None); + var matchedContentLines = row.Compact.MatchFacets + .Select(facet => facet.Line) + .Distinct() + .Order() + .Where(line => line >= row.Result.StartLine && line - row.Result.StartLine < contentLines.Length) + .Select(line => (Text: contentLines[line - row.Result.StartLine].TrimEnd('\r'), Line: (int?)line)) + .Where(item => item.Text.Contains(marker, StringComparison.Ordinal)) + .ToList(); + if (matchedContentLines.Count > 0) + return matchedContentLines; + + var highlights = row.Compact.Highlights + .Where(highlight => highlight.Text.Contains(marker, StringComparison.Ordinal)) + .Select(highlight => (highlight.Text, (int?)highlight.Line)) + .ToList(); + if (highlights.Count > 0) + return highlights; + + return row.Compact.Snippet + .Split('\n', StringSplitOptions.None) + .Where(line => line.Contains(marker, StringComparison.Ordinal)) + .Select(line => (line, (int?)null)); + } + + private sealed record SearchRecipeSemanticEvidence( + bool Suppress, + string Category, + string Reason, + string Operation, + string? Value, + int? Line); + private static SearchAuditClassificationJsonResult? TryClassifyTaskResultIntent( SearchRecipeClassifierJsonResult classifier, SearchDisplayRow row) @@ -2460,6 +2757,7 @@ private static List CollectSearchRecipeA requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); + rows = ApplySearchRecipeSemanticFilter(reader, options, recipeQuery, rows); foreach (var path in rows.Select(row => row.Result.Path)) paths.Add(path); diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 9d866dca6..7683fda6b 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -249,6 +249,26 @@ internal static class SearchAuditRecipes ], ["path", "enclosing_symbol_name", "risk_evidence", "guard_evidence", "guard_checks"], "Treat process-launch results as trust-boundary evidence; prefer ProcessLaunchPolicy/SubprocessEnvironmentPolicy or nearby purpose-specific wrappers."); + private static readonly SearchRecipeClassifierJsonResult RegexOperationClassifier = new( + "regex_operation_semantics", + "Classifies the matched System.Text.RegularExpressions.Regex member by whether it escapes text, executes a pattern, or cannot be resolved conservatively.", + [ + new("safe_escape_helper", "The matched Regex.Escape or Regex.Unescape helper transforms text without executing or compiling a pattern.", "This category is suppressed from audit findings."), + new("regex_pattern_operation", "The matched Regex member performs matching, replacement, splitting, or another pattern operation.", "Review timeout policy, pattern trust, and input bounds."), + new("regex_operation_unresolved", "The matched receiver or member could not be proven to be a safe escaping helper.", "Keep aliases, qualifications, truncated snippets, and unknown members for manual review.") + ], + ["path", "enclosing_symbol_name", "audit_classifications.evidence", "risk_evidence", "match_origins"], + "Only exact Regex.Escape and Regex.Unescape operations are suppressed; execution members and unresolved receiver/member evidence remain findings."); + private static readonly SearchRecipeClassifierJsonResult ShellExecutePolarityClassifier = new( + "shell_execute_polarity", + "Classifies UseShellExecute assignments by their literal boolean polarity while preserving unresolved value flows for review.", + [ + new("shell_explicitly_disabled", "The matched assignment uses the literal false value and explicitly disables shell execution.", "This category is suppressed from audit findings."), + new("shell_explicitly_enabled", "The matched assignment uses the literal true value and enables shell execution.", "Review command, argument, environment, and file-association trust boundaries."), + new("shell_policy_unresolved", "The assigned value is propagated, computed, truncated, or otherwise not a literal boolean.", "Retain the finding until the no-shell policy can be proven at the assignment boundary.") + ], + ["path", "enclosing_symbol_name", "audit_classifications.evidence", "risk_evidence", "match_origins"], + "Only a direct literal false assignment is suppressed; true and unresolved value propagation remain findings."); private static readonly SearchRecipeClassifierJsonResult CancellationIntentClassifier = new( "cancellation_intent", "Classifies cancellation-token hits by compatibility wrapper, short-lived probe, or long-running operation risk.", @@ -489,7 +509,7 @@ private static SearchAuditRecipeQuery DogfoodStaticRegexApiQuery(string name, st query, $"Find raw static Regex API usage candidates with {shape} so bounded instance names are not counted.", ["audit", "performance", "security"], - "False positives include Regex.Escape/Unescape, explicit timeout overloads, generated/precompiled patterns, trusted small inputs, and tests that intentionally exercise raw Regex behavior.") + "Regex.Escape/Unescape are suppressed by matched-member semantics. Review retained execution members and unresolved receiver/member evidence for explicit timeout, generated/precompiled patterns, trusted small inputs, or intentional test behavior.") { RejectFileQueries = [ @@ -500,10 +520,13 @@ private static SearchAuditRecipeQuery DogfoodStaticRegexApiQuery(string name, st [ "risk: raw System.Text.RegularExpressions.Regex static APIs can run without explicit timeout or shared bounded-regex policy.", "risk: classify each pattern by trust boundary: user input, config/env input, repository-controlled patterns, test fixtures, or generated diagnostics.", - "positive: BoundedRegex aliases and instance names ending in Regex are filtered out; remaining hits should be classified as timeout-backed, generated/precompiled, trusted small input, or non-matching helpers such as Escape." + "positive: exact Regex.Escape and Regex.Unescape helpers do not execute or compile patterns and are suppressed by matched-member semantics.", + "risk: matching/execution members and unresolved alias or qualification evidence remain findings until timeout and trust boundaries are proven." ], GuardFilters = BoundedRegexEvidenceGuardFilters(), MatchOrigins = ["code"], + Classifiers = [RegexOperationClassifier], + SemanticFilter = SearchRecipeSemanticFilter.RegexStaticMember, }; private static readonly string[] TimestampBoundaryRiskEvidence = @@ -1258,6 +1281,10 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( "static-regex-api-parenthesized", "(Regex.", "an opening-parenthesis prefix"), + DogfoodStaticRegexApiQuery( + "static-regex-api-qualified", + "RegularExpressions.Regex.", + "a fully qualified System.Text.RegularExpressions receiver"), new( "relaxed-json-encoder", "UnsafeRelaxedJsonEscaping", @@ -1494,17 +1521,19 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( new( "process-shell-execute", "UseShellExecute", - "Find shell-execution toggles that decide whether the platform shell participates in process launch.", + "Find shell-execution toggles whose assigned value enables shell use or cannot be resolved conservatively.", ["audit", "security"], - "False positives include assertions that verify UseShellExecute is false.") + "Literal UseShellExecute=false assignments are suppressed by value-polarity semantics; propagated or computed values remain findings for review.") { RiskEvidence = [ "risk: UseShellExecute=true can reintroduce shell expansion, file association behavior, and inherited shell state.", - "positive: UseShellExecute=false with ArgumentList and redirected stream handling is preferred for subprocess boundaries." + "risk: variable-propagated or computed assignments remain findings until their no-shell value can be proven at the assignment boundary.", + "positive: a direct UseShellExecute=false assignment explicitly disables the shell and is suppressed." ], MatchOrigins = ["code"], - Classifiers = [ProcessLaunchClassifier], + Classifiers = [ProcessLaunchClassifier, ShellExecutePolarityClassifier], + SemanticFilter = SearchRecipeSemanticFilter.ShellExecuteAssignment, }, new( "process-working-directory", @@ -4325,6 +4354,14 @@ internal sealed record SearchAuditRecipeQuery( public SearchRecipeStringComparisonTaxonomyJsonResult? StringComparisonTaxonomy { get; init; } public SearchRecipeBroadCatchTaxonomyJsonResult? BroadCatchTaxonomy { get; init; } public SearchRecipeNullableContractTaxonomyJsonResult? NullableContractTaxonomy { get; init; } + public SearchRecipeSemanticFilter SemanticFilter { get; init; } +} + +internal enum SearchRecipeSemanticFilter +{ + None, + RegexStaticMember, + ShellExecuteAssignment, } internal sealed record SearchRecipeListJsonResult( diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index e2e67c77f..7771b3bad 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -2644,10 +2644,11 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() Assert.Contains(dogfoodTrustOverridesQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("sanitized", StringComparison.Ordinal)); Assert.Equal(" Regex.", dogfoodRegexQuery.GetProperty("query").GetString()); Assert.Contains(dogfoodRegexQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("raw System.Text.RegularExpressions.Regex static APIs", StringComparison.Ordinal)); - Assert.Contains(dogfoodRegexQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("BoundedRegex aliases and instance names ending in Regex are filtered out", StringComparison.Ordinal)); + Assert.Contains(dogfoodRegexQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("suppressed by matched-member semantics", StringComparison.Ordinal)); AssertRegexBoundedGuardFilters(dogfoodRegexQuery); Assert.Contains(dogfoodRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "static-regex-api-negated"); Assert.Contains(dogfoodRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "static-regex-api-parenthesized"); + Assert.Contains(dogfoodRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "static-regex-api-qualified"); Assert.Contains(dogfoodSqlQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("identifier", StringComparison.OrdinalIgnoreCase)); Assert.Contains(sqlitePolicyCommandTextQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("SqliteCommandPolicy", StringComparison.Ordinal)); Assert.Contains(sqlitePolicyPragmaQuery.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("cannot bind every pragma value", StringComparison.Ordinal)); @@ -3394,6 +3395,7 @@ public void RunSearch_BuiltInRecipeSnapshotCoversNamesScopesAndQueries_Issue3692 "static-regex-api", "static-regex-api-negated", "static-regex-api-parenthesized", + "static-regex-api-qualified", "relaxed-json-encoder", "temp-file-name", "overwrite-file-move", @@ -4438,6 +4440,260 @@ public sealed class RegexDiagnosticOnly } } + [Fact] + public void RunSearch_SafetyRecipesApplyOperationAndPolaritySemantics_Issue4911() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_safety_semantics_4911"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-safe.cs", + "csharp", + """ + using System.Text.RegularExpressions; + + public static class RegexSafeHelpers + { + public static string Escape(string input) => Regex.Escape(input); + public static string Unescape(string input) => Regex.Unescape(input); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-risk.cs", + "csharp", + """ + using System.Text.RegularExpressions; + + public static class RegexRisk + { + public static bool Match(string input) => Regex.IsMatch(input, "token"); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-unresolved.cs", + "csharp", + """ + using System.Text.RegularExpressions; + + public static class RegexUnresolved + { + public static object Inspect(string input) => Regex.CustomOperation(input); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-alias.cs", + "csharp", + """ + using Regex = Vendor.Text.Regex; + + public static class RegexAlias + { + public static string Escape(string input) => Regex.Escape(input); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-qualified-safe.cs", + "csharp", + """ + public static class RegexQualifiedSafe + { + public static string Escape(string input) => global::System.Text.RegularExpressions.Regex.Escape(input); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-qualified-risk.cs", + "csharp", + """ + public static class RegexQualifiedRisk + { + public static bool Match(string input) => System.Text.RegularExpressions.Regex.IsMatch(input, "token"); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-mixed-chunk.cs", + "csharp", + """ + using System.Text.RegularExpressions; + + public static class RegexMixedChunk + { + public static bool Match(string input) + { + var escaped1 = Regex.Escape(input); + var escaped2 = Regex.Escape(escaped1); + var escaped3 = Regex.Escape(escaped2); + var escaped4 = Regex.Escape(escaped3); + var escaped5 = Regex.Escape(escaped4); + var escaped6 = Regex.Escape(escaped5); + var escaped7 = Regex.Escape(escaped6); + var escaped8 = Regex.Escape(escaped7); + return Regex.IsMatch(escaped8, "token"); + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-construction.cs", + "csharp", + """ + using System.Text.RegularExpressions; + + public static class RegexConstruction + { + public static Regex Build(string input) => new Regex("^" + Regex.Escape(input)); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/shell-disabled.cs", + "csharp", + """ + using System.Diagnostics; + + public static class ShellDisabled + { + public static ProcessStartInfo Build() => new() { UseShellExecute = false }; + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/shell-named-disabled.cs", + "csharp", + """ + public static class ShellNamedDisabled + { + public static object Build() => Launch(UseShellExecute: false); + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/shell-enabled.cs", + "csharp", + """ + using System.Diagnostics; + + public static class ShellEnabled + { + public static ProcessStartInfo Build() => new() { UseShellExecute = true }; + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/shell-propagated.cs", + "csharp", + """ + using System.Diagnostics; + + public static class ShellPropagated + { + public static ProcessStartInfo Build(bool useShell) => new() { UseShellExecute = useShell }; + } + """); + + var recipeArgs = new[] + { + "--recipe", "dogfood-risk-patterns", + "--include-query", "static-regex-api,static-regex-api-qualified,process-shell-execute", + "--db", dbPath, + "--json", + "--limit", "20", + "--lang", "csharp" + }; + var (runExitCode, runStdout, runStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(recipeArgs, _jsonOptions)); + var (countExitCode, countStdout, countStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [.. recipeArgs, "--format", "count"], + _jsonOptions)); + var (constructionExitCode, constructionStdout, constructionStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "dotnet-risk-patterns/regex-construction", "--db", dbPath, "--json", "--limit", "20", "--lang", "csharp"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, runExitCode); + Assert.Equal(string.Empty, runStderr); + using (var document = ParseJsonOutput(runStdout)) + { + var queries = document.RootElement.GetProperty("queries").EnumerateArray().ToArray(); + var regexQuery = Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api"); + var qualifiedRegexQuery = Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api-qualified"); + var shellQuery = Assert.Single(queries, query => query.GetProperty("name").GetString() == "process-shell-execute"); + + Assert.Equal(4, regexQuery.GetProperty("count").GetInt32()); + Assert.DoesNotContain(regexQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-safe.cs"); + AssertSemanticClassification(regexQuery, "src/regex-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); + AssertSemanticClassification(regexQuery, "src/regex-unresolved.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.CustomOperation"); + AssertSemanticClassification(regexQuery, "src/regex-mixed-chunk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); + AssertSemanticClassification(regexQuery, "src/regex-alias.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.Escape"); + AssertClassifierCategories(regexQuery, "regex_operation_semantics", "safe_escape_helper", "regex_pattern_operation", "regex_operation_unresolved"); + + Assert.Equal(1, qualifiedRegexQuery.GetProperty("count").GetInt32()); + Assert.DoesNotContain(qualifiedRegexQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-qualified-safe.cs"); + AssertSemanticClassification(qualifiedRegexQuery, "src/regex-qualified-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:System.Text.RegularExpressions.Regex.IsMatch"); + + Assert.Equal(2, shellQuery.GetProperty("count").GetInt32()); + Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-disabled.cs"); + Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-named-disabled.cs"); + AssertSemanticClassification(shellQuery, "src/shell-enabled.cs", "shell_execute_polarity", "shell_explicitly_enabled", "value:true"); + AssertSemanticClassification(shellQuery, "src/shell-propagated.cs", "shell_execute_polarity", "shell_policy_unresolved", "value:unresolved"); + AssertClassifierCategories(shellQuery, "shell_execute_polarity", "shell_explicitly_disabled", "shell_explicitly_enabled", "shell_policy_unresolved"); + } + + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(string.Empty, countStderr); + using (var document = ParseJsonOutput(countStdout)) + { + var queries = document.RootElement.GetProperty("queries").EnumerateArray().ToArray(); + Assert.Equal(4, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api").GetProperty("count").GetInt32()); + Assert.Equal(1, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api-qualified").GetProperty("count").GetInt32()); + Assert.Equal(2, Assert.Single(queries, query => query.GetProperty("name").GetString() == "process-shell-execute").GetProperty("count").GetInt32()); + } + + Assert.Equal(CommandExitCodes.Success, constructionExitCode); + Assert.Equal(string.Empty, constructionStderr); + using (var document = ParseJsonOutput(constructionStdout)) + { + var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Contains(query.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-construction.cs"); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + + static void AssertSemanticClassification( + JsonElement query, + string path, + string expectedClassifier, + string expectedCategory, + string expectedEvidence) + { + var result = Assert.Single(query.GetProperty("results").EnumerateArray(), item => item.GetProperty("path").GetString() == path); + var classification = Assert.Single(result.GetProperty("audit_classifications").EnumerateArray()); + Assert.Equal(expectedClassifier, classification.GetProperty("classifier").GetString()); + Assert.Equal(expectedCategory, classification.GetProperty("category").GetString()); + Assert.Contains(classification.GetProperty("evidence").EnumerateArray(), evidence => evidence.GetString() == expectedEvidence); + } + + static void AssertClassifierCategories(JsonElement query, string name, params string[] expectedCategories) + { + var classifier = Assert.Single( + query.GetProperty("classifiers").EnumerateArray(), + item => item.GetProperty("name").GetString() == name); + var categories = classifier.GetProperty("categories").EnumerateArray() + .Select(category => category.GetProperty("name").GetString()) + .ToArray(); + foreach (var expectedCategory in expectedCategories) + Assert.Contains(expectedCategory, categories); + } + } + [Fact] public void RunSearch_RegexRecipeClassifiesTimeoutAndGeneratedEvidence_Issue4326() { @@ -7166,7 +7422,7 @@ public void Run(Exception ex, DbCommand command, Type pluginType) redirectStandardError: true); SubprocessEnvironmentPolicy.ApplyGitEnvironment(startInfo); startInfo.ArgumentList.Add("--info"); - startInfo.UseShellExecute = false; + startInfo.UseShellExecute = true; startInfo.WorkingDirectory = Environment.CurrentDirectory; startInfo.RedirectStandardOutput = true; startInfo.RedirectStandardError = true; From 87e839ad26ed4d7150d883975932a661b9075283 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 1 Aug 2026 03:06:52 +0900 Subject: [PATCH 2/4] Address safety audit review findings (#4911) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 24 +- changelog.d/unreleased/4911.fixed.md | 4 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 309 ++++++++++++------ .../QueryCommandRunnerSearchTests.cs | 75 ++++- 5 files changed, 297 insertions(+), 119 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index eb52b679b..d60c0e8a1 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -195,7 +195,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. Status-explain coverage must enumerate the source-generated `StatusResult` serializer properties and require every serialized top-level key to succeed without opening a database. Keep major readiness/trust/extension/maintenance/cap-hit metadata assertions, dotted-member resolution, bounded valid unknown candidates, and redaction of hostile field input in the same focused fixture so serialization and explainability cannot drift independently. Keep every structured explain response key registered for status `--fields` projection, and exercise the response through a bounded JSON projection that proves the outer envelope also omits runtime and path metadata. - Safety-recipe semantic coverage must keep safe and unsafe controls in separate indexed files: assert that `Regex.Escape` / `Regex.Unescape` and literal `UseShellExecute=false` are absent, while matching or unresolved Regex members, literal `true`, and propagated shell-policy values remain with classification evidence. Keep count output aligned with full JSON and retain a separate `new Regex` construction positive. + Safety-recipe semantic coverage must keep safe and unsafe controls in separate indexed files: assert that `Regex.Escape` / `Regex.Unescape` and literal `UseShellExecute=false` are absent, while matching, source-defined, or unresolved Regex members, literal `true`, and propagated shell-policy values remain with classification evidence. Include alias trivia, a line break before literal `false`, misleading same-line comments, and enough safe helpers to saturate the normal result envelope; keep count output aligned with full JSON and retain a separate `new Regex` construction positive. Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. Search-to-find recovery coverage must exercise `--regex` and `--all` in human and JSON modes, option-shaped and shell-sensitive query values, normalized path/language/exclusion/limit arguments, and explicit non-equivalent or unsafe cases. Assert typed argv plus POSIX-shell and PowerShell displays, absence of implicit workspace defaults, and the no-execution markers; an unmappable request must return no alternative command. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. @@ -1149,7 +1149,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 status explain の coverage は source-generated `StatusResult` serializer property を列挙し、database を開かずに serialized top-level key がすべて成功することを必須にします。主要な readiness / trust / extension / maintenance / cap-hit metadata、dot 区切り member resolution、unknown key に対する上限付きの有効な candidate、悪意ある field input の redaction を同じ focused fixture に置き、serialization と explainability が独立して drift しないようにしてください。structured explain response の全 key を status の `--fields` projection に登録し、outer envelope からも runtime / path metadata が省略されることを上限付き JSON projection で固定してください。 - safety recipe の意味論 coverage では、安全側と危険側の control を別々の indexed file に置いてください。`Regex.Escape` / `Regex.Unescape` と literal `UseShellExecute=false` が除外され、matching または解決不能な Regex member、literal `true`、伝播された shell-policy 値が classification evidence 付きで残ることを検証します。count 出力を full JSON と一致させ、`new Regex` construction の正例は別に維持してください。 + safety recipe の意味論 coverage では、安全側と危険側の control を別々の indexed file に置いてください。`Regex.Escape` / `Regex.Unescape` と literal `UseShellExecute=false` が除外され、matching、source-defined、または解決不能な Regex member、literal `true`、伝播された shell-policy 値が classification evidence 付きで残ることを検証します。alias の trivia、literal `false` の前の改行、同じ行にある誤誘導 comment、通常の result envelope を埋める数の safe helper を含め、count 出力を full JSON と一致させ、`new Regex` construction の正例は別に維持してください。 graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 search から find への recovery coverage では、human / JSON mode の `--regex` と `--all`、option 風および shell-sensitive な query 値、正規化した path / language / exclude / limit 引数、明示的に非等価または unsafe な case を検証してください。型付き argv と POSIX shell / PowerShell 表示、暗黙の workspace default が含まれないこと、未実行 marker を固定し、対応不能な request が代替 command を返さないことを必須にします。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 5b694a52a..42adcdbf2 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1420,12 +1420,14 @@ terms can be separated before filing. working-directory choices, stdout/stderr redirection, waits, termination, shared launch/environment policies, and broad plugin/hook/trust-override discovery terms. -The `static-regex-api*` children inspect the matched `Regex` member: exact -`Escape` / `Unescape` helpers are suppressed, while matching operations and -unresolved receiver/member evidence remain findings. `process-shell-execute` -similarly suppresses only a direct literal `UseShellExecute=false` assignment; +The `static-regex-api*` children inspect the matched code-origin `Regex` member: +exact `Escape` / `Unescape` helpers on a receiver proven to be the BCL type are +suppressed, while matching operations and unresolved or source-defined +receiver/member evidence remain findings. `process-shell-execute` similarly +suppresses only a matched direct literal `UseShellExecute=false` assignment; literal `true` and propagated or otherwise unresolved values remain findings -with semantic classification evidence. +with semantic classification evidence. Nearby comments and string literals do +not change either semantic decision. `risky-code/broad-exception-catch` includes broad-catch boundary categories and expected diagnostic behaviors so users can distinguish intentional top-level, cleanup, probe, diagnostic-sanitization, and worker boundaries from catches that @@ -4816,11 +4818,13 @@ evidence field、guidance を持つため、ノイズの多い audit term を起 `UseShellExecute`、working-directory 選択、stdout/stderr redirection、wait、 termination、共有 launch/environment policy、広めの plugin/hook/trust-override discovery 用語を process-launch boundary の child query として含みます。 -`static-regex-api*` child は一致した `Regex` member を判定し、厳密な `Escape` / `Unescape` -helper を除外する一方、matching operation と receiver/member を解決できない evidence は -finding として残します。`process-shell-execute` も、直接の literal -`UseShellExecute=false` 代入だけを除外します。literal `true` と、伝播またはその他の理由で -解決できない値は、意味論的な分類 evidence を伴う finding として残ります。 +`static-regex-api*` child は一致した code-origin の `Regex` member を判定し、BCL type と +証明できる receiver 上の厳密な `Escape` / `Unescape` helper を除外する一方、matching +operation、解決不能または source-defined の receiver/member evidence は finding として +残します。`process-shell-execute` も、一致した直接の literal `UseShellExecute=false` 代入だけを +除外します。literal `true` と、伝播またはその他の理由で解決できない値は、意味論的な分類 +evidence を伴う finding として残ります。周辺の comment や string literal は、どちらの意味 +判定も変更しません。 たとえば `risky-code/broad-exception-catch` は broad catch の境界カテゴリと期待される diagnostic behavior を含めるため、意図的な top-level、cleanup、probe、diagnostic-sanitization、worker 境界と、narrowing または diff --git a/changelog.d/unreleased/4911.fixed.md b/changelog.d/unreleased/4911.fixed.md index 91a60668e..4258f0078 100644 --- a/changelog.d/unreleased/4911.fixed.md +++ b/changelog.d/unreleased/4911.fixed.md @@ -12,8 +12,8 @@ affected: ## English -- **Safety audits now distinguish safe helper operations and negative policy values (#4911)** — the dogfood static-Regex audit suppresses exact `Regex.Escape` / `Regex.Unescape` helpers without hiding pattern execution or unresolved members, while the shell-execution audit suppresses direct literal `UseShellExecute=false` assignments and retains `true` or propagated values. Retained findings include semantic classification evidence for the matched operation or value. +- **Safety audits now distinguish safe helper operations and negative policy values (#4911)** — the dogfood static-Regex audit suppresses exact BCL `Regex.Escape` / `Regex.Unescape` helpers without hiding pattern execution, source-defined receivers, or unresolved members, while the shell-execution audit suppresses direct literal `UseShellExecute=false` assignments and retains `true` or propagated values. The decisions follow matched code evidence rather than nearby comments, and retained findings include semantic classification evidence for the matched operation or value. ## 日本語 -- **安全性 audit が安全な helper 操作と否定 policy 値を区別するようになりました (#4911)** — dogfood の static Regex audit は厳密な `Regex.Escape` / `Regex.Unescape` helper を除外しつつ、pattern 実行と解決不能な member を検出に残します。shell-execution audit は直接の literal `UseShellExecute=false` 代入を除外し、`true` または伝播された値を残します。残った finding には、一致した操作または値の意味論的な分類 evidence が含まれます。 +- **安全性 audit が安全な helper 操作と否定 policy 値を区別するようになりました (#4911)** — dogfood の static Regex audit は厳密な BCL `Regex.Escape` / `Regex.Unescape` helper を除外しつつ、pattern 実行、source-defined receiver、解決不能な member を検出に残します。shell-execution audit は直接の literal `UseShellExecute=false` 代入を除外し、`true` または伝播された値を残します。判定は周辺 comment ではなく一致した code evidence に従い、残った finding には一致した操作または値の意味論的な分類 evidence が含まれます。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index deb8f346f..c139d5ed2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1925,7 +1925,7 @@ private static List CollectSearchRecipeQueryR var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); var resultLimit = GetSearchRecipeEffectiveResultLimit(options, total); var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); - var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit); + var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit, recipeQuery); var results = reader.Search( recipeQuery.Query, fetchLimit, @@ -2027,7 +2027,7 @@ private static List CollectSearchRecip var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); var resultLimit = GetSearchRecipeEffectiveResultLimit(options, total); var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); - var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit); + var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit, recipeQuery); var results = reader.Search( recipeQuery.Query, fetchLimit, @@ -2135,8 +2135,14 @@ private static bool IsSearchRecipeSourceTotalAuthoritative( && !HasSearchOriginFilters(BuildSearchDisplayFacetFilters(options, recipeQuery)) && resultCount < fetchLimit; - private static int GetSearchRecipeFetchLimit(QueryCommandOptions options, int resultLimit) + private static int GetSearchRecipeFetchLimit( + QueryCommandOptions options, + int resultLimit, + SearchAuditRecipeQuery? recipeQuery = null) { + if (recipeQuery?.SemanticFilter != SearchRecipeSemanticFilter.None) + return int.MaxValue; + var selectionTarget = resultLimit > 0 && options.SampleSize.HasValue ? Math.Max(resultLimit, options.SampleSize.Value) : resultLimit; @@ -2247,14 +2253,17 @@ private static List ApplySearchRecipeSemanticFilter( return rows; var retained = new List(rows.Count); - var regexAliasPaths = new Dictionary(StringComparer.Ordinal); + var regexBindingPaths = recipeQuery.SemanticFilter == SearchRecipeSemanticFilter.RegexStaticMember + ? BuildRegexBareReceiverContexts(reader, options, rows) + : new Dictionary(StringComparer.Ordinal); foreach (var row in rows) { var evidence = recipeQuery.SemanticFilter switch { SearchRecipeSemanticFilter.RegexStaticMember => GetRegexOperationEvidence( + reader, row, - HasRegexAliasDeclaration(reader, options, row.Result.Path, regexAliasPaths)), + regexBindingPaths), SearchRecipeSemanticFilter.ShellExecuteAssignment => GetShellExecuteAssignmentEvidence(row), _ => null, }; @@ -2310,7 +2319,10 @@ private static List ApplySearchRecipeSemanticFilter( details); } - private static SearchRecipeSemanticEvidence GetRegexOperationEvidence(SearchDisplayRow row, bool hasRegexAliasDeclaration) + private static SearchRecipeSemanticEvidence GetRegexOperationEvidence( + DbReader reader, + SearchDisplayRow row, + Dictionary bindingPaths) { var operations = new List(); int? firstLine = null; @@ -2318,39 +2330,37 @@ private static SearchRecipeSemanticEvidence GetRegexOperationEvidence(SearchDisp var foundUnresolvedOperation = false; var foundSafeOperation = false; - foreach (var (text, line) in GetSemanticEvidenceLines(row, "Regex.")) + foreach (var match in GetSemanticEvidenceMatches(row, "Regex.")) { - var searchFrom = 0; - while (searchFrom < text.Length) - { - var regexIndex = text.IndexOf("Regex.", searchFrom, StringComparison.Ordinal); - if (regexIndex < 0) - break; - searchFrom = regexIndex + "Regex.".Length; - firstLine ??= line; - - var receiver = ExtractRegexReceiver(text, regexIndex); - var member = ExtractIdentifier(text, searchFrom); - operations.Add(string.IsNullOrWhiteSpace(member) ? receiver : $"{receiver}.{member}"); - if (!IsSystemRegexReceiver(receiver) - || (hasRegexAliasDeclaration && string.Equals(receiver, "Regex", StringComparison.Ordinal)) - || string.IsNullOrWhiteSpace(member)) - { - foundUnresolvedOperation = true; - continue; - } + var searchFrom = match.MarkerIndex + "Regex.".Length; + firstLine ??= match.Line; - if (member is "Escape" or "Unescape") - { - foundSafeOperation = true; - continue; - } + var receiver = ExtractRegexReceiver(match.Text, match.MarkerIndex); + var member = ExtractIdentifier(match.Text, searchFrom); + operations.Add(string.IsNullOrWhiteSpace(member) ? receiver : $"{receiver}.{member}"); + if (!IsProvenSystemRegexReceiver( + reader, + row.Result.Path, + receiver, + match.Line, + match.MarkerIndex, + bindingPaths) + || string.IsNullOrWhiteSpace(member)) + { + foundUnresolvedOperation = true; + continue; + } - if (member is "IsMatch" or "Match" or "Matches" or "Replace" or "Split" or "EnumerateMatches" or "Count") - foundRiskOperation = true; - else - foundUnresolvedOperation = true; + if (member is "Escape" or "Unescape") + { + foundSafeOperation = true; + continue; } + + if (member is "IsMatch" or "Match" or "Matches" or "Replace" or "Split" or "EnumerateMatches" or "Count") + foundRiskOperation = true; + else + foundUnresolvedOperation = true; } var operation = string.Join(",", operations.Distinct(StringComparer.Ordinal)); @@ -2369,38 +2379,139 @@ private static string ExtractRegexReceiver(string text, int regexIndex) return text[start..(regexIndex + "Regex".Length)].Trim('.'); } - private static bool IsSystemRegexReceiver(string receiver) - => string.Equals(receiver, "Regex", StringComparison.Ordinal) - || string.Equals(receiver, "System.Text.RegularExpressions.Regex", StringComparison.Ordinal) - || string.Equals(receiver, "global::System.Text.RegularExpressions.Regex", StringComparison.Ordinal); - - private static bool HasRegexAliasDeclaration( + private static bool IsProvenSystemRegexReceiver( DbReader reader, - QueryCommandOptions options, string path, - Dictionary aliasPaths) + string receiver, + int line, + int regexIndex, + Dictionary bindingPaths) { - if (aliasPaths.TryGetValue(path, out var hasAlias)) - return hasAlias; + if (string.Equals(receiver, "System.Text.RegularExpressions.Regex", StringComparison.Ordinal) + || string.Equals(receiver, "global::System.Text.RegularExpressions.Regex", StringComparison.Ordinal)) + { + return true; + } + if (!string.Equals(receiver, "Regex", StringComparison.Ordinal)) + return false; - hasAlias = new[] { "using Regex =", "using Regex=" } - .Any(aliasQuery => reader.Search( - aliasQuery, - 1, + if (!bindingPaths.TryGetValue(path, out var binding) + || !binding.HasSystemNamespaceImport + || binding.HasAliasDeclaration) + { + return false; + } + + var resolution = reader.GetReferencePositionResolution(path, "Regex", line, regexIndex + 1, maxCandidates: 1); + return !resolution.IdentityAvailable + || (!resolution.CandidatesTruncated && resolution.Candidates.Count == 0); + } + + private static Dictionary BuildRegexBareReceiverContexts( + DbReader reader, + QueryCommandOptions options, + IReadOnlyCollection rows) + { + var paths = rows + .Select(row => row.Result.Path) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var contexts = paths.ToDictionary( + path => path, + _ => new RegexBareReceiverContext(false, false), + StringComparer.Ordinal); + foreach (var pathBatch in paths.Chunk(100)) + { + var usingResults = reader.Search( + "using", + int.MaxValue, options.Lang, rawQuery: false, - pathPatterns: [path], + pathPatterns: pathBatch, excludePathPatterns: null, excludeTests: false, - deduplicate: true, + deduplicate: false, since: options.Since, exact: true, prefix: false, - visibilityRank: false).Count > 0); - aliasPaths[path] = hasAlias; - return hasAlias; + visibilityRank: false); + foreach (var result in usingResults) + { + if (!contexts.TryGetValue(result.Path, out var context)) + continue; + + contexts[result.Path] = new RegexBareReceiverContext( + context.HasSystemNamespaceImport + || result.Content.Contains("using System.Text.RegularExpressions;", StringComparison.Ordinal), + context.HasAliasDeclaration || ContainsRegexAliasDeclaration(result.Content)); + } + } + + return contexts; } + private static bool ContainsRegexAliasDeclaration(string content) + { + for (var searchFrom = 0; searchFrom < content.Length;) + { + var usingIndex = content.IndexOf("using", searchFrom, StringComparison.Ordinal); + if (usingIndex < 0) + return false; + searchFrom = usingIndex + "using".Length; + if ((usingIndex > 0 && IsIdentifierCharacter(content[usingIndex - 1])) + || (searchFrom < content.Length && IsIdentifierCharacter(content[searchFrom]))) + { + continue; + } + + var cursor = SkipCSharpTrivia(content, searchFrom); + var identifier = ExtractIdentifier(content, cursor); + if (!string.Equals(identifier, "Regex", StringComparison.Ordinal)) + continue; + + cursor = SkipCSharpTrivia(content, cursor + identifier.Length); + if (cursor < content.Length && content[cursor] == '=') + return true; + } + + return false; + } + + private static int SkipCSharpTrivia(string text, int start) + { + var cursor = start; + while (cursor < text.Length) + { + if (char.IsWhiteSpace(text[cursor])) + { + cursor++; + continue; + } + if (cursor + 1 < text.Length && text[cursor] == '/' && text[cursor + 1] == '/') + { + cursor += 2; + while (cursor < text.Length && text[cursor] is not '\r' and not '\n') + cursor++; + continue; + } + if (cursor + 1 < text.Length && text[cursor] == '/' && text[cursor + 1] == '*') + { + var commentEnd = text.IndexOf("*/", cursor + 2, StringComparison.Ordinal); + if (commentEnd < 0) + return text.Length; + cursor = commentEnd + 2; + continue; + } + + break; + } + + return cursor; + } + + private static bool IsIdentifierCharacter(char value) + => char.IsLetterOrDigit(value) || value == '_'; + private static bool IsQualifiedIdentifierCharacter(char value) => char.IsLetterOrDigit(value) || value is '_' or '.' or ':'; @@ -2420,29 +2531,18 @@ private static SearchRecipeSemanticEvidence GetShellExecuteAssignmentEvidence(Se var foundTrue = false; var foundUnresolved = false; - foreach (var (text, line) in GetSemanticEvidenceLines(row, "UseShellExecute")) + foreach (var match in GetSemanticEvidenceMatches(row, "UseShellExecute")) { - var searchFrom = 0; - while (searchFrom < text.Length) - { - var propertyIndex = text.IndexOf("UseShellExecute", searchFrom, StringComparison.Ordinal); - if (propertyIndex < 0) - break; - searchFrom = propertyIndex + "UseShellExecute".Length; - if ((propertyIndex > 0 && IsIdentifierCharacter(text[propertyIndex - 1])) - || (searchFrom < text.Length && IsIdentifierCharacter(text[searchFrom]))) - continue; - - firstLine ??= line; - var value = ExtractAssignedBooleanLiteral(text, searchFrom); - values.Add(value ?? "unresolved"); - if (string.Equals(value, "false", StringComparison.Ordinal)) - foundFalse = true; - else if (string.Equals(value, "true", StringComparison.Ordinal)) - foundTrue = true; - else - foundUnresolved = true; - } + var searchFrom = match.MarkerIndex + "UseShellExecute".Length; + firstLine ??= match.Line; + var value = ExtractAssignedBooleanLiteral(match.ContinuationText, searchFrom); + values.Add(value ?? "unresolved"); + if (string.Equals(value, "false", StringComparison.Ordinal)) + foundFalse = true; + else if (string.Equals(value, "true", StringComparison.Ordinal)) + foundTrue = true; + else + foundUnresolved = true; } var valueEvidence = string.Join(",", values.Distinct(StringComparer.Ordinal)); @@ -2480,34 +2580,39 @@ private static SearchRecipeSemanticEvidence GetShellExecuteAssignmentEvidence(Se return null; } - private static bool IsIdentifierCharacter(char value) - => char.IsLetterOrDigit(value) || value == '_'; - - private static IEnumerable<(string Text, int? Line)> GetSemanticEvidenceLines(SearchDisplayRow row, string marker) + private static IEnumerable GetSemanticEvidenceMatches(SearchDisplayRow row, string marker) { var contentLines = row.Result.Content.Split('\n', StringSplitOptions.None); - var matchedContentLines = row.Compact.MatchFacets - .Select(facet => facet.Line) - .Distinct() - .Order() - .Where(line => line >= row.Result.StartLine && line - row.Result.StartLine < contentLines.Length) - .Select(line => (Text: contentLines[line - row.Result.StartLine].TrimEnd('\r'), Line: (int?)line)) - .Where(item => item.Text.Contains(marker, StringComparison.Ordinal)) - .ToList(); - if (matchedContentLines.Count > 0) - return matchedContentLines; + var matches = new List(); + var seen = new HashSet<(int Line, int MarkerIndex)>(); + foreach (var facet in row.Compact.MatchFacets + .Where(facet => string.Equals(facet.Origin, SearchMatchClassifier.Code, StringComparison.Ordinal)) + .OrderBy(facet => facet.Line) + .ThenBy(facet => facet.Column)) + { + if (facet.Line < row.Result.StartLine || facet.Line - row.Result.StartLine >= contentLines.Length) + continue; - var highlights = row.Compact.Highlights - .Where(highlight => highlight.Text.Contains(marker, StringComparison.Ordinal)) - .Select(highlight => (highlight.Text, (int?)highlight.Line)) - .ToList(); - if (highlights.Count > 0) - return highlights; + var contentLineIndex = facet.Line - row.Result.StartLine; + var text = contentLines[contentLineIndex].TrimEnd('\r'); + var continuationText = string.Join('\n', contentLines.Skip(contentLineIndex)); + var facetStart = Math.Max(0, facet.Column - 1); + var facetEnd = facetStart + Math.Max(1, facet.Length); + for (var searchFrom = 0; searchFrom < text.Length;) + { + var markerIndex = text.IndexOf(marker, searchFrom, StringComparison.Ordinal); + if (markerIndex < 0) + break; + searchFrom = markerIndex + marker.Length; + var markerEnd = markerIndex + marker.Length; + if (markerIndex >= facetEnd || facetStart >= markerEnd || !seen.Add((facet.Line, markerIndex))) + continue; + + matches.Add(new SearchRecipeSemanticMatch(text, continuationText, facet.Line, markerIndex)); + } + } - return row.Compact.Snippet - .Split('\n', StringSplitOptions.None) - .Where(line => line.Contains(marker, StringComparison.Ordinal)) - .Select(line => (line, (int?)null)); + return matches; } private sealed record SearchRecipeSemanticEvidence( @@ -2518,6 +2623,10 @@ private sealed record SearchRecipeSemanticEvidence( string? Value, int? Line); + private sealed record RegexBareReceiverContext(bool HasSystemNamespaceImport, bool HasAliasDeclaration); + + private sealed record SearchRecipeSemanticMatch(string Text, string ContinuationText, int Line, int MarkerIndex); + private static SearchAuditClassificationJsonResult? TryClassifyTaskResultIntent( SearchRecipeClassifierJsonResult classifier, SearchDisplayRow row) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 7771b3bad..b4cd0699f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -4444,6 +4444,7 @@ public sealed class RegexDiagnosticOnly public void RunSearch_SafetyRecipesApplyOperationAndPolaritySemantics_Issue4911() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_safety_semantics_4911"); + var overfetchProjectRoot = TestProjectHelper.CreateTempProject("cdidx_search_safety_semantics_overfetch_4911"); try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); @@ -4456,7 +4457,7 @@ public void RunSearch_SafetyRecipesApplyOperationAndPolaritySemantics_Issue4911( public static class RegexSafeHelpers { - public static string Escape(string input) => Regex.Escape(input); + public static string Escape(string input) => Regex.Escape(input); // Regex.IsMatch is deliberately not called. public static string Unescape(string input) => Regex.Unescape(input); } """); @@ -4489,13 +4490,31 @@ public static class RegexUnresolved "src/regex-alias.cs", "csharp", """ - using Regex = Vendor.Text.Regex; + using System.Text.RegularExpressions; + using Regex = Vendor.Text.Regex; public static class RegexAlias { public static string Escape(string input) => Regex.Escape(input); } """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-source-shadow.cs", + "csharp", + """ + namespace Vendor; + + public static class Regex + { + public static string Escape(string input) => input; + } + + public static class RegexSourceShadow + { + public static string Escape(string input) => Regex.Escape(input); + } + """); TestProjectHelper.InsertIndexedFile( dbPath, "src/regex-qualified-safe.cs", @@ -4560,7 +4579,7 @@ public static class RegexConstruction public static class ShellDisabled { - public static ProcessStartInfo Build() => new() { UseShellExecute = false }; + public static ProcessStartInfo Build() => new() { UseShellExecute = false }; // UseShellExecute must stay false. } """); TestProjectHelper.InsertIndexedFile( @@ -4573,6 +4592,22 @@ public static class ShellNamedDisabled public static object Build() => Launch(UseShellExecute: false); } """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/shell-multiline-disabled.cs", + "csharp", + """ + using System.Diagnostics; + + public static class ShellMultilineDisabled + { + public static ProcessStartInfo Build() => new() + { + UseShellExecute = + false + }; + } + """); TestProjectHelper.InsertIndexedFile( dbPath, "src/shell-enabled.cs", @@ -4624,12 +4659,13 @@ public static class ShellPropagated var qualifiedRegexQuery = Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api-qualified"); var shellQuery = Assert.Single(queries, query => query.GetProperty("name").GetString() == "process-shell-execute"); - Assert.Equal(4, regexQuery.GetProperty("count").GetInt32()); + Assert.Equal(5, regexQuery.GetProperty("count").GetInt32()); Assert.DoesNotContain(regexQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-safe.cs"); AssertSemanticClassification(regexQuery, "src/regex-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); AssertSemanticClassification(regexQuery, "src/regex-unresolved.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.CustomOperation"); AssertSemanticClassification(regexQuery, "src/regex-mixed-chunk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); AssertSemanticClassification(regexQuery, "src/regex-alias.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.Escape"); + AssertSemanticClassification(regexQuery, "src/regex-source-shadow.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.Escape"); AssertClassifierCategories(regexQuery, "regex_operation_semantics", "safe_escape_helper", "regex_pattern_operation", "regex_operation_unresolved"); Assert.Equal(1, qualifiedRegexQuery.GetProperty("count").GetInt32()); @@ -4639,6 +4675,7 @@ public static class ShellPropagated Assert.Equal(2, shellQuery.GetProperty("count").GetInt32()); Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-disabled.cs"); Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-named-disabled.cs"); + Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-multiline-disabled.cs"); AssertSemanticClassification(shellQuery, "src/shell-enabled.cs", "shell_execute_polarity", "shell_explicitly_enabled", "value:true"); AssertSemanticClassification(shellQuery, "src/shell-propagated.cs", "shell_execute_polarity", "shell_policy_unresolved", "value:unresolved"); AssertClassifierCategories(shellQuery, "shell_execute_polarity", "shell_explicitly_disabled", "shell_explicitly_enabled", "shell_policy_unresolved"); @@ -4649,7 +4686,7 @@ public static class ShellPropagated using (var document = ParseJsonOutput(countStdout)) { var queries = document.RootElement.GetProperty("queries").EnumerateArray().ToArray(); - Assert.Equal(4, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api").GetProperty("count").GetInt32()); + Assert.Equal(5, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api").GetProperty("count").GetInt32()); Assert.Equal(1, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api-qualified").GetProperty("count").GetInt32()); Assert.Equal(2, Assert.Single(queries, query => query.GetProperty("name").GetString() == "process-shell-execute").GetProperty("count").GetInt32()); } @@ -4661,10 +4698,38 @@ public static class ShellPropagated var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); Assert.Contains(query.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-construction.cs"); } + + var overfetchDbPath = TestProjectHelper.CreateProjectDb(overfetchProjectRoot); + TestProjectHelper.InsertIndexedFile( + overfetchDbPath, + "src/a-risk.cs", + "csharp", + "using System.Text.RegularExpressions; public static class Risk { public static bool Go(string input) => Regex.IsMatch(input, \"token\"); }"); + for (var index = 1; index <= 250; index++) + { + TestProjectHelper.InsertIndexedFile( + overfetchDbPath, + $"src/z-safe-{index:D3}.cs", + "csharp", + $"using System.Text.RegularExpressions; public static class Safe{index} {{ public static string Go(string input) => Regex.Escape(input); }}"); + } + + var (overfetchExitCode, overfetchStdout, overfetchStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "dogfood-risk-patterns", "--include-query", "static-regex-api", "--db", overfetchDbPath, "--json", "--limit", "1", "--lang", "csharp"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, overfetchExitCode); + Assert.Equal(string.Empty, overfetchStderr); + using (var document = ParseJsonOutput(overfetchStdout)) + { + var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal(1, query.GetProperty("count").GetInt32()); + AssertSemanticClassification(query, "src/a-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); + } } finally { TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(overfetchProjectRoot); } static void AssertSemanticClassification( From 21208eed02d1be96bfdcee623ac25a285d545b01 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 1 Aug 2026 03:46:20 +0900 Subject: [PATCH 3/4] Address final safety audit review (#4911) --- TESTING_GUIDE.md | 6 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 100 +++++++++---- src/CodeIndex/Cli/SearchAuditRecipes.cs | 14 +- .../QueryCommandRunnerSearchTests.cs | 138 +++++++++++++++--- 4 files changed, 201 insertions(+), 57 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index d60c0e8a1..8589b5282 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -76,6 +76,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `ChunkSplitterTests.cs`, `SymbolExtractorTests.cs`, `ReferenceExtractorTests.cs`, `SearchSnippetFormatterTests.cs`, `DbPathResolverTests.cs`, `ExcerptRecoveryCommandFormatterTests.cs`, `ConsoleUiTests.cs` Pure or mostly pure behavior tests with in-memory inputs. C# field coverage keeps collection expressions, constants, multiple declarators, target-typed `new`, arrays, real properties, oversized multiline initializer summarization, inherited and qualified field-receiver persistence, extractor-contract reindexing, search declaration metadata, and LSP field-kind projection in coordinated fixtures so public kind, signature, and reference-identity contracts cannot drift apart. + Markdown heading-range coverage keeps LF/CRLF and terminated/non-terminated files in one extractor fixture, including empty, one-line, Setext, nested, final-body, empty-body, and large-file cases. Pair it with persisted outline/definition-body and LSP document-symbol assertions so inclusive source ranges and 0-based protocol projection cannot drift apart. `DbPathResolverPureTests` keeps only path, injected-input, and URI validation cases that neither read process-global state nor open SQLite, allowing them to run outside the `SQLite pool sensitive` collection. Keep environment/current-directory resolution, real database and metadata probes, pool resets, and static test seams in `DbPathResolverTests`. Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, option-like source names such as `--db`, and file-URI database query parameters containing raw/encoded paths, percent-encoded sensitive keys, or path values with embedded sensitive assignments. Default-output assertions must reject the fixture's full absolute paths and secrets while preserving safe URI controls. Pair this with `status --config` coverage for default DB/data/log path and URI-query redaction, always-redacted secrets, and explicit `--show-paths`. @@ -195,7 +196,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. Status-explain coverage must enumerate the source-generated `StatusResult` serializer properties and require every serialized top-level key to succeed without opening a database. Keep major readiness/trust/extension/maintenance/cap-hit metadata assertions, dotted-member resolution, bounded valid unknown candidates, and redaction of hostile field input in the same focused fixture so serialization and explainability cannot drift independently. Keep every structured explain response key registered for status `--fields` projection, and exercise the response through a bounded JSON projection that proves the outer envelope also omits runtime and path metadata. - Safety-recipe semantic coverage must keep safe and unsafe controls in separate indexed files: assert that `Regex.Escape` / `Regex.Unescape` and literal `UseShellExecute=false` are absent, while matching, source-defined, or unresolved Regex members, literal `true`, and propagated shell-policy values remain with classification evidence. Include alias trivia, a line break before literal `false`, misleading same-line comments, and enough safe helpers to saturate the normal result envelope; keep count output aligned with full JSON and retain a separate `new Regex` construction positive. + Safety-recipe semantic coverage must keep safe and unsafe controls in separate indexed files: assert that `Regex.Escape` / `Regex.Unescape` and literal `UseShellExecute=false` are absent, while matching, source-defined, or unresolved Regex members, literal `true`, and propagated shell-policy values remain with classification evidence. Include alias trivia, alias-like comments and strings, a qualified BCL call in a file with a bare `BoundedRegex` alias, a line break before literal `false`, and computed continuations after block and line comments. Also retain a bare receiver from a legacy index without current reference identity, add enough safe helpers to saturate the normal result envelope, keep count output aligned with full JSON, and retain a separate `new Regex` construction positive. Graph snippet-length validation uses one command/format matrix across `references`, `callers`, and `callees`. Keep explicit `--snippet-lines` fail-fast without `--body`, reject location-only and count output even when `--body` is present, preserve the zero/range errors, and retain successful text/JSON body excerpts through the accepted maximum. Also cover option-like literals supplied through `--query` and `--`, plus two-page bounded body projection with clean stderr, correct total counts, and cursor continuation. Top-level compact aliases must validate the original arguments before database access, and bounded count replay must preserve snippet-like query literals. Search-to-find recovery coverage must exercise `--regex` and `--all` in human and JSON modes, option-shaped and shell-sensitive query values, normalized path/language/exclusion/limit arguments, and explicit non-equivalent or unsafe cases. Assert typed argv plus POSIX-shell and PowerShell displays, absence of implicit workspace defaults, and the no-execution markers; an unmappable request must return no alternative command. C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. @@ -1027,6 +1028,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ChunkSplitterTests.cs`、`SymbolExtractorTests.cs`、`ReferenceExtractorTests.cs`、`SearchSnippetFormatterTests.cs`、`DbPathResolverTests.cs`、`ExcerptRecoveryCommandFormatterTests.cs`、`ConsoleUiTests.cs` インメモリ入力中心の、純粋またはほぼ純粋な振る舞いのテスト。 C# field coverage は collection expression、constant、multiple declarator、target-typed `new`、array、実 property、巨大 multiline initializer の要約、継承 / qualified field receiver の永続化、extractor contract による再 index、search declaration metadata、LSP field kind への投影を連携 fixture にまとめ、公開 kind、signature、reference identity の契約が別々にずれないようにする。 + Markdown heading range の coverage は LF / CRLF と終端改行あり / なしを1つの extractor fixture にまとめ、empty、1行、Setext、nested、final body、empty body、large file の case を含めます。包含 source range と0始まりの protocol 投影がずれないよう、永続化後の outline / definition body と LSP document symbol の assertion を対にしてください。 `DbPathResolverPureTests` には process-global state を読まず SQLite も開かない path、注入済み入力、URI validation の case だけを置き、`SQLite pool sensitive` collection の外で実行できるようにします。環境変数 / current directory の解決、実 database / metadata probe、pool reset、static test seam は `DbPathResolverTests` に残してください。 search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、`--db` のように option と紛らわしい source 名、raw / encoded path、percent-encoded な機密 key、機密 assignment を内包する path 値を持つ file-URI database query parameter を含めます。既定出力に fixture の完全な絶対パスや secret が残らず、安全な URI control は維持されることを assertion にします。`status --config` の DB/data/log path と URI query の既定 redaction、mode に関係なく維持される secret redaction、明示的 `--show-paths` も対で検証してください。 @@ -1149,7 +1151,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 status explain の coverage は source-generated `StatusResult` serializer property を列挙し、database を開かずに serialized top-level key がすべて成功することを必須にします。主要な readiness / trust / extension / maintenance / cap-hit metadata、dot 区切り member resolution、unknown key に対する上限付きの有効な candidate、悪意ある field input の redaction を同じ focused fixture に置き、serialization と explainability が独立して drift しないようにしてください。structured explain response の全 key を status の `--fields` projection に登録し、outer envelope からも runtime / path metadata が省略されることを上限付き JSON projection で固定してください。 - safety recipe の意味論 coverage では、安全側と危険側の control を別々の indexed file に置いてください。`Regex.Escape` / `Regex.Unescape` と literal `UseShellExecute=false` が除外され、matching、source-defined、または解決不能な Regex member、literal `true`、伝播された shell-policy 値が classification evidence 付きで残ることを検証します。alias の trivia、literal `false` の前の改行、同じ行にある誤誘導 comment、通常の result envelope を埋める数の safe helper を含め、count 出力を full JSON と一致させ、`new Regex` construction の正例は別に維持してください。 + safety recipe の意味論 coverage では、安全側と危険側の control を別々の indexed file に置いてください。`Regex.Escape` / `Regex.Unescape` と literal `UseShellExecute=false` が除外され、matching、source-defined、または解決不能な Regex member、literal `true`、伝播された shell-policy 値が classification evidence 付きで残ることを検証します。alias の trivia、alias に見える comment / string、bare `BoundedRegex` alias と完全修飾 BCL call が同居する file、literal `false` の前の改行、block / line comment の後に続く計算式を含めます。また、現行 reference identity を持たない legacy index の bare receiver を残すこと、通常の result envelope を埋める数の safe helper、count 出力と full JSON の一致、別の `new Regex` construction 正例も維持してください。 graph の snippet 長 validation は、`references`、`callers`、`callees` を横断する1つの command / format matrix で検証します。明示した `--snippet-lines` は `--body` なしで fail-fast し、`--body` があっても location-only / count 出力では拒否すること、zero / range error を維持すること、受理上限まで text / JSON の body excerpt が成功することを固定してください。さらに、`--query` と `--` で渡した option 風 literal、および stderr が空で total count と cursor continuation が正しい2ページの bounded body projection も検証してください。top-level compact alias は database access より前に原引数を検証し、bounded count replay は snippet 風の query literal を維持しなければなりません。 search から find への recovery coverage では、human / JSON mode の `--regex` と `--all`、option 風および shell-sensitive な query 値、正規化した path / language / exclude / limit 引数、明示的に非等価または unsafe な case を検証してください。型付き argv と POSIX shell / PowerShell 表示、暗黙の workspace default が含まれないこと、未実行 marker を固定し、対応不能な request が代替 command を返さないことを必須にします。 C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index c139d5ed2..0aaa08053 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -2403,8 +2403,9 @@ private static bool IsProvenSystemRegexReceiver( } var resolution = reader.GetReferencePositionResolution(path, "Regex", line, regexIndex + 1, maxCandidates: 1); - return !resolution.IdentityAvailable - || (!resolution.CandidatesTruncated && resolution.Candidates.Count == 0); + return resolution.IdentityAvailable + && !resolution.CandidatesTruncated + && resolution.Candidates.Count == 0; } private static Dictionary BuildRegexBareReceiverContexts( @@ -2440,41 +2441,55 @@ private static Dictionary BuildRegexBareReceiv if (!contexts.TryGetValue(result.Path, out var context)) continue; - contexts[result.Path] = new RegexBareReceiverContext( - context.HasSystemNamespaceImport - || result.Content.Contains("using System.Text.RegularExpressions;", StringComparison.Ordinal), - context.HasAliasDeclaration || ContainsRegexAliasDeclaration(result.Content)); + foreach (var match in GetCodeSemanticMatches(result, "using")) + { + var directive = ParseRegexUsingDirective(match.ContinuationText, match.MarkerIndex); + context = new RegexBareReceiverContext( + context.HasSystemNamespaceImport || directive.HasSystemNamespaceImport, + context.HasAliasDeclaration || directive.HasAliasDeclaration); + } + + contexts[result.Path] = context; } } return contexts; } - private static bool ContainsRegexAliasDeclaration(string content) + private static RegexBareReceiverContext ParseRegexUsingDirective(string content, int usingIndex) { - for (var searchFrom = 0; searchFrom < content.Length;) + var cursor = usingIndex + "using".Length; + if ((usingIndex > 0 && IsIdentifierCharacter(content[usingIndex - 1])) + || (cursor < content.Length && IsIdentifierCharacter(content[cursor]))) { - var usingIndex = content.IndexOf("using", searchFrom, StringComparison.Ordinal); - if (usingIndex < 0) - return false; - searchFrom = usingIndex + "using".Length; - if ((usingIndex > 0 && IsIdentifierCharacter(content[usingIndex - 1])) - || (searchFrom < content.Length && IsIdentifierCharacter(content[searchFrom]))) - { - continue; - } + return new RegexBareReceiverContext(false, false); + } - var cursor = SkipCSharpTrivia(content, searchFrom); - var identifier = ExtractIdentifier(content, cursor); - if (!string.Equals(identifier, "Regex", StringComparison.Ordinal)) - continue; + cursor = SkipCSharpTrivia(content, cursor); + var firstIdentifier = ExtractIdentifier(content, cursor); + if (string.Equals(firstIdentifier, "Regex", StringComparison.Ordinal)) + { + cursor = SkipCSharpTrivia(content, cursor + firstIdentifier.Length); + return new RegexBareReceiverContext(false, cursor < content.Length && content[cursor] == '='); + } - cursor = SkipCSharpTrivia(content, cursor + identifier.Length); - if (cursor < content.Length && content[cursor] == '=') - return true; + foreach (var identifier in new[] { "System", "Text", "RegularExpressions" }) + { + var actual = ExtractIdentifier(content, cursor); + if (!string.Equals(actual, identifier, StringComparison.Ordinal)) + return new RegexBareReceiverContext(false, false); + cursor = SkipCSharpTrivia(content, cursor + actual.Length); + if (!string.Equals(identifier, "RegularExpressions", StringComparison.Ordinal)) + { + if (cursor >= content.Length || content[cursor] != '.') + return new RegexBareReceiverContext(false, false); + cursor = SkipCSharpTrivia(content, cursor + 1); + } } - return false; + return new RegexBareReceiverContext( + cursor < content.Length && content[cursor] == ';', + false); } private static int SkipCSharpTrivia(string text, int start) @@ -2571,11 +2586,8 @@ private static SearchRecipeSemanticEvidence GetShellExecuteAssignmentEvidence(Se return null; cursor += value.Length; - while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) - cursor++; - if (cursor == text.Length - || text[cursor] is ',' or ';' or '}' or ')' or ']' - || (cursor + 1 < text.Length && text[cursor] == '/' && text[cursor + 1] is '/' or '*')) + cursor = SkipCSharpTrivia(text, cursor); + if (cursor == text.Length || text[cursor] is ',' or ';' or '}' or ')' or ']') return value; return null; } @@ -2615,6 +2627,34 @@ private static IEnumerable GetSemanticEvidenceMatches return matches; } + private static IEnumerable GetCodeSemanticMatches(SearchResult result, string marker) + { + var contentLines = result.Content.Split('\n', StringSplitOptions.None); + for (var contentLineIndex = 0; contentLineIndex < contentLines.Length; contentLineIndex++) + { + var text = contentLines[contentLineIndex].TrimEnd('\r'); + var continuationText = string.Join('\n', contentLines.Skip(contentLineIndex)); + for (var searchFrom = 0; searchFrom < text.Length;) + { + var markerIndex = text.IndexOf(marker, searchFrom, StringComparison.Ordinal); + if (markerIndex < 0) + break; + searchFrom = markerIndex + marker.Length; + var line = result.StartLine + contentLineIndex; + var facet = SearchMatchClassifier.Classify( + result.Path, + result.Lang, + line, + text, + markerIndex + 1, + marker.Length, + result.EnclosingSymbolKind); + if (string.Equals(facet.Origin, SearchMatchClassifier.Code, StringComparison.Ordinal)) + yield return new SearchRecipeSemanticMatch(text, continuationText, line, markerIndex); + } + } + } + private sealed record SearchRecipeSemanticEvidence( bool Suppress, string Category, diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 7683fda6b..c1cad729e 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -503,7 +503,11 @@ private static SearchAuditRecipeQuery StaticRegexApiQuery(string name, string qu MatchOrigins = ["code"], }; - private static SearchAuditRecipeQuery DogfoodStaticRegexApiQuery(string name, string query, string shape) => + private static SearchAuditRecipeQuery DogfoodStaticRegexApiQuery( + string name, + string query, + string shape, + bool rejectBoundedRegexAlias = true) => new( name, query, @@ -511,10 +515,7 @@ private static SearchAuditRecipeQuery DogfoodStaticRegexApiQuery(string name, st ["audit", "performance", "security"], "Regex.Escape/Unescape are suppressed by matched-member semantics. Review retained execution members and unresolved receiver/member evidence for explicit timeout, generated/precompiled patterns, trusted small inputs, or intentional test behavior.") { - RejectFileQueries = - [ - BoundedRegexAliasUsing - ], + RejectFileQueries = rejectBoundedRegexAlias ? [BoundedRegexAliasUsing] : [], ExcludePaths = [BoundedRegexPath], RiskEvidence = [ @@ -1284,7 +1285,8 @@ private static SearchAuditRecipeQuery TimestampBoundaryQuery( DogfoodStaticRegexApiQuery( "static-regex-api-qualified", "RegularExpressions.Regex.", - "a fully qualified System.Text.RegularExpressions receiver"), + "a fully qualified System.Text.RegularExpressions receiver", + rejectBoundedRegexAlias: false), new( "relaxed-json-encoder", "UnsafeRelaxedJsonEscaping", diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index b4cd0699f..f56d7419f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -4445,6 +4445,7 @@ public void RunSearch_SafetyRecipesApplyOperationAndPolaritySemantics_Issue4911( { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_safety_semantics_4911"); var overfetchProjectRoot = TestProjectHelper.CreateTempProject("cdidx_search_safety_semantics_overfetch_4911"); + var shadowProjectRoot = TestProjectHelper.CreateTempProject("cdidx_search_safety_semantics_shadow_4911"); try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); @@ -4500,18 +4501,15 @@ public static class RegexAlias """); TestProjectHelper.InsertIndexedFile( dbPath, - "src/regex-source-shadow.cs", + "src/regex-comment-string-alias.cs", "csharp", """ - namespace Vendor; - - public static class Regex - { - public static string Escape(string input) => input; - } + using System.Text.RegularExpressions; + // using Regex = Vendor.Text.Regex; - public static class RegexSourceShadow + public static class RegexCommentStringAlias { + private const string Example = "using Regex = Vendor.Text.Regex;"; public static string Escape(string input) => Regex.Escape(input); } """); @@ -4535,6 +4533,18 @@ public static class RegexQualifiedRisk public static bool Match(string input) => System.Text.RegularExpressions.Regex.IsMatch(input, "token"); } """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/regex-qualified-risk-with-alias.cs", + "csharp", + """ + using Regex = CodeIndex.Indexer.BoundedRegex; + + public static class RegexQualifiedRiskWithAlias + { + public static bool Match(string input) => System.Text.RegularExpressions.Regex.IsMatch(input, "token"); + } + """); TestProjectHelper.InsertIndexedFile( dbPath, "src/regex-mixed-chunk.cs", @@ -4632,6 +4642,37 @@ public static class ShellPropagated public static ProcessStartInfo Build(bool useShell) => new() { UseShellExecute = useShell }; } """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/shell-computed-after-comments.cs", + "csharp", + """ + using System.Diagnostics; + + public static class ShellComputedAfterComments + { + public static ProcessStartInfo BuildBlock(bool enableShell) => new() + { + UseShellExecute = false /* default */ || enableShell + }; + + public static ProcessStartInfo BuildLine(bool enableShell) => new() + { + UseShellExecute = false // default + || enableShell + }; + } + """); + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + writer.MarkGraphReady(); + writer.SetMeta( + DbContext.ReferenceIdentityContractVersionMetaKey, + DbContext.ReferenceIdentityContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } var recipeArgs = new[] { @@ -4659,25 +4700,27 @@ public static class ShellPropagated var qualifiedRegexQuery = Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api-qualified"); var shellQuery = Assert.Single(queries, query => query.GetProperty("name").GetString() == "process-shell-execute"); - Assert.Equal(5, regexQuery.GetProperty("count").GetInt32()); + Assert.Equal(4, regexQuery.GetProperty("count").GetInt32()); Assert.DoesNotContain(regexQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-safe.cs"); + Assert.DoesNotContain(regexQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-comment-string-alias.cs"); AssertSemanticClassification(regexQuery, "src/regex-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); AssertSemanticClassification(regexQuery, "src/regex-unresolved.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.CustomOperation"); AssertSemanticClassification(regexQuery, "src/regex-mixed-chunk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); AssertSemanticClassification(regexQuery, "src/regex-alias.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.Escape"); - AssertSemanticClassification(regexQuery, "src/regex-source-shadow.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.Escape"); AssertClassifierCategories(regexQuery, "regex_operation_semantics", "safe_escape_helper", "regex_pattern_operation", "regex_operation_unresolved"); - Assert.Equal(1, qualifiedRegexQuery.GetProperty("count").GetInt32()); + Assert.Equal(2, qualifiedRegexQuery.GetProperty("count").GetInt32()); Assert.DoesNotContain(qualifiedRegexQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-qualified-safe.cs"); AssertSemanticClassification(qualifiedRegexQuery, "src/regex-qualified-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:System.Text.RegularExpressions.Regex.IsMatch"); + AssertSemanticClassification(qualifiedRegexQuery, "src/regex-qualified-risk-with-alias.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:System.Text.RegularExpressions.Regex.IsMatch"); - Assert.Equal(2, shellQuery.GetProperty("count").GetInt32()); + Assert.Equal(3, shellQuery.GetProperty("count").GetInt32()); Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-disabled.cs"); Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-named-disabled.cs"); Assert.DoesNotContain(shellQuery.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/shell-multiline-disabled.cs"); AssertSemanticClassification(shellQuery, "src/shell-enabled.cs", "shell_execute_polarity", "shell_explicitly_enabled", "value:true"); AssertSemanticClassification(shellQuery, "src/shell-propagated.cs", "shell_execute_polarity", "shell_policy_unresolved", "value:unresolved"); + AssertSemanticClassification(shellQuery, "src/shell-computed-after-comments.cs", "shell_execute_polarity", "shell_policy_unresolved", "value:unresolved"); AssertClassifierCategories(shellQuery, "shell_execute_polarity", "shell_explicitly_disabled", "shell_explicitly_enabled", "shell_policy_unresolved"); } @@ -4686,9 +4729,9 @@ public static class ShellPropagated using (var document = ParseJsonOutput(countStdout)) { var queries = document.RootElement.GetProperty("queries").EnumerateArray().ToArray(); - Assert.Equal(5, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api").GetProperty("count").GetInt32()); - Assert.Equal(1, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api-qualified").GetProperty("count").GetInt32()); - Assert.Equal(2, Assert.Single(queries, query => query.GetProperty("name").GetString() == "process-shell-execute").GetProperty("count").GetInt32()); + Assert.Equal(4, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api").GetProperty("count").GetInt32()); + Assert.Equal(2, Assert.Single(queries, query => query.GetProperty("name").GetString() == "static-regex-api-qualified").GetProperty("count").GetInt32()); + Assert.Equal(3, Assert.Single(queries, query => query.GetProperty("name").GetString() == "process-shell-execute").GetProperty("count").GetInt32()); } Assert.Equal(CommandExitCodes.Success, constructionExitCode); @@ -4699,23 +4742,67 @@ public static class ShellPropagated Assert.Contains(query.GetProperty("results").EnumerateArray(), result => result.GetProperty("path").GetString() == "src/regex-construction.cs"); } + var shadowDbPath = TestProjectHelper.CreateProjectDb(shadowProjectRoot); + TestProjectHelper.InsertIndexedFile( + shadowDbPath, + "src/regex-source-shadow.cs", + "csharp", + """ + namespace Vendor; + + public static class Regex + { + public static string Escape(string input) => input; + } + + public static class RegexSourceShadow + { + public static string Escape(string input) => Regex.Escape(input); + } + """); + using (var db = new DbContext(DbOpenIntent.WriteIndex, shadowDbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + writer.MarkGraphReady(); + writer.SetMeta( + DbContext.ReferenceIdentityContractVersionMetaKey, + DbContext.ReferenceIdentityContractVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + var (shadowExitCode, shadowStdout, shadowStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "dogfood-risk-patterns", "--include-query", "static-regex-api", "--db", shadowDbPath, "--json", "--limit", "20", "--lang", "csharp"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, shadowExitCode); + Assert.Equal(string.Empty, shadowStderr); + using (var document = ParseJsonOutput(shadowStdout)) + { + var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal(1, query.GetProperty("count").GetInt32()); + AssertSemanticClassification(query, "src/regex-source-shadow.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.Escape"); + } + var overfetchDbPath = TestProjectHelper.CreateProjectDb(overfetchProjectRoot); TestProjectHelper.InsertIndexedFile( overfetchDbPath, "src/a-risk.cs", "csharp", - "using System.Text.RegularExpressions; public static class Risk { public static bool Go(string input) => Regex.IsMatch(input, \"token\"); }"); + "public static class Risk { public static bool Go(string input) => System.Text.RegularExpressions.Regex.IsMatch(input, \"token\"); }"); for (var index = 1; index <= 250; index++) { TestProjectHelper.InsertIndexedFile( overfetchDbPath, $"src/z-safe-{index:D3}.cs", "csharp", - $"using System.Text.RegularExpressions; public static class Safe{index} {{ public static string Go(string input) => Regex.Escape(input); }}"); + $"public static class Safe{index} {{ public static string Go(string input) => System.Text.RegularExpressions.Regex.Escape(input); }}"); } + TestProjectHelper.InsertIndexedFile( + overfetchDbPath, + "src/legacy-unresolved.cs", + "csharp", + "using System.Text.RegularExpressions; public static class LegacySafe { public static string Go(string input) => Regex.Escape(input); }"); var (overfetchExitCode, overfetchStdout, overfetchStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["--recipe", "dogfood-risk-patterns", "--include-query", "static-regex-api", "--db", overfetchDbPath, "--json", "--limit", "1", "--lang", "csharp"], + ["--recipe", "dogfood-risk-patterns", "--include-query", "static-regex-api-qualified", "--db", overfetchDbPath, "--json", "--limit", "1", "--lang", "csharp"], _jsonOptions)); Assert.Equal(CommandExitCodes.Success, overfetchExitCode); Assert.Equal(string.Empty, overfetchStderr); @@ -4723,13 +4810,26 @@ public static class ShellPropagated { var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); Assert.Equal(1, query.GetProperty("count").GetInt32()); - AssertSemanticClassification(query, "src/a-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:Regex.IsMatch"); + AssertSemanticClassification(query, "src/a-risk.cs", "regex_operation_semantics", "regex_pattern_operation", "operation:System.Text.RegularExpressions.Regex.IsMatch"); + } + + var (legacyExitCode, legacyStdout, legacyStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "dogfood-risk-patterns", "--include-query", "static-regex-api", "--db", overfetchDbPath, "--json", "--limit", "20", "--lang", "csharp"], + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, legacyExitCode); + Assert.Equal(string.Empty, legacyStderr); + using (var document = ParseJsonOutput(legacyStdout)) + { + var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal(1, query.GetProperty("count").GetInt32()); + AssertSemanticClassification(query, "src/legacy-unresolved.cs", "regex_operation_semantics", "regex_operation_unresolved", "operation:Regex.Escape"); } } finally { TestProjectHelper.DeleteDirectory(projectRoot); TestProjectHelper.DeleteDirectory(overfetchProjectRoot); + TestProjectHelper.DeleteDirectory(shadowProjectRoot); } static void AssertSemanticClassification( From b6eb94a3fcd5690fef09eddc9bd3ea0e2ab38976 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 1 Aug 2026 04:38:16 +0900 Subject: [PATCH 4/4] Keep ad-hoc search fetch limits bounded (#4911) --- src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 0aaa08053..950c76ee3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -2140,7 +2140,7 @@ private static int GetSearchRecipeFetchLimit( int resultLimit, SearchAuditRecipeQuery? recipeQuery = null) { - if (recipeQuery?.SemanticFilter != SearchRecipeSemanticFilter.None) + if (recipeQuery is { SemanticFilter: not SearchRecipeSemanticFilter.None }) return int.MaxValue; var selectionTarget = resultLimit > 0 && options.SampleSize.HasValue