mirror of
https://github.com/zizmorcore/zizmor.git
synced 2025-12-23 08:47:33 +00:00
feat: begin scaffolding for Dependabot support (#1215)
Some checks are pending
Benchmark baseline / Continuous Benchmarking with Bencher (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Test (push) Waiting to run
CI / Test site build (push) Waiting to run
CI / All tests pass (push) Blocked by required conditions
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (manylinux) (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (musllinux) (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Release (push) Blocked by required conditions
zizmor wheel builds for PyPI 🐍 / Build Windows wheels (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Build macOS wheels (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Build source distribution (push) Waiting to run
Deploy zizmor documentation site 🌐 / Deploy zizmor documentation to GitHub Pages 🌐 (push) Waiting to run
GitHub Actions Security Analysis with zizmor 🌈 / Run zizmor 🌈 (push) Waiting to run
Some checks are pending
Benchmark baseline / Continuous Benchmarking with Bencher (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Test (push) Waiting to run
CI / Test site build (push) Waiting to run
CI / All tests pass (push) Blocked by required conditions
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (manylinux) (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Build Linux wheels (musllinux) (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Release (push) Blocked by required conditions
zizmor wheel builds for PyPI 🐍 / Build Windows wheels (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Build macOS wheels (push) Waiting to run
zizmor wheel builds for PyPI 🐍 / Build source distribution (push) Waiting to run
Deploy zizmor documentation site 🌐 / Deploy zizmor documentation to GitHub Pages 🌐 (push) Waiting to run
GitHub Actions Security Analysis with zizmor 🌈 / Run zizmor 🌈 (push) Waiting to run
This commit is contained in:
parent
bee86b1bb2
commit
fbd86b5955
15 changed files with 1442 additions and 10 deletions
1
Makefile
1
Makefile
|
|
@ -30,6 +30,7 @@ docs/snippets/sponsors.html: docs/snippets/sponsors.json docs/snippets/render-sp
|
|||
refresh-schemas:
|
||||
curl https://www.schemastore.org/github-workflow.json > crates/zizmor/src/data/github-workflow.json
|
||||
curl https://www.schemastore.org/github-action.json > crates/zizmor/src/data/github-action.json
|
||||
curl https://www.schemastore.org/dependabot-2.0.json > crates/zizmor/src/data/dependabot-2.0.json
|
||||
|
||||
.PHONY: webhooks-to-contexts
|
||||
webhooks-to-contexts:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ github-actions-models
|
|||
[](https://github.com/sponsors/woodruffw)
|
||||
[](https://discord.com/invite/PGU3zGZuGG)
|
||||
|
||||
Unofficial, high-quality data models for GitHub Actions workflows, actions, and related components.
|
||||
Unofficial, high-quality data models for GitHub Actions workflows, actions, and
|
||||
Dependabot configuration files.
|
||||
|
||||
## Why?
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,26 @@ pub enum Registry {
|
|||
},
|
||||
}
|
||||
|
||||
/// Cooldown settings for Dependabot updates.
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct Cooldown {
|
||||
pub default_days: Option<u64>,
|
||||
pub semver_major_days: Option<u64>,
|
||||
pub semver_minor_days: Option<u64>,
|
||||
pub semver_patch_days: Option<u64>,
|
||||
pub include: Vec<String>,
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
/// A `directory` or `directories` field in a Dependabot `update` directive.
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Directories {
|
||||
Directory(String),
|
||||
Directories(Vec<String>),
|
||||
}
|
||||
|
||||
/// A single `update` directive.
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
|
|
@ -97,7 +117,9 @@ pub struct Update {
|
|||
#[serde(default)]
|
||||
pub assignees: IndexSet<String>,
|
||||
pub commit_message: Option<CommitMessage>,
|
||||
pub directory: String,
|
||||
pub cooldown: Option<Cooldown>,
|
||||
#[serde(flatten)]
|
||||
pub directories: Directories,
|
||||
#[serde(default)]
|
||||
pub groups: IndexMap<String, Group>,
|
||||
#[serde(default)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates#setting-up-a-cooldown-period-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
include:
|
||||
- "requests"
|
||||
- "numpy"
|
||||
- "pandas*"
|
||||
- "django"
|
||||
exclude:
|
||||
- "pandas"
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# https://github.com/grafana/grafana/blob/0de6d103c286ae8c0380dd420dfaec24ee706fe9/.github/dependabot.yml
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "gomod"
|
||||
directories:
|
||||
- "/"
|
||||
- "/apps/playlist"
|
||||
- "/apps/secret"
|
||||
- "/apps/investigations"
|
||||
- "/pkg/aggregator"
|
||||
- "/pkg/apimachinery"
|
||||
- "/pkg/apis/folder"
|
||||
- "/pkg/apiserver"
|
||||
- "/pkg/build"
|
||||
- "/pkg/build/wire"
|
||||
- "/pkg/promlib"
|
||||
- "/pkg/semconv"
|
||||
- "/pkg/storage/unified/apistore"
|
||||
- "/pkg/storage/unified/resource"
|
||||
- "/pkg/util/xorm"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "02:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "/"
|
||||
- "/packaging/docker/custom"
|
||||
- "/scripts/verify-repo-update"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "02:00"
|
||||
timezone: Etc/UTC
|
||||
open-pull-requests-limit: 10
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use github_actions_models::dependabot::v2::{
|
||||
Dependabot, Interval, PackageEcosystem, RebaseStrategy,
|
||||
Dependabot, Directories, Interval, PackageEcosystem, RebaseStrategy,
|
||||
};
|
||||
use indexmap::IndexSet;
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ fn test_contents() {
|
|||
|
||||
let pip = &dependabot.updates[0];
|
||||
assert_eq!(pip.package_ecosystem, PackageEcosystem::Pip);
|
||||
assert_eq!(pip.directory, "/");
|
||||
assert_eq!(pip.directories, Directories::Directory("/".into()));
|
||||
assert_eq!(pip.schedule.interval, Interval::Daily);
|
||||
assert_eq!(pip.open_pull_requests_limit, 5); // default
|
||||
|
||||
|
|
@ -42,7 +42,10 @@ fn test_contents() {
|
|||
github_actions.package_ecosystem,
|
||||
PackageEcosystem::GithubActions
|
||||
);
|
||||
assert_eq!(github_actions.directory, "/");
|
||||
assert_eq!(
|
||||
github_actions.directories,
|
||||
Directories::Directory("/".into())
|
||||
);
|
||||
assert_eq!(github_actions.open_pull_requests_limit, 99);
|
||||
assert_eq!(github_actions.rebase_strategy, RebaseStrategy::Disabled);
|
||||
assert_eq!(github_actions.groups.len(), 1);
|
||||
|
|
@ -56,7 +59,10 @@ fn test_contents() {
|
|||
github_actions.package_ecosystem,
|
||||
PackageEcosystem::GithubActions
|
||||
);
|
||||
assert_eq!(github_actions.directory, ".github/actions/upload-coverage/");
|
||||
assert_eq!(
|
||||
github_actions.directories,
|
||||
Directories::Directory(".github/actions/upload-coverage/".into())
|
||||
);
|
||||
assert_eq!(github_actions.open_pull_requests_limit, 99);
|
||||
assert_eq!(github_actions.rebase_strategy, RebaseStrategy::Disabled);
|
||||
assert_eq!(github_actions.groups.len(), 1);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use crate::{
|
|||
models::{
|
||||
AsDocument,
|
||||
action::{Action, CompositeStep},
|
||||
dependabot::Dependabot,
|
||||
workflow::{Job, NormalJob, ReusableWorkflowCallJob, Step, Workflow},
|
||||
},
|
||||
registry::input::InputKey,
|
||||
|
|
@ -48,6 +49,7 @@ pub(crate) mod use_trusted_publishing;
|
|||
pub(crate) enum AuditInput {
|
||||
Workflow(Workflow),
|
||||
Action(Action),
|
||||
Dependabot(Dependabot),
|
||||
}
|
||||
|
||||
impl AuditInput {
|
||||
|
|
@ -55,6 +57,7 @@ impl AuditInput {
|
|||
match self {
|
||||
AuditInput::Workflow(workflow) => &workflow.key,
|
||||
AuditInput::Action(action) => &action.key,
|
||||
AuditInput::Dependabot(dependabot) => &dependabot.key,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +65,7 @@ impl AuditInput {
|
|||
match self {
|
||||
AuditInput::Workflow(workflow) => workflow.link.as_deref(),
|
||||
AuditInput::Action(action) => action.link.as_deref(),
|
||||
AuditInput::Dependabot(dependabot) => dependabot.link.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +73,7 @@ impl AuditInput {
|
|||
match self {
|
||||
AuditInput::Workflow(workflow) => workflow.location(),
|
||||
AuditInput::Action(action) => action.location(),
|
||||
AuditInput::Dependabot(dependabot) => dependabot.location(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +83,7 @@ impl<'a> AsDocument<'a, 'a> for AuditInput {
|
|||
match self {
|
||||
AuditInput::Workflow(workflow) => workflow.as_document(),
|
||||
AuditInput::Action(action) => action.as_document(),
|
||||
AuditInput::Dependabot(dependabot) => dependabot.as_document(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +100,12 @@ impl From<Action> for AuditInput {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<Dependabot> for AuditInput {
|
||||
fn from(value: Dependabot) -> Self {
|
||||
Self::Dependabot(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A supertrait for all audits.
|
||||
///
|
||||
/// Workflow audits, action audits, and all future audit types
|
||||
|
|
@ -270,6 +282,14 @@ pub(crate) trait Audit: AuditCore {
|
|||
Ok(results)
|
||||
}
|
||||
|
||||
fn audit_dependabot<'doc>(
|
||||
&self,
|
||||
_dependabot: &'doc Dependabot,
|
||||
_config: &Config,
|
||||
) -> anyhow::Result<Vec<Finding<'doc>>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
fn audit_raw<'doc>(
|
||||
&self,
|
||||
_input: &'doc AuditInput,
|
||||
|
|
@ -308,6 +328,7 @@ pub(crate) trait Audit: AuditCore {
|
|||
let mut results = match input {
|
||||
AuditInput::Workflow(workflow) => self.audit_workflow(workflow, config),
|
||||
AuditInput::Action(action) => self.audit_action(action, config),
|
||||
AuditInput::Dependabot(dependabot) => self.audit_dependabot(dependabot, config),
|
||||
}?;
|
||||
|
||||
results.extend(self.audit_raw(input, config)?);
|
||||
|
|
|
|||
1189
crates/zizmor/src/data/dependabot-2.0.json
Normal file
1189
crates/zizmor/src/data/dependabot-2.0.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -633,6 +633,14 @@ impl Client {
|
|||
let mut contents = String::with_capacity(entry.size() as usize);
|
||||
entry.read_to_string(&mut contents)?;
|
||||
group.register(InputKind::Action, contents, key, options.strict)?;
|
||||
} else if matches!(
|
||||
file_path.file_name(),
|
||||
Some("dependabot.yml" | "dependabot.yaml")
|
||||
) {
|
||||
let key = InputKey::remote(slug, file_path.to_string())?;
|
||||
let mut contents = String::with_capacity(entry.size() as usize);
|
||||
entry.read_to_string(&mut contents)?;
|
||||
group.register(InputKind::Dependabot, contents, key, options.strict)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -364,6 +364,8 @@ pub(crate) enum CollectionMode {
|
|||
WorkflowsOnly,
|
||||
/// Collect only action definitions (i.e. `action.yml`).
|
||||
ActionsOnly,
|
||||
/// Collect only Dependabot configuration files (i.e. `dependabot.yml`).
|
||||
DependabotOnly,
|
||||
}
|
||||
|
||||
impl CollectionMode {
|
||||
|
|
@ -387,6 +389,13 @@ impl CollectionMode {
|
|||
CollectionMode::All | CollectionMode::Default | CollectionMode::ActionsOnly
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn dependabot(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
CollectionMode::All | CollectionMode::Default | CollectionMode::DependabotOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use crate::models::inputs::HasInputs;
|
|||
|
||||
pub(crate) mod action;
|
||||
pub(crate) mod coordinate;
|
||||
pub(crate) mod dependabot;
|
||||
pub(crate) mod inputs;
|
||||
pub(crate) mod uses;
|
||||
pub(crate) mod workflow;
|
||||
|
|
|
|||
79
crates/zizmor/src/models/dependabot.rs
Normal file
79
crates/zizmor/src/models/dependabot.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//! Dependabot config models.
|
||||
//!
|
||||
//! These models enrich the models under [`github_actions_models::dependabot`],
|
||||
//! providing higher-level APIs for zizmor to use.
|
||||
|
||||
use github_actions_models::dependabot;
|
||||
use terminal_link::Link;
|
||||
|
||||
use crate::{
|
||||
finding::location::{SymbolicFeature, SymbolicLocation},
|
||||
models::AsDocument,
|
||||
registry::input::{CollectionError, InputKey},
|
||||
utils::{DEPENDABOT_VALIDATOR, from_str_with_validation},
|
||||
};
|
||||
|
||||
pub(crate) struct Dependabot {
|
||||
pub(crate) key: InputKey,
|
||||
pub(crate) link: Option<String>,
|
||||
document: yamlpath::Document,
|
||||
inner: dependabot::v2::Dependabot,
|
||||
}
|
||||
|
||||
impl<'a> AsDocument<'a, 'a> for Dependabot {
|
||||
fn as_document(&'a self) -> &'a yamlpath::Document {
|
||||
&self.document
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Dependabot {
|
||||
type Target = dependabot::v2::Dependabot;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Dependabot {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{key}", key = self.key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Dependabot {
|
||||
pub(crate) fn from_string(contents: String, key: InputKey) -> Result<Self, CollectionError> {
|
||||
let inner = from_str_with_validation(&contents, &DEPENDABOT_VALIDATOR)?;
|
||||
|
||||
let document = yamlpath::Document::new(&contents)?;
|
||||
|
||||
let link = match key {
|
||||
InputKey::Local(_) => None,
|
||||
InputKey::Remote(_) => {
|
||||
// NOTE: InputKey's Display produces a URL, hence `key.to_string()`.
|
||||
Some(Link::new(key.presentation_path(), &key.to_string()).to_string())
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
key,
|
||||
link,
|
||||
document,
|
||||
inner,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns this Dependabot config's [`SymbolicLocation`].
|
||||
///
|
||||
/// See [`Workflow::location`] for an explanation of why this isn't
|
||||
/// implemented through the [`Locatable`] trait.
|
||||
pub(crate) fn location(&self) -> SymbolicLocation<'_> {
|
||||
SymbolicLocation {
|
||||
key: &self.key,
|
||||
annotation: "this config".to_string(),
|
||||
link: None,
|
||||
route: Default::default(),
|
||||
feature_kind: SymbolicFeature::Normal,
|
||||
kind: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ use crate::{
|
|||
audit::AuditInput,
|
||||
config::{Config, ConfigError},
|
||||
github_api::{Client, ClientError},
|
||||
models::{action::Action, workflow::Workflow},
|
||||
models::{action::Action, dependabot::Dependabot, workflow::Workflow},
|
||||
};
|
||||
|
||||
/// Errors that can occur while collecting inputs.
|
||||
|
|
@ -99,6 +99,8 @@ pub(crate) enum InputKind {
|
|||
Workflow,
|
||||
/// An action definition.
|
||||
Action,
|
||||
/// A Dependabot configuration file.
|
||||
Dependabot,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InputKind {
|
||||
|
|
@ -106,6 +108,7 @@ impl std::fmt::Display for InputKind {
|
|||
match self {
|
||||
InputKind::Workflow => write!(f, "workflow"),
|
||||
InputKind::Action => write!(f, "action"),
|
||||
InputKind::Dependabot => write!(f, "dependabot config"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -348,6 +351,9 @@ impl InputGroup {
|
|||
let input: Result<AuditInput, CollectionError> = match kind {
|
||||
InputKind::Workflow => Workflow::from_string(contents, key.clone()).map(|wf| wf.into()),
|
||||
InputKind::Action => Action::from_string(contents, key.clone()).map(|a| a.into()),
|
||||
InputKind::Dependabot => {
|
||||
Dependabot::from_string(contents, key.clone()).map(|d| d.into())
|
||||
}
|
||||
};
|
||||
|
||||
match input {
|
||||
|
|
@ -375,6 +381,11 @@ impl InputGroup {
|
|||
// When collecting individual files, we don't know which part
|
||||
// of the input path is the prefix.
|
||||
let (key, kind) = match (path.file_stem(), path.extension()) {
|
||||
(Some("dependabot"), Some("yml" | "yaml")) => (
|
||||
// NOTE: Safe unwrap because we just checked the filename.
|
||||
InputKey::local(Group(path.as_str().into()), path, None).unwrap(),
|
||||
InputKind::Dependabot,
|
||||
),
|
||||
(Some("action"), Some("yml" | "yaml")) => (
|
||||
// NOTE: Safe unwrap because we just checked the filename.
|
||||
InputKey::local(Group(path.as_str().into()), path, None).unwrap(),
|
||||
|
|
@ -466,6 +477,25 @@ impl InputGroup {
|
|||
})?;
|
||||
group.register(InputKind::Action, contents, key, options.strict)?;
|
||||
}
|
||||
|
||||
if options.mode.dependabot()
|
||||
&& entry.is_file()
|
||||
&& matches!(
|
||||
entry.file_name(),
|
||||
Some("dependabot.yml" | "dependabot.yaml")
|
||||
)
|
||||
{
|
||||
// NOTE: Safe unwrap because we just checked the filename.
|
||||
let key = InputKey::local(Group(path.as_str().into()), entry, Some(path)).unwrap();
|
||||
let contents = std::fs::read_to_string(entry).map_err(|e| {
|
||||
CollectionError::Inner(
|
||||
CollectionError::Io(e).into(),
|
||||
key.to_string(),
|
||||
InputKind::Dependabot,
|
||||
)
|
||||
})?;
|
||||
group.register(InputKind::Dependabot, contents, key, options.strict)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(group)
|
||||
|
|
|
|||
|
|
@ -18,13 +18,18 @@ use std::{fmt::Write, sync::LazyLock};
|
|||
|
||||
use crate::{audit::AuditInput, models::AsDocument, registry::input::CollectionError};
|
||||
|
||||
pub(crate) static WORKFLOW_VALIDATOR: LazyLock<Validator> = LazyLock::new(|| {
|
||||
validator_for(&serde_json::from_str(include_str!("./data/github-workflow.json")).unwrap())
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
pub(crate) static ACTION_VALIDATOR: LazyLock<Validator> = LazyLock::new(|| {
|
||||
validator_for(&serde_json::from_str(include_str!("./data/github-action.json")).unwrap())
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
pub(crate) static WORKFLOW_VALIDATOR: LazyLock<Validator> = LazyLock::new(|| {
|
||||
validator_for(&serde_json::from_str(include_str!("./data/github-workflow.json")).unwrap())
|
||||
pub(crate) static DEPENDABOT_VALIDATOR: LazyLock<Validator> = LazyLock::new(|| {
|
||||
validator_for(&serde_json::from_str(include_str!("./data/dependabot-2.0.json")).unwrap())
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Options:
|
|||
--cache-dir <CACHE_DIR>
|
||||
The directory to use for HTTP caching. By default, a host-appropriate user-caching directory will be used
|
||||
--collect <COLLECT>
|
||||
Control which kinds of inputs are collected for auditing [default: default] [possible values: all, default, workflows-only, actions-only]
|
||||
Control which kinds of inputs are collected for auditing [default: default] [possible values: all, default, workflows-only, actions-only, dependabot-only]
|
||||
--strict-collection
|
||||
Fail instead of warning on syntax and schema errors in collected inputs
|
||||
--completions <SHELL>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue