Files
syft/syft/pkg/cataloger/ai/huggingface.go
T
Alex Goodman 50dbb33e66 fix(ai): bound model companion file reads and validate their contents (#5344)
Companion files next to AI models (`config.json`, `README.md`, and the OCI model.file, license and model config layers) are now read with named size limits, and are only used when they look like what they claim to be.

- JSON companions are read whole up to a limit and skipped when larger, rather than being fully buffered or parsed truncated. OCI companion layers are fetched one byte past the cap so oversize is detectable.
- README and license layers read a prefix. The frontmatter block is capped at 256 KB and list values at 32.
- dir and OCI scans share one 8 MB safetensors header cap. Directory scans used to allow 100 MB.
- `config.json` is only used when it has `architectures` or `_name_or_path`, and is looked up beside the model or in its parent directory, no longer all the way up to the scan root
- OCI model.file layers are processed in a stable order, so the chosen name no longer changes between runs
- README frontmatter:
  - a list-form `license:` is accepted and no longer drops `base_model`
  - `null` values are ignored
  - delimiters must be exact `---` lines
- model names from `_name_or_path` / `base_model`:
  - Windows paths are handled
  - `.`, `..` and `/` fall back to the next name source
  - names are capped at 256 characters

Behavior changes worth noting:
- a `config.json` two or more directories above the model no longer names it
- JSON `config.json` files without `architectures` or `_name_or_path` (e.g. `generation_config.json`, app configs) are ignored
- safetensors headers over 8 MB in directory scans are now rejected, matching OCI scans (real shard headers are well under 1 MB)

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-09-29 09:33:57 -04:00

134 lines
3.4 KiB
Go

package ai
import (
"bytes"
"go.yaml.in/yaml/v3"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/pkg"
)
// hfConfig is a minimal projection of Hugging Face config.json fields.
type hfConfig struct {
Architectures []string `json:"architectures"`
NameOrPath string `json:"_name_or_path"`
}
// looksLikeHF reports whether the config carries any field we use. Other JSON
// (generation_config.json, unrelated app configs) is ignored.
func (c hfConfig) looksLikeHF() bool {
return len(c.Architectures) > 0 || c.NameOrPath != ""
}
func applyHFConfig(md *pkg.SafeTensorsModelInfo, cfg *hfConfig) {
if md.Architecture == "" && len(cfg.Architectures) > 0 {
md.Architecture = cfg.Architectures[0]
}
}
// readmeFrontmatter holds the subset of YAML frontmatter fields we extract.
type readmeFrontmatter struct {
Licenses []string
BaseModel []string
}
type licenseFrontmatter struct {
SPDXID string `yaml:"spdx-id"`
}
// extractFrontmatterBlock returns the YAML bytes between the first and second
// "---" delimiter lines of a file
func extractFrontmatterBlock(buf []byte) []byte {
trimmed := bytes.TrimLeft(buf, "\xef\xbb\xbf \t\r\n")
first, rest, _ := bytes.Cut(trimmed, []byte("\n"))
if !isFrontmatterDelimiter(first) {
return nil
}
for off := 0; off < len(rest); {
line, _, found := bytes.Cut(rest[off:], []byte("\n"))
if isFrontmatterDelimiter(line) {
if off == 0 || off > maxFrontmatterSize {
return nil
}
return rest[:off]
}
if !found {
break
}
off += len(line) + 1
}
return nil
}
// isFrontmatterDelimiter reports whether a line is exactly "---", ignoring
// trailing whitespace and \r.
func isFrontmatterDelimiter(line []byte) bool {
return bytes.Equal(bytes.TrimRight(line, " \t\r"), []byte("---"))
}
// parseFrontmatter decodes a Hugging Face model card YAML frontmatter block
// and returns the license and base_model fields.
func parseFrontmatter(buf []byte) *readmeFrontmatter {
block := extractFrontmatterBlock(buf)
if block == nil {
return nil
}
// both fields may be a scalar or a list, so decode them as nodes
var raw struct {
License yaml.Node `yaml:"license"`
BaseModel yaml.Node `yaml:"base_model"`
}
if err := yaml.Unmarshal(block, &raw); err != nil {
log.Debugf("failed to parse README frontmatter: %v", err)
return nil
}
return &readmeFrontmatter{
Licenses: yamlStrings(raw.License),
BaseModel: yamlStrings(raw.BaseModel),
}
}
// yamlStrings returns the non-empty, non-null values of a scalar or a sequence
// of scalars, keeping at most maxFrontmatterValues. Anything else yields nil.
func yamlStrings(n yaml.Node) []string {
switch n.Kind {
case yaml.ScalarNode:
if isYAMLString(&n) {
return []string{n.Value}
}
case yaml.SequenceNode:
var out []string
for _, c := range n.Content {
if len(out) == maxFrontmatterValues {
break
}
if c.Kind == yaml.ScalarNode && isYAMLString(c) {
out = append(out, c.Value)
}
}
return out
}
return nil
}
func isYAMLString(n *yaml.Node) bool {
return n.Value != "" && n.ShortTag() != "!!null"
}
// parseLicenseFrontmatter returns the producer-declared SPDX identifier
func parseLicenseFrontmatter(buf []byte) string {
block := extractFrontmatterBlock(buf)
if block == nil {
return ""
}
var fm licenseFrontmatter
if err := yaml.Unmarshal(block, &fm); err != nil {
log.Debugf("failed to parse license frontmatter: %v", err)
return ""
}
return fm.SPDXID
}