build-time-secret-injection — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited build-time-secret-injection (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Tuist assumption: the Project.swift snippets below assume the app's .xcodeproj is generated by Tuist from a root-level Project.swift (Tuist's own convention keeps Tuist/ for Package.swift and shared helpers, not for Project.swift itself). A hand-maintained .xcodeproj needs no Project.swift step — see Non-Tuist projects below for the equivalent (an xcconfig referenced directly from the target's Build Settings → Configurations, instead of via Project.swift).
Any task that introduces or wires values which are:
Info.plist, visible in shipped binary, observable in network traffic), ANDExamples:
.p8 key, key-id, issuer ID, ASC numeric app-idDo NOT invoke for:
apple-public-repo-security)| Value's consumer | Layer | Storage |
|---|---|---|
Xcode build / Info.plist / Bundle.main read | Layer 1 — xcconfig | Tuist/<Domain>.xcconfig (gitignored) |
CLI tooling (swift run <CLI>, shell scripts) | Layer 2 — .env | secrets/.env (gitignored) |
| Signing certs, CloudKit server-to-server key, APNs key | Not this skill | → apple-public-repo-security |
Layer 1 — Build-time secrets (consumed by Xcode build process)
Tuist/
├── <Domain>.xcconfig # gitignored, real values
├── <Domain>.xcconfig.example # committed, sandbox values + structure
├── Signing.xcconfig # existing precedent (gitignored)
└── Signing.xcconfig.example # existing precedent (committed)KEY = VALUE pairsProject.swift declares per-target settings(configurations: [.debug(name:, xcconfig:), .release(name:, xcconfig:)]) pointing at the file$(KEY) substitution to embed values at compile time — e.g. ADMOB_APP_ID = ca-app-pub-<publisher-id>~<app-id> in the xcconfig and <key>GADApplicationIdentifier</key><string>$(ADMOB_APP_ID)</string> in Info.plist (GADApplicationIdentifier is the key the Google Mobile Ads SDK reads at startup; ADMOB_APP_ID / ADMOB_BANNER_UNIT_ID are this project's own xcconfig names)Bundle.main.object(forInfoDictionaryKey: "...") — guarded against nil / empty / unresolved $() tokenci_scripts/ci_post_clone.sh): reads XCC env vars (stored as Secrets in ASC → Xcode Cloud → Workflow → Environment Variables) and generates the xcconfig file before tuist generate runsLayer 2 — CLI tooling secrets (consumed by `swift run <CLI>` etc.)
secrets/
├── .env # gitignored, real values
├── .env.example # committed, structure + docstring
├── <Domain>AuthKey_*.p8 # gitignored binary cert
└── .gitignore # deny-by-default: */!*.example/!README.md/!example/ /!example/**.env is KEY=VALUE shell-stylesource secrets/.env && swift run <CLI> --flag-using-$KEY ...let appTarget = Target.target(
// ...
settings: .settings(
base: ["SWIFT_VERSION": "6"],
configurations: [
.debug(name: "Debug", xcconfig: "Tuist/Config-Debug.xcconfig"),
.release(name: "Release", xcconfig: "Tuist/Config-Release.xcconfig"),
]
)
)Wrap multiple xcconfigs via a Config-{Debug,Release}.xcconfig that #include? both Signing + AdMob (Tuist's xcconfig: arg takes a single path).
ci_post_clone.shWhen one repo ships multiple app schemes (e.g. AppA + AppB), XCC sets $CI_PRODUCT and $CI_XCODE_SCHEME per workflow. For the case-switch that picks the right env-var prefix per scheme, read references/multi-app-ci-dispatch.md.
If the project uses a hand-edited .xcodeproj, the equivalent storage is Config/*.xcconfig referenced via target → Build Settings → Base Configuration. Pattern is otherwise unchanged. Tuist regen / clobbering concerns don't apply; manual sync remains your responsibility.
The substitution-resolution check must run against the built bundle's Info.plist, not the source-tree Info.plist:
// ❌ WRONG — reads source plist, gets literal "$(ADMOB_BANNER_UNIT_ID)" — passes falsely
// (ADMOB_BANNER_UNIT_ID is this project's own xcconfig key name, not one Google defines —
// see the multi-app xcconfig rendering above.)
let plist = try PropertyListSerialization.propertyList(from: sourceData, ...)
#expect((plist["ADMOB_BANNER_UNIT_ID"] as? String)?.isEmpty == false) // passes for "$(...)" string
// ✅ RIGHT — combine source-plist key-presence test + runtime guard in code
// Source test catches "someone deleted the key"; runtime guard catches "substitution failed"
guard
let bannerID = Bundle.main.object(forInfoDictionaryKey: "ADMOB_BANNER_UNIT_ID") as? String,
!bannerID.isEmpty,
!bannerID.hasPrefix("$(")
else { preconditionFailure("...") }Consider adding a build-phase script that asserts no $() literals survived substitution into the built .app/Info.plist; until one exists, the runtime guard above is the only catch.
fatalError("REPLACE_BEFORE_RELEASE: ...") pattern is acceptable as a TRANSITIONAL guard paired with xcconfig migration, but is forbidden as a long-term standalone solution. Once xcconfig is in place, replace with: Info.plist $() + runtime guard verifying Bundle.main.object(forInfoDictionaryKey:) returns non-empty AND non-$(...).source admob.env && xcodebuild archive does NOT populate $(ADMOB_APP_ID). Only positional xcodebuild VAR=value or -xcconfig override.xcconfig actually injects, OR a CI script writes the xcconfig file before build.as? String + guard let ... else { preconditionFailure } with the unresolved-$() check.secrets/.gitignore deny-list (* / !*.example / !README.md / !example/ / !example/** — the last two are required, otherwise * ignores the example/ directory and git never descends into it) PLUS root .gitignore rules Tuist/*.xcconfig + !Tuist/*.xcconfig.example so neither slips through default-add operations.Tuist/<Domain>.xcconfig exists but is NOT referenced in Project.swift's .settings(configurations:), Tuist regen drops it from the project. Verify Project.swift wiring before assuming xcconfig is active.Use this both when adding a new secret value and when auditing an existing implementation.
Bundle.main read → Layer 1 xcconfig; swift run / CLI scripts / shell → Layer 2 secrets/.env.gitignore has Tuist/*.xcconfig + !Tuist/*.xcconfig.example; secrets/.gitignore inner deny-list present (* / !*.example / !README.md / !example/ / !example/**) — git check-ignore -v secrets/example/README.md reports nothing.example file with a sandbox/test default value, with an inline comment naming the out-of-repo vault entry that holds the real value (password manager / team vault) — never the literal valueProject.swift per-target .settings(configurations:) references the xcconfigInfo.plist uses $(KEY) substitution for each secret; app code reads via Bundle.main.object(forInfoDictionaryKey:) with a guard (NOT as!) that rejects nil, empty, and the $(...) literalci_post_clone.sh writes the xcconfig from the XCC env var (${VAR:?missing message}) BEFORE tuist generate; if multi-app, case on $CI_XCODE_SCHEME selects per-app env vars — see references/multi-app-ci-dispatch.mdgrep -r "<real-prod-value>" . (excluding gitignored dirs) returns zero hits across all tracked filesapple-public-repo-security — broader secret-leak prevention (gitleaks, lefthook, GitHub Secret Scanning)monetization-sdk-integration — invoke together when wiring AdMob; this skill is the secret-handling layerasc-api-automation — ASC API key handling (the .p8) once the key leaves the build and drives the REST APIreferences/official-docs.md.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.