From 069757209a94f8e68cc5eec504d95d48e633ab2b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 20:00:28 -0400 Subject: [PATCH] =?UTF-8?q?fix(scriptanalysis):=20M3.6=20=E2=80=94=20full-?= =?UTF-8?q?framework=20analysis=20refs=20close=20forbidden-type-in-allowed?= =?UTF-8?q?-ns=20blind=20spot;=20pin=20Process/Stopwatch;=20fix=20stale=20?= =?UTF-8?q?codec=20test;=20drop=20dead=20ContainsInCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScriptTrustPolicy.cs | 62 ++++++++ .../ScriptTrustValidator.cs | 7 +- .../Validation/CSharpDelimiterScanner.cs | 132 ++---------------- .../ScriptTrustValidatorTests.cs | 17 +++ .../Scripts/ScopeAccessorTests.cs | 5 +- .../Validation/ScriptCompilerTests.cs | 25 ++++ 6 files changed, 121 insertions(+), 127 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs index c819ae1b..0d8a4901 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Reflection; using Microsoft.CodeAnalysis; using ZB.MOM.WW.ScadaBridge.Commons.Types; @@ -121,6 +122,67 @@ public static class ScriptTrustPolicy .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)) .ToList(); + /// + /// The full trusted-platform reference set used ONLY by + /// 's semantic analysis — NOT by + /// . Unlike + /// (the minimal, runtime-fidelity set used to decide script validity, + /// which must mirror exactly what the site runtime compiles against), the + /// trust validator references the entire framework so that EVERY type a + /// script names resolves to a real symbol and is judged by its true + /// namespace. Without this, a forbidden TYPE that sits inside an ALLOWED + /// namespace and is reached as a bare identifier — the only such case in the + /// policy being System.Diagnostics.Process via + /// using System.Diagnostics; — would not resolve against a minimal + /// reference set and would slip past the semantic pass (still blocked + /// downstream as an undefined-symbol compile error, but with a misleading + /// message). Referencing the full framework lets the validator flag it + /// authoritatively as a forbidden API. Enriching the analysis reference set + /// can only IMPROVE detection — the verdict is by namespace/type, so more + /// resolvable symbols means more correct verdicts, never a false allow. + /// + public static readonly IReadOnlyList AnalysisReferences = BuildAnalysisReferences(); + + private static IReadOnlyList BuildAnalysisReferences() + { + var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Trusted platform assemblies = the full framework reference set the host + // started with; lets the semantic pass resolve any BCL type. + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string tpa) + { + foreach (var path in tpa.Split(Path.PathSeparator)) + { + if (path.Length == 0 || + !path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || + byPath.ContainsKey(path) || + !File.Exists(path)) + { + continue; + } + + try { byPath[path] = MetadataReference.CreateFromFile(path); } + catch { /* skip an unreadable assembly rather than fail validation */ } + } + } + + // Ensure app assemblies the script API surface needs are present even if + // not in the TPA list (e.g. Commons / DynamicJsonElement). + foreach (var asm in DefaultAssemblies) + { + var loc = asm.Location; + if (loc.Length == 0 || byPath.ContainsKey(loc) || !File.Exists(loc)) + continue; + + try { byPath[loc] = MetadataReference.CreateFromFile(loc); } + catch { /* ignore */ } + } + + // Fallback to the minimal set if the TPA list was unavailable (e.g. a + // single-file/AOT host) so validation still functions. + return byPath.Count > 0 ? byPath.Values.ToList() : DefaultReferences; + } + /// /// Default namespace imports made available to compiled scripts. /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs index d0335f31..bbe2c7c1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs @@ -79,7 +79,12 @@ public static class ScriptTrustValidator var violations = new SortedSet(StringComparer.Ordinal); // ---- Pass 1: semantic symbol analysis (ported from SiteRuntime) ---- - var references = ScriptTrustPolicy.DefaultReferences.ToList(); + // Use the full trusted-platform reference set (not the minimal + // runtime-fidelity DefaultReferences) so EVERY type a script names + // resolves and is judged by its true namespace — closing the + // forbidden-type-in-allowed-namespace blind spot (e.g. a bare + // System.Diagnostics.Process via `using System.Diagnostics;`). + var references = ScriptTrustPolicy.AnalysisReferences.ToList(); if (extraReferences != null) references.AddRange(extraReferences); diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/CSharpDelimiterScanner.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/CSharpDelimiterScanner.cs index 3fc99c74..b9a7c41a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/CSharpDelimiterScanner.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/CSharpDelimiterScanner.cs @@ -2,12 +2,11 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; /// /// String/comment-aware scanner for the balanced-delimiter ("does it look like -/// valid C#") checks used by and -/// SharedScriptService.ValidateSyntax. +/// valid C#") check used by SharedScriptService.ValidateSyntax. /// /// -/// This is not a compiler. It is an interim structural check that walks -/// the source once and tracks {}, [] and () depth while +/// This is not a compiler. It is a structural check that walks the +/// source once and tracks {}, [] and () depth while /// correctly skipping over the C# lexical constructs in which a delimiter is /// inert: line/block comments, regular string literals (with \ escapes), /// verbatim strings (@"...", where "" escapes a quote and \ @@ -17,11 +16,11 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; /// /// /// -/// It is intentionally conservative: when the real Roslyn-based compiler is -/// wired in (see ) this hand-rolled scan should be -/// replaced by CSharpSyntaxTree.ParseText diagnostics. Until then this -/// scanner removes the false positives that a naive character count produced -/// for valid scripts containing a delimiter inside a string or comment. +/// Trust enforcement and full compilation are NOT done here — those are the +/// authoritative (which delegates to the shared +/// ZB.MOM.WW.ScadaBridge.ScriptAnalysis validator + Roslyn compile). This +/// scanner only provides SharedScriptService a cheap pre-compile sanity +/// check for balanced delimiters. /// /// internal static class CSharpDelimiterScanner @@ -41,121 +40,6 @@ internal static class CSharpDelimiterScanner UnterminatedChar, } - /// - /// Returns true when occurs in a code - /// region of — i.e. not wholly inside a string - /// literal, char literal, or comment. Used by the interim forbidden-API - /// scan so that the inert text System.IO. in a comment or string - /// literal is not flagged as a forbidden API call (TemplateEngine-006). - /// - /// - /// This removes the false-positive half of the substring scan. It does - /// not close the bypass half: namespace aliases, using static, - /// and global::-qualified references still evade a pure text match. - /// Authoritative forbidden-API enforcement requires Roslyn semantic symbol - /// analysis and is deferred to the real script compiler / Site Runtime - /// sandbox; this check is advisory only. - /// - /// - /// The C# source code to scan. - /// The substring to search for in code regions only. - /// true if occurs in a code region (not inside a comment, string, or char literal); otherwise false. - internal static bool ContainsInCode(string code, string pattern) - { - if (string.IsNullOrEmpty(pattern)) - return false; - - // Blank out every string/char-literal/comment span, then do an ordinary - // substring search over what remains (the code regions). - var codeOnly = BlankNonCodeSpans(code); - return codeOnly.Contains(pattern, StringComparison.Ordinal); - } - - /// - /// Replaces the content of every comment, string literal, and char literal - /// with spaces (newlines preserved), leaving only code regions intact. - /// Delimiter characters themselves are also blanked so a pattern cannot - /// straddle a literal boundary. - /// - private static string BlankNonCodeSpans(string code) - { - var buffer = code.ToCharArray(); - int n = code.Length; - int i = 0; - - void Blank(int from, int to) - { - for (int k = from; k < to && k < n; k++) - if (buffer[k] != '\n' && buffer[k] != '\r') - buffer[k] = ' '; - } - - while (i < n) - { - char c = code[i]; - char next = i + 1 < n ? code[i + 1] : '\0'; - int start = i; - - if (c == '/' && next == '/') - { - i += 2; - while (i < n && code[i] != '\n') i++; - Blank(start, i); - continue; - } - if (c == '/' && next == '*') - { - i += 2; - while (i < n && !(code[i] == '*' && i + 1 < n && code[i + 1] == '/')) i++; - if (i < n) i += 2; - Blank(start, i); - continue; - } - if (c == '"' && next == '"' && i + 2 < n && code[i + 2] == '"') - { - SkipRawString(code, ref i); - Blank(start, i); - continue; - } - if (c == '$') - { - int j = i + 1; - bool verbatim = false; - if (j < n && code[j] == '@') { verbatim = true; j++; } - if (j < n && code[j] == '"') - { - i = j; - SkipInterpolatedString(code, ref i, verbatim); - Blank(start, i); - continue; - } - } - if (c == '@' && next == '"') - { - i++; - SkipVerbatimString(code, ref i); - Blank(start, i); - continue; - } - if (c == '"') - { - SkipRegularString(code, ref i); - Blank(start, i); - continue; - } - if (c == '\'') - { - SkipCharLiteral(code, ref i); - Blank(start, i); - continue; - } - - i++; - } - - return new string(buffer); - } - /// /// Walks once and reports the first structural /// delimiter problem, or when the source is diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs index 431b6714..be77f8c2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs @@ -147,6 +147,23 @@ public class ScriptTrustValidatorTests Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } + [Fact] + public void Rejects_Process_QualifiedType() + { + var code = "var p = System.Diagnostics.Process.Start(\"x\");"; + Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); + } + + [Fact] + public void Rejects_Process_BareIdentifier_ViaUsing() + { + // System.Diagnostics is an ALLOWED namespace (Stopwatch/Debug ok), so the + // using directive is not flagged; Process is a forbidden TYPE reached as a + // bare identifier. This pins whether FindViolations resolves it. + var code = "using System.Diagnostics; var p = Process.Start(\"x\");"; + Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); + } + // ---- Clean (empty violations) ------------------------------------------- [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs index 73458ab8..048a2449 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs @@ -130,9 +130,10 @@ public class ScopeAccessorTests [Fact] public void AttributeValueCodec_Encode_IntList_ProducesJsonArray() { - // Integer list elements encode via InvariantCulture IFormattable. + // Integer list elements encode as native-typed JSON numbers (NJ-1): + // [1,2,3], not the old quoted-element form ["1","2","3"]. var list = new List { 1, 2, 3 }; var encoded = AttributeValueCodec.Encode(list); - Assert.Equal("[\"1\",\"2\",\"3\"]", encoded); + Assert.Equal("[1,2,3]", encoded); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs index e066cfcb..2f37665a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs @@ -56,6 +56,31 @@ public class ScriptCompilerTests Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void TryCompile_ForbiddenTypeInAllowedNamespace_RejectedAsForbidden() + { + // System.Diagnostics is an ALLOWED namespace (Stopwatch/Debug ok), so the + // `using` directive can't be flagged; Process is a forbidden TYPE reached + // as a bare identifier. The validator's full-framework semantic resolution + // must catch it authoritatively as a forbidden API (not merely as an + // undefined-symbol compile error). + var result = _sut.TryCompile( + "using System.Diagnostics; var p = Process.Start(\"x\");", "Test"); + Assert.True(result.IsFailure); + Assert.Contains("forbidden", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryCompile_StopwatchInAllowedDiagnostics_ReturnsSuccess() + { + // The companion to the Process case: Stopwatch lives in the same allowed + // System.Diagnostics namespace and must NOT be flagged. + var result = _sut.TryCompile( + "using System.Diagnostics; var sw = Stopwatch.StartNew(); var e = sw.ElapsedMilliseconds;", + "Test"); + Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null); + } + // --- Real-compile gate (the win over the old structural-only scan) --- [Fact]