diff --git a/pkg/attestation/crafter/materials/accesschk.go b/pkg/attestation/crafter/materials/accesschk.go index 934f76b40..c9d97d249 100644 --- a/pkg/attestation/crafter/materials/accesschk.go +++ b/pkg/attestation/crafter/materials/accesschk.go @@ -62,6 +62,18 @@ func (i *AccessChkCrafter) Craft(ctx context.Context, filePath string) (*api.Att return nil, fmt.Errorf("input does not look like accesschk output: %w", ErrInvalidMaterialType) } + // The material is attested by digest and stored as-is, but at policy + // evaluation time it is projected to JSON client-side on the runner. Above + // this size the parser omits the verbatim raw-text fallback to keep peak + // memory bounded; warn so an operator relying on string-matching policies is + // aware the fallback fields will not be present for this material. + if len(data) > accesschk.RawRetentionLimit { + i.logger.Warn(). + Int("size", len(data)). + Int("threshold", accesschk.RawRetentionLimit). + Msg("large AccessChk material: raw-text fallback fields are omitted from the policy input to limit memory use") + } + m, err := uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger) if err != nil { return nil, err diff --git a/pkg/attestation/crafter/materials/accesschk/accesschk.go b/pkg/attestation/crafter/materials/accesschk/accesschk.go index e4b928b07..344d3212f 100644 --- a/pkg/attestation/crafter/materials/accesschk/accesschk.go +++ b/pkg/attestation/crafter/materials/accesschk/accesschk.go @@ -17,12 +17,23 @@ // (https://learn.microsoft.com/en-us/sysinternals/downloads/accesschk) into a // structured representation. AccessChk has no machine-readable output mode, so // the parser is intentionally tolerant: anything it cannot recognize is -// preserved verbatim and the full original text is always retained in Raw, so a -// policy can fall back to string matching regardless of the output mode used. +// preserved verbatim and, for inputs below RawRetentionLimit, the full original +// text is retained in Raw so a policy can fall back to string matching +// regardless of the output mode used. +// +// The parser streams the input line by line rather than building a normalized +// full-text copy, and it size-gates the verbatim fallback fields (Raw and the +// per-object RawLines). Both measures keep peak memory bounded: a +// several-hundred-MB material would otherwise pin multiple copies of itself in +// memory and double the JSON document handed to the policy engine, which has +// OOM-killed CI runners during client-side policy evaluation. package accesschk import ( + "bufio" + "bytes" "fmt" + "io" "regexp" "strings" "unicode/utf8" @@ -31,6 +42,15 @@ import ( // ToolName is the canonical tool name recorded for AccessChk materials. const ToolName = "AccessChk" +// RawRetentionLimit is the maximum input size (in bytes) for which Parse retains +// the verbatim fallback fields Raw and RawLines. Above it these fields are +// omitted: they are not part of the attestation (only the original file's digest +// is attested) and no policy reads them, so trimming them for oversized inputs +// does not change the recorded evidence or any current evaluation — it only +// prevents the transient JSON projection handed to the policy engine from +// ballooning to multiples of the original file size. +const RawRetentionLimit = 10 * 1024 * 1024 // 10 MiB + // versionRe extracts the AccessChk version from its banner, e.g. "Accesschk v6.15". var versionRe = regexp.MustCompile(`(?i)accesschk v([0-9][0-9.]*)`) @@ -84,154 +104,200 @@ type Object struct { } // Report is the structured projection of an AccessChk run. +// +// Raw holds the full original text for inputs below RawRetentionLimit and is +// empty otherwise; descriptorMarker records whether an SDDL/descriptor marker +// was seen during parsing so LooksLikeAccessChk stays reliable even when Raw is +// omitted for oversized inputs. type Report struct { Tool Tool `json:"tool"` Objects []Object `json:"objects"` Raw string `json:"raw"` + + descriptorMarker bool } // Parse converts AccessChk text output into a Report. It only returns an error // when the input is not valid UTF-8 text; well-formed text always parses, with // any unrecognized content preserved in the per-object RawLines and the -// top-level Raw field. +// top-level Raw field for inputs below RawRetentionLimit (see the package doc). func Parse(data []byte) (*Report, error) { if !utf8.Valid(data) { return nil, fmt.Errorf("input is not valid UTF-8 text") } - raw := string(data) + // Retain the verbatim fallback fields only for inputs small enough that the + // resulting JSON projection stays bounded; see RawRetentionLimit. + retainRaw := len(data) <= RawRetentionLimit + report := &Report{ Tool: Tool{Name: ToolName}, Objects: []Object{}, - Raw: raw, } - - if m := versionRe.FindStringSubmatch(raw); m != nil { - report.Tool.Version = m[1] + if retainRaw { + report.Raw = string(data) } - normalized := strings.ReplaceAll(raw, "\r\n", "\n") var current *Object var entryIndent int - - // State for the -l (full security descriptor) mode. - const ( - sectNone = iota - sectDescriptorFlags - sectDACL - sectSACL - ) section := sectNone var currentACE *ACE - for _, line := range strings.Split(normalized, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" || isBannerLine(trimmed) { - continue + // Stream line by line instead of normalizing and splitting the whole input + // at once. bufio.Reader.ReadString grows to fit arbitrarily long lines and + // returns freshly allocated strings, so stored substrings do not pin the + // entire input in memory. + reader := bufio.NewReader(bytes.NewReader(data)) + for { + raw, readErr := reader.ReadString('\n') + line := strings.TrimSuffix(raw, "\n") + line = strings.TrimSuffix(line, "\r") + + if line != "" || readErr == nil { + processLine(report, line, ¤t, &entryIndent, §ion, ¤tACE, retainRaw) } - indent := len(line) - len(strings.TrimLeft(line, " \t")) - - // A line at column zero starts a new object. - if indent == 0 { - report.Objects = append(report.Objects, Object{ - Name: trimmed, - AccessEntries: []AccessEntry{}, - RawLines: []string{}, - }) - current = &report.Objects[len(report.Objects)-1] - entryIndent = -1 - section = sectNone - currentACE = nil - continue - } - - // Indented content before any object header is dropped. - if current == nil { - continue + if readErr != nil { + if readErr != io.EOF { + return nil, fmt.Errorf("reading accesschk output: %w", readErr) + } + break } + } - current.RawLines = append(current.RawLines, line) - - // Compact default (R/W) output mode. - if m := accessEntryRe.FindStringSubmatch(trimmed); m != nil { - current.AccessEntries = append(current.AccessEntries, AccessEntry{ - Access: m[1], - Principal: m[2], - Rights: []string{}, - }) - entryIndent = indent - currentACE = nil - continue - } + return report, nil +} - // -l (full security descriptor) section headers. - switch { - case strings.HasPrefix(trimmed, "DESCRIPTOR FLAGS"): - section = sectDescriptorFlags - currentACE = nil - continue - case strings.HasPrefix(trimmed, "OWNER:"): - current.Owner = strings.TrimSpace(strings.TrimPrefix(trimmed, "OWNER:")) - currentACE = nil - continue - case strings.HasPrefix(trimmed, "DACL"): - section = sectDACL - currentACE = nil - continue - case strings.HasPrefix(trimmed, "SACL"): - section = sectSACL - currentACE = nil - continue - } +// sect* constants for the -l (full security descriptor) mode section state. +const ( + sectNone = iota + sectDescriptorFlags + sectDACL + sectSACL +) - // -l numbered ACE lines (DACL by default, SACL once inside a SACL block). - if m := aceRe.FindStringSubmatch(trimmed); m != nil { - ace := ACE{ - Index: atoi(m[1]), - AceType: strings.TrimSpace(m[2]), - Principal: strings.TrimSpace(m[3]), - AceFlags: []string{}, - Rights: []string{}, - } - if section == sectSACL { - current.SACL = append(current.SACL, ace) - currentACE = ¤t.SACL[len(current.SACL)-1] - } else { - section = sectDACL - current.DACL = append(current.DACL, ace) - currentACE = ¤t.DACL[len(current.DACL)-1] +// processLine folds a single (newline-stripped) line into the report, advancing +// the parser's cursor into the current object, access entry and descriptor +// section. It is the per-line body of Parse's streaming loop. +func processLine(report *Report, line string, current **Object, entryIndent *int, section *int, currentACE **ACE, retainRaw bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || isBannerLine(trimmed) { + if report.Tool.Version == "" { + if m := versionRe.FindStringSubmatch(trimmed); m != nil { + report.Tool.Version = m[1] } - continue } + return + } - // Detail lines: bracketed tokens are flags, bare tokens are rights. - isFlag := strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") - token := strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]") + // Track SDDL/descriptor markers so LooksLikeAccessChk works without Raw. + if !report.descriptorMarker && + (strings.Contains(trimmed, "DESCRIPTOR FLAGS") || strings.Contains(trimmed, "ACCESS_ALLOWED")) { + report.descriptorMarker = true + } - if currentACE != nil { - if isFlag { - currentACE.AceFlags = append(currentACE.AceFlags, token) - } else { - currentACE.Rights = append(currentACE.Rights, trimmed) - } - continue + indent := len(line) - len(strings.TrimLeft(line, " \t")) + + // A line at column zero starts a new object. + if indent == 0 { + obj := Object{Name: trimmed, AccessEntries: []AccessEntry{}} + if retainRaw { + obj.RawLines = []string{} } + report.Objects = append(report.Objects, obj) + *current = &report.Objects[len(report.Objects)-1] + *entryIndent = -1 + *section = sectNone + *currentACE = nil + return + } + + // Indented content before any object header is dropped. + if *current == nil { + return + } + + cur := *current + if retainRaw { + cur.RawLines = append(cur.RawLines, line) + } + + // Compact default (R/W) output mode. + if m := accessEntryRe.FindStringSubmatch(trimmed); m != nil { + cur.AccessEntries = append(cur.AccessEntries, AccessEntry{ + Access: m[1], + Principal: m[2], + Rights: []string{}, + }) + *entryIndent = indent + *currentACE = nil + return + } + + // -l (full security descriptor) section headers. + switch { + case strings.HasPrefix(trimmed, "DESCRIPTOR FLAGS"): + *section = sectDescriptorFlags + *currentACE = nil + return + case strings.HasPrefix(trimmed, "OWNER:"): + cur.Owner = strings.TrimSpace(strings.TrimPrefix(trimmed, "OWNER:")) + *currentACE = nil + return + case strings.HasPrefix(trimmed, "DACL"): + *section = sectDACL + *currentACE = nil + return + case strings.HasPrefix(trimmed, "SACL"): + *section = sectSACL + *currentACE = nil + return + } - if section == sectDescriptorFlags { - current.DescriptorFlags = append(current.DescriptorFlags, token) - continue + // -l numbered ACE lines (DACL by default, SACL once inside a SACL block). + if m := aceRe.FindStringSubmatch(trimmed); m != nil { + ace := ACE{ + Index: atoi(m[1]), + AceType: strings.TrimSpace(m[2]), + Principal: strings.TrimSpace(m[3]), + AceFlags: []string{}, + Rights: []string{}, + } + if *section == sectSACL { + cur.SACL = append(cur.SACL, ace) + *currentACE = &cur.SACL[len(cur.SACL)-1] + } else { + *section = sectDACL + cur.DACL = append(cur.DACL, ace) + *currentACE = &cur.DACL[len(cur.DACL)-1] } + return + } - // A line indented deeper than the compact access entry it follows is a - // specific right (only emitted under -v); attach it to the entry. - if entryIndent >= 0 && indent > entryIndent && len(current.AccessEntries) > 0 { - last := ¤t.AccessEntries[len(current.AccessEntries)-1] - last.Rights = append(last.Rights, trimmed) + // Detail lines: bracketed tokens are flags, bare tokens are rights. + isFlag := strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") + token := strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]") + + if ace := *currentACE; ace != nil { + if isFlag { + ace.AceFlags = append(ace.AceFlags, token) + } else { + ace.Rights = append(ace.Rights, trimmed) } + return } - return report, nil + if *section == sectDescriptorFlags { + cur.DescriptorFlags = append(cur.DescriptorFlags, token) + return + } + + // A line indented deeper than the compact access entry it follows is a + // specific right (only emitted under -v); attach it to the entry. + if *entryIndent >= 0 && indent > *entryIndent && len(cur.AccessEntries) > 0 { + last := &cur.AccessEntries[len(cur.AccessEntries)-1] + last.Rights = append(last.Rights, trimmed) + } } // atoi parses a non-negative integer, returning 0 on failure. ACE indexes are @@ -260,10 +326,9 @@ func (r *Report) LooksLikeAccessChk() bool { return true } } - if strings.Contains(r.Raw, "DESCRIPTOR FLAGS") || strings.Contains(r.Raw, "ACCESS_ALLOWED") { - return true - } - return false + // descriptorMarker is set during parsing when an SDDL/descriptor marker is + // seen, so this fallback works even when Raw is omitted for oversized inputs. + return r.descriptorMarker } // isBannerLine reports whether a trimmed line belongs to the AccessChk startup diff --git a/pkg/attestation/crafter/materials/accesschk/gate_test.go b/pkg/attestation/crafter/materials/accesschk/gate_test.go new file mode 100644 index 000000000..34352efd5 --- /dev/null +++ b/pkg/attestation/crafter/materials/accesschk/gate_test.go @@ -0,0 +1,135 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package accesschk_test + +import ( + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/accesschk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildLargeInput returns a valid AccessChk default-mode document whose size +// exceeds targetSize bytes, made of repeated per-object blocks. +func buildLargeInput(targetSize int) []byte { + var b strings.Builder + b.WriteString("Accesschk v6.15 - Reports effective permissions for securable objects\n") + b.WriteString("Copyright (C) 2006-2023 Mark Russinovich\n") + b.WriteString("Sysinternals - www.sysinternals.com\n\n") + i := 0 + for b.Len() <= targetSize { + b.WriteString("c:\\windows\\system32\\object") + b.WriteString(strings.Repeat("x", 8)) + b.WriteString("\n") + b.WriteString(" RW NT AUTHORITY\\SYSTEM\n") + b.WriteString(" RW BUILTIN\\Administrators\n") + b.WriteString(" R BUILTIN\\Users\n") + i++ + } + return []byte(b.String()) +} + +// TestParse_LargeInputOmitsRawFields verifies that above RawRetentionLimit the +// verbatim fallback fields (Raw / RawLines) are omitted while the structured +// projection is still produced. This is the memory guard: those fields are not +// attested and unused by policies, so trimming them for oversized inputs keeps +// the OPA input document from ballooning. +func TestParse_LargeInputOmitsRawFields(t *testing.T) { + data := buildLargeInput(accesschk.RawRetentionLimit + 1) + require.Greater(t, len(data), accesschk.RawRetentionLimit) + + report, err := accesschk.Parse(data) + require.NoError(t, err) + + assert.Empty(t, report.Raw, "Raw must be omitted above the retention limit") + require.NotEmpty(t, report.Objects) + for _, obj := range report.Objects { + assert.Empty(t, obj.RawLines, "RawLines must be omitted above the retention limit") + } + + // Structured parsing is unaffected: access entries are still populated. + assert.Equal(t, "6.15", report.Tool.Version) + assert.Len(t, report.Objects[0].AccessEntries, 3) + assert.True(t, report.LooksLikeAccessChk()) +} + +// TestParse_SmallInputRetainsRawFields verifies that below the limit the +// verbatim fields are retained, preserving the string-matching fallback for +// normal-sized evidence. +func TestParse_SmallInputRetainsRawFields(t *testing.T) { + data := []byte("c:\\file\n RW BUILTIN\\Administrators\n") + + report, err := accesschk.Parse(data) + require.NoError(t, err) + + assert.Equal(t, string(data), report.Raw) + require.Len(t, report.Objects, 1) + assert.Equal(t, []string{" RW BUILTIN\\Administrators"}, report.Objects[0].RawLines) +} + +// TestParse_LargeSDDLStillDetected verifies that an oversized descriptor-only +// document (no compact access entries) is still recognized as AccessChk output +// even though Raw is omitted, i.e. detection does not depend on Raw. +func TestParse_LargeSDDLStillDetected(t *testing.T) { + var b strings.Builder + b.WriteString("Accesschk v6.15\n\n") + for b.Len() <= accesschk.RawRetentionLimit { + b.WriteString("c:\\windows\\system32\\object\n") + b.WriteString(" DESCRIPTOR FLAGS:\n") + b.WriteString(" [SE_DACL_PRESENT]\n") + b.WriteString(" OWNER: NT SERVICE\\TrustedInstaller\n") + } + data := []byte(b.String()) + require.Greater(t, len(data), accesschk.RawRetentionLimit) + + report, err := accesschk.Parse(data) + require.NoError(t, err) + + assert.Empty(t, report.Raw) + assert.True(t, report.LooksLikeAccessChk(), "descriptor marker detection must not depend on Raw") +} + +// TestParse_CRLFLineEndings verifies the streaming line reader strips a +// trailing CR from CRLF-terminated lines, matching the LF behavior. +func TestParse_CRLFLineEndings(t *testing.T) { + data := []byte("c:\\file\r\n RW Everyone\r\n") + + report, err := accesschk.Parse(data) + require.NoError(t, err) + + require.Len(t, report.Objects, 1) + assert.Equal(t, "c:\\file", report.Objects[0].Name) + require.Len(t, report.Objects[0].AccessEntries, 1) + assert.Equal(t, "Everyone", report.Objects[0].AccessEntries[0].Principal) + // The trailing CR from the CRLF terminator is stripped, matching LF handling. + assert.Equal(t, []string{" RW Everyone"}, report.Objects[0].RawLines) +} + +// TestParse_VeryLongLine verifies the streaming reader handles a single line +// far larger than a typical scanner token buffer without erroring. +func TestParse_VeryLongLine(t *testing.T) { + principal := strings.Repeat("A", 128*1024) + data := []byte("c:\\file\n RW " + principal + "\n") + + report, err := accesschk.Parse(data) + require.NoError(t, err) + + require.Len(t, report.Objects, 1) + require.Len(t, report.Objects[0].AccessEntries, 1) + assert.Equal(t, principal, report.Objects[0].AccessEntries[0].Principal) +}